The HotCodePush React Native SDK: @hotcodepush/react-native-code-push, the Turbo Module that delivers over-the-air (OTA) updates to React Native apps on iOS and Android.
Stack: TypeScript for the JavaScript surface and the Codegen spec, Swift and Objective-C++ on iOS, Kotlin on Android, React Native 0.82 and newer on the New Architecture only.
The plan is the private handbook repo, checked out beside this one: ../handbook/docs/.
sdk-api.md is the SDK's specification — the methods, the configuration, the state keys, the wire shapes and the reason catalog; architecture.md's The device protocol and Packs are the behaviour behind them.
When code and plan disagree, stop and surface it; never improvise.
src/ NativeHotCodePush.ts (the Codegen spec), NativeHotCodePush.web.ts (null, the web has no native module), hot-code-push.ts (the API's type and object), use-updates.ts (the hook), definitions.ts
ios/ the Turbo Module (Objective-C++), the runtime and the bundle loader (Swift) over HotCodePushCore
android/src/main/java/com/hotcodepush/reactnative the Turbo Module, the runtime, the first-render listener, the bundle loader, the reader of the embedded bundle and the React host over com.hotcodepush:core-android
android/src/test/java/com/hotcodepush/reactnative the Robolectric tests of the Android runtime and the Turbo Module, which verify:android runs
android/settings.gradle the library's own build, which takes React Native's Gradle plugin and react-android's version from node_modules
android/hotcodepush.gradle the task that runs binary create for every variant, and core-android's maven branch among `:app`'s repositories; applied by the app's build.gradle
scripts/binary-create-xcode.sh binary create as the app's Xcode phase runs it
scripts/download-core-ios.mjs the core's XCFramework into the ignored ios/Frameworks, which the pod vendors
benchmarks/ the size and cold-start baseline, measured on the demo
The native cores live in core-ios and core-android. core-ios is the XCFramework of a release: package.json pins its URL and checksum under hotcodepush.coreIos, and scripts/download-core-ios.mjs unpacks it into the ignored ios/Frameworks for the pod's vendored_frameworks, so the app's Podfile names no core. The Android library comes from core-android's maven branch by full sha in android/build.gradle; a core change lands there first and arrives here as a bump of both pins.
This package keeps what is React Native's: which bundle the host loads, the reload, the readiness signal and the bridge. Nothing of the protocol lives here.
- The host asks for its bundle. React Native fixes the JavaScript it loads when its host is created, so the app hands that question to the SDK:
HotCodePush.bundleURL()inAppDelegate.swift,HotCodePushReactHost.getDefaultReactHostinMainApplication.kt. Both are asked again on every reload. - The core starts on that first question, and the host waits for it: the start's verdict — the pending switch, the rollback of a release that never became ready — is in before any JavaScript runs, so a start never loads one bundle and reloads into another. The wait is the core's two seconds: past it the host serves the embedded bundle and the core reloads it into the right one once the start answers. A host that is not in the foreground at that question starts headless, so no release is applied and no gate armed. A reload the SDK did not ask for serves the running bundle, and the core is not told: a waiting release applies at the next start or through
applyUpdate(). - A host that never asked runs JavaScript the SDK does not serve — Metro in a debug build, or an app that is not wired. The core then runs with
isEnabledInDebugBuildsoff, and every result isskippedwithbuild_debug; a debug build's resource file, which the build step writes without an embedded bundle, says the same on its own. - A switch is a reload of the host,
RCTTriggerReloadCommandListenersandReactHost.reload: the JavaScript restarts, the process stays. The cores hold every reload but a rollback until the app is up in the run, the app's ownapplyUpdate()andclearUpdates()included: a reload while React Native starts an instance crashes the app on iOS. The app is up once it has rendered, callednotifyReady()or run into the readiness timeout. This package holds no reload back: the loader persists the core's choice and reloads a host that has asked for its bundle. What the cores need from it is the first render, reported on every start and after every reload, whateverreadySignalis:HotCodePushRuntimecalls the core'shandleRendered()inhandleContentDidAppearon iOS and on Android in theFirstRenderListenerthatobserveFirstRenderregisters for every JavaScript instanceHotCodePushReactHostreports. - Readiness is the first frame after the root view renders, observed natively:
RCTContentDidAppearNotificationon iOS, the first frame of each JavaScript instance at which a React root holds a view on Android, checked at every frame React Native dispatches its mounting work, since the first mount can come before React Native reports the instance and a mount made before the root view is attached runs at the attach, which no mount listener hears, since React Native logs its content-appeared marker once per surface and not again after a reload, and mounts the root itself before any render. A bundle that mounts nothing never becomes ready. - A bundle is laid out as React Native's own build lays it out:
main.jsbundlewithassets/on iOS,index.android.bundlewith itsdrawable-*directories on Android, under the store'sserved/<bundleId>. updateRolledBackis retained natively until the JavaScript of the start that follows the rollback listens, and every listener of that start receives it,useUpdates()and the app's own alike; the other four events go to whichever module instance is alive.- Android's React host mirrors
DefaultReactHost.getDefaultReactHostwith one difference, a delegate whose bundle loader is resolved per instance; it uses the same unstable React Native API that function uses, so a React Native upgrade is checked against it.
| Command | Does |
|---|---|
npm run lint |
ESLint, Prettier and SwiftLint |
npm run typecheck |
TypeScript |
npm test |
Vitest over the JavaScript |
npm run build |
the TypeScript into dist/; writes the SDK version from package.json into SdkVersion.swift and SdkVersion.kt and downloads the core's XCFramework, all ignored, so a fresh checkout runs it before opening the native projects |
npm run verify |
lint, typecheck, the Vitest tests and the build |
npm run verify:android |
Gradle builds, lints and unit-tests the Android library on its own, Robolectric; needs a JDK and the Android SDK |
Run npm run fmt before every commit.
The iOS code compiles only inside an app: ci.yml builds the demo with the package from the commit, packed after npm run build so the pod carries the core; benchmarks/prepare-demo.mjs drops a core pod line the demo's Podfile still pins. The Android library also builds on its own: android/settings.gradle resolves React Native from the devDependency, so Codegen runs on the host, and verify:android runs its Robolectric tests in their own ci.yml job.
Consumers pin the preview builds pkg.pr.new publishes from ci.yml on every push to main and every pull request, never npm: npm install https://pkg.pr.new/hotcodepush-team/react-native-code-push/@hotcodepush/react-native-code-push@<sha>.
A consumer pins a commit and bumps it deliberately — never @main, whose moving content breaks npm ci against the lockfile's integrity hash.
The shared types come from @hotcodepush/protocol the same way, pinned to a commit in package.json; a protocol change is a bump of that sha.
- The SDK never sees an API DTO: its contract is the channel index, the bundle manifest and the events endpoint, additive only.
- Safety is on by default and cannot be switched off: the readiness gate, the local blocklist, the automatic rollback. The embedded bundle is the one exception: a release that carries it is confirmed as it becomes current, never gated, rolled back or blocklisted, and downloads nothing.
- Nothing a device does not need to decide lives here: rollout, conditions, revocation and the cap are evaluated from the index, never configured.
- Every catalog value is lowercase snake_case with no prefix, the statuses, the reasons and the strategies alike:
up_to_date,signature_invalid,next_start; the event names, the methods, the fields, the configuration keys, the environment variables and each language's identifiers keep their own form. A method throws a plain error only for a programming mistake. - Results are one shape per method and never throw for an outcome an app should handle.
- Prose names the feature over-the-air (OTA) updates: "over-the-air (OTA) update" on the first mention of a surface, "OTA update" after it, and the appositive "HotCodePush, the over-the-air (OTA) update service for React Native, Expo, Capacitor and Cordova". "Live update" is the Capacitor community's word: it stays in the
capacitor-live-updatespackage name and in a list of alternative names for search, never as HotCodePush's own term. - Prose names the supported frameworks in the order React Native, Expo, Capacitor and Cordova, the larger communities first; a list a reader scans for their own item, such as an enum's choices, a prompt, a tab set or a table, sorts alphabetically.
- This repository's own sentences lead with React Native.
- A test earns its place by catching a bug nothing else catches, at the lowest layer that can prove it; one test per branch, never per value, and nothing for what the types, the framework or a library guarantee.
- A new test names the behaviour or the bug it proves; if deleting the production code would not fail it, it does not get in.
- A method rejects with one error where the native module is missing, on the web and in Expo Go.
- The Codegen spec carries plain objects;
hot-code-push.tstypes them one layer up with@hotcodepush/protocol's result and event types, so those stay the one definition.
- Comment only what the code cannot say: the why behind a choice, an outside quirk or limit, what a regex matches, a TODO with its issue link. Never what the code does.
- Before adding a comment, try a clearer name or a small extracted function first.
- One or two short plain sentences. Capital letter, full stop, own line above the code. No run-on sentences, no chains of possessives.
- Doc comments (JSDoc, KDoc,
///, docstrings) only on a package's public API: one line that says what it does, then only what a caller must know. - Never: section banners, commented-out code, change history, the same comment in two places.
- No
.mcp.json: no server covers this repo's stack yet; the HotCodePush server joins when it exists. .claude/skills/holds the developer skills copied fromhotcodepush-team/.githubwith the skills CLI and pinned inskills-lock.json; update them withnpx skills update..github/copilot-instructions.mdholds the review criteria, the only Copilot-specific file.- Commits are conventional commits;
mainis trunk, CI is the gate, and a commit that lands an issue saysCloses #<n>.