@otaupdate/react-native 1.0.3 → 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
|
@@ -22,6 +22,26 @@ rollback-on-failure; JS handles the update check and the app-facing API.
|
|
|
22
22
|
npm install @otaupdate/react-native
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
> **Bare React Native on Android — pin your version to match your RN version.**
|
|
26
|
+
> `npm install` with no version pin gets you the latest (currently 1.0.5).
|
|
27
|
+
> That version relies on an internal React Native API whose shape changed
|
|
28
|
+
> between RN releases — it requires **RN 0.80+**. If your app is on an
|
|
29
|
+
> **older RN version (0.7x)**, pin `@otaupdate/react-native@1.0.4` instead:
|
|
30
|
+
>
|
|
31
|
+
> ```bash
|
|
32
|
+
> npm install @otaupdate/react-native@1.0.4 # RN 0.7x
|
|
33
|
+
> npm install @otaupdate/react-native@1.0.5 # RN 0.80+ (or just @otaupdate/react-native)
|
|
34
|
+
> ```
|
|
35
|
+
>
|
|
36
|
+
> Both versions are fully functional and behave identically from the JS API
|
|
37
|
+
> you call (`sync`, `checkForUpdate`, `withOtaUpdate`, etc. — none of that
|
|
38
|
+
> changes). The only difference is *how* Android applies a mandatory/resume
|
|
39
|
+
> install: 1.0.4 restarts the process (a brief visible flash during the
|
|
40
|
+
> switch); 1.0.5 swaps the bundle in place with no restart and no flash, the
|
|
41
|
+
> same way Expo's own update mechanism does, but that technique only exists
|
|
42
|
+
> on newer RN. Expo and iOS are unaffected by any of this — both already
|
|
43
|
+
> update seamlessly regardless of which SDK version you're on.
|
|
44
|
+
|
|
25
45
|
### Bare React Native
|
|
26
46
|
|
|
27
47
|
**iOS**
|
|
@@ -92,6 +112,34 @@ And the configuration in `android/app/src/main/res/values/strings.xml`:
|
|
|
92
112
|
<string name="ota_server_url" translatable="false">https://ota.example.com</string>
|
|
93
113
|
```
|
|
94
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
|
+
|
|
95
143
|
### Expo
|
|
96
144
|
|
|
97
145
|
Add the config plugin to `app.json`. There is nothing to patch by hand — the
|
|
@@ -158,6 +206,21 @@ SHA-256 verify → bundle swap on restart, `IMMEDIATE` in-process reload, and
|
|
|
158
206
|
rollback rescuing a device already on the bad build. Confirmed by screenshot,
|
|
159
207
|
native log, and server-side install reports.
|
|
160
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
|
+
|
|
161
224
|
**iOS — verified end to end.** On an Expo SDK 57 / RN 0.86 Release build
|
|
162
225
|
(Xcode 26, iPhone 17 Pro simulator, iOS 26.5): publish → download → SHA-256
|
|
163
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
|
-
*
|
|
224
|
+
* Applies the new bundle.
|
|
225
225
|
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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 place — no 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
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
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)
|