@callstack/react-native-brownfield 5.0.2 → 5.1.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @callstack/react-native-brownfield
2
2
 
3
+ ## 5.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#450](https://github.com/callstack/react-native-brownfield/pull/450) [`4846a05`](https://github.com/callstack/react-native-brownfield/commit/4846a055eb06f8c8dea08b33d0bb0d1b2a2f2f86) Thanks [@borisyankov](https://github.com/borisyankov)! - Add `preloadBundle` to `startReactNative` on iOS. Without a preload, `startReactNative` only makes the factory, and React Native loads the JavaScript bundle when it creates the first React Native view. `startReactNative(launchOptions:preloadBundle:onBundleLoaded:)` moves that work to the app launch: the call starts the React Host, and React Native evaluates the bundle on the JavaScript thread, in parallel with the remainder of the app launch. On Android, `ReactNativeBrownfield.initialize` does the same operation. This shape of `startReactNative` also takes the launch options for the React Host.
8
+
9
+ `onBundleLoaded` now runs on the main thread, and not on the JavaScript thread. A callback that you add after React Native loaded the bundle runs in the next turn of the main run loop.
10
+
11
+ ### Patch Changes
12
+
13
+ - [#381](https://github.com/callstack/react-native-brownfield/pull/381) [`b8e8da5`](https://github.com/callstack/react-native-brownfield/commit/b8e8da50fbf20058c5c2745981c7deb5cb33061a) Thanks [@alpharius-ck](https://github.com/alpharius-ck)! - e2e tests for android
14
+ - Updated dependencies []:
15
+ - @callstack/brownfield-cli@5.1.0
16
+
3
17
  ## 5.0.2
4
18
 
5
19
  ### Patch Changes
@@ -65,6 +65,8 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) {
65
65
  if (!initialized.getAndSet(true)) {
66
66
  loadNativeLibs(application)
67
67
  installAndPreload(reactHost, onJSBundleLoaded)
68
+ } else {
69
+ invokeWhenJSBundleLoaded(onJSBundleLoaded)
68
70
  }
69
71
  }
70
72
 
@@ -77,6 +79,8 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) {
77
79
  if (!initialized.getAndSet(true)) {
78
80
  loadNativeLibs(application)
79
81
  installAndPreload(reactHostFactory(), onJSBundleLoaded)
82
+ } else {
83
+ invokeWhenJSBundleLoaded(onJSBundleLoaded)
80
84
  }
81
85
  }
82
86
 
@@ -102,6 +106,8 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) {
102
106
  jsRuntimeFactory = null
103
107
  )
104
108
  installAndPreload(reactHost, onJSBundleLoaded)
109
+ } else {
110
+ invokeWhenJSBundleLoaded(onJSBundleLoaded)
105
111
  }
106
112
  }
107
113
 
@@ -134,6 +140,25 @@ class ReactNativeBrownfield private constructor(val reactHost: ReactHost) {
134
140
  onJSBundleLoaded?.invoke(true)
135
141
  }
136
142
  }
143
+
144
+ private fun invokeWhenJSBundleLoaded(onJSBundleLoaded: OnJSBundleLoaded?) {
145
+ if (onJSBundleLoaded == null || !::instance.isInitialized) {
146
+ return
147
+ }
148
+
149
+ val reactHost = instance.reactHost
150
+ reactHost.currentReactContext?.let {
151
+ onJSBundleLoaded.invoke(true)
152
+ return
153
+ }
154
+
155
+ reactHost.addReactInstanceEventListener(object : ReactInstanceEventListener {
156
+ override fun onReactContextInitialized(context: ReactContext) {
157
+ onJSBundleLoaded.invoke(true)
158
+ reactHost.removeReactInstanceEventListener(this)
159
+ }
160
+ })
161
+ }
137
162
  }
138
163
 
139
164
  /**
@@ -69,6 +69,26 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
69
69
  }
70
70
  }
71
71
 
72
+ override fun onPause() {
73
+ try {
74
+ super.onPause()
75
+ } catch (error: AssertionError) {
76
+ if (
77
+ error.message?.contains(
78
+ "Pausing an activity that is not the current activity"
79
+ ) == true
80
+ ) {
81
+ Log.w(
82
+ "ReactNativeFragment",
83
+ "Ignoring stale onHostPause for ${activity?.javaClass?.simpleName}",
84
+ error
85
+ )
86
+ return
87
+ }
88
+ throw error
89
+ }
90
+ }
91
+
72
92
  override fun onRequestPermissionsResult(
73
93
  requestCode: Int,
74
94
  permissions: Array<String>,
@@ -0,0 +1,30 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ /**
6
+ * Creates and starts the React Host, but does not create a view. React Native loads and evaluates
7
+ * the JavaScript bundle immediately, and not when it mounts the first React Native view.
8
+ * `viewWithModuleName:` makes the same call internally, thus a view that you create later uses the
9
+ * host that is ready.
10
+ *
11
+ * This class is in Objective-C, because Swift cannot make this call:
12
+ * `RCTReactNativeFactory.devMenuConfiguration` is nullable, but the related parameter is not
13
+ * nullable.
14
+ */
15
+ @interface BrownfieldReactHostPreloader : NSObject
16
+
17
+ /**
18
+ * Call this method on the main thread. If a host is already available, this method does nothing.
19
+ *
20
+ * @param reactNativeFactory An `RCTReactNativeFactory`, or a subclass, for example
21
+ * `ExpoReactNativeFactory`. The type is `id`, because this header must stay free of the React
22
+ * Native imports.
23
+ * @param launchOptions The launch options for the React Host.
24
+ */
25
+ + (void)preloadWithReactNativeFactory:(id)reactNativeFactory
26
+ launchOptions:(nullable NSDictionary *)launchOptions;
27
+
28
+ @end
29
+
30
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,113 @@
1
+ #import "BrownfieldReactHostPreloader.h"
2
+
3
+ // This file is plain Objective-C, and not Objective-C++. The umbrella header of
4
+ // React-RCTAppDelegate imports the Hermes and JSI headers if `__cplusplus` is set. This pod does
5
+ // not have the necessary search paths for those headers. Without C++, the compiler ignores those
6
+ // imports. The compiler then builds the module in the same way that Swift imports it.
7
+ //
8
+ // The name of the pod changes if the build makes it a framework. Thus you must try each possible
9
+ // name. Expo uses the same method in `RCTAppDelegateUmbrella.h`.
10
+ #if __has_include(<React_RCTAppDelegate/RCTRootViewFactory.h>)
11
+ #import <React_RCTAppDelegate/RCTReactNativeFactory.h>
12
+ #import <React_RCTAppDelegate/RCTRootViewFactory.h>
13
+ #elif __has_include(<React-RCTAppDelegate/RCTRootViewFactory.h>)
14
+ #import <React-RCTAppDelegate/RCTReactNativeFactory.h>
15
+ #import <React-RCTAppDelegate/RCTRootViewFactory.h>
16
+ #else
17
+ #import <React/RCTReactNativeFactory.h>
18
+ #import <React/RCTRootViewFactory.h>
19
+ #endif
20
+
21
+ NS_ASSUME_NONNULL_BEGIN
22
+
23
+ // `RCTBundleConfiguration` first exists in React Native 0.84. React Native 0.83 does not declare
24
+ // the class. This forward declaration lets the file name the type with both versions.
25
+ @class RCTBundleConfiguration;
26
+
27
+ /**
28
+ * The two shapes of `initializeReactHostWithLaunchOptions:...`. React Native 0.83 has the shape
29
+ * without `bundleConfiguration:`. React Native 0.84 added that parameter and removed the earlier
30
+ * shape. No header tells the two versions apart at compile time, thus this file declares both
31
+ * shapes and `respondsToSelector:` selects one at run time. The declarations are equal to the
32
+ * React Native declarations. The compiler then accepts the file with both versions.
33
+ */
34
+ @protocol BrownfieldReactHostInitializing <NSObject>
35
+
36
+ - (void)initializeReactHostWithLaunchOptions:(NSDictionary *__nullable)launchOptions
37
+ bundleConfiguration:(RCTBundleConfiguration *)bundleConfiguration
38
+ devMenuConfiguration:(RCTDevMenuConfiguration *)devMenuConfiguration;
39
+
40
+ - (void)initializeReactHostWithLaunchOptions:(NSDictionary *__nullable)launchOptions
41
+ devMenuConfiguration:(RCTDevMenuConfiguration *)devMenuConfiguration;
42
+
43
+ @end
44
+
45
+ /**
46
+ * `RCTReactNativeFactory.bundleConfiguration` also first exists in React Native 0.84.
47
+ */
48
+ @protocol BrownfieldBundleConfigurationProviding <NSObject>
49
+
50
+ @property (nonatomic, readonly) RCTBundleConfiguration *bundleConfiguration;
51
+
52
+ @end
53
+
54
+ NS_ASSUME_NONNULL_END
55
+
56
+ @implementation BrownfieldReactHostPreloader
57
+
58
+ + (void)preloadWithReactNativeFactory:(id)reactNativeFactory
59
+ launchOptions:(NSDictionary *_Nullable)launchOptions
60
+ {
61
+ RCTReactNativeFactory *factory = (RCTReactNativeFactory *)reactNativeFactory;
62
+ RCTRootViewFactory *rootViewFactory = factory.rootViewFactory;
63
+
64
+ // `initializeReactHostWithLaunchOptions:...` returns early if a view, or an earlier preload,
65
+ // created a host. Thus this method makes no other check. React Native declares
66
+ // `rootViewFactory` as not nullable, and this guard is only a protection.
67
+ if (rootViewFactory == nil) {
68
+ return;
69
+ }
70
+
71
+ id<BrownfieldReactHostInitializing> hostInitializer = (id)rootViewFactory;
72
+ SEL initializeWithBundleConfiguration =
73
+ @selector(initializeReactHostWithLaunchOptions:bundleConfiguration:devMenuConfiguration:);
74
+ SEL initializeWithDevMenuConfiguration =
75
+ @selector(initializeReactHostWithLaunchOptions:devMenuConfiguration:);
76
+
77
+ // Get the configurations from the factory. Do not make new configurations from the default
78
+ // values. The Expo runtime sends `RCTReactNativeFactory.devMenuConfiguration` from
79
+ // `ExpoReactNativeFactory.recreateRootView:`, thus the factory value keeps the two runtimes
80
+ // equal.
81
+ //
82
+ // The preloaded host is equal to a host that a view creates later, but the two paths send
83
+ // different objects. The bare runtime creates the view with
84
+ // `viewWithModuleName:initialProperties:launchOptions:`, and that method sends
85
+ // `[RCTBundleConfiguration defaultConfiguration]` and `[RCTDevMenuConfiguration
86
+ // defaultConfiguration]`. The values are equal:
87
+ // - `RCTReactNativeFactory.bundleConfiguration` gives `[RCTBundleConfiguration
88
+ // defaultConfiguration]` while nobody sets another value.
89
+ // - `RCTReactNativeFactory.devMenuConfiguration` is nil by default.
90
+ // `RCTDevMenuConfigurationDecorator` does nothing with nil, and `RCTDevMenu` and
91
+ // `RCTDevSettings` keep their own default values. Those values are equal to
92
+ // `[RCTDevMenuConfiguration defaultConfiguration]`, because the two sides read `RCT_DEV_MENU`.
93
+ if ([hostInitializer respondsToSelector:initializeWithBundleConfiguration]) {
94
+ id<BrownfieldBundleConfigurationProviding> configurationProvider = (id)factory;
95
+
96
+ [hostInitializer initializeReactHostWithLaunchOptions:launchOptions
97
+ bundleConfiguration:configurationProvider.bundleConfiguration
98
+ devMenuConfiguration:factory.devMenuConfiguration];
99
+ } else if ([hostInitializer respondsToSelector:initializeWithDevMenuConfiguration]) {
100
+ [hostInitializer initializeReactHostWithLaunchOptions:launchOptions
101
+ devMenuConfiguration:factory.devMenuConfiguration];
102
+ } else {
103
+ // A React Native version with a third shape of the method. Do not crash. The first React
104
+ // Native view creates the host, as without a preload.
105
+ NSLog(
106
+ @"%@",
107
+ @"ReactNativeBrownfield: startReactNative did not preload the JavaScript bundle, because "
108
+ @"this React Native version has no known initializeReactHostWithLaunchOptions: method. The "
109
+ @"first React Native view loads the bundle.");
110
+ }
111
+ }
112
+
113
+ @end
@@ -11,7 +11,7 @@ internal import EXUpdates
11
11
  final class ExpoHostRuntime {
12
12
  static let shared = ExpoHostRuntime()
13
13
 
14
- private let jsBundleLoadObserver = JSBundleLoadObserver()
14
+ let preloadState = ReactHostPreloadState()
15
15
  private var delegate = ExpoHostRuntimeDelegate()
16
16
  private var reactNativeFactory: RCTReactNativeFactory?
17
17
  private var expoDelegate: ExpoAppDelegate?
@@ -35,9 +35,15 @@ final class ExpoHostRuntime {
35
35
  /**
36
36
  * Starts React Native with optional callback when bundle is loaded.
37
37
  *
38
- * @param onBundleLoaded Optional callback invoked after JS bundle is fully loaded.
38
+ * @param onBundleLoaded Optional callback invoked on the main thread after JS bundle is fully loaded.
39
39
  */
40
40
  public func startReactNative(onBundleLoaded: (() -> Void)?) {
41
+ // The callback registration is outside of the guard below. An earlier `startReactNative` call
42
+ // can already have made the factory, and the callback must still run.
43
+ if let onBundleLoaded {
44
+ preloadState.jsBundleLoadObserver.observe(onBundleLoaded: onBundleLoaded)
45
+ }
46
+
41
47
  guard reactNativeFactory == nil else { return }
42
48
 
43
49
  let appDelegate = ExpoAppDelegate()
@@ -50,10 +56,6 @@ final class ExpoHostRuntime {
50
56
  appDelegate.bindReactNativeFactory(reactNativeFactory)
51
57
  #endif
52
58
  expoDelegate = appDelegate
53
-
54
- if let onBundleLoaded {
55
- jsBundleLoadObserver.observeOnce(onBundleLoaded: onBundleLoaded)
56
- }
57
59
  }
58
60
 
59
61
  /**
@@ -70,6 +72,7 @@ final class ExpoHostRuntime {
70
72
  }
71
73
  reactNativeFactory = nil
72
74
  expoDelegate = nil
75
+ preloadState.reset()
73
76
  }
74
77
 
75
78
  /**
@@ -169,6 +172,8 @@ final class ExpoHostRuntime {
169
172
  let bundleURL = delegate.bundleURL()
170
173
  configureDevLoadingView(with: bundleURL)
171
174
 
175
+ let resolvedLaunchOptions = preloadState.launchOptions(overriddenBy: launchOptions)
176
+
172
177
  // below: https://github.com/expo/expo/commit/2013760c46cde1404872d181a691da72fbf207a4
173
178
  // has moved the recreateRootView method to ExpoReactNativeFactory
174
179
  #if EXPO_SDK_GTE_55 // this define comes from the Brownfield Expo config plugin
@@ -176,19 +181,51 @@ final class ExpoHostRuntime {
176
181
  withBundleURL: bundleURL,
177
182
  moduleName: moduleName,
178
183
  initialProps: initialProps,
179
- launchOptions: launchOptions
184
+ launchOptions: resolvedLaunchOptions
180
185
  )
181
186
  #else
182
187
  return expoDelegate?.recreateRootView(
183
188
  withBundleURL: bundleURL,
184
189
  moduleName: moduleName,
185
190
  initialProps: initialProps,
186
- launchOptions: launchOptions
191
+ launchOptions: resolvedLaunchOptions
187
192
  )
188
193
  #endif
189
194
  }
190
195
  }
191
196
 
197
+ extension ExpoHostRuntime: ReactHostPreloading {
198
+ var reactNativeFactoryForPreload: AnyObject? {
199
+ return reactNativeFactory
200
+ }
201
+
202
+ /**
203
+ * expo-dev-launcher finds the Metro URL only after the user selects an app in the launcher. This
204
+ * is a Debug behavior. In Release the class is in the binary, but it does nothing. Thus this
205
+ * check is also only in Debug.
206
+ *
207
+ * The check reads the class name, because this pod has no dependency on expo-dev-launcher.
208
+ * `EXDevLauncherController` is the name in expo-dev-launcher 56 and 57. If a later version
209
+ * changes the name, this method gives `true`, and a preload can keep a bundle of the launcher
210
+ * screen. Examine the name again when you add a new Expo SDK.
211
+ *
212
+ * `ExpoHostRuntimeDelegate.isBundleURLStable` covers the other conditions.
213
+ */
214
+ func canPreloadReactNative() -> Bool {
215
+ #if DEBUG
216
+ if NSClassFromString("EXDevLauncherController") != nil {
217
+ return false
218
+ }
219
+ #endif
220
+
221
+ return delegate.isBundleURLStable
222
+ }
223
+
224
+ func prepareDevLoadingView() {
225
+ configureDevLoadingView()
226
+ }
227
+ }
228
+
192
229
  class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate {
193
230
  var entryFile = ".expo/.virtual-metro-entry"
194
231
  var bundlePath = "main.jsbundle"
@@ -242,5 +279,30 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate {
242
279
  return nil
243
280
  }
244
281
  }
282
+
283
+ /**
284
+ * `true` if `bundleURL()` gives now the same result as a later resolution. This property follows
285
+ * the steps of `bundleURL()`, and it must change together with them.
286
+ *
287
+ * The override wins over every other step, thus an override makes the URL stable.
288
+ *
289
+ * expo-updates selects the launch asset while `AppController` starts. `ReactNativeViewController`
290
+ * starts `AppController`. Before this operation is complete, `launchAssetUrl()` is nil, and
291
+ * `bundleURL()` gives the embedded bundle. A host from that time keeps the embedded bundle, and
292
+ * Expo never applies the update. Thus the URL is stable only after `launchAssetUrl()` has a
293
+ * value. The class can be in the binary while the app does not use it, thus this check reads the
294
+ * state and not the class.
295
+ */
296
+ var isBundleURLStable: Bool {
297
+ if bundleURLOverride?() != nil {
298
+ return true
299
+ }
300
+
301
+ #if canImport(EXUpdates) && !DEBUG
302
+ return AppController.isInitialized() && AppController.sharedInstance.launchAssetUrl() != nil
303
+ #else
304
+ return true
305
+ #endif
306
+ }
245
307
  }
246
308
  #endif
@@ -1,38 +1,85 @@
1
1
  import Foundation
2
- internal import React
3
2
 
3
+ /**
4
+ * Watches the `RCTInstanceDidLoadBundle` notification, and calls the callback that waits for it.
5
+ *
6
+ * React Native sends the notification from the JavaScript thread. This class always calls the
7
+ * callback on the main thread, because the callback changes the user interface.
8
+ *
9
+ * The class starts to watch at the initialization, and not at the first callback. The class then
10
+ * knows that React Native loaded the bundle, also if nobody waited for the notification.
11
+ */
4
12
  final class JSBundleLoadObserver {
5
- private var onBundleLoaded: (() -> Void)?
13
+ private var pendingCallback: (() -> Void)?
14
+ private var didLoadBundle = false
6
15
  private var observerToken: NSObjectProtocol?
7
16
 
8
- func observeOnce(onBundleLoaded: @escaping () -> Void) {
9
- removeObserverIfNeeded()
10
- self.onBundleLoaded = onBundleLoaded
11
-
17
+ init() {
12
18
  observerToken = NotificationCenter.default.addObserver(
13
19
  forName: NSNotification.Name("RCTInstanceDidLoadBundle"),
14
20
  object: nil,
15
- queue: nil
21
+ queue: .main
16
22
  ) { [weak self] _ in
17
- self?.notifyAndClear()
23
+ self?.bundleDidLoad()
18
24
  }
19
25
  }
20
26
 
21
27
  deinit {
22
- removeObserverIfNeeded()
28
+ if let observerToken {
29
+ NotificationCenter.default.removeObserver(observerToken)
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Keeps one callback, and calls it one time. A new callback replaces the callback that waits. If
35
+ * React Native already loaded the bundle, the class calls the callback in the next turn of the
36
+ * main run loop.
37
+ *
38
+ * @param onBundleLoaded The class always calls this callback on the main thread.
39
+ */
40
+ func observe(onBundleLoaded: @escaping () -> Void) {
41
+ onMainThread { [weak self] in
42
+ self?.register(onBundleLoaded)
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Removes the callback that waits, and forgets the bundle of the earlier session. Call this
48
+ * method when you stop React Native. A callback of the earlier session must not run in the next
49
+ * session.
50
+ */
51
+ func reset() {
52
+ onMainThread { [weak self] in
53
+ self?.pendingCallback = nil
54
+ self?.didLoadBundle = false
55
+ }
23
56
  }
24
57
 
25
- private func notifyAndClear() {
26
- let callback = onBundleLoaded
27
- onBundleLoaded = nil
28
- removeObserverIfNeeded()
58
+ // MARK: - Main thread only
59
+
60
+ private func register(_ onBundleLoaded: @escaping () -> Void) {
61
+ guard !didLoadBundle else {
62
+ DispatchQueue.main.async(execute: onBundleLoaded)
63
+ return
64
+ }
65
+
66
+ pendingCallback = onBundleLoaded
67
+ }
68
+
69
+ private func bundleDidLoad() {
70
+ didLoadBundle = true
71
+
72
+ let callback = pendingCallback
73
+ pendingCallback = nil
74
+
29
75
  callback?()
30
76
  }
31
77
 
32
- private func removeObserverIfNeeded() {
33
- if let observerToken {
34
- NotificationCenter.default.removeObserver(observerToken)
35
- self.observerToken = nil
78
+ private func onMainThread(_ work: @escaping () -> Void) {
79
+ if Thread.isMainThread {
80
+ work()
81
+ } else {
82
+ DispatchQueue.main.async(execute: work)
36
83
  }
37
84
  }
38
85
  }
@@ -0,0 +1,177 @@
1
+ import Foundation
2
+
3
+ /**
4
+ * The preload state that `startReactNative`, `view` and `stopReactNative` share.
5
+ */
6
+ final class ReactHostPreloadState {
7
+ let jsBundleLoadObserver = JSBundleLoadObserver()
8
+
9
+ private let lock = NSLock()
10
+ private var storedLaunchOptions: [AnyHashable: Any]?
11
+ private var hasPreloadRequest = false
12
+
13
+ /**
14
+ * Keeps the launch options of a `startReactNative` call.
15
+ *
16
+ * `startReactNative` can move its preload work to the main thread. A view on the main thread can
17
+ * create the React Host before that. Thus this method must run at the call, and not after the
18
+ * change of thread. The view then finds the options.
19
+ */
20
+ func storeLaunchOptions(_ launchOptions: [AnyHashable: Any]?) {
21
+ guard let launchOptions else { return }
22
+
23
+ lock.lock()
24
+ defer { lock.unlock() }
25
+
26
+ storedLaunchOptions = launchOptions
27
+ }
28
+
29
+ /**
30
+ * Records that a `startReactNative` call must preload the bundle. `consumePreloadRequest` reads
31
+ * the request one time, and `reset` cancels it.
32
+ */
33
+ func requestPreload() {
34
+ lock.lock()
35
+ defer { lock.unlock() }
36
+
37
+ hasPreloadRequest = true
38
+ }
39
+
40
+ /**
41
+ * `true` if a preload request waits. The method also removes the request.
42
+ *
43
+ * `startReactNative` can move its preload work to the main thread. `stopReactNative` on the main
44
+ * thread can run before that work. The stop cancels the request, thus this method then gives
45
+ * `false`, and React Native stays stopped.
46
+ */
47
+ func consumePreloadRequest() -> Bool {
48
+ lock.lock()
49
+ defer { lock.unlock() }
50
+
51
+ let hadRequest = hasPreloadRequest
52
+ hasPreloadRequest = false
53
+
54
+ return hadRequest
55
+ }
56
+
57
+ /**
58
+ * The launch options for a call that can create the React Host. The options of the caller win
59
+ * over the options of an earlier `startReactNative` call.
60
+ */
61
+ func launchOptions(
62
+ overriddenBy explicitLaunchOptions: [AnyHashable: Any]? = nil
63
+ ) -> [AnyHashable: Any]? {
64
+ lock.lock()
65
+ defer { lock.unlock() }
66
+
67
+ return explicitLaunchOptions ?? storedLaunchOptions
68
+ }
69
+
70
+ /**
71
+ * Removes the state of the session. Call this method when you stop React Native.
72
+ */
73
+ func reset() {
74
+ jsBundleLoadObserver.reset()
75
+
76
+ lock.lock()
77
+ defer { lock.unlock() }
78
+
79
+ storedLaunchOptions = nil
80
+ hasPreloadRequest = false
81
+ }
82
+ }
83
+
84
+ /**
85
+ * The preload sequence that the two host runtimes share. Only one runtime is in a build. The
86
+ * shared sequence keeps the behavior of the two runtimes equal.
87
+ */
88
+ protocol ReactHostPreloading: AnyObject {
89
+ var preloadState: ReactHostPreloadState { get }
90
+
91
+ /**
92
+ * The React Native factory, or nil while React Native did not start. The type is `AnyObject`,
93
+ * because `BrownfieldReactHostPreloader` also takes the factory as `id`. This file then needs no
94
+ * React Native import, and it builds with both runtimes.
95
+ */
96
+ var reactNativeFactoryForPreload: AnyObject? { get }
97
+
98
+ func startReactNative()
99
+
100
+ /**
101
+ * `false` while the bundle URL can still change. The runtime must not create the host in this
102
+ * condition, because the host keeps the first bundle that it evaluates.
103
+ */
104
+ func canPreloadReactNative() -> Bool
105
+
106
+ func prepareDevLoadingView()
107
+ }
108
+
109
+ extension ReactHostPreloading {
110
+ /**
111
+ * The implementation of
112
+ * `ReactNativeBrownfield.startReactNative(launchOptions:preloadBundle:onBundleLoaded:)`, which
113
+ * holds the contract.
114
+ */
115
+ func startReactNative(
116
+ launchOptions: [AnyHashable: Any]?,
117
+ preloadBundle: Bool,
118
+ onBundleLoaded: (() -> Void)?
119
+ ) {
120
+ preloadState.storeLaunchOptions(launchOptions)
121
+
122
+ if let onBundleLoaded {
123
+ preloadState.jsBundleLoadObserver.observe(onBundleLoaded: onBundleLoaded)
124
+ }
125
+
126
+ guard preloadBundle else {
127
+ startReactNative()
128
+ return
129
+ }
130
+
131
+ preloadState.requestPreload()
132
+
133
+ if Thread.isMainThread {
134
+ createReactHost()
135
+ } else {
136
+ DispatchQueue.main.async { [weak self] in
137
+ self?.createReactHost()
138
+ }
139
+ }
140
+ }
141
+
142
+ private func createReactHost() {
143
+ // `stopReactNative` can run between the request and this method, because a caller that is not
144
+ // on the main thread moves the work to the main thread. The stop cancels the request, and
145
+ // React Native must then stay stopped.
146
+ guard preloadState.consumePreloadRequest() else { return }
147
+
148
+ startReactNative()
149
+
150
+ guard let factory = reactNativeFactoryForPreload else {
151
+ logSkippedPreload("React Native did not make the factory")
152
+ return
153
+ }
154
+
155
+ guard canPreloadReactNative() else {
156
+ logSkippedPreload("the bundle URL can still change")
157
+ return
158
+ }
159
+
160
+ // You must configure the dev loading view before React Native loads the bundle. Usually the
161
+ // `view` method does this.
162
+ prepareDevLoadingView()
163
+
164
+ BrownfieldReactHostPreloader.preload(
165
+ withReactNativeFactory: factory,
166
+ launchOptions: preloadState.launchOptions()
167
+ )
168
+ }
169
+
170
+ private func logSkippedPreload(_ reason: String) {
171
+ NSLog(
172
+ "%@",
173
+ "ReactNativeBrownfield: startReactNative did not preload the JavaScript bundle, because "
174
+ + reason + ". The first React Native view loads the bundle."
175
+ )
176
+ }
177
+ }
@@ -192,7 +192,9 @@ internal import Expo
192
192
  /**
193
193
  * Starts React Native with optional callback when bundle is loaded.
194
194
  *
195
- * @param onBundleLoaded Optional callback invoked after JS bundle is fully loaded.
195
+ * @param onBundleLoaded Optional callback invoked on the main thread after the JS bundle is
196
+ * fully loaded. It runs in the next turn of the main run loop when the bundle is already
197
+ * loaded. A new callback replaces the callback that waits.
196
198
  */
197
199
  @objc public func startReactNative(onBundleLoaded: (() -> Void)?) {
198
200
  #if canImport(Expo)
@@ -202,6 +204,39 @@ internal import Expo
202
204
  #endif
203
205
  }
204
206
 
207
+ /**
208
+ * Starts React Native, and optionally loads the JavaScript bundle immediately. Without a
209
+ * preload, React Native loads the bundle when it creates the first React Native view. On
210
+ * Android, `ReactNativeBrownfield.initialize` does the same operation.
211
+ *
212
+ * @param launchOptions The launch options for the React Host. Only the call that creates the
213
+ * host reads these options. This method keeps the options. A view that creates the host later
214
+ * uses them, also if this method cannot create the host. Options that you give to `view` win
215
+ * over these options.
216
+ * @param preloadBundle `true` loads and evaluates the JavaScript bundle now. A preload adds
217
+ * work to the section of the caller, but the first React Native screen appears faster.
218
+ * @param onBundleLoaded An optional callback, with the rules of `startReactNative`.
219
+ */
220
+ @objc public func startReactNative(
221
+ launchOptions: [AnyHashable: Any]?,
222
+ preloadBundle: Bool,
223
+ onBundleLoaded: (() -> Void)?
224
+ ) {
225
+ #if canImport(Expo)
226
+ ExpoHostRuntime.shared.startReactNative(
227
+ launchOptions: launchOptions,
228
+ preloadBundle: preloadBundle,
229
+ onBundleLoaded: onBundleLoaded
230
+ )
231
+ #else
232
+ ReactNativeHostRuntime.shared.startReactNative(
233
+ launchOptions: launchOptions,
234
+ preloadBundle: preloadBundle,
235
+ onBundleLoaded: onBundleLoaded
236
+ )
237
+ #endif
238
+ }
239
+
205
240
  /**
206
241
  * Send a serialized JSON message to the React Native JS application.
207
242
  * The message is delivered as a `brownfieldMessage` DeviceEventEmitter event.
@@ -46,7 +46,7 @@ class ReactNativeBrownfieldDelegate: RCTDefaultReactNativeFactoryDelegate {
46
46
 
47
47
  final class ReactNativeHostRuntime {
48
48
  public static let shared = ReactNativeHostRuntime()
49
- private let jsBundleLoadObserver = JSBundleLoadObserver()
49
+ let preloadState = ReactHostPreloadState()
50
50
  private var delegate = ReactNativeBrownfieldDelegate()
51
51
 
52
52
  private func configureDevLoadingView() {
@@ -133,6 +133,7 @@ final class ReactNativeHostRuntime {
133
133
  }
134
134
 
135
135
  reactNativeFactory = nil
136
+ preloadState.reset()
136
137
  }
137
138
 
138
139
  public func view(
@@ -145,7 +146,7 @@ final class ReactNativeHostRuntime {
145
146
  return reactNativeFactory?.rootViewFactory.view(
146
147
  withModuleName: moduleName,
147
148
  initialProperties: initialProps,
148
- launchOptions: launchOptions
149
+ launchOptions: preloadState.launchOptions(overriddenBy: launchOptions)
149
150
  )
150
151
  }
151
152
 
@@ -188,17 +189,38 @@ final class ReactNativeHostRuntime {
188
189
  /**
189
190
  * Starts React Native with optional callback when bundle is loaded.
190
191
  *
191
- * @param onBundleLoaded Optional callback invoked after JS bundle is fully loaded.
192
+ * @param onBundleLoaded Optional callback invoked on the main thread after JS bundle is fully loaded.
192
193
  */
193
194
  public func startReactNative(onBundleLoaded: (() -> Void)?) {
195
+ // The callback registration is outside of the guard below. An earlier `startReactNative` call
196
+ // can already have made the factory, and the callback must still run.
197
+ if let onBundleLoaded {
198
+ preloadState.jsBundleLoadObserver.observe(onBundleLoaded: onBundleLoaded)
199
+ }
200
+
194
201
  guard reactNativeFactory == nil else { return }
195
202
 
196
203
  delegate.dependencyProvider = RCTAppDependencyProvider()
197
204
  reactNativeFactory = RCTReactNativeFactory(delegate: delegate)
205
+ }
206
+ }
198
207
 
199
- if let onBundleLoaded {
200
- jsBundleLoadObserver.observeOnce(onBundleLoaded: onBundleLoaded)
201
- }
208
+ extension ReactNativeHostRuntime: ReactHostPreloading {
209
+ var reactNativeFactoryForPreload: AnyObject? {
210
+ return reactNativeFactory
211
+ }
212
+
213
+ /**
214
+ * The bare React Native delegate resolves the bundle URL from the override, from Metro, or from
215
+ * the embedded bundle. No step waits for an asynchronous operation. Thus a preload uses the same
216
+ * bundle as the first view.
217
+ */
218
+ func canPreloadReactNative() -> Bool {
219
+ return true
220
+ }
221
+
222
+ func prepareDevLoadingView() {
223
+ configureDevLoadingView()
202
224
  }
203
225
  }
204
226
  #endif
@@ -7,10 +7,15 @@ import com.callstack.reactnativebrownfield.ReactNativeBrownfield
7
7
  import com.facebook.react.PackageList
8
8
  import expo.modules.ApplicationLifecycleDispatcher
9
9
  import expo.modules.ExpoReactHostFactory
10
+ import java.util.concurrent.atomic.AtomicBoolean
10
11
 
11
12
  object ReactNativeHostManager {
13
+ private val applicationCreateDispatched = AtomicBoolean()
14
+
12
15
  fun initialize(application: Application, onJSBundleLoaded: OnJSBundleLoaded? = null) {
13
- ApplicationLifecycleDispatcher.onApplicationCreate(application)
16
+ if (applicationCreateDispatched.compareAndSet(false, true)) {
17
+ ApplicationLifecycleDispatcher.onApplicationCreate(application)
18
+ }
14
19
 
15
20
  ReactNativeBrownfield.initialize(application, onJSBundleLoaded) {
16
21
  ExpoReactHostFactory.getDefaultReactHost(
@@ -7,10 +7,15 @@ import com.callstack.reactnativebrownfield.ReactNativeBrownfield
7
7
  import com.facebook.react.PackageList
8
8
  import expo.modules.ApplicationLifecycleDispatcher
9
9
  import expo.modules.ExpoReactHostFactory
10
+ import java.util.concurrent.atomic.AtomicBoolean
10
11
 
11
12
  object ReactNativeHostManager {
13
+ private val applicationCreateDispatched = AtomicBoolean()
14
+
12
15
  fun initialize(application: Application, onJSBundleLoaded: OnJSBundleLoaded? = null) {
13
- ApplicationLifecycleDispatcher.onApplicationCreate(application)
16
+ if (applicationCreateDispatched.compareAndSet(false, true)) {
17
+ ApplicationLifecycleDispatcher.onApplicationCreate(application)
18
+ }
14
19
 
15
20
  ReactNativeBrownfield.initialize(application, onJSBundleLoaded) {
16
21
  ExpoReactHostFactory.getDefaultReactHost(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@callstack/react-native-brownfield",
3
- "version": "5.0.2",
3
+ "version": "5.1.0",
4
4
  "license": "MIT",
5
5
  "author": "Michal Chudziak <mike.chudziak@callstack.com>",
6
6
  "bin": {
@@ -89,7 +89,7 @@
89
89
  "@expo/config-plugins": ">=54 <100"
90
90
  },
91
91
  "dependencies": {
92
- "@callstack/brownfield-cli": "^5.0.2"
92
+ "@callstack/brownfield-cli": "^5.1.0"
93
93
  },
94
94
  "devDependencies": {
95
95
  "@babel/core": "^7.29.6",
@@ -7,10 +7,15 @@ import com.callstack.reactnativebrownfield.ReactNativeBrownfield
7
7
  import com.facebook.react.PackageList
8
8
  import expo.modules.ApplicationLifecycleDispatcher
9
9
  import expo.modules.ExpoReactHostFactory
10
+ import java.util.concurrent.atomic.AtomicBoolean
10
11
 
11
12
  object ReactNativeHostManager {
13
+ private val applicationCreateDispatched = AtomicBoolean()
14
+
12
15
  fun initialize(application: Application, onJSBundleLoaded: OnJSBundleLoaded? = null) {
13
- ApplicationLifecycleDispatcher.onApplicationCreate(application)
16
+ if (applicationCreateDispatched.compareAndSet(false, true)) {
17
+ ApplicationLifecycleDispatcher.onApplicationCreate(application)
18
+ }
14
19
 
15
20
  ReactNativeBrownfield.initialize(application, onJSBundleLoaded) {
16
21
  ExpoReactHostFactory.getDefaultReactHost(