Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
24 changes: 15 additions & 9 deletions native-views/react-native-native-overlay/docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ObjectIdentifier>()
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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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)!
Expand Down
Loading