@callstack/react-native-brownfield 5.3.0 → 6.0.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 (97) hide show
  1. package/CHANGELOG.md +44 -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/gradle.properties +1 -1
  13. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNSourceSets.kt +59 -15
  14. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibrary.kt +41 -4
  15. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/AgpCompatibilityMatrixTest.kt +6 -8
  16. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/LegacyConsumerConfigurationTest.kt +6 -4
  17. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/RNBundleTaskStubs.kt +27 -0
  18. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/AndroidArchiveLibraryNamespaceFallbackTest.kt +137 -0
  19. package/ios/BrownfieldDisplayMetrics.swift +265 -0
  20. package/ios/BrownfieldPerformanceBridge.h +8 -0
  21. package/ios/BrownfieldPerformanceBridge.mm +103 -0
  22. package/ios/Expo/ExpoHostRuntime.swift +60 -14
  23. package/ios/Expo/ExpoUpdatesStartup.swift +50 -0
  24. package/ios/JSBundleLoadObserver.swift +56 -19
  25. package/ios/JSBundleTimingObserver.h +26 -0
  26. package/ios/JSBundleTimingObserver.m +140 -0
  27. package/ios/ReactHostPreloading.swift +7 -3
  28. package/ios/ReactNativeBrownfield.swift +82 -14
  29. package/ios/ReactNativeBrownfield.xcodeproj/project.pbxproj +16 -0
  30. package/ios/Vanilla/ReactNativeHostRuntime.swift +1 -1
  31. package/ios/Views/ReactNativeView.swift +21 -3
  32. package/ios/Views/ReactNativeViewController.swift +44 -42
  33. package/lib/commonjs/expo-config-plugin/android/utils/constants.js +1 -1
  34. package/lib/commonjs/expo-config-plugin/ios/podfileHelpers.js +2 -2
  35. package/lib/commonjs/expo-config-plugin/ios/podfileHelpers.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 +5 -7
  41. package/lib/commonjs/expo-config-plugin/template/android/consumer-rules.pro +7 -0
  42. package/lib/commonjs/expo-config-plugin/template/ios/ExpoModulesProviderExtension.swift +7 -0
  43. package/lib/commonjs/index.js +1 -1
  44. package/lib/commonjs/index.js.map +1 -1
  45. package/lib/commonjs/scaffold/index.js +1 -1
  46. package/lib/commonjs/scaffold/index.js.map +1 -1
  47. package/lib/commonjs/scaffold/projectFiles.js +1 -1
  48. package/lib/commonjs/scaffold/projectFiles.js.map +1 -1
  49. package/lib/module/expo-config-plugin/android/utils/constants.js +1 -1
  50. package/lib/module/expo-config-plugin/ios/podfileHelpers.js +2 -2
  51. package/lib/module/expo-config-plugin/ios/podfileHelpers.js.map +1 -1
  52. package/lib/module/expo-config-plugin/ios/withIosFrameworkFiles.js +1 -1
  53. package/lib/module/expo-config-plugin/ios/withIosFrameworkFiles.js.map +1 -1
  54. package/lib/module/expo-config-plugin/ios/xcodeHelpers.js +2 -2
  55. package/lib/module/expo-config-plugin/ios/xcodeHelpers.js.map +1 -1
  56. package/lib/module/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt +5 -7
  57. package/lib/module/expo-config-plugin/template/android/consumer-rules.pro +7 -0
  58. package/lib/module/expo-config-plugin/template/ios/ExpoModulesProviderExtension.swift +7 -0
  59. package/lib/module/index.js +1 -1
  60. package/lib/module/index.js.map +1 -1
  61. package/lib/module/scaffold/index.js +1 -1
  62. package/lib/module/scaffold/index.js.map +1 -1
  63. package/lib/module/scaffold/projectFiles.js +1 -1
  64. package/lib/module/scaffold/projectFiles.js.map +1 -1
  65. package/lib/typescript/commonjs/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
  66. package/lib/typescript/commonjs/src/expo-config-plugin/ios/podfileHelpers.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts +5 -0
  68. package/lib/typescript/commonjs/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/src/expo-config-plugin/ios/xcodeHelpers.d.ts +5 -0
  70. package/lib/typescript/commonjs/src/expo-config-plugin/ios/xcodeHelpers.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/src/index.d.ts +7 -0
  72. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/src/scaffold/index.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/src/scaffold/projectFiles.d.ts +2 -2
  75. package/lib/typescript/commonjs/src/scaffold/projectFiles.d.ts.map +1 -1
  76. package/lib/typescript/module/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
  77. package/lib/typescript/module/src/expo-config-plugin/ios/podfileHelpers.d.ts.map +1 -1
  78. package/lib/typescript/module/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts +5 -0
  79. package/lib/typescript/module/src/expo-config-plugin/ios/withIosFrameworkFiles.d.ts.map +1 -1
  80. package/lib/typescript/module/src/expo-config-plugin/ios/xcodeHelpers.d.ts +5 -0
  81. package/lib/typescript/module/src/expo-config-plugin/ios/xcodeHelpers.d.ts.map +1 -1
  82. package/lib/typescript/module/src/index.d.ts +7 -0
  83. package/lib/typescript/module/src/index.d.ts.map +1 -1
  84. package/lib/typescript/module/src/scaffold/index.d.ts.map +1 -1
  85. package/lib/typescript/module/src/scaffold/projectFiles.d.ts +2 -2
  86. package/lib/typescript/module/src/scaffold/projectFiles.d.ts.map +1 -1
  87. package/package.json +2 -2
  88. package/src/expo-config-plugin/android/utils/constants.ts +1 -1
  89. package/src/expo-config-plugin/ios/podfileHelpers.ts +47 -13
  90. package/src/expo-config-plugin/ios/withIosFrameworkFiles.ts +11 -1
  91. package/src/expo-config-plugin/ios/xcodeHelpers.ts +67 -9
  92. package/src/expo-config-plugin/template/android/ReactNativeHostManager.vanilla.kt +5 -7
  93. package/src/expo-config-plugin/template/android/consumer-rules.pro +7 -0
  94. package/src/expo-config-plugin/template/ios/ExpoModulesProviderExtension.swift +7 -0
  95. package/src/index.ts +13 -1
  96. package/src/scaffold/index.ts +60 -4
  97. package/src/scaffold/projectFiles.ts +5 -3
@@ -0,0 +1,50 @@
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
+ // expo-updates 57 traps in launchAssetUrl() until start() sets its startup procedure.
20
+ if updatesReady || (controller.isStarted && controller.launchAssetUrl() != nil) {
21
+ completion()
22
+ return
23
+ }
24
+ updatesCallbacks.append(completion)
25
+ controller.delegate = self
26
+ if !controller.isStarted {
27
+ controller.start()
28
+ }
29
+ }
30
+
31
+ func cancelPendingRequests() { updatesCallbacks.removeAll() }
32
+ }
33
+ #endif
34
+
35
+ #if canImport(EXUpdates)
36
+ extension ExpoUpdatesStartup: AppControllerDelegate {
37
+ func appController(_ appController: any AppControllerInterface, didStartWithSuccess success: Bool) {
38
+ DispatchQueue.main.async { [weak self] in
39
+ guard let self else { return }
40
+ self.updatesReady = true
41
+ if !success {
42
+ NSLog("%@", "Expo Updates failed to select a launch asset; using the bundle resolver fallback.")
43
+ }
44
+ let callbacks = self.updatesCallbacks
45
+ self.updatesCallbacks.removeAll()
46
+ callbacks.forEach { $0() }
47
+ }
48
+ }
49
+ }
50
+ #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
  }
@@ -4,6 +4,35 @@ import UIKit
4
4
  internal import Expo
5
5
  #endif
6
6
 
7
+ /**
8
+ * JS bundle timings from React Native's performance logger.
9
+ *
10
+ * - `jsBundleLoadTime` is `RCTPLScriptDownload` (Metro HTTP in Debug, file read in Release).
11
+ * - `jsBundleEvaluationTime` is `RCTPLScriptExecution`.
12
+ *
13
+ * A tag field is `nil` when the tag never completed (failed load, logger missing, or
14
+ * bridgeless React Native below 0.87).
15
+ */
16
+ @objc public class JSBundleTimings: NSObject {
17
+ @objc public let jsBundleLoadTime: NSNumber?
18
+ @objc public let jsBundleEvaluationTime: NSNumber?
19
+ @objc public let timeline: [[String: Any]]
20
+
21
+ @objc public init(jsBundleLoadTime: NSNumber?, jsBundleEvaluationTime: NSNumber?) {
22
+ self.timeline = []
23
+ self.jsBundleLoadTime = jsBundleLoadTime
24
+ self.jsBundleEvaluationTime = jsBundleEvaluationTime
25
+ super.init()
26
+ }
27
+
28
+ @objc public init(jsBundleLoadTime: NSNumber?, jsBundleEvaluationTime: NSNumber?, timeline: [[String: Any]]) {
29
+ self.jsBundleLoadTime = jsBundleLoadTime
30
+ self.jsBundleEvaluationTime = jsBundleEvaluationTime
31
+ self.timeline = timeline
32
+ super.init()
33
+ }
34
+ }
35
+
7
36
  @objc public class ReactNativeBrownfield: NSObject {
8
37
  public static let shared = ReactNativeBrownfield()
9
38
 
@@ -100,6 +129,7 @@ internal import Expo
100
129
  * Stops React Native.
101
130
  */
102
131
  @objc public func stopReactNative() {
132
+ NotificationCenter.default.post(name: Notification.Name("BrownfieldPerformanceStop"), object: nil)
103
133
  #if canImport(Expo)
104
134
  ExpoHostRuntime.shared.stopReactNative()
105
135
  #else
@@ -112,19 +142,57 @@ internal import Expo
112
142
  initialProps: [AnyHashable: Any]?,
113
143
  launchOptions: [AnyHashable: Any]? = nil
114
144
  ) -> UIView? {
145
+ makeView(moduleName: moduleName, initialProps: initialProps, launchOptions: launchOptions,
146
+ session: nil, isHostVisible: nil)
147
+ }
148
+
149
+ /// Creates a measured presentation. The completion receives one immutable snapshot on main.
150
+ /// Opt in to wait for JS `markFullyDisplayed(brownfieldPresentationID)`.
151
+ /// Thread sampling requires both collectThreadMetrics and an onMetrics callback.
152
+ @objc public func view(
153
+ moduleName: String,
154
+ initialProps: [AnyHashable: Any]?,
155
+ launchOptions: [AnyHashable: Any]? = nil,
156
+ waitForFullDisplay: Bool = false,
157
+ collectThreadMetrics: Bool = false,
158
+ onMetrics: ((BrownfieldDisplayMetrics) -> Void)?
159
+ ) -> UIView? {
160
+ let session = BrownfieldDisplaySession(moduleName: moduleName,
161
+ waitForFullDisplay: waitForFullDisplay, collectThreadMetrics: collectThreadMetrics,
162
+ callback: onMetrics)
163
+ return makeView(moduleName: moduleName, initialProps: initialProps,
164
+ launchOptions: launchOptions, session: session, isHostVisible: nil)
165
+ }
166
+
167
+ /// Only measured presentations get a session. Without one, `initialProps` pass through as is.
168
+ /// `isHostVisible` is set by `ReactNativeViewController`, which reports appearance itself.
169
+ internal func makeView(moduleName: String, initialProps: [AnyHashable: Any]?,
170
+ launchOptions: [AnyHashable: Any]?, session: BrownfieldDisplaySession?,
171
+ isHostVisible: (() -> Bool)?) -> UIView? {
172
+ var props = initialProps
173
+ if let session {
174
+ props = (props ?? [:]).merging(["brownfieldPresentationID": session.id]) { $1 }
175
+ }
176
+ // The probe goes into the React Native root, not into a placeholder, so initial display
177
+ // can't complete before the root exists.
178
+ let attachProbe: (UIView?) -> Void = { rootView in
179
+ guard let session else { return }
180
+ guard let rootView else { session.cancel(); return }
181
+ let probe = BrownfieldAppearanceProbe(session: session)
182
+ probe.isHostVisible = isHostVisible
183
+ session.hasRootView = true
184
+ rootView.addSubview(probe)
185
+ }
115
186
  #if canImport(Expo)
116
- ExpoHostRuntime.shared.view(
117
- moduleName: moduleName,
118
- initialProps: initialProps,
119
- launchOptions: launchOptions
120
- )
187
+ let view = ExpoHostRuntime.shared.view(moduleName: moduleName, initialProps: props,
188
+ launchOptions: launchOptions, onRootView: attachProbe)
121
189
  #else
122
- ReactNativeHostRuntime.shared.view(
123
- moduleName: moduleName,
124
- initialProps: initialProps,
125
- launchOptions: launchOptions
126
- )
190
+ let view = ReactNativeHostRuntime.shared.view(moduleName: moduleName, initialProps: props,
191
+ launchOptions: launchOptions)
192
+ attachProbe(view)
127
193
  #endif
194
+ if view == nil { session?.cancel() }
195
+ return view
128
196
  }
129
197
 
130
198
  /**
@@ -193,10 +261,10 @@ internal import Expo
193
261
  * Starts React Native with optional callback when bundle is loaded.
194
262
  *
195
263
  * @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.
264
+ * fully loaded. It receives `JSBundleTimings`. It runs in the next turn of the main run loop
265
+ * when the bundle is already loaded. A new callback replaces the callback that waits.
198
266
  */
199
- @objc public func startReactNative(onBundleLoaded: (() -> Void)?) {
267
+ @objc public func startReactNative(onBundleLoaded: ((JSBundleTimings) -> Void)?) {
200
268
  #if canImport(Expo)
201
269
  ExpoHostRuntime.shared.startReactNative(onBundleLoaded: onBundleLoaded)
202
270
  #else
@@ -220,7 +288,7 @@ internal import Expo
220
288
  @objc public func startReactNative(
221
289
  launchOptions: [AnyHashable: Any]?,
222
290
  preloadBundle: Bool,
223
- onBundleLoaded: (() -> Void)?
291
+ onBundleLoaded: ((JSBundleTimings) -> Void)?
224
292
  ) {
225
293
  #if canImport(Expo)
226
294
  ExpoHostRuntime.shared.startReactNative(
@@ -17,6 +17,9 @@
17
17
  79FEE77E2FB499E400E407DE /* ReactNativeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79FEE77C2FB499E400E407DE /* ReactNativeViewController.swift */; };
18
18
  79FEE7802FB499EC00E407DE /* ReactNativeHostRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79FEE77F2FB499EC00E407DE /* ReactNativeHostRuntime.swift */; };
19
19
  79FEE7822FB499F300E407DE /* ExpoHostRuntime.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79FEE7812FB499F300E407DE /* ExpoHostRuntime.swift */; };
20
+ 79FEE7852FB500A100E407DE /* JSBundleTimingObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = 79FEE7842FB500A100E407DE /* JSBundleTimingObserver.m */; };
21
+ BEAF10000000000000000000 /* BrownfieldDisplayMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEAF00000000000000000000 /* BrownfieldDisplayMetrics.swift */; };
22
+ BEAF10000000000000000002 /* BrownfieldPerformanceBridge.mm in Sources */ = {isa = PBXBuildFile; fileRef = BEAF00000000000000000002 /* BrownfieldPerformanceBridge.mm */; };
20
23
  /* End PBXBuildFile section */
21
24
 
22
25
  /* Begin PBXCopyFilesBuildPhase section */
@@ -44,6 +47,11 @@
44
47
  79FEE77C2FB499E400E407DE /* ReactNativeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactNativeViewController.swift; sourceTree = "<group>"; };
45
48
  79FEE77F2FB499EC00E407DE /* ReactNativeHostRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactNativeHostRuntime.swift; sourceTree = "<group>"; };
46
49
  79FEE7812FB499F300E407DE /* ExpoHostRuntime.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExpoHostRuntime.swift; sourceTree = "<group>"; };
50
+ 79FEE7832FB500A100E407DE /* JSBundleTimingObserver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = JSBundleTimingObserver.h; sourceTree = "<group>"; };
51
+ 79FEE7842FB500A100E407DE /* JSBundleTimingObserver.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = JSBundleTimingObserver.m; sourceTree = "<group>"; };
52
+ BEAF00000000000000000000 /* BrownfieldDisplayMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrownfieldDisplayMetrics.swift; sourceTree = "<group>"; };
53
+ BEAF00000000000000000001 /* BrownfieldPerformanceBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BrownfieldPerformanceBridge.h; sourceTree = "<group>"; };
54
+ BEAF00000000000000000002 /* BrownfieldPerformanceBridge.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = BrownfieldPerformanceBridge.mm; sourceTree = "<group>"; };
47
55
  /* End PBXFileReference section */
48
56
 
49
57
  /* Begin PBXFrameworksBuildPhase section */
@@ -65,7 +73,12 @@
65
73
  79FEE7692FB4997100E407DE /* Expo */,
66
74
  696CB22A230EF910000207AF /* Products */,
67
75
  79FEE76D2FB499C700E407DE /* BrownfieldBundlePathResolver.swift */,
76
+ BEAF00000000000000000000 /* BrownfieldDisplayMetrics.swift */,
77
+ BEAF00000000000000000001 /* BrownfieldPerformanceBridge.h */,
78
+ BEAF00000000000000000002 /* BrownfieldPerformanceBridge.mm */,
68
79
  79FEE76E2FB499C700E407DE /* JSBundleLoadObserver.swift */,
80
+ 79FEE7832FB500A100E407DE /* JSBundleTimingObserver.h */,
81
+ 79FEE7842FB500A100E407DE /* JSBundleTimingObserver.m */,
69
82
  79FEE76F2FB499C700E407DE /* Notification+Brownfield.swift */,
70
83
  79FEE7702FB499C700E407DE /* ReactNativeBrownfield.swift */,
71
84
  79FEE7712FB499C700E407DE /* ReactNativeBrownfieldModule.h */,
@@ -170,7 +183,10 @@
170
183
  79FEE7762FB499C700E407DE /* Notification+Brownfield.swift in Sources */,
171
184
  79FEE7822FB499F300E407DE /* ExpoHostRuntime.swift in Sources */,
172
185
  79FEE7772FB499C700E407DE /* ReactNativeBrownfieldModule.swift in Sources */,
186
+ BEAF10000000000000000000 /* BrownfieldDisplayMetrics.swift in Sources */,
187
+ BEAF10000000000000000002 /* BrownfieldPerformanceBridge.mm in Sources */,
173
188
  79FEE7782FB499C700E407DE /* JSBundleLoadObserver.swift in Sources */,
189
+ 79FEE7852FB500A100E407DE /* JSBundleTimingObserver.m in Sources */,
174
190
  79FEE7792FB499C700E407DE /* ReactNativeBrownfield.swift in Sources */,
175
191
  79FEE77D2FB499E400E407DE /* ReactNativeView.swift in Sources */,
176
192
  79FEE77E2FB499E400E407DE /* ReactNativeViewController.swift in Sources */,
@@ -191,7 +191,7 @@ final class ReactNativeHostRuntime {
191
191
  *
192
192
  * @param onBundleLoaded Optional callback invoked on the main thread after JS bundle is fully loaded.
193
193
  */
194
- public func startReactNative(onBundleLoaded: (() -> Void)?) {
194
+ public func startReactNative(onBundleLoaded: ((JSBundleTimings) -> Void)?) {
195
195
  // The callback registration is outside of the guard below. An earlier `startReactNative` call
196
196
  // can already have made the factory, and the callback must still run.
197
197
  if let onBundleLoaded {
@@ -17,11 +17,17 @@ struct ReactNativeViewRepresentable: UIViewControllerRepresentable {
17
17
  var moduleName: String
18
18
  var initialProperties: [String: Any] = [:]
19
19
  var host: BrownfieldSurfaceHost
20
+ var waitForFullDisplay = false
21
+ var collectThreadMetrics = false
22
+ var onMetrics: ((BrownfieldDisplayMetrics) -> Void)?
20
23
 
21
24
  func makeUIViewController(context: Context) -> UIViewController {
22
25
  let controller = ReactNativeViewController(
23
26
  moduleName: moduleName,
24
- initialProperties: initialProperties
27
+ initialProperties: initialProperties,
28
+ waitForFullDisplay: waitForFullDisplay,
29
+ collectThreadMetrics: collectThreadMetrics,
30
+ onMetrics: onMetrics
25
31
  )
26
32
  controller.isHostedBySwiftUI = true
27
33
  host.controller = controller
@@ -45,17 +51,29 @@ public struct ReactNativeView: View {
45
51
  @State private var host = BrownfieldSurfaceHost()
46
52
  var moduleName: String
47
53
  var initialProperties: [String: Any] = [:]
54
+ var waitForFullDisplay = false
55
+ var collectThreadMetrics = false
56
+ var onMetrics: ((BrownfieldDisplayMetrics) -> Void)?
48
57
 
49
- public init(moduleName: String, initialProperties: [String : Any] = [:]) {
58
+ public init(moduleName: String, initialProperties: [String : Any] = [:],
59
+ waitForFullDisplay: Bool = false,
60
+ collectThreadMetrics: Bool = false,
61
+ onMetrics: ((BrownfieldDisplayMetrics) -> Void)? = nil) {
50
62
  self.moduleName = moduleName
51
63
  self.initialProperties = initialProperties
64
+ self.waitForFullDisplay = waitForFullDisplay
65
+ self.collectThreadMetrics = collectThreadMetrics
66
+ self.onMetrics = onMetrics
52
67
  }
53
68
 
54
69
  public var body: some View {
55
70
  ReactNativeViewRepresentable(
56
71
  moduleName: moduleName,
57
72
  initialProperties: initialProperties,
58
- host: host
73
+ host: host,
74
+ waitForFullDisplay: waitForFullDisplay,
75
+ collectThreadMetrics: collectThreadMetrics,
76
+ onMetrics: onMetrics
59
77
  )
60
78
  .ignoresSafeArea(.all)
61
79
  // `.popToNative` is public API and may be posted from any thread, while