@callstack/react-native-brownfield 5.0.2 → 5.1.1

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfield.kt +47 -4
  3. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeFragment.kt +23 -1
  4. package/gradle-plugin/brownfield/build.gradle.kts +7 -0
  5. package/gradle-plugin/brownfield/gradle/libs.versions.toml +2 -0
  6. package/gradle-plugin/brownfield/gradle.properties +1 -1
  7. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt +96 -0
  8. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt +10 -214
  9. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPublishingInfo.kt +7 -0
  10. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt +92 -1
  11. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt +12 -1
  12. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/{expo/utils/BrownfieldPrimitives.kt → shared/DependencyInfo.kt} +3 -8
  13. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyPublishability.kt +22 -0
  14. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt +44 -0
  15. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt +181 -0
  16. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/TransitiveDependencyFiltering.kt +17 -0
  17. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/{expo/utils → shared}/VersionMediatingDependencySet.kt +1 -1
  18. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt +11 -0
  19. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt +171 -0
  20. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelperGradleFallbackTest.kt +47 -0
  21. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/LegacyConsumerConfigurationTest.kt +110 -0
  22. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt +67 -0
  23. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjectorTest.kt +222 -0
  24. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/TransitiveDependencyFilteringTest.kt +37 -0
  25. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt +42 -0
  26. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt +11 -0
  27. package/ios/BrownfieldReactHostPreloader.h +30 -0
  28. package/ios/BrownfieldReactHostPreloader.m +113 -0
  29. package/ios/BrownfieldSurfaceRegistry.swift +93 -0
  30. package/ios/Expo/ExpoHostRuntime.swift +70 -8
  31. package/ios/JSBundleLoadObserver.swift +64 -17
  32. package/ios/ReactHostPreloading.swift +177 -0
  33. package/ios/ReactNativeBrownfield.swift +36 -1
  34. package/ios/Vanilla/ReactNativeHostRuntime.swift +28 -6
  35. package/ios/Views/ReactNativeView.swift +34 -5
  36. package/ios/Views/ReactNativeViewController.swift +120 -1
  37. package/lib/commonjs/expo-config-plugin/android/utils/constants.js +1 -1
  38. package/lib/commonjs/expo-config-plugin/template/android/ReactNativeHostManager.post55.kt +6 -1
  39. package/lib/module/expo-config-plugin/android/utils/constants.js +1 -1
  40. package/lib/module/expo-config-plugin/template/android/ReactNativeHostManager.post55.kt +6 -1
  41. package/lib/typescript/commonjs/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
  42. package/lib/typescript/module/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
  43. package/package.json +2 -2
  44. package/src/expo-config-plugin/android/utils/constants.ts +1 -1
  45. package/src/expo-config-plugin/template/android/ReactNativeHostManager.post55.kt +6 -1
@@ -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
@@ -0,0 +1,93 @@
1
+ import Foundation
2
+
3
+ /// Identifies one live React Native surface. Vended by `BrownfieldSurfaceRegistry`.
4
+ struct BrownfieldSurfaceID: Hashable, CustomStringConvertible {
5
+ fileprivate let rawValue: UInt64
6
+
7
+ var description: String { "surface#\(rawValue)" }
8
+ }
9
+
10
+ /// Decides which React Native surface may act on a `popToNative()` request.
11
+ ///
12
+ /// `popToNative()` is delivered as an app-wide notification, so every live React
13
+ /// Native surface receives it — including ones buried under other screens. Acting
14
+ /// on all of them tears down the whole navigation stack
15
+ /// (https://github.com/callstack/react-native-brownfield/issues/354).
16
+ ///
17
+ /// The registry resolves that to a single surface: whichever on-screen surface
18
+ /// was shown most recently.
19
+ ///
20
+ /// Visibility is asked for at query time instead of being cached from lifecycle
21
+ /// callbacks. Hosts recreate view controllers — SwiftUI does it freely — and a
22
+ /// cached flag is stale exactly when the notification arrives.
23
+ ///
24
+ /// Deliberately free of UIKit so it can be unit tested on macOS via `swift test`.
25
+ /// Internal, like the sibling `BrownfieldBundleSupport` target: it is an
26
+ /// implementation detail of the pod, not API for host apps.
27
+ final class BrownfieldSurfaceRegistry {
28
+ static let shared = BrownfieldSurfaceRegistry()
29
+
30
+ private struct Surface {
31
+ let isOnScreen: () -> Bool
32
+ var shownAt: UInt64
33
+ }
34
+
35
+ private let lock = NSLock()
36
+ private var nextSurfaceValue: UInt64 = 0
37
+ private var nextShownValue: UInt64 = 0
38
+ private var surfaces: [BrownfieldSurfaceID: Surface] = [:]
39
+
40
+ init() {}
41
+
42
+ /// - Parameter isOnScreen: queried on every `popTarget()`. Capture the host
43
+ /// weakly so a released surface stops winning the election.
44
+ func register(isOnScreen: @escaping () -> Bool) -> BrownfieldSurfaceID {
45
+ lock.lock()
46
+ defer { lock.unlock() }
47
+
48
+ nextSurfaceValue += 1
49
+ let id = BrownfieldSurfaceID(rawValue: nextSurfaceValue)
50
+ surfaces[id] = Surface(isOnScreen: isOnScreen, shownAt: 0)
51
+ return id
52
+ }
53
+
54
+ func unregister(_ surface: BrownfieldSurfaceID) {
55
+ lock.lock()
56
+ defer { lock.unlock() }
57
+
58
+ surfaces.removeValue(forKey: surface)
59
+ }
60
+
61
+ /// Records that a surface was just shown, ordering it ahead of other on-screen
62
+ /// surfaces. Only a tiebreaker — a surface never marked shown can still win
63
+ /// when it is the one on screen.
64
+ func markShown(_ surface: BrownfieldSurfaceID) {
65
+ lock.lock()
66
+ defer { lock.unlock() }
67
+
68
+ guard var entry = surfaces[surface] else { return }
69
+
70
+ nextShownValue += 1
71
+ entry.shownAt = nextShownValue
72
+ surfaces[surface] = entry
73
+ }
74
+
75
+ /// The single surface allowed to handle `popToNative()`, or `nil` when no
76
+ /// React Native surface is on screen.
77
+ func popTarget() -> BrownfieldSurfaceID? {
78
+ lock.lock()
79
+ let candidates = surfaces
80
+ lock.unlock()
81
+
82
+ return candidates
83
+ .filter { $0.value.isOnScreen() }
84
+ .max { lhs, rhs in
85
+ (lhs.value.shownAt, lhs.key.rawValue) < (rhs.value.shownAt, rhs.key.rawValue)
86
+ }?
87
+ .key
88
+ }
89
+
90
+ func isPopTarget(_ surface: BrownfieldSurfaceID) -> Bool {
91
+ popTarget() == surface
92
+ }
93
+ }
@@ -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.