@callstack/react-native-brownfield 5.1.0 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfield.kt +22 -4
  3. package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeFragment.kt +3 -1
  4. package/gradle-plugin/brownfield/build.gradle.kts +7 -0
  5. package/gradle-plugin/brownfield/gradle/libs.versions.toml +2 -0
  6. package/gradle-plugin/brownfield/gradle.properties +1 -1
  7. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt +96 -0
  8. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt +10 -214
  9. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPublishingInfo.kt +7 -0
  10. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt +92 -1
  11. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt +12 -1
  12. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/{expo/utils/BrownfieldPrimitives.kt → shared/DependencyInfo.kt} +3 -8
  13. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyPublishability.kt +22 -0
  14. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt +44 -0
  15. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt +181 -0
  16. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/TransitiveDependencyFiltering.kt +17 -0
  17. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/{expo/utils → shared}/VersionMediatingDependencySet.kt +1 -1
  18. package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt +11 -0
  19. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt +171 -0
  20. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelperGradleFallbackTest.kt +47 -0
  21. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/LegacyConsumerConfigurationTest.kt +110 -0
  22. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt +67 -0
  23. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjectorTest.kt +222 -0
  24. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/TransitiveDependencyFilteringTest.kt +37 -0
  25. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt +42 -0
  26. package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt +11 -0
  27. package/ios/BrownfieldSurfaceRegistry.swift +93 -0
  28. package/ios/Views/ReactNativeView.swift +34 -5
  29. package/ios/Views/ReactNativeViewController.swift +120 -1
  30. package/lib/commonjs/expo-config-plugin/android/utils/constants.js +1 -1
  31. package/lib/module/expo-config-plugin/android/utils/constants.js +1 -1
  32. package/lib/typescript/commonjs/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
  33. package/lib/typescript/module/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
  34. package/package.json +2 -2
  35. package/src/expo-config-plugin/android/utils/constants.ts +1 -1
@@ -0,0 +1,67 @@
1
+ package com.callstack.react.brownfield.shared
2
+
3
+ import org.junit.jupiter.api.Test
4
+ import kotlin.test.assertFalse
5
+ import kotlin.test.assertTrue
6
+
7
+ class DependencyPublishabilityTest {
8
+ @Test
9
+ fun `rejects a fully dynamic version`() {
10
+ val dep = DependencyInfo("com.facebook.react", "react-native", "+", "compile", false)
11
+ assertFalse(isPublishableCoordinate(dep))
12
+ }
13
+
14
+ @Test
15
+ fun `rejects a partially dynamic version`() {
16
+ val dep = DependencyInfo("androidx.core", "core-ktx", "1.+", "compile", false)
17
+ assertFalse(isPublishableCoordinate(dep))
18
+ }
19
+
20
+ @Test
21
+ fun `rejects a null version`() {
22
+ val dep = DependencyInfo("com.facebook.react", "react-android", null, "compile", false)
23
+ assertFalse(isPublishableCoordinate(dep))
24
+ }
25
+
26
+ @Test
27
+ fun `rejects a blank version`() {
28
+ val dep = DependencyInfo("com.facebook.react", "react-android", " ", "compile", false)
29
+ assertFalse(isPublishableCoordinate(dep))
30
+ }
31
+
32
+ @Test
33
+ fun `accepts a normal pinned version`() {
34
+ val dep = DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false)
35
+ assertTrue(isPublishableCoordinate(dep))
36
+ }
37
+
38
+ @Test
39
+ fun `rejects latest_release`() {
40
+ val dep = DependencyInfo("androidx.core", "core-ktx", "latest.release", "compile", false)
41
+ assertFalse(isPublishableCoordinate(dep))
42
+ }
43
+
44
+ @Test
45
+ fun `rejects latest_integration`() {
46
+ val dep = DependencyInfo("androidx.core", "core-ktx", "latest.integration", "compile", false)
47
+ assertFalse(isPublishableCoordinate(dep))
48
+ }
49
+
50
+ @Test
51
+ fun `rejects a closed Maven version range`() {
52
+ val dep = DependencyInfo("androidx.core", "core-ktx", "[1.0,2.0]", "compile", false)
53
+ assertFalse(isPublishableCoordinate(dep))
54
+ }
55
+
56
+ @Test
57
+ fun `rejects a half-open Maven version range`() {
58
+ val dep = DependencyInfo("androidx.core", "core-ktx", "[1.0,2.0)", "compile", false)
59
+ assertFalse(isPublishableCoordinate(dep))
60
+ }
61
+
62
+ @Test
63
+ fun `rejects an unbounded Maven version range`() {
64
+ val dep = DependencyInfo("androidx.core", "core-ktx", "[1.0,)", "compile", false)
65
+ assertFalse(isPublishableCoordinate(dep))
66
+ }
67
+ }
@@ -0,0 +1,222 @@
1
+ package com.callstack.react.brownfield.shared
2
+
3
+ import groovy.util.NodeList
4
+ import groovy.xml.XmlParser
5
+ import org.gradle.testfixtures.ProjectBuilder
6
+ import org.junit.jupiter.api.Test
7
+ import kotlin.test.assertEquals
8
+ import kotlin.test.assertNotNull
9
+ import kotlin.test.assertNull
10
+ import kotlin.test.assertTrue
11
+
12
+ class PublishingMetadataInjectorTaskRegistrationTest {
13
+ @Test
14
+ fun `does not claim the task name the setup docs tell consumers to register`() {
15
+ val project = ProjectBuilder.builder().build()
16
+
17
+ PublishingMetadataInjector(project)
18
+
19
+ assertNull(
20
+ project.tasks.findByName(Constants.LEGACY_CONSUMER_MODULE_METADATA_TASK_NAME),
21
+ "the plugin must leave this name free: the published setup docs tell every bare-RN " +
22
+ "consumer to register it themselves, and the plugin applies before their script body",
23
+ )
24
+ assertNotNull(
25
+ project.tasks.findByName(Constants.MODULE_METADATA_POST_PROCESS_TASK_NAME),
26
+ )
27
+ }
28
+
29
+ @Test
30
+ fun `a not-yet-migrated consumer can still register the legacy task after the plugin has run`() {
31
+ // The upgrade path at the task-container level: plugin registers during apply(), then the
32
+ // consumer's script body registers the task the docs told them to. Must not throw.
33
+ val project = ProjectBuilder.builder().build()
34
+
35
+ PublishingMetadataInjector(project)
36
+ project.tasks.register(Constants.LEGACY_CONSUMER_MODULE_METADATA_TASK_NAME)
37
+
38
+ assertNotNull(project.tasks.findByName(Constants.LEGACY_CONSUMER_MODULE_METADATA_TASK_NAME))
39
+ assertNotNull(project.tasks.findByName(Constants.MODULE_METADATA_POST_PROCESS_TASK_NAME))
40
+ }
41
+ }
42
+
43
+ class MutatePomDependenciesNodeTest {
44
+ private fun dependenciesNodeFrom(xml: String): groovy.util.Node {
45
+ val root = XmlParser().parseText(xml)
46
+ return (root.get("dependencies") as NodeList).first() as groovy.util.Node
47
+ }
48
+
49
+ private fun versionsFor(
50
+ dependenciesNode: groovy.util.Node,
51
+ groupId: String,
52
+ artifactId: String,
53
+ ): List<String> {
54
+ return dependenciesNode.children()
55
+ .filterIsInstance<groovy.util.Node>()
56
+ .filter {
57
+ (it["groupId"] as NodeList).text() == groupId && (it["artifactId"] as NodeList).text() == artifactId
58
+ }
59
+ .map { (it["version"] as NodeList).text() }
60
+ }
61
+
62
+ @Test
63
+ fun `appends new dependencies with no pre-existing entries`() {
64
+ val dependenciesNode = dependenciesNodeFrom("<project><dependencies/></project>")
65
+ val toInject = VersionMediatingDependencySet()
66
+ toInject.add(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false))
67
+
68
+ mutatePomDependenciesNode(dependenciesNode, toInject) { _, _ -> false }
69
+
70
+ assertEquals(listOf("1.17.0"), versionsFor(dependenciesNode, "androidx.core", "core-ktx"))
71
+ }
72
+
73
+ @Test
74
+ fun `injects into a POM that has no dependencies element at all`() {
75
+ // Must call resolveOrCreateDependenciesNode — inlining the fallback here would make this
76
+ // pass even with the production path reverted to first().
77
+ val root = XmlParser().parseText("<project/>")
78
+ val toInject = VersionMediatingDependencySet()
79
+ toInject.add(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false))
80
+
81
+ val dependenciesNode = resolveOrCreateDependenciesNode(root)
82
+ mutatePomDependenciesNode(dependenciesNode, toInject) { _, _ -> false }
83
+
84
+ assertEquals(listOf("1.17.0"), versionsFor(dependenciesNode, "androidx.core", "core-ktx"))
85
+ }
86
+
87
+ @Test
88
+ fun `reuses the existing dependencies element instead of appending a second one`() {
89
+ val root =
90
+ XmlParser().parseText(
91
+ """
92
+ <project>
93
+ <dependencies>
94
+ <dependency>
95
+ <groupId>androidx.core</groupId>
96
+ <artifactId>core-ktx</artifactId>
97
+ <version>1.17.0</version>
98
+ </dependency>
99
+ </dependencies>
100
+ </project>
101
+ """.trimIndent(),
102
+ )
103
+
104
+ val dependenciesNode = resolveOrCreateDependenciesNode(root)
105
+
106
+ assertEquals(
107
+ 1,
108
+ (root.get("dependencies") as NodeList).size,
109
+ "a POM that already has <dependencies> must not gain a second one",
110
+ )
111
+ assertEquals(listOf("1.17.0"), versionsFor(dependenciesNode, "androidx.core", "core-ktx"))
112
+ }
113
+
114
+ @Test
115
+ fun `removes an unrelated excluded entry that is not being re-injected`() {
116
+ val dependenciesNode =
117
+ dependenciesNodeFrom(
118
+ """
119
+ <project>
120
+ <dependencies>
121
+ <dependency>
122
+ <groupId>com.rnapp</groupId>
123
+ <artifactId>brownfieldlib</artifactId>
124
+ <version>unspecified</version>
125
+ </dependency>
126
+ </dependencies>
127
+ </project>
128
+ """.trimIndent(),
129
+ )
130
+
131
+ mutatePomDependenciesNode(dependenciesNode, VersionMediatingDependencySet()) { groupId, _ ->
132
+ groupId == "com.rnapp"
133
+ }
134
+
135
+ assertTrue(versionsFor(dependenciesNode, "com.rnapp", "brownfieldlib").isEmpty())
136
+ }
137
+
138
+ @Test
139
+ fun `a superseded stale entry is replaced by the mediated version, not left duplicated or missing`() {
140
+ // Consumer hand-declares 1.6.0, embedded module needs 1.7.1, so the coordinate is
141
+ // superseded and shouldExclude matches it. The mediated 1.7.1 must survive exactly once.
142
+ val dependenciesNode =
143
+ dependenciesNodeFrom(
144
+ """
145
+ <project>
146
+ <dependencies>
147
+ <dependency>
148
+ <groupId>androidx.appcompat</groupId>
149
+ <artifactId>appcompat</artifactId>
150
+ <version>1.6.0</version>
151
+ <scope>runtime</scope>
152
+ </dependency>
153
+ </dependencies>
154
+ </project>
155
+ """.trimIndent(),
156
+ )
157
+ val mediated = VersionMediatingDependencySet()
158
+ mediated.add(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false))
159
+
160
+ mutatePomDependenciesNode(dependenciesNode, mediated) { groupId, artifactId ->
161
+ groupId == "androidx.appcompat" && artifactId == "appcompat"
162
+ }
163
+
164
+ assertEquals(listOf("1.7.1"), versionsFor(dependenciesNode, "androidx.appcompat", "appcompat"))
165
+ }
166
+ }
167
+
168
+ class MutateModuleJsonVariantsTest {
169
+ private fun jsonWithVariant(dependencies: MutableList<MutableMap<String, Any>>): MutableMap<String, Any> {
170
+ return mutableMapOf(
171
+ "variants" to
172
+ mutableListOf(
173
+ mutableMapOf<String, Any>(
174
+ "name" to "runtimeElements",
175
+ "dependencies" to dependencies,
176
+ ),
177
+ ),
178
+ )
179
+ }
180
+
181
+ @Suppress("UNCHECKED_CAST")
182
+ private fun dependenciesOf(json: Map<*, *>): List<Map<String, Any>> {
183
+ val variants = json["variants"] as List<Map<String, Any>>
184
+ return variants.first()["dependencies"] as List<Map<String, Any>>
185
+ }
186
+
187
+ @Test
188
+ fun `appends new dependencies with no pre-existing entries`() {
189
+ val json = jsonWithVariant(mutableListOf())
190
+ val toInject = VersionMediatingDependencySet()
191
+ toInject.add(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false))
192
+
193
+ mutateModuleJsonVariants(json, toInject) { _, _ -> false }
194
+
195
+ val deps = dependenciesOf(json)
196
+ assertEquals(1, deps.size)
197
+ assertEquals("androidx.core", deps.first()["group"])
198
+ assertEquals("core-ktx", deps.first()["module"])
199
+ }
200
+
201
+ @Test
202
+ fun `a superseded stale entry is replaced by the mediated version, not left duplicated or missing`() {
203
+ val json =
204
+ jsonWithVariant(
205
+ mutableListOf(
206
+ mutableMapOf("group" to "androidx.appcompat", "module" to "appcompat"),
207
+ ),
208
+ )
209
+ val mediated = VersionMediatingDependencySet()
210
+ mediated.add(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false))
211
+
212
+ mutateModuleJsonVariants(json, mediated) { group, module ->
213
+ group == "androidx.appcompat" && module == "appcompat"
214
+ }
215
+
216
+ val deps = dependenciesOf(json)
217
+ assertEquals(1, deps.size, "expected exactly one entry, not zero (deleted) or two (duplicated)")
218
+ @Suppress("UNCHECKED_CAST")
219
+ val version = (deps.first()["version"] as? Map<String, Any>)?.get("requires")
220
+ assertEquals("1.7.1", version)
221
+ }
222
+ }
@@ -0,0 +1,37 @@
1
+ package com.callstack.react.brownfield.shared
2
+
3
+ import org.junit.jupiter.api.Test
4
+ import kotlin.test.assertEquals
5
+ import kotlin.test.assertFalse
6
+ import kotlin.test.assertTrue
7
+
8
+ class TransitiveDependencyFilteringTest {
9
+ @Test
10
+ fun `drops a hard-excluded coordinate the RNC discoverer found, even though it has no Expo awareness`() {
11
+ // The RNC discoverer has no Expo blacklist, so an embedded module depending on an Expo
12
+ // coordinate would otherwise be published as a Maven dependency that resolves nowhere.
13
+ val transitiveDeps = VersionMediatingDependencySet()
14
+ transitiveDeps.add(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false))
15
+ transitiveDeps.add(DependencyInfo("host.exp.exponent", "expo.modules.device", "56.0.4", "compile", false))
16
+
17
+ val hardExcludePredicate: (String, String) -> Boolean = { groupId, _ -> groupId == "host.exp.exponent" }
18
+ dropHardExcludedDependencies(transitiveDeps, hardExcludePredicate)
19
+
20
+ assertTrue(transitiveDeps.contains(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false)))
21
+ assertFalse(transitiveDeps.any { it.groupId == "host.exp.exponent" })
22
+ assertEquals(1, transitiveDeps.size)
23
+ }
24
+
25
+ @Test
26
+ fun `does not drop a superseded coordinate that is meant to still be injected`() {
27
+ // hardExcludePredicate must never include the supersede check, or a mediated
28
+ // replacement for a consumer's stale hand-declared dependency would be dropped too.
29
+ val transitiveDeps = VersionMediatingDependencySet()
30
+ transitiveDeps.add(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false))
31
+
32
+ dropHardExcludedDependencies(transitiveDeps) { _, _ -> false }
33
+
34
+ assertTrue(transitiveDeps.contains(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false)))
35
+ assertEquals(1, transitiveDeps.size)
36
+ }
37
+ }
@@ -0,0 +1,42 @@
1
+ package com.callstack.react.brownfield.shared
2
+
3
+ import org.junit.jupiter.api.Test
4
+ import kotlin.test.assertEquals
5
+ import kotlin.test.assertTrue
6
+
7
+ class VersionMediatingDependencySetTest {
8
+ @Test
9
+ fun `keeps the higher version when the same coordinate is added twice`() {
10
+ val set = VersionMediatingDependencySet()
11
+
12
+ set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.6.0", "compile", false))
13
+ set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false))
14
+
15
+ assertEquals(1, set.size)
16
+ assertTrue(set.contains(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false)))
17
+ }
18
+
19
+ @Test
20
+ fun `does not downgrade when a lower version is added second`() {
21
+ val set = VersionMediatingDependencySet()
22
+
23
+ set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false))
24
+ set.add(DependencyInfo("androidx.appcompat", "appcompat", "1.6.0", "compile", false))
25
+
26
+ val kept = set.first { it.groupId == "androidx.appcompat" }
27
+ assertEquals("1.7.1", kept.version)
28
+ }
29
+
30
+ @Test
31
+ fun `removeAll removes matching entries and returns them`() {
32
+ val set = VersionMediatingDependencySet()
33
+ set.add(DependencyInfo("host.exp.exponent", "expo", "1.0.0", "compile", false))
34
+ set.add(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false))
35
+
36
+ val removed = set.removeAll { it.groupId == "host.exp.exponent" }
37
+
38
+ assertEquals(1, removed.size)
39
+ assertEquals(1, set.size)
40
+ assertTrue(set.contains(DependencyInfo("androidx.core", "core-ktx", "1.17.0", "compile", false)))
41
+ }
42
+ }
@@ -0,0 +1,11 @@
1
+ package com.callstack.react.brownfield.utils
2
+
3
+ import org.junit.jupiter.api.Test
4
+ import kotlin.test.assertTrue
5
+
6
+ class ExtensionTest {
7
+ @Test
8
+ fun `experimentalIncludeTransitiveDependencies defaults to true`() {
9
+ assertTrue(Extension().experimentalIncludeTransitiveDependencies)
10
+ }
11
+ }
@@ -0,0 +1,93 @@
1
+ import Foundation
2
+
3
+ /// Identifies one live React Native surface. Vended by `BrownfieldSurfaceRegistry`.
4
+ struct BrownfieldSurfaceID: Hashable, CustomStringConvertible {
5
+ fileprivate let rawValue: UInt64
6
+
7
+ var description: String { "surface#\(rawValue)" }
8
+ }
9
+
10
+ /// Decides which React Native surface may act on a `popToNative()` request.
11
+ ///
12
+ /// `popToNative()` is delivered as an app-wide notification, so every live React
13
+ /// Native surface receives it — including ones buried under other screens. Acting
14
+ /// on all of them tears down the whole navigation stack
15
+ /// (https://github.com/callstack/react-native-brownfield/issues/354).
16
+ ///
17
+ /// The registry resolves that to a single surface: whichever on-screen surface
18
+ /// was shown most recently.
19
+ ///
20
+ /// Visibility is asked for at query time instead of being cached from lifecycle
21
+ /// callbacks. Hosts recreate view controllers — SwiftUI does it freely — and a
22
+ /// cached flag is stale exactly when the notification arrives.
23
+ ///
24
+ /// Deliberately free of UIKit so it can be unit tested on macOS via `swift test`.
25
+ /// Internal, like the sibling `BrownfieldBundleSupport` target: it is an
26
+ /// implementation detail of the pod, not API for host apps.
27
+ final class BrownfieldSurfaceRegistry {
28
+ static let shared = BrownfieldSurfaceRegistry()
29
+
30
+ private struct Surface {
31
+ let isOnScreen: () -> Bool
32
+ var shownAt: UInt64
33
+ }
34
+
35
+ private let lock = NSLock()
36
+ private var nextSurfaceValue: UInt64 = 0
37
+ private var nextShownValue: UInt64 = 0
38
+ private var surfaces: [BrownfieldSurfaceID: Surface] = [:]
39
+
40
+ init() {}
41
+
42
+ /// - Parameter isOnScreen: queried on every `popTarget()`. Capture the host
43
+ /// weakly so a released surface stops winning the election.
44
+ func register(isOnScreen: @escaping () -> Bool) -> BrownfieldSurfaceID {
45
+ lock.lock()
46
+ defer { lock.unlock() }
47
+
48
+ nextSurfaceValue += 1
49
+ let id = BrownfieldSurfaceID(rawValue: nextSurfaceValue)
50
+ surfaces[id] = Surface(isOnScreen: isOnScreen, shownAt: 0)
51
+ return id
52
+ }
53
+
54
+ func unregister(_ surface: BrownfieldSurfaceID) {
55
+ lock.lock()
56
+ defer { lock.unlock() }
57
+
58
+ surfaces.removeValue(forKey: surface)
59
+ }
60
+
61
+ /// Records that a surface was just shown, ordering it ahead of other on-screen
62
+ /// surfaces. Only a tiebreaker — a surface never marked shown can still win
63
+ /// when it is the one on screen.
64
+ func markShown(_ surface: BrownfieldSurfaceID) {
65
+ lock.lock()
66
+ defer { lock.unlock() }
67
+
68
+ guard var entry = surfaces[surface] else { return }
69
+
70
+ nextShownValue += 1
71
+ entry.shownAt = nextShownValue
72
+ surfaces[surface] = entry
73
+ }
74
+
75
+ /// The single surface allowed to handle `popToNative()`, or `nil` when no
76
+ /// React Native surface is on screen.
77
+ func popTarget() -> BrownfieldSurfaceID? {
78
+ lock.lock()
79
+ let candidates = surfaces
80
+ lock.unlock()
81
+
82
+ return candidates
83
+ .filter { $0.value.isOnScreen() }
84
+ .max { lhs, rhs in
85
+ (lhs.value.shownAt, lhs.key.rawValue) < (rhs.value.shownAt, rhs.key.rawValue)
86
+ }?
87
+ .key
88
+ }
89
+
90
+ func isPopTarget(_ surface: BrownfieldSurfaceID) -> Bool {
91
+ popTarget() == surface
92
+ }
93
+ }
@@ -1,20 +1,38 @@
1
1
  import SwiftUI
2
2
 
3
+ /**
4
+ Links a SwiftUI `ReactNativeView` to the view controller it hosts, so the
5
+ SwiftUI layer can ask whether this surface may act on `popToNative()`.
6
+ */
7
+ final class BrownfieldSurfaceHost {
8
+ weak var controller: ReactNativeViewController?
9
+
10
+ var isPopTarget: Bool { controller?.isPopTarget ?? false }
11
+ }
12
+
3
13
  /**
4
14
  A UIViewControllerRepresentable that bridges ReactNativeViewController to SwiftUI.
5
15
  */
6
16
  struct ReactNativeViewRepresentable: UIViewControllerRepresentable {
7
17
  var moduleName: String
8
18
  var initialProperties: [String: Any] = [:]
19
+ var host: BrownfieldSurfaceHost
9
20
 
10
21
  func makeUIViewController(context: Context) -> UIViewController {
11
- return ReactNativeViewController(
22
+ let controller = ReactNativeViewController(
12
23
  moduleName: moduleName,
13
24
  initialProperties: initialProperties
14
25
  )
26
+ controller.isHostedBySwiftUI = true
27
+ host.controller = controller
28
+ return controller
15
29
  }
16
30
 
17
- func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
31
+ func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
32
+ if let controller = uiViewController as? ReactNativeViewController {
33
+ host.controller = controller
34
+ }
35
+ }
18
36
  }
19
37
 
20
38
  /**
@@ -24,6 +42,7 @@ struct ReactNativeViewRepresentable: UIViewControllerRepresentable {
24
42
  @available(iOS 15.0, *)
25
43
  public struct ReactNativeView: View {
26
44
  @Environment(\.dismiss) var dismiss
45
+ @State private var host = BrownfieldSurfaceHost()
27
46
  var moduleName: String
28
47
  var initialProperties: [String: Any] = [:]
29
48
 
@@ -35,11 +54,21 @@ public struct ReactNativeView: View {
35
54
  public var body: some View {
36
55
  ReactNativeViewRepresentable(
37
56
  moduleName: moduleName,
38
- initialProperties: initialProperties
57
+ initialProperties: initialProperties,
58
+ host: host
39
59
  )
40
60
  .ignoresSafeArea(.all)
41
- .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name.popToNative))
42
- { notification in
61
+ // `.popToNative` is public API and may be posted from any thread, while
62
+ // `host.isPopTarget` reads UIKit state — so deliver on main.
63
+ .onReceive(
64
+ NotificationCenter.default
65
+ .publisher(for: NSNotification.Name.popToNative)
66
+ .receive(on: DispatchQueue.main)
67
+ ) { _ in
68
+ // Every live surface receives this app-wide notification; only the
69
+ // topmost on-screen one may dismiss.
70
+ // https://github.com/callstack/react-native-brownfield/issues/354
71
+ guard host.isPopTarget else { return }
43
72
  dismiss()
44
73
  }
45
74
  }