From d6482dca496d2dce8c682cf821e4d13cf77a91f4 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 10 Nov 2026 19:17:19 +0900 Subject: [PATCH 1/9] perf(ios): gate capture settling on component readiness --- .github/workflows/native-ui-engine.yml | 2 +- docs/advanced/native-handoff.md | 20 +++++ .../Components/NativeUIButton.swift | 8 ++ .../Components/NativeUIButtons.swift | 2 + .../Components/NativeUIFab.swift | 2 + .../NativeUICaptureState.swift | 12 +++ .../NativeUIController.swift | 85 ++++++++++++++++-- .../CaptureReadiness.swift | 15 ++++ .../CoveredSnapshots.swift | 37 +++++--- .../NativeUISnapshotTests.swift | 81 +++++++++++++++++ .../CoveredSnapshotsTests.swift | 90 +++++++++++++++++++ 11 files changed, 334 insertions(+), 20 deletions(-) create mode 100644 ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift create mode 100644 ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift diff --git a/.github/workflows/native-ui-engine.yml b/.github/workflows/native-ui-engine.yml index 40b9430..64c1806 100644 --- a/.github/workflows/native-ui-engine.yml +++ b/.github/workflows/native-ui-engine.yml @@ -69,7 +69,7 @@ jobs: brew list xcodegen >/dev/null 2>&1 || brew install xcodegen cd ios/Tests xcodegen generate - xcodebuild -project NativeUIControlsHostedTests.xcodeproj -scheme NativeUIControlsHostedTests -destination "platform=iOS Simulator,id=$CORDOVA_SIMULATOR" CODE_SIGNING_ALLOWED=NO -only-testing:WebViewNativeUIControlsTests/NativeUISnapshotTests/testNativePopoverClosureNotifiesWebBeforeReopening -only-testing:WebViewNativeUIControlsTests/NativeUISnapshotTests/testPopoverReopensAfterPresentationIdentityKeepsReplacedSource test + xcodebuild -project NativeUIControlsHostedTests.xcodeproj -scheme NativeUIControlsHostedTests -destination "platform=iOS Simulator,id=$CORDOVA_SIMULATOR" CODE_SIGNING_ALLOWED=NO -only-testing:WebViewNativeUIControlsTests/NativeUISnapshotTests/testNativePopoverClosureNotifiesWebBeforeReopening -only-testing:WebViewNativeUIControlsTests/NativeUISnapshotTests/testPopoverReopensAfterPresentationIdentityKeepsReplacedSource -only-testing:WebViewNativeUIControlsTests/NativeUISnapshotTests/testControllerReadinessTracksActualOwnersAndCaptureAcknowledgements test - name: Verify capture-to-Web image reuse on a real simulator screen run: python3 scripts/verify-image-reuse.py --device "$CORDOVA_SIMULATOR" - name: Verify npm-distributed CocoaPod diff --git a/docs/advanced/native-handoff.md b/docs/advanced/native-handoff.md index 14215ff..446cb88 100644 --- a/docs/advanced/native-handoff.md +++ b/docs/advanced/native-handoff.md @@ -115,3 +115,23 @@ Native replies acknowledge reusable pixels with `reused: true`. Only then may Web reuse its decoded image for the same URL and destination; other replies keep the existing load/decode behavior. Tokens own separate leases on shared files. Continue releasing retired tokens and reset the manager on owner teardown. + +### Renderer-owned capture readiness (iOS) + +A native renderer may supply the optional `readiness` callback to `handle`, or to +`prepare` with explicit source views. Return `NativeUICaptureReadiness` for the +requested revision: the component declares whether pixels are backdrop-independent +and whether its layout and appearance have settled. The Engine does not inspect +component styles to infer Glass. + +Only independent, settled view captures use one matching geometry sample instead +of three. Coordinates and source traits are checked again before publishing fresh +pixels. Missing information, composited capture, Glass, and unsettled state retain +the standard checks. Active touches still invalidate preparation. This does not +change `captureDelayMs`, paint acknowledgements, or handoff deadlines. + +Controls supplies this proof for settled, standalone Plain buttons without scroll +bindings. A tap keeps its owner conservative until that owner's successful +capture; capturing another owner does not settle it. Shared surfaces, unknown +input targets, active animations and unresolved interaction across a new revision +remain conservative. Other component types retain their existing behavior. diff --git a/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift b/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift index 49fb2fb..3210030 100644 --- a/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift +++ b/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift @@ -1,3 +1,4 @@ +import WebViewNativeUIEngine import UIKit enum NativeUIButton { @@ -32,6 +33,13 @@ enum NativeUIButton { // These Ionic components share the same native UIButton presentation. static let kinds: [NativeUIComponent] = [.button, .backButton, .menuButton] + // The component declares the same material choice used by make(). + static func captureEffect(_ node: NativeUIControl) -> NativeUICaptureReadiness.Effect { + guard kinds.contains(node.kind), node.placement == nil, node.scroll == nil, node.items.count == 1, + let item = node.items.first else { return .unknown } + return item.content.buttonFill == .clear ? .independent : .backdrop + } + @available(iOS 26.0, *) static func make(_ node: NativeUIControl, existing: UIButton? = nil, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) -> UIButton? { guard node.items.count == 1, let item = node.items.first else { return nil } diff --git a/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift b/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift index 1e3b6c8..00651fe 100644 --- a/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift +++ b/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift @@ -1,7 +1,9 @@ +import WebViewNativeUIEngine import UIKit enum NativeUIButtons { static let kind = NativeUIComponent.buttons + static let captureEffect = NativeUICaptureReadiness.Effect.backdrop @available(iOS 26.0, *) static func make(_ node: NativeUIControl, scale: CGFloat, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) -> UIView { diff --git a/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift b/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift index 77cda7e..2749ac0 100644 --- a/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift +++ b/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift @@ -1,7 +1,9 @@ +import WebViewNativeUIEngine import UIKit final class NativeUIFab: UIView { static let kind = NativeUIComponent.fab + static let captureEffect = NativeUICaptureReadiness.Effect.backdrop var buttons: [String: UIButton] = [:] var content: [String: NativeUIItemContent] = [:] override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { diff --git a/ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift b/ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift new file mode 100644 index 0000000..774c7e8 --- /dev/null +++ b/ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift @@ -0,0 +1,12 @@ +import UIKit +import WebViewNativeUIEngine + +// Component IDs remain dirty until their own successful conservative capture. +// Capturing an unrelated component never acknowledges their interaction. +struct NativeUICaptureState { + private(set) var dirty = Set() + mutating func interacted(_ ids: Set) { dirty.formUnion(ids) } + mutating func captured(_ ids: Set) { dirty.subtract(ids) } + mutating func retain(_ ids: Set) { dirty.formIntersection(ids) } + func appearanceSettled(_ ids: Set) -> Bool { dirty.isDisjoint(with: ids) } +} diff --git a/ios/Sources/WebViewNativeUIControls/NativeUIController.swift b/ios/Sources/WebViewNativeUIControls/NativeUIController.swift index b0e2a14..a845c85 100644 --- a/ios/Sources/WebViewNativeUIControls/NativeUIController.swift +++ b/ios/Sources/WebViewNativeUIControls/NativeUIController.swift @@ -23,6 +23,9 @@ public final class NativeUIController: NSObject, UITabBarDelegate { super.init() } private let coveredSnapshots = NativeUICoveredSnapshots() + private var captureState = NativeUICaptureState() + private var captureLayoutRevision: Int? + private weak var captureObservationWindow: UIWindow? private var snapshotControls: [NativeUIControl] = [] private var overlays: NativeUIOverlayController? private var toolbarTransition: NativeUIToolbarTransition? @@ -253,18 +256,71 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } } + private func observeCaptureInteractions() { + guard let window = webView?.window else { return } + if captureObservationWindow !== window { + captureState.interacted(Set(snapshotControls.map(\.id))) + captureObservationWindow = window + } + coveredSnapshots.observe(window, interacted: { [weak self] touched in + guard let self else { return } + self.recordCaptureInteraction(touched) + }) { [weak self] active in + self?.notifyListeners("snapshotInteraction", ["active": active]) + } + } + + func recordCaptureInteraction(_ touched: UIView?) { + let all = Set(snapshotControls.map(\.id)) + guard let touched else { captureState.interacted(all); return } + if let rail = verticalBars, touched.isDescendant(of: rail.view) { + captureState.interacted(Set(snapshotControls.filter { $0.placement == .verticalBars }.map(\.id))) + return + } + let owners = Set(controls.filter { touched.isDescendant(of: $0.value) }.keys) + captureState.interacted(owners.isEmpty ? all : owners) + } + + func captureReadiness(_ ids: Set, revision: Int) -> NativeUICaptureReadiness { + guard revision == self.revision, captureLayoutRevision == revision, + captureObservationWindow === webView?.window, webView?.window != nil, + !keyboardVisible, toolbarTransition == nil else { return .init() } + let nodes = snapshotControls.filter { ids.contains($0.id) } + guard !ids.isEmpty, nodes.count == ids.count else { return .init() } + let effects = nodes.map { node -> NativeUICaptureReadiness.Effect in + if node.kind == NativeUIButtons.kind { return NativeUIButtons.captureEffect } + if node.kind == NativeUIFab.kind { return NativeUIFab.captureEffect } + return NativeUIButton.captureEffect(node) + } + let effect: NativeUICaptureReadiness.Effect = effects.allSatisfy { $0 == .independent } ? .independent : + effects.contains(.backdrop) ? .backdrop : .unknown + // update() acknowledges our explicit, nonanimated layout transaction. + // UIKit may dirty internal layout flags while drawing without changing + // that transaction; the Engine separately rechecks actual frames. + let settled = nodes.allSatisfy { node in + guard let button = controls[node.id] as? UIButton, !button.isHighlighted, !button.isTracking, + button.window != nil else { return false } + var view: UIView? = button + while let current = view { + if !(current.layer.animationKeys() ?? []).isEmpty { return false } + view = current.superview + } + return true + } + return .init(effect: effect, layoutSettled: settled, + appearanceSettled: settled && captureState.appearanceSettled(ids)) + } + private func resetCoveredSnapshots() { coveredSnapshots.reset() + captureState = NativeUICaptureState() + captureLayoutRevision = nil } public func coveredSnapshot(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) { DispatchQueue.main.async { [weak self] in guard let self, let webView = self.webView else { reject("WebView unavailable"); return } - if (options["action"] as? String) == "prepare", let window = webView.window { - self.coveredSnapshots.observe(window) { [weak self] active in - self?.notifyListeners("snapshotInteraction", ["active": active]) - } - } + if (options["action"] as? String) == "prepare" { self.observeCaptureInteractions() } let measure: (Set, Int) -> [String: CGRect] = { [weak self, weak webView] ids, revision in guard let self, let webView, revision == self.revision, !self.keyboardVisible, self.toolbarTransition == nil else { return [:] } @@ -308,7 +364,12 @@ public final class NativeUIController: NSObject, UITabBarDelegate { if !railIDs.isEmpty, ids.isSuperset(of: railIDs), let rail = self.verticalBars { views.append(rail.view) } return views } - self.coveredSnapshots.handle(options, in: webView, frames: measure, views: views, complete: { resolve($0) }) + self.coveredSnapshots.handle(options, in: webView, frames: measure, views: views, + readiness: { [weak self] ids, revision in self?.captureReadiness(ids, revision: revision) ?? .init() }, complete: { result in + if result["token"] != nil, options["revision"] as? Int == self.revision, + let ids = options["ids"] as? [String] { self.captureState.captured(Set(ids)) } + resolve(result) + }) } } @@ -458,11 +519,19 @@ public final class NativeUIController: NSObject, UITabBarDelegate { guard let webView = self.webView, let parent = webView.superview else { reject("WebView unavailable"); return } + self.observeCaptureInteractions() // Decode and validate the entire batch before changing visible controls. guard JSONSerialization.isValidJSONObject(options), let snapshot = try? JSONDecoder().decode(NativeUISnapshot.self, from: JSONSerialization.data(withJSONObject: options)), snapshot.isValid else { reject("Invalid control snapshot"); return } + // IDs may be reassociated with existing views during reconciliation. + // Carry unresolved interaction across the whole new revision rather + // than accidentally declaring a newly named owner settled. + if !self.captureState.dirty.isEmpty { + self.captureState.interacted(Set(snapshot.controls.map(\.id))) + } + self.captureLayoutRevision = nil self.coveredSnapshots.restoreControls() if snapshot.coveredSnapshotToken == nil && self.coveredSnapshots.covering { _ = self.coveredSnapshots.restore(in: webView) @@ -764,6 +833,10 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } self.snapshotControls = snapshot.controls.filter { !rejectedControls.contains($0.id) } let complete = { + if self.revision == next { + self.captureLayoutRevision = next + self.captureState.retain(Set(self.snapshotControls.map(\.id))) + } var result: [String: Any] = ["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls, "tabBarWidths": tabBarWidths] if let transition = self.toolbarTransition { result["toolbarTransition"] = transition.id } resolve(result) diff --git a/ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift b/ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift new file mode 100644 index 0000000..5709707 --- /dev/null +++ b/ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Native renderer-owned readiness for a particular capture revision. +/// Unspecified or backdrop-dependent captures retain the standard geometry checks. +public struct NativeUICaptureReadiness { + /// Whether pixels depend on backdrop composition (including Glass). + public enum Effect: Equatable { case independent, backdrop, unknown } + public let effect: Effect + public let layoutSettled: Bool + public let appearanceSettled: Bool + public init(effect: Effect = .unknown, layoutSettled: Bool = false, appearanceSettled: Bool = false) { + self.effect = effect; self.layoutSettled = layoutSettled; self.appearanceSettled = appearanceSettled + } + var stableSamples: Int { effect == .independent && layoutSettled && appearanceSettled ? 1 : 3 } +} diff --git a/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift b/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift index 4b339a7..e78ef1b 100644 --- a/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +++ b/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift @@ -78,7 +78,8 @@ public final class NativeUICoveredSnapshots { public func handle(_ request: [String: Any], in webView: UIView, frames: @escaping (Set, Int) -> [String: CGRect], anchorFrames: (() -> [String: CGRect])? = nil, - views: (Set) -> [UIView]?, complete: @escaping ([String: Any]) -> Void) { + views: (Set) -> [UIView]?, + readiness: ((Set, Int) -> NativeUICaptureReadiness)? = nil, complete: @escaping ([String: Any]) -> Void) { let token = request["token"] as? String ?? "" switch request["action"] as? String { case "prepare": @@ -96,7 +97,8 @@ public final class NativeUICoveredSnapshots { let measured = initial ?? frames(expected, revision) initial = nil return Set(measured.keys) == expected ? measured : [:] - }, in: webView, viewportWidth: width, views: selected, anchorFrames: anchorFrames, contentRevision: viewCapture ? revision : nil, complete: { result in + }, in: webView, viewportWidth: width, views: selected, anchorFrames: anchorFrames, contentRevision: viewCapture ? revision : nil, + readiness: viewCapture ? readiness.map { provider in { provider(expected, revision) } } : nil, complete: { result in if let token = result["token"] as? String { self.captures[token]?.preservesLayout = request["imageSurface"] as? String == "web" } complete(result) }) @@ -142,11 +144,11 @@ public final class NativeUICoveredSnapshots { public var covering: Bool { coveringToken != nil } - public func observe(_ window: UIWindow, changed: @escaping (Bool) -> Void) { + public func observe(_ window: UIWindow, interacted: ((UIView?) -> Void)? = nil, changed: @escaping (Bool) -> Void) { guard self.window !== window else { return } if let observer { self.window?.removeGestureRecognizer(observer) } self.window = window - let observer = SnapshotTouchObserver { [weak self] active in + let observer = SnapshotTouchObserver(interacted: interacted) { [weak self] active in guard let self else { return } self.touching = active if active { self.generation += 1 } @@ -159,22 +161,27 @@ public final class NativeUICoveredSnapshots { // Gesture release can keep changing SwiftUI button bounds after touchesEnded. // Wait off the presentation path for unchanged rendered geometry, not a guessed delay. public func prepare(frames: @escaping () -> [String: CGRect], in webView: UIView, viewportWidth: CGFloat, - views: [UIView]? = nil, anchorFrames: (() -> [String: CGRect])? = nil, complete: @escaping ([String: Any]) -> Void) { + views: [UIView]? = nil, anchorFrames: (() -> [String: CGRect])? = nil, readiness: (() -> NativeUICaptureReadiness)? = nil, + complete: @escaping ([String: Any]) -> Void) { prepareCapture(frames: frames, in: webView, viewportWidth: viewportWidth, views: views, - anchorFrames: anchorFrames, contentRevision: nil, complete: complete) + anchorFrames: anchorFrames, contentRevision: nil, readiness: readiness, complete: complete) } private func prepareCapture(frames: @escaping () -> [String: CGRect], in webView: UIView, viewportWidth: CGFloat, views: [UIView]?, anchorFrames: (() -> [String: CGRect])?, contentRevision: Int?, - complete: @escaping ([String: Any]) -> Void) { + readiness: (() -> NativeUICaptureReadiness)? = nil, complete: @escaping ([String: Any]) -> Void) { let stamp = generation let start = CACurrentMediaTime() func sample(_ previous: [String: CGRect], _ stable: Int) { guard generation == stamp, !touching, CACurrentMediaTime() - start < 1 else { complete([:]); return } let current = frames() let count = !current.isEmpty && current == previous ? stable + 1 : 0 - if count >= 3 { - capture(frames: current, anchorFrames: anchorFrames?() ?? [:], in: webView, viewportWidth: viewportWidth, views: views, contentRevision: contentRevision, complete: complete) + // Recheck the renderer's proof on every sample; a revoked proof falls back. + let requiredSamples = views?.isEmpty == false ? (readiness?().stableSamples ?? 3) : 3 + if count >= requiredSamples { + capture(frames: current, anchorFrames: anchorFrames?() ?? [:], in: webView, viewportWidth: viewportWidth, views: views, contentRevision: contentRevision, + stillReady: requiredSamples == 1 ? { readiness?().stableSamples == 1 && frames() == current } : nil, + complete: complete) } else { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0 / 60.0) { sample(current, count) } } @@ -183,7 +190,7 @@ public final class NativeUICoveredSnapshots { } private func capture(frames: [String: CGRect], anchorFrames: [String: CGRect], in webView: UIView, viewportWidth: CGFloat, - views: [UIView]?, contentRevision: Int?, complete: @escaping ([String: Any]) -> Void) { + views: [UIView]?, contentRevision: Int?, stillReady: (() -> Bool)? = nil, complete: @escaping ([String: Any]) -> Void) { guard !touching, let window = webView.window, !frames.isEmpty, viewportWidth > 0, webView.bounds.width > 0 else { complete([:]); return } let stamp = generation @@ -326,7 +333,8 @@ public final class NativeUICoveredSnapshots { parts = [] } DispatchQueue.main.async { - guard let self, self.generation == stamp, !self.touching, + guard let self, self.generation == stamp, !self.touching, stillReady?() != false, + stillReady == nil || zip(capturedViews ?? [], views ?? []).allSatisfy({ $0.0.matches($0.1) }), window.bounds.size == size, webView.convert(webView.bounds, to: window) == webFrame, parts.count == crops.count else { parts.forEach { try? FileManager.default.removeItem(at: $0.file) } complete([:]); return @@ -480,10 +488,12 @@ public final class NativeUICoveredSnapshots { } // Observes the start of interaction without recognizing or delaying the app's gestures. -private final class SnapshotTouchObserver: UIGestureRecognizer { +final class SnapshotTouchObserver: UIGestureRecognizer { private let changed: (Bool) -> Void + private let interacted: ((UIView?) -> Void)? private var touches = Set() - init(changed: @escaping (Bool) -> Void) { + init(interacted: ((UIView?) -> Void)?, changed: @escaping (Bool) -> Void) { + self.interacted = interacted self.changed = changed super.init(target: nil, action: nil) cancelsTouchesInView = false @@ -493,6 +503,7 @@ private final class SnapshotTouchObserver: UIGestureRecognizer { override func canPrevent(_ other: UIGestureRecognizer) -> Bool { false } override func canBePrevented(by other: UIGestureRecognizer) -> Bool { false } override func touchesBegan(_ touches: Set, with event: UIEvent) { + touches.forEach { interacted?($0.view) } let first = self.touches.isEmpty self.touches.formUnion(touches) if first { changed(true) } diff --git a/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift b/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift index 0412588..5ac0905 100644 --- a/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift +++ b/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift @@ -1,9 +1,90 @@ import XCTest +import WebViewNativeUIEngine import WebKit import SwiftUI @testable import WebViewNativeUIControls final class NativeUISnapshotTests: XCTestCase { + func testCaptureInteractionMatrixKeepsOtherOwnersDirty() { + var state = NativeUICaptureState() + XCTAssertTrue(state.appearanceSettled(["b"])) + state.interacted(["a"]) + XCTAssertFalse(state.appearanceSettled(["a"])) + XCTAssertTrue(state.appearanceSettled(["b"])) + state.interacted(["b"]) + state.captured(["b"]) + XCTAssertFalse(state.appearanceSettled(["a"]), "Capturing B cannot settle A's tap/cancel") + state.interacted(["a", "b"]) + XCTAssertFalse(state.appearanceSettled(["b"]), "Shared/unknown owners remain conservative") + state.captured(["a"]) + XCTAssertTrue(state.appearanceSettled(["a"])) + XCTAssertFalse(state.appearanceSettled(["b"])) + state.retain(["a"]) + XCTAssertTrue(state.dirty.isEmpty, "Removed component IDs must not accumulate") + } + + @MainActor func testControllerReadinessTracksActualOwnersAndCaptureAcknowledgements() async throws { + guard UIApplication.shared.delegate != nil else { throw XCTSkip("Requires hosted UIApplication") } + guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native controls") } + let window = UIWindow(frame: UIScreen.main.bounds) + let owner = UIViewController(); window.rootViewController = owner; window.makeKeyAndVisible() + let web = WKWebView(frame: window.bounds); owner.view.addSubview(web) + let controller = NativeUIController(webView: web, viewController: owner, emit: { _, _ in }, hasListeners: { _ in false }, refresh: { _ in }) + defer { window.isHidden = true } + let nodes = [control(["id": "a", "items": [item(["id": "a-action", "label": "A", "buttonFill": "clear"])]]), + control(["id": "b", "x": 90.0, "items": [item(["id": "b-action", "label": "B", "buttonFill": "clear"])]]), + control(["id": "glass", "x": 160.0, "items": [item(["id": "glass-action", "label": "Glass"]) ]])] + let applied: [String: Any] = await withCheckedContinuation { continuation in + controller.update(["revision": 1, "viewportWidth": Double(web.bounds.width), "controls": nodes], + resolve: { continuation.resume(returning: $0) }, reject: { continuation.resume(returning: ["error": $0]) }) + } + XCTAssertNil(applied["error"]) + window.layoutIfNeeded() + func buttons(_ view: UIView) -> [UIButton] { (view as? UIButton).map { [$0] } ?? view.subviews.flatMap(buttons) } + let button = try XCTUnwrap(buttons(owner.view).first { $0.configuration?.title == "A" }) + XCTAssertTrue(controller.captureReadiness(["a"], revision: 1).layoutSettled) + XCTAssertTrue(controller.captureReadiness(["a"], revision: 1).appearanceSettled) + XCTAssertEqual(controller.captureReadiness(["glass"], revision: 1).effect, .backdrop) + controller.recordCaptureInteraction(button.titleLabel ?? button) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled) + XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).appearanceSettled) + func capture(_ id: String) async -> [String: Any] { + await withCheckedContinuation { continuation in + controller.coveredSnapshot(["action": "prepare", "ids": [id], "revision": 1, "captureMode": "view", "viewportWidth": Double(web.bounds.width)], + resolve: { continuation.resume(returning: $0) }, reject: { continuation.resume(returning: ["error": $0]) }) + } + } + let bResult = await capture("b") + XCTAssertNotNil(bResult["token"]) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled) + let aResult = await capture("a") + XCTAssertNotNil(aResult["token"]) + XCTAssertTrue(controller.captureReadiness(["a"], revision: 1).appearanceSettled) + controller.recordCaptureInteraction(nil) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled) + XCTAssertFalse(controller.captureReadiness(["b"], revision: 1).appearanceSettled) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 0).layoutSettled) + var rebound = nodes + rebound[0]["id"] = "renamed" + _ = await withCheckedContinuation { continuation in + controller.update(["revision": 2, "viewportWidth": Double(web.bounds.width), "controls": rebound], + resolve: { continuation.resume(returning: $0) }, reject: { _ in continuation.resume(returning: [:]) }) + } + window.layoutIfNeeded() + XCTAssertTrue(controller.captureReadiness(["renamed"], revision: 2).layoutSettled) + XCTAssertFalse(controller.captureReadiness(["renamed"], revision: 2).appearanceSettled, "ID reassociation must not forget an unsettled interaction") + _ = await withCheckedContinuation { continuation in controller.clear(["revision": 3], resolve: { continuation.resume(returning: $0) }, reject: { _ in continuation.resume(returning: [:]) }) } + } + + @MainActor func testCaptureEffectsAreDeclaredByComponentIncludingOuterGlass() throws { + let plain = try XCTUnwrap(decode([control(["kind": "ion-button", "items": [item(["buttonFill": "clear"])]])]).controls.first) + let glass = try XCTUnwrap(decode([control(["kind": "ion-button", "items": [item() ]])]).controls.first) + XCTAssertEqual(NativeUIButton.captureEffect(plain), .independent) + XCTAssertEqual(NativeUIButton.captureEffect(glass), .backdrop) + XCTAssertEqual(NativeUIButtons.captureEffect, .backdrop, "Plain children do not remove outer Glass") + XCTAssertEqual(NativeUIFab.captureEffect, .backdrop) + } + @MainActor func testMeasuredTabWidthTracksNativeContentWithAndWithoutSearch() async throws { guard #available(iOS 26.0, *) else { throw XCTSkip("Requires UISearchTab") } let owner = UIViewController() diff --git a/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift b/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift index 26c7c18..f47da9b 100644 --- a/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift +++ b/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift @@ -5,6 +5,89 @@ import UIKit @testable import WebViewNativeUIEngine final class NativeUICoveredSnapshotsTests: XCTestCase { + @MainActor func testTouchObserverReportsEveryOwnerAndWaitsForAllTouchesOnCancel() { + var active: [Bool] = [] + var owners: [UIView?] = [] + let observer = SnapshotTouchObserver(interacted: { owners.append($0) }, changed: { active.append($0) }) + let a = ReadinessTestTouch(); a.source = UIView() + let b = ReadinessTestTouch(); b.source = UIView() + let unknown = ReadinessTestTouch() + let event = UIEvent() + observer.touchesBegan([a], with: event) + observer.touchesBegan([b, unknown], with: event) + XCTAssertEqual(active, [true]) + XCTAssertEqual(owners.count, 3, "Additional fingers and unknown owners must also invalidate readiness") + XCTAssertTrue(owners.contains { $0 === a.source }) + XCTAssertTrue(owners.contains { $0 === b.source }) + XCTAssertTrue(owners.contains { $0 == nil }) + observer.touchesEnded([a], with: event) + XCTAssertEqual(active, [true], "One released finger does not settle the interaction") + observer.touchesCancelled([b, unknown], with: event) + XCTAssertEqual(active, [true, false]) + XCTAssertFalse(observer.cancelsTouchesInView) + XCTAssertFalse(observer.delaysTouchesBegan) + XCTAssertFalse(observer.delaysTouchesEnded) + } + + func testReadinessRequiresEveryIndependentSettledCondition() { + for effect in [NativeUICaptureReadiness.Effect.independent, .backdrop, .unknown] { + for layout in [false, true] { + for appearance in [false, true] { + let state = NativeUICaptureReadiness(effect: effect, layoutSettled: layout, appearanceSettled: appearance) + XCTAssertEqual(state.stableSamples, effect == .independent && layout && appearance ? 1 : 3) + } + } + } + } + + @MainActor func testReadyCaptureUsesFewerSamplesAndRechecksBeforePublication() async throws { + let window = SnapshotTestWindow(frame: UIScreen.main.bounds) + window.rootViewController = UIViewController(); window.makeKeyAndVisible() + let web = try XCTUnwrap(window.rootViewController?.view) + web.frame = window.bounds + let control = CountingSnapshotView(frame: CGRect(x: 40, y: 100, width: 60, height: 44)) + web.addSubview(control); window.layoutIfNeeded() + defer { window.isHidden = true } + for mode in ["legacy", "ready", "glass", "layout", "appearance", "revoked", "late-revoked", "late-trait", "composited"] { + let snapshots = NativeUICoveredSnapshots() + var reads = 0 + var proofs = 0 + var request: [String: Any] = ["action": "prepare", "ids": ["control"], "revision": 1, + "viewportWidth": Double(web.bounds.width), "captureMode": "view"] + if mode == "composited" { request.removeValue(forKey: "captureMode") } + let provider: ((Set, Int) -> NativeUICaptureReadiness)? = mode == "legacy" ? nil : { _, _ in + proofs += 1 + if mode == "late-trait", proofs == 3 { + control.testTraits = UITraitCollection(userInterfaceStyle: control.traitCollection.userInterfaceStyle == .dark ? .light : .dark) + } + return .init(effect: mode == "glass" ? .backdrop : .independent, + layoutSettled: mode != "layout" && !(mode == "revoked" && proofs >= 2), + appearanceSettled: mode != "appearance" && !(mode == "late-revoked" && proofs >= 3)) + } + let result: [String: Any] = await withCheckedContinuation { continuation in + snapshots.handle(request, in: web, frames: { _, _ in reads += 1; return ["control": control.frame] }, + views: { _ in [control] }, readiness: provider) { continuation.resume(returning: $0) } + } + if mode == "late-revoked" || mode == "late-trait" { + XCTAssertNil(result["token"], "Revoked proof must not publish or cache provisional pixels") + let retry: [String: Any] = await withCheckedContinuation { continuation in + snapshots.handle(request, in: web, frames: { _, _ in ["control": control.frame] }, + views: { _ in [control] }) { continuation.resume(returning: $0) } + } + XCTAssertNotNil(retry["token"]) + XCTAssertNotEqual(retry["reused"] as? Bool, true) + let part = try XCTUnwrap((retry["parts"] as? [[String: Any]])?.first) + let file = try XCTUnwrap(URL(string: try XCTUnwrap(part["url"] as? String))) + let files = try FileManager.default.contentsOfDirectory(at: file.deletingLastPathComponent(), includingPropertiesForKeys: nil) + XCTAssertEqual(files.filter { $0.pathExtension == "png" }.count, 1, "Rejected provisional files must be removed") + } else { + XCTAssertNotNil(result["token"], mode) + XCTAssertEqual(reads, mode == "ready" ? 3 : 4, mode) + } + snapshots.reset() + } + } + @MainActor func testOverlappingRootsInvalidateRelativeMovementButReuseCommonTranslation() async throws { let window = SnapshotTestWindow(frame: UIScreen.main.bounds) window.rootViewController = UIViewController() @@ -567,6 +650,8 @@ private final class TransparentSnapshotTestView: UIView { } private final class CountingSnapshotView: UIView { + var testTraits: UITraitCollection? + override var traitCollection: UITraitCollection { testTraits ?? super.traitCollection } var draws = 0 var color = UIColor.red override func drawHierarchy(in rect: CGRect, afterScreenUpdates: Bool) -> Bool { @@ -576,3 +661,8 @@ private final class CountingSnapshotView: UIView { return true } } + +private final class ReadinessTestTouch: UITouch { + var source: UIView? + override var view: UIView? { source } +} From 974cfd1933d132f21ace34660d4bf649e697e7d9 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 10 Nov 2026 19:27:40 +0900 Subject: [PATCH 2/9] fix(ios): keep readiness fallback conservative within a request --- .../WebViewNativeUIEngine/CoveredSnapshots.swift | 4 +++- .../CoveredSnapshotsTests.swift | 12 ++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift b/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift index e78ef1b..d377790 100644 --- a/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +++ b/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift @@ -172,12 +172,14 @@ public final class NativeUICoveredSnapshots { readiness: (() -> NativeUICaptureReadiness)? = nil, complete: @escaping ([String: Any]) -> Void) { let stamp = generation let start = CACurrentMediaTime() + var requiredSamples = 1 func sample(_ previous: [String: CGRect], _ stable: Int) { guard generation == stamp, !touching, CACurrentMediaTime() - start < 1 else { complete([:]); return } let current = frames() let count = !current.isEmpty && current == previous ? stable + 1 : 0 // Recheck the renderer's proof on every sample; a revoked proof falls back. - let requiredSamples = views?.isEmpty == false ? (readiness?().stableSamples ?? 3) : 3 + let currentRequirement = views?.isEmpty == false ? (readiness?().stableSamples ?? 3) : 3 + requiredSamples = max(requiredSamples, currentRequirement) if count >= requiredSamples { capture(frames: current, anchorFrames: anchorFrames?() ?? [:], in: webView, viewportWidth: viewportWidth, views: views, contentRevision: contentRevision, stillReady: requiredSamples == 1 ? { readiness?().stableSamples == 1 && frames() == current } : nil, diff --git a/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift b/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift index f47da9b..85b6283 100644 --- a/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift +++ b/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift @@ -48,10 +48,12 @@ final class NativeUICoveredSnapshotsTests: XCTestCase { let control = CountingSnapshotView(frame: CGRect(x: 40, y: 100, width: 60, height: 44)) web.addSubview(control); window.layoutIfNeeded() defer { window.isHidden = true } - for mode in ["legacy", "ready", "glass", "layout", "appearance", "revoked", "late-revoked", "late-trait", "composited"] { + for mode in ["legacy", "ready", "glass", "layout", "appearance", "revoked", "flapped", "late-revoked", "late-trait", "composited"] { let snapshots = NativeUICoveredSnapshots() var reads = 0 var proofs = 0 + var readsAtRaster = 0 + control.beforeDraw = { readsAtRaster = reads } var request: [String: Any] = ["action": "prepare", "ids": ["control"], "revision": 1, "viewportWidth": Double(web.bounds.width), "captureMode": "view"] if mode == "composited" { request.removeValue(forKey: "captureMode") } @@ -61,13 +63,17 @@ final class NativeUICoveredSnapshotsTests: XCTestCase { control.testTraits = UITraitCollection(userInterfaceStyle: control.traitCollection.userInterfaceStyle == .dark ? .light : .dark) } return .init(effect: mode == "glass" ? .backdrop : .independent, - layoutSettled: mode != "layout" && !(mode == "revoked" && proofs >= 2), + layoutSettled: mode != "layout" && !(mode == "revoked" && proofs >= 2) && !(mode == "flapped" && proofs == 2), appearanceSettled: mode != "appearance" && !(mode == "late-revoked" && proofs >= 3)) } let result: [String: Any] = await withCheckedContinuation { continuation in snapshots.handle(request, in: web, frames: { _, _ in reads += 1; return ["control": control.frame] }, views: { _ in [control] }, readiness: provider) { continuation.resume(returning: $0) } } + if mode != "composited" { + XCTAssertEqual(readsAtRaster, ["ready", "late-revoked", "late-trait"].contains(mode) ? 2 : 4, + "An uncertain request cannot upgrade back to the short path: \(mode)") + } if mode == "late-revoked" || mode == "late-trait" { XCTAssertNil(result["token"], "Revoked proof must not publish or cache provisional pixels") let retry: [String: Any] = await withCheckedContinuation { continuation in @@ -651,10 +657,12 @@ private final class TransparentSnapshotTestView: UIView { private final class CountingSnapshotView: UIView { var testTraits: UITraitCollection? + var beforeDraw: (() -> Void)? override var traitCollection: UITraitCollection { testTraits ?? super.traitCollection } var draws = 0 var color = UIColor.red override func drawHierarchy(in rect: CGRect, afterScreenUpdates: Bool) -> Bool { + beforeDraw?() draws += 1 color.setFill() UIRectFill(rect) From d2c8bfef61b45770a4fca4e9710aad051388cf51 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 10 Nov 2026 19:45:31 +0900 Subject: [PATCH 3/9] fix(ios): retain unsettled input by source view identity --- docs/advanced/native-handoff.md | 8 ++-- .../NativeUICaptureState.swift | 16 ++++---- .../NativeUIController.swift | 35 +++++++--------- .../NativeUISnapshotTests.swift | 41 +++++++++++-------- 4 files changed, 49 insertions(+), 51 deletions(-) diff --git a/docs/advanced/native-handoff.md b/docs/advanced/native-handoff.md index 446cb88..1ca320a 100644 --- a/docs/advanced/native-handoff.md +++ b/docs/advanced/native-handoff.md @@ -131,7 +131,9 @@ the standard checks. Active touches still invalidate preparation. This does not change `captureDelayMs`, paint acknowledgements, or handoff deadlines. Controls supplies this proof for settled, standalone Plain buttons without scroll -bindings. A tap keeps its owner conservative until that owner's successful -capture; capturing another owner does not settle it. Shared surfaces, unknown -input targets, active animations and unresolved interaction across a new revision +bindings. A tap or keyboard/accessibility activation keeps its source View conservative; +a snapshot ACK alone cannot prove UIKit's press animation has completed. This +history follows reassociated Views without retaining retired components. A new +View starts clean; capturing another source does not clear an interaction. Shared surfaces, unknown +input targets, active animations and unresolved interactions remain conservative. Other component types retain their existing behavior. diff --git a/ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift b/ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift index 774c7e8..55c7408 100644 --- a/ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift +++ b/ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift @@ -1,12 +1,10 @@ import UIKit -import WebViewNativeUIEngine -// Component IDs remain dirty until their own successful conservative capture. -// Capturing an unrelated component never acknowledges their interaction. -struct NativeUICaptureState { - private(set) var dirty = Set() - mutating func interacted(_ ids: Set) { dirty.formUnion(ids) } - mutating func captured(_ ids: Set) { dirty.subtract(ids) } - mutating func retain(_ ids: Set) { dirty.formIntersection(ids) } - func appearanceSettled(_ ids: Set) -> Bool { dirty.isDisjoint(with: ids) } +// A capture ACK does not prove that UIKit's press animation finished. +// Track the actual source weakly, so reassociation keeps its interaction state +// while a genuinely new component starts clean without retaining old views. +final class NativeUICaptureState { + private let dirty = NSHashTable.weakObjects() + func interacted(_ views: [UIView]) { views.forEach(dirty.add) } + func appearanceSettled(_ views: [UIView]) -> Bool { views.allSatisfy { !dirty.contains($0) } } } diff --git a/ios/Sources/WebViewNativeUIControls/NativeUIController.swift b/ios/Sources/WebViewNativeUIControls/NativeUIController.swift index a845c85..709f300 100644 --- a/ios/Sources/WebViewNativeUIControls/NativeUIController.swift +++ b/ios/Sources/WebViewNativeUIControls/NativeUIController.swift @@ -23,7 +23,7 @@ public final class NativeUIController: NSObject, UITabBarDelegate { super.init() } private let coveredSnapshots = NativeUICoveredSnapshots() - private var captureState = NativeUICaptureState() + private let captureState = NativeUICaptureState() private var captureLayoutRevision: Int? private weak var captureObservationWindow: UIWindow? private var snapshotControls: [NativeUIControl] = [] @@ -259,7 +259,7 @@ public final class NativeUIController: NSObject, UITabBarDelegate { private func observeCaptureInteractions() { guard let window = webView?.window else { return } if captureObservationWindow !== window { - captureState.interacted(Set(snapshotControls.map(\.id))) + captureState.interacted(Array(controls.values) + (verticalBars.map { [$0.view] } ?? [])) captureObservationWindow = window } coveredSnapshots.observe(window, interacted: { [weak self] touched in @@ -271,14 +271,13 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } func recordCaptureInteraction(_ touched: UIView?) { - let all = Set(snapshotControls.map(\.id)) + let all = Array(controls.values) + (verticalBars.map { [$0.view] } ?? []) guard let touched else { captureState.interacted(all); return } if let rail = verticalBars, touched.isDescendant(of: rail.view) { - captureState.interacted(Set(snapshotControls.filter { $0.placement == .verticalBars }.map(\.id))) - return + captureState.interacted([rail.view]); return } - let owners = Set(controls.filter { touched.isDescendant(of: $0.value) }.keys) - captureState.interacted(owners.isEmpty ? all : owners) + let owners = controls.values.filter { touched.isDescendant(of: $0) } + captureState.interacted(owners.isEmpty ? all : Array(owners)) } func captureReadiness(_ ids: Set, revision: Int) -> NativeUICaptureReadiness { @@ -308,12 +307,11 @@ public final class NativeUIController: NSObject, UITabBarDelegate { return true } return .init(effect: effect, layoutSettled: settled, - appearanceSettled: settled && captureState.appearanceSettled(ids)) + appearanceSettled: settled && captureState.appearanceSettled(nodes.compactMap { controls[$0.id] })) } private func resetCoveredSnapshots() { coveredSnapshots.reset() - captureState = NativeUICaptureState() captureLayoutRevision = nil } @@ -365,11 +363,7 @@ public final class NativeUIController: NSObject, UITabBarDelegate { return views } self.coveredSnapshots.handle(options, in: webView, frames: measure, views: views, - readiness: { [weak self] ids, revision in self?.captureReadiness(ids, revision: revision) ?? .init() }, complete: { result in - if result["token"] != nil, options["revision"] as? Int == self.revision, - let ids = options["ids"] as? [String] { self.captureState.captured(Set(ids)) } - resolve(result) - }) + readiness: { [weak self] ids, revision in self?.captureReadiness(ids, revision: revision) ?? .init() }, complete: { resolve($0) }) } } @@ -525,12 +519,6 @@ public final class NativeUIController: NSObject, UITabBarDelegate { let snapshot = try? JSONDecoder().decode(NativeUISnapshot.self, from: JSONSerialization.data(withJSONObject: options)), snapshot.isValid else { reject("Invalid control snapshot"); return } - // IDs may be reassociated with existing views during reconciliation. - // Carry unresolved interaction across the whole new revision rather - // than accidentally declaring a newly named owner settled. - if !self.captureState.dirty.isEmpty { - self.captureState.interacted(Set(snapshot.controls.map(\.id))) - } self.captureLayoutRevision = nil self.coveredSnapshots.restoreControls() if snapshot.coveredSnapshotToken == nil && self.coveredSnapshots.covering { @@ -835,7 +823,6 @@ public final class NativeUIController: NSObject, UITabBarDelegate { let complete = { if self.revision == next { self.captureLayoutRevision = next - self.captureState.retain(Set(self.snapshotControls.map(\.id))) } var result: [String: Any] = ["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls, "tabBarWidths": tabBarWidths] if let transition = self.toolbarTransition { result["toolbarTransition"] = transition.id } @@ -867,6 +854,12 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } private func activate(_ id: String) { + // Keyboard/VoiceOver activation can bypass UITouch delivery. + let owners = snapshotControls.filter { $0.id == id || $0.items.contains { $0.id == id } } + let sources = owners.compactMap { controls[$0.id] ?? ($0.placement == .verticalBars ? verticalBars?.view : nil) } + if sources.isEmpty { recordCaptureInteraction(nil) } else { + sources.forEach { recordCaptureInteraction($0) } + } sequence += 1 notifyListeners("activate", ["id": id, "revision": revision, "sequence": sequence]) diff --git a/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift b/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift index 5ac0905..58476d3 100644 --- a/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift +++ b/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift @@ -5,22 +5,22 @@ import SwiftUI @testable import WebViewNativeUIControls final class NativeUISnapshotTests: XCTestCase { - func testCaptureInteractionMatrixKeepsOtherOwnersDirty() { - var state = NativeUICaptureState() - XCTAssertTrue(state.appearanceSettled(["b"])) - state.interacted(["a"]) - XCTAssertFalse(state.appearanceSettled(["a"])) - XCTAssertTrue(state.appearanceSettled(["b"])) - state.interacted(["b"]) - state.captured(["b"]) - XCTAssertFalse(state.appearanceSettled(["a"]), "Capturing B cannot settle A's tap/cancel") - state.interacted(["a", "b"]) - XCTAssertFalse(state.appearanceSettled(["b"]), "Shared/unknown owners remain conservative") - state.captured(["a"]) - XCTAssertTrue(state.appearanceSettled(["a"])) - XCTAssertFalse(state.appearanceSettled(["b"])) - state.retain(["a"]) - XCTAssertTrue(state.dirty.isEmpty, "Removed component IDs must not accumulate") + @MainActor func testCaptureInteractionMatrixKeepsOtherOwnersDirty() { + let state = NativeUICaptureState() + let a = UIView(); let b = UIView() + XCTAssertTrue(state.appearanceSettled([b])) + state.interacted([a]) + XCTAssertFalse(state.appearanceSettled([a])) + XCTAssertTrue(state.appearanceSettled([b])) + state.interacted([b]) + XCTAssertFalse(state.appearanceSettled([a]), "A different capture cannot prove A's animation finished") + XCTAssertFalse(state.appearanceSettled([b])) + XCTAssertTrue(state.appearanceSettled([UIView()]), "A genuinely new source has no old interaction") + weak var released: UIView? + autoreleasepool { + let temporary = UIView(); released = temporary; state.interacted([temporary]) + } + XCTAssertNil(released, "Interaction history must not retain retired components") } @MainActor func testControllerReadinessTracksActualOwnersAndCaptureAcknowledgements() async throws { @@ -31,7 +31,7 @@ final class NativeUISnapshotTests: XCTestCase { let web = WKWebView(frame: window.bounds); owner.view.addSubview(web) let controller = NativeUIController(webView: web, viewController: owner, emit: { _, _ in }, hasListeners: { _ in false }, refresh: { _ in }) defer { window.isHidden = true } - let nodes = [control(["id": "a", "items": [item(["id": "a-action", "label": "A", "buttonFill": "clear"])]]), + let nodes = [control(["id": "a", "items": [item(["id": "a-action", "label": "A", "buttonFill": "clear", "transitionId": "readiness-a"])]]), control(["id": "b", "x": 90.0, "items": [item(["id": "b-action", "label": "B", "buttonFill": "clear"])]]), control(["id": "glass", "x": 160.0, "items": [item(["id": "glass-action", "label": "Glass"]) ]])] let applied: [String: Any] = await withCheckedContinuation { continuation in @@ -45,6 +45,10 @@ final class NativeUISnapshotTests: XCTestCase { XCTAssertTrue(controller.captureReadiness(["a"], revision: 1).layoutSettled) XCTAssertTrue(controller.captureReadiness(["a"], revision: 1).appearanceSettled) XCTAssertEqual(controller.captureReadiness(["glass"], revision: 1).effect, .backdrop) + button.sendActions(for: .touchUpInside) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled, "UIAction without UITouch must invalidate its owner") + XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).appearanceSettled) + // A real UIAction must invalidate readiness even without a UITouch. controller.recordCaptureInteraction(button.titleLabel ?? button) XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled) XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).appearanceSettled) @@ -59,7 +63,7 @@ final class NativeUISnapshotTests: XCTestCase { XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled) let aResult = await capture("a") XCTAssertNotNil(aResult["token"]) - XCTAssertTrue(controller.captureReadiness(["a"], revision: 1).appearanceSettled) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled, "Snapshot completion is not animation completion") controller.recordCaptureInteraction(nil) XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled) XCTAssertFalse(controller.captureReadiness(["b"], revision: 1).appearanceSettled) @@ -71,6 +75,7 @@ final class NativeUISnapshotTests: XCTestCase { resolve: { continuation.resume(returning: $0) }, reject: { _ in continuation.resume(returning: [:]) }) } window.layoutIfNeeded() + XCTAssertTrue(buttons(owner.view).contains { $0 === button }, "The source must actually be reassociated") XCTAssertTrue(controller.captureReadiness(["renamed"], revision: 2).layoutSettled) XCTAssertFalse(controller.captureReadiness(["renamed"], revision: 2).appearanceSettled, "ID reassociation must not forget an unsettled interaction") _ = await withCheckedContinuation { continuation in controller.clear(["revision": 3], resolve: { continuation.resume(returning: $0) }, reject: { _ in continuation.resume(returning: [:]) }) } From 09e22fb043008bed35006884d16dcc5d7be57572 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 10 Nov 2026 20:19:20 +0900 Subject: [PATCH 4/9] fix(ios): validate visible sources and preserve capture order --- .../NativeUIController.swift | 12 ++++++++--- .../NativeUISnapshotTests.swift | 21 +++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/ios/Sources/WebViewNativeUIControls/NativeUIController.swift b/ios/Sources/WebViewNativeUIControls/NativeUIController.swift index 709f300..bfa3b79 100644 --- a/ios/Sources/WebViewNativeUIControls/NativeUIController.swift +++ b/ios/Sources/WebViewNativeUIControls/NativeUIController.swift @@ -298,10 +298,10 @@ public final class NativeUIController: NSObject, UITabBarDelegate { // that transaction; the Engine separately rechecks actual frames. let settled = nodes.allSatisfy { node in guard let button = controls[node.id] as? UIButton, !button.isHighlighted, !button.isTracking, - button.window != nil else { return false } + button.window != nil, button.layer.mask == nil else { return false } var view: UIView? = button while let current = view { - if !(current.layer.animationKeys() ?? []).isEmpty { return false } + if current.isHidden || current.alpha <= 0 || !(current.layer.animationKeys() ?? []).isEmpty { return false } view = current.superview } return true @@ -358,7 +358,13 @@ public final class NativeUIController: NSObject, UITabBarDelegate { ids.isDisjoint(with: railIDs) || ids.isSuperset(of: railIDs) else { return nil } - var views = Array(self.controls.filter { ids.contains($0.key) }.values) + let selected = Array(self.controls.filter { ids.contains($0.key) }.values) + var views = selected + // Partial Dictionary selection must not randomize the shared + // raster's draw order or turn unchanged sources into cache misses. + if let ordered = self.host?.subviews.filter({ selected.contains($0) }), ordered.count == selected.count { + views = ordered + } if !railIDs.isEmpty, ids.isSuperset(of: railIDs), let rail = self.verticalBars { views.append(rail.view) } return views } diff --git a/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift b/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift index 58476d3..825126c 100644 --- a/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift +++ b/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift @@ -45,6 +45,11 @@ final class NativeUISnapshotTests: XCTestCase { XCTAssertTrue(controller.captureReadiness(["a"], revision: 1).layoutSettled) XCTAssertTrue(controller.captureReadiness(["a"], revision: 1).appearanceSettled) XCTAssertEqual(controller.captureReadiness(["glass"], revision: 1).effect, .backdrop) + let other = try XCTUnwrap(buttons(owner.view).first { $0.configuration?.title == "B" }) + other.layer.mask = CALayer() + XCTAssertFalse(controller.captureReadiness(["b"], revision: 1).appearanceSettled, "Concealed pixels cannot declare readiness") + other.layer.mask = nil + button.sendActions(for: .touchUpInside) XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled, "UIAction without UITouch must invalidate its owner") XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).appearanceSettled) @@ -52,16 +57,24 @@ final class NativeUISnapshotTests: XCTestCase { controller.recordCaptureInteraction(button.titleLabel ?? button) XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled) XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).appearanceSettled) - func capture(_ id: String) async -> [String: Any] { + func capture(_ ids: [String]) async -> [String: Any] { await withCheckedContinuation { continuation in - controller.coveredSnapshot(["action": "prepare", "ids": [id], "revision": 1, "captureMode": "view", "viewportWidth": Double(web.bounds.width)], + controller.coveredSnapshot(["action": "prepare", "ids": ids, "revision": 1, "captureMode": "view", "viewportWidth": Double(web.bounds.width)], resolve: { continuation.resume(returning: $0) }, reject: { continuation.resume(returning: ["error": $0]) }) } } - let bResult = await capture("b") + let pair = await capture(["a", "b"]) + XCTAssertNotNil(pair["token"]) + let samePair = await capture(["a", "b"]) + XCTAssertEqual(samePair["reused"] as? Bool, true, "Partial selection must keep native drawing order") + button.superview?.bringSubviewToFront(button) + let reorderedPair = await capture(["a", "b"]) + XCTAssertNotEqual(reorderedPair["reused"] as? Bool, true, "A real native z-order change must recapture") + button.superview?.insertSubview(button, at: 0) + let bResult = await capture(["b"]) XCTAssertNotNil(bResult["token"]) XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled) - let aResult = await capture("a") + let aResult = await capture(["a"]) XCTAssertNotNil(aResult["token"]) XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled, "Snapshot completion is not animation completion") controller.recordCaptureInteraction(nil) From d2c0fe6c9b095e94fec6741d34cbd540370b4a84 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 10 Nov 2026 20:44:02 +0900 Subject: [PATCH 5/9] refactor(ios): normalize readiness at public API boundaries --- docs/advanced/native-handoff.md | 6 ++++++ .../WebViewNativeUIControls/NativeUIController.swift | 2 +- .../WebViewNativeUIEngine/CoveredSnapshots.swift | 11 ++++++----- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/advanced/native-handoff.md b/docs/advanced/native-handoff.md index 1ca320a..4a8b444 100644 --- a/docs/advanced/native-handoff.md +++ b/docs/advanced/native-handoff.md @@ -137,3 +137,9 @@ history follows reassociated Views without retaining retired components. A new View starts clean; capturing another source does not clear an interaction. Shared surfaces, unknown input targets, active animations and unresolved interactions remain conservative. Other component types retain their existing behavior. + +Controls and Engine are released at the same version and use the typed readiness +contract directly. The optional public callback is an integration boundary for +independent renderers; its omission is normalized to unknown readiness before +entering the capture scheduler. Unknown appearance or layout remains a valid +runtime state even when both modules have the same version. diff --git a/ios/Sources/WebViewNativeUIControls/NativeUIController.swift b/ios/Sources/WebViewNativeUIControls/NativeUIController.swift index bfa3b79..06c3c41 100644 --- a/ios/Sources/WebViewNativeUIControls/NativeUIController.swift +++ b/ios/Sources/WebViewNativeUIControls/NativeUIController.swift @@ -369,7 +369,7 @@ public final class NativeUIController: NSObject, UITabBarDelegate { return views } self.coveredSnapshots.handle(options, in: webView, frames: measure, views: views, - readiness: { [weak self] ids, revision in self?.captureReadiness(ids, revision: revision) ?? .init() }, complete: { resolve($0) }) + readiness: self.captureReadiness, complete: { resolve($0) }) } } diff --git a/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift b/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift index d377790..f7d0cb2 100644 --- a/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +++ b/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift @@ -90,6 +90,7 @@ public final class NativeUICoveredSnapshots { let firstFrames = frames(expected, revision) guard Set(firstFrames.keys) == expected else { complete([:]); return } let viewCapture = request["captureMode"] as? String == "view" + let readinessProvider = readiness ?? { _, _ in NativeUICaptureReadiness() } let selected = viewCapture ? views(expected) : nil guard !viewCapture || selected?.isEmpty == false else { complete([:]); return } var initial: [String: CGRect]? = firstFrames @@ -98,7 +99,7 @@ public final class NativeUICoveredSnapshots { initial = nil return Set(measured.keys) == expected ? measured : [:] }, in: webView, viewportWidth: width, views: selected, anchorFrames: anchorFrames, contentRevision: viewCapture ? revision : nil, - readiness: viewCapture ? readiness.map { provider in { provider(expected, revision) } } : nil, complete: { result in + readiness: { readinessProvider(expected, revision) }, complete: { result in if let token = result["token"] as? String { self.captures[token]?.preservesLayout = request["imageSurface"] as? String == "web" } complete(result) }) @@ -164,12 +165,12 @@ public final class NativeUICoveredSnapshots { views: [UIView]? = nil, anchorFrames: (() -> [String: CGRect])? = nil, readiness: (() -> NativeUICaptureReadiness)? = nil, complete: @escaping ([String: Any]) -> Void) { prepareCapture(frames: frames, in: webView, viewportWidth: viewportWidth, views: views, - anchorFrames: anchorFrames, contentRevision: nil, readiness: readiness, complete: complete) + anchorFrames: anchorFrames, contentRevision: nil, readiness: readiness ?? { .init() }, complete: complete) } private func prepareCapture(frames: @escaping () -> [String: CGRect], in webView: UIView, viewportWidth: CGFloat, views: [UIView]?, anchorFrames: (() -> [String: CGRect])?, contentRevision: Int?, - readiness: (() -> NativeUICaptureReadiness)? = nil, complete: @escaping ([String: Any]) -> Void) { + readiness: @escaping () -> NativeUICaptureReadiness, complete: @escaping ([String: Any]) -> Void) { let stamp = generation let start = CACurrentMediaTime() var requiredSamples = 1 @@ -178,11 +179,11 @@ public final class NativeUICoveredSnapshots { let current = frames() let count = !current.isEmpty && current == previous ? stable + 1 : 0 // Recheck the renderer's proof on every sample; a revoked proof falls back. - let currentRequirement = views?.isEmpty == false ? (readiness?().stableSamples ?? 3) : 3 + let currentRequirement = views?.isEmpty == false ? readiness().stableSamples : 3 requiredSamples = max(requiredSamples, currentRequirement) if count >= requiredSamples { capture(frames: current, anchorFrames: anchorFrames?() ?? [:], in: webView, viewportWidth: viewportWidth, views: views, contentRevision: contentRevision, - stillReady: requiredSamples == 1 ? { readiness?().stableSamples == 1 && frames() == current } : nil, + stillReady: requiredSamples == 1 ? { readiness().stableSamples == 1 && frames() == current } : nil, complete: complete) } else { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0 / 60.0) { sample(current, count) } From d32861878b4dffa642c45a0730a87cc19ef9817f Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 10 Nov 2026 20:56:46 +0900 Subject: [PATCH 6/9] refactor(ios): let capture callers own readiness decisions --- docs/advanced/native-handoff.md | 22 ++++++++++----- .../Components/NativeUIButton.swift | 2 +- .../Components/NativeUIButtons.swift | 2 +- .../Components/NativeUIFab.swift | 2 +- .../NativeUICaptureState.swift | 3 ++ .../NativeUIController.swift | 8 +++--- .../CaptureReadiness.swift | 17 ++++------- .../NativeUISnapshotTests.swift | 28 +++++++++---------- .../CoveredSnapshotsTests.swift | 19 +++++-------- 9 files changed, 51 insertions(+), 52 deletions(-) diff --git a/docs/advanced/native-handoff.md b/docs/advanced/native-handoff.md index 4a8b444..832f10d 100644 --- a/docs/advanced/native-handoff.md +++ b/docs/advanced/native-handoff.md @@ -120,11 +120,12 @@ Continue releasing retired tokens and reset the manager on owner teardown. A native renderer may supply the optional `readiness` callback to `handle`, or to `prepare` with explicit source views. Return `NativeUICaptureReadiness` for the -requested revision: the component declares whether pixels are backdrop-independent -and whether its layout and appearance have settled. The Engine does not inspect -component styles to infer Glass. +requested revision and source set: `NativeUICaptureReadiness(isSettled: true)` +asserts that the caller has verified its effects, layout and interaction state. +The Engine receives no component kind or effect classification; it does not +decide whether a caller is a Plain button, Glass control or another renderer. -Only independent, settled view captures use one matching geometry sample instead +Only caller-declared settled view captures use one matching geometry sample instead of three. Coordinates and source traits are checked again before publishing fresh pixels. Missing information, composited capture, Glass, and unsettled state retain the standard checks. Active touches still invalidate preparation. This does not @@ -140,6 +141,13 @@ remain conservative. Other component types retain their existing behavior. Controls and Engine are released at the same version and use the typed readiness contract directly. The optional public callback is an integration boundary for -independent renderers; its omission is normalized to unknown readiness before -entering the capture scheduler. Unknown appearance or layout remains a valid -runtime state even when both modules have the same version. +independent renderers; its omission is normalized to unsettled readiness before +entering the capture scheduler. The caller must return `isSettled: false` while any relevant state is unknown, +and revoke its proof if that state changes during capture. Engine still validates +geometry, source traits and capture lifetime independently. + +Existing integrations do not need to add this callback. An explicit Web +`captureDelayMs` (including a delay for Glass selection animations) remains +unchanged: readiness neither bypasses nor overrides that scheduling delay. +Native settling happens after it, and no animation-completion signal is inferred +from the elapsed delay. diff --git a/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift b/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift index 3210030..debd925 100644 --- a/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift +++ b/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift @@ -34,7 +34,7 @@ enum NativeUIButton { static let kinds: [NativeUIComponent] = [.button, .backButton, .menuButton] // The component declares the same material choice used by make(). - static func captureEffect(_ node: NativeUIControl) -> NativeUICaptureReadiness.Effect { + static func captureEffect(_ node: NativeUIControl) -> NativeUICaptureEffect { guard kinds.contains(node.kind), node.placement == nil, node.scroll == nil, node.items.count == 1, let item = node.items.first else { return .unknown } return item.content.buttonFill == .clear ? .independent : .backdrop diff --git a/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift b/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift index 00651fe..d8116f0 100644 --- a/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift +++ b/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift @@ -3,7 +3,7 @@ import UIKit enum NativeUIButtons { static let kind = NativeUIComponent.buttons - static let captureEffect = NativeUICaptureReadiness.Effect.backdrop + static let captureEffect = NativeUICaptureEffect.backdrop @available(iOS 26.0, *) static func make(_ node: NativeUIControl, scale: CGFloat, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) -> UIView { diff --git a/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift b/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift index 2749ac0..dc350c8 100644 --- a/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift +++ b/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift @@ -3,7 +3,7 @@ import UIKit final class NativeUIFab: UIView { static let kind = NativeUIComponent.fab - static let captureEffect = NativeUICaptureReadiness.Effect.backdrop + static let captureEffect = NativeUICaptureEffect.backdrop var buttons: [String: UIButton] = [:] var content: [String: NativeUIItemContent] = [:] override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { diff --git a/ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift b/ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift index 55c7408..cfbdc7c 100644 --- a/ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift +++ b/ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift @@ -1,5 +1,8 @@ import UIKit +// Component effects belong to the renderer, not the capture engine. +enum NativeUICaptureEffect { case independent, backdrop, unknown } + // A capture ACK does not prove that UIKit's press animation finished. // Track the actual source weakly, so reassociation keeps its interaction state // while a genuinely new component starts clean without retaining old views. diff --git a/ios/Sources/WebViewNativeUIControls/NativeUIController.swift b/ios/Sources/WebViewNativeUIControls/NativeUIController.swift index 06c3c41..38ca4e6 100644 --- a/ios/Sources/WebViewNativeUIControls/NativeUIController.swift +++ b/ios/Sources/WebViewNativeUIControls/NativeUIController.swift @@ -286,12 +286,12 @@ public final class NativeUIController: NSObject, UITabBarDelegate { !keyboardVisible, toolbarTransition == nil else { return .init() } let nodes = snapshotControls.filter { ids.contains($0.id) } guard !ids.isEmpty, nodes.count == ids.count else { return .init() } - let effects = nodes.map { node -> NativeUICaptureReadiness.Effect in + let effects = nodes.map { node -> NativeUICaptureEffect in if node.kind == NativeUIButtons.kind { return NativeUIButtons.captureEffect } if node.kind == NativeUIFab.kind { return NativeUIFab.captureEffect } return NativeUIButton.captureEffect(node) } - let effect: NativeUICaptureReadiness.Effect = effects.allSatisfy { $0 == .independent } ? .independent : + let effect: NativeUICaptureEffect = effects.allSatisfy { $0 == .independent } ? .independent : effects.contains(.backdrop) ? .backdrop : .unknown // update() acknowledges our explicit, nonanimated layout transaction. // UIKit may dirty internal layout flags while drawing without changing @@ -306,8 +306,8 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } return true } - return .init(effect: effect, layoutSettled: settled, - appearanceSettled: settled && captureState.appearanceSettled(nodes.compactMap { controls[$0.id] })) + return .init(isSettled: effect == .independent && settled && + captureState.appearanceSettled(nodes.compactMap { controls[$0.id] })) } private func resetCoveredSnapshots() { diff --git a/ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift b/ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift index 5709707..3a3191a 100644 --- a/ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift +++ b/ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift @@ -1,15 +1,10 @@ import Foundation -/// Native renderer-owned readiness for a particular capture revision. -/// Unspecified or backdrop-dependent captures retain the standard geometry checks. +/// Caller-owned proof for a particular capture revision and source set. +/// The caller decides whether its effects, layout and interaction state are settled. +/// Engine validates geometry and capture lifetime without classifying components. public struct NativeUICaptureReadiness { - /// Whether pixels depend on backdrop composition (including Glass). - public enum Effect: Equatable { case independent, backdrop, unknown } - public let effect: Effect - public let layoutSettled: Bool - public let appearanceSettled: Bool - public init(effect: Effect = .unknown, layoutSettled: Bool = false, appearanceSettled: Bool = false) { - self.effect = effect; self.layoutSettled = layoutSettled; self.appearanceSettled = appearanceSettled - } - var stableSamples: Int { effect == .independent && layoutSettled && appearanceSettled ? 1 : 3 } + public let isSettled: Bool + public init(isSettled: Bool = false) { self.isSettled = isSettled } + var stableSamples: Int { isSettled ? 1 : 3 } } diff --git a/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift b/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift index 825126c..4f2386d 100644 --- a/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift +++ b/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift @@ -42,21 +42,20 @@ final class NativeUISnapshotTests: XCTestCase { window.layoutIfNeeded() func buttons(_ view: UIView) -> [UIButton] { (view as? UIButton).map { [$0] } ?? view.subviews.flatMap(buttons) } let button = try XCTUnwrap(buttons(owner.view).first { $0.configuration?.title == "A" }) - XCTAssertTrue(controller.captureReadiness(["a"], revision: 1).layoutSettled) - XCTAssertTrue(controller.captureReadiness(["a"], revision: 1).appearanceSettled) - XCTAssertEqual(controller.captureReadiness(["glass"], revision: 1).effect, .backdrop) + XCTAssertTrue(controller.captureReadiness(["a"], revision: 1).isSettled) + XCTAssertFalse(controller.captureReadiness(["glass"], revision: 1).isSettled) let other = try XCTUnwrap(buttons(owner.view).first { $0.configuration?.title == "B" }) other.layer.mask = CALayer() - XCTAssertFalse(controller.captureReadiness(["b"], revision: 1).appearanceSettled, "Concealed pixels cannot declare readiness") + XCTAssertFalse(controller.captureReadiness(["b"], revision: 1).isSettled, "Concealed pixels cannot declare readiness") other.layer.mask = nil button.sendActions(for: .touchUpInside) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled, "UIAction without UITouch must invalidate its owner") - XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).appearanceSettled) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled, "UIAction without UITouch must invalidate its owner") + XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).isSettled) // A real UIAction must invalidate readiness even without a UITouch. controller.recordCaptureInteraction(button.titleLabel ?? button) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled) - XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).appearanceSettled) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled) + XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).isSettled) func capture(_ ids: [String]) async -> [String: Any] { await withCheckedContinuation { continuation in controller.coveredSnapshot(["action": "prepare", "ids": ids, "revision": 1, "captureMode": "view", "viewportWidth": Double(web.bounds.width)], @@ -73,14 +72,14 @@ final class NativeUISnapshotTests: XCTestCase { button.superview?.insertSubview(button, at: 0) let bResult = await capture(["b"]) XCTAssertNotNil(bResult["token"]) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled) let aResult = await capture(["a"]) XCTAssertNotNil(aResult["token"]) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled, "Snapshot completion is not animation completion") + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled, "Snapshot completion is not animation completion") controller.recordCaptureInteraction(nil) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).appearanceSettled) - XCTAssertFalse(controller.captureReadiness(["b"], revision: 1).appearanceSettled) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 0).layoutSettled) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled) + XCTAssertFalse(controller.captureReadiness(["b"], revision: 1).isSettled) + XCTAssertFalse(controller.captureReadiness(["a"], revision: 0).isSettled) var rebound = nodes rebound[0]["id"] = "renamed" _ = await withCheckedContinuation { continuation in @@ -89,8 +88,7 @@ final class NativeUISnapshotTests: XCTestCase { } window.layoutIfNeeded() XCTAssertTrue(buttons(owner.view).contains { $0 === button }, "The source must actually be reassociated") - XCTAssertTrue(controller.captureReadiness(["renamed"], revision: 2).layoutSettled) - XCTAssertFalse(controller.captureReadiness(["renamed"], revision: 2).appearanceSettled, "ID reassociation must not forget an unsettled interaction") + XCTAssertFalse(controller.captureReadiness(["renamed"], revision: 2).isSettled, "ID reassociation must not forget an unsettled interaction") _ = await withCheckedContinuation { continuation in controller.clear(["revision": 3], resolve: { continuation.resume(returning: $0) }, reject: { _ in continuation.resume(returning: [:]) }) } } diff --git a/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift b/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift index 85b6283..0184577 100644 --- a/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift +++ b/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift @@ -29,15 +29,10 @@ final class NativeUICoveredSnapshotsTests: XCTestCase { XCTAssertFalse(observer.delaysTouchesEnded) } - func testReadinessRequiresEveryIndependentSettledCondition() { - for effect in [NativeUICaptureReadiness.Effect.independent, .backdrop, .unknown] { - for layout in [false, true] { - for appearance in [false, true] { - let state = NativeUICaptureReadiness(effect: effect, layoutSettled: layout, appearanceSettled: appearance) - XCTAssertEqual(state.stableSamples, effect == .independent && layout && appearance ? 1 : 3) - } - } - } + func testReadinessUsesCallerDeclaration() { + XCTAssertEqual(NativeUICaptureReadiness().stableSamples, 3) + XCTAssertEqual(NativeUICaptureReadiness(isSettled: false).stableSamples, 3) + XCTAssertEqual(NativeUICaptureReadiness(isSettled: true).stableSamples, 1) } @MainActor func testReadyCaptureUsesFewerSamplesAndRechecksBeforePublication() async throws { @@ -62,9 +57,9 @@ final class NativeUICoveredSnapshotsTests: XCTestCase { if mode == "late-trait", proofs == 3 { control.testTraits = UITraitCollection(userInterfaceStyle: control.traitCollection.userInterfaceStyle == .dark ? .light : .dark) } - return .init(effect: mode == "glass" ? .backdrop : .independent, - layoutSettled: mode != "layout" && !(mode == "revoked" && proofs >= 2) && !(mode == "flapped" && proofs == 2), - appearanceSettled: mode != "appearance" && !(mode == "late-revoked" && proofs >= 3)) + return .init(isSettled: mode != "glass" && mode != "layout" && mode != "appearance" && + !(mode == "revoked" && proofs >= 2) && !(mode == "flapped" && proofs == 2) && + !(mode == "late-revoked" && proofs >= 3)) } let result: [String: Any] = await withCheckedContinuation { continuation in snapshots.handle(request, in: web, frames: { _, _ in reads += 1; return ["control": control.frame] }, From b74a6f193fdcf983d627876fa9ab1cc313a3fe72 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 10 Nov 2026 21:33:32 +0900 Subject: [PATCH 7/9] fix(ci): include readiness contract in image reuse fixture --- scripts/verify-image-reuse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/verify-image-reuse.py b/scripts/verify-image-reuse.py index 16300cb..7943bd4 100644 --- a/scripts/verify-image-reuse.py +++ b/scripts/verify-image-reuse.py @@ -30,7 +30,7 @@ def run(*cmd): encoder = encoder.replace(' let bounds = image.extent', ' pipelineCounterLock.lock(); pngEncodes += 1; pipelineCounterLock.unlock()\n let bounds = image.extent', 1) (folder / 'Encoder.swift').write_text(encoder) library = root / 'native/artifacts/NativeUIPng.xcframework/ios-arm64_x86_64-simulator/libnative_ui_png.a' - run('xcrun', '--sdk', 'iphonesimulator', 'swiftc', '-O', '-parse-as-library', '-target', platform.machine() + '-apple-ios15.0-simulator', '-module-cache-path', folder / 'modules', '-I', root / 'native/include', root / 'tests/integration/image-reuse/main.swift', folder / 'Capture.swift', folder / 'Encoder.swift', library, '-o', app / 'Pipeline') + run('xcrun', '--sdk', 'iphonesimulator', 'swiftc', '-O', '-parse-as-library', '-target', platform.machine() + '-apple-ios15.0-simulator', '-module-cache-path', folder / 'modules', '-I', root / 'native/include', root / 'tests/integration/image-reuse/main.swift', root / 'ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift', folder / 'Capture.swift', folder / 'Encoder.swift', library, '-o', app / 'Pipeline') plist = {'CFBundleIdentifier': 'local.native.pipeline', 'CFBundleExecutable': 'Pipeline', 'CFBundleName': 'Pipeline', 'CFBundlePackageType': 'APPL', 'MinimumOSVersion': '15.0', 'UILaunchScreen': {}, 'UIApplicationSceneManifest': {'UIApplicationSupportsMultipleScenes': False, 'UISceneConfigurations': {'UIWindowSceneSessionRoleApplication': [{'UISceneConfigurationName': 'Default', 'UISceneDelegateClassName': 'Pipeline.Scene'}]}}} (app / 'Info.plist').write_bytes(plistlib.dumps(plist)) run('xcrun', 'simctl', 'install', args.device, app) From ae055fdfb03e0bca0cd5084627fe2a31c70bf5f6 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Tue, 10 Nov 2026 23:36:32 +0900 Subject: [PATCH 8/9] docs: clarify renderer-owned capture readiness boundaries --- docs/advanced/native-handoff.md | 11 ++++++----- .../WebViewNativeUIEngine/CaptureReadiness.swift | 2 ++ .../CoveredSnapshotsTests.swift | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/advanced/native-handoff.md b/docs/advanced/native-handoff.md index 832f10d..2a641f6 100644 --- a/docs/advanced/native-handoff.md +++ b/docs/advanced/native-handoff.md @@ -127,19 +127,20 @@ decide whether a caller is a Plain button, Glass control or another renderer. Only caller-declared settled view captures use one matching geometry sample instead of three. Coordinates and source traits are checked again before publishing fresh -pixels. Missing information, composited capture, Glass, and unsettled state retain -the standard checks. Active touches still invalidate preparation. This does not +pixels. Missing information, composited capture and caller-declared unsettled state +retain the standard checks. Active touches still invalidate preparation. This does not change `captureDelayMs`, paint acknowledgements, or handoff deadlines. -Controls supplies this proof for settled, standalone Plain buttons without scroll -bindings. A tap or keyboard/accessibility activation keeps its source View conservative; +The Controls renderer owns component classification and supplies this proof only +for settled, standalone Plain buttons without scroll bindings. It withholds the +proof for Glass; Engine does not inspect or classify the material. A tap or keyboard/accessibility activation keeps its source View conservative; a snapshot ACK alone cannot prove UIKit's press animation has completed. This history follows reassociated Views without retaining retired components. A new View starts clean; capturing another source does not clear an interaction. Shared surfaces, unknown input targets, active animations and unresolved interactions remain conservative. Other component types retain their existing behavior. -Controls and Engine are released at the same version and use the typed readiness +Controls in this package and Engine are released at the same version and use the typed readiness contract directly. The optional public callback is an integration boundary for independent renderers; its omission is normalized to unsettled readiness before entering the capture scheduler. The caller must return `isSettled: false` while any relevant state is unknown, diff --git a/ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift b/ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift index 3a3191a..3ebe21d 100644 --- a/ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift +++ b/ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift @@ -4,6 +4,8 @@ import Foundation /// The caller decides whether its effects, layout and interaction state are settled. /// Engine validates geometry and capture lifetime without classifying components. public struct NativeUICaptureReadiness { + /// The renderer must return false whenever any requested source or revision is uncertain. + /// Engine does not infer this value from the source views or their component types. public let isSettled: Bool public init(isSettled: Bool = false) { self.isSettled = isSettled } var stableSamples: Int { isSettled ? 1 : 3 } diff --git a/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift b/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift index 0184577..f7f4d5d 100644 --- a/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift +++ b/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift @@ -43,7 +43,7 @@ final class NativeUICoveredSnapshotsTests: XCTestCase { let control = CountingSnapshotView(frame: CGRect(x: 40, y: 100, width: 60, height: 44)) web.addSubview(control); window.layoutIfNeeded() defer { window.isHidden = true } - for mode in ["legacy", "ready", "glass", "layout", "appearance", "revoked", "flapped", "late-revoked", "late-trait", "composited"] { + for mode in ["legacy", "ready", "unsettled", "revoked", "flapped", "late-revoked", "late-trait", "composited"] { let snapshots = NativeUICoveredSnapshots() var reads = 0 var proofs = 0 @@ -57,7 +57,7 @@ final class NativeUICoveredSnapshotsTests: XCTestCase { if mode == "late-trait", proofs == 3 { control.testTraits = UITraitCollection(userInterfaceStyle: control.traitCollection.userInterfaceStyle == .dark ? .light : .dark) } - return .init(isSettled: mode != "glass" && mode != "layout" && mode != "appearance" && + return .init(isSettled: mode != "unsettled" && !(mode == "revoked" && proofs >= 2) && !(mode == "flapped" && proofs == 2) && !(mode == "late-revoked" && proofs >= 3)) } From d92829e60928210ca367eadd7e7adb2735bd5851 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Wed, 11 Nov 2026 00:27:09 +0900 Subject: [PATCH 9/9] fix(ios): limit capture changes to Controls drawing order --- .github/workflows/native-ui-engine.yml | 2 +- docs/advanced/native-handoff.md | 37 ------- .../Components/NativeUIButton.swift | 8 -- .../Components/NativeUIButtons.swift | 2 - .../Components/NativeUIFab.swift | 2 - .../NativeUICaptureState.swift | 13 --- .../NativeUIController.swift | 84 ++------------- .../CaptureReadiness.swift | 12 --- .../CoveredSnapshots.swift | 40 +++---- .../NativeUISnapshotTests.swift | 101 +++++------------- .../CoveredSnapshotsTests.swift | 93 ---------------- scripts/verify-image-reuse.py | 2 +- 12 files changed, 47 insertions(+), 349 deletions(-) delete mode 100644 ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift delete mode 100644 ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift diff --git a/.github/workflows/native-ui-engine.yml b/.github/workflows/native-ui-engine.yml index 64c1806..8cab36a 100644 --- a/.github/workflows/native-ui-engine.yml +++ b/.github/workflows/native-ui-engine.yml @@ -69,7 +69,7 @@ jobs: brew list xcodegen >/dev/null 2>&1 || brew install xcodegen cd ios/Tests xcodegen generate - xcodebuild -project NativeUIControlsHostedTests.xcodeproj -scheme NativeUIControlsHostedTests -destination "platform=iOS Simulator,id=$CORDOVA_SIMULATOR" CODE_SIGNING_ALLOWED=NO -only-testing:WebViewNativeUIControlsTests/NativeUISnapshotTests/testNativePopoverClosureNotifiesWebBeforeReopening -only-testing:WebViewNativeUIControlsTests/NativeUISnapshotTests/testPopoverReopensAfterPresentationIdentityKeepsReplacedSource -only-testing:WebViewNativeUIControlsTests/NativeUISnapshotTests/testControllerReadinessTracksActualOwnersAndCaptureAcknowledgements test + xcodebuild -project NativeUIControlsHostedTests.xcodeproj -scheme NativeUIControlsHostedTests -destination "platform=iOS Simulator,id=$CORDOVA_SIMULATOR" CODE_SIGNING_ALLOWED=NO -only-testing:WebViewNativeUIControlsTests/NativeUISnapshotTests/testNativePopoverClosureNotifiesWebBeforeReopening -only-testing:WebViewNativeUIControlsTests/NativeUISnapshotTests/testPopoverReopensAfterPresentationIdentityKeepsReplacedSource -only-testing:WebViewNativeUIControlsTests/NativeUISnapshotTests/testViewCapturePreservesNativeSourceOrder test - name: Verify capture-to-Web image reuse on a real simulator screen run: python3 scripts/verify-image-reuse.py --device "$CORDOVA_SIMULATOR" - name: Verify npm-distributed CocoaPod diff --git a/docs/advanced/native-handoff.md b/docs/advanced/native-handoff.md index 2a641f6..14215ff 100644 --- a/docs/advanced/native-handoff.md +++ b/docs/advanced/native-handoff.md @@ -115,40 +115,3 @@ Native replies acknowledge reusable pixels with `reused: true`. Only then may Web reuse its decoded image for the same URL and destination; other replies keep the existing load/decode behavior. Tokens own separate leases on shared files. Continue releasing retired tokens and reset the manager on owner teardown. - -### Renderer-owned capture readiness (iOS) - -A native renderer may supply the optional `readiness` callback to `handle`, or to -`prepare` with explicit source views. Return `NativeUICaptureReadiness` for the -requested revision and source set: `NativeUICaptureReadiness(isSettled: true)` -asserts that the caller has verified its effects, layout and interaction state. -The Engine receives no component kind or effect classification; it does not -decide whether a caller is a Plain button, Glass control or another renderer. - -Only caller-declared settled view captures use one matching geometry sample instead -of three. Coordinates and source traits are checked again before publishing fresh -pixels. Missing information, composited capture and caller-declared unsettled state -retain the standard checks. Active touches still invalidate preparation. This does not -change `captureDelayMs`, paint acknowledgements, or handoff deadlines. - -The Controls renderer owns component classification and supplies this proof only -for settled, standalone Plain buttons without scroll bindings. It withholds the -proof for Glass; Engine does not inspect or classify the material. A tap or keyboard/accessibility activation keeps its source View conservative; -a snapshot ACK alone cannot prove UIKit's press animation has completed. This -history follows reassociated Views without retaining retired components. A new -View starts clean; capturing another source does not clear an interaction. Shared surfaces, unknown -input targets, active animations and unresolved interactions -remain conservative. Other component types retain their existing behavior. - -Controls in this package and Engine are released at the same version and use the typed readiness -contract directly. The optional public callback is an integration boundary for -independent renderers; its omission is normalized to unsettled readiness before -entering the capture scheduler. The caller must return `isSettled: false` while any relevant state is unknown, -and revoke its proof if that state changes during capture. Engine still validates -geometry, source traits and capture lifetime independently. - -Existing integrations do not need to add this callback. An explicit Web -`captureDelayMs` (including a delay for Glass selection animations) remains -unchanged: readiness neither bypasses nor overrides that scheduling delay. -Native settling happens after it, and no animation-completion signal is inferred -from the elapsed delay. diff --git a/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift b/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift index debd925..49fb2fb 100644 --- a/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift +++ b/ios/Sources/WebViewNativeUIControls/Components/NativeUIButton.swift @@ -1,4 +1,3 @@ -import WebViewNativeUIEngine import UIKit enum NativeUIButton { @@ -33,13 +32,6 @@ enum NativeUIButton { // These Ionic components share the same native UIButton presentation. static let kinds: [NativeUIComponent] = [.button, .backButton, .menuButton] - // The component declares the same material choice used by make(). - static func captureEffect(_ node: NativeUIControl) -> NativeUICaptureEffect { - guard kinds.contains(node.kind), node.placement == nil, node.scroll == nil, node.items.count == 1, - let item = node.items.first else { return .unknown } - return item.content.buttonFill == .clear ? .independent : .backdrop - } - @available(iOS 26.0, *) static func make(_ node: NativeUIControl, existing: UIButton? = nil, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) -> UIButton? { guard node.items.count == 1, let item = node.items.first else { return nil } diff --git a/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift b/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift index d8116f0..1e3b6c8 100644 --- a/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift +++ b/ios/Sources/WebViewNativeUIControls/Components/NativeUIButtons.swift @@ -1,9 +1,7 @@ -import WebViewNativeUIEngine import UIKit enum NativeUIButtons { static let kind = NativeUIComponent.buttons - static let captureEffect = NativeUICaptureEffect.backdrop @available(iOS 26.0, *) static func make(_ node: NativeUIControl, scale: CGFloat, rendering: NativeUIControlRendering, activate: @escaping (String) -> Void) -> UIView { diff --git a/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift b/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift index dc350c8..77cda7e 100644 --- a/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift +++ b/ios/Sources/WebViewNativeUIControls/Components/NativeUIFab.swift @@ -1,9 +1,7 @@ -import WebViewNativeUIEngine import UIKit final class NativeUIFab: UIView { static let kind = NativeUIComponent.fab - static let captureEffect = NativeUICaptureEffect.backdrop var buttons: [String: UIButton] = [:] var content: [String: NativeUIItemContent] = [:] override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { diff --git a/ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift b/ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift deleted file mode 100644 index cfbdc7c..0000000 --- a/ios/Sources/WebViewNativeUIControls/NativeUICaptureState.swift +++ /dev/null @@ -1,13 +0,0 @@ -import UIKit - -// Component effects belong to the renderer, not the capture engine. -enum NativeUICaptureEffect { case independent, backdrop, unknown } - -// A capture ACK does not prove that UIKit's press animation finished. -// Track the actual source weakly, so reassociation keeps its interaction state -// while a genuinely new component starts clean without retaining old views. -final class NativeUICaptureState { - private let dirty = NSHashTable.weakObjects() - func interacted(_ views: [UIView]) { views.forEach(dirty.add) } - func appearanceSettled(_ views: [UIView]) -> Bool { views.allSatisfy { !dirty.contains($0) } } -} diff --git a/ios/Sources/WebViewNativeUIControls/NativeUIController.swift b/ios/Sources/WebViewNativeUIControls/NativeUIController.swift index 38ca4e6..65562ce 100644 --- a/ios/Sources/WebViewNativeUIControls/NativeUIController.swift +++ b/ios/Sources/WebViewNativeUIControls/NativeUIController.swift @@ -23,9 +23,6 @@ public final class NativeUIController: NSObject, UITabBarDelegate { super.init() } private let coveredSnapshots = NativeUICoveredSnapshots() - private let captureState = NativeUICaptureState() - private var captureLayoutRevision: Int? - private weak var captureObservationWindow: UIWindow? private var snapshotControls: [NativeUIControl] = [] private var overlays: NativeUIOverlayController? private var toolbarTransition: NativeUIToolbarTransition? @@ -256,69 +253,18 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } } - private func observeCaptureInteractions() { - guard let window = webView?.window else { return } - if captureObservationWindow !== window { - captureState.interacted(Array(controls.values) + (verticalBars.map { [$0.view] } ?? [])) - captureObservationWindow = window - } - coveredSnapshots.observe(window, interacted: { [weak self] touched in - guard let self else { return } - self.recordCaptureInteraction(touched) - }) { [weak self] active in - self?.notifyListeners("snapshotInteraction", ["active": active]) - } - } - - func recordCaptureInteraction(_ touched: UIView?) { - let all = Array(controls.values) + (verticalBars.map { [$0.view] } ?? []) - guard let touched else { captureState.interacted(all); return } - if let rail = verticalBars, touched.isDescendant(of: rail.view) { - captureState.interacted([rail.view]); return - } - let owners = controls.values.filter { touched.isDescendant(of: $0) } - captureState.interacted(owners.isEmpty ? all : Array(owners)) - } - - func captureReadiness(_ ids: Set, revision: Int) -> NativeUICaptureReadiness { - guard revision == self.revision, captureLayoutRevision == revision, - captureObservationWindow === webView?.window, webView?.window != nil, - !keyboardVisible, toolbarTransition == nil else { return .init() } - let nodes = snapshotControls.filter { ids.contains($0.id) } - guard !ids.isEmpty, nodes.count == ids.count else { return .init() } - let effects = nodes.map { node -> NativeUICaptureEffect in - if node.kind == NativeUIButtons.kind { return NativeUIButtons.captureEffect } - if node.kind == NativeUIFab.kind { return NativeUIFab.captureEffect } - return NativeUIButton.captureEffect(node) - } - let effect: NativeUICaptureEffect = effects.allSatisfy { $0 == .independent } ? .independent : - effects.contains(.backdrop) ? .backdrop : .unknown - // update() acknowledges our explicit, nonanimated layout transaction. - // UIKit may dirty internal layout flags while drawing without changing - // that transaction; the Engine separately rechecks actual frames. - let settled = nodes.allSatisfy { node in - guard let button = controls[node.id] as? UIButton, !button.isHighlighted, !button.isTracking, - button.window != nil, button.layer.mask == nil else { return false } - var view: UIView? = button - while let current = view { - if current.isHidden || current.alpha <= 0 || !(current.layer.animationKeys() ?? []).isEmpty { return false } - view = current.superview - } - return true - } - return .init(isSettled: effect == .independent && settled && - captureState.appearanceSettled(nodes.compactMap { controls[$0.id] })) - } - private func resetCoveredSnapshots() { coveredSnapshots.reset() - captureLayoutRevision = nil } public func coveredSnapshot(_ options: [String: Any], resolve: @escaping ([String: Any]) -> Void, reject: @escaping (String) -> Void) { DispatchQueue.main.async { [weak self] in guard let self, let webView = self.webView else { reject("WebView unavailable"); return } - if (options["action"] as? String) == "prepare" { self.observeCaptureInteractions() } + if (options["action"] as? String) == "prepare", let window = webView.window { + self.coveredSnapshots.observe(window) { [weak self] active in + self?.notifyListeners("snapshotInteraction", ["active": active]) + } + } let measure: (Set, Int) -> [String: CGRect] = { [weak self, weak webView] ids, revision in guard let self, let webView, revision == self.revision, !self.keyboardVisible, self.toolbarTransition == nil else { return [:] } @@ -360,16 +306,11 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } let selected = Array(self.controls.filter { ids.contains($0.key) }.values) var views = selected - // Partial Dictionary selection must not randomize the shared - // raster's draw order or turn unchanged sources into cache misses. - if let ordered = self.host?.subviews.filter({ selected.contains($0) }), ordered.count == selected.count { - views = ordered - } + if let ordered = self.host?.subviews.filter({ selected.contains($0) }), ordered.count == selected.count { views = ordered } if !railIDs.isEmpty, ids.isSuperset(of: railIDs), let rail = self.verticalBars { views.append(rail.view) } return views } - self.coveredSnapshots.handle(options, in: webView, frames: measure, views: views, - readiness: self.captureReadiness, complete: { resolve($0) }) + self.coveredSnapshots.handle(options, in: webView, frames: measure, views: views, complete: { resolve($0) }) } } @@ -519,13 +460,11 @@ public final class NativeUIController: NSObject, UITabBarDelegate { guard let webView = self.webView, let parent = webView.superview else { reject("WebView unavailable"); return } - self.observeCaptureInteractions() // Decode and validate the entire batch before changing visible controls. guard JSONSerialization.isValidJSONObject(options), let snapshot = try? JSONDecoder().decode(NativeUISnapshot.self, from: JSONSerialization.data(withJSONObject: options)), snapshot.isValid else { reject("Invalid control snapshot"); return } - self.captureLayoutRevision = nil self.coveredSnapshots.restoreControls() if snapshot.coveredSnapshotToken == nil && self.coveredSnapshots.covering { _ = self.coveredSnapshots.restore(in: webView) @@ -827,9 +766,6 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } self.snapshotControls = snapshot.controls.filter { !rejectedControls.contains($0.id) } let complete = { - if self.revision == next { - self.captureLayoutRevision = next - } var result: [String: Any] = ["revision": next, "rejectedSearches": rejectedSearches, "rejectedControls": rejectedControls, "tabBarWidths": tabBarWidths] if let transition = self.toolbarTransition { result["toolbarTransition"] = transition.id } resolve(result) @@ -860,12 +796,6 @@ public final class NativeUIController: NSObject, UITabBarDelegate { } private func activate(_ id: String) { - // Keyboard/VoiceOver activation can bypass UITouch delivery. - let owners = snapshotControls.filter { $0.id == id || $0.items.contains { $0.id == id } } - let sources = owners.compactMap { controls[$0.id] ?? ($0.placement == .verticalBars ? verticalBars?.view : nil) } - if sources.isEmpty { recordCaptureInteraction(nil) } else { - sources.forEach { recordCaptureInteraction($0) } - } sequence += 1 notifyListeners("activate", ["id": id, "revision": revision, "sequence": sequence]) diff --git a/ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift b/ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift deleted file mode 100644 index 3ebe21d..0000000 --- a/ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Foundation - -/// Caller-owned proof for a particular capture revision and source set. -/// The caller decides whether its effects, layout and interaction state are settled. -/// Engine validates geometry and capture lifetime without classifying components. -public struct NativeUICaptureReadiness { - /// The renderer must return false whenever any requested source or revision is uncertain. - /// Engine does not infer this value from the source views or their component types. - public let isSettled: Bool - public init(isSettled: Bool = false) { self.isSettled = isSettled } - var stableSamples: Int { isSettled ? 1 : 3 } -} diff --git a/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift b/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift index f7d0cb2..4b339a7 100644 --- a/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +++ b/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift @@ -78,8 +78,7 @@ public final class NativeUICoveredSnapshots { public func handle(_ request: [String: Any], in webView: UIView, frames: @escaping (Set, Int) -> [String: CGRect], anchorFrames: (() -> [String: CGRect])? = nil, - views: (Set) -> [UIView]?, - readiness: ((Set, Int) -> NativeUICaptureReadiness)? = nil, complete: @escaping ([String: Any]) -> Void) { + views: (Set) -> [UIView]?, complete: @escaping ([String: Any]) -> Void) { let token = request["token"] as? String ?? "" switch request["action"] as? String { case "prepare": @@ -90,7 +89,6 @@ public final class NativeUICoveredSnapshots { let firstFrames = frames(expected, revision) guard Set(firstFrames.keys) == expected else { complete([:]); return } let viewCapture = request["captureMode"] as? String == "view" - let readinessProvider = readiness ?? { _, _ in NativeUICaptureReadiness() } let selected = viewCapture ? views(expected) : nil guard !viewCapture || selected?.isEmpty == false else { complete([:]); return } var initial: [String: CGRect]? = firstFrames @@ -98,8 +96,7 @@ public final class NativeUICoveredSnapshots { let measured = initial ?? frames(expected, revision) initial = nil return Set(measured.keys) == expected ? measured : [:] - }, in: webView, viewportWidth: width, views: selected, anchorFrames: anchorFrames, contentRevision: viewCapture ? revision : nil, - readiness: { readinessProvider(expected, revision) }, complete: { result in + }, in: webView, viewportWidth: width, views: selected, anchorFrames: anchorFrames, contentRevision: viewCapture ? revision : nil, complete: { result in if let token = result["token"] as? String { self.captures[token]?.preservesLayout = request["imageSurface"] as? String == "web" } complete(result) }) @@ -145,11 +142,11 @@ public final class NativeUICoveredSnapshots { public var covering: Bool { coveringToken != nil } - public func observe(_ window: UIWindow, interacted: ((UIView?) -> Void)? = nil, changed: @escaping (Bool) -> Void) { + public func observe(_ window: UIWindow, changed: @escaping (Bool) -> Void) { guard self.window !== window else { return } if let observer { self.window?.removeGestureRecognizer(observer) } self.window = window - let observer = SnapshotTouchObserver(interacted: interacted) { [weak self] active in + let observer = SnapshotTouchObserver { [weak self] active in guard let self else { return } self.touching = active if active { self.generation += 1 } @@ -162,29 +159,22 @@ public final class NativeUICoveredSnapshots { // Gesture release can keep changing SwiftUI button bounds after touchesEnded. // Wait off the presentation path for unchanged rendered geometry, not a guessed delay. public func prepare(frames: @escaping () -> [String: CGRect], in webView: UIView, viewportWidth: CGFloat, - views: [UIView]? = nil, anchorFrames: (() -> [String: CGRect])? = nil, readiness: (() -> NativeUICaptureReadiness)? = nil, - complete: @escaping ([String: Any]) -> Void) { + views: [UIView]? = nil, anchorFrames: (() -> [String: CGRect])? = nil, complete: @escaping ([String: Any]) -> Void) { prepareCapture(frames: frames, in: webView, viewportWidth: viewportWidth, views: views, - anchorFrames: anchorFrames, contentRevision: nil, readiness: readiness ?? { .init() }, complete: complete) + anchorFrames: anchorFrames, contentRevision: nil, complete: complete) } private func prepareCapture(frames: @escaping () -> [String: CGRect], in webView: UIView, viewportWidth: CGFloat, views: [UIView]?, anchorFrames: (() -> [String: CGRect])?, contentRevision: Int?, - readiness: @escaping () -> NativeUICaptureReadiness, complete: @escaping ([String: Any]) -> Void) { + complete: @escaping ([String: Any]) -> Void) { let stamp = generation let start = CACurrentMediaTime() - var requiredSamples = 1 func sample(_ previous: [String: CGRect], _ stable: Int) { guard generation == stamp, !touching, CACurrentMediaTime() - start < 1 else { complete([:]); return } let current = frames() let count = !current.isEmpty && current == previous ? stable + 1 : 0 - // Recheck the renderer's proof on every sample; a revoked proof falls back. - let currentRequirement = views?.isEmpty == false ? readiness().stableSamples : 3 - requiredSamples = max(requiredSamples, currentRequirement) - if count >= requiredSamples { - capture(frames: current, anchorFrames: anchorFrames?() ?? [:], in: webView, viewportWidth: viewportWidth, views: views, contentRevision: contentRevision, - stillReady: requiredSamples == 1 ? { readiness().stableSamples == 1 && frames() == current } : nil, - complete: complete) + if count >= 3 { + capture(frames: current, anchorFrames: anchorFrames?() ?? [:], in: webView, viewportWidth: viewportWidth, views: views, contentRevision: contentRevision, complete: complete) } else { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0 / 60.0) { sample(current, count) } } @@ -193,7 +183,7 @@ public final class NativeUICoveredSnapshots { } private func capture(frames: [String: CGRect], anchorFrames: [String: CGRect], in webView: UIView, viewportWidth: CGFloat, - views: [UIView]?, contentRevision: Int?, stillReady: (() -> Bool)? = nil, complete: @escaping ([String: Any]) -> Void) { + views: [UIView]?, contentRevision: Int?, complete: @escaping ([String: Any]) -> Void) { guard !touching, let window = webView.window, !frames.isEmpty, viewportWidth > 0, webView.bounds.width > 0 else { complete([:]); return } let stamp = generation @@ -336,8 +326,7 @@ public final class NativeUICoveredSnapshots { parts = [] } DispatchQueue.main.async { - guard let self, self.generation == stamp, !self.touching, stillReady?() != false, - stillReady == nil || zip(capturedViews ?? [], views ?? []).allSatisfy({ $0.0.matches($0.1) }), + guard let self, self.generation == stamp, !self.touching, window.bounds.size == size, webView.convert(webView.bounds, to: window) == webFrame, parts.count == crops.count else { parts.forEach { try? FileManager.default.removeItem(at: $0.file) } complete([:]); return @@ -491,12 +480,10 @@ public final class NativeUICoveredSnapshots { } // Observes the start of interaction without recognizing or delaying the app's gestures. -final class SnapshotTouchObserver: UIGestureRecognizer { +private final class SnapshotTouchObserver: UIGestureRecognizer { private let changed: (Bool) -> Void - private let interacted: ((UIView?) -> Void)? private var touches = Set() - init(interacted: ((UIView?) -> Void)?, changed: @escaping (Bool) -> Void) { - self.interacted = interacted + init(changed: @escaping (Bool) -> Void) { self.changed = changed super.init(target: nil, action: nil) cancelsTouchesInView = false @@ -506,7 +493,6 @@ final class SnapshotTouchObserver: UIGestureRecognizer { override func canPrevent(_ other: UIGestureRecognizer) -> Bool { false } override func canBePrevented(by other: UIGestureRecognizer) -> Bool { false } override func touchesBegan(_ touches: Set, with event: UIEvent) { - touches.forEach { interacted?($0.view) } let first = self.touches.isEmpty self.touches.formUnion(touches) if first { changed(true) } diff --git a/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift b/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift index 4f2386d..de452d9 100644 --- a/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift +++ b/ios/Tests/WebViewNativeUIControlsTests/NativeUISnapshotTests.swift @@ -1,29 +1,10 @@ import XCTest -import WebViewNativeUIEngine import WebKit import SwiftUI @testable import WebViewNativeUIControls final class NativeUISnapshotTests: XCTestCase { - @MainActor func testCaptureInteractionMatrixKeepsOtherOwnersDirty() { - let state = NativeUICaptureState() - let a = UIView(); let b = UIView() - XCTAssertTrue(state.appearanceSettled([b])) - state.interacted([a]) - XCTAssertFalse(state.appearanceSettled([a])) - XCTAssertTrue(state.appearanceSettled([b])) - state.interacted([b]) - XCTAssertFalse(state.appearanceSettled([a]), "A different capture cannot prove A's animation finished") - XCTAssertFalse(state.appearanceSettled([b])) - XCTAssertTrue(state.appearanceSettled([UIView()]), "A genuinely new source has no old interaction") - weak var released: UIView? - autoreleasepool { - let temporary = UIView(); released = temporary; state.interacted([temporary]) - } - XCTAssertNil(released, "Interaction history must not retain retired components") - } - - @MainActor func testControllerReadinessTracksActualOwnersAndCaptureAcknowledgements() async throws { + @MainActor func testViewCapturePreservesNativeSourceOrder() async throws { guard UIApplication.shared.delegate != nil else { throw XCTSkip("Requires hosted UIApplication") } guard #available(iOS 26.0, *) else { throw XCTSkip("Requires native controls") } let window = UIWindow(frame: UIScreen.main.bounds) @@ -31,9 +12,9 @@ final class NativeUISnapshotTests: XCTestCase { let web = WKWebView(frame: window.bounds); owner.view.addSubview(web) let controller = NativeUIController(webView: web, viewController: owner, emit: { _, _ in }, hasListeners: { _ in false }, refresh: { _ in }) defer { window.isHidden = true } - let nodes = [control(["id": "a", "items": [item(["id": "a-action", "label": "A", "buttonFill": "clear", "transitionId": "readiness-a"])]]), - control(["id": "b", "x": 90.0, "items": [item(["id": "b-action", "label": "B", "buttonFill": "clear"])]]), - control(["id": "glass", "x": 160.0, "items": [item(["id": "glass-action", "label": "Glass"]) ]])] + let nodes = [control(["id": "a", "items": [item(["id": "a-action", "label": "Shared", "buttonFill": "clear", "color": "rgb(255, 0, 0)"])]]), + control(["id": "b", "items": [item(["id": "b-action", "label": "Shared", "buttonFill": "clear", "color": "rgb(0, 0, 255)"])]]), + control(["id": "c", "x": 160.0])] let applied: [String: Any] = await withCheckedContinuation { continuation in controller.update(["revision": 1, "viewportWidth": Double(web.bounds.width), "controls": nodes], resolve: { continuation.resume(returning: $0) }, reject: { continuation.resume(returning: ["error": $0]) }) @@ -41,64 +22,32 @@ final class NativeUISnapshotTests: XCTestCase { XCTAssertNil(applied["error"]) window.layoutIfNeeded() func buttons(_ view: UIView) -> [UIButton] { (view as? UIButton).map { [$0] } ?? view.subviews.flatMap(buttons) } - let button = try XCTUnwrap(buttons(owner.view).first { $0.configuration?.title == "A" }) - XCTAssertTrue(controller.captureReadiness(["a"], revision: 1).isSettled) - XCTAssertFalse(controller.captureReadiness(["glass"], revision: 1).isSettled) - let other = try XCTUnwrap(buttons(owner.view).first { $0.configuration?.title == "B" }) - other.layer.mask = CALayer() - XCTAssertFalse(controller.captureReadiness(["b"], revision: 1).isSettled, "Concealed pixels cannot declare readiness") - other.layer.mask = nil - - button.sendActions(for: .touchUpInside) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled, "UIAction without UITouch must invalidate its owner") - XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).isSettled) - // A real UIAction must invalidate readiness even without a UITouch. - controller.recordCaptureInteraction(button.titleLabel ?? button) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled) - XCTAssertTrue(controller.captureReadiness(["b"], revision: 1).isSettled) - func capture(_ ids: [String]) async -> [String: Any] { + let button = try XCTUnwrap(buttons(owner.view).first { $0.accessibilityIdentifier == "a-action" }) + func capture() async -> [String: Any] { await withCheckedContinuation { continuation in - controller.coveredSnapshot(["action": "prepare", "ids": ids, "revision": 1, "captureMode": "view", "viewportWidth": Double(web.bounds.width)], + controller.coveredSnapshot(["action": "prepare", "ids": ["a", "b"], "revision": 1, "captureMode": "view", "viewportWidth": Double(web.bounds.width)], resolve: { continuation.resume(returning: $0) }, reject: { continuation.resume(returning: ["error": $0]) }) } } - let pair = await capture(["a", "b"]) - XCTAssertNotNil(pair["token"]) - let samePair = await capture(["a", "b"]) - XCTAssertEqual(samePair["reused"] as? Bool, true, "Partial selection must keep native drawing order") - button.superview?.bringSubviewToFront(button) - let reorderedPair = await capture(["a", "b"]) - XCTAssertNotEqual(reorderedPair["reused"] as? Bool, true, "A real native z-order change must recapture") - button.superview?.insertSubview(button, at: 0) - let bResult = await capture(["b"]) - XCTAssertNotNil(bResult["token"]) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled) - let aResult = await capture(["a"]) - XCTAssertNotNil(aResult["token"]) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled, "Snapshot completion is not animation completion") - controller.recordCaptureInteraction(nil) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 1).isSettled) - XCTAssertFalse(controller.captureReadiness(["b"], revision: 1).isSettled) - XCTAssertFalse(controller.captureReadiness(["a"], revision: 0).isSettled) - var rebound = nodes - rebound[0]["id"] = "renamed" - _ = await withCheckedContinuation { continuation in - controller.update(["revision": 2, "viewportWidth": Double(web.bounds.width), "controls": rebound], - resolve: { continuation.resume(returning: $0) }, reject: { _ in continuation.resume(returning: [:]) }) + func pixels(_ result: [String: Any]) throws -> Data { + let parts = try XCTUnwrap(result["parts"] as? [[String: Any]]) + let path = try XCTUnwrap(parts.first?["url"] as? String) + let image = try XCTUnwrap(UIImage(data: Data(contentsOf: XCTUnwrap(URL(string: path))))?.cgImage) + return try XCTUnwrap(image.dataProvider?.data) as Data } - window.layoutIfNeeded() - XCTAssertTrue(buttons(owner.view).contains { $0 === button }, "The source must actually be reassociated") - XCTAssertFalse(controller.captureReadiness(["renamed"], revision: 2).isSettled, "ID reassociation must not forget an unsettled interaction") - _ = await withCheckedContinuation { continuation in controller.clear(["revision": 3], resolve: { continuation.resume(returning: $0) }, reject: { _ in continuation.resume(returning: [:]) }) } - } - - @MainActor func testCaptureEffectsAreDeclaredByComponentIncludingOuterGlass() throws { - let plain = try XCTUnwrap(decode([control(["kind": "ion-button", "items": [item(["buttonFill": "clear"])]])]).controls.first) - let glass = try XCTUnwrap(decode([control(["kind": "ion-button", "items": [item() ]])]).controls.first) - XCTAssertEqual(NativeUIButton.captureEffect(plain), .independent) - XCTAssertEqual(NativeUIButton.captureEffect(glass), .backdrop) - XCTAssertEqual(NativeUIButtons.captureEffect, .backdrop, "Plain children do not remove outer Glass") - XCTAssertEqual(NativeUIFab.captureEffect, .backdrop) + func stablePixels() async throws -> Data { + let first = await capture() + let expected = try pixels(first) + for _ in 0..<4 { + let repeated = await capture() + XCTAssertEqual(try pixels(repeated), expected, "Repeated handoffs must keep the native drawing order") + } + return expected + } + let before = try await stablePixels() + button.superview?.bringSubviewToFront(button) + let after = try await stablePixels() + XCTAssertNotEqual(after, before, "Overlapping controls must use their current native drawing order") } @MainActor func testMeasuredTabWidthTracksNativeContentWithAndWithoutSearch() async throws { diff --git a/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift b/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift index f7f4d5d..26c7c18 100644 --- a/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift +++ b/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift @@ -5,90 +5,6 @@ import UIKit @testable import WebViewNativeUIEngine final class NativeUICoveredSnapshotsTests: XCTestCase { - @MainActor func testTouchObserverReportsEveryOwnerAndWaitsForAllTouchesOnCancel() { - var active: [Bool] = [] - var owners: [UIView?] = [] - let observer = SnapshotTouchObserver(interacted: { owners.append($0) }, changed: { active.append($0) }) - let a = ReadinessTestTouch(); a.source = UIView() - let b = ReadinessTestTouch(); b.source = UIView() - let unknown = ReadinessTestTouch() - let event = UIEvent() - observer.touchesBegan([a], with: event) - observer.touchesBegan([b, unknown], with: event) - XCTAssertEqual(active, [true]) - XCTAssertEqual(owners.count, 3, "Additional fingers and unknown owners must also invalidate readiness") - XCTAssertTrue(owners.contains { $0 === a.source }) - XCTAssertTrue(owners.contains { $0 === b.source }) - XCTAssertTrue(owners.contains { $0 == nil }) - observer.touchesEnded([a], with: event) - XCTAssertEqual(active, [true], "One released finger does not settle the interaction") - observer.touchesCancelled([b, unknown], with: event) - XCTAssertEqual(active, [true, false]) - XCTAssertFalse(observer.cancelsTouchesInView) - XCTAssertFalse(observer.delaysTouchesBegan) - XCTAssertFalse(observer.delaysTouchesEnded) - } - - func testReadinessUsesCallerDeclaration() { - XCTAssertEqual(NativeUICaptureReadiness().stableSamples, 3) - XCTAssertEqual(NativeUICaptureReadiness(isSettled: false).stableSamples, 3) - XCTAssertEqual(NativeUICaptureReadiness(isSettled: true).stableSamples, 1) - } - - @MainActor func testReadyCaptureUsesFewerSamplesAndRechecksBeforePublication() async throws { - let window = SnapshotTestWindow(frame: UIScreen.main.bounds) - window.rootViewController = UIViewController(); window.makeKeyAndVisible() - let web = try XCTUnwrap(window.rootViewController?.view) - web.frame = window.bounds - let control = CountingSnapshotView(frame: CGRect(x: 40, y: 100, width: 60, height: 44)) - web.addSubview(control); window.layoutIfNeeded() - defer { window.isHidden = true } - for mode in ["legacy", "ready", "unsettled", "revoked", "flapped", "late-revoked", "late-trait", "composited"] { - let snapshots = NativeUICoveredSnapshots() - var reads = 0 - var proofs = 0 - var readsAtRaster = 0 - control.beforeDraw = { readsAtRaster = reads } - var request: [String: Any] = ["action": "prepare", "ids": ["control"], "revision": 1, - "viewportWidth": Double(web.bounds.width), "captureMode": "view"] - if mode == "composited" { request.removeValue(forKey: "captureMode") } - let provider: ((Set, Int) -> NativeUICaptureReadiness)? = mode == "legacy" ? nil : { _, _ in - proofs += 1 - if mode == "late-trait", proofs == 3 { - control.testTraits = UITraitCollection(userInterfaceStyle: control.traitCollection.userInterfaceStyle == .dark ? .light : .dark) - } - return .init(isSettled: mode != "unsettled" && - !(mode == "revoked" && proofs >= 2) && !(mode == "flapped" && proofs == 2) && - !(mode == "late-revoked" && proofs >= 3)) - } - let result: [String: Any] = await withCheckedContinuation { continuation in - snapshots.handle(request, in: web, frames: { _, _ in reads += 1; return ["control": control.frame] }, - views: { _ in [control] }, readiness: provider) { continuation.resume(returning: $0) } - } - if mode != "composited" { - XCTAssertEqual(readsAtRaster, ["ready", "late-revoked", "late-trait"].contains(mode) ? 2 : 4, - "An uncertain request cannot upgrade back to the short path: \(mode)") - } - if mode == "late-revoked" || mode == "late-trait" { - XCTAssertNil(result["token"], "Revoked proof must not publish or cache provisional pixels") - let retry: [String: Any] = await withCheckedContinuation { continuation in - snapshots.handle(request, in: web, frames: { _, _ in ["control": control.frame] }, - views: { _ in [control] }) { continuation.resume(returning: $0) } - } - XCTAssertNotNil(retry["token"]) - XCTAssertNotEqual(retry["reused"] as? Bool, true) - let part = try XCTUnwrap((retry["parts"] as? [[String: Any]])?.first) - let file = try XCTUnwrap(URL(string: try XCTUnwrap(part["url"] as? String))) - let files = try FileManager.default.contentsOfDirectory(at: file.deletingLastPathComponent(), includingPropertiesForKeys: nil) - XCTAssertEqual(files.filter { $0.pathExtension == "png" }.count, 1, "Rejected provisional files must be removed") - } else { - XCTAssertNotNil(result["token"], mode) - XCTAssertEqual(reads, mode == "ready" ? 3 : 4, mode) - } - snapshots.reset() - } - } - @MainActor func testOverlappingRootsInvalidateRelativeMovementButReuseCommonTranslation() async throws { let window = SnapshotTestWindow(frame: UIScreen.main.bounds) window.rootViewController = UIViewController() @@ -651,21 +567,12 @@ private final class TransparentSnapshotTestView: UIView { } private final class CountingSnapshotView: UIView { - var testTraits: UITraitCollection? - var beforeDraw: (() -> Void)? - override var traitCollection: UITraitCollection { testTraits ?? super.traitCollection } var draws = 0 var color = UIColor.red override func drawHierarchy(in rect: CGRect, afterScreenUpdates: Bool) -> Bool { - beforeDraw?() draws += 1 color.setFill() UIRectFill(rect) return true } } - -private final class ReadinessTestTouch: UITouch { - var source: UIView? - override var view: UIView? { source } -} diff --git a/scripts/verify-image-reuse.py b/scripts/verify-image-reuse.py index 7943bd4..16300cb 100644 --- a/scripts/verify-image-reuse.py +++ b/scripts/verify-image-reuse.py @@ -30,7 +30,7 @@ def run(*cmd): encoder = encoder.replace(' let bounds = image.extent', ' pipelineCounterLock.lock(); pngEncodes += 1; pipelineCounterLock.unlock()\n let bounds = image.extent', 1) (folder / 'Encoder.swift').write_text(encoder) library = root / 'native/artifacts/NativeUIPng.xcframework/ios-arm64_x86_64-simulator/libnative_ui_png.a' - run('xcrun', '--sdk', 'iphonesimulator', 'swiftc', '-O', '-parse-as-library', '-target', platform.machine() + '-apple-ios15.0-simulator', '-module-cache-path', folder / 'modules', '-I', root / 'native/include', root / 'tests/integration/image-reuse/main.swift', root / 'ios/Sources/WebViewNativeUIEngine/CaptureReadiness.swift', folder / 'Capture.swift', folder / 'Encoder.swift', library, '-o', app / 'Pipeline') + run('xcrun', '--sdk', 'iphonesimulator', 'swiftc', '-O', '-parse-as-library', '-target', platform.machine() + '-apple-ios15.0-simulator', '-module-cache-path', folder / 'modules', '-I', root / 'native/include', root / 'tests/integration/image-reuse/main.swift', folder / 'Capture.swift', folder / 'Encoder.swift', library, '-o', app / 'Pipeline') plist = {'CFBundleIdentifier': 'local.native.pipeline', 'CFBundleExecutable': 'Pipeline', 'CFBundleName': 'Pipeline', 'CFBundlePackageType': 'APPL', 'MinimumOSVersion': '15.0', 'UILaunchScreen': {}, 'UIApplicationSceneManifest': {'UIApplicationSupportsMultipleScenes': False, 'UISceneConfigurations': {'UIWindowSceneSessionRoleApplication': [{'UISceneConfigurationName': 'Default', 'UISceneDelegateClassName': 'Pipeline.Scene'}]}}} (app / 'Info.plist').write_bytes(plistlib.dumps(plist)) run('xcrun', 'simctl', 'install', args.device, app)