@callstack/react-native-brownfield 5.3.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 (42) hide show
  1. package/CHANGELOG.md +12 -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/template/android/consumer-rules.pro +7 -0
  31. package/lib/commonjs/index.js +1 -1
  32. package/lib/commonjs/index.js.map +1 -1
  33. package/lib/module/expo-config-plugin/template/android/consumer-rules.pro +7 -0
  34. package/lib/module/index.js +1 -1
  35. package/lib/module/index.js.map +1 -1
  36. package/lib/typescript/commonjs/src/index.d.ts +7 -0
  37. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  38. package/lib/typescript/module/src/index.d.ts +7 -0
  39. package/lib/typescript/module/src/index.d.ts.map +1 -1
  40. package/package.json +2 -2
  41. package/src/expo-config-plugin/template/android/consumer-rules.pro +7 -0
  42. package/src/index.ts +13 -1
@@ -17,6 +17,10 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
17
17
  private lateinit var permissionsCallback: Callback
18
18
  private var permissionListener: PermissionListener? = null
19
19
  private lateinit var moduleName: String
20
+ private var displaySession: BrownfieldDisplaySession? = null
21
+
22
+ /** Reattach after Fragment restoration; callbacks are intentionally not serialized. */
23
+ var onMetrics: OnDisplayMetrics? = null
20
24
 
21
25
  override fun onCreate(savedInstanceState: Bundle?) {
22
26
  /**
@@ -51,12 +55,34 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
51
55
  container: ViewGroup?,
52
56
  savedInstanceState: Bundle?
53
57
  ): View {
54
- return ReactNativeBrownfield.shared.createView(
58
+ val args = arguments
59
+ // Only the measured factories set this argument. Plain fragments keep their props as is.
60
+ val isMeasured = args?.containsKey(ReactNativeFragmentArgNames.ARG_WAIT_FOR_FULL_DISPLAY) == true
61
+ val session = if (isMeasured) {
62
+ BrownfieldDisplaySession(
63
+ moduleName,
64
+ args!!.getBoolean(ReactNativeFragmentArgNames.ARG_WAIT_FOR_FULL_DISPLAY, false),
65
+ args.getBoolean(ReactNativeFragmentArgNames.ARG_COLLECT_THREAD_METRICS, false),
66
+ onMetrics
67
+ )
68
+ } else {
69
+ null
70
+ }
71
+ displaySession = session
72
+ val launchOptions = Bundle(
73
+ args?.getBundle(ReactNativeFragmentArgNames.ARG_LAUNCH_OPTIONS) ?: Bundle()
74
+ ).apply { session?.let { putString("brownfieldPresentationID", it.id) } }
75
+ this.reactDelegate = ReactDelegateWrapper(
76
+ activity, this.reactHost, moduleName, launchOptions
77
+ )
78
+ return ReactNativeBrownfield.shared.createViewInternal(
55
79
  activity,
56
80
  moduleName,
57
81
  this.reactDelegate as ReactDelegateWrapper,
58
- launchOptions = null,
59
- lifecycleOwner = viewLifecycleOwner
82
+ launchOptions,
83
+ viewLifecycleOwner,
84
+ session,
85
+ true
60
86
  )
61
87
  }
62
88
 
@@ -69,9 +95,11 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
69
95
  } catch (_: ClassCastException) {
70
96
  (this.reactDelegate as ReactDelegateWrapper).onReactHostResume()
71
97
  }
98
+ displaySession?.setResumed(true)
72
99
  }
73
100
 
74
101
  override fun onPause() {
102
+ displaySession?.cancel()
75
103
  try {
76
104
  super.onPause()
77
105
  } catch (error: AssertionError) {
@@ -91,6 +119,12 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
91
119
  }
92
120
  }
93
121
 
122
+ override fun onDestroyView() {
123
+ displaySession?.cancel()
124
+ displaySession = null
125
+ super.onDestroyView()
126
+ }
127
+
94
128
  override fun onRequestPermissionsResult(
95
129
  requestCode: Int,
96
130
  permissions: Array<String>,
@@ -143,6 +177,26 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
143
177
  return fragment
144
178
  }
145
179
 
180
+ @JvmStatic
181
+ @JvmOverloads
182
+ fun createReactNativeFragment(
183
+ moduleName: String,
184
+ initialProps: Bundle?,
185
+ waitForFullDisplay: Boolean,
186
+ collectThreadMetrics: Boolean = false,
187
+ onMetrics: OnDisplayMetrics? = null
188
+ ): ReactNativeFragment {
189
+ return createReactNativeFragment(moduleName, initialProps).also { fragment ->
190
+ fragment.arguments?.putBoolean(
191
+ ReactNativeFragmentArgNames.ARG_WAIT_FOR_FULL_DISPLAY, waitForFullDisplay
192
+ )
193
+ fragment.arguments?.putBoolean(
194
+ ReactNativeFragmentArgNames.ARG_COLLECT_THREAD_METRICS, collectThreadMetrics
195
+ )
196
+ fragment.onMetrics = onMetrics
197
+ }
198
+ }
199
+
146
200
  @JvmStatic
147
201
  fun createReactNativeFragment(
148
202
  moduleName: String,
@@ -151,6 +205,22 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
151
205
  return createReactNativeFragment(moduleName, PropsBundle.fromHashMap(initialProps))
152
206
  }
153
207
 
208
+ @JvmStatic
209
+ @JvmOverloads
210
+ fun createReactNativeFragment(
211
+ moduleName: String,
212
+ initialProps: HashMap<String, *>,
213
+ waitForFullDisplay: Boolean,
214
+ collectThreadMetrics: Boolean = false,
215
+ onMetrics: OnDisplayMetrics? = null
216
+ ): ReactNativeFragment = createReactNativeFragment(
217
+ moduleName,
218
+ PropsBundle.fromHashMap(initialProps),
219
+ waitForFullDisplay,
220
+ collectThreadMetrics,
221
+ onMetrics
222
+ )
223
+
154
224
  @JvmStatic
155
225
  fun createReactNativeFragment(
156
226
  moduleName: String,
@@ -158,5 +228,21 @@ class ReactNativeFragment : ReactFragment(), PermissionAwareActivity {
158
228
  ): ReactNativeFragment {
159
229
  return createReactNativeFragment(moduleName, initialProps.toHashMap())
160
230
  }
231
+
232
+ @JvmStatic
233
+ @JvmOverloads
234
+ fun createReactNativeFragment(
235
+ moduleName: String,
236
+ initialProps: WritableMap,
237
+ waitForFullDisplay: Boolean,
238
+ collectThreadMetrics: Boolean = false,
239
+ onMetrics: OnDisplayMetrics? = null
240
+ ): ReactNativeFragment = createReactNativeFragment(
241
+ moduleName,
242
+ initialProps.toHashMap(),
243
+ waitForFullDisplay,
244
+ collectThreadMetrics,
245
+ onMetrics
246
+ )
161
247
  }
162
248
  }
@@ -16,5 +16,7 @@ class ReactNativeFragmentArgNames private constructor() :
16
16
 
17
17
  // re-export constants from ReactFragment to enable access
18
18
  const val ARG_LAUNCH_OPTIONS: String = "arg_launch_options"
19
+ const val ARG_WAIT_FOR_FULL_DISPLAY = "arg_wait_for_full_display"
20
+ const val ARG_COLLECT_THREAD_METRICS = "arg_collect_thread_metrics"
19
21
  }
20
22
  }
@@ -0,0 +1,54 @@
1
+ package com.callstack.reactnativebrownfield
2
+
3
+ import org.junit.Assert.assertEquals
4
+ import org.junit.Assert.assertNull
5
+ import org.junit.Test
6
+
7
+ class PerformanceMetricsTest {
8
+ @Test fun `pairs markers and sorts overlapping timeline`() {
9
+ val samples = JSBundleTimingAccumulator()
10
+ samples.record(BundleMarker.EVALUATION_START, 1, 20)
11
+ samples.record(BundleMarker.DOWNLOAD_START, 0, 10)
12
+ samples.record(BundleMarker.EVALUATION_END, 1, 40)
13
+ samples.record(BundleMarker.DOWNLOAD_END, 0, 30)
14
+
15
+ val result = samples.snapshot()
16
+ assertEquals(20.0, result.jsBundleLoadTime!!, 0.0)
17
+ assertEquals(20.0, result.jsBundleEvaluationTime!!, 0.0)
18
+ assertEquals(listOf("DOWNLOAD", "RUN_JS_BUNDLE"), result.timeline.map { it["tag"] })
19
+ }
20
+
21
+ @Test fun `keeps zero duration and rejects missing ambiguous and other instances`() {
22
+ val zero = JSBundleTimingAccumulator().apply {
23
+ record(BundleMarker.EVALUATION_START, 1, 5)
24
+ record(BundleMarker.EVALUATION_END, 1, 5)
25
+ }.snapshot()
26
+ assertEquals(0.0, zero.jsBundleEvaluationTime!!, 0.0)
27
+
28
+ val missing = JSBundleTimingAccumulator().apply {
29
+ record(BundleMarker.DOWNLOAD_END, 0, 8)
30
+ record(BundleMarker.EVALUATION_START, 2, 9)
31
+ record(BundleMarker.EVALUATION_END, 2, 10)
32
+ }.snapshot()
33
+ assertNull(missing.jsBundleLoadTime)
34
+ assertNull(missing.jsBundleEvaluationTime)
35
+
36
+ val ambiguous = JSBundleTimingAccumulator().apply {
37
+ record(BundleMarker.DOWNLOAD_START, 0, 1)
38
+ record(BundleMarker.DOWNLOAD_START, 0, 2)
39
+ record(BundleMarker.DOWNLOAD_END, 0, 3)
40
+ }.snapshot()
41
+ assertNull(ambiguous.jsBundleLoadTime)
42
+ }
43
+
44
+ @Test fun `frame accumulator includes unfinished deadline excess`() {
45
+ val samples = BrownfieldFrameSamples()
46
+ samples.begin(0)
47
+ samples.record(20_000_000, 10_000_000)
48
+ val result = samples.snapshot(50_000_000)
49
+ assertEquals(20.0, result.fps!!, 0.001)
50
+ assertEquals(0.6, result.busyRatio!!, 0.001)
51
+ assertEquals(50.0, result.sampledMs, 0.001)
52
+ assertEquals(1, result.frameCount)
53
+ }
54
+ }
@@ -3,13 +3,16 @@ package com.callstack.react.brownfield.plugin
3
3
  import com.android.build.api.dsl.AndroidSourceSet
4
4
  import com.android.build.api.dsl.LibraryExtension
5
5
  import com.android.build.api.variant.LibraryAndroidComponentsExtension
6
+ import com.android.build.api.variant.SourceDirectories
6
7
  import com.callstack.react.brownfield.exceptions.NameSpaceNotFound
7
8
  import com.callstack.react.brownfield.processors.VariantHelper
8
9
  import com.callstack.react.brownfield.utils.Extension
9
10
  import com.callstack.react.brownfield.utils.Utils
10
11
  import com.callstack.react.brownfield.utils.capitalized
12
+ import org.gradle.api.GradleException
11
13
  import org.gradle.api.Project
12
14
  import org.gradle.api.Task
15
+ import org.gradle.api.file.DirectoryProperty
13
16
  import org.gradle.api.tasks.Copy
14
17
  import java.io.File
15
18
 
@@ -63,26 +66,67 @@ object RNSourceSets {
63
66
  val capitalizedBundledAssetsVariantName = bundledAssetsVariantName.capitalized()
64
67
  val appProject = getAppProject()
65
68
 
66
- val bundlePathSegments =
67
- listOf(
68
- // outputs for RN <= 0.81
69
- "createBundle${capitalizedBundledAssetsVariantName}JsAndAssets",
70
- // outputs for RN >= 0.82
71
- "react/$bundledAssetsVariantName",
72
- )
69
+ val jsBundleTaskName = "createBundle${capitalizedBundledAssetsVariantName}JsAndAssets"
73
70
 
74
- val appBuildDir = getAppBuildDir()
75
- bundlePathSegments.forEach { segment ->
76
- variant.sources.assets?.addStaticSourceDirectory("$appBuildDir/generated/assets/$segment")
77
- variant.sources.res?.addStaticSourceDirectory("$appBuildDir/generated/res/$segment")
78
- }
71
+ /**
72
+ * Wire the bundle task's own `jsBundleDir`/`resourcesDir` outputs as *generated*
73
+ * source directories
74
+ */
75
+ wireGeneratedSourceDirectory(appProject, jsBundleTaskName, "getJsBundleDir", variant.sources.assets)
76
+ wireGeneratedSourceDirectory(appProject, jsBundleTaskName, "getResourcesDir", variant.sources.res)
79
77
 
80
78
  if (Utils.hasExpoUpdates(appProject, variantName)) {
81
- val updateResourcesPathSegment = VariantHelper.getExpoUpdatesResourcesTaskName(variant.name)
82
- variant.sources.assets?.addStaticSourceDirectory("$appBuildDir/generated/assets/$updateResourcesPathSegment")
83
- variant.sources.res?.addStaticSourceDirectory("$appBuildDir/generated/res/$updateResourcesPathSegment")
79
+ /**
80
+ * expo-updates registers `create<Variant>UpdatesResources` as
81
+ * `ExpoUpdatesPlugin.CreateUpdatesResourcesTask`, with a single
82
+ * `assetDir: DirectoryProperty` output (no `res` output exists for this task).
83
+ */
84
+ val updatesResourcesTaskName = VariantHelper.getExpoUpdatesResourcesTaskName(variant.name)
85
+ wireGeneratedSourceDirectory(appProject, updatesResourcesTaskName, "getAssetDir", variant.sources.assets)
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Wires `taskName`'s `getterName` output (expected to be a `DirectoryProperty`) as a
92
+ * generated source directory for `sources`.
93
+ *
94
+ * Resolving the task by name (rather than importing its class) keeps this plugin from
95
+ * depending on RNGP's/expo-updates' internal task types. If the task, or its expected output
96
+ * property, isn't there, this fails the build immediately with a clear message instead of
97
+ * silently producing an empty/stale source directory.
98
+ */
99
+ private fun wireGeneratedSourceDirectory(
100
+ appProject: Project,
101
+ taskName: String,
102
+ getterName: String,
103
+ sources: SourceDirectories?,
104
+ ) {
105
+ if (sources == null) return
106
+
107
+ val task =
108
+ appProject.tasks.findByName(taskName)
109
+ ?: throw GradleException(
110
+ "brownfield: expected task '$taskName' was not found in project " +
111
+ "'${appProject.path}'. Check that the plugin producing it is applied there.",
112
+ )
113
+
114
+ val getter =
115
+ runCatching { task.javaClass.getMethod(getterName) }.getOrElse {
116
+ throw GradleException(
117
+ "brownfield: task '$taskName' (${task.javaClass.name}) does not expose " +
118
+ "'$getterName()'. The plugin that registers it most likely changed its " +
119
+ "API; the brownfield Gradle plugin needs updating to match.",
120
+ it,
121
+ )
84
122
  }
123
+
124
+ check(DirectoryProperty::class.java.isAssignableFrom(getter.returnType)) {
125
+ "brownfield: '$taskName.$getterName()' no longer returns a DirectoryProperty."
85
126
  }
127
+
128
+ val taskProvider = appProject.tasks.named(taskName)
129
+ sources.addGeneratedSourceDirectory(taskProvider) { t -> getter.invoke(t) as DirectoryProperty }
86
130
  }
87
131
 
88
132
  private fun getLibraryNameSpace(): String {
@@ -102,9 +102,10 @@ class AgpCompatibilityMatrixTest {
102
102
  File(projectDir, "local.properties").writeText("sdk.dir=$androidSdk\n")
103
103
  // Stands in for the RN app project. It has to be a real `com.android.application` because
104
104
  // the brownfield plugin wires into AGP's own app-side tasks; the extra registrations cover
105
- // the tasks the React Native Gradle plugin would otherwise contribute. Empty registrations
106
- // are enough: this test is about AGP API compatibility, not about what those tasks produce.
107
- File(projectDir, "app").mkdirs()
105
+ // the tasks the React Native Gradle plugin would otherwise contribute. The bundle tasks need
106
+ // their `jsBundleDir`/`resourcesDir` outputs, which the plugin wires as generated sources;
107
+ // the rest can stay empty, since this test is about AGP API compatibility.
108
+ writeRNBundleTaskStubs(File(projectDir, "app").apply { mkdirs() })
108
109
  File(projectDir, "app/build.gradle.kts").writeText(
109
110
  """
110
111
  plugins {
@@ -116,11 +117,8 @@ class AgpCompatibilityMatrixTest {
116
117
  compileSdk = 34
117
118
  }
118
119
 
119
- listOf(
120
- "generateAutolinkingPackageList",
121
- "createBundleDebugJsAndAssets",
122
- "createBundleReleaseJsAndAssets",
123
- ).forEach { name -> tasks.register(name) }
120
+ tasks.register("generateAutolinkingPackageList")
121
+ apply(from = "$RN_BUNDLE_TASK_STUBS_SCRIPT")
124
122
  """.trimIndent(),
125
123
  )
126
124
 
@@ -64,10 +64,12 @@ class LegacyConsumerConfigurationTest {
64
64
  """.trimIndent(),
65
65
  )
66
66
  File(projectDir, "local.properties").writeText("sdk.dir=$androidSdk\n")
67
- // ArtifactsResolver looks for an `app` project; an empty stub is enough, this test is about
68
- // the task container, not discovery.
69
- File(projectDir, "app").mkdirs()
70
- File(projectDir, "app/build.gradle.kts").writeText("")
67
+ // ArtifactsResolver looks for an `app` project. It only needs the RN bundle tasks whose
68
+ // outputs the plugin wires as generated sources; this test is about the task container,
69
+ // not discovery.
70
+ val appDir = File(projectDir, "app").apply { mkdirs() }
71
+ writeRNBundleTaskStubs(appDir)
72
+ File(appDir, "build.gradle.kts").writeText("apply(from = \"$RN_BUNDLE_TASK_STUBS_SCRIPT\")\n")
71
73
 
72
74
  // The `tasks.register("removeDependenciesFromModuleFile")` block below is verbatim from
73
75
  // the published setup docs. Do not "modernize" it — its whole purpose is to reproduce
@@ -0,0 +1,27 @@
1
+ package com.callstack.react.brownfield.plugin
2
+
3
+ import java.io.File
4
+
5
+ internal const val RN_BUNDLE_TASK_STUBS_SCRIPT = "rn-bundle-task-stubs.gradle.kts"
6
+
7
+ /**
8
+ * Writes a script plugin into [appDir] that stubs RNGP's `createBundle<Variant>JsAndAssets` tasks.
9
+ * The brownfield plugin wires their `jsBundleDir`/`resourcesDir` outputs as generated sources and
10
+ * fails fast when they are missing, so plain `tasks.register(name)` stubs are not enough.
11
+ * Apply it from the app build script with `apply(from = RN_BUNDLE_TASK_STUBS_SCRIPT)`.
12
+ */
13
+ internal fun writeRNBundleTaskStubs(appDir: File) {
14
+ File(appDir, RN_BUNDLE_TASK_STUBS_SCRIPT).writeText(
15
+ """
16
+ abstract class StubBundleTask : DefaultTask() {
17
+ @get:OutputDirectory abstract val jsBundleDir: DirectoryProperty
18
+ @get:OutputDirectory abstract val resourcesDir: DirectoryProperty
19
+ }
20
+
21
+ listOf(
22
+ "createBundleDebugJsAndAssets",
23
+ "createBundleReleaseJsAndAssets",
24
+ ).forEach { name -> tasks.register<StubBundleTask>(name) }
25
+ """.trimIndent(),
26
+ )
27
+ }
@@ -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