HotupdaterHot Updater

Brownfield Apps

Ship OTA updates to React Native packaged as an XCFramework or AAR inside an existing native iOS or Android app.

Overview

A brownfield app is an existing native iOS or Android app that shows some screens with React Native. The React Native code lives in its own project and ships to the native app as a prebuilt artifact: an XCFramework on iOS and an AAR on Android. The native app never runs Metro, Node.js, or the Hot Updater CLI.

Hot Updater works inside that artifact:

  • The React Native project keeps the usual setup: @hot-updater/react-native, hot-updater.config.ts, HotUpdater.init, and hot-updater deploy.
  • Before React Native starts, the artifact checks for an update in native code, with the same checkForUpdate and updateBundle as JavaScript. It waits up to a timeout for the check and any required update; other updates download in the background.
  • The artifact asks Hot Updater for the JavaScript bundle when React Native starts. Hot Updater returns the downloaded update, or the bundle embedded in the artifact.
  • The CLI writes the channel and signing public key into the artifact's own Info.plist and AndroidManifest.xml. Packaging embeds the fingerprint, so these values travel with the artifact.
  • The native app calls one function of the artifact and never imports Hot Updater.
React Native project (BrownfieldRN)
├─ ios/BrownfieldLib       ── package ──▶ BrownfieldLib.xcframework ──▶ native iOS app
├─ android/brownfieldlib   ── package ──▶ brownfieldlib AAR         ──▶ native Android app
└─ hot-updater deploy      ──────────────▶ update server ──▶ downloaded by the artifact

This guide uses React Native Brownfield to package the artifacts, through its brownfield CLI or the Rock brownfield plugins. Both produce the same artifacts with the same runtime library, so the Hot Updater steps are the same.

Before you start

  • Package and run React Native in your native app without Hot Updater first. Follow the React Native Brownfield iOS and Android guides, or the Rock brownfield guide. For a React Native CLI project, npm create @callstack/react-native-brownfield@latest creates the framework target and library module.
  • Use the New Architecture, which React Native Brownfield requires.
  • Install Hot Updater in the React Native project and connect it to an update server, as in Install packages and Connect your app. Skip that page's native code setup: this guide replaces it. Keep its JavaScript HotUpdater.init, authentication, plugins, and build adapter setup, but skip its JavaScript update button and wrap examples: the native code below checks for updates. Give the native HotUpdaterOptions the same server URL and client request headers as JavaScript's HotUpdater.init.

The examples use these names. Replace them with yours:

NameMeaning
BrownfieldRNThe React Native project, and the component it registers with AppRegistry
BrownfieldLibThe iOS framework target and its XCFramework
brownfieldlibThe Android library module and its AAR
com.brownfieldrnThe Android library module's package

Choose an update strategy

Fingerprint suits brownfield apps best. Its hash comes from the React Native project, which builds every native part the JavaScript bundle depends on, so it changes only when you ship a new artifact. Downloaded updates are kept per artifact: a native app release that ships the same artifact keeps them, and a new artifact starts from the bundle it embeds.

App Version compares the native app's version: CFBundleShortVersionString of the app on iOS and versionName of the app on Android. The artifact has no version of its own at runtime. With App Version, deploy each update with --target-app-version set to the native app versions that contain the matching artifact.

The examples below use Fingerprint. For App Version, set updateStrategy to "appVersion" in hot-updater.config.ts and change the native HotUpdaterCheckOptions to .appVersion on iOS and HotUpdaterUpdateStrategy.APP_VERSION on Android. Omit the fingerprint config below and skip fingerprint create in the later steps. If the project already has a fingerprint.json, delete it before packaging: native builds embed a fingerprint whenever that file exists.

hot-updater.config.ts
export default defineConfig({
  // ... build, storage, database, and plugins
  updateStrategy: "fingerprint",
  fingerprint: {
    extraSources: {
      ios: [
        "ios/BrownfieldLib/**/*.swift",
        "ios/BrownfieldRN.xcodeproj/project.pbxproj",
      ],
      android: [
        "android/brownfieldlib/src/**/*.kt",
        "android/brownfieldlib/build.gradle.kts",
      ],
    },
  },
});

The fingerprint skips the ios/ and android/ trees by default. extraSources adds the framework and library module files you write in the steps below, and the Xcode project file that defines the framework target's build settings.

Connect the iOS framework

Choose the bundle in the framework

The framework's public interface file, which re-exports ReactBrownfield, starts React Native. Before it starts, a HotUpdaterInstance checks for an update, and React Native Brownfield's bundleURLOverride asks it for the bundle:

ios/BrownfieldLib/BrownfieldLib.swift
import Foundation

@_exported import ReactBrownfield
internal import HotUpdater

public let ReactNativeBundle = Bundle(for: InternalClassForBundle.self)

class InternalClassForBundle {}

// The counterpart of `HotUpdater.init` in your JavaScript.
private let hotUpdater = try! HotUpdaterInstance( // A valid baseURL never throws.
  HotUpdaterOptions(
    baseURL: "https://updates.example.com/hot-updater",
    requestHeaders: [:],
    bundle: ReactNativeBundle
  )
)

/// What `startReactNativeWithHotUpdater` found before React Native started.
public enum ReactNativeUpdateStatus: String, Sendable {
  case upToDate, ready, pending, failed, skipped
}

@MainActor private var reactNativeStart: Task<ReactNativeUpdateStatus, Never>?

/// Checks for an update, then starts React Native with Hot Updater choosing
/// the JavaScript bundle. A required update that downloads within `timeout`
/// runs in this start. React Native starts once per process: later calls
/// return the first call's status.
@MainActor
public func startReactNativeWithHotUpdater(
  checkForUpdates: Bool,
  timeout: TimeInterval = 3
) async -> ReactNativeUpdateStatus {
  if let reactNativeStart { return await reactNativeStart.value }
  let start = Task { @MainActor in
    let status = checkForUpdates ? await checkForUpdate(hotUpdater, timeout: timeout) : .skipped
    ReactNativeBrownfield.shared.bundle = ReactNativeBundle
    ReactNativeBrownfield.shared.bundleURLOverride = {
      #if DEBUG
      return nil // Metro
      #else
      return hotUpdater.bundleURL()
      #endif
    }
    ReactNativeBrownfield.shared.startReactNative()
    return status
  }
  reactNativeStart = start
  return await start.value
}

/// Waits up to `timeout` seconds for the check and any required update.
/// A download already started continues after the timeout.
private func checkForUpdate(
  _ hotUpdater: HotUpdaterInstance,
  timeout: TimeInterval
) async -> ReactNativeUpdateStatus {
  await withTaskGroup(of: ReactNativeUpdateStatus.self) { group in
    group.addTask {
      do {
        let options = HotUpdaterCheckOptions(updateStrategy: .fingerprint)
        guard let update = try await hotUpdater.checkForUpdate(options) else {
          return .upToDate
        }
        // A newer Release of the bundle this start runs: nothing to download.
        if update.transitionKind == "ADOPT_RELEASE" {
          return try await update.updateBundle() ? .upToDate : .failed
        }
        guard update.shouldForceUpdate else {
          Task { _ = try? await update.updateBundle() } // Downloads in the background.
          return .pending
        }
        return try await update.updateBundle() ? .ready : .failed
      } catch is CancellationError {
        return .pending
      } catch {
        return .failed
      }
    }
    group.addTask {
      try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
      return .pending
    }
    let status = await group.next() ?? .failed
    group.cancelAll()
    return status
  }
}
  • HotUpdaterInstance mirrors the instance HotUpdater.init returns in your JavaScript. HotUpdaterOptions takes the update server URL and request headers, such as a client API key header, and HotUpdaterCheckOptions takes the update strategy, as checkForUpdate does. bundle: ReactNativeBundle makes Hot Updater read its HOT_UPDATER_* keys and embedded files from the framework, not the native app.
  • Unlike JavaScript, which returns null, checkForUpdate throws when the check fails. The failure still reaches your plugins as onUpdateError.
  • A required update runs in this start only if updateBundle() finishes before React Native reads the bundle. The task group stops waiting at timeout. A download already started continues; a check still in progress is cancelled and may not have started a download. How updates apply explains each status.
  • hotUpdater.bundleURL() returns the downloaded update, or main.jsbundle embedded in the framework. Hot Updater reuses the bundle when it rolls back.
  • checkForUpdates comes from the native app: the framework is built separately, so its own DEBUG says nothing about the app's build.

Keep React Native Brownfield's default of loading the bundle with the first React Native screen. With preloadBundle: true, a new update loads while the app starts, and closing the app before any React Native screen appears counts as an unfinished launch, which rolls the update back.

internal import keeps HotUpdater out of the framework's public Swift interface, so the native app needs no HotUpdater module to build. A plain import HotUpdater makes the native app fail with "no such module 'HotUpdater'". For the same reason the function returns the framework's own ReactNativeUpdateStatus, not a Hot Updater type.

Start React Native from the iOS app

Call the framework's function instead of ReactNativeBrownfield.shared.startReactNative, and show React Native screens once it returns. It sets every option React Native Brownfield reads, so set nothing on ReactNativeBrownfield.shared after the call. When your first screen is native, call it when the user first opens a React Native screen, as the SwiftUI example does; later calls return at once.

NativeApp/NativeApp.swift
import SwiftUI
import BrownfieldLib

#if DEBUG
private let checkForUpdates = false
#else
private let checkForUpdates = true
#endif

@main
struct NativeApp: App {
  var body: some Scene {
    WindowGroup {
      HomeView()
    }
  }
}

struct HomeView: View {
  @State private var path: [String] = []
  @State private var starting = false

  var body: some View {
    NavigationStack(path: $path) {
      Button {
        Task {
          // The first tap starts React Native; later taps open it at once.
          starting = true
          _ = await startReactNativeWithHotUpdater(checkForUpdates: checkForUpdates)
          starting = false
          path.append("BrownfieldRN")
        }
      } label: {
        if starting { ProgressView() } else { Text("Open React Native") }
      }
      .disabled(starting)
      .navigationDestination(for: String.self) { moduleName in
        ReactNativeView(moduleName: moduleName)
      }
    }
  }
}

Connect the Android library

Choose the bundle in the host manager

The library module's ReactNativeHostManager creates the React host. Before it does, a HotUpdaterInstance checks for an update. Pass its bundle path as jsBundleFilePath, then hand the host to Hot Updater:

android/brownfieldlib/src/main/java/com/brownfieldrn/ReactNativeHostManager.kt
package com.brownfieldrn

import android.app.Application
import com.callstack.reactnativebrownfield.ReactNativeBrownfield
import com.facebook.react.PackageList
import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
import com.hotupdater.HotUpdater 
import com.hotupdater.HotUpdaterCheckOptions
import com.hotupdater.HotUpdaterInstance
import com.hotupdater.HotUpdaterOptions
import com.hotupdater.HotUpdaterUpdateStrategy
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull

object ReactNativeHostManager {
    // Outlives every activity, so closing a screen never cancels the start.
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
    private var status: String? = null
    private val waiting = mutableListOf<(String) -> Unit>()

    /**
     * Checks for an update, then starts React Native, once per process. A
     * required update that downloads within [timeoutMillis] runs in this
     * start. Call it on the main thread. [onReady] runs on the main thread
     * with what was found; later calls get the first call's status.
     */
    fun start(
        application: Application,
        checkForUpdates: Boolean,
        timeoutMillis: Long = 3_000,
        onReady: (String) -> Unit = {},
    ) {
        status?.let { return onReady(it) } 
        waiting += onReady
        if (waiting.size > 1) return // Already starting.
        // The counterpart of `HotUpdater.init` in your JavaScript.
        val hotUpdater =
            HotUpdater.init(
                application,
                HotUpdaterOptions(
                    baseURL = "https://updates.example.com/hot-updater",
                    requestHeaders = emptyMap(),
                ),
            )
        scope.launch {
            val result = if (checkForUpdates) checkForUpdate(hotUpdater, timeoutMillis) else "skipped"
            ReactNativeBrownfield.initialize(application) {
                getDefaultReactHost(
                    context = application,
                    packageList = PackageList(application).packages,
                    jsMainModulePath = "index",
                    jsBundleAssetPath = "index.android.bundle",
                    jsBundleFilePath = if (BuildConfig.DEBUG) null else hotUpdater.getJSBundleFile(), 
                    useDevSupport = BuildConfig.DEBUG,
                    jsRuntimeFactory = null,
                )
            }
            HotUpdater.setReactHost(ReactNativeBrownfield.shared.reactHost) 
            status = result 
            waiting.toList().forEach { it(result) }
            waiting.clear()
        }
    }

    // Waits up to [timeoutMillis] for the check and any required update.
    // A download already started continues after the timeout.
    internal suspend fun checkForUpdate(
        hotUpdater: HotUpdaterInstance,
        timeoutMillis: Long,
    ): String =
        withTimeoutOrNull(timeoutMillis) {
            try {
                val options = HotUpdaterCheckOptions(updateStrategy = HotUpdaterUpdateStrategy.FINGERPRINT)
                val update = hotUpdater.checkForUpdate(options) ?: return@withTimeoutOrNull "upToDate"
                when {
                    // A newer Release of the bundle this start runs: nothing to download.
                    update.transitionKind == "ADOPT_RELEASE" -> if (update.updateBundle()) "upToDate" else "failed"
                    // Downloads in the background.
                    !update.shouldForceUpdate -> "pending".also { scope.launch { runCatching { update.updateBundle() } } }
                    update.updateBundle() -> "ready"
                    else -> "failed"
                }
            } catch (error: CancellationException) {
                throw error
            } catch (error: Exception) {
                "failed"
            }
        } ?: "pending"
}

The native update check uses coroutines in the library module:

android/brownfieldlib/build.gradle.kts
dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") 
}
  • HotUpdater.init returns a HotUpdaterInstance, which mirrors the instance HotUpdater.init returns in your JavaScript. HotUpdaterOptions takes the update server URL and request headers, and HotUpdaterCheckOptions takes the update strategy, as checkForUpdate does.
  • Unlike JavaScript, which returns null, checkForUpdate throws when the check fails. The failure still reaches your plugins as onUpdateError.
  • A required update runs in this start only if updateBundle() finishes before React Native reads the bundle. withTimeoutOrNull stops waiting at timeoutMillis. A download already started continues; a check still in progress is cancelled and may not have started a download. How updates apply explains each status.
  • The start runs in the module's own scope, not an activity's lifecycleScope: React Native must start even if the first screen closes. ReactNativeBrownfield.initialize evaluates the bundle at once, so it runs after the check.
  • hotUpdater.getJSBundleFile() returns the downloaded update, or assets://index.android.bundle embedded in the AAR. A native app has no ReactApplication, so HotUpdater.setReactHost gives Hot Updater the host it runs.
  • checkForUpdates comes from the native app: the AAR is built separately, so its own BuildConfig.DEBUG says nothing about the app's build.

ReactNativeBrownfield.initialize loads React Native's native libraries. Do not call loadReactNative(application) before it: a second load crashes with "Feature flags cannot be overridden more than once".

Give the library manifest an application element

The CLI writes Hot Updater's values as <meta-data> of an <application> element. Android merges the library's manifest into the native app, so add one to the library:

android/brownfieldlib/src/main/AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application /> 
</manifest>

Start React Native from the Android app

When your first screen is native, start React Native when the user first opens a React Native screen. Later calls return at once:

app/src/main/java/com/example/nativeapp/MainActivity.kt
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.Lifecycle
import com.brownfieldrn.ReactNativeHostManager

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val openReactNative = Button(this).apply { text = "Open React Native" }
        setContentView(openReactNative)
        openReactNative.setOnClickListener {
            // The first tap starts React Native; later taps open it at once.
            openReactNative.isEnabled = false
            ReactNativeHostManager.start(application, checkForUpdates = !BuildConfig.DEBUG) {
                openReactNative.isEnabled = true
                if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
                    startActivity(Intent(this, ReactNativeActivity::class.java))
                }
            }
        }
    }
}

ReactNativeActivity is your activity that shows the React Native screen.

Show the screens with ReactNativeFragment.createReactNativeFragment("BrownfieldRN") or ReactNativeBrownfield.shared.createView(...), as in the React Native Brownfield guide, after onReady runs.

Embed the native values

Run the native configuration commands in the React Native project, before you package. By default channel set updates every Info.plist under ios/ and every AndroidManifest.xml under android/, which includes the framework target and the library module. With the fingerprint strategy, fingerprint create writes fingerprint.json, and packaging in Release embeds the fingerprint in the framework and the library:

npx hot-updater channel set production
# Fingerprint strategy only:
npx hot-updater fingerprint create

If you use bundle signing, export the public key the same way:

npx hot-updater keys export-public

Check that ios/BrownfieldLib/Info.plist has HOT_UPDATER_CHANNEL and that the library manifest has com.hotupdater.CHANNEL. To limit the commands to the artifacts, list the files in your config:

hot-updater.config.ts
export default defineConfig({
  // ...
  platform: {
    ios: { infoPlistPaths: ["ios/BrownfieldLib/Info.plist"] },
    android: {
      androidManifestPaths: ["android/brownfieldlib/src/main/AndroidManifest.xml"],
    },
  },
});

Package and ship the artifacts

Package the artifacts in Release after setting the native values above. For the Brownfield CLI, select Release in your brownfield configuration. Run the commands from the React Native project:

npx brownfield package:ios
npx brownfield package:android
npx brownfield publish:android

Add the XCFrameworks and the AAR to the native app as the React Native Brownfield guide describes, then build the native app in Release for OTA verification. In the main runtime examples, the artifact's build configuration selects Metro or Hot Updater's bundle; the native app's checkForUpdates argument controls only the update check. A Release artifact in a Debug native app still loads its embedded or downloaded bundle.

Package again whenever the React Native project's native code or dependencies change. With Fingerprint, each packaged artifact carries the fingerprint of the native interface it was built from. Run fingerprint create before packaging so deploy targets that fingerprint. A new artifact needs a new native app release.

Deploy an update

Deploy from the React Native project as usual. Pass the channel you embedded:

npx hot-updater deploy -p ios -c production
npx hot-updater deploy -p android -c production

See Deploy.

How updates apply

The start code above checks the update server before React Native starts, with the same checkForUpdate and updateBundle as JavaScript, and returns one of these statuses:

StatusMeaning
upToDateThe bundle this start runs is the latest. It may have been downloaded by an earlier start.
readyA required update downloaded before React Native read the bundle, so this start runs it.
pendingAn update downloads in the background, or the check or required download exceeded the timeout. A timed-out check may not have started a download.
failedThe check or the required download failed. React Native starts with the current bundle.
skippedThe native app passed checkForUpdates: false. React Native starts without checking for an update.
  • Required updates (shouldForceUpdate) share the timeout with the check. The example starts React Native when that wait ends. A download already started continues, and calling updateBundle again on the same update joins it. If the check itself times out, its request is cancelled.
  • Other updates download in the background. They run at the next start, or at this one if they finish before React Native reads the bundle.
  • Rollbacks deployed from the console apply the same way as updates.
  • An update applies at this start only when updateBundle finishes before bundleURL() or getJSBundleFile() is read. One that finishes later applies at the next start.

To block React Native until a required update is ready, change the helper inside the framework or library to wait or retry before initializing React Native, with a limit and a UI for failure or timeout. Calling the public start function again only returns the first call's status; it does not check again.

The check request itself is small, but every device that finds a new update downloads it. If React Native is not your native app's first screen, start it when the user first heads to a React Native screen, not at every launch, so users who never open one download nothing, as the SwiftUI and Android "Native first screen" examples do. checkForUpdates is also where the native app can turn the check off, for example from its own remote flag.

React Native runs for the life of the native app's process. Do not call hotUpdater.reload() in a native app: on Android it restarts the whole native app, which opens again at its first native screen.

Keep HotUpdater.init in your JavaScript for its plugins, such as Insights, but do not call checkForUpdate or wrap's update check there: the native code already checked. The next HotUpdater.init reports the native update checks, downloads, and errors to its plugins, each with the time it happened as occurredAt.

The channel comes from the framework's Info.plist, the library's manifest, or channel in HotUpdaterOptions. Keep it the same from launch to launch: changing it deletes the downloaded bundles. To move a device to another channel, pass channel in HotUpdaterCheckOptions; the switch is saved once that channel's update downloads, and hotUpdater.resetChannel() switches back.

Automatic rollback works as in any app: a bundle that crashes before its first React Native screen appears rolls back to the previous bundle, or to the one embedded in the artifact.

Verify

Package the artifact before you deploy, and do not package again until you have tested the update. Each package embeds a bundle with a new ID from the time it was built, and the update check skips updates deployed before that time. Rebuilding only the native app is fine. In these steps, restarting means fully terminating the native app's process and opening it again. Returning from the background or reopening a React Native screen does not run another check.

  1. Show the native values in a React Native screen:

    <Text>Channel: {hotUpdater.getChannel()}</Text>
    <Text>Fingerprint: {String(hotUpdater.getFingerprintHash())}</Text>
    <Text>Bundle ID: {hotUpdater.getBundleId()}</Text>

    Show the status the framework or library returned in a native screen. For this test, log when the native helper's update.updateBundle() returns true, including inside its background download task. Use that log to confirm completion before terminating the process; pending is not a completion notification.

  2. Use Release artifacts, build the native app in Release, and open a React Native screen. A successful check returns upToDate, and the screen shows the channel and, with Fingerprint, the hash you embedded.

  3. Change a screen and deploy it as required with --force-update. Restart the process and open the React Native screen. If the check and download finish within the timeout, the status is ready, the bundle ID changes, and the screen shows your change. pending is also expected when the timeout expires: if a download started, wait for its completion log and restart. If the check timed out before a download started, pass a longer timeout to the start function for this test and restart the process.

  4. Change the screen again and deploy without --force-update. Restart the process and open the React Native screen. Once the check succeeds, it starts a background download and returns pending. Wait for the completion log, then restart the process and open the screen again. Confirm the new bundle ID and your screen change. A successful check now returns upToDate.

Run more than one React Native app

Some native apps host more than one React Native app, each in its own React Native runtime with its own JavaScript bundle, such as a main app and a widget that ships on its own schedule. Give each one its own HotUpdaterInstance with a name, and Hot Updater updates each one on its own. When one bundle shows all your React Native screens, you don't need this: one runtime can register several components.

The steps below add a second app, widget, from the entry file index.widget.js. The example app runs this setup.

Name each instance

name in HotUpdaterOptions keeps an instance's downloads, channel, stored values, and crash history apart from every other name's. It is 1 to 32 of a-z, 0-9, and -; any other name throws. The default is main, the instance the steps above create. Each named instance needs:

  • Its own embedded bundle. Hot Updater finds a runtime's instance by the bundle it runs, so a named instance can't share main's:

    • iOS: a resource bundle in the framework, such as Widget.bundle, that holds the main.jsbundle built from index.widget.js and an Info.plist. Pass it as bundle. Bundle.main throws.
    • Android: an asset of the library module with a name of its own, such as widget.android.bundle, built from index.widget.js. Pass it as bundleAssetName. index.android.bundle throws.

    The example app builds them in the Bundle widget React Native code phase of its Xcode project and in its library module's build.gradle.kts.

  • Its own channel, as channel in its HotUpdaterOptions. Without it, an Android instance reads the merged manifest, which holds main's channel, and channel set writes one channel to every Info.plist it updates.

  • The app's fingerprint and public key. Both bundles come from one React Native project, so deploy computes the same fingerprint for both, and every instance uses the fingerprint packaging embedded unless its options pass fingerprintHash. A named instance reads publicKey from its options, then, on iOS, from its own bundle's Info.plist only, never the framework's or the app's: keys export-public must update that file too, so list it in infoPlistPaths if you set them. On Android it reads the merged manifest, which already has it. Pass another publicKey only if you sign that channel's deploys with another key.

Start each runtime

Create the named instance, check it for an update with the start code's checkForUpdate, then start its runtime with its bundle. React Native Brownfield runs one runtime, so the second one starts without it. Start it once main's bundle has loaded: after main's first React Native screen appears, or from React Native Brownfield's onBundleLoaded callback (OnJSBundleLoaded on Android).

ios/BrownfieldLib/BrownfieldLib.swift (excerpt)
import UIKit
internal import React
internal import React_RCTAppDelegate
internal import ReactAppDependencyProvider

private let widget = try! HotUpdaterInstance(
  HotUpdaterOptions(
    baseURL: "https://updates.example.com/hot-updater",
    requestHeaders: [:],
    bundle: Bundle(url: ReactNativeBundle.url(forResource: "Widget", withExtension: "bundle")!)!,
    name: "widget",
    channel: "widget"
  )
)

/// Starts the widget's runtime with the bundle its instance picks.
private final class WidgetDelegate: RCTDefaultReactNativeFactoryDelegate {
  override func sourceURL(for bridge: RCTBridge) -> URL? { bundleURL() }
  override func bundleURL() -> URL? { widget.bundleURL() }
}

@MainActor private var widgetRuntime: (delegate: WidgetDelegate, factory: RCTReactNativeFactory)?

/// The widget's React Native view. Call it after main's bundle has loaded.
/// The first call checks for the widget's update, then starts its runtime.
@MainActor
public func makeWidgetView(checkForUpdates: Bool, timeout: TimeInterval = 3) async -> UIView {
  if widgetRuntime == nil, checkForUpdates {
    _ = await checkForUpdate(widget, timeout: timeout)
  }
  if widgetRuntime == nil { // Another call may have started it meanwhile.
    let delegate = WidgetDelegate()
    delegate.dependencyProvider = RCTAppDependencyProvider()
    widgetRuntime = (delegate, RCTReactNativeFactory(delegate: delegate))
  }
  return widgetRuntime!.factory.rootViewFactory.view(
    withModuleName: "Widget",
    initialProperties: nil,
    launchOptions: nil
  )
}
  • checkForUpdate is the start code's helper, which takes the instance to check.
  • The widget's runtime always loads the bundle its instance picks, never Metro, so embed its bundle in every build that shows it, Debug included. A runtime served by Metro works with main's state.

Keep JavaScript the same

index.widget.js registers the component the native code shows, Widget above, and calls HotUpdater.init as usual, with no name. Each runtime has its own Hot Updater native module, which finds its instance from the bundle the runtime runs. So getChannel(), getBundleId(), and the native checks reported to that runtime's plugins are the widget's.

Deploy each app to its channel

Each instance checks its own channel, so deploy each bundle to its channel. The CLI has no entry file option: choose the build's entry file from the channel in a config function.

hot-updater.config.ts (excerpt)
// options is { platform, channel }, or null for a command without a platform.
export default defineConfig((options) => ({
  build: bare({
    enableHermes: true,
    entryFile: options?.channel === "widget" ? "index.widget.js" : "index.js",
  }),
  storage, // created outside the function
  database,
  // ...
}));
npx hot-updater deploy -p ios -c widget
npx hot-updater deploy -p android -c widget

Create storage and database once, outside the function, as Deploy both platforms explains.

What each instance keeps apart

  • Downloads and state. Each name has its own downloaded bundles, channel, stored values, and update checks. A channel reset or an update of one leaves the others alone.
  • Crash recovery. A launch of an instance starts when one of its views shows in the foreground, and is verified by its own first content. A runtime started without showing a view is never charged.
  • Crashes and rollbacks. A JavaScript error that crashes one runtime is charged only to its instance: that runtime stops, the app may end (on iOS it can keep running the others), and only that instance rolls back, at its next start. A native crash is charged to each instance whose launch has not shown content yet. One instance's crash never rolls back another.

Limits

  • hotUpdater.reload() and reloadProcess() reject with RELOAD_UNSUPPORTED in a named runtime. Its updates apply the next time its runtime starts, which for a runtime kept for the whole process is the next app launch.
  • Instances with the same name share their state. Creating a second one that resolves to other values throws, on iOS HotUpdater instance "widget" was already created with another bundle, channel, fingerprintHash, or publicKey, and on Android ... another channel, fingerprintHash, publicKey, or bundleAssetName. Create each instance once, as the code above does.
  • A name and its embedded bundle ship in the artifact. Adding one takes a new artifact and a native app release.

Troubleshooting

SymptomCause and fix
With Fingerprint, the channel is production and the fingerprint is nullThe artifact has no Hot Updater values. Run channel set and fingerprint create, check the channel in the framework's Info.plist and the library manifest, and package again. The fingerprint is embedded when packaging finds fingerprint.json in the React Native project; set HOT_UPDATER_APP_ROOT if its native folders are elsewhere. On Android, check that the library manifest has an <application> element.
The native app fails with "no such module 'HotUpdater'"The framework imports HotUpdater publicly. Use internal import HotUpdater.
The status is always skippedThe native app passes checkForUpdates: false, as its Debug build does. Build it in Release.
The status is failedCheck baseURL and the request headers in HotUpdaterOptions, and that the device reaches the update server.
A required update is pending instead of readyThe check or download exceeded the timeout. The download may not have started. Raise the timeout for the next process start, or wait for a started download to finish and restart. Recalling the start function does not check again.
The update downloads but the screen does not changeIn the main runtime examples, a Debug artifact loads Metro. Package the artifact in Release. With a Release artifact, a download that finishes after bundle selection applies at the next process start.
The native app restarts at its first screen on AndroidSomething calls hotUpdater.reload(). Remove it; the update applies at the next launch.
Downloaded updates disappearThe channel changed between launches, which deletes the downloaded bundles. Keep channel in HotUpdaterOptions or the embedded channel the same, and switch channels with HotUpdaterCheckOptions. A native app release with a new artifact also starts from the bundle it embeds.
HotUpdater instance "main" was already created with another channel (or fingerprintHash, publicKey)The instance was created after React Native read the bundle, or a second one passes a different value. Create it once, before React Native starts.
"A named HotUpdater instance needs its own bundle" (or bundleAssetName)A named instance passes Bundle.main on iOS or index.android.bundle on Android. Give it its own embedded bundle, as in Name each instance.
hotUpdater.reload() rejects with RELOAD_UNSUPPORTEDIt runs in a named instance's runtime, which can't reload. The update applies the next time that runtime starts.
"Feature flags cannot be overridden more than once" on AndroidloadReactNative ran before ReactNativeBrownfield.initialize. Remove it, and pass the React host as a factory, ReactNativeBrownfield.initialize(application) { getDefaultReactHost(...) }, as the host manager above does. A host built before initialize fails with "SoLoader.init() not yet called".
The AAR keeps showing JavaScript from an earlier packageThe library module reused an earlier bundle. Delete android/brownfieldlib/build and package again.
Packaging the AAR fails with "No namespace found in .../build.gradle"React Native Brownfield reads each native module's namespace from the module's build.gradle. A module that declares its package only in AndroidManifest.xml cannot be packaged; ask its maintainer to add a namespace, or replace the module.
The native app fails with "resource attr/... not found" from a libraryWhen several native modules ship res/values resources, React Native Brownfield keeps only one module's in the AAR, so attributes and styles another module references go missing. Remove that library from the React Native project, or declare the missing resources in the native app.
Deploy reports a fingerprint mismatchThe native code changed after fingerprint create. Create the fingerprint again, package, and ship a new native app release.

Native API

Each HotUpdaterInstance updates one React Native bundle. Create it with the same server and headers as that bundle's HotUpdater.init: the first HotUpdater.init after the next launch reports the native checks to its plugins.

Read the URL and the client key for HotUpdaterOptions and HotUpdater.init from the same build input, as in Supply values to native and OTA builds. When they change, package the artifact again: an OTA update changes only the JavaScript.

PlatformAPIUse
iOSHotUpdaterInstance(HotUpdaterOptions(baseURL:requestHeaders:requestTimeout:bundle:name:channel:fingerprintHash:publicKey:))The counterpart of JavaScript's HotUpdater.init; bundle holds the embedded main.jsbundle and the HOT_UPDATER_* keys; channel and publicKey replace those keys' values, and fingerprintHash the fingerprint packaging embedded. name picks the instance, main by default: see Run more than one React Native app. Create it before React Native starts
iOShotUpdater.checkForUpdate(HotUpdaterCheckOptions(updateStrategy:channel:requestHeaders:requestTimeout:)), update.updateBundle(onProgress:)Checks and downloads, as JavaScript's checkForUpdate and updateBundle do
iOShotUpdater.bundleURL()Returns the bundle React Native starts with
iOShotUpdater.resetChannel(), getChannel(), getBundleId(), getFingerprintHash()As JavaScript's methods of the same names
AndroidHotUpdater.init(context, HotUpdaterOptions(baseURL, requestHeaders, requestTimeoutMillis))The counterpart of JavaScript's HotUpdater.init; returns a HotUpdaterInstance. The named arguments channel and publicKey replace the manifest's values, and fingerprintHash the fingerprint packaging embedded. name picks the instance, main by default, and a named instance's bundleAssetName is its embedded bundle: see Run more than one React Native app. Call it before React Native starts
AndroidhotUpdater.checkForUpdate(HotUpdaterCheckOptions(updateStrategy, channel, requestHeaders, requestTimeoutMillis)), update.updateBundle(onProgress)Checks and downloads, as JavaScript's checkForUpdate and updateBundle do
AndroidhotUpdater.getJSBundleFile()Returns the bundle path React Native starts with
AndroidhotUpdater.resetChannel(), getChannel(), getBundleId(), getFingerprintHash()As JavaScript's methods of the same names
AndroidHotUpdater.setReactHost(reactHost)Gives Hot Updater a host that no ReactApplication owns

On this page