One Logo Pool Ball
wwwwwwwwwwwwwwwwwww

Updates

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.

Usage

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:

Terminal window
one updates publish --platform ios --out ./ota-ios
one updates publish --platform android --out ./ota-android

Upload 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(() => {})
}, [])
}

How it works

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.

API reference

PropertyValue
isEnabledWhether this build takes updates
runtimeVersionThe binary’s runtime version
updateIdThe running update’s ID
isEmbeddedLaunchWhether the embedded bundle is running
createdAtThe running update’s date
manifestThe running update’s manifest

On an embedded launch, updateId is the embedded bundle’s ID and manifest is null.

FunctionReturns
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.

UpdateManifestTypeDescription
idstringA fresh ID for each publish.
stringA fresh ID for each publish.
createdAtstringThe publish time, ISO 8601. Newest wins.
stringThe publish time, ISO 8601. Newest wins.
runtimeVersionstringThe binaries this update runs on.
stringThe binaries this update runs on.
launchAssetUpdateAssetThe Hermes bundle.
UpdateAssetThe 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.
UpdateAssetTypeDescription
hashstringSHA-256 of the file, base64url.
stringSHA-256 of the file, base64url.
urlstringWhere to download it, relative to the manifest or absolute.
stringWhere to download it, relative to the manifest or absolute.
pathstringWhere it lands beside the bundle.
stringWhere it lands beside the bundle.
CodeMeans
E_UPDATES_DISABLEDThe build does not take updates
E_UPDATES_CHECKUnreachable server, or a bad manifest in check
E_UPDATES_FETCHA bad manifest or file in fetch
E_UPDATES_RELOADA reload is running or failed
Publish optionDoes
–platformios or android
–outAn empty folder for the output
–metadata key=valueAdds metadata; repeatable
–intermediates-outA folder for the bundle and source map
–runtime-versionFor an app with no native.app config

Platform differences

iOSAndroidWeb
Stored inApplication SupportApp filesNothing
Rollback restartsJavaScriptThe activityNo
Images in the binaryReused by updatesDownloaded onceNone
reloadNewest updateNewest updateReloads the page

Limits and gotchas

Examples

Download on every resume

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.

Offer a restart when an update is ready

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.

Show the running version in settings

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.