diff --git a/native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift b/native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift index ddd83f8f8..4eafa897d 100644 --- a/native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift +++ b/native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift @@ -47,14 +47,22 @@ final class NativeListMediaFrameCaptureTests: XCTestCase { XCTAssertEqual(UIApplication.shared.applicationState, .active, "Video capture requires a foreground app host") let url = try writeVideo() defer { try? FileManager.default.removeItem(at: url) } - let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) - window.rootViewController = UIViewController() - window.isHidden = false - defer { window.isHidden = true } + let sceneWindow = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first(where: { $0.activationState == .foregroundActive })? + .windows.first(where: { $0.isKeyWindow }) + let window = try XCTUnwrap(sceneWindow ?? UIApplication.shared.delegate?.window ?? nil) + let rootView = try XCTUnwrap(window.rootViewController?.view) + XCTAssertFalse(window.isHidden, "Video capture requires a visible app window") + let container = UIView(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + rootView.addSubview(container) + defer { container.removeFromSuperview() } let slot = NativeListMediaPreviewSlot() slot.view.frame = CGRect(x: 0, y: 0, width: 120, height: 120) - window.rootViewController?.view.addSubview(slot.view) + container.addSubview(slot.view) slot.view.layoutIfNeeded() + XCTAssertTrue(slot.view.window === window) + XCTAssertTrue(NativeListMediaPreviewSlot.intersectsViewport(slot.view)) var loaded: Bool? slot.bind(["uri": url.absoluteString], key: "video", probeOrder: ["video"]) { loaded = $0 } @@ -73,7 +81,7 @@ final class NativeListMediaFrameCaptureTests: XCTestCase { slot.view.removeFromSuperview() XCTAssertTrue(slot.capturedFrame === frame, "Detaching has no replacement screen scale") XCTAssertEqual(slot.createdPlayerCount, playersBeforeDetach) - window.rootViewController?.view.addSubview(slot.view) + container.addSubview(slot.view) slot.bind(["uri": url.absoluteString], key: "video", probeOrder: ["video"]) XCTAssertTrue(slot.capturedFrame === frame, "The same window retains the same tier") XCTAssertEqual(slot.createdPlayerCount, playersBeforeDetach) @@ -91,7 +99,7 @@ final class NativeListMediaFrameCaptureTests: XCTestCase { XCTAssertEqual(slot.createdPlayerCount, coldPlayers, "Recycled row reuses the cached still") let second = NativeListMediaPreviewSlot() second.view.frame = CGRect(x: 120, y: 0, width: 120, height: 120) - window.rootViewController?.view.addSubview(second.view) + container.addSubview(second.view) second.view.layoutIfNeeded() second.bind(["uri": url.absoluteString], key: "another-cell", probeOrder: ["video"]) let crossCellDeadline = Date().addingTimeInterval(3) diff --git a/native-views/react-native-native-overlay/docs/SPEC.md b/native-views/react-native-native-overlay/docs/SPEC.md index e215b9fab..18286f536 100644 --- a/native-views/react-native-native-overlay/docs/SPEC.md +++ b/native-views/react-native-native-overlay/docs/SPEC.md @@ -1,10 +1,12 @@ # react-native-native-overlay SPEC Status: Implemented through the P5 caller migration in app-monorepo. Android -and Web runtime checks passed there; focused iOS 26.5 simulator and Android 16 -emulator checks are recorded below. Broader iOS runtime, physical-device and -iPad acceptance remain unverified. Packaging in app-modules is validated -separately. +and Web runtime checks passed there; focused iOS 26.5 and iOS 27.0 simulator +checks and Android 16 emulator checks are recorded below. The iOS 27.0 check +covers this window handoff with repeated More-sheet gestures, immediate and +delayed account-selector dismissal, and an interactive cancellation. Broader +iOS runtime, physical-device and iPad acceptance remain unverified. Packaging +in app-modules is validated separately. The implementation is migrated from app-monorepo commit `376633bd9f1c410dbccb01fb6a0492be9a5c3f38`, package @@ -345,6 +347,16 @@ not change React ownership. Public APIs, Android/Web, and page scope are unchang first responder; passive toast/debug entries do not win solely by level. Keyboard restoration may finish after dismissal completion; callers/tests requiring keyboard geometry must observe the new keyboard readiness event. +- Removing an entry while the app window has an interactive UIKit presentation + removes that entry from active ordering and restores key ownership immediately, + but retains its exiting window/root/scene until the interaction finishes or + cancels. After UIKit reports the interaction change, cleanup runs on the next + main turn and rechecks the current transition coordinator before releasing + that exact window lifetime. A newly interactive transition keeps it retired + until its own completion; repeated or late callbacks cannot release a new + entry for the same owner. Scene disconnect releases active and retired windows + without notifying an already removed owner again. No fixed timeout controls + this handoff. - Every presentation lifetime has a generation. Asynchronous present, dismiss, and animation completions must match the generation, controller/entry identity and applicable phase before changing state or emitting callbacks. @@ -634,11 +646,13 @@ of these UIKit resources. Native logger persistence is owned by the logger. ## 5.8 Performance and resource budget -- iOS entry windows are created lazily and released after their entry detaches. - Window count is O(active global entries) per scene, rather than a six-window - level cache. No new public limit/configuration is introduced; page entries - remain views. Re-ranking is O(n log n) for the affected scene and creates no - per-frame bridge work. +- iOS entry windows are created lazily and released after their entry detaches, + or after an in-progress UIKit interaction finishes when release is deferred. + Window count is O(active global entries plus retired interactive windows) per + scene, rather than a six-window level cache. Scene disconnect drains both. + No new public limit/configuration is introduced; page entries remain views. + Re-ranking is O(n log n) for the affected scene and creates no per-frame + bridge work. - Android layers are views, not windows: no extra surfaces or WindowManager IPC. - Store operations are O(n) in the number of live entries. Snapshots are rebuilt once per mutation. @@ -670,15 +684,27 @@ navigation remains a separate runtime acceptance item. ## 5.9 Conformance and acceptance +Focused iOS 27.0 simulator runtime check (2026-11-13): three repetitions of +the previously failing 0.25-second More-to-account-selector handoff and one +1.25-second control dismissed to Home. A separate 0.25-second handoff with a +slow partial selector drag canceled and returned to its original position; +More reopened and a subsequent full drag dismissed normally. Home and Market +remained interactive. This checks the main-UI window handoff only; physical +devices and the broader runtime matrix remain unverified. + Automated (P0): - `tests/ios/run.sh` compiles the production WindowManager, SheetController and - Animation into an isolated UIKit test app. Its seven checks cover bounded/live + Animation into an isolated UIKit test app. Its eleven checks cover bounded/live ordering, key restoration eligibility, owning-entry release, injected scene disconnect/reuse with original-window identity and reentrant-registration rejection, conditional original-input focus restoration (including higher/foreign/new-input guards), and a native sheet surviving another - entry's dismissal. + entry's dismissal. Four focused checks cover interactive retirement after + finish and cancellation, reopening the same owner while the old window is + retired, and disconnecting a scene with only a retired window. They use a + controlled transition coordinator to test manager lifecycle; the real + gesture check is the iOS 27.0 simulator run above. Sheet checks use `animated: false`; they do not compile the React/Fabric Container or replace Gallery coverage of animated reversal/generation races, real multi-scene/disconnect, keyboard geometry or accessibility. diff --git a/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift b/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift index 91ac5fff8..0c3584b3b 100644 --- a/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift +++ b/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift @@ -174,7 +174,19 @@ final class NativeOverlayWindowManager: NSObject { } } + private final class RetiredWindow { + let window: NativeOverlayPassthroughWindow + weak var appWindow: UIWindow? + weak var observedCoordinator: AnyObject? + + init(window: NativeOverlayPassthroughWindow, appWindow: UIWindow?) { + self.window = window + self.appWindow = appWindow + } + } + private var scenes: [ObjectIdentifier: [ObjectIdentifier: Entry]] = [:] + private var retiredWindows: [ObjectIdentifier: [ObjectIdentifier: RetiredWindow]] = [:] private var disconnectingScenes = Set() private var sequence = 0 @@ -256,7 +268,15 @@ final class NativeOverlayWindowManager: NSObject { scenes[sceneKey]?[ownerKey] = nil let remaining = scenes[sceneKey] ?? [:] if remaining.isEmpty { scenes[sceneKey] = nil } - release(entry.window) + if Self.interactiveCoordinator(in: entry.appWindow) != nil { + // Destroying this window context can strand a touch already routed to the app's interactive sheet. + let retirement = RetiredWindow(window: entry.window, appWindow: entry.appWindow) + let windowKey = ObjectIdentifier(entry.window) + retiredWindows[sceneKey, default: [:]][windowKey] = retirement + finishRetirement(retirement, sceneKey: sceneKey, windowKey: windowKey) + } else { + release(entry.window) + } reorder(remaining) guard wasKey, let scene, scene.activationState == .foregroundActive || scene.activationState == .foregroundInactive else { return } @@ -331,11 +351,45 @@ final class NativeOverlayWindowManager: NSObject { window.windowScene = nil } + private static func interactiveCoordinator(in appWindow: UIWindow?) -> UIViewControllerTransitionCoordinator? { + var controller = appWindow?.rootViewController + while let current = controller { + if let coordinator = current.transitionCoordinator, coordinator.isInteractive { + return coordinator + } + controller = current.presentedViewController + } + return nil + } + + private func finishRetirement(_ retirement: RetiredWindow, sceneKey: ObjectIdentifier, + windowKey: ObjectIdentifier) { + guard retiredWindows[sceneKey]?[windowKey] === retirement else { return } + guard let coordinator = Self.interactiveCoordinator(in: retirement.appWindow) else { + retiredWindows[sceneKey]?[windowKey] = nil + if retiredWindows[sceneKey]?.isEmpty == true { retiredWindows[sceneKey] = nil } + release(retirement.window) + return + } + guard retirement.observedCoordinator !== coordinator else { return } + retirement.observedCoordinator = coordinator + coordinator.notifyWhenInteractionChanges { [weak self, weak retirement] context in + guard !context.isInteractive else { return } + DispatchQueue.main.async { [weak self, weak retirement] in + guard let self, let retirement, + self.retiredWindows[sceneKey]?[windowKey] === retirement else { return } + self.finishRetirement(retirement, sceneKey: sceneKey, windowKey: windowKey) + } + } + } + @objc private func sceneDidDisconnect(_ notification: Notification) { precondition(Thread.isMainThread) guard let scene = notification.object as? UIWindowScene else { return } let sceneKey = ObjectIdentifier(scene) - guard let entries = scenes.removeValue(forKey: sceneKey) else { return } + let entries = scenes.removeValue(forKey: sceneKey) ?? [:] + let retired = retiredWindows.removeValue(forKey: sceneKey) ?? [:] + guard !entries.isEmpty || !retired.isEmpty else { return } disconnectingScenes.insert(sceneKey) defer { disconnectingScenes.remove(sceneKey) } // Unregister first: owner callbacks must not restore a disconnected scene's key. @@ -343,5 +397,6 @@ final class NativeOverlayWindowManager: NSObject { entry.owner?.overlaySceneDidDisconnect(window: entry.window) release(entry.window) } + for retirement in retired.values { release(retirement.window) } } } diff --git a/native-views/react-native-native-overlay/tests/ios/WindowManagerTests.swift b/native-views/react-native-native-overlay/tests/ios/WindowManagerTests.swift index e187504a1..5e1c05d54 100644 --- a/native-views/react-native-native-overlay/tests/ios/WindowManagerTests.swift +++ b/native-views/react-native-native-overlay/tests/ios/WindowManagerTests.swift @@ -16,11 +16,61 @@ private final class Owner: NativeOverlayWindowOwner, NativeOverlaySheetHost { func sheetRequestedDismiss(_ controller: NativeOverlaySheetController, reason: String) {} } +private final class TransitionRoot: UIViewController { + var activeCoordinator: UIViewControllerTransitionCoordinator? + override var transitionCoordinator: UIViewControllerTransitionCoordinator? { activeCoordinator } +} + +private final class TestTransitionCoordinator: NSObject, UIViewControllerTransitionCoordinator { + var isInteractive = true + var isCancelled = false + var subscriptions = 0 + private var handlers: [(UIViewControllerTransitionCoordinatorContext) -> Void] = [] + + var isAnimated: Bool { true } + var presentationStyle: UIModalPresentationStyle { .pageSheet } + var initiallyInteractive: Bool { true } + var isInterruptible: Bool { true } + var transitionDuration: TimeInterval { 0.3 } + var percentComplete: CGFloat { 0.2 } + var completionVelocity: CGFloat { 1 } + var completionCurve: UIView.AnimationCurve { .easeInOut } + var containerView = UIView() + var targetTransform: CGAffineTransform { .identity } + + func viewController(forKey key: UITransitionContextViewControllerKey) -> UIViewController? { nil } + func view(forKey key: UITransitionContextViewKey) -> UIView? { nil } + func animate(alongsideTransition animation: ((UIViewControllerTransitionCoordinatorContext) -> Void)?, + completion: ((UIViewControllerTransitionCoordinatorContext) -> Void)?) -> Bool { false } + func animateAlongsideTransition(in view: UIView?, + animation: ((UIViewControllerTransitionCoordinatorContext) -> Void)?, + completion: ((UIViewControllerTransitionCoordinatorContext) -> Void)?) -> Bool { false } + func notifyWhenInteractionEnds(_ handler: @escaping (UIViewControllerTransitionCoordinatorContext) -> Void) { + handlers.append(handler) + } + func notifyWhenInteractionChanges(_ handler: @escaping (UIViewControllerTransitionCoordinatorContext) -> Void) { + subscriptions += 1 + handlers.append(handler) + } + + func changeInteraction(to interactive: Bool, cancelled: Bool = false) { + isInteractive = interactive + isCancelled = cancelled + handlers.forEach { $0(self) } + } +} + private struct Failure: Error { let name: String } private func require(_ condition: @autoclosure () -> Bool, _ name: String) throws { if !condition() { throw Failure(name: name) } } +@MainActor private func nextMainTurn() async { + await withCheckedContinuation { (done: CheckedContinuation) in + DispatchQueue.main.async { done.resume() } + } +} + @MainActor private func runTests(scene: UIWindowScene, app: UIWindow) async throws -> [String] { var passed: [String] = [] let manager = NativeOverlayWindowManager() @@ -128,6 +178,84 @@ private func require(_ condition: @autoclosure () -> Bool, _ name: String) throw await withCheckedContinuation { (done: CheckedContinuation) in cb.dismiss(animated: false) { done.resume() } } manager.entryRemoved(from: sb) passed.append("production-system-sheet-independent-close") + + let transitionApp = UIWindow(windowScene: scene) + let transitionRoot = TransitionRoot() + transitionApp.rootViewController = transitionRoot + transitionApp.makeKeyAndVisible() + let transitionManager = NativeOverlayWindowManager() + let finishingOwner = Owner() + let finishingWindow = transitionManager.window(for: finishingOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: transitionApp)! + finishingWindow.makeKey() + let finishing = TestTransitionCoordinator() + transitionRoot.activeCoordinator = finishing + transitionManager.entryRemoved(from: finishingWindow) + try require(transitionApp.isKeyWindow && finishingWindow.windowScene === scene && + finishingWindow.rootViewController != nil && finishing.subscriptions == 1, + "interactive removal retains old context and restores key") + finishing.changeInteraction(to: false) + await nextMainTurn() + try require(finishingWindow.windowScene == nil && finishingWindow.rootViewController == nil, + "finished interaction releases old context on next main turn") + passed.append("interactive-window-retirement-finish") + + let cancellingOwner = Owner() + let cancellingWindow = transitionManager.window(for: cancellingOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: transitionApp)! + let cancelling = TestTransitionCoordinator() + transitionRoot.activeCoordinator = cancelling + transitionManager.entryRemoved(from: cancellingWindow) + cancelling.changeInteraction(to: false) + cancelling.changeInteraction(to: true) + await nextMainTurn() + try require(cancellingWindow.windowScene === scene && cancelling.subscriptions == 1, + "same coordinator reentry keeps context without duplicate subscription") + cancelling.changeInteraction(to: false, cancelled: true) + await nextMainTurn() + try require(cancellingWindow.windowScene == nil && cancellingWindow.rootViewController == nil, + "cancelled interaction releases old context") + passed.append("interactive-window-retirement-cancel-reentry") + + let reopeningOwner = Owner() + let oldWindow = transitionManager.window(for: reopeningOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: transitionApp)! + let oldInteraction = TestTransitionCoordinator() + transitionRoot.activeCoordinator = oldInteraction + transitionManager.entryRemoved(from: oldWindow) + let newWindow = transitionManager.window(for: reopeningOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: transitionApp)! + let newInteraction = TestTransitionCoordinator() + oldInteraction.changeInteraction(to: false) + transitionRoot.activeCoordinator = newInteraction + await nextMainTurn() + try require(oldWindow.windowScene === scene && newWindow.windowScene === scene && + newInteraction.subscriptions == 1, "new coordinator retains only old window lifetime") + newInteraction.changeInteraction(to: false) + await nextMainTurn() + try require(oldWindow.windowScene == nil && newWindow.windowScene === scene && + newWindow.rootViewController != nil, "late callback does not release reopened owner") + transitionManager.entryRemoved(from: newWindow) + passed.append("interactive-window-retirement-owner-reuse") + + let disconnectedOwner = Owner() + let disconnectedWindow = transitionManager.window(for: disconnectedOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: transitionApp)! + let disconnectedInteraction = TestTransitionCoordinator() + transitionRoot.activeCoordinator = disconnectedInteraction + transitionManager.entryRemoved(from: disconnectedWindow) + NotificationCenter.default.post(name: UIScene.didDisconnectNotification, object: scene) + try require(disconnectedWindow.windowScene == nil && disconnectedWindow.rootViewController == nil && + disconnectedOwner.disconnects == 0, "scene disconnect drains retired-only window once") + disconnectedInteraction.changeInteraction(to: false) + await nextMainTurn() + try require(disconnectedOwner.disconnects == 0 && disconnectedWindow.rootViewController == nil, + "late interaction callback after disconnect is inert") + transitionApp.isHidden = true + transitionApp.rootViewController = nil + transitionApp.windowScene = nil + app.makeKey() + passed.append("interactive-window-retirement-scene-disconnect") return passed }