Skip to content
Merged
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 @@ -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 }
Expand All @@ -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)
Expand All @@ -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)
Expand Down
48 changes: 37 additions & 11 deletions native-views/react-native-native-overlay/docs/SPEC.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ObjectIdentifier>()
private var sequence = 0

Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -331,17 +351,52 @@ 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.
for entry in entries.values {
entry.owner?.overlaySceneDidDisconnect(window: entry.window)
release(entry.window)
}
for retirement in retired.values { release(retirement.window) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>) in
DispatchQueue.main.async { done.resume() }
}
}

@MainActor private func runTests(scene: UIWindowScene, app: UIWindow) async throws -> [String] {
var passed: [String] = []
let manager = NativeOverlayWindowManager()
Expand Down Expand Up @@ -128,6 +178,84 @@ private func require(_ condition: @autoclosure () -> Bool, _ name: String) throw
await withCheckedContinuation { (done: CheckedContinuation<Void, Never>) 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
}

Expand Down
Loading