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 4eafa897d..171b465f8 100644 --- a/native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift +++ b/native-views/react-native-native-list/ios/tests/NativeListMediaVisibilityTests.swift @@ -58,6 +58,7 @@ final class NativeListMediaFrameCaptureTests: XCTestCase { rootView.addSubview(container) defer { container.removeFromSuperview() } let slot = NativeListMediaPreviewSlot() + defer { slot.recycle() } slot.view.frame = CGRect(x: 0, y: 0, width: 120, height: 120) container.addSubview(slot.view) slot.view.layoutIfNeeded() @@ -98,6 +99,7 @@ final class NativeListMediaFrameCaptureTests: XCTestCase { XCTAssertNotNil(slot.capturedFrame) XCTAssertEqual(slot.createdPlayerCount, coldPlayers, "Recycled row reuses the cached still") let second = NativeListMediaPreviewSlot() + defer { second.recycle() } second.view.frame = CGRect(x: 120, y: 0, width: 120, height: 120) container.addSubview(second.view) second.view.layoutIfNeeded() @@ -108,7 +110,6 @@ final class NativeListMediaFrameCaptureTests: XCTestCase { } XCTAssertNotNil(second.capturedFrame) XCTAssertEqual(second.createdPlayerCount, 0, "A different cell uses shared cached pixels") - second.recycle(); slot.recycle() } /// A 64×48, ten-frame H.264 MP4 (generated with AVAssetWriter on macOS); the simulator diff --git a/native-views/react-native-native-overlay/docs/SPEC.md b/native-views/react-native-native-overlay/docs/SPEC.md index 18286f536..4e1090aa8 100644 --- a/native-views/react-native-native-overlay/docs/SPEC.md +++ b/native-views/react-native-native-overlay/docs/SPEC.md @@ -347,11 +347,14 @@ 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, +- Removing an entry while the app window or an attached contained controller + has an interactive UIKit transition 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 + cancels. The search covers presented controllers and child controllers whose + loaded views belong to the app window. 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 @@ -695,16 +698,19 @@ 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 eleven checks cover bounded/live + Animation into an isolated UIKit test app. Its thirteen 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. Four focused checks cover interactive retirement after + entry's dismissal. Six 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. + retired, and disconnecting a scene with only a retired window. Four use a + controlled transition coordinator to test manager lifecycle; one uses a + real contained `UINavigationController` interactive pop to verify retention + until cancellation; one ensures unloaded and unattached children do not + retain the window or load their views. The App gesture check above covers + the main-window handoff, not this contained-navigation path. 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 0c3584b3b..3e4fcbc7a 100644 --- a/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift +++ b/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift @@ -352,12 +352,16 @@ final class NativeOverlayWindowManager: NSObject { } private static func interactiveCoordinator(in appWindow: UIWindow?) -> UIViewControllerTransitionCoordinator? { - var controller = appWindow?.rootViewController - while let current = controller { + guard let appWindow, let root = appWindow.rootViewController else { return nil } + var stack = [root] + var visited = Set() + while let current = stack.popLast() { + guard visited.insert(ObjectIdentifier(current)).inserted else { continue } if let coordinator = current.transitionCoordinator, coordinator.isInteractive { return coordinator } - controller = current.presentedViewController + stack.append(contentsOf: current.children.filter { $0.viewIfLoaded?.window === appWindow }) + if let presented = current.presentedViewController { stack.append(presented) } } return nil } 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 5e1c05d54..9626ca15a 100644 --- a/native-views/react-native-native-overlay/tests/ios/WindowManagerTests.swift +++ b/native-views/react-native-native-overlay/tests/ios/WindowManagerTests.swift @@ -60,6 +60,40 @@ private final class TestTransitionCoordinator: NSObject, UIViewControllerTransit } } +private final class TestPopAnimator: NSObject, UIViewControllerAnimatedTransitioning { + func transitionDuration(using context: UIViewControllerContextTransitioning?) -> TimeInterval { 0.2 } + + func animateTransition(using context: UIViewControllerContextTransitioning) { + guard let from = context.view(forKey: .from), let to = context.view(forKey: .to) else { + context.completeTransition(false) + return + } + if to.superview == nil { context.containerView.insertSubview(to, belowSubview: from) } + UIView.animate(withDuration: transitionDuration(using: context), animations: { + from.transform = CGAffineTransform(translationX: context.containerView.bounds.width, y: 0) + }, completion: { _ in + from.transform = .identity + context.completeTransition(!context.transitionWasCancelled) + }) + } +} + +private final class TestPopDelegate: NSObject, UINavigationControllerDelegate { + let animator = TestPopAnimator() + var interaction: UIPercentDrivenInteractiveTransition? + + func navigationController(_ navigationController: UINavigationController, + animationControllerFor operation: UINavigationController.Operation, + from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { + operation == .pop ? animator : nil + } + + func navigationController(_ navigationController: UINavigationController, + interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + interaction + } +} + private struct Failure: Error { let name: String } private func require(_ condition: @autoclosure () -> Bool, _ name: String) throws { if !condition() { throw Failure(name: name) } @@ -238,6 +272,68 @@ private func require(_ condition: @autoclosure () -> Bool, _ name: String) throw transitionManager.entryRemoved(from: newWindow) passed.append("interactive-window-retirement-owner-reuse") + let nestedApp = UIWindow(windowScene: scene) + let containerRoot = UIViewController() + nestedApp.rootViewController = containerRoot + nestedApp.makeKeyAndVisible() + let navigation = UINavigationController(rootViewController: UIViewController()) + containerRoot.addChild(navigation) + navigation.view.frame = containerRoot.view.bounds + containerRoot.view.addSubview(navigation.view) + navigation.didMove(toParent: containerRoot) + navigation.pushViewController(UIViewController(), animated: false) + let popDelegate = TestPopDelegate() + let popInteraction = UIPercentDrivenInteractiveTransition() + popDelegate.interaction = popInteraction + navigation.delegate = popDelegate + let nestedManager = NativeOverlayWindowManager() + let nestedOwner = Owner() + let nestedWindow = nestedManager.window(for: nestedOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: nestedApp)! + nestedWindow.makeKey() + _ = navigation.popViewController(animated: true) + popInteraction.update(0.25) + for _ in 0..<5 where navigation.transitionCoordinator?.isInteractive != true { await nextMainTurn() } + try require(containerRoot.transitionCoordinator == nil && + navigation.transitionCoordinator?.isInteractive == true && navigation.viewIfLoaded?.window === nestedApp, + "real contained navigation transition is interactive only in the child") + nestedManager.entryRemoved(from: nestedWindow) + try require(nestedWindow.windowScene === scene && nestedWindow.rootViewController != nil, + "attached child navigation transition retains the exiting window") + popInteraction.cancel() + let nestedDeadline = Date().addingTimeInterval(2) + while nestedWindow.windowScene != nil && Date() < nestedDeadline { + try await Task.sleep(for: .milliseconds(20)) + } + try require(nestedWindow.windowScene == nil && nestedWindow.rootViewController == nil, + "cancelled child navigation transition releases the exiting window") + let unloadedChild = TransitionRoot() + unloadedChild.activeCoordinator = TestTransitionCoordinator() + containerRoot.addChild(unloadedChild) + let detachedChild = TransitionRoot() + _ = detachedChild.view + detachedChild.activeCoordinator = TestTransitionCoordinator() + containerRoot.addChild(detachedChild) + try require(unloadedChild.viewIfLoaded == nil && detachedChild.viewIfLoaded?.window == nil, + "inactive child transition precondition") + let inactiveOwner = Owner() + let inactiveWindow = nestedManager.window(for: inactiveOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: nestedApp)! + nestedManager.entryRemoved(from: inactiveWindow) + try require(inactiveWindow.windowScene == nil && inactiveWindow.rootViewController == nil && + unloadedChild.viewIfLoaded == nil, "unattached children do not retain a window or load a view") + unloadedChild.removeFromParent() + detachedChild.removeFromParent() + passed.append("interactive-window-retirement-unattached-children") + navigation.willMove(toParent: nil) + navigation.view.removeFromSuperview() + navigation.removeFromParent() + nestedApp.isHidden = true + nestedApp.rootViewController = nil + nestedApp.windowScene = nil + transitionApp.makeKey() + passed.append("interactive-window-retirement-contained-navigation") + let disconnectedOwner = Owner() let disconnectedWindow = transitionManager.window(for: disconnectedOwner, level: .modal, stackOrder: 1, blocking: true, appWindow: transitionApp)!