@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— thetilecommand. Everytile ota …in this README comes from here:config,fingerprint,preflight,deploy,channel,policy,bundle,rollback. Install it globally (or run it withnpx).@tiledev/tile-push-cli— a project devDependency.tile-push.config.tsimportstilePushStorageandtilePushDatabasefrom it,tile otaforwards to its binary, and the config plugin'sfingerprint: "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-nativeand@hot-updater/coreare 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);
apiUrldefaults tohttps://ota.tile.dev, the hosted Tile backend — both OTA and the update gate live behind it. Pass it explicitly to target your own deployment ordemo.tile.dev. In__DEV__the SDK logs the resolved origin once, so a mis-pointed environment shows up in the Metro log.
updateUIis off by default — the SDK renders nothing it was not asked to render, the same stance as CodePush (whoseupdateDialog"defaults tonull, which has the effect of disabling the dialog completely") and expo-updates (which ships no UI at all). PassupdateUI: '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 bytile init --otahave 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"— runstile-push fingerprint create, writesfingerprint.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"— injectfingerprint.jsonas-is, never compute.true— alias for"generate".false— inject nothing, and clear any hash a previous prebuild wrote. ForappVersionprojects. The clearing matters becauseexpo prebuildedits in place, so a stale hash would otherwise linger in the native isolation key.- omitted — inject
fingerprint.jsonif 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.
updateStrategyis not a plugin option — it belongs totile-push.config.ts(deploy side) andTileUpdater.wrap()(runtime side), and all three must agree.fingerprintis a plugin option because the plugin cannot read your deploy config: the published@hot-updater/cli-toolshardcodes thehot-updater.configfilename and can never seetile-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 buildThree 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. Withfingerprintomitted and nofingerprint.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.
checkForUpdatereturnsnullunconditionally in__DEV__, and Expo Go cannot work at all (there is a native module). - Deploy after the build.
minBundleIdcomes 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(); // 242f5029b5fb147b059fa7f07ba2442438b6784cThe 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 keptTwo 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 reportok / untrusted-runtime, because the version they report is not yours.binary.untrustedBecausesays 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: falsenothing 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
onProgresscallback 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
checkForUpdatereturnsnullunconditionally 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 withcheckOnForegroundleft on. WithcheckOnForeground: falseit 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:
- A toast is mounted for you (any
updateUIexceptfalse), saying what happened — and offering Retry only when a retry could actually help. useTileUpdaterError()exposes the same failure for your own UI.checkNow()/onCheckOutcomereturn{ status: 'error', kind }, neverup-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-nativedefines 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 tokind: '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, andcheckNow()answers{ status: 'skipped', reason: 'not-configured' }. Its config type accepts onlyappId,apiUrl,onErrorand the upstream network options —onSuccess,checkOnForegroundand friends are rejected at compile time rather than accepted and ignored.status/isChecking/update/lastCheckedAtonuseTileUpdater()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 iswrap()-only.Prefer
wrap()unless you specifically want none of that. Upstream likewise deprecateswrap({ updateMode: 'manual' })in favour ofinit()+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.
