@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,137 @@
1
+ package com.callstack.react.brownfield.utils
2
+
3
+ import com.callstack.react.brownfield.shared.UnresolvedArtifactInfo
4
+ import org.gradle.testfixtures.ProjectBuilder
5
+ import org.junit.jupiter.api.Test
6
+ import kotlin.test.assertEquals
7
+ import kotlin.test.assertFailsWith
8
+
9
+ class AndroidArchiveLibraryNamespaceFallbackTest {
10
+ private fun artifactFor(moduleName: String) =
11
+ UnresolvedArtifactInfo(
12
+ moduleGroup = "com.example",
13
+ moduleName = moduleName,
14
+ moduleVersion = "1.0.0",
15
+ file = null,
16
+ isExpoPublishDependency = false,
17
+ )
18
+
19
+ @Test
20
+ fun `falls back to the AndroidManifest package when build-gradle declares no namespace`() {
21
+ val root = ProjectBuilder.builder().build()
22
+ val subProject =
23
+ ProjectBuilder.builder()
24
+ .withParent(root)
25
+ .withName("react-native-launch-arguments")
26
+ .build()
27
+
28
+ // No `namespace` declaration, mirroring libraries whose build.gradle predates AGP namespaces.
29
+ subProject.projectDir.mkdirs()
30
+ subProject.buildFile.writeText(
31
+ """
32
+ apply plugin: 'com.android.library'
33
+ """.trimIndent(),
34
+ )
35
+ subProject.projectDir.resolve("src/main").mkdirs()
36
+ subProject.projectDir.resolve("src/main/AndroidManifest.xml").writeText(
37
+ """
38
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
39
+ package="com.reactnativelauncharguments">
40
+ </manifest>
41
+ """.trimIndent(),
42
+ )
43
+
44
+ val library = AndroidArchiveLibrary(root, artifactFor("react-native-launch-arguments"), "devRelease")
45
+
46
+ assertEquals("com.reactnativelauncharguments", library.getPackageName())
47
+ }
48
+
49
+ @Test
50
+ fun `prefers the namespace declared in build-gradle over the manifest package`() {
51
+ val root = ProjectBuilder.builder().build()
52
+ val subProject =
53
+ ProjectBuilder.builder()
54
+ .withParent(root)
55
+ .withName("react-native-screens")
56
+ .build()
57
+
58
+ subProject.projectDir.mkdirs()
59
+ subProject.buildFile.writeText(
60
+ """
61
+ apply plugin: 'com.android.library'
62
+ android {
63
+ namespace = "com.swmansion.rnscreens"
64
+ }
65
+ """.trimIndent(),
66
+ )
67
+ subProject.projectDir.resolve("src/main").mkdirs()
68
+ subProject.projectDir.resolve("src/main/AndroidManifest.xml").writeText(
69
+ """
70
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
71
+ package="com.swmansion.rnscreens.legacy">
72
+ </manifest>
73
+ """.trimIndent(),
74
+ )
75
+
76
+ val library = AndroidArchiveLibrary(root, artifactFor("react-native-screens"), "devRelease")
77
+
78
+ assertEquals("com.swmansion.rnscreens", library.getPackageName())
79
+ }
80
+
81
+ @Test
82
+ fun `reads the package from the manifest root element and ignores comments and child attributes`() {
83
+ val root = ProjectBuilder.builder().build()
84
+ val subProject =
85
+ ProjectBuilder.builder()
86
+ .withParent(root)
87
+ .withName("react-native-legacy-manifest")
88
+ .build()
89
+
90
+ subProject.projectDir.mkdirs()
91
+ subProject.buildFile.writeText(
92
+ """
93
+ apply plugin: 'com.android.library'
94
+ """.trimIndent(),
95
+ )
96
+ subProject.projectDir.resolve("src/main").mkdirs()
97
+ subProject.projectDir.resolve("src/main/AndroidManifest.xml").writeText(
98
+ """
99
+ <!-- legacy location: package="com.example.stale" -->
100
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
101
+ package="com.reactnativelegacymanifest">
102
+ <application>
103
+ <activity
104
+ android:name=".LegacyActivity"
105
+ android:exported="false"
106
+ android:targetPackage="com.example.target" />
107
+ </application>
108
+ </manifest>
109
+ """.trimIndent(),
110
+ )
111
+
112
+ val library = AndroidArchiveLibrary(root, artifactFor("react-native-legacy-manifest"), "devRelease")
113
+
114
+ assertEquals("com.reactnativelegacymanifest", library.getPackageName())
115
+ }
116
+
117
+ @Test
118
+ fun `errors when neither build-gradle nor the manifest declare a namespace`() {
119
+ val root = ProjectBuilder.builder().build()
120
+ val subProject =
121
+ ProjectBuilder.builder()
122
+ .withParent(root)
123
+ .withName("no-namespace-anywhere")
124
+ .build()
125
+
126
+ subProject.projectDir.mkdirs()
127
+ subProject.buildFile.writeText(
128
+ """
129
+ apply plugin: 'com.android.library'
130
+ """.trimIndent(),
131
+ )
132
+
133
+ val library = AndroidArchiveLibrary(root, artifactFor("no-namespace-anywhere"), "devRelease")
134
+
135
+ assertFailsWith<IllegalStateException> { library.getPackageName() }
136
+ }
137
+ }
@@ -0,0 +1,265 @@
1
+ import UIKit
2
+
3
+ /// Frame cadence, not CPU utilization. A missing sampler produces nil FPS/busyRatio.
4
+ @objcMembers public final class BrownfieldThreadMetrics: NSObject {
5
+ public let fps: NSNumber?
6
+ public let busyRatio: NSNumber?
7
+ public let sampledMs: Double
8
+ public let frameCount: Int
9
+
10
+ internal init(fps: Double?, busyRatio: Double?, sampledMs: Double, frameCount: Int) {
11
+ self.fps = fps.map { NSNumber(value: $0) }
12
+ self.busyRatio = busyRatio.map { NSNumber(value: $0) }
13
+ self.sampledMs = sampledMs
14
+ self.frameCount = frameCount
15
+ }
16
+ }
17
+
18
+ @objcMembers public final class BrownfieldDisplayInterval: NSObject {
19
+ public let duration: Double
20
+ public let jsThread: BrownfieldThreadMetrics
21
+ public let uiThread: BrownfieldThreadMetrics
22
+
23
+ internal init(duration: Double, jsThread: BrownfieldThreadMetrics, uiThread: BrownfieldThreadMetrics) {
24
+ self.duration = duration
25
+ self.jsThread = jsThread
26
+ self.uiThread = uiThread
27
+ }
28
+ }
29
+
30
+ /// An immutable completed snapshot, delivered once through onMetrics.
31
+ @objcMembers public final class BrownfieldDisplayMetrics: NSObject {
32
+ public let presentationID: String
33
+ public let moduleName: String
34
+ public let initialDisplay: BrownfieldDisplayInterval?
35
+ public let fullDisplay: BrownfieldDisplayInterval?
36
+ public let isFinal: Bool
37
+ public let isCancelled: Bool
38
+
39
+ internal init(presentationID: String, moduleName: String, initialDisplay: BrownfieldDisplayInterval?,
40
+ fullDisplay: BrownfieldDisplayInterval?, isFinal: Bool, isCancelled: Bool = false) {
41
+ self.presentationID = presentationID
42
+ self.moduleName = moduleName
43
+ self.initialDisplay = initialDisplay
44
+ self.fullDisplay = fullDisplay
45
+ self.isFinal = isFinal
46
+ self.isCancelled = isCancelled
47
+ }
48
+ }
49
+
50
+ /// Pure cadence accumulator, kept separate from run-loop and locking mechanics.
51
+ internal struct BrownfieldFrameSamples {
52
+ var start: CFTimeInterval?
53
+ var last: CFTimeInterval = 0
54
+ var budget: CFTimeInterval = 1 / 60
55
+ var frames = 0
56
+ var busy: CFTimeInterval = 0
57
+
58
+ mutating func begin(at now: CFTimeInterval) {
59
+ start = now
60
+ last = now
61
+ }
62
+
63
+ mutating func record(at now: CFTimeInterval, frameInterval: CFTimeInterval) {
64
+ budget = max(frameInterval, 1 / 240)
65
+ busy += max(0, now - last - budget)
66
+ frames += 1
67
+ last = now
68
+ }
69
+
70
+ func snapshot(at now: CFTimeInterval) -> BrownfieldThreadMetrics {
71
+ guard let start, now > start else {
72
+ return BrownfieldThreadMetrics(fps: nil, busyRatio: nil, sampledMs: 0, frameCount: 0)
73
+ }
74
+ let elapsed = now - start
75
+ let blocked = busy + max(0, now - last - budget)
76
+ return BrownfieldThreadMetrics(fps: Double(frames) / elapsed,
77
+ busyRatio: min(1, blocked / elapsed), sampledMs: elapsed * 1000, frameCount: frames)
78
+ }
79
+ }
80
+
81
+ /// Lock protects the JS run-loop sampler from main-thread snapshots and cancellation.
82
+ internal final class BrownfieldFrameSampler: NSObject {
83
+ private let lock = NSLock()
84
+ private var link: CADisplayLink?
85
+ private var stopped = false
86
+ private var samples = BrownfieldFrameSamples()
87
+
88
+ func startOnCurrentRunLoop() {
89
+ lock.lock()
90
+ defer { lock.unlock() }
91
+ guard !stopped, link == nil else { return }
92
+ samples.begin(at: CACurrentMediaTime())
93
+ let link = CADisplayLink(target: self, selector: #selector(tick(_:)))
94
+ self.link = link
95
+ link.add(to: .current, forMode: .common)
96
+ }
97
+
98
+ @objc private func tick(_ link: CADisplayLink) {
99
+ lock.lock()
100
+ defer { lock.unlock() }
101
+ guard !stopped else { return }
102
+ samples.record(at: CACurrentMediaTime(), frameInterval: link.targetTimestamp - link.timestamp)
103
+ }
104
+
105
+ func snapshot(at now: CFTimeInterval) -> BrownfieldThreadMetrics {
106
+ lock.lock()
107
+ defer { lock.unlock() }
108
+ return samples.snapshot(at: now)
109
+ }
110
+
111
+ func stop() {
112
+ lock.lock()
113
+ stopped = true
114
+ let oldLink = link
115
+ link = nil
116
+ lock.unlock()
117
+ oldLink?.invalidate()
118
+ }
119
+ }
120
+
121
+ /// All session state belongs to the main thread. The JS sampler alone crosses threads.
122
+ internal final class BrownfieldDisplaySession {
123
+ let id = UUID().uuidString
124
+ let moduleName: String
125
+ private let waitForFullDisplay: Bool
126
+ private var callback: ((BrownfieldDisplayMetrics) -> Void)?
127
+ private let start = CACurrentMediaTime()
128
+ private let ui: BrownfieldFrameSampler?
129
+ private let js: BrownfieldFrameSampler?
130
+ private var initial: BrownfieldDisplayInterval?
131
+ private var fullWasMarked = false
132
+ private var finished = false
133
+ private var jsScheduled = false
134
+ private var tokens: [NSObjectProtocol] = []
135
+ /// Set once the appearance probe is inside the React Native root view.
136
+ var hasRootView = false
137
+
138
+ init(moduleName: String, waitForFullDisplay: Bool, collectThreadMetrics: Bool = false, callback: ((BrownfieldDisplayMetrics) -> Void)?) {
139
+ precondition(Thread.isMainThread)
140
+ self.moduleName = moduleName
141
+ self.waitForFullDisplay = waitForFullDisplay
142
+ self.callback = callback
143
+ let shouldSample = collectThreadMetrics && callback != nil
144
+ ui = shouldSample ? BrownfieldFrameSampler() : nil
145
+ js = shouldSample ? BrownfieldFrameSampler() : nil
146
+ guard callback != nil else {
147
+ finished = true
148
+ return
149
+ }
150
+ ui?.startOnCurrentRunLoop()
151
+ scheduleJS()
152
+ if js != nil {
153
+ tokens.append(NotificationCenter.default.addObserver(
154
+ forName: Notification.Name("BrownfieldPerformanceBridgeReady"), object: nil, queue: .main
155
+ ) { [weak self] _ in
156
+ guard let self else { return }
157
+ if self.jsScheduled { self.cancel() } else { self.scheduleJS() }
158
+ })
159
+ }
160
+ if waitForFullDisplay {
161
+ tokens.append(NotificationCenter.default.addObserver(
162
+ forName: Notification.Name("BrownfieldMarkFullyDisplayed"), object: nil, queue: .main
163
+ ) { [weak self] notification in
164
+ guard let self, notification.object as? String == self.id, !self.finished else { return }
165
+ self.fullWasMarked = true
166
+ if self.initial != nil { self.complete() }
167
+ })
168
+ }
169
+ // Cancellation releases samplers when the application stops drawing.
170
+ tokens.append(NotificationCenter.default.addObserver(
171
+ forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main
172
+ ) { [weak self] _ in self?.cancel() })
173
+ tokens.append(NotificationCenter.default.addObserver(
174
+ forName: Notification.Name("RCTJavaScriptDidFailToLoadNotification"), object: nil, queue: .main
175
+ ) { [weak self] _ in self?.cancel() })
176
+ tokens.append(NotificationCenter.default.addObserver(
177
+ forName: Notification.Name("BrownfieldPerformanceStop"), object: nil, queue: .main
178
+ ) { [weak self] _ in self?.cancel() })
179
+ }
180
+
181
+ private func scheduleJS() {
182
+ guard !finished, !jsScheduled, let sampler = js else { return }
183
+ jsScheduled = BrownfieldPerformanceBridge.dispatch(toJavaScript: {
184
+ sampler.startOnCurrentRunLoop()
185
+ })
186
+ }
187
+
188
+ private func interval() -> BrownfieldDisplayInterval {
189
+ let now = CACurrentMediaTime()
190
+ return BrownfieldDisplayInterval(duration: (now - start) * 1000,
191
+ jsThread: js?.snapshot(at: now) ?? BrownfieldFrameSamples().snapshot(at: now),
192
+ uiThread: ui?.snapshot(at: now) ?? BrownfieldFrameSamples().snapshot(at: now))
193
+ }
194
+
195
+ func appeared() {
196
+ guard !finished, initial == nil else { return }
197
+ initial = interval()
198
+ if !waitForFullDisplay || fullWasMarked {
199
+ complete()
200
+ }
201
+ }
202
+
203
+ private func complete() {
204
+ guard !finished, let initial else { return }
205
+ let full = waitForFullDisplay ? interval() : initial
206
+ finished = true
207
+ let result = BrownfieldDisplayMetrics(presentationID: id, moduleName: moduleName,
208
+ initialDisplay: initial, fullDisplay: full, isFinal: true)
209
+ let completion = callback
210
+ callback = nil
211
+ stop()
212
+ completion?(result)
213
+ }
214
+
215
+ func cancel() {
216
+ guard !finished else { return }
217
+ finished = true
218
+ callback = nil
219
+ stop()
220
+ }
221
+
222
+ private func stop() {
223
+ ui?.stop()
224
+ js?.stop()
225
+ tokens.forEach(NotificationCenter.default.removeObserver)
226
+ tokens.removeAll()
227
+ }
228
+
229
+ deinit { stop() }
230
+ }
231
+
232
+ /// A child probe preserves the RN root view's type, layout, and touch handling.
233
+ internal final class BrownfieldAppearanceProbe: UIView {
234
+ let session: BrownfieldDisplaySession
235
+ /// Set when a view controller hosts the root. The probe then reports appearance only while
236
+ /// that controller is visible, for a root that arrives after `viewDidAppear`.
237
+ var isHostVisible: (() -> Bool)?
238
+ private var hasAttached = false
239
+
240
+ init(session: BrownfieldDisplaySession) {
241
+ self.session = session
242
+ super.init(frame: .zero)
243
+ isUserInteractionEnabled = false
244
+ isHidden = true
245
+ }
246
+
247
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
248
+
249
+ override func didMoveToWindow() {
250
+ super.didMoveToWindow()
251
+ if window != nil {
252
+ hasAttached = true
253
+ if let isHostVisible {
254
+ if isHostVisible() { session.appeared() }
255
+ } else {
256
+ DispatchQueue.main.async { [weak self] in
257
+ guard let self, self.window != nil else { return }
258
+ self.session.appeared()
259
+ }
260
+ }
261
+ } else if hasAttached {
262
+ session.cancel()
263
+ }
264
+ }
265
+ }
@@ -0,0 +1,8 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ NS_ASSUME_NONNULL_BEGIN
4
+ @interface BrownfieldPerformanceBridge : NSObject
5
+ // Call on main. Returns NO until the JS thread or an RN bridge/proxy is available.
6
+ + (BOOL)dispatchToJavaScript:(dispatch_block_t)block;
7
+ @end
8
+ NS_ASSUME_NONNULL_END
@@ -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,9 @@ 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,
302
+ AppController.sharedInstance.isStarted,
256
303
  let launchAssetURL = AppController.sharedInstance.launchAssetUrl()
257
304
  {
258
305
  return launchAssetURL
@@ -286,20 +333,19 @@ class ExpoHostRuntimeDelegate: ExpoReactNativeFactoryDelegate {
286
333
  *
287
334
  * The override wins over every other step, thus an override makes the URL stable.
288
335
  *
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.
336
+ * The shared runtime waits for Updates before preloading or creating views. Inactive
337
+ * controllers use the normal bundle resolver and do not need an Updates launch asset.
295
338
  */
296
339
  var isBundleURLStable: Bool {
297
340
  if bundleURLOverride?() != nil {
298
341
  return true
299
342
  }
300
343
 
301
- #if canImport(EXUpdates) && !DEBUG
302
- return AppController.isInitialized() && AppController.sharedInstance.launchAssetUrl() != nil
344
+ #if canImport(EXUpdates)
345
+ guard AppController.isInitialized() else { return false }
346
+ return !AppController.sharedInstance.isActiveController
347
+ || (AppController.sharedInstance.isStarted
348
+ && AppController.sharedInstance.launchAssetUrl() != nil)
303
349
  #else
304
350
  return true
305
351
  #endif