Reviewed against SDK 10.58.1 and CLI 2.28.0 on 2026-09-27. For tvOS, RN 0.87 TypeScript, error metadata, source maps, and modern publishing options, also read modern-integration.md. These reviewed versions are not blanket minimum requirements for older apps.
- Fast path
- Compatibility and version floors
- Service choice
- App shape specifics
- JS wiring
- Strategy and hook options
- Native cold-start recovery
- Native touchpoints
- Release baseline and publishing
- Verification
- Troubleshooting
- Examples
- In the app root, install the SDK and ensure the CLI is available:
npm i react-native-updatenpm i -g react-native-update-cli- Install the CLI globally and invoke
pushyorcrescdirectly. Do not replace these commands with a project-local CLI ornpx. - Use
yarn,pnpm, orbunequivalents for the app dependency only when the project already uses that package manager.
- For iOS, run
cd ios && pod installafter dependency changes. - Create or select app records per platform with the matching service CLI:
- Pushy:
pushy createApp --platform ios|android|harmonyorpushy selectApp --platform ... - Cresc:
cresc createApp --platform ios|android|harmonyorcresc selectApp --platform ...
- Pushy:
- Commit the generated
update.json. It is not a secret. Do not commit.update. - Configure native bundle loading for iOS/Android/Harmony.
- Initialize one
PushyorCrescclient and wrap the app withUpdateProvider. - Build a release package, pass the actual distribution artifact to the global CLI for native-baseline upload, then publish a
.ppkhot update.
- Read the installed version from
node_modules/react-native-update/package.jsonor the active lockfile, not only the dependency range inpackage.json. - Read the global CLI version with
pushy versionorcresc version. - Preserve existing commands and older app binaries. A feature introduced in a newer SDK must be optional or guarded until every supported native binary contains its native implementation.
- Ship a new native release and upload its baseline when upgrading a capability implemented in iOS, Android, or Harmony native code. An OTA package alone cannot add that native method.
- When newer JS can run on an older binary, use the SDK's documented compatibility fallback. If calling a native module escape hatch directly, guard it with
typeof method === 'function'and keep the previous behavior as the fallback.
Useful feature floors:
| Capability | Minimum react-native-update |
Compatibility note |
|---|---|---|
afterCheckUpdate |
10.38.3 | Notification hook; do not make basic update flow depend on it. |
beforeReload |
10.42.2 | Only affects switchVersion() and restartApp(). |
useUpdateProgress() |
10.45.0 | Keep useUpdate().progress for code that must compile against older SDKs. |
Stable error codes and client.onError() |
10.46.0 | lastError remains the provider-level compatibility path. |
resetToPackagedBundle() |
10.48.0 | The client feature-detects older native modules and returns false; do not ignore the boolean. |
bundleHash / bundleStatus |
10.49.0 | Use global CLI >= 2.20.3 when uploading baselines. |
| Native cold-start check, force-boot recovery, and resumable rescue | 10.52.1 | Requires a new native release on every target platform. |
| tvOS support, purge-restore ordering, nested Expo module layout fix | 10.58.0 | Native application rebuild required. Apple TV uses the ios configuration key. |
| RN 0.87 TypeScript compatibility fixes | 10.58.1 | The 10.58.0 -> 10.58.1 patch alone does not require a native rebuild. |
Do not reject an otherwise valid integration merely because it is below a feature floor. Report the unavailable capability, keep the older supported flow, and recommend a native upgrade when the capability matters.
- Pushy China service: use
Pushyfromreact-native-update,pushyCLI, Pushy admin, RMB billing. - Cresc global service: use
Crescfromreact-native-update,crescCLI, Cresc admin, USD billing. update.jsonstores app IDs and app keys for whichever service created them. Keep the JS client class and CLI service aligned.- The same SDK supports both services; most integration code differs only in the client class name and command prefix.
- Standard install plus iOS pods.
- RN >= 0.60 normally autolinks. Very old RN, monorepos, brownfield, or custom native layouts may need manual linking.
- Use Expo prebuild workflow:
npx expo prebuild. - Expo 50+ is the supported baseline. Expo New Architecture support before Expo 51 is incomplete; prefer the latest Expo available.
- Do not co-install
expo-updates; it conflicts with update behavior. - Expo 48+ with
react-native-update>= 10.28.2 configures iOS bundle URL automatically. Still run pods after prebuild.
- Read modern-integration.md for the 10.58.0 native floor, cache-purge recovery, and testing requirements.
- Apple TV still uses
Platform.OS === 'ios'and aniosappKey. Do not introduce atvosCLI platform orupdate.jsonkey.
update.jsoncan include aharmonyentry. Do not rely onPlatform.OSunless the app already normalizes it toharmony.- Harmony needs native package/provider/bundle-provider wiring in addition to JS
UpdateProvider. - Keep the bundle file name
bundle.harmony.jswhether or not Hermes bytecode is used.
- Do not change host app inheritance just to integrate OTA.
- Wire the bundle URL at the runtime creation point:
- iOS XCFramework: call
RCTPushy.bundleURL()through the brownfield bundle URL override or bridge delegate. - Android AAR/new architecture: pass
UpdateContext.getBundleUrl(application, "assets://index.android.bundle")intoReactHost/getDefaultReactHost.
- iOS XCFramework: call
- Ensure the final distributed package contains the exact embedded baseline bundle that is uploaded as the native baseline.
Read the platform app key from update.json.
For iOS/Android:
import { Platform } from 'react-native';
import updateConfig from './update.json';
const { appKey } = updateConfig[Platform.OS as 'ios' | 'android'];For Harmony:
import updateConfig from './update.json';
const { appKey } = updateConfig.harmony;Pushy:
import { Pushy, UpdateProvider } from 'react-native-update';
import App from './App';
const updateClient = new Pushy({
appKey,
});
export default function Root() {
return (
<UpdateProvider client={updateClient}>
<App />
</UpdateProvider>
);
}Cresc:
import { Cresc, UpdateProvider } from 'react-native-update';
import App from './App';
const updateClient = new Cresc({
appKey,
});
export default function Root() {
return (
<UpdateProvider client={updateClient}>
<App />
</UpdateProvider>
);
}Rules:
- Create the client outside the component so it is stable across renders.
- Mount exactly one client and one
UpdateProviderper process. Multiple clients/providers are an integration error because native and JS update state is process-wide. - Do not call
useUpdate()in the same component that rendersUpdateProvider; call it in descendants. - Prefer the functions returned from
useUpdate()(checkUpdate,downloadUpdate,switchVersion,switchVersionLater, etc.) over directclientcalls. Directclientaccess is an escape hatch for unusual low-level integrations, not the normal app API. - Prefer
updateInfoandlastErrorfor rendered UI, automatic checks, deep links, and QR flows so they share one provider-owned state path. checkUpdate()returnsCheckResult | undefined. A contained manual action may inspect that result; treatundefinedas skipped/failed and do not maintain a second long-lived copy of provider state.- For TypeScript JSON imports, enable
resolveJsonModuleor use the project's existing JSON-import pattern.
checkStrategy:both/onAppStart/onAppResume/nullupdateStrategy:alwaysAlert/alertUpdateAndIgnoreError/silentAndNow/silentAndLater/null- For custom UI: set
updateStrategy: null, then useuseUpdate(). debug: truecan check/download in development but cannot apply patches. Applying updates requires release builds.throwError: truelets callers usetry/catch; otherwise uselastError.beforeCheckUpdate,beforeDownloadUpdate,afterDownloadUpdate, andonPackageExpiredare control hooks for custom gates.afterCheckUpdate(v10.38.3+) is useful for analytics or observability after each check. ReceivesUpdateCheckStatewithstatus("completed"/"skipped"/"error"), optionalresult, and optionalerror.beforeReload(v10.42.2+) executes beforeswitchVersion()orrestartApp()actually restarts the app. Returnfalseto cancel the restart. Useful for cleaning up native SDKs (e.g., Sentry profiling) before the RN instance is destroyed.switchVersionLater()does NOT trigger this hook.maxRetriescontrols failed-download retry attempts (default: 3).disableTelemetry: truedisables lifecycle/health telemetry. Explain that this removes dashboard health signals before recommending it.loggercan forward update events to analytics.- Avoid passing the
clientobject down through app code. Keep the client at provider setup and drive UI throughuseUpdate()state.
If the app integrates Sentry profiling, performance sampling, or similar native SDKs that work across threads, use beforeReload to stop sampling and flush queues before Pushy restarts:
import { NativeModules } from "react-native";
import * as Sentry from "@sentry/react-native";
import { Pushy } from "react-native-update";
const pushyClient = new Pushy({
appKey,
beforeReload: async (context) => {
// context.type is "switchVersion" or "restartApp"
// context.hash is the update hash when type is "switchVersion"
try {
NativeModules.RNSentry?.stopProfiling?.();
} catch {}
const flushed = await Promise.race([
Sentry.flush(),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 1500)),
]);
// Return false to cancel this restart; the next check or manual trigger will retry.
return flushed;
},
});The useUpdate() hook returns these key functions and state:
checkUpdate: Trigger update check. ReturnsCheckResult | undefinedon v10.26.0+, but prefer readingupdateInfofrom the hook for rendered UI.downloadUpdate: Download the update. Returnsbooleanon v10.16.0+.switchVersion: Immediately restart and apply the downloaded update. Waits forbeforeReloadif configured.switchVersionLater: Apply update on next manual restart. Does NOT triggerbeforeReload.restartApp(v10.28.2+): Restart the app immediately. Waits forbeforeReloadif configured.resetToPackagedBundle(v10.48.0+): Delete downloaded update state and optionally restart into the embedded bundle. Check the returned boolean;falsemeans reset did not happen, including when the native binary is too old.updateInfo: Current update state ({update: true},{upToDate: true}, or{expired: true}).lastError: Most recent error from check/download/apply.progress: Download progress{hash, received, total, progress?}. For progress-only UI on v10.45.0+, preferuseUpdateProgress()so progress ticks do not re-render unrelateduseUpdate()consumers.currentHash: Current hot-update version hash.packageVersion: Current native version number.currentVersionInfo(v10.31.2+): Sync field with{name, description, metaInfo}of current hot-update version.
For detailed error handling on v10.46.0+, use stable UpdateError.code values and client.onError(). Keep lastError as the normal provider/UI path and as the fallback for older integrations. For getUpdateMetadata(), crash reporters, and source-map matching, see modern-integration.md.
- Keep the native check enabled by default. It runs once per cold start after a short delay, off the startup path, and reuses its response in the JS check.
- Treat this as a native capability: upgrade the SDK, rebuild every platform, upload the new native baseline, and verify one healthy launch before relying on recovery. Do not claim an OTA package can retrofit it into an older binary.
checkStrategycontrols automatic activation authority, not whether the native request runs. WithcheckStrategy: null, the native side may download but does not activate normally.silentAndNow/silentAndLaterwith automatic checks enabled allow a native-downloaded version to activate on the next launch. Alert/custom strategies leave normal activation to JS.- A dashboard version marked
forceBootmay activate on the next launch regardless of those strategies. Device-local rollback protection still rejects a version already rolled back on that device. - Android and iOS can briefly hold a process dying from an uncaught JS startup error to finish rescue. Do not promise recovery for native crashes, ANRs, OOM kills, or iOS apps that replace React Native's fatal handler.
- Downloads resume across process death. Harmony does not use the crash-time hold; its native check and resumable download continue independently of a failed JS startup.
- Set
disableNativeCheck: trueonly when the background request itself violates traffic, battery, privacy, or consent requirements. State explicitly that this gives up automatic recovery for a JS-bricked app.
- Import the native module outside debug/flipper conditionals:
- Objective-C / Objective-C++:
#import "RCTPushy.h" - Swift:
import react_native_update
- Objective-C / Objective-C++:
- In release mode, use
RCTPushy.bundleURL()for the bundle URL. - For RN >= 0.74, update
bundleURL. - For RN < 0.74, update
sourceURLForBridge. - Keep DEBUG bundle behavior unchanged.
- In mixed native/RN apps, initialize the bridge with a delegate and then create the root view with
initWithBridge; do not pass a fixed releasebundleURLdirectly to the root view. - After any iOS native change, rebuild the app.
- Import
cn.reactnative.modules.update.UpdateContext. - RN 0.82+ / New Architecture
ReactHost: passjsBundleFilePath = UpdateContext.getBundleUrl(this)intogetDefaultReactHost(...). - RN 0.81 or lower
DefaultReactNativeHost: overridegetJSBundleFile()and returnUpdateContext.getBundleUrl(this@MainApplication). - Java
DefaultReactNativeHost: overrideprotected String getJSBundleFile()and returnUpdateContext.getBundleUrl(MainApplication.this). - Brownfield/custom
ReactInstanceManager: call.setJSBundleFile(UpdateContext.getBundleUrl(context, "assets://index.android.bundle"))and do not also setsetBundleAssetName. - If
react-native-screensis installed, registerRNScreensFragmentFactoryinMainActivity.onCreate; do not put it inMainActivityDelegate. - Disable release PNG crunching with
crunchPngs falseto keep diffs predictable. - For AAB resource splits,
react-native-update>= 10.36.0 handles the known image issue. On older versions, disable density splitting.
Minimum native checklist with code examples:
1. harmony/entry/src/main/cpp/CMakeLists.txt
Add after add_library(rnoh_app ...):
set(PUSHY_CPP_DIR "${NODE_MODULES}/react-native-update/harmony/pushy/src/main/cpp")
target_include_directories(rnoh_app PRIVATE "${PUSHY_CPP_DIR}")
target_sources(rnoh_app PRIVATE "${PUSHY_CPP_DIR}/PushyTurboModule.cpp")2. harmony/entry/src/main/cpp/PackageProvider.cpp
#include "RNOH/PackageProvider.h"
#include "PushyPackage.h"
using namespace rnoh;
std::vector<std::shared_ptr<Package>> PackageProvider::getPackages(Package::Context ctx) {
return {
std::make_shared<PushyPackage>(ctx)
};
}3. harmony/entry/oh-package.json5
Add to dependencies:
"dependencies": {
"pushy": "file:../../node_modules/react-native-update/harmony/pushy.har",
}4. harmony/hvigor/hvigor-config.json5
{
dependencies: {
pushy: "file:../../node_modules/react-native-update/harmony",
},
}5. harmony/entry/hvigorfile.ts
import {hapTasks} from '@ohos/hvigor-ohos-plugin';
import {reactNativeUpdatePlugin} from 'pushy/hvigor-plugin';
export default {
system: hapTasks /* Built-in plugin of Hvigor. It cannot be modified. */,
plugins: [
reactNativeUpdatePlugin(),
] /* Custom plugin to extend the functionality of Hvigor. */,
};6. harmony/entry/src/main/ets/RNPackagesFactory.ets
import type {
RNPackageContext,
RNPackage,
} from '@rnoh/react-native-openharmony';
import PushyPackage from 'pushy';
export function createRNPackages(ctx: RNPackageContext): RNPackage[] {
return [new PushyPackage(ctx)];
}7. harmony/entry/src/main/ets/pages/Index.ets
Add PushyFileJSBundleProvider into AnyJSBundleProvider:
import { PushyFileJSBundleProvider } from 'pushy';
// Inside RNApp jsBundleProvider:
jsBundleProvider: new TraceJSBundleProviderDecorator(
new AnyJSBundleProvider([
new PushyFileJSBundleProvider(this.rnohCoreContext.uiAbilityContext),
// Note: keep bundle filename as bundle.harmony.js regardless of hermes bytecode
new ResourceJSBundleProvider(this.rnohCoreContext.uiAbilityContext.resourceManager, 'bundle.harmony.js')
]),
this.rnohCoreContext.logger),Important: Keep the bundle file name as bundle.harmony.js whether or not Hermes bytecode is used.
Harmony version info: The versionName field in harmony/AppScope/app.json5 is recorded as packageVersion.
Build and upload: Use DevEco-Studio: Build => Build Hap(s)/App(s) => Build App(s). The output is at harmony/build/outputs/default/harmony-default-unsigned.app. Upload with pushy uploadApp <file.app> or cresc uploadApp <file.app>.
- Build the native release first and pass the actual build artifact intended for distribution to the global CLI for baseline upload:
- iOS:
pushy uploadIpa <file.ipa>orcresc uploadIpa <file.ipa> - Android APK:
pushy uploadApk <file.apk>orcresc uploadApk <file.apk> - Android AAB:
pushy uploadAab <file.aab>orcresc uploadAab <file.aab> - Harmony APP:
pushy uploadApp <file.app>orcresc uploadApp <file.app>
- iOS:
- Do not manually slim, re-sign, or rebuild the artifact between distribution and CLI input. Current CLI versions derive and upload a slim server artifact internally; the local input remains the real IPA/APK/AAB/APP intended for users.
- With
react-native-update>= 10.49.0 and global CLI >= 2.20.3, the client and uploaded baseline identify the embedded JS bybundleHash.bundleStatuscan bematched,rebuiltSameJs, orunknownBundle; an unknown bundle falls back from unsafe incremental artifacts to a full update rather than proving that the whole archive differs byte-for-byte. - If native code/config changes, or a rebuild changes embedded JS, bump the native version and upload a new baseline. A same-version rebuild with identical JS may be recognized as
rebuiltSameJs, but do not make repeated same-version rebuilds the release workflow. - If producing APK and AAB for the same version, build both in the same Gradle invocation, for example:
{
"scripts": {
"package:android:release": "cd android && ./gradlew clean assembleRelease bundleRelease"
}
}- Publish JS/assets-only changes with:
pushy bundle --platform ios|android|harmonycresc bundle --platform ios|android|harmony
- If a framework such as modern Expo has no
index.js, create one that imports the real entry, for exampleimport "expo-router/entry";. - After publishing the
.ppk, bind it to one or more uploaded native baselines. Canary rollout can bind one partial rollout and one full rollout per native baseline; client support requiresreact-native-update>= 10.32.0. - For noninteractive publishing, dry runs, source maps, symbolication, and Hermes-base verification, read modern-integration.md.
- Release build succeeds on target platform.
- The actual distribution artifact was passed to the global CLI, which registered the corresponding native baseline.
-
update.jsonhas the platformappKeyused by the JS client. - App can call check update and returns structured update state.
- Update package download succeeds.
- App can switch to new version (now/later behavior as expected).
- QR/deep-link test path works if used.
- Rollback behavior understood/tested for crash scenarios.
- Installed SDK and global CLI versions were read from the actual installation, and versioned features have fallbacks for older supported binaries.
- On v10.49.0+: release logs/dashboard do not report an unexpected
unknownBundle; if they do, upload the missing native baseline and expect full-download fallback until fixed. - On v10.52.1+: native cold-start check is enabled or explicitly waived, activation policy is understood, and a controlled force-boot recovery has been tested before an emergency.
- On tvOS: rebuilt SDK 10.58.0+ is in the native binary; test normal and offline launch after cache purge, restore timeout, and reset while restoring.
- When execution is available,
node <skill-root>/scripts/integration_doctor.mjs <app-root> --strict --jsonhas no missing requirements. Resolve the skill root from the loadedSKILL.md, not the app directory. Otherwise record the diagnostic as not run and perform the manual checks above. Static diagnostics do not replace device Release-build verification. - Harmony: all 7 native files configured (CMakeLists.txt, PackageProvider.cpp, oh-package.json5, hvigor-config.json5, hvigorfile.ts, RNPackagesFactory.ets, Index.ets).
- Harmony: bundle filename is
bundle.harmony.js. - Harmony:
PushyFileJSBundleProvidercomes beforeResourceJSBundleProviderinAnyJSBundleProvider. - If using Sentry/profiling SDK:
beforeReloadconfigured to flush before restart.
- Missing/incorrect
update.jsonappKey by platform. - Mixing Pushy CLI/app keys with
new Cresc(...), or Cresc app keys withnew Pushy(...). - Calling
client.checkUpdate()/client.downloadUpdate()directly in normal UI code instead of usinguseUpdate(). - Maintaining separate long-lived state from both
await checkUpdate()andupdateInfo. Use provider state for UI; reserve the return value for contained one-shot actions and handleundefined. - Expecting real apply-update behavior in DEBUG builds.
- Expo project still carrying
expo-updates. - Expo project not prebuilt before native changes.
- iOS release still returning the embedded Metro bundle URL instead of
RCTPushy.bundleURL(). - Android native host still using
setBundleAssetName("index.android.bundle")instead ofUpdateContext.getBundleUrl(...). react-native-screensblank screen after OTA restart becauseRNScreensFragmentFactoryis missing.- The artifact passed to the global CLI is not the build intended for distribution, or it was rebuilt/re-signed in a way that changed the embedded bundle before distribution.
- Rebuilding the same native version and distributing it without uploading the new baseline.
- Treating
unknownBundleas a network error. It means the installed embedded bundle is not registered; full update remains the safe fallback until its baseline is uploaded. - Android release PNG crunching or old AAB density split behavior changing asset bytes.
- iOS pods not installed after dependency update.
- Native file edits not followed by full rebuild.
- Treating
metaInfoas an object or trusting a TypeScript assertion after JSON.parse. Validate its runtime shape and fail closed. - Harmony: using wrong bundle filename. Must be
bundle.harmony.jsregardless of Hermes bytecode usage. - Harmony: missing
PushyFileJSBundleProviderinAnyJSBundleProvider— it must come before theResourceJSBundleProviderfallback. - Harmony: missing
reactNativeUpdatePlugin()inhvigorfile.ts. - Harmony: missing
PushyTurboModule.cppinCMakeLists.txt. - App uses native SDKs with cross-thread work (Sentry profiling, etc.) and calls
switchVersion()orrestartApp()without configuringbeforeReload— can cause crashes or data loss during restart. - Assuming
checkStrategy: nulldisables the v10.52.1 native request. It only removes normal automatic activation authority; usedisableNativeCheckonly after accepting the loss of brick recovery. - Shipping OTA JS that requires a newer native method into an older binary without a capability guard or fallback.
Use this when the app root is still class-based.
import React from 'react';
import { Platform } from 'react-native';
import { Pushy, UpdateProvider } from 'react-native-update';
import App from './App';
import updateConfig from './update.json';
const { appKey } = updateConfig[Platform.OS as 'ios' | 'android'];
const pushyClient = new Pushy({
appKey,
checkStrategy: 'onAppStart',
updateStrategy: 'alertUpdateAndIgnoreError',
});
export default class Root extends React.Component {
render() {
return (
<UpdateProvider client={pushyClient}>
<App />
</UpdateProvider>
);
}
}For Cresc, replace Pushy with Cresc.
Use metaInfo and your own user/device attributes to decide whether to apply update. Copy rollout-whitelist.ts into the app beside this hook. Its tested isUserAllowed predicate rejects invalid JSON, null, non-object metadata, non-array or mixed-type allowlists, and substring matches.
Configure the singleton client with updateStrategy: null when this hook owns download/apply decisions; do not leave an automatic strategy that bypasses the gate.
import { useEffect, useRef } from 'react';
import { useUpdate } from 'react-native-update';
import { isUserAllowed } from './rollout-whitelist';
function useWhitelistGate(currentUserId: string) {
const { checkUpdate, updateInfo, downloadUpdate, switchVersionLater } = useUpdate();
const handledHashRef = useRef<string | null>(null);
const inFlightHashRef = useRef<string | null>(null);
useEffect(() => {
void checkUpdate().catch(() => { /* Render lastError from useUpdate() in the UI. */ });
}, [checkUpdate]);
useEffect(() => {
if (!currentUserId) return;
if (!updateInfo?.update || !updateInfo.hash) return;
if (handledHashRef.current === updateInfo.hash) return;
if (inFlightHashRef.current === updateInfo.hash) return;
if (!isUserAllowed(updateInfo.metaInfo, currentUserId)) return;
let cancelled = false;
const hash = updateInfo.hash;
(async () => {
inFlightHashRef.current = hash;
try {
const ok = await downloadUpdate();
if (!cancelled && ok) {
switchVersionLater();
handledHashRef.current = hash;
}
} catch {
// Use lastError in the UI; never allow an unhandled async rejection.
} finally {
if (inFlightHashRef.current === hash) inFlightHashRef.current = null;
}
})();
return () => {
cancelled = true;
};
}, [currentUserId, updateInfo, downloadUpdate, switchVersionLater]);
}Notes:
- Keep server-side rollout rules as source of truth; client whitelist is an extra guard, not access control or a substitute for server/native recovery policy.
- Store small, explicit whitelist keys in
metaInfosuch asallowUsers. Keep personal data out of public logs and crash-report metadata. - Prefer phased rollout: internal users -> small percent -> full rollout.
- Treat JSON.parse output as
unknownand validate it before accessing properties. Missing or malformed metadata must reject this update.