From 404e87c68004bee166caf3e2f313ee3ab0c17af3 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 13 Nov 2026 20:10:04 +0800 Subject: [PATCH 1/3] fix: preserve iOS overlay contexts during app touches --- .../react-native-native-overlay/docs/SPEC.md | 62 +++++- .../ios/NativeOverlayWindowManager.swift | 132 +++++++++++- .../tests/ios/WindowManagerTests.swift | 195 ++++++++++++++++++ 3 files changed, 378 insertions(+), 11 deletions(-) diff --git a/native-views/react-native-native-overlay/docs/SPEC.md b/native-views/react-native-native-overlay/docs/SPEC.md index 18286f536..a55e8c469 100644 --- a/native-views/react-native-native-overlay/docs/SPEC.md +++ b/native-views/react-native-native-overlay/docs/SPEC.md @@ -347,16 +347,34 @@ 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 +- App-touch retirement extension (Implemented; nineteen focused native checks + and eight local iOS 27.0 App handoff cases passed): before showing the first + global entry for a real app window, install + one passive touch observer on that app window. Entries, including nested + overlay hosts, share that observer. It tracks delivered touch identities, never + retains UIEvent objects, never cancels or delays touches, and neither prevents + nor can be prevented by another recognizer. It stays possible until the last + tracked touch ends or cancels, then fails and resets without recognizing. +- Removing an entry while its real app window has delivered active touches or + 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 + but retains its exiting window/root/scene, without hiding it, until both the + delivered touch set is empty and the interaction finishes or cancels. Touch + completion/cancellation and UIKit interaction changes schedule cleanup on the + next main turn. Cleanup rechecks the complete current touch set and 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 + entry for the same owner. A new touch beginning after a previous touch ends but + before queued cleanup keeps the window retired. Overlapping multi-touch can + extend retirement without a timeout. When neither condition exists, removal + still releases synchronously. Remove the shared observer only when that app + window has no active or retired entries. Scene disconnect unregisters observers + and their callbacks before releasing active and retired windows without notifying an already removed owner again. No fixed timeout controls this handoff. + Public UIKit observation cannot account for a DOWN not yet delivered to the + app window; acceptance must separately exercise that delivery/teardown boundary. - 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. @@ -692,10 +710,34 @@ 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. +App-touch extension acceptance (2026-11-13, local Debug build on an isolated +iOS 27.0 simulator): all eight first-pan-only cases passed, starting from the +Home header or list body at 250, 300, 350 and 400 ms after More close UP. The +250/300 ms cases kept all twenty old-context events routed to the app window; +350/400 ms started after teardown (observed 5–49 ms later) and routed all twenty +new-context events normally. No backing-event-only dispatch occurred. Native +pager logs recorded ended state 3 in every case. List-body accessibility samples +continued moving after UP without another input, confirming inertia. After +waiting for release, the first React control tap opened Home More (header) or +AccountSelectorStack (body), without another pan or tab switch. Per-case main +and background runtime markers remained unchanged. These measured teardown +boundaries do not guarantee observation of a DOWN UIKit has not delivered yet. + +Two additional selector handoffs exercised multiple front-card drags and a +partial rebound, then front dismissal followed 250 ms later by either a complete +back-selector dismissal or a partial drag canceled into rebound. Both preserved +complete old-context delivery (36 and 16 events respectively), zero backing-only +events, stable main/background markers, and working direct React controls. +The entry was the watch-only account-row More inside AccountSelectorStack, +which uses the same global NativeOverlay UIWindow over a UIKit modal host. +It is an equivalent-host regression, not a replay of the original three-account +HD wallet-level menu. Release/TestFlight binaries, physical devices and other +OS versions 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 nineteen 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 @@ -705,6 +747,14 @@ Automated (P0): 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. + Eight app-touch checks directly drive the production observer's + `touchesBegan`/`touchesEnded`/`touchesCancelled` and reset with synthetic + touch objects. They assert passive gesture flags, touch-only retirement, + multi-touch and per-real-window isolation, next-turn touch-set rechecks, + reset cleanup, conjunction with coordinator ownership, owner/orphan reuse, + and scene-disconnect callback invalidation. They prove manager lifecycle, + not UIKit delivery or scroll inertia; those are covered separately by the + local App runtime checks above, with the stated selector-entry limitation. 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..79fa172ab 100644 --- a/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift +++ b/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift @@ -147,6 +147,56 @@ protocol NativeOverlayWindowOwner: AnyObject { func overlaySceneDidDisconnect(window: UIWindow) } +/// Observes delivered app-window touches without participating in gesture arbitration. +final class NativeOverlayAppTouchObserver: UIGestureRecognizer { + var onTouchesFinished: (() -> Void)? + private var activeTouches = Set() + var hasActiveTouches: Bool { !activeTouches.isEmpty } + + override init(target: Any?, action: Selector?) { + super.init(target: target, action: action) + configure() + } + + private func configure() { + cancelsTouchesInView = false + delaysTouchesBegan = false + delaysTouchesEnded = false + } + + override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool { false } + override func canBePrevented(by preventingGestureRecognizer: UIGestureRecognizer) -> Bool { false } + + override func touchesBegan(_ touches: Set, with event: UIEvent) { + activeTouches.formUnion(touches.map(ObjectIdentifier.init)) + } + + override func touchesMoved(_ touches: Set, with event: UIEvent) {} + + override func touchesEnded(_ touches: Set, with event: UIEvent) { + finish(touches) + } + + override func touchesCancelled(_ touches: Set, with event: UIEvent) { + finish(touches) + } + + private func finish(_ touches: Set) { + let hadTouches = hasActiveTouches + activeTouches.subtract(touches.map(ObjectIdentifier.init)) + guard hadTouches, !hasActiveTouches else { return } + state = .failed + onTouchesFinished?() + } + + override func reset() { + super.reset() + let hadTouches = hasActiveTouches + activeTouches.removeAll() + if hadTouches { onTouchesFinished?() } + } +} + final class NativeOverlayWindowManager: NSObject { static let shared = NativeOverlayWindowManager() @@ -185,8 +235,21 @@ final class NativeOverlayWindowManager: NSObject { } } + private final class AppTouchObservation { + weak var appWindow: UIWindow? + let sceneKey: ObjectIdentifier + let observer: NativeOverlayAppTouchObserver + + init(appWindow: UIWindow, sceneKey: ObjectIdentifier, observer: NativeOverlayAppTouchObserver) { + self.appWindow = appWindow + self.sceneKey = sceneKey + self.observer = observer + } + } + private var scenes: [ObjectIdentifier: [ObjectIdentifier: Entry]] = [:] private var retiredWindows: [ObjectIdentifier: [ObjectIdentifier: RetiredWindow]] = [:] + private var appTouchObservations: [ObjectIdentifier: AppTouchObservation] = [:] private var disconnectingScenes = Set() private var sequence = 0 @@ -198,7 +261,13 @@ final class NativeOverlayWindowManager: NSObject { ) } - deinit { NotificationCenter.default.removeObserver(self) } + deinit { + NotificationCenter.default.removeObserver(self) + for observation in appTouchObservations.values { + observation.observer.onTouchesFinished = nil + observation.appWindow?.removeGestureRecognizer(observation.observer) + } + } /// Every owner has an independent root presenter, shared by no other entry. func window(for owner: NativeOverlayWindowOwner, level: NativeOverlayLevel, @@ -212,6 +281,7 @@ final class NativeOverlayWindowManager: NSObject { guard !disconnectingScenes.contains(sceneKey) else { return nil } let orphanWindows = (scenes[sceneKey] ?? [:]).values.filter { $0.owner == nil }.map(\.window) orphanWindows.forEach { entryRemoved(from: $0) } + observeTouches(in: appWindow, sceneKey: sceneKey) let ownerKey = ObjectIdentifier(owner) var entries = scenes[sceneKey] ?? [:] let entry: Entry @@ -237,6 +307,7 @@ final class NativeOverlayWindowManager: NSObject { reorder(entries) entry.window.isHidden = false entry.window.rootViewController?.setNeedsStatusBarAppearanceUpdate() + removeUnusedTouchObservers() return entry.window } @@ -268,8 +339,8 @@ final class NativeOverlayWindowManager: NSObject { scenes[sceneKey]?[ownerKey] = nil let remaining = scenes[sceneKey] ?? [:] if remaining.isEmpty { scenes[sceneKey] = nil } - if Self.interactiveCoordinator(in: entry.appWindow) != nil { - // Destroying this window context can strand a touch already routed to the app's interactive sheet. + if hasActiveTouches(in: entry.appWindow) || Self.interactiveCoordinator(in: entry.appWindow) != nil { + // Keep the routing context until delivered app touches and UIKit interactions both finish. let retirement = RetiredWindow(window: entry.window, appWindow: entry.appWindow) let windowKey = ObjectIdentifier(entry.window) retiredWindows[sceneKey, default: [:]][windowKey] = retirement @@ -277,6 +348,7 @@ final class NativeOverlayWindowManager: NSObject { } else { release(entry.window) } + removeUnusedTouchObservers() reorder(remaining) guard wasKey, let scene, scene.activationState == .foregroundActive || scene.activationState == .foregroundInactive else { return } @@ -351,6 +423,47 @@ final class NativeOverlayWindowManager: NSObject { window.windowScene = nil } + private func observeTouches(in appWindow: UIWindow, sceneKey: ObjectIdentifier) { + let appWindowKey = ObjectIdentifier(appWindow) + guard appTouchObservations[appWindowKey] == nil else { return } + let observer = NativeOverlayAppTouchObserver(target: nil, action: nil) + appTouchObservations[appWindowKey] = AppTouchObservation( + appWindow: appWindow, sceneKey: sceneKey, observer: observer) + observer.onTouchesFinished = { [weak self, weak appWindow, weak observer] in + guard let self, let appWindow, + self.retiredWindows[sceneKey]?.values.contains(where: { $0.appWindow === appWindow }) == true + else { return } + DispatchQueue.main.async { [weak self, weak appWindow, weak observer] in + guard let self, let appWindow, let observer, + self.appTouchObservations[appWindowKey]?.observer === observer else { return } + // Query current retirements and touches: another DOWN may precede this queued check. + let retirements = (self.retiredWindows[sceneKey] ?? [:]).filter { $0.value.appWindow === appWindow } + for (windowKey, retirement) in retirements { + self.finishRetirement(retirement, sceneKey: sceneKey, windowKey: windowKey) + } + } + } + appWindow.addGestureRecognizer(observer) + } + + private func hasActiveTouches(in appWindow: UIWindow?) -> Bool { + guard let appWindow else { return false } + return appTouchObservations[ObjectIdentifier(appWindow)]?.observer.hasActiveTouches == true + } + + private func removeUnusedTouchObservers() { + for (key, observation) in appTouchObservations { + let active = observation.appWindow.map { appWindow in + scenes.values.contains { $0.values.contains { $0.appWindow === appWindow } } || + retiredWindows.values.contains { $0.values.contains { $0.appWindow === appWindow } } + } ?? false + guard !active else { continue } + observation.observer.onTouchesFinished = nil + observation.appWindow?.removeGestureRecognizer(observation.observer) + appTouchObservations[key] = nil + } + } + private static func interactiveCoordinator(in appWindow: UIWindow?) -> UIViewControllerTransitionCoordinator? { var controller = appWindow?.rootViewController while let current = controller { @@ -365,12 +478,15 @@ final class NativeOverlayWindowManager: NSObject { 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 { + let coordinator = Self.interactiveCoordinator(in: retirement.appWindow) + if coordinator == nil && !hasActiveTouches(in: retirement.appWindow) { retiredWindows[sceneKey]?[windowKey] = nil if retiredWindows[sceneKey]?.isEmpty == true { retiredWindows[sceneKey] = nil } release(retirement.window) + removeUnusedTouchObservers() return } + guard let coordinator else { return } guard retirement.observedCoordinator !== coordinator else { return } retirement.observedCoordinator = coordinator coordinator.notifyWhenInteractionChanges { [weak self, weak retirement] context in @@ -389,9 +505,15 @@ final class NativeOverlayWindowManager: NSObject { let sceneKey = ObjectIdentifier(scene) let entries = scenes.removeValue(forKey: sceneKey) ?? [:] let retired = retiredWindows.removeValue(forKey: sceneKey) ?? [:] - guard !entries.isEmpty || !retired.isEmpty else { return } + let observations = appTouchObservations.filter { $0.value.sceneKey == sceneKey } + guard !entries.isEmpty || !retired.isEmpty || !observations.isEmpty else { return } disconnectingScenes.insert(sceneKey) defer { disconnectingScenes.remove(sceneKey) } + for (key, observation) in observations { + observation.observer.onTouchesFinished = nil + observation.appWindow?.removeGestureRecognizer(observation.observer) + appTouchObservations[key] = nil + } // Unregister first: owner callbacks must not restore a disconnected scene's key. for entry in entries.values { entry.owner?.overlaySceneDidDisconnect(window: entry.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 5e1c05d54..5daa7a2bd 100644 --- a/native-views/react-native-native-overlay/tests/ios/WindowManagerTests.swift +++ b/native-views/react-native-native-overlay/tests/ios/WindowManagerTests.swift @@ -71,6 +71,12 @@ private func require(_ condition: @autoclosure () -> Bool, _ name: String) throw } } +private func touchObserver(in window: UIWindow) throws -> NativeOverlayAppTouchObserver { + guard let observer = window.gestureRecognizers?.compactMap({ $0 as? NativeOverlayAppTouchObserver }).first + else { throw Failure(name: "app touch observer installed before overlay visibility") } + return observer +} + @MainActor private func runTests(scene: UIWindowScene, app: UIWindow) async throws -> [String] { var passed: [String] = [] let manager = NativeOverlayWindowManager() @@ -256,6 +262,195 @@ private func require(_ condition: @autoclosure () -> Bool, _ name: String) throw transitionApp.windowScene = nil app.makeKey() passed.append("interactive-window-retirement-scene-disconnect") + + let touchManager = NativeOverlayWindowManager() + let touchOwner = Owner() + let touchWindow = touchManager.window(for: touchOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let observer = try touchObserver(in: app) + let pan = UIPanGestureRecognizer() + try require(!observer.cancelsTouchesInView && !observer.delaysTouchesBegan && + !observer.delaysTouchesEnded && !observer.canPrevent(pan) && !observer.canBePrevented(by: pan), + "touch observer cannot delay, cancel, prevent or be prevented") + let event = UIEvent() + let firstTouch = UITouch() + observer.touchesBegan([firstTouch], with: event) + observer.touchesMoved([firstTouch], with: event) + touchWindow.makeKey() + let touchRoot = touchWindow.rootViewController + touchManager.entryRemoved(from: touchWindow) + try require(app.isKeyWindow && touchWindow.windowScene === scene && + touchWindow.rootViewController === touchRoot && !touchWindow.isHidden && observer.state == .possible, + "touch-only removal keeps routing context visible while restoring key") + observer.touchesEnded([firstTouch], with: event) + try require(touchWindow.windowScene === scene, "touch completion waits for next main turn") + await nextMainTurn() + try require(touchWindow.windowScene == nil && touchWindow.rootViewController == nil && + observer.view == nil && observer.onTouchesFinished == nil, + "last touch releases window and unregisters idle observer") + passed.append("touch-window-retirement-and-passive-observer") + + let overlapA = Owner(), overlapB = Owner() + let overlapWindowA = touchManager.window(for: overlapA, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let sharedObserver = try touchObserver(in: app) + let overlapWindowB = touchManager.window(for: overlapB, level: .modal, + stackOrder: 2, blocking: true, appWindow: overlapWindowA)! + let nestedObserver = try touchObserver(in: app) + try require(nestedObserver === sharedObserver && + app.gestureRecognizers?.filter({ $0 is NativeOverlayAppTouchObserver }).count == 1 && + overlapWindowA.gestureRecognizers?.contains(where: { $0 is NativeOverlayAppTouchObserver }) != true, + "nested and multiple entries share only the real app window observer") + let fingerA = UITouch(), fingerB = UITouch() + sharedObserver.touchesBegan([fingerA, fingerB], with: event) + let otherApp = UIWindow(windowScene: scene) + otherApp.rootViewController = UIViewController() + let otherOwner = Owner() + let otherWindow = touchManager.window(for: otherOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: otherApp)! + let otherObserver = try touchObserver(in: otherApp) + touchManager.entryRemoved(from: otherWindow) + try require(otherObserver !== sharedObserver && otherWindow.windowScene == nil && + otherObserver.view == nil && sharedObserver.view === app, + "another app window in the same scene has an independent touch lifetime") + otherApp.rootViewController = nil + otherApp.windowScene = nil + touchManager.entryRemoved(from: overlapWindowA) + touchManager.entryRemoved(from: overlapWindowB) + sharedObserver.touchesEnded([fingerA], with: event) + await nextMainTurn() + try require(overlapWindowA.windowScene === scene && overlapWindowB.windowScene === scene && + sharedObserver.hasActiveTouches, "one finger ending cannot release either retired entry") + sharedObserver.touchesCancelled([fingerB], with: event) + await nextMainTurn() + try require(overlapWindowA.windowScene == nil && overlapWindowB.windowScene == nil && + sharedObserver.view == nil, "last finger cancellation releases all retired entries") + passed.append("touch-window-retirement-shared-multitouch-cancel") + + let queuedOwner = Owner() + let queuedWindow = touchManager.window(for: queuedOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let queuedObserver = try touchObserver(in: app) + let endingTouch = UITouch(), nextTouch = UITouch() + queuedObserver.touchesBegan([endingTouch], with: event) + touchManager.entryRemoved(from: queuedWindow) + queuedObserver.touchesEnded([endingTouch], with: event) + queuedObserver.reset() + queuedObserver.touchesBegan([nextTouch], with: event) + await nextMainTurn() + try require(queuedWindow.windowScene === scene && queuedObserver.hasActiveTouches, + "queued cleanup rechecks a new touch beginning after the prior last UP") + queuedObserver.touchesEnded([nextTouch], with: event) + await nextMainTurn() + try require(queuedWindow.windowScene == nil, "replacement touch completion releases old window") + passed.append("touch-window-retirement-rechecks-current-touch-set") + + let resetOwner = Owner() + let resetWindow = touchManager.window(for: resetOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let resetObserver = try touchObserver(in: app) + resetObserver.touchesBegan([UITouch()], with: event) + touchManager.entryRemoved(from: resetWindow) + resetObserver.reset() + await nextMainTurn() + try require(resetWindow.windowScene == nil && resetObserver.view == nil, + "UIKit reset of a live observer wakes pending retirement cleanup") + passed.append("touch-window-retirement-reset-cleanup") + + let combinedApp = UIWindow(windowScene: scene) + let combinedRoot = TransitionRoot() + combinedApp.rootViewController = combinedRoot + combinedApp.makeKeyAndVisible() + let combinedOwner = Owner() + let combinedWindow = touchManager.window(for: combinedOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: combinedApp)! + let combinedObserver = try touchObserver(in: combinedApp) + let combinedTouch = UITouch() + let combinedCoordinator = TestTransitionCoordinator() + combinedRoot.activeCoordinator = combinedCoordinator + combinedObserver.touchesBegan([combinedTouch], with: event) + touchManager.entryRemoved(from: combinedWindow) + combinedCoordinator.changeInteraction(to: false) + await nextMainTurn() + try require(combinedWindow.windowScene === scene, "coordinator completion cannot release an active touch") + combinedCoordinator.changeInteraction(to: true) + combinedObserver.touchesCancelled([combinedTouch], with: event) + await nextMainTurn() + try require(combinedWindow.windowScene === scene && combinedCoordinator.subscriptions == 1, + "touch cancellation cannot release a newly interactive coordinator") + combinedCoordinator.changeInteraction(to: false, cancelled: true) + await nextMainTurn() + try require(combinedWindow.windowScene == nil && combinedObserver.view == nil, + "combined ownership releases only after both current conditions finish") + combinedApp.isHidden = true + combinedApp.rootViewController = nil + combinedApp.windowScene = nil + app.makeKey() + passed.append("touch-window-retirement-coordinator-conjunction") + + let reuseOwner = Owner() + let reuseOldWindow = touchManager.window(for: reuseOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let reuseObserver = try touchObserver(in: app) + let reuseTouch = UITouch() + reuseObserver.touchesBegan([reuseTouch], with: event) + touchManager.entryRemoved(from: reuseOldWindow) + let reuseNewWindow = touchManager.window(for: reuseOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + reuseObserver.touchesEnded([reuseTouch], with: event) + await nextMainTurn() + try require(reuseOldWindow.windowScene == nil && reuseNewWindow.windowScene === scene && + reuseObserver.view === app, "touch callback releases only the retired window, preserving reopened owner") + touchManager.entryRemoved(from: reuseNewWindow) + try require(reuseNewWindow.windowScene == nil && reuseObserver.view == nil, + "touch-free noninteractive removal remains synchronous") + passed.append("touch-window-retirement-owner-reuse-and-sync-removal") + + var orphanOwner: Owner? = Owner() + let orphanWindow = touchManager.window(for: orphanOwner!, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let orphanObserver = try touchObserver(in: app) + orphanOwner = nil + let afterOrphanOwner = Owner() + let afterOrphanWindow = touchManager.window(for: afterOrphanOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let afterOrphanObserver = try touchObserver(in: app) + try require(orphanWindow.windowScene == nil && orphanObserver.view == nil && + afterOrphanObserver !== orphanObserver, "orphan cleanup precedes replacement observer installation") + let afterOrphanTouch = UITouch() + afterOrphanObserver.touchesBegan([afterOrphanTouch], with: event) + touchManager.entryRemoved(from: afterOrphanWindow) + try require(afterOrphanWindow.windowScene === scene, "replacement observer protects the new entry") + afterOrphanObserver.touchesEnded([afterOrphanTouch], with: event) + await nextMainTurn() + passed.append("touch-window-retirement-orphan-replacement") + + let disconnectTouchOwner = Owner(), activeDisconnectOwner = Owner() + let disconnectTouchWindow = touchManager.window(for: disconnectTouchOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let disconnectObserver = try touchObserver(in: app) + let disconnectTouch = UITouch() + disconnectObserver.touchesBegan([disconnectTouch], with: event) + touchManager.entryRemoved(from: disconnectTouchWindow) + disconnectObserver.touchesEnded([disconnectTouch], with: event) + let activeDisconnectWindow = touchManager.window(for: activeDisconnectOwner, level: .modal, + stackOrder: 2, blocking: true, appWindow: app)! + var observerRemovedBeforeCallback = false + activeDisconnectOwner.onDisconnect = { _ in + observerRemovedBeforeCallback = disconnectObserver.view == nil && disconnectObserver.onTouchesFinished == nil + } + NotificationCenter.default.post(name: UIScene.didDisconnectNotification, object: scene) + try require(observerRemovedBeforeCallback && disconnectTouchWindow.windowScene == nil && + activeDisconnectWindow.windowScene == nil, "disconnect unregisters observer before owner callbacks and releases retired windows") + let afterDisconnectOwner = Owner() + let afterDisconnectWindow = touchManager.window(for: afterDisconnectOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + await nextMainTurn() + let afterDisconnectObserver = try touchObserver(in: app) + try require(afterDisconnectWindow.windowScene === scene && + afterDisconnectObserver.view === app, "queued pre-disconnect touch callback cannot affect new registration") + touchManager.entryRemoved(from: afterDisconnectWindow) + passed.append("touch-window-retirement-disconnect-callback-identity") return passed } From c59572b79a27890b55131b666ae0e7c10381795d Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 13 Nov 2026 20:48:08 +0800 Subject: [PATCH 2/3] fix: cover nested overlay touches and migrated hosts --- .../react-native-native-overlay/docs/SPEC.md | 56 +++- .../ios/NativeOverlayWindowManager.swift | 138 ++++++--- .../tests/ios/WindowManagerTests.swift | 287 +++++++++++++++++- 3 files changed, 426 insertions(+), 55 deletions(-) diff --git a/native-views/react-native-native-overlay/docs/SPEC.md b/native-views/react-native-native-overlay/docs/SPEC.md index a55e8c469..ac4bcbb33 100644 --- a/native-views/react-native-native-overlay/docs/SPEC.md +++ b/native-views/react-native-native-overlay/docs/SPEC.md @@ -347,15 +347,20 @@ 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. -- App-touch retirement extension (Implemented; nineteen focused native checks - and eight local iOS 27.0 App handoff cases passed): before showing the first - global entry for a real app window, install - one passive touch observer on that app window. Entries, including nested - overlay hosts, share that observer. It tracks delivered touch identities, never +- Delivered-touch retirement extension (Implemented; thirty native checks + passed, final review-version App acceptance pending): + before showing a global entry, install a passive touch observer on its real + app window and on the entry window. Active and retired entries of that real + host share a touch lifetime, including touches delivered to a lower entry or + the exiting window itself. Other real app windows remain independent, even + within the same scene. Each observer tracks delivered touch identities, never retains UIEvent objects, never cancels or delays touches, and neither prevents nor can be prevented by another recognizer. It stays possible until the last tracked touch ends or cancels, then fails and resets without recognizing. -- Removing an entry while its real app window has delivered active touches or + It accepts concurrent touch types with `requiresExclusiveTouchType = false` + and preserves UIKit's default allowed touch types. +- Removing an entry while its real app window or any active/retired entry owned + by that real host has delivered active touches or an interactive UIKit presentation removes that entry from active ordering and restores key ownership immediately, but retains its exiting window/root/scene, without hiding it, until both the @@ -368,11 +373,22 @@ not change React ownership. Public APIs, Android/Web, and page scope are unchang entry for the same owner. A new touch beginning after a previous touch ends but before queued cleanup keeps the window retired. Overlapping multi-touch can extend retirement without a timeout. When neither condition exists, removal - still releases synchronously. Remove the shared observer only when that app - window has no active or retired entries. Scene disconnect unregisters observers - and their callbacks before releasing active and retired windows + still releases synchronously. Remove an entry observer only when its window + is released; remove a real app window's observer only when that host has no + active or retired entries. Touch callbacks query current host ownership across + scenes, rather than binding to the scene of first installation. Moving the + same real host to another scene keeps its remaining users protected. + Reassigning an observed window to a different host does not change its + delivered touch context: retain weak prior-host associations until a next-turn + check finds its complete touch set empty and those hosts' retirements released. + A new touch during an idle gap still protects a prior host whose context remains + retired. Touch completion, observer removal and host reassignment wake current + and prior hosts; queued cleanup does not require the source observer to survive. + Scene disconnect removes that scene's records and unregisters only observers with + no remaining users before releasing active and retired windows without notifying an already removed owner again. No fixed timeout controls this handoff. + Key restoration MUST NOT activate a host that has moved to another scene. Public UIKit observation cannot account for a DOWN not yet delivered to the app window; acceptance must separately exercise that delivery/teardown boundary. - Every presentation lifetime has a generation. Asynchronous present, dismiss, @@ -710,7 +726,8 @@ 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. -App-touch extension acceptance (2026-11-13, local Debug build on an isolated +Historical app-touch acceptance for revision `404e87c` (2026-11-13, +local Debug build on an isolated iOS 27.0 simulator): all eight first-pan-only cases passed, starting from the Home header or list body at 250, 300, 350 and 400 ms after More close UP. The 250/300 ms cases kept all twenty old-context events routed to the app window; @@ -723,8 +740,9 @@ AccountSelectorStack (body), without another pan or tab switch. Per-case main and background runtime markers remained unchanged. These measured teardown boundaries do not guarantee observation of a DOWN UIKit has not delivered yet. -Two additional selector handoffs exercised multiple front-card drags and a -partial rebound, then front dismissal followed 250 ms later by either a complete +For the same historical revision, two additional selector handoffs exercised +multiple front-card drags and a partial rebound, then front dismissal followed +250 ms later by either a complete back-selector dismissal or a partial drag canceled into rebound. Both preserved complete old-context delivery (36 and 16 events respectively), zero backing-only events, stable main/background markers, and working direct React controls. @@ -734,10 +752,22 @@ It is an equivalent-host regression, not a replay of the original three-account HD wallet-level menu. Release/TestFlight binaries, physical devices and other OS versions remain unverified. +Review extension acceptance: thirty focused native checks passed. Final +review-version App first-only Home and nested-entry interaction acceptance is +pending. The earlier nineteen native checks, eight Home cases and two equivalent +selector handoffs above belong to `404e87c`, not a rerun of the review version. +The eleven added native groups cover lower/exiting-entry and cross-window touch +ownership, mixed delivered types, repeated host reassignment, UP/rehost/DOWN and +coordinator-held idle gaps, projected scene ownership/disconnect, and key scope. +Mixed-type tests directly deliver synthetic touches and assert observer flags; +they do not prove physical Pencil input. Scene tests project one real scene into +controlled manager identity buckets using an internal resolver; they do not prove +system multi-scene migration. The default resolver remains `ObjectIdentifier(scene)`. + Automated (P0): - `tests/ios/run.sh` compiles the production WindowManager, SheetController and - Animation into an isolated UIKit test app. Its nineteen checks cover bounded/live + Animation into an isolated UIKit test app. Its original nineteen 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 diff --git a/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift b/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift index 79fa172ab..dfc846408 100644 --- a/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift +++ b/native-views/react-native-native-overlay/ios/NativeOverlayWindowManager.swift @@ -147,7 +147,7 @@ protocol NativeOverlayWindowOwner: AnyObject { func overlaySceneDidDisconnect(window: UIWindow) } -/// Observes delivered app-window touches without participating in gesture arbitration. +/// Observes delivered window touches without participating in gesture arbitration. final class NativeOverlayAppTouchObserver: UIGestureRecognizer { var onTouchesFinished: (() -> Void)? private var activeTouches = Set() @@ -162,6 +162,7 @@ final class NativeOverlayAppTouchObserver: UIGestureRecognizer { cancelsTouchesInView = false delaysTouchesBegan = false delaysTouchesEnded = false + requiresExclusiveTouchType = false } override func canPrevent(_ preventedGestureRecognizer: UIGestureRecognizer) -> Bool { false } @@ -235,14 +236,24 @@ final class NativeOverlayWindowManager: NSObject { } } + private final class WeakAppWindow { + weak var window: UIWindow? + init(_ window: UIWindow) { self.window = window } + } + private final class AppTouchObservation { + weak var window: UIWindow? weak var appWindow: UIWindow? - let sceneKey: ObjectIdentifier let observer: NativeOverlayAppTouchObserver + var previousAppWindows: [ObjectIdentifier: WeakAppWindow] = [:] + + var appWindows: [UIWindow] { + (appWindow.map { [$0] } ?? []) + previousAppWindows.values.compactMap(\.window) + } - init(appWindow: UIWindow, sceneKey: ObjectIdentifier, observer: NativeOverlayAppTouchObserver) { + init(window: UIWindow, appWindow: UIWindow, observer: NativeOverlayAppTouchObserver) { + self.window = window self.appWindow = appWindow - self.sceneKey = sceneKey self.observer = observer } } @@ -252,8 +263,15 @@ final class NativeOverlayWindowManager: NSObject { private var appTouchObservations: [ObjectIdentifier: AppTouchObservation] = [:] private var disconnectingScenes = Set() private var sequence = 0 + private let sceneIdentity: (UIWindowScene) -> ObjectIdentifier + + override convenience init() { + self.init(sceneIdentity: { ObjectIdentifier($0) }) + } - override init() { + // Internal identity projection lets native tests exercise scene ownership with real windows. + init(sceneIdentity: @escaping (UIWindowScene) -> ObjectIdentifier) { + self.sceneIdentity = sceneIdentity super.init() NotificationCenter.default.addObserver( self, selector: #selector(sceneDidDisconnect(_:)), @@ -265,7 +283,7 @@ final class NativeOverlayWindowManager: NSObject { NotificationCenter.default.removeObserver(self) for observation in appTouchObservations.values { observation.observer.onTouchesFinished = nil - observation.appWindow?.removeGestureRecognizer(observation.observer) + observation.window?.removeGestureRecognizer(observation.observer) } } @@ -277,11 +295,11 @@ final class NativeOverlayWindowManager: NSObject { ((hostWindow as? NativeOverlayPassthroughWindow)?.rootViewController as? NativeOverlayRootViewController)?.appWindow ?? hostWindow guard let scene = appWindow.windowScene, scene.activationState != .unattached else { return nil } - let sceneKey = ObjectIdentifier(scene) + let sceneKey = sceneIdentity(scene) guard !disconnectingScenes.contains(sceneKey) else { return nil } let orphanWindows = (scenes[sceneKey] ?? [:]).values.filter { $0.owner == nil }.map(\.window) orphanWindows.forEach { entryRemoved(from: $0) } - observeTouches(in: appWindow, sceneKey: sceneKey) + observeTouches(in: appWindow, appWindow: appWindow) let ownerKey = ObjectIdentifier(owner) var entries = scenes[sceneKey] ?? [:] let entry: Entry @@ -304,6 +322,7 @@ final class NativeOverlayWindowManager: NSObject { } entry.appWindow = appWindow (entry.window.rootViewController as? NativeOverlayRootViewController)?.appWindow = appWindow + observeTouches(in: entry.window, appWindow: appWindow) reorder(entries) entry.window.isHidden = false entry.window.rootViewController?.setNeedsStatusBarAppearanceUpdate() @@ -340,7 +359,7 @@ final class NativeOverlayWindowManager: NSObject { let remaining = scenes[sceneKey] ?? [:] if remaining.isEmpty { scenes[sceneKey] = nil } if hasActiveTouches(in: entry.appWindow) || Self.interactiveCoordinator(in: entry.appWindow) != nil { - // Keep the routing context until delivered app touches and UIKit interactions both finish. + // Keep the routing context until this host's delivered touches and UIKit interactions finish. let retirement = RetiredWindow(window: entry.window, appWindow: entry.appWindow) let windowKey = ObjectIdentifier(entry.window) retiredWindows[sceneKey, default: [:]][windowKey] = retirement @@ -354,7 +373,8 @@ final class NativeOverlayWindowManager: NSObject { scene.activationState == .foregroundActive || scene.activationState == .foregroundInactive else { return } let next = remaining.values.filter { Self.eligible($0, in: scene) } .max { Self.ordered($0, before: $1) } - (next?.window ?? entry.appWindow)?.makeKey() + let appWindow = entry.appWindow?.windowScene === scene ? entry.appWindow : nil + (next?.window ?? appWindow)?.makeKey() } func restoreFocus(_ responder: UIView, in window: UIWindow) -> Bool { @@ -423,44 +443,89 @@ final class NativeOverlayWindowManager: NSObject { window.windowScene = nil } - private func observeTouches(in appWindow: UIWindow, sceneKey: ObjectIdentifier) { - let appWindowKey = ObjectIdentifier(appWindow) - guard appTouchObservations[appWindowKey] == nil else { return } + private func observeTouches(in window: UIWindow, appWindow: UIWindow) { + let windowKey = ObjectIdentifier(window) + if let observation = appTouchObservations[windowKey] { + if observation.appWindow !== appWindow, let previous = observation.appWindow { + // Reparenting does not change the context of an already delivered or queued touch stream. + if observation.observer.hasActiveTouches || hasRetirements(for: previous) { + observation.previousAppWindows[ObjectIdentifier(previous)] = WeakAppWindow(previous) + } + observation.appWindow = appWindow + scheduleObservationCleanup(observation, windowKey: windowKey) + scheduleRetirementCheck(for: previous) + } else { + observation.appWindow = appWindow + } + return + } let observer = NativeOverlayAppTouchObserver(target: nil, action: nil) - appTouchObservations[appWindowKey] = AppTouchObservation( - appWindow: appWindow, sceneKey: sceneKey, observer: observer) - observer.onTouchesFinished = { [weak self, weak appWindow, weak observer] in - guard let self, let appWindow, - self.retiredWindows[sceneKey]?.values.contains(where: { $0.appWindow === appWindow }) == true - else { return } - DispatchQueue.main.async { [weak self, weak appWindow, weak observer] in - guard let self, let appWindow, let observer, - self.appTouchObservations[appWindowKey]?.observer === observer else { return } - // Query current retirements and touches: another DOWN may precede this queued check. - let retirements = (self.retiredWindows[sceneKey] ?? [:]).filter { $0.value.appWindow === appWindow } - for (windowKey, retirement) in retirements { + appTouchObservations[windowKey] = AppTouchObservation( + window: window, appWindow: appWindow, observer: observer) + observer.onTouchesFinished = { [weak self, weak window, weak observer] in + guard let self, let window, let observer, + let observation = self.appTouchObservations[ObjectIdentifier(window)], + observation.observer === observer else { return } + self.scheduleObservationCleanup(observation, windowKey: ObjectIdentifier(window)) + for appWindow in observation.appWindows { self.scheduleRetirementCheck(for: appWindow) } + } + window.addGestureRecognizer(observer) + } + + private func scheduleObservationCleanup(_ observation: AppTouchObservation, windowKey: ObjectIdentifier) { + guard !observation.previousAppWindows.isEmpty else { return } + DispatchQueue.main.async { [weak self, weak observation] in + guard let self, let observation, self.appTouchObservations[windowKey] === observation, + !observation.observer.hasActiveTouches else { return } + self.prunePreviousAppWindows(in: observation) + } + } + + private func prunePreviousAppWindows(in observation: AppTouchObservation) { + guard !observation.observer.hasActiveTouches else { return } + // An idle gap does not retire the old context if another touch or transition still owns it. + observation.previousAppWindows = observation.previousAppWindows.filter { + $0.value.window.map { hasRetirements(for: $0) } ?? false + } + } + + private func hasRetirements(for appWindow: UIWindow) -> Bool { + retiredWindows.values.contains { $0.values.contains { $0.appWindow === appWindow } } + } + + private func scheduleRetirementCheck(for appWindow: UIWindow?) { + guard let appWindow, hasRetirements(for: appWindow) else { return } + DispatchQueue.main.async { [weak self, weak appWindow] in + guard let self, let appWindow else { return } + // The source observer may be gone; query current host ownership and all delivered touches. + for (sceneKey, windows) in self.retiredWindows { + for (windowKey, retirement) in windows where retirement.appWindow === appWindow { self.finishRetirement(retirement, sceneKey: sceneKey, windowKey: windowKey) } } } - appWindow.addGestureRecognizer(observer) } private func hasActiveTouches(in appWindow: UIWindow?) -> Bool { guard let appWindow else { return false } - return appTouchObservations[ObjectIdentifier(appWindow)]?.observer.hasActiveTouches == true + return appTouchObservations.values.contains { + $0.observer.hasActiveTouches && ($0.appWindow === appWindow || + $0.previousAppWindows[ObjectIdentifier(appWindow)]?.window === appWindow) + } } private func removeUnusedTouchObservers() { for (key, observation) in appTouchObservations { - let active = observation.appWindow.map { appWindow in - scenes.values.contains { $0.values.contains { $0.appWindow === appWindow } } || - retiredWindows.values.contains { $0.values.contains { $0.appWindow === appWindow } } + prunePreviousAppWindows(in: observation) + let active = observation.window.map { window in + scenes.values.contains { $0.values.contains { $0.window === window || $0.appWindow === window } } || + retiredWindows.values.contains { $0.values.contains { $0.window === window || $0.appWindow === window } } } ?? false guard !active else { continue } observation.observer.onTouchesFinished = nil - observation.appWindow?.removeGestureRecognizer(observation.observer) + observation.window?.removeGestureRecognizer(observation.observer) appTouchObservations[key] = nil + for appWindow in observation.appWindows { scheduleRetirementCheck(for: appWindow) } } } @@ -502,18 +567,13 @@ final class NativeOverlayWindowManager: NSObject { @objc private func sceneDidDisconnect(_ notification: Notification) { precondition(Thread.isMainThread) guard let scene = notification.object as? UIWindowScene else { return } - let sceneKey = ObjectIdentifier(scene) + let sceneKey = sceneIdentity(scene) let entries = scenes.removeValue(forKey: sceneKey) ?? [:] let retired = retiredWindows.removeValue(forKey: sceneKey) ?? [:] - let observations = appTouchObservations.filter { $0.value.sceneKey == sceneKey } - guard !entries.isEmpty || !retired.isEmpty || !observations.isEmpty else { return } + guard !entries.isEmpty || !retired.isEmpty else { return } disconnectingScenes.insert(sceneKey) defer { disconnectingScenes.remove(sceneKey) } - for (key, observation) in observations { - observation.observer.onTouchesFinished = nil - observation.appWindow?.removeGestureRecognizer(observation.observer) - appTouchObservations[key] = nil - } + removeUnusedTouchObservers() // Unregister first: owner callbacks must not restore a disconnected scene's key. for entry in entries.values { entry.owner?.overlaySceneDidDisconnect(window: entry.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 5daa7a2bd..41966f77a 100644 --- a/native-views/react-native-native-overlay/tests/ios/WindowManagerTests.swift +++ b/native-views/react-native-native-overlay/tests/ios/WindowManagerTests.swift @@ -16,6 +16,17 @@ private final class Owner: NativeOverlayWindowOwner, NativeOverlaySheetHost { func sheetRequestedDismiss(_ controller: NativeOverlaySheetController, reason: String) {} } +private final class TypedTouch: UITouch { + private let touchType: UITouch.TouchType + init(_ type: UITouch.TouchType) { touchType = type; super.init() } + override var type: UITouch.TouchType { touchType } +} + +private final class KeyCountingWindow: UIWindow { + var keyActivations = 0 + override func makeKey() { keyActivations += 1; super.makeKey() } +} + private final class TransitionRoot: UIViewController { var activeCoordinator: UIViewControllerTransitionCoordinator? override var transitionCoordinator: UIViewControllerTransitionCoordinator? { activeCoordinator } @@ -270,7 +281,7 @@ private func touchObserver(in window: UIWindow) throws -> NativeOverlayAppTouchO let observer = try touchObserver(in: app) let pan = UIPanGestureRecognizer() try require(!observer.cancelsTouchesInView && !observer.delaysTouchesBegan && - !observer.delaysTouchesEnded && !observer.canPrevent(pan) && !observer.canBePrevented(by: pan), + !observer.delaysTouchesEnded && !observer.requiresExclusiveTouchType && !observer.canPrevent(pan) && !observer.canBePrevented(by: pan), "touch observer cannot delay, cancel, prevent or be prevented") let event = UIEvent() let firstTouch = UITouch() @@ -299,8 +310,9 @@ private func touchObserver(in window: UIWindow) throws -> NativeOverlayAppTouchO let nestedObserver = try touchObserver(in: app) try require(nestedObserver === sharedObserver && app.gestureRecognizers?.filter({ $0 is NativeOverlayAppTouchObserver }).count == 1 && - overlapWindowA.gestureRecognizers?.contains(where: { $0 is NativeOverlayAppTouchObserver }) != true, - "nested and multiple entries share only the real app window observer") + overlapWindowA.gestureRecognizers?.filter({ $0 is NativeOverlayAppTouchObserver }).count == 1 && + overlapWindowB.gestureRecognizers?.filter({ $0 is NativeOverlayAppTouchObserver }).count == 1, + "nested entries each observe delivery while sharing the real host lifetime") let fingerA = UITouch(), fingerB = UITouch() sharedObserver.touchesBegan([fingerA, fingerB], with: event) let otherApp = UIWindow(windowScene: scene) @@ -451,6 +463,275 @@ private func touchObserver(in window: UIWindow) throws -> NativeOverlayAppTouchO afterDisconnectObserver.view === app, "queued pre-disconnect touch callback cannot affect new registration") touchManager.entryRemoved(from: afterDisconnectWindow) passed.append("touch-window-retirement-disconnect-callback-identity") + let lowerOwner = Owner(), upperOwner = Owner() + let lower = touchManager.window(for: lowerOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let upper = touchManager.window(for: upperOwner, level: .toast, + stackOrder: 2, blocking: false, appWindow: lower)! + let lowerObserver = try touchObserver(in: lower) + let upperObserver = try touchObserver(in: upper) + let lowerRoot = lower.rootViewController + let lowerTouch = UITouch() + lowerObserver.touchesBegan([lowerTouch], with: event) + touchManager.entryRemoved(from: upper) + try require(upper.windowScene === scene && upper.rootViewController != nil && + !upper.isHidden && lower.rootViewController === lowerRoot, + "lower entry touch retains upper toast context without changing lower root") + lowerObserver.touchesEnded([lowerTouch], with: event) + await nextMainTurn() + try require(upper.windowScene == nil && upperObserver.view == nil && + lowerObserver.view === lower && lower.rootViewController === lowerRoot, + "lower entry UP releases only retired toast and its observer") + passed.append("entry-touch-retirement-lower-entry") + + let ownTouch = UITouch(), ownHostTouch = UITouch() + let ownHostObserver = try touchObserver(in: app) + ownHostObserver.touchesBegan([ownHostTouch], with: event) + lowerObserver.touchesBegan([ownTouch], with: event) + lower.makeKey() + touchManager.entryRemoved(from: lower) + try require(lower.windowScene === scene && !lower.isHidden && app.isKeyWindow, + "exiting entry retains its own delivered touch and restores key") + ownHostObserver.touchesEnded([ownHostTouch], with: event) + await nextMainTurn() + try require(lower.windowScene === scene && lowerObserver.hasActiveTouches, + "one window touch ending cannot release another window's simultaneous touch") + lowerObserver.touchesCancelled([ownTouch], with: event) + await nextMainTurn() + try require(lower.windowScene == nil && lowerObserver.view == nil, + "exiting entry cancellation releases window and observer") + passed.append("entry-touch-retirement-exiting-window") + + let mixedOwner = Owner() + let mixedWindow = touchManager.window(for: mixedOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let mixedObserver = try touchObserver(in: mixedWindow) + let finger = TypedTouch(.direct), pencil = TypedTouch(.pencil) + try require(finger.type != pencil.type && !mixedObserver.requiresExclusiveTouchType, + "observer accepts simultaneous touch types without restricting allowed types") + mixedObserver.touchesBegan([finger], with: event) + mixedObserver.touchesBegan([pencil], with: event) + touchManager.entryRemoved(from: mixedWindow) + mixedObserver.touchesEnded([finger], with: event) + await nextMainTurn() + try require(mixedWindow.windowScene === scene && mixedObserver.hasActiveTouches, + "first touch type ending preserves the second delivered touch type") + mixedObserver.touchesEnded([pencil], with: event) + await nextMainTurn() + try require(mixedWindow.windowScene == nil && mixedObserver.view == nil, + "second touch type ending releases retirement") + passed.append("entry-touch-retirement-mixed-types") + + let switchOwner = Owner(), switchToastOwner = Owner() + let switchWindow = touchManager.window(for: switchOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: app)! + let switchToast = touchManager.window(for: switchToastOwner, level: .toast, + stackOrder: 2, blocking: false, appWindow: switchWindow)! + let switchAppObserver = try touchObserver(in: app) + let switchEntryObserver = try touchObserver(in: switchWindow) + let switchA = UITouch(), switchB = UITouch() + switchAppObserver.touchesBegan([switchA], with: event) + touchManager.entryRemoved(from: switchToast) + switchAppObserver.touchesEnded([switchA], with: event) + switchEntryObserver.touchesBegan([switchB], with: event) + await nextMainTurn() + try require(switchToast.windowScene === scene && switchEntryObserver.hasActiveTouches, + "queued cleanup rechecks touches that move to another window of the same host") + touchManager.entryRemoved(from: switchWindow) + switchEntryObserver.touchesEnded([switchB], with: event) + await nextMainTurn() + try require(switchToast.windowScene == nil && switchWindow.windowScene == nil && + switchEntryObserver.view == nil && switchAppObserver.view == nil, + "last cross-window touch releases all retired windows and idle observers") + passed.append("entry-touch-retirement-cross-window-recheck") + + let firstHost = UIWindow(windowScene: scene), secondHost = UIWindow(windowScene: scene) + let thirdHost = UIWindow(windowScene: scene) + thirdHost.rootViewController = UIViewController() + firstHost.rootViewController = UIViewController() + secondHost.rootViewController = UIViewController() + let rehostOwner = Owner(), rehostToastOwner = Owner() + let rehostWindow = touchManager.window(for: rehostOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: firstHost)! + let rehostToast = touchManager.window(for: rehostToastOwner, level: .toast, + stackOrder: 2, blocking: false, appWindow: firstHost)! + let rehostObserver = try touchObserver(in: rehostWindow) + let rehostTouch = UITouch() + rehostObserver.touchesBegan([rehostTouch], with: event) + touchManager.entryRemoved(from: rehostToast) + let reassignedWindow = touchManager.window(for: rehostOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: secondHost)! + try require(reassignedWindow === rehostWindow && rehostToast.windowScene === scene, + "same owner can change real host while old host retirement awaits a next-turn check") + await nextMainTurn() + try require(rehostToast.windowScene === scene && rehostWindow.windowScene === scene && + rehostObserver.hasActiveTouches, "reparenting cannot release the old context of a delivered touch") + let secondToastOwner = Owner() + let secondToast = touchManager.window(for: secondToastOwner, level: .toast, + stackOrder: 2, blocking: false, appWindow: secondHost)! + touchManager.entryRemoved(from: secondToast) + _ = touchManager.window(for: rehostOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: thirdHost)! + touchManager.entryRemoved(from: rehostWindow) + await nextMainTurn() + try require(rehostToast.windowScene === scene && secondToast.windowScene === scene && + rehostWindow.windowScene === scene, "repeated host changes preserve all contexts held by the same touch") + rehostObserver.touchesEnded([rehostTouch], with: event) + await nextMainTurn() + try require(rehostToast.windowScene == nil && secondToast.windowScene == nil && + rehostWindow.windowScene == nil && rehostObserver.view == nil, + "final touch completion releases original, intermediate and current host retirements") + firstHost.rootViewController = nil; firstHost.windowScene = nil + secondHost.rootViewController = nil; secondHost.windowScene = nil + thirdHost.rootViewController = nil; thirdHost.windowScene = nil + passed.append("entry-touch-retirement-real-host-reassignment") + + let queuedHostA = UIWindow(windowScene: scene), queuedHostB = UIWindow(windowScene: scene) + queuedHostA.rootViewController = UIViewController() + queuedHostB.rootViewController = UIViewController() + let queuedRehostOwner = Owner(), queuedRehostToastOwner = Owner() + let queuedRehost = touchManager.window(for: queuedRehostOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: queuedHostA)! + let queuedRehostToast = touchManager.window(for: queuedRehostToastOwner, level: .toast, + stackOrder: 2, blocking: false, appWindow: queuedHostA)! + let queuedRehostObserver = try touchObserver(in: queuedRehost) + let rehostEnding = UITouch(), rehostBeginning = UITouch() + queuedRehostObserver.touchesBegan([rehostEnding], with: event) + touchManager.entryRemoved(from: queuedRehostToast) + queuedRehostObserver.touchesEnded([rehostEnding], with: event) + queuedRehostObserver.reset() + _ = touchManager.window(for: queuedRehostOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: queuedHostB)! + queuedRehostObserver.touchesBegan([rehostBeginning], with: event) + await nextMainTurn() + try require(queuedRehostToast.windowScene === scene, + "UP then rehost then DOWN before cleanup preserves old host context") + queuedRehostObserver.touchesCancelled([rehostBeginning], with: event) + await nextMainTurn() + try require(queuedRehostToast.windowScene == nil && queuedRehost.windowScene === scene, + "replacement touch completion releases only the retired old host window") + touchManager.entryRemoved(from: queuedRehost) + queuedHostA.rootViewController = nil; queuedHostA.windowScene = nil + queuedHostB.rootViewController = nil; queuedHostB.windowScene = nil + passed.append("entry-touch-retirement-rehost-up-down-boundary") + + let delayedHost = UIWindow(windowScene: scene), currentHost = UIWindow(windowScene: scene) + let delayedRoot = TransitionRoot() + delayedHost.rootViewController = delayedRoot + currentHost.rootViewController = UIViewController() + let delayedCoordinator = TestTransitionCoordinator() + delayedRoot.activeCoordinator = delayedCoordinator + let delayedOwner = Owner(), delayedToastOwner = Owner() + let delayedEntry = touchManager.window(for: delayedOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: delayedHost)! + let delayedToast = touchManager.window(for: delayedToastOwner, level: .toast, + stackOrder: 2, blocking: false, appWindow: delayedHost)! + let delayedObserver = try touchObserver(in: delayedEntry) + let delayedTouchA = UITouch(), delayedTouchB = UITouch(), unrelatedTouch = UITouch() + delayedObserver.touchesBegan([delayedTouchA], with: event) + touchManager.entryRemoved(from: delayedToast) + _ = touchManager.window(for: delayedOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: currentHost)! + delayedObserver.touchesEnded([delayedTouchA], with: event) + await nextMainTurn() + try require(delayedToast.windowScene === scene, "old host coordinator retains context through an idle touch gap") + delayedObserver.reset() + delayedObserver.touchesBegan([delayedTouchB], with: event) + delayedCoordinator.changeInteraction(to: false) + await nextMainTurn() + try require(delayedToast.windowScene === scene, + "new touch after an idle turn still protects an old host context retained by a coordinator") + delayedObserver.touchesEnded([delayedTouchB], with: event) + await nextMainTurn() + try require(delayedToast.windowScene == nil, "last old-context dependency releases after both conditions end") + delayedObserver.reset() + delayedObserver.touchesBegan([unrelatedTouch], with: event) + let unrelatedOwner = Owner() + let unrelatedWindow = touchManager.window(for: unrelatedOwner, level: .toast, + stackOrder: 3, blocking: false, appWindow: delayedHost)! + touchManager.entryRemoved(from: unrelatedWindow) + try require(unrelatedWindow.windowScene == nil, + "released context prunes old host affiliation so future current-host touches remain independent") + delayedObserver.touchesEnded([unrelatedTouch], with: event) + await nextMainTurn() + touchManager.entryRemoved(from: delayedEntry) + delayedHost.rootViewController = nil; delayedHost.windowScene = nil + currentHost.rootViewController = nil; currentHost.windowScene = nil + passed.append("entry-touch-retirement-rehost-coordinator-idle-gap") + + // Project one genuine UIWindowScene into controlled manager buckets; this is not OS scene migration. + let sceneTokenA = NSObject(), sceneTokenB = NSObject() + var projectedSceneKey = ObjectIdentifier(sceneTokenA) + let migrationManager = NativeOverlayWindowManager(sceneIdentity: { _ in projectedSceneKey }) + let migratingHost = UIWindow(windowScene: scene) + migratingHost.rootViewController = UIViewController() + let inAOwner = Owner(), inBOwner = Owner() + let inA = migrationManager.window(for: inAOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: migratingHost)! + let migratingObserver = try touchObserver(in: migratingHost) + projectedSceneKey = ObjectIdentifier(sceneTokenB) + let inB = migrationManager.window(for: inBOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: migratingHost)! + let migratingTouch = UITouch() + migratingObserver.touchesBegan([migratingTouch], with: event) + migrationManager.entryRemoved(from: inB) + migratingObserver.touchesEnded([migratingTouch], with: event) + await nextMainTurn() + try require(inB.windowScene == nil && inA.windowScene === scene && migratingObserver.view === migratingHost, + "host touch callback cleans new logical scene while old scene remains active") + passed.append("touch-window-retirement-projected-scene-migration") + + let inBPendingOwner = Owner() + let inBPending = migrationManager.window(for: inBPendingOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: migratingHost)! + let inAObserver = try touchObserver(in: inA) + let queuedSceneTouch = UITouch() + inAObserver.touchesBegan([queuedSceneTouch], with: event) + migrationManager.entryRemoved(from: inBPending) + inAObserver.touchesEnded([queuedSceneTouch], with: event) + projectedSceneKey = ObjectIdentifier(sceneTokenA) + NotificationCenter.default.post(name: UIScene.didDisconnectNotification, object: scene) + try require(inA.windowScene == nil && inAObserver.view == nil && + migratingObserver.view === migratingHost && inBPending.windowScene === scene, + "old logical scene disconnect removes source observer while preserving migrated host") + await nextMainTurn() + try require(inBPending.windowScene == nil && migratingObserver.view == nil, + "queued old-scene UP wakes new-scene retirement even after its source observer is removed") + passed.append("touch-window-retirement-projected-scene-queued-up-disconnect") + + let disconnectSourceOwner = Owner(), disconnectTargetOwner = Owner() + let disconnectSource = migrationManager.window(for: disconnectSourceOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: migratingHost)! + let sourceObserver = try touchObserver(in: disconnectSource) + projectedSceneKey = ObjectIdentifier(sceneTokenB) + let disconnectTarget = migrationManager.window(for: disconnectTargetOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: migratingHost)! + sourceObserver.touchesBegan([UITouch()], with: event) + migrationManager.entryRemoved(from: disconnectTarget) + projectedSceneKey = ObjectIdentifier(sceneTokenA) + NotificationCenter.default.post(name: UIScene.didDisconnectNotification, object: scene) + await nextMainTurn() + try require(disconnectTarget.windowScene == nil && sourceObserver.view == nil && + sourceObserver.onTouchesFinished == nil, + "removing active old-scene observer wakes and releases another scene retirement") + migratingHost.rootViewController = nil + migratingHost.windowScene = nil + passed.append("touch-window-retirement-projected-scene-active-disconnect") + + let detachedHost = KeyCountingWindow(windowScene: scene) + detachedHost.rootViewController = UIViewController() + let detachedOwner = Owner() + let detachedWindow = touchManager.window(for: detachedOwner, level: .modal, + stackOrder: 1, blocking: true, appWindow: detachedHost)! + detachedWindow.makeKey() + detachedHost.windowScene = nil + let previousActivations = detachedHost.keyActivations + try require(detachedWindow.isKeyWindow, "exiting window remains key before cross-scene restoration") + touchManager.entryRemoved(from: detachedWindow) + try require(detachedHost.keyActivations == previousActivations && !detachedHost.isKeyWindow, + "removal cannot restore key to a host outside the entry scene") + detachedHost.rootViewController = nil + passed.append("entry-removal-host-scene-focus-boundary") return passed } From 4b07581f0cd216155a9277d99f004c512129f95b Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 13 Nov 2026 20:54:24 +0800 Subject: [PATCH 3/3] docs: record overlay review acceptance --- .../react-native-native-overlay/docs/SPEC.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/native-views/react-native-native-overlay/docs/SPEC.md b/native-views/react-native-native-overlay/docs/SPEC.md index ac4bcbb33..e4ad23bbf 100644 --- a/native-views/react-native-native-overlay/docs/SPEC.md +++ b/native-views/react-native-native-overlay/docs/SPEC.md @@ -347,8 +347,8 @@ 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. -- Delivered-touch retirement extension (Implemented; thirty native checks - passed, final review-version App acceptance pending): +- Delivered-touch retirement extension (Implemented; thirty native checks and + four focused local iOS 27.0 App cases passed): before showing a global entry, install a passive touch observer on its real app window and on the entry window. Active and retired entries of that real host share a touch lifetime, including touches delivered to a lower entry or @@ -752,9 +752,18 @@ It is an equivalent-host regression, not a replay of the original three-account HD wallet-level menu. Release/TestFlight binaries, physical devices and other OS versions remain unverified. -Review extension acceptance: thirty focused native checks passed. Final -review-version App first-only Home and nested-entry interaction acceptance is -pending. The earlier nineteen native checks, eight Home cases and two equivalent +Review extension acceptance for `c59572b`: thirty focused native checks passed. +A rebuilt local Debug App passed first-only header/250 ms and body/300 ms Home +handoffs with native ended state 3, direct React control actions and stable +main/background runtime markers. Two further cases used an independently mounted +passive global entry over Home More. A lower-entry drag received all sixteen +events in its own context while the upper entry closed; its next React close +action worked. This does not establish use of the upper entry's context by the +lower drag. In the exiting-entry case, UIKit delivered DOWN to the upper window, +restored key ownership to the lower window on close, and delivered UP to the +same upper window/context 657 ms after native close; the next lower React close +action worked. Runtime markers stayed stable throughout these four post-recovery +cases. The earlier nineteen native checks, eight Home cases and two equivalent selector handoffs above belong to `404e87c`, not a rerun of the review version. The eleven added native groups cover lower/exiting-entry and cross-window touch ownership, mixed delivered types, repeated host reassignment, UP/rehost/DOWN and