@callstack/react-native-brownfield 5.2.0 → 6.0.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.
Files changed (117) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/android/build.gradle +1 -0
  3. package/android/consumer-rules.pro +6 -0
  4. package/android/src/main/java/com/callstack/reactnativebrownfield/BrownfieldPerformance.kt +384 -0
  5. package/android/src/main/java/com/callstack/reactnativebrownfield/BrownfieldPerformanceModule.kt +15 -0
  6. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactDelegateWrapper.kt +8 -5
  7. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfield.kt +78 -23
  8. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfieldPackage.kt +1 -0
  9. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeFragment.kt +89 -3
  10. package/android/src/main/java/com/callstack/reactnativebrownfield/constants/ReactNativeFragmentArgNames.kt +2 -0
  11. package/android/src/test/java/com/callstack/reactnativebrownfield/PerformanceMetricsTest.kt +54 -0
  12. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt +59 -15
  13. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt +6 -8
  14. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/LegacyConsumerConfigurationTest.kt +6 -4
  15. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/RNBundleTaskStubs.kt +27 -0
  16. package/ios/BrownfieldDisplayMetrics.swift +265 -0
  17. package/ios/BrownfieldPerformanceBridge.h +8 -0
  18. package/ios/BrownfieldPerformanceBridge.mm +103 -0
  19. package/ios/Expo/ExpoHostRuntime.swift +58 -14
  20. package/ios/Expo/ExpoUpdatesStartup.swift +49 -0
  21. package/ios/JSBundleLoadObserver.swift +56 -19
  22. package/ios/JSBundleTimingObserver.h +26 -0
  23. package/ios/JSBundleTimingObserver.m +140 -0
  24. package/ios/ReactHostPreloading.swift +7 -3
  25. package/ios/ReactNativeBrownfield.swift +82 -14
  26. package/ios/ReactNativeBrownfield.xcodeproj/project.pbxproj +16 -0
  27. package/ios/Vanilla/ReactNativeHostRuntime.swift +1 -1
  28. package/ios/Views/ReactNativeView.swift +21 -3
  29. package/ios/Views/ReactNativeViewController.swift +44 -42
  30. package/lib/commonjs/expo-config-plugin/android/withAndroidModuleFiles.js +1 -1
  31. package/lib/commonjs/expo-config-plugin/android/withAndroidModuleFiles.js.map +1 -1
  32. package/lib/commonjs/expo-config-plugin/ios/podfileHelpers.js +1 -1
  33. package/lib/commonjs/expo-config-plugin/ios/podfileHelpers.js.map +1 -1
  34. package/lib/commonjs/expo-config-plugin/ios/withBrownfieldIos.js +1 -1
  35. package/lib/commonjs/expo-config-plugin/ios/withBrownfieldIos.js.map +1 -1
  36. package/lib/commonjs/expo-config-plugin/ios/withIosFrameworkFiles.js +1 -1
  37. package/lib/commonjs/expo-config-plugin/ios/withIosFrameworkFiles.js.map +1 -1
  38. package/lib/commonjs/expo-config-plugin/ios/xcodeHelpers.js +2 -2
  39. package/lib/commonjs/expo-config-plugin/ios/xcodeHelpers.js.map +1 -1
  40. package/lib/commonjs/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt +35 -0
  41. package/lib/commonjs/expo-config-plugin/template/android/consumer-rules.pro +7 -0
  42. package/lib/commonjs/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift +12 -0
  43. package/lib/commonjs/expo-config-plugin/template/ios/PodfileTargetBlock.vanilla.rb +5 -0
  44. package/lib/commonjs/index.js +1 -1
  45. package/lib/commonjs/index.js.map +1 -1
  46. package/lib/commonjs/scaffold/index.js +2 -0
  47. package/lib/commonjs/scaffold/index.js.map +1 -0
  48. package/lib/commonjs/scaffold/projectFiles.js +2 -0
  49. package/lib/commonjs/scaffold/projectFiles.js.map +1 -0
  50. package/lib/commonjs/scaffold/xcode.d.js +2 -0
  51. package/lib/commonjs/scaffold/xcode.d.js.map +1 -0
  52. package/lib/module/expo-config-plugin/android/withAndroidModuleFiles.js +1 -1
  53. package/lib/module/expo-config-plugin/android/withAndroidModuleFiles.js.map +1 -1
  54. package/lib/module/expo-config-plugin/ios/podfileHelpers.js +1 -1
  55. package/lib/module/expo-config-plugin/ios/podfileHelpers.js.map +1 -1
  56. package/lib/module/expo-config-plugin/ios/withBrownfieldIos.js +1 -1
  57. package/lib/module/expo-config-plugin/ios/withBrownfieldIos.js.map +1 -1
  58. package/lib/module/expo-config-plugin/ios/withIosFrameworkFiles.js +1 -1
  59. package/lib/module/expo-config-plugin/ios/withIosFrameworkFiles.js.map +1 -1
  60. package/lib/module/expo-config-plugin/ios/xcodeHelpers.js +2 -2
  61. package/lib/module/expo-config-plugin/ios/xcodeHelpers.js.map +1 -1
  62. package/lib/module/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt +35 -0
  63. package/lib/module/expo-config-plugin/template/android/consumer-rules.pro +7 -0
  64. package/lib/module/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift +12 -0
  65. package/lib/module/expo-config-plugin/template/ios/PodfileTargetBlock.vanilla.rb +5 -0
  66. package/lib/module/index.js +1 -1
  67. package/lib/module/index.js.map +1 -1
  68. package/lib/module/scaffold/index.js +2 -0
  69. package/lib/module/scaffold/index.js.map +1 -0
  70. package/lib/module/scaffold/projectFiles.js +2 -0
  71. package/lib/module/scaffold/projectFiles.js.map +1 -0
  72. package/lib/module/scaffold/xcode.d.js +2 -0
  73. package/lib/module/scaffold/xcode.d.js.map +1 -0
  74. package/lib/typescript/commonjs/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts +10 -2
  75. package/lib/typescript/commonjs/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/src/expo-config-plugin/ios/podfileHelpers.d.ts +4 -2
  77. package/lib/typescript/commonjs/src/expo-config-plugin/ios/podfileHelpers.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/src/expo-config-plugin/ios/withBrownfieldIos.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts +11 -2
  80. package/lib/typescript/commonjs/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/src/expo-config-plugin/ios/xcodeHelpers.d.ts +6 -2
  82. package/lib/typescript/commonjs/src/expo-config-plugin/ios/xcodeHelpers.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/src/index.d.ts +7 -0
  84. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/src/scaffold/index.d.ts +23 -0
  86. package/lib/typescript/commonjs/src/scaffold/index.d.ts.map +1 -0
  87. package/lib/typescript/commonjs/src/scaffold/projectFiles.d.ts +63 -0
  88. package/lib/typescript/commonjs/src/scaffold/projectFiles.d.ts.map +1 -0
  89. package/lib/typescript/module/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts +10 -2
  90. package/lib/typescript/module/src/expo-config-plugin/android/withAndroidModuleFiles.d.ts.map +1 -1
  91. package/lib/typescript/module/src/expo-config-plugin/ios/podfileHelpers.d.ts +4 -2
  92. package/lib/typescript/module/src/expo-config-plugin/ios/podfileHelpers.d.ts.map +1 -1
  93. package/lib/typescript/module/src/expo-config-plugin/ios/withBrownfieldIos.d.ts.map +1 -1
  94. package/lib/typescript/module/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts +11 -2
  95. package/lib/typescript/module/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts.map +1 -1
  96. package/lib/typescript/module/src/expo-config-plugin/ios/xcodeHelpers.d.ts +6 -2
  97. package/lib/typescript/module/src/expo-config-plugin/ios/xcodeHelpers.d.ts.map +1 -1
  98. package/lib/typescript/module/src/index.d.ts +7 -0
  99. package/lib/typescript/module/src/index.d.ts.map +1 -1
  100. package/lib/typescript/module/src/scaffold/index.d.ts +23 -0
  101. package/lib/typescript/module/src/scaffold/index.d.ts.map +1 -0
  102. package/lib/typescript/module/src/scaffold/projectFiles.d.ts +63 -0
  103. package/lib/typescript/module/src/scaffold/projectFiles.d.ts.map +1 -0
  104. package/package.json +16 -2
  105. package/src/expo-config-plugin/android/withAndroidModuleFiles.ts +33 -5
  106. package/src/expo-config-plugin/ios/podfileHelpers.ts +24 -6
  107. package/src/expo-config-plugin/ios/withBrownfieldIos.ts +5 -2
  108. package/src/expo-config-plugin/ios/withIosFrameworkFiles.ts +21 -6
  109. package/src/expo-config-plugin/ios/xcodeHelpers.ts +77 -5
  110. package/src/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt +35 -0
  111. package/src/expo-config-plugin/template/android/consumer-rules.pro +7 -0
  112. package/src/expo-config-plugin/template/ios/FrameworkInterface.vanilla.swift +12 -0
  113. package/src/expo-config-plugin/template/ios/PodfileTargetBlock.vanilla.rb +5 -0
  114. package/src/index.ts +20 -1
  115. package/src/scaffold/index.ts +395 -0
  116. package/src/scaffold/projectFiles.ts +243 -0
  117. package/src/scaffold/xcode.d.ts +4 -0
@@ -0,0 +1,103 @@
1
+ #import "BrownfieldPerformanceBridge.h"
2
+ #import <React/RCTBridge.h>
3
+ #import <React/RCTBridge+Private.h>
4
+ #import <React/RCTBridgeModule.h>
5
+ #import <React/RCTConstants.h>
6
+ #import <objc/runtime.h>
7
+ #include <atomic>
8
+
9
+ // Accessed on main, like BrownfieldDisplaySession.
10
+ static __weak RCTBridge *sPerformanceBridge;
11
+ static __weak NSThread *sJavaScriptThread;
12
+ static std::atomic<uint64_t> sGeneration{0};
13
+
14
+ static void BrownfieldPerformanceReset(void)
15
+ {
16
+ ++sGeneration;
17
+ sPerformanceBridge = nil;
18
+ sJavaScriptThread = nil;
19
+ }
20
+
21
+ @implementation BrownfieldPerformanceBridge
22
+ + (BOOL)dispatchToJavaScript:(dispatch_block_t)block
23
+ {
24
+ NSThread *thread = sJavaScriptThread;
25
+ if (thread && !thread.finished && !thread.cancelled) {
26
+ [self performSelector:@selector(runJavaScriptBlock:)
27
+ onThread:thread
28
+ withObject:[block copy]
29
+ waitUntilDone:NO
30
+ modes:@[NSRunLoopCommonModes]];
31
+ return YES;
32
+ }
33
+
34
+ RCTBridge *bridge = sPerformanceBridge;
35
+ // NSProxy's respondsToSelector: does not reliably describe RCTBridgeProxy.
36
+ if (!bridge || !class_getInstanceMethod(object_getClass(bridge), @selector(dispatchBlock:queue:))) {
37
+ return NO;
38
+ }
39
+ [bridge dispatchBlock:block queue:RCTJSThread];
40
+ return YES;
41
+ }
42
+
43
+ + (void)runJavaScriptBlock:(dispatch_block_t)block
44
+ {
45
+ block();
46
+ }
47
+ @end
48
+
49
+ // A separate iOS-only module leaves Android's generated module contract unchanged.
50
+ @interface BrownfieldPerformance : NSObject <RCTBridgeModule>
51
+ @end
52
+ @implementation BrownfieldPerformance
53
+ RCT_EXPORT_MODULE();
54
+ + (BOOL)requiresMainQueueSetup { return NO; }
55
+ RCT_EXPORT_METHOD(markFullyDisplayed:(NSString *)presentationID)
56
+ {
57
+ dispatch_async(dispatch_get_main_queue(), ^{
58
+ [[NSNotificationCenter defaultCenter] postNotificationName:@"BrownfieldMarkFullyDisplayed"
59
+ object:presentationID];
60
+ });
61
+ }
62
+ @end
63
+
64
+ __attribute__((constructor)) static void BrownfieldPerformanceRegister(void)
65
+ {
66
+ // Bridgeless RN (including 0.86) posts synchronously from loadScript's afterLoad
67
+ // callback on the JS thread. Observe without a queue so we capture that thread,
68
+ // whose run loop is maintained by RCTJSThreadManager. currentBridge is a stub
69
+ // when the legacy architecture is removed, and this notification has no payload.
70
+ [[NSNotificationCenter defaultCenter] addObserverForName:@"RCTInstanceDidLoadBundle"
71
+ object:nil queue:nil usingBlock:^(__unused NSNotification *notification) {
72
+ const auto generation = sGeneration.load();
73
+ NSThread *thread = NSThread.currentThread;
74
+ if (thread.isMainThread) {
75
+ return;
76
+ }
77
+ dispatch_async(dispatch_get_main_queue(), ^{
78
+ if (generation != sGeneration.load()) {
79
+ return;
80
+ }
81
+ sJavaScriptThread = thread;
82
+ sPerformanceBridge = nil;
83
+ [[NSNotificationCenter defaultCenter] postNotificationName:@"BrownfieldPerformanceBridgeReady" object:nil];
84
+ });
85
+ }];
86
+ [[NSNotificationCenter defaultCenter] addObserverForName:RCTJavaScriptDidLoadNotification
87
+ object:nil queue:NSOperationQueue.mainQueue usingBlock:^(NSNotification *notification) {
88
+ sPerformanceBridge = notification.userInfo[@"bridge"];
89
+ // Newer RN also sends this notification after the instance notification.
90
+ // Announcing readiness twice would cancel an already attached display session.
91
+ if (!sJavaScriptThread) {
92
+ [[NSNotificationCenter defaultCenter] postNotificationName:@"BrownfieldPerformanceBridgeReady" object:nil];
93
+ }
94
+ }];
95
+ [[NSNotificationCenter defaultCenter] addObserverForName:@"BrownfieldPerformanceStop"
96
+ object:nil queue:NSOperationQueue.mainQueue usingBlock:^(__unused NSNotification *notification) {
97
+ BrownfieldPerformanceReset();
98
+ }];
99
+ [[NSNotificationCenter defaultCenter] addObserverForName:RCTJavaScriptDidFailToLoadNotification
100
+ object:nil queue:NSOperationQueue.mainQueue usingBlock:^(__unused NSNotification *notification) {
101
+ BrownfieldPerformanceReset();
102
+ }];
103
+ }
@@ -15,6 +15,17 @@ final class ExpoHostRuntime {
15
15
  private var delegate = ExpoHostRuntimeDelegate()
16
16
  private var reactNativeFactory: RCTReactNativeFactory?
17
17
  private var expoDelegate: ExpoAppDelegate?
18
+ #if canImport(EXUpdates)
19
+ private let updatesStartup = ExpoUpdatesStartup()
20
+ #endif
21
+
22
+ func prepareReactNative(_ completion: @escaping () -> Void) {
23
+ #if canImport(EXUpdates)
24
+ updatesStartup.prepareReactNative(completion)
25
+ #else
26
+ completion()
27
+ #endif
28
+ }
18
29
 
19
30
  private func configureDevLoadingView(with bundleURL: URL? = nil) {
20
31
  #if DEBUG
@@ -37,7 +48,7 @@ final class ExpoHostRuntime {
37
48
  *
38
49
  * @param onBundleLoaded Optional callback invoked on the main thread after JS bundle is fully loaded.
39
50
  */
40
- public func startReactNative(onBundleLoaded: (() -> Void)?) {
51
+ public func startReactNative(onBundleLoaded: ((JSBundleTimings) -> Void)?) {
41
52
  // The callback registration is outside of the guard below. An earlier `startReactNative` call
42
53
  // can already have made the factory, and the callback must still run.
43
54
  if let onBundleLoaded {
@@ -73,6 +84,9 @@ final class ExpoHostRuntime {
73
84
  reactNativeFactory = nil
74
85
  expoDelegate = nil
75
86
  preloadState.reset()
87
+ #if canImport(EXUpdates)
88
+ updatesStartup.cancelPendingRequests()
89
+ #endif
76
90
  }
77
91
 
78
92
  /**
@@ -164,11 +178,42 @@ final class ExpoHostRuntime {
164
178
  return ExpoAppDelegateSubscriberManager.application(application, willFinishLaunchingWithOptions: launchOptions)
165
179
  }
166
180
 
181
+ /// `onRootView` receives the React Native root view, or `nil` when React Native can't create
182
+ /// it. While Updates selects its launch asset, this happens after `view` returns a container.
167
183
  func view(
184
+ moduleName: String,
185
+ initialProps: [AnyHashable: Any]?,
186
+ launchOptions: [AnyHashable: Any]?,
187
+ onRootView: ((UIView?) -> Void)? = nil
188
+ ) -> UIView? {
189
+ // Return a container while Updates selects its launch asset. This also supports the
190
+ // public view API, which does not go through ReactNativeViewController.
191
+ let container = UIView()
192
+ var immediateView: UIView?
193
+ var preparing = true
194
+ prepareReactNative { [weak self, weak container] in
195
+ guard let self, preparing || container != nil else { return }
196
+ let root = self.createView(moduleName: moduleName, initialProps: initialProps,
197
+ launchOptions: launchOptions)
198
+ if preparing {
199
+ immediateView = root
200
+ } else if let container, let root {
201
+ root.frame = container.bounds
202
+ root.autoresizingMask = [.flexibleWidth, .flexibleHeight]
203
+ container.addSubview(root)
204
+ }
205
+ onRootView?(root)
206
+ }
207
+ preparing = false
208
+ return immediateView ?? container
209
+ }
210
+
211
+ private func createView(
168
212
  moduleName: String,
169
213
  initialProps: [AnyHashable: Any]?,
170
214
  launchOptions: [AnyHashable: Any]?
171
215
  ) -> UIView? {
216
+ startReactNative()
172
217
  let bundleURL = delegate.bundleURL()
173
218
  configureDevLoadingView(with: bundleURL)
174
219
 
@@ -201,8 +246,8 @@ extension ExpoHostRuntime: ReactHostPreloading {
201
246
 
202
247
  /**
203
248
  * 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.
249
+ * is a Debug behavior. Packaged apps opting into an embedded bundle or an explicit URL
250
+ * can preload without the launcher. In Release the launcher does nothing.
206
251
  *
207
252
  * The check reads the class name, because this pod has no dependency on expo-dev-launcher.
208
253
  * `EXDevLauncherController` is the name in expo-dev-launcher 56 and 57. If a later version
@@ -213,7 +258,8 @@ extension ExpoHostRuntime: ReactHostPreloading {
213
258
  */
214
259
  func canPreloadReactNative() -> Bool {
215
260
  #if DEBUG
216
- if NSClassFromString("EXDevLauncherController") != nil {
261
+ if !preferEmbeddedBundleInDebug && bundleURLOverride?() == nil,
262
+ NSClassFromString("EXDevLauncherController") != nil {
217
263
  return false
218
264
  }
219
265
  #endif
@@ -251,8 +297,8 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate {
251
297
  }
252
298
 
253
299
  #if canImport(EXUpdates)
254
- if !isDebug,
255
- AppController.isInitialized(),
300
+ if AppController.isInitialized(),
301
+ AppController.sharedInstance.isActiveController,
256
302
  let launchAssetURL = AppController.sharedInstance.launchAssetUrl()
257
303
  {
258
304
  return launchAssetURL
@@ -286,20 +332,18 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate {
286
332
  *
287
333
  * The override wins over every other step, thus an override makes the URL stable.
288
334
  *
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.
335
+ * The shared runtime waits for Updates before preloading or creating views. Inactive
336
+ * controllers use the normal bundle resolver and do not need an Updates launch asset.
295
337
  */
296
338
  var isBundleURLStable: Bool {
297
339
  if bundleURLOverride?() != nil {
298
340
  return true
299
341
  }
300
342
 
301
- #if canImport(EXUpdates) && !DEBUG
302
- return AppController.isInitialized() && AppController.sharedInstance.launchAssetUrl() != nil
343
+ #if canImport(EXUpdates)
344
+ guard AppController.isInitialized() else { return false }
345
+ return !AppController.sharedInstance.isActiveController
346
+ || AppController.sharedInstance.launchAssetUrl() != nil
303
347
  #else
304
348
  return true
305
349
  #endif
@@ -0,0 +1,49 @@
1
+ #if canImport(EXUpdates)
2
+ import Foundation
3
+ internal import EXUpdates
4
+
5
+ final class ExpoUpdatesStartup: NSObject {
6
+ private var updatesReady = false
7
+ private var updatesCallbacks: [() -> Void] = []
8
+
9
+ // Updates belongs to the application lifetime, not to a view or React Host session.
10
+ // Inactive controllers (including DevLauncherAppController) must never be started here.
11
+ func prepareReactNative(_ completion: @escaping () -> Void) {
12
+ if !Thread.isMainThread {
13
+ DispatchQueue.main.async { [weak self] in self?.prepareReactNative(completion) }
14
+ return
15
+ }
16
+ AppController.initializeWithoutStarting()
17
+ let controller = AppController.sharedInstance
18
+ guard controller.isActiveController else { completion(); return }
19
+ if updatesReady || controller.launchAssetUrl() != nil {
20
+ completion()
21
+ return
22
+ }
23
+ updatesCallbacks.append(completion)
24
+ controller.delegate = self
25
+ if !controller.isStarted {
26
+ controller.start()
27
+ }
28
+ }
29
+
30
+ func cancelPendingRequests() { updatesCallbacks.removeAll() }
31
+ }
32
+ #endif
33
+
34
+ #if canImport(EXUpdates)
35
+ extension ExpoUpdatesStartup: AppControllerDelegate {
36
+ func appController(_ appController: any AppControllerInterface, didStartWithSuccess success: Bool) {
37
+ DispatchQueue.main.async { [weak self] in
38
+ guard let self else { return }
39
+ self.updatesReady = true
40
+ if !success {
41
+ NSLog("%@", "Expo Updates failed to select a launch asset; using the bundle resolver fallback.")
42
+ }
43
+ let callbacks = self.updatesCallbacks
44
+ self.updatesCallbacks.removeAll()
45
+ callbacks.forEach { $0() }
46
+ }
47
+ }
48
+ }
49
+ #endif
@@ -1,33 +1,39 @@
1
1
  import Foundation
2
2
 
3
3
  /**
4
- * Watches the `RCTInstanceDidLoadBundle` notification, and calls the callback that waits for it.
4
+ * Watches the Brownfield timing-ready notification, and calls the callback that waits for it.
5
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.
6
+ * The timing observer forwards React Native completion to the main thread after capturing
7
+ * timings. This class also delivers callbacks on the main thread for UI updates.
8
8
  *
9
9
  * The class starts to watch at the initialization, and not at the first callback. The class then
10
10
  * knows that React Native loaded the bundle, also if nobody waited for the notification.
11
11
  */
12
12
  final class JSBundleLoadObserver {
13
- private var pendingCallback: (() -> Void)?
13
+ private var pendingCallback: ((JSBundleTimings) -> Void)?
14
14
  private var didLoadBundle = false
15
- private var observerToken: NSObjectProtocol?
15
+ private var latestTimings: JSBundleTimings?
16
+ private var observerTokens: [NSObjectProtocol] = []
16
17
 
17
18
  init() {
18
- observerToken = NotificationCenter.default.addObserver(
19
- forName: NSNotification.Name("RCTInstanceDidLoadBundle"),
19
+ observerTokens.append(NotificationCenter.default.addObserver(
20
+ forName: .JSBundleTimingDidLoad,
20
21
  object: nil,
21
22
  queue: .main
22
23
  ) { [weak self] _ in
23
24
  self?.bundleDidLoad()
24
- }
25
+ })
26
+ observerTokens.append(NotificationCenter.default.addObserver(
27
+ forName: NSNotification.Name("RCTJavaScriptDidFailToLoadNotification"),
28
+ object: nil,
29
+ queue: .main
30
+ ) { [weak self] _ in
31
+ self?.bundleDidFailToLoad()
32
+ })
25
33
  }
26
34
 
27
35
  deinit {
28
- if let observerToken {
29
- NotificationCenter.default.removeObserver(observerToken)
30
- }
36
+ observerTokens.forEach(NotificationCenter.default.removeObserver)
31
37
  }
32
38
 
33
39
  /**
@@ -35,31 +41,37 @@ final class JSBundleLoadObserver {
35
41
  * React Native already loaded the bundle, the class calls the callback in the next turn of the
36
42
  * main run loop.
37
43
  *
38
- * @param onBundleLoaded The class always calls this callback on the main thread.
44
+ * @param onBundleLoaded The class always calls this callback on the main thread, with the
45
+ * timings of the session that loaded the bundle.
39
46
  */
40
- func observe(onBundleLoaded: @escaping () -> Void) {
47
+ func observe(onBundleLoaded: @escaping (JSBundleTimings) -> Void) {
41
48
  onMainThread { [weak self] in
42
49
  self?.register(onBundleLoaded)
43
50
  }
44
51
  }
45
52
 
46
53
  /**
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.
54
+ * Removes the callback that waits, forgets the bundle of the earlier session, and clears stored
55
+ * JS bundle timings. Call this method when you stop React Native. A callback of the earlier
56
+ * session must not run in the next session, and the next session must not reuse the last timings.
50
57
  */
51
58
  func reset() {
52
59
  onMainThread { [weak self] in
53
60
  self?.pendingCallback = nil
54
61
  self?.didLoadBundle = false
62
+ self?.latestTimings = nil
63
+ JSBundleTimingObserver.reset()
55
64
  }
56
65
  }
57
66
 
58
67
  // MARK: - Main thread only
59
68
 
60
- private func register(_ onBundleLoaded: @escaping () -> Void) {
69
+ private func register(_ onBundleLoaded: @escaping (JSBundleTimings) -> Void) {
61
70
  guard !didLoadBundle else {
62
- DispatchQueue.main.async(execute: onBundleLoaded)
71
+ let timings = latestTimings ?? Self.snapshotTimings()
72
+ DispatchQueue.main.async {
73
+ onBundleLoaded(timings)
74
+ }
63
75
  return
64
76
  }
65
77
 
@@ -68,11 +80,36 @@ final class JSBundleLoadObserver {
68
80
 
69
81
  private func bundleDidLoad() {
70
82
  didLoadBundle = true
83
+ latestTimings = Self.snapshotTimings()
71
84
 
72
85
  let callback = pendingCallback
73
86
  pendingCallback = nil
74
87
 
75
- callback?()
88
+ if let callback, let timings = latestTimings {
89
+ callback(timings)
90
+ }
91
+ }
92
+
93
+ /**
94
+ * A failed load, for example a reload in development, replaces the bundle that loaded earlier.
95
+ * Forget it and its timings, so a new callback waits for the next load. A callback that waits
96
+ * keeps waiting: it belongs to this session, and runs when a reload succeeds.
97
+ */
98
+ private func bundleDidFailToLoad() {
99
+ didLoadBundle = false
100
+ latestTimings = nil
101
+ }
102
+
103
+ /**
104
+ * The timing observer posts our notification only after storing the snapshot. This avoids
105
+ * depending on NotificationCenter observer ordering, including for late registrations.
106
+ */
107
+ private static func snapshotTimings() -> JSBundleTimings {
108
+ JSBundleTimings(
109
+ jsBundleLoadTime: JSBundleTimingObserver.jsBundleLoadTime,
110
+ jsBundleEvaluationTime: JSBundleTimingObserver.jsBundleEvaluationTime,
111
+ timeline: JSBundleTimingObserver.timeline
112
+ )
76
113
  }
77
114
 
78
115
  private func onMainThread(_ work: @escaping () -> Void) {
@@ -0,0 +1,26 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+
5
+ // Posted on main after the timing snapshot is ready.
6
+ FOUNDATION_EXPORT NSNotificationName const JSBundleTimingDidLoadNotification;
7
+
8
+ /**
9
+ * Captures JS bundle timings from `RCTPerformanceLogger` when React Native
10
+ * finishes (or fails) loading the bundle.
11
+ *
12
+ * The observer registers at binary load, before Swift host singletons run.
13
+ */
14
+ @interface JSBundleTimingObserver : NSObject
15
+
16
+ @property (class, nonatomic, readonly, nullable) NSNumber *jsBundleLoadTime;
17
+ @property (class, nonatomic, readonly, nullable) NSNumber *jsBundleEvaluationTime;
18
+
19
+ // Completed startup intervals, in monotonic milliseconds.
20
+ @property (class, nonatomic, readonly) NSArray<NSDictionary<NSString *, id> *> *timeline;
21
+
22
+ + (void)reset;
23
+
24
+ @end
25
+
26
+ NS_ASSUME_NONNULL_END
@@ -0,0 +1,140 @@
1
+ #import "JSBundleTimingObserver.h"
2
+
3
+ #import <React/RCTBridge.h>
4
+ #import <React/RCTConstants.h>
5
+ #import <React/RCTPerformanceLogger.h>
6
+ #import <React/RCTPLTag.h>
7
+ #import <objc/runtime.h>
8
+
9
+ static NSNumber *sJSBundleLoadTime;
10
+ static NSNumber *sJSBundleEvaluationTime;
11
+ static NSArray *sTimeline;
12
+
13
+ static NSString *const kRCTInstanceDidLoadBundleNotification = @"RCTInstanceDidLoadBundle";
14
+
15
+ NSNotificationName const JSBundleTimingDidLoadNotification = @"BrownfieldJSBundleTimingDidLoad";
16
+
17
+ // Check the endpoints: a completed interval can round to zero milliseconds,
18
+ // while an unfinished interval can produce a negative durationForTag: value.
19
+ static NSNumber *JSBundleTimingNumberOrNil(NSArray<NSNumber *> *values, RCTPLTag tag)
20
+ {
21
+ NSUInteger index = (NSUInteger)tag * 2;
22
+ if (values.count <= index + 1) {
23
+ return nil;
24
+ }
25
+ int64_t start = values[index].longLongValue;
26
+ int64_t stop = values[index + 1].longLongValue;
27
+ return start > 0 && stop >= start ? @(stop - start) : nil;
28
+ }
29
+
30
+ static void JSBundleTimingClearTagSnapshot(void)
31
+ {
32
+ sJSBundleLoadTime = nil;
33
+ sJSBundleEvaluationTime = nil;
34
+ sTimeline = @[];
35
+ }
36
+
37
+ @implementation JSBundleTimingObserver
38
+
39
+ + (NSNumber *)jsBundleLoadTime
40
+ {
41
+ return sJSBundleLoadTime;
42
+ }
43
+
44
+ + (NSNumber *)jsBundleEvaluationTime
45
+ {
46
+ return sJSBundleEvaluationTime;
47
+ }
48
+
49
+ + (NSArray *)timeline { return sTimeline ?: @[]; }
50
+
51
+ + (void)reset
52
+ {
53
+ if ([NSThread isMainThread]) {
54
+ JSBundleTimingClearTagSnapshot();
55
+ } else {
56
+ dispatch_async(dispatch_get_main_queue(), ^{
57
+ [self reset];
58
+ });
59
+ }
60
+ }
61
+
62
+ + (RCTPerformanceLogger *)performanceLoggerFromNotification:(NSNotification *)notification
63
+ {
64
+ id bridge = notification.userInfo[@"bridge"];
65
+ RCTPerformanceLogger *logger = nil;
66
+
67
+ // RCTBridgeProxy inherits NSProxy's forwarding-based respondsToSelector:.
68
+ // It can report NO even for its synthesized performanceLogger getter. Inspect
69
+ // the actual class method table so we can safely call an implemented getter.
70
+ Class bridgeClass = object_getClass(bridge);
71
+ BOOL hasLoggerGetter = class_getInstanceMethod(bridgeClass, @selector(performanceLogger)) != NULL;
72
+ if (hasLoggerGetter) {
73
+ logger = [bridge performanceLogger];
74
+ }
75
+
76
+ return logger;
77
+ }
78
+
79
+ + (void)captureTimingsFromNotification:(NSNotification *)notification
80
+ {
81
+ RCTPerformanceLogger *logger = [self performanceLoggerFromNotification:notification];
82
+ if (logger == nil) {
83
+ JSBundleTimingClearTagSnapshot();
84
+ return;
85
+ }
86
+
87
+ NSArray<NSNumber *> *values = [logger valuesForTags];
88
+ sJSBundleLoadTime = JSBundleTimingNumberOrNil(values, RCTPLScriptDownload);
89
+ sJSBundleEvaluationTime = JSBundleTimingNumberOrNil(values, RCTPLScriptExecution);
90
+ // These intervals can overlap. Preserve endpoints instead of adding durations.
91
+ RCTPLTag tags[] = {RCTPLScriptDownload, RCTPLScriptExecution};
92
+ NSArray *names = @[@"RCTPLScriptDownload", @"RCTPLScriptExecution"];
93
+ NSMutableArray *timeline = [NSMutableArray new];
94
+ for (NSUInteger i = 0; i < 2; i++) {
95
+ NSUInteger index = (NSUInteger)tags[i] * 2;
96
+ if (JSBundleTimingNumberOrNil(values, tags[i]) != nil) {
97
+ [timeline addObject:@{@"tag": names[i], @"startMs": values[index], @"stopMs": values[index + 1]}];
98
+ }
99
+ }
100
+ sTimeline = [timeline sortedArrayUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"startMs" ascending:YES]]];
101
+ }
102
+
103
+ @end
104
+
105
+ __attribute__((constructor)) static void JSBundleTimingObserverRegister(void)
106
+ {
107
+ NSNotificationCenter *center = [NSNotificationCenter defaultCenter];
108
+ NSOperationQueue *mainQueue = [NSOperationQueue mainQueue];
109
+
110
+ [center addObserverForName:RCTJavaScriptDidLoadNotification
111
+ object:nil
112
+ queue:mainQueue
113
+ usingBlock:^(NSNotification *notification) {
114
+ [JSBundleTimingObserver captureTimingsFromNotification:notification];
115
+ [[NSNotificationCenter defaultCenter] postNotificationName:JSBundleTimingDidLoadNotification object:nil];
116
+ }];
117
+
118
+ [center addObserverForName:kRCTInstanceDidLoadBundleNotification
119
+ object:nil
120
+ queue:mainQueue
121
+ usingBlock:^(NSNotification *notification) {
122
+ // RN versions that expose the instance logger on the bridge proxy
123
+ // send RCTJavaScriptDidLoadNotification next, with that proxy.
124
+ // currentBridge is a stub, and this earlier notification has no payload.
125
+ Class proxyClass = NSClassFromString(@"RCTBridgeProxy");
126
+ BOOL hasLoggerSetter = [proxyClass instancesRespondToSelector:NSSelectorFromString(@"setPerformanceLogger:")];
127
+ if (hasLoggerSetter) {
128
+ return;
129
+ }
130
+ [JSBundleTimingObserver captureTimingsFromNotification:notification];
131
+ [[NSNotificationCenter defaultCenter] postNotificationName:JSBundleTimingDidLoadNotification object:nil];
132
+ }];
133
+
134
+ [center addObserverForName:RCTJavaScriptDidFailToLoadNotification
135
+ object:nil
136
+ queue:mainQueue
137
+ usingBlock:^(__unused NSNotification *notification) {
138
+ [JSBundleTimingObserver reset];
139
+ }];
140
+ }
@@ -103,10 +103,14 @@ protocol ReactHostPreloading: AnyObject {
103
103
  */
104
104
  func canPreloadReactNative() -> Bool
105
105
 
106
+ func prepareReactNative(_ completion: @escaping () -> Void)
107
+
106
108
  func prepareDevLoadingView()
107
109
  }
108
110
 
109
111
  extension ReactHostPreloading {
112
+ func prepareReactNative(_ completion: @escaping () -> Void) { completion() }
113
+
110
114
  /**
111
115
  * The implementation of
112
116
  * `ReactNativeBrownfield.startReactNative(launchOptions:preloadBundle:onBundleLoaded:)`, which
@@ -115,7 +119,7 @@ extension ReactHostPreloading {
115
119
  func startReactNative(
116
120
  launchOptions: [AnyHashable: Any]?,
117
121
  preloadBundle: Bool,
118
- onBundleLoaded: (() -> Void)?
122
+ onBundleLoaded: ((JSBundleTimings) -> Void)?
119
123
  ) {
120
124
  preloadState.storeLaunchOptions(launchOptions)
121
125
 
@@ -131,10 +135,10 @@ extension ReactHostPreloading {
131
135
  preloadState.requestPreload()
132
136
 
133
137
  if Thread.isMainThread {
134
- createReactHost()
138
+ prepareReactNative { [weak self] in self?.createReactHost() }
135
139
  } else {
136
140
  DispatchQueue.main.async { [weak self] in
137
- self?.createReactHost()
141
+ self?.prepareReactNative { [weak self] in self?.createReactHost() }
138
142
  }
139
143
  }
140
144
  }