npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tiledev/tile-updater

v0.5.1

Published

Tile Push React Native SDK — multi-tenant OTA updates for React Native apps.

Readme

@tiledev/tile-updater

Multi-tenant React Native OTA updates, powered by Tile Push. This is the runtime SDK — wrap your app and devices auto-update on launch.

Two packages ship the updates, and they do different jobs:

  • @tiledev/tile-cli — the tile command. Every tile ota … in this README comes from here: config, fingerprint, preflight, deploy, channel, policy, bundle, rollback. Install it globally (or run it with npx).
  • @tiledev/tile-push-cli — a project devDependency. tile-push.config.ts imports tilePushStorage and tilePushDatabase from it, tile ota forwards to its binary, and the config plugin's fingerprint: "generate" | "check" modes shell out to it.

You need both: the tile command does not bundle the deploy engine, and the deploy engine has no tile ota surface of its own.

Install

npm install @tiledev/tile-updater @hot-updater/react-native @hot-updater/core
# or
yarn add @tiledev/tile-updater @hot-updater/react-native @hot-updater/core

@hot-updater/react-native and @hot-updater/core are peer dependencies — install them explicitly.

Usage

1. Wrap your root component

import { TileUpdater } from '@tiledev/tile-updater';

function App() {
  return <YourAppRoot />;
}

export default TileUpdater.wrap({
  appId: 'your-app-id',           // your Tile tenant id
  updateStrategy: 'fingerprint',  // or 'appVersion'
  updateUI: 'default',            // opt in to the built-in overlay/banner/toast
  updateGate: true,               // optional binary version floor
})(App);

apiUrl defaults to https://ota.tile.dev, the hosted Tile backend — both OTA and the update gate live behind it. Pass it explicitly to target your own deployment or demo.tile.dev. In __DEV__ the SDK logs the resolved origin once, so a mis-pointed environment shows up in the Metro log.

updateUI is off by default — the SDK renders nothing it was not asked to render, the same stance as CodePush (whose updateDialog "defaults to null, which has the effect of disabling the dialog completely") and expo-updates (which ships no UI at all). Pass updateUI: 'default' for the built-in treatment: a full-screen overlay during a forced download, a banner for a background one, and a toast when a check fails. Apps generated by tile init --ota have that line written in for them.

2. Register the Expo config plugin

Add the package to app.json → expo.plugins. This is required for updateStrategy: 'fingerprint' — it injects the native fingerprint hash at expo prebuild so a device only accepts bundles built from a matching native tree:

{
  "expo": {
    "plugins": [
      ["@tiledev/tile-updater", { "fingerprint": "generate", "channel": "production" }]
    ]
  }
}

That's the entire integration.

Plugin options

| option | type | default | what it does | |---|---|---|---| | channel | string | "production" | Bakes the release channel into the binary — com.hotupdater.CHANNEL on Android, HOT_UPDATER_CHANNEL in Info.plist. A device only ever sees bundles deployed to its channel. | | fingerprint | false \| true \| "generate" \| "check" \| "file" | see below | How the native fingerprint is produced. | | fingerprintHash | string | — | Use this hash and never compute. For CI where the value comes from a pipeline variable rather than a file. Wins over fingerprint.json. | | buildTimestamp | number \| string \| false | Date.now() | The iOS OTA floor. See The three ids. |

fingerprint modes:

  • "generate" — runs tile-push fingerprint create, writes fingerprint.json, injects it. Keeps the label current for you; the right choice for local work.
  • "check" — recomputes and fails the build if it drifted from the stored file, without moving the label. The CI mode: a native dependency is the one change that must never be papered over.
  • "file" — inject fingerprint.json as-is, never compute.
  • true — alias for "generate".
  • false — inject nothing, and clear any hash a previous prebuild wrote. For appVersion projects. The clearing matters because expo prebuild edits in place, so a stale hash would otherwise linger in the native isolation key.
  • omitted — inject fingerprint.json if it exists, otherwise do nothing. The compatibility path, so a bare "@tiledev/tile-updater" registration keeps working.

"generate" and "check" shell out to @tiledev/tile-push-cli, so it must be a devDependency of the project — it owns the authoritative fingerprint implementation. "check" fails the prebuild rather than degrade to "file" if it is missing, because a check that cannot check is worse than no check.

Where each setting lives. updateStrategy is not a plugin option — it belongs to tile-push.config.ts (deploy side) and TileUpdater.wrap() (runtime side), and all three must agree. fingerprint is a plugin option because the plugin cannot read your deploy config: the published @hot-updater/cli-tools hardcodes the hot-updater.config filename and can never see tile-push.config.ts. Tile Push handles multi-tenant URL routing (/api/check-update/v2/t/{appId}/...), client-side cohort rollout picking, and update download + apply via the underlying SDK.

Setting up deploys

The steps above give you a working client. Shipping a bundle needs the deploy side too, and the order matters — one of these mistakes is silent.

npm i -g @tiledev/tile-cli                        # provides the `tile` command
npm i -D @tiledev/tile-push-cli @hot-updater/expo # the deploy engine, per project

tile ota config              # writes tile-push.config.ts + TILE_PUSH_APP_ID
tile ota fingerprint create  # or let the config plugin do it — see below
npx expo prebuild
npx expo run:android --variant release
tile ota deploy -p android   # AFTER the build

Three traps, in the order you'd hit them:

  • The fingerprint has to exist. With fingerprint: "generate" (above) the plugin creates it during prebuild and this trap is closed. With fingerprint omitted and no fingerprint.json, prebuild still succeeds, the binary ships with no fingerprint, and the device silently never matches any bundle. With "generate" or "check" a missing-or-stale fingerprint fails the prebuild instead.
  • Release, not Debug. checkForUpdate returns null unconditionally in __DEV__, and Expo Go cannot work at all (there is a native module).
  • Deploy after the build. minBundleId comes from build time, so a bundle deployed before the binary was built is correctly ignored — which reads exactly like "OTA is broken".

Already have a project wired by hand? tile ota config writes only the deploy config and touches nothing else — tile ota wire is the one that also adds the SDK dependency, the app.json plugin and the entry wrap.

The three ids, and why each exists

Three values decide whether a device accepts a bundle. They are easy to confuse and they fail in different ways.

| value | what it is | who sets it | |---|---|---| | bundleId | the bundle running right now, a UUIDv7 | minted by the deploy CLI; the embedded bundle reports the floor | | minBundleId | the floor — the oldest bundle this binary will accept | derived from build time, natively | | fingerprint | a label saying "this binary and this bundle were built from the same native tree" | the config plugin, from fingerprint.json |

TileUpdater.getBundleId();        // 01a08444-481d-7e8c-… (or the floor, if embedded)
TileUpdater.getMinBundleId();     // 01a08444-481d-7000-8000-000000000000
TileUpdater.getFingerprintHash(); // 242f5029b5fb147b059fa7f07ba2442438b6784c

The floor is why "deploy after you build" matters. A bundle deployed before the binary was built is older than the floor and is correctly ignored — which reads exactly like OTA being broken.

The floor is derived differently on each platform, and the difference is measurable:

| | Android | iOS | |---|---|---| | source | BuildConfig.MIN_BUNDLE_ID, else BUILD_TIMESTAMP | HOT_UPDATER_BUILD_TIMESTAMP in Info.plist, else __DATE__/__TIME__ | | generated by | upstream's build.gradle, automatically | this plugin, at prebuild (buildTimestamp) | | precision | epoch milliseconds | milliseconds when injected; seconds in the fallback | | fallback | n/a — gradle always injects | compile time of HotUpdater.mm, minus 26 hours |

The 26-hour subtraction exists because __DATE__/__TIME__ are bare local time strings that the reader parses as UTC, so the true instant lies somewhere in UTC−12…UTC+14. Subtracting the whole spread guarantees the floor is never in the future, which would reject every bundle. Worse, those macros are baked into the compiled object file, so on a warm CocoaPods cache the fallback floor can be days stale — it only moves on a clean build or a pod update.

That is why this plugin writes buildTimestamp by default. For an exact match across platforms, pin one value in CI:

TS=$(date +%s000)
./gradlew assembleRelease -PMIN_BUNDLE_ID="$TS"     # Android
# iOS: ["@tiledev/tile-updater", { "buildTimestamp": <same TS> }]

buildTimestamp accepts epoch-ms (digits), a full UUID, or false to opt out and restore the __DATE__ − 26h fallback.

The update gate

A separate, orthogonal mechanism: OTA asks "is there a newer bundle for this binary?" (an equality question about fingerprints). The gate asks "may this binary still run at all?" (an ordering question about store versions). Use it to force users onto a newer native build — something OTA can never do.

TileUpdater.wrap({
  appId: 'your-app-id',
  updateStrategy: 'fingerprint',
  updateGate: true,        // fetch the policy and render the built-in wall
})(App);

It is inert until you publish a policy — the endpoint answers enabled: false for every app that has none, so shipping with the gate mounted is safe long before you intend to gate anything.

tile ota policy show                                  # what devices see now
tile ota policy set --min-version 2.1.0 --min-build 42 \
    --store-url https://apps.apple.com/us/app/id123 \
    --note "payment SDK crash" --yes
tile ota policy disable                               # kill switch, floor kept

Two rules worth knowing before you publish one: --min-version is required on iOS (CFBundleVersion restarts each release, so a build-only iOS floor can wall a newer build) and --min-build is required on Android (Tile bumps versionCode and leaves versionName alone). The floor is the lowest supported value, not the lowest banned one — to wall build 23, the floor is 24.

Building your own wall

updateGate: { ui: false } keeps the evaluation and renders nothing:

import { useUpdateGate } from '@tiledev/tile-updater';

function StoreWall() {
  const gate = useUpdateGate();
  if (!gate.blocked) return null;

  // `blocked` is a discriminant: narrowing it proves `content` and `storeUrl`
  // exist, because a decision that has neither is demoted to `ok` rather than
  // producing a wall whose only button does nothing.
  return (
    <Modal visible animationType="fade" onRequestClose={() => {}}>
      <Text>{gate.content.title}</Text>
      <Text>{gate.content.message}</Text>
      <Text>{`on ${gate.currentVersion} · ${gate.requiredVersion} required`}</Text>
      <Button title={gate.content.button} onPress={() => void gate.openStore()} />
    </Modal>
  );
}

Use a Modal, not an absolute-fill View — a Modal captures the Android hardware back button, so there is no way around the wall.

useUpdateGate() returns:

{
  blocked,                                  // boolean — the question, and a type discriminant
  status,                                   // 'unknown' | 'ok' | 'required'
  reason,                                   // WHY: 'pending' | 'no-policy' | 'disabled' |
                                            // 'untrusted-runtime' | 'unreadable-runtime' |
                                            // 'above-floors' | 'below-required' |
                                            // 'no-store-url' | 'unparseable-floor' | 'fetch-failed'
  currentVersion, currentBuild,             // this binary
  requiredVersion, requiredBuild,           // the floor, present even when not blocked
  storeUrl, content, theme,                 // content/storeUrl guaranteed when blocked
  binary,                                   // the raw probe: trusted, untrustedBecause, expoGo, dev, warnings
  checked,                                  // has a fetch resolved yet
  endpoint, appId,                           // where the policy came from
  openStore, refresh,                        // actions
}

reason is the field to reach for first — it is the answer to "why isn't my wall showing". And TileUpdater.gate.getDecision() / .getSnapshot() / .refresh() / .openStore() / .subscribe() read the same instance from outside React, for an interceptor or a background task.

Four behaviours to design around

  • It refetches on every foreground, unthrottled. That is how a wall clears in place when you roll a bad floor back — a walled user backgrounds the app, and that is the same signal.
  • It fails open. Offline gives ok / fetch-failed. No infrastructure failure can brick your app — which also means you cannot test the wall by going offline.
  • Untrusted runtimes are never blocked. __DEV__, Expo Go and simulators report ok / untrusted-runtime, because the version they report is not yours. binary.untrustedBecause says which. (A Release build on a simulator is trusted.)
  • No store URL ⇒ never blocked, demoted to ok / no-store-url.

Test it without publishing anything by handing it a policy directly — not __DEV__-gated, so it works in a Release build:

updateGate: {
  ui: false,
  policy: { enabled: true, minVersion: '99.0.0', storeUrl: { ios: '…', android: '…' },
            content: { title: 'Update required', message: '…', button: 'Update' } },
}

OTA goes first

When a forced OTA update is downloading, the wall waits. The reasoning: an OTA bundle may be the fix you are shipping right now, while a store update takes days to reach the user — walling first would mean the fix never applies. Once OTA reloads, the gate re-evaluates on the fresh launch and the wall appears then.

The update lifecycle

wrap() checks on mount and on every return from the background. The second half matters more than it sounds: an app can sit backgrounded for a week, so without it a deploy is only picked up on a cold start the user may never perform.

TileUpdater.wrap({
  appId: 'your-app-id',
  updateStrategy: 'fingerprint',
  checkOnForeground: true,               // the default
  // checkOnForeground: { minInterval: 60_000 },  // floor between checks, ms
  // checkOnForeground: false,           // launch-only, the old behaviour
  onCheckOutcome: (outcome, trigger) => analytics.track('ota_check', { ...outcome, trigger }),
})(App);

What it handles so your app does not have to:

  • One check at a time. A resume while the launch check is still downloading joins that run instead of starting a second download, and resolves with the same outcome. This is the "a deploy landed mid-flight" case.
  • A failure is never reported as success. See below.
  • Only real resumes. An iOS control-centre swipe (inactive → active) is not a return from the background and does not trigger a check.
  • Force updates found mid-session download behind a blocking overlay and then reload — the same experience as at launch. With reloadOnForceUpdate: false nothing blocks and the reload is yours to schedule.

TileUpdater.checkNow() runs the same check on demand — for a "Check for updates" button, a pull-to-refresh, or a retry. Prefer it over checkForUpdate(): it is single-flighted, it applies force updates, and it resolves to an outcome you can branch on.

const outcome = await TileUpdater.checkNow();
// { status: 'up-to-date' }
// { status: 'downloaded', bundleId, forced, reloaded }
// { status: 'error', error, kind: 'offline' | 'timeout' | 'server' | 'unknown' }
// { status: 'skipped', reason: 'throttled' | 'dev' | 'unsupported' | 'not-configured' }

The lifecycle callbacks

Three callbacks cover the whole flow, and each fires for every trigger — launch, foreground resume, and checkNow() — so analytics never special-cases where a check came from.

TileUpdater.wrap({
  appId: 'tk_acme-prod',
  updateStrategy: 'fingerprint',

  onStatusChange: (status, ctx) => log(`${ctx.trigger}: ${status}`),
  // checking → update-available → downloading → downloaded → applying
  // checking → up-to-date
  // … → error

  onSuccess: (r) => analytics.track('ota_applied', r),
  // { bundleId, fromBundleId, trigger, forced, applied,
  //   alreadyDownloaded, artifactType, bytes, durationMs, channel }

  onError: (error, info) => {
    Sentry.captureException(error, { tags: { ota_code: info.code } });
    if (!info.retryable) tellUserToActInstead(info);
  },
})(App);

onSuccess fires before a forced reload — a callback after it would never run, and the lost events would be exactly the mandatory updates you most want to measure. bytes is summed from per-file totals for patch downloads, which report no overall size.

alreadyDownloaded separates two very different successes. A staged bundle does not change what getBundleId() returns, so every later check honestly finds it "available" again until the app restarts — and native re-stages it from disk with no download. Left alone, that means a success (and a toast) on every resume for a bundle that has been ready the whole time.

So a staged bundle is announced once per runtime. A manual check is exempt — you asked, so you get an answer, with alreadyDownloaded: true so your UI can say "already downloaded, restart to apply" rather than implying a fresh download. onStatusChange and onCheckOutcome still fire every time. When it is true, artifactType is null and bytes is undefined rather than carrying values left over from the transfer that did happen.

useTileUpdater()

One hook for the whole flow — state and the actions that change it, reading the same stores the callbacks are fed from, so a component and a handler can never disagree.

function UpdateRow() {
  const {
    status, isChecking, isDownloading, isUpdatePending,
    progress, update, error,
    currentBundleId, channel, fingerprint, lastCheckedAt,
    checkNow, applyNow, dismissError,
  } = useTileUpdater();

  if (error) return <Text onPress={dismissError}>{error.message}</Text>;
  if (isUpdatePending) return <Button title="Restart to update" onPress={applyNow} />;
  if (isDownloading) return <Text>{Math.round(progress * 100)}%</Text>;
  return <Button title="Check for updates" onPress={checkNow} />;
}

It subscribes to download progress. Native emits that ~100 times per download, in a burst — the SDK rate-limits what reaches React to about 10 updates a second (phase changes, such as the download starting or finishing, always pass through immediately). Still mount it in a leaf rather than at the root of your tree: the rate limit keeps a subscriber cheap, it does not make re-rendering your whole app free. useTileUpdaterUpdate() and useTileUpdaterError() remain for code that wants a single slice.

The onProgress callback is the exception. It is an upstream passthrough that reads the raw store, so it fires at the full native rate — every event, no rate limit. Keep it cheap, and prefer the hooks if you are rendering from it.

In a Debug build there is nothing to test: the underlying checkForUpdate returns null unconditionally in __DEV__. Foreground checks say so once in the console and resolve { status: 'skipped', reason: 'dev' }. Build in Release to exercise OTA.

Driving it yourself

Three levels of control, all shipped. Nothing here needs configuration beyond having called wrap() or init() once — that is what installs the resolver every primitive below depends on.

| You want | Use | You keep | |---|---|---| | The SDK to handle everything | checkOnForeground (default) | all of it | | To choose when, not how | TileUpdater.checkNow() | single-flight, force-apply, onSuccess / onStatusChange / onError, the overlay | | To own every step | checkForUpdate() → updateBundle() → reload() | onError only — the rest is yours to emit |

// Full manual control. Note the per-call `onError`: without it you cannot tell
// "up to date" from "the check failed", because checkForUpdate returns null
// for BOTH — it reports the failure through onError and then returns null.
let failure: unknown = null;
const update = await TileUpdater.checkForUpdate({
  updateStrategy: 'fingerprint',
  onError: (error) => { failure = error; },
});

if (failure) {
  const info = describeTileUpdaterError(failure);   // { code, kind, stage, retryable, … }
  if (info.retryable) scheduleYourOwnRetry(info);
  return;
}
if (!update) return;                                 // genuinely up to date

const ok = await update.updateBundle();              // bound to this update — no args needed
if (ok && update.shouldForceUpdate) await TileUpdater.reload();
// A non-forced update is on disk; it applies on the next launch, or whenever
// you decide to call reload().

TileUpdater.updateBundle({ bundleId, fileUrl, … }) is also available for the rare case where you are not holding the object checkForUpdate() returned.

checkNow() runs on the same engine as the automatic checks, so it exists only when that engine does — in auto mode with checkOnForeground left on. With checkOnForeground: false it answers { status: 'skipped', reason: 'not-configured' }, and the primitives above are the path.

When something fails

checkForUpdate() reports failures through onError and then returns null — the same null it returns for "already up to date". Anything branching on that return value alone reads a dead network as a healthy no-op. The SDK closes that gap in three places:

  1. A toast is mounted for you (any updateUI except false), saying what happened — and offering Retry only when a retry could actually help.
  2. useTileUpdaterError() exposes the same failure for your own UI.
  3. checkNow() / onCheckOutcome return { status: 'error', kind }, never up-to-date.

Your own onError still fires, with the original error, exactly as before.

// Built-in toast, tuned:
TileUpdater.wrap({
  appId: 'your-app-id',
  updateStrategy: 'fingerprint',
  updateUI: {
    errorToast: true,              // default
    errorToastPosition: 'bottom',  // default; banner sits at the top
    errorToastRetry: true,         // default — Retry calls checkNow()
    copy: { errorOffline: "You're offline — we'll retry later." },
  },
})(App);

// Or drive it yourself:
function MyErrorUI() {
  const failure = useTileUpdaterError();   // { message, kind, phase, at } | null
  if (!failure) return null;
  return <Toast text={failure.message} onClose={dismissTileUpdaterError} />;
}

The failure is described, not flattened

Every failure the SDK reports carries four fields beyond the raw error:

| field | meaning | |---|---| | stage | check (we don't know whether an update exists) or install (one was found and could not be applied) | | kind | coarse cause, used to pick the message | | code | the native error code, when the failure came from the native updater | | retryable | whether the same update could plausibly succeed next time |

Check failures have no code to read — the resolver and RN's fetch throw plain Errors — so kind is derived from the message: offline (the request never left the device), timeout (the client-side requestTimeout aborted it), server (a non-2xx answer), or unknown.

Install failures are different: the native module tells you exactly what went wrong, identically on iOS and Android, and that code is authoritative over any message text. The full mapping:

| native code | kind | retryable | typical cause | |---|---|---|---| | DOWNLOAD_FAILED | download | ✅ | network or HTTP error during the transfer | | INCOMPLETE_DOWNLOAD | download-incomplete | ✅ | transfer truncated; message carries received/expected bytes | | INSUFFICIENT_DISK_SPACE | disk-space | ❌ | needs fileSize × 2; message carries required/available bytes | | EXTRACTION_FORMAT_ERROR, INVALID_BUNDLE | corrupt | ❌ | not a usable archive, or missing the platform bundle | | SIGNATURE_VERIFICATION_FAILED | signature | ❌ | tampered or wrongly signed | | DIRECTORY_CREATION_FAILED, MOVE_OPERATION_FAILED | storage | ❌ | filesystem or permissions | | BUNDLE_IN_CRASHED_HISTORY | blocked | ❌ | this bundle crashed before; clear with HotUpdater.clearCrashHistory() | | MISSING_BUNDLE_ID, INVALID_FILE_URL, SELF_DEALLOCATED | internal | ❌ | a bug, not the user's problem | | UNKNOWN_ERROR, or any future code | unknown | ✅ | unclassified |

retryable is what the toast uses to decide whether to offer Retry: a full disk, a bad signature or a crash-blocked bundle will fail identically on the next attempt, so offering one would just show the user the same error again.

The native message is preserved verbatim and is worth surfacing to support: "Insufficient disk space: need 8000000 bytes, available 12000 bytes", "Download incomplete: received 512 bytes, expected 4096 bytes".

To act per-cause, switch on the code:

import {
  TileUpdaterErrorCode,
  useTileUpdaterError,
} from '@tiledev/tile-updater';

const failure = useTileUpdaterError();
if (failure?.code === TileUpdaterErrorCode.INSUFFICIENT_DISK_SPACE) {
  promptUserToFreeSpace(failure.message);   // the numbers are in the message
}

isTileUpdaterNativeError(error) narrows an onError argument to a coded native failure, and describeTileUpdaterError(error) returns the same { kind, stage, code, retryable, message } for an error you obtained yourself.

Wording each failure yourself

Static copy overrides are keyed on kind, which is coarser than the codes on purpose — two "corrupt" codes share one line — and a fixed string cannot use the numbers the native message carries. errorCopy is the escape hatch: it receives the full description and returns words for the cases you care about, null for the rest.

TileUpdater.wrap({
  appId: 'your-app-id',
  updateStrategy: 'fingerprint',
  updateUI: {
    errorCopy: ({ code, message }) => {
      if (code === TileUpdaterErrorCode.BUNDLE_IN_CRASHED_HISTORY)
        return {
          title: 'This build was rolled back',
          message: `Blocked after a crash. Support code: ${code}`,
        };
      if (code === TileUpdaterErrorCode.INSUFFICIENT_DISK_SPACE) {
        const need = /need (\d+) bytes/.exec(message)?.[1];
        return need ? `Free up ${Math.ceil(Number(need) / 1e6)} MB and we'll retry.` : null;
      }
      return null;   // keep the built-in copy for everything else
    },
  },
})(App);

Return a string to replace only the cause line, { title, message } to replace either or both, or null/undefined to fall through. The same resolver works on a hand-mounted toast: <TileUpdaterToast resolveErrorCopy={…} />.

Deciding what Retry does — and whether to offer it

retryable is the SDK's judgement, not a verdict you're stuck with. Three knobs, from coarse to specific:

updateUI: {
  // 1. WHETHER Retry appears. Default: the failure's own `retryable`.
  //    `false` never, `true` always, or decide per failure with the code.
  errorToastRetry: (info) =>
    info.retryable || info.code === TileUpdaterErrorCode.INSUFFICIENT_DISK_SPACE,

  // 2. WHAT Retry does. Default: TileUpdater.checkNow(). Receives the failure,
  //    so you can act on the cause instead of just re-checking.
  errorRetry: async (info) => {
    if (info.code === TileUpdaterErrorCode.INSUFFICIENT_DISK_SPACE) return openStorageSettings();
    analytics.track('ota_retry', { code: info.code });
    await TileUpdater.checkNow();
  },

  // 3. Its LABEL, per failure — or `retry: false` to veto this one alone.
  errorCopy: ({ kind }) => (kind === 'offline' ? { retry: 'Retry now' } : null),
}

The two gates compose: errorToastRetry says whether Retry may ever appear for this failure, and a retry: false from errorCopy vetoes one specific case. Either hiding it wins. Driving your own UI instead? retryable is just a field on useTileUpdaterError() — ignore it freely.

The code table is mirrored in this package on purpose: @hot-updater/react-native defines it but re-exports it as a type only, so the values are not importable from that package's root. An unmapped future code degrades to kind: 'unknown' rather than throwing.

A device HTTP cache sits in front of all this. The check endpoint answers Cache-Control: public, max-age=60, and React Native's Android OkHttp client keeps a disk cache — so a resume within 60s of a successful check is answered from disk, with no request and no error even with the radio off. That is correct HTTP behaviour, but it does mean a fresh deploy can take up to a minute to become visible to a resuming app, and that testing the offline path needs a gap longer than the freshness window.

Configuration

TileUpdater.wrap({
  // Required
  appId: 'your-app-id',
  updateStrategy: 'fingerprint',

  // Optional — defaults to https://ota.tile.dev
  apiUrl: 'https://ota.tile.dev',

  // Pass through any standard HotUpdater wrap option
  fallbackComponent: MyFallback,
  reloadOnForceUpdate: true,
  requestHeaders: { 'x-client': 'myapp' },
  requestTimeout: 30000, // default; bounds the check request, not the download
  onError: (err) => console.error('[Tile]', err),
  checkOnForeground: true,
  onCheckOutcome: (outcome, trigger) => console.log('[Tile]', trigger, outcome),
  onProgress: (progress) => console.log(`download ${progress * 100}%`), // NOT rate-limited — fires on every native event
  onNotifyAppReady: (result) => console.log('app ready', result),

  // UI — both default to rendering nothing
  updateUI: false,          // false | 'default' | 'banner' | Component | { … }
  updateGate: {             // true | false | { … }
    ui: false,              // false | 'default' | Component
    onStatusChange: (decision) => console.log('[gate]', decision.status, decision.reason),
    // policy, dev, requestTimeout, onError, fallback, theme, appId, endpoint
  },
})(App);

| option | default | notes | |---|---|---| | appId | — | required; your Tile tenant id | | updateStrategy | — | required; must match tile-push.config.ts | | apiUrl | https://ota.tile.dev | logged once in __DEV__ so a mis-pointed environment is visible | | checkOnForeground | true | false also disables checkNow() — they share one engine | | reloadOnForceUpdate | true | false stages a forced update and leaves the reload to you | | requestTimeout | 30000 | bounds the check request only — the bundle download has its own 30s × 3 retries in native. Matches upstream's own posture; the old 5s fired on cold-radio connection setup. Inherited by the gate unless it sets its own | | updateUI | false | renders nothing unless asked | | updateGate | off | true mounts the built-in wall; { ui: false } evaluates only |

Alternative: manual init

init() is for apps that want no automatic behaviour at all — you drive every check yourself. It sets the resolver every primitive depends on, and routes failures into useTileUpdaterError(). It returns nothing and renders nothing.

import { TileUpdater } from '@tiledev/tile-updater';

TileUpdater.init({
  appId: 'your-app-id',
  onError: (error, info) => log(info.kind, info.retryable),
  // plus requestHeaders / requestTimeout / onNotifyAppReady
});

// The strategy is per call here — `init()` has nowhere to store one.
const update = await TileUpdater.checkForUpdate({ updateStrategy: 'fingerprint' });
if (update) await update.updateBundle();

What init() does not give you. There is no engine behind it, so there are no launch or foreground checks, no single-flighting, no staged-bundle dedupe, and checkNow() answers { status: 'skipped', reason: 'not-configured' }. Its config type accepts only appId, apiUrl, onError and the upstream network options — onSuccess, checkOnForeground and friends are rejected at compile time rather than accepted and ignored. status / isChecking / update / lastCheckedAt on useTileUpdater() stay at their initial values, because nothing publishes to the status store. What does work: progress, isDownloading, isUpdatePending, error, dismissError(), applyNow(), the identity getters, and the primitives. The update gate needs a React tree, so it is wrap()-only.

Prefer wrap() unless you specifically want none of that. Upstream likewise deprecates wrap({ updateMode: 'manual' }) in favour of init() + checkForUpdate().

The debug panel

A drop-in inspector: active bundle and floor, device identity, cohort, gate state, the recent check-update requests with status and latency, and actions (check now, reload, reset channel, clear crash history).

import { TileUpdaterDebug } from '@tiledev/tile-updater';

<TileUpdaterDebug />                                   // floating pill opens it
<TileUpdaterDebug fab={false} />                       // no pill — TileUpdater.openDebug()
<TileUpdaterDebug fab={false} visible={open}           // fully controlled: wire your own
                  onClose={() => setOpen(false)}       // hidden gesture
                  readOnly redact />

readOnly disables the mutating actions (for a support agent inspecting state); redact trims bundle ids and URLs to their last four characters, for screenshots. TileUpdater.openDebug() works in every mode — visibility is the OR of the prop and the store — so a deep link can always force it open.

The panel is a component. openDebug() only flips a flag; if <TileUpdaterDebug /> is not mounted somewhere in your tree, nothing appears.

Cohort targeting

Tile Push handles cohort picking transparently. Each device gets a stable cohort value (1–1000) on first launch, persisted natively. When a bundle is rolled out to a subset of cohorts, only devices in that set receive it — the SDK picks the right candidate from the server's response automatically.

For testing, override the device cohort:

import { TileUpdater } from '@tiledev/tile-updater';

TileUpdater.setCohort('500');           // numeric cohort
TileUpdater.setCohort('beta-team');     // custom slug for explicit targeting
console.log(TileUpdater.getCohort());

Troubleshooting

Every row below is a real failure mode that looks like something else.

| Symptom | Cause | Fix | |---|---|---| | Nothing ever updates, no error | Debug build — checkForUpdate returns null unconditionally in __DEV__ | build Release | | up-to-date right after a deploy | binary was built after the deploy, so the bundle is below minBundleId | deploy after building | | up-to-date, deploy definitely newer | channel mismatch — the binary's baked channel is not the one you deployed to | tile ota channel set <ch>, or deploy with -c <ch> | | Deploy never matches, silently | binary has no fingerprint (plugin not registered, or no fingerprint.json) | register the plugin with fingerprint: "generate" | | Deploys stop matching after a native change | fingerprint moved; old bundles were built against the old label | deploy again after rebuilding | | Prebuild fails "fingerprint.json is STALE" | fingerprint: "check" found real drift — usually a new native dependency | tile ota fingerprint create, rebuild, redeploy | | A fresh deploy takes ~a minute to appear | the check response carries Cache-Control: max-age=60 and Android has an HTTP disk cache | wait out the window; it also means offline checks can succeed from cache | | Gate never walls | reason says why — most often untrusted-runtime (Debug/Expo Go), disabled, or no-store-url | read reason; a Release build is trusted | | openDebug() does nothing | <TileUpdaterDebug /> is not mounted — openDebug only sets a flag | mount the component | | checkNow() returns skipped / not-configured | no engine — checkOnForeground: false, updateMode: 'manual', or only init() was called | use the primitives, or leave checkOnForeground on | | iOS floor looks days old | HOT_UPDATER_BUILD_TIMESTAMP absent, so the __DATE__ fallback is frozen in a cached .o | let the plugin inject buildTimestamp (the default) |

What's under the hood

A thin wrapper around @hot-updater/react-native (MIT). The wrapper adds a tenant-aware URL builder, client-side cohort picking against the v2 candidates response, an Expo config plugin that injects the fingerprint at build time, and re-exports upstream APIs under Tile Push branding so you only import from @tiledev/tile-updater. The native bridge, downloader, applier, store, and hooks are upstream.

The wrapper also compensates for two upstream behaviours that together crash apps during a download: wrap() memoises your root component, and every progress consumer reads a throttled mirror of the upstream store rather than the store itself. OTA-PROGRESS-STORM.md is the root cause, the evidence, and why React.memo alone is not enough.

License

MIT. See LICENSE for full text including required upstream attribution to the @hot-updater/react-native authors.