@otaupdate/react-native 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -112,6 +112,34 @@ And the configuration in `android/app/src/main/res/values/strings.xml`:
112
112
  <string name="ota_server_url" translatable="false">https://ota.example.com</string>
113
113
  ```
114
114
 
115
+ **Android — newer (bridgeless) template.** Some RN versions generate a
116
+ `MainApplication.kt` with no `ReactNativeHost`/`getJSBundleFile()` at all —
117
+ it builds `reactHost` directly via `getDefaultReactHost(...)`. If yours looks
118
+ like this, use `OtaUpdate.createReactHost(...)` in its place instead of the
119
+ override above (requires `@otaupdate/react-native@1.0.5`+ — see the version
120
+ note under [Install](#install)):
121
+
122
+ ```kotlin
123
+ import com.otaupdate.OtaUpdate
124
+
125
+ override val reactHost: ReactHost by lazy {
126
+ OtaUpdate.createReactHost(
127
+ context = applicationContext,
128
+ packageList =
129
+ PackageList(this).packages.apply {
130
+ // add(MyReactNativePackage())
131
+ },
132
+ )
133
+ }
134
+ ```
135
+
136
+ This isn't just a drop-in replacement for wiring convenience — it's what
137
+ makes mandatory/resume installs apply with **no restart and no visible
138
+ flash**, the same way Expo's own update mechanism works. The plain
139
+ `getDefaultReactHost(...)` call only supports the safer-but-visible
140
+ process-restart behavior (see [Platform verification status](#platform-verification-status)).
141
+ The `strings.xml` configuration above is unchanged either way.
142
+
115
143
  ### Expo
116
144
 
117
145
  Add the config plugin to `app.json`. There is nothing to patch by hand — the
@@ -178,6 +206,21 @@ SHA-256 verify → bundle swap on restart, `IMMEDIATE` in-process reload, and
178
206
  rollback rescuing a device already on the bad build. Confirmed by screenshot,
179
207
  native log, and server-side install reports.
180
208
 
209
+ **Bare RN Android specifically** applies a mandatory/resume install one of
210
+ two ways, depending on how `MainApplication.kt` is wired (see the Android
211
+ install steps above). `getDefaultReactHost(...)`
212
+ (`@otaupdate/react-native@1.0.4`, RN 0.7x, or `1.0.5`+ without the
213
+ `createReactHost` change) restarts the process — verified against a real
214
+ bare RN 0.87 build on an emulator, logcat-traced end to end: publish →
215
+ download → SHA-256 verify → install → confirmed healthy, including catching
216
+ and fixing a real infinite-reload loop and a lost-update race along the way
217
+ (see the Troubleshooting table). `OtaUpdate.createReactHost(...)` (`1.0.5`+,
218
+ RN 0.80+) is designed to swap the bundle in place with no restart — its
219
+ underlying mechanism (`ReactHostImpl` re-reading the delegate's bundle
220
+ loader on every reload) was confirmed by decompiling the actual compiled RN
221
+ runtime, but the end-to-end in-place-swap path itself has not yet been
222
+ run on a real device. Treat it as promising but unverified until confirmed.
223
+
181
224
  **iOS — verified end to end.** On an Expo SDK 57 / RN 0.86 Release build
182
225
  (Xcode 26, iPhone 17 Pro simulator, iOS 26.5): publish → download → SHA-256
183
226
  verify → bundle swap on restart, `IMMEDIATE` in-process reload, and rollback
@@ -3,6 +3,20 @@ package com.otaupdate
3
3
  import android.content.Context
4
4
  import android.content.pm.PackageManager
5
5
  import android.util.Log
6
+ import com.facebook.react.ReactHost
7
+ import com.facebook.react.ReactPackage
8
+ import com.facebook.react.ReactPackageTurboModuleManagerDelegate
9
+ import com.facebook.react.bridge.JSBundleLoader
10
+ import com.facebook.react.common.annotations.UnstableReactNativeAPI
11
+ import com.facebook.react.common.build.ReactBuildConfig
12
+ import com.facebook.react.defaults.DefaultComponentsRegistry
13
+ import com.facebook.react.defaults.DefaultTurboModuleManagerDelegate
14
+ import com.facebook.react.fabric.ComponentFactory
15
+ import com.facebook.react.runtime.BindingsInstaller
16
+ import com.facebook.react.runtime.JSRuntimeFactory
17
+ import com.facebook.react.runtime.ReactHostDelegate
18
+ import com.facebook.react.runtime.ReactHostImpl
19
+ import com.facebook.react.runtime.hermes.HermesInstance
6
20
 
7
21
  /**
8
22
  * Entry point used by the host app's `MainApplication`.
@@ -14,6 +28,16 @@ import android.util.Log
14
28
  * That single override is what makes updates take effect: React Native asks
15
29
  * for the bundle path at start-up, and we hand back the most recent healthy
16
30
  * downloaded bundle (or null, meaning "use the one in the APK").
31
+ *
32
+ * Newer (bridgeless / New Architecture) templates have no `ReactNativeHost` to
33
+ * override at all — `MainApplication` builds a `ReactHost` directly. For that
34
+ * shape, use `OtaUpdate.createReactHost(...)` in place of the RN template's
35
+ * `getDefaultReactHost(...)` call — see its doc comment for why this exists.
36
+ *
37
+ * This version of the SDK targets RN 0.80+'s `ReactHostDelegate` shape
38
+ * (confirmed against the compiled runtime — no `getReactNativeConfig()`, no
39
+ * JSC). For RN 0.7x, use `@otaupdate/react-native@1.0.4` instead — see the
40
+ * README's "Install" section.
17
41
  */
18
42
  object OtaUpdate {
19
43
 
@@ -24,6 +48,9 @@ object OtaUpdate {
24
48
  @Volatile private var store: OtaUpdateStore? = null
25
49
  @Volatile private var initialized = false
26
50
 
51
+ /** Set only when the host app used `createReactHost` — see `reload()` in OtaUpdateModule. */
52
+ @Volatile internal var dynamicReactHost: ReactHost? = null
53
+
27
54
  @JvmStatic
28
55
  @Synchronized
29
56
  fun store(context: Context): OtaUpdateStore =
@@ -59,6 +86,74 @@ object OtaUpdate {
59
86
  }
60
87
  }
61
88
 
89
+ /**
90
+ * Builds a `ReactHost` the same way the RN "new app template"'s
91
+ * `getDefaultReactHost(...)` does, but with one difference: the bundle
92
+ * loader it hands to React Native is re-resolved on every access instead of
93
+ * fixed once at construction time.
94
+ *
95
+ * Why this exists: React Native's own `DefaultReactHost.getDefaultReactHost`
96
+ * takes a plain `jsBundleFilePath` string and bakes it into a
97
+ * `DefaultReactHostDelegate` once. `ReactHostImpl` genuinely re-asks its
98
+ * delegate for a bundle loader on every reload — confirmed by decompiling
99
+ * the compiled runtime, not assumed — but that only helps if the delegate
100
+ * itself has something new to say. `DefaultReactHostDelegate` never does,
101
+ * which is *why* `IMMEDIATE`/`ON_NEXT_RESUME` installs never took visible
102
+ * effect without a full process restart (see `reload()` in
103
+ * `OtaUpdateModule`). Expo's own bridgeless host avoids this because its
104
+ * `ReactNativeHostHandler` hook is genuinely re-invoked on each reload,
105
+ * which is the same property this delegate restores.
106
+ *
107
+ * Use in `MainApplication` exactly where the template would call
108
+ * `getDefaultReactHost`:
109
+ *
110
+ * override val reactHost: ReactHost by lazy {
111
+ * OtaUpdate.createReactHost(
112
+ * context = applicationContext,
113
+ * packageList = PackageList(this).packages,
114
+ * )
115
+ * }
116
+ *
117
+ * This constructs `ReactHostImpl` directly (an `@UnstableReactNativeAPI`
118
+ * class) rather than going through the public helper, since the public
119
+ * helper has no seam for a dynamic loader. `reload()` only relies on this
120
+ * when the host was actually built this way (tracked via
121
+ * `dynamicReactHost`); apps that still use `getDefaultReactHost` directly,
122
+ * or the older bridge-mode `DefaultReactNativeHost`, keep the
123
+ * process-restart fallback — this can't reach into either of those.
124
+ *
125
+ * `ReactHostDelegate`'s exact method set is an `@UnstableReactNativeAPI`
126
+ * surface that has already changed shape once (RN 0.7x had a
127
+ * `getReactNativeConfig()` method this interface no longer has as of
128
+ * 0.80) — this build targets the current (0.80+) shape. That's the reason
129
+ * this functionality ships as a separate SDK version (1.0.5+) rather than
130
+ * folding into the one 1.0.4 line that supports both RN ranges.
131
+ */
132
+ @OptIn(UnstableReactNativeAPI::class)
133
+ @JvmStatic
134
+ fun createReactHost(
135
+ context: Context,
136
+ packageList: List<ReactPackage>,
137
+ jsMainModulePath: String = "index",
138
+ bundleAssetName: String = "index",
139
+ useDevSupport: Boolean = ReactBuildConfig.DEBUG,
140
+ ): ReactHost {
141
+ val appContext = context.applicationContext
142
+ val delegate = OtaReactHostDelegate(
143
+ context = appContext,
144
+ jsMainModulePath = jsMainModulePath,
145
+ bundleAssetName = bundleAssetName,
146
+ reactPackages = packageList,
147
+ jsRuntimeFactory = HermesInstance(),
148
+ turboModuleManagerDelegateBuilder = DefaultTurboModuleManagerDelegate.Builder(),
149
+ )
150
+ val componentFactory = ComponentFactory()
151
+ DefaultComponentsRegistry.register(componentFactory)
152
+ val host = ReactHostImpl(appContext, delegate, componentFactory, true, useDevSupport)
153
+ dynamicReactHost = host
154
+ return host
155
+ }
156
+
62
157
  // --- Build-time configuration --------------------------------------------
63
158
 
64
159
  @JvmStatic
@@ -96,3 +191,38 @@ object OtaUpdate {
96
191
  null
97
192
  }
98
193
  }
194
+
195
+ /**
196
+ * The dynamic half of `OtaUpdate.createReactHost` — everything here matches
197
+ * `DefaultReactHostDelegate` except `jsBundleLoader`, which is a computed
198
+ * property instead of a value fixed at construction. `ReactHostImpl` reads
199
+ * this fresh on every reload (confirmed against the compiled runtime), so
200
+ * this is what lets an installed update take effect in place, without
201
+ * restarting the process.
202
+ */
203
+ @OptIn(UnstableReactNativeAPI::class)
204
+ private class OtaReactHostDelegate(
205
+ private val context: Context,
206
+ override val jsMainModulePath: String,
207
+ private val bundleAssetName: String,
208
+ override val reactPackages: List<ReactPackage>,
209
+ override val jsRuntimeFactory: JSRuntimeFactory,
210
+ override val turboModuleManagerDelegateBuilder: ReactPackageTurboModuleManagerDelegate.Builder,
211
+ ) : ReactHostDelegate {
212
+
213
+ override val bindingsInstaller: BindingsInstaller? = null
214
+
215
+ override val jsBundleLoader: JSBundleLoader
216
+ get() {
217
+ val path = OtaUpdate.getJSBundleFile(context)
218
+ return if (path != null) {
219
+ JSBundleLoader.createFileLoader(path)
220
+ } else {
221
+ JSBundleLoader.createAssetLoader(context, "assets://$bundleAssetName", true)
222
+ }
223
+ }
224
+
225
+ override fun handleInstanceException(error: Exception) {
226
+ throw error
227
+ }
228
+ }
@@ -221,27 +221,37 @@ class OtaUpdateModule(private val reactContext: ReactApplicationContext) :
221
221
  }
222
222
 
223
223
  /**
224
- * Restarts the whole process so the new bundle is loaded.
224
+ * Applies the new bundle.
225
225
  *
226
- * This used to try an in-place JS-context reload (ReactHost.reload() via
227
- * reflection on the new architecture, recreateReactContextInBackground() as
228
- * a bridge-mode fallback)but on Android BOTH of those reuse a bundle
229
- * loader/path that was fixed once at ReactInstanceManager/ReactHost
230
- * construction time (see ReactInstanceManager#mBundleLoader, a `final`
231
- * field, and DefaultReactHost's internal singleton caching). Neither ever
232
- * re-consults MainApplication's getJSBundleFile()/jsBundleFilePath, so an
233
- * IMMEDIATE or ON_NEXT_RESUME install silently kept reloading the OLD
234
- * bundle forever — for a mandatory update this produced an infinite
235
- * reload loop, since the freshly-reloaded old bundle immediately saw the
236
- * same "mandatory update available" response and tried to install again.
226
+ * If the host app was wired up via `OtaUpdate.createReactHost` (see that
227
+ * function's doc comment), `dynamicHost.reload()` genuinely swaps the
228
+ * bundle in placeno process restart, no activity transition, no flash
229
+ * because that host's delegate re-resolves the bundle path on every reload
230
+ * instead of reusing one fixed at construction time.
237
231
  *
238
- * A genuine process kill + relaunch is the only reliable way to force
239
- * MainApplication to re-run from scratch and pick up the new bundle path —
240
- * the same technique restart libraries like react-native-restart use.
241
- * iOS does not need this: RCTTriggerReloadCommandListeners re-queries
242
- * bundleURL() fresh on every reload, so it was never affected.
232
+ * Otherwise (an app still on `getDefaultReactHost` directly, or the older
233
+ * bridge-mode `DefaultReactNativeHost`) neither of those delegates ever
234
+ * re-consults MainApplication's bundle path confirmed against the
235
+ * compiled runtime, not assumed so an in-place reload would silently
236
+ * keep loading the OLD bundle forever. For a mandatory update that meant
237
+ * an infinite reinstall loop, since the never-actually-updated old bundle
238
+ * would immediately see the same "mandatory update available" response
239
+ * again. For those apps, a genuine process kill + relaunch remains the
240
+ * only reliable fallback, the same technique restart libraries like
241
+ * react-native-restart use — visible activity-transition animation
242
+ * suppressed via overridePendingTransition where possible, but still a
243
+ * real (if brief) restart.
244
+ *
245
+ * iOS never needed any of this: RCTTriggerReloadCommandListeners re-queries
246
+ * bundleURL() fresh on every reload regardless of how the app is set up.
243
247
  */
244
248
  private fun reload() {
249
+ val dynamicHost = OtaUpdate.dynamicReactHost
250
+ if (dynamicHost != null) {
251
+ dynamicHost.reload("OTA update applied")
252
+ return
253
+ }
254
+
245
255
  val context = reactContext.applicationContext
246
256
  try {
247
257
  val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@otaupdate/react-native",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Over-the-air JS bundle updates for React Native \u2014 bare and Expo",
5
5
  "license": "MIT",
6
6
  "publishConfig": {