Ship JavaScript and asset changes over the air, with automatic rollback when an update fails to start
One.Updates lets a release build download a new JavaScript bundle and its
assets from your own server and run it, without a store review. One’s
launcher picks the bundle before React Native starts, verifies every file it
downloads, and falls back to the previous bundle when an update crashes
before its first screen appears.
An update can change JavaScript and assets only. Anything native, such as a new module, permission, or icon, needs a new binary. The runtime version draws that line: a binary only takes updates published for its own runtime version.
Set the server and runtime version in your app config, then prebuild and build the release binary:
import { one } from 'one/vite'
export default { plugins: [ one({ native: { app: { name: 'MyApp', updates: { url: 'https://updates.example.com', runtimeVersion: '1.0', }, }, }, }), ],}When you have a change to ship, publish it for each platform from the app’s folder:
one updates publish --platform ios --out ./ota-iosone updates publish --platform android --out ./ota-androidUpload each output folder so its manifest.json is served at
<url>/<platform>/<runtimeVersion>/manifest.json, for this config
https://updates.example.com/ios/1.0/manifest.json. Any static file host
works.
In the app, download updates in the background; the next launch runs the newest one:
import { useEffect } from 'react'import { One } from 'one'
export function useDownloadUpdates() { useEffect(() => { if (One.Updates.isEnabled) One.Updates.fetch().catch(() => {}) }, [])}Publishing. one updates publish bundles the app with the same bundler,
entry, and Hermes compiler as the release build, so the bytecode matches
the binary. It writes manifest.json and an assets folder where every
file is named by its SHA-256 hash. The manifest gets a fresh ID, the publish
time as createdAt, the runtime version, and any --metadata you pass.
The output folder must be empty, so each publish is a new folder.
The embedded update. The release build writes its own small manifest
beside the bundle in the binary, with the build time as its createdAt.
It is always a candidate at launch, so the app can always start.
Launching. Before React Native starts, the launcher reads a state file
and picks the newest candidate by createdAt: a downloaded update that is
complete and not marked failed, or the embedded bundle. It never touches
the network at launch. Downloads for another runtime version, or older than
the embedded bundle, are deleted then.
Checking and fetching. check downloads the manifest and reports
available when it is newer than the running bundle and not already
downloaded. fetch does the same check and then downloads the bundle and
every asset into a temporary folder. Files already on disk from another
update are linked instead of downloaded again. Each file’s hash is checked,
and only when every one matches is the update moved into place and
recorded. Any failure leaves nothing behind. A 404 for the manifest, or a
manifest for another runtime version, resolves none.
Applying. A downloaded update is staged: it runs on the next cold launch,
or right away when you call reload, which restarts JavaScript on the
newest ready update. Values like updateId and manifest describe the
running bundle and are fixed for the life of the JavaScript context.
Rollback. An update is proven once its first screen appears. If an unproven update hits a fatal JavaScript error before that, the launcher marks it failed and restarts on the next candidate in the same session. If the app instead crashes natively, hangs, or is killed before the first screen, the next launch marks the update failed. A failed update that never started is never chosen again.
Cleanup. After each successful start the launcher keeps three updates at most: the running one, the newest older one as a rollback target, and the newest staged one. Every other download is deleted.
Debug builds. The launcher is off in debug builds, which keep loading
from the dev server, and in builds without updates.url. There
isEnabled is false and check, fetch, and reload reject with
E_UPDATES_DISABLED.
| Property | Value |
|---|---|
| isEnabled | Whether this build takes updates |
| runtimeVersion | The binary’s runtime version |
| updateId | The running update’s ID |
| isEmbeddedLaunch | Whether the embedded bundle is running |
| createdAt | The running update’s date |
| manifest | The running update’s manifest |
On an embedded launch, updateId is the embedded bundle’s ID and
manifest is null.
| Function | Returns |
|---|---|
| check() | A promise of available or none |
| fetch() | A promise of fetched or none |
| getStaged() | The staged manifest, or null |
| addStagedListener(listener) | A subscription with remove() |
| reload() | A promise; restarts JavaScript |
check and fetch resolve { type: 'available' | 'fetched', manifest }
or { type: 'none' }. The staged listener is called when fetch stages an
update.
| UpdateManifest | Type | Description |
|---|---|---|
idstringA fresh ID for each publish. | string | A fresh ID for each publish. |
createdAtstringThe publish time, ISO 8601. Newest wins. | string | The publish time, ISO 8601. Newest wins. |
runtimeVersionstringThe binaries this update runs on. | string | The binaries this update runs on. |
launchAssetUpdateAssetThe Hermes bundle. | UpdateAsset | The Hermes bundle. |
assetsUpdateAsset[]Every image, font, and file the bundle uses. | UpdateAsset[] | Every image, font, and file the bundle uses. |
metadataRecord<string, UpdateMetadataValue>Values from --metadata: strings, numbers, or booleans. | Record<string, UpdateMetadataValue> | Values from --metadata: strings, numbers, or booleans. |
| UpdateAsset | Type | Description |
|---|---|---|
hashstringSHA-256 of the file, base64url. | string | SHA-256 of the file, base64url. |
urlstringWhere to download it, relative to the manifest or absolute. | string | Where to download it, relative to the manifest or absolute. |
pathstringWhere it lands beside the bundle. | string | Where it lands beside the bundle. |
| Code | Means |
|---|---|
| E_UPDATES_DISABLED | The build does not take updates |
| E_UPDATES_CHECK | Unreachable server, or a bad manifest in check |
| E_UPDATES_FETCH | A bad manifest or file in fetch |
| E_UPDATES_RELOAD | A reload is running or failed |
| Publish option | Does |
|---|---|
| –platform | ios or android |
| –out | An empty folder for the output |
| –metadata key=value | Adds metadata; repeatable |
| –intermediates-out | A folder for the bundle and source map |
| –runtime-version | For an app with no native.app config |
| iOS | Android | Web | |
|---|---|---|---|
| Stored in | Application Support | App files | Nothing |
| Rollback restarts | JavaScript | The activity | No |
| Images in the binary | Reused by updates | Downloaded once | None |
| reload | Newest update | Newest update | Reloads the page |
runtimeVersion
whenever the native side changes, and publish for that version only after
the new binary ships.--intermediates-out writes the plain bundle
and a Hermes source map for symbolication. The map can contain your source
text, so never upload that folder. The map matches only the bundle from
the same publish run.reload last; listeners,
timers, and state from the old context are gone afterward.check and fetch
reject, and reload reloads the page.import { useEffect } from 'react'import { AppState } from 'react-native'import { One } from 'one'
let downloading: Promise<unknown> | null = null
function download() { downloading ??= One.Updates.fetch() .catch(() => null) .finally(() => { downloading = null })}
export function useUpdatesOnResume() { useEffect(() => { if (!One.Updates.isEnabled) return download() const subscription = AppState.addEventListener('change', (state) => { if (state === 'active') download() }) return () => subscription.remove() }, [])}Checking on launch and each return to the app keeps long-running sessions current, and the shared promise keeps one download at a time. The update runs on the next launch, so nothing changes under people mid-task.
import { useEffect, useState } from 'react'import { Button } from 'react-native'import { One } from 'one'
export function UpdateReady() { const [staged, setStaged] = useState(() => One.Updates.getStaged()) useEffect(() => { const subscription = One.Updates.addStagedListener(setStaged) return () => subscription.remove() }, []) if (!staged) return null const note = staged.metadata.message return ( <Button title={typeof note === 'string' ? `Restart: ${note}` : 'Restart to update'} onPress={() => One.Updates.reload()} /> )}Publishing with --metadata message="New photo editor" gives the button its
label. getStaged covers an update downloaded before this screen mounted,
and the listener covers one that arrives while it is open.
import { Text } from 'react-native'import { One } from 'one'
export function VersionRow() { const { isEmbeddedLaunch, runtimeVersion, updateId, createdAt } = One.Updates const source = isEmbeddedLaunch ? 'built in' : `update ${updateId?.slice(0, 8)}` return ( <Text> {runtimeVersion ?? 'dev'} · {source} · {createdAt?.toLocaleDateString() ?? ''} </Text> )}Support can tell from this row whether someone runs the bundle from the store or a downloaded update, and which one.
Edit this page on GitHub.