diff --git a/android/src/androidTest/java/io/github/webview_native_ui/engine/NativeUIEngineTest.kt b/android/src/androidTest/java/io/github/webview_native_ui/engine/NativeUIEngineTest.kt index b8a4114..545df2a 100644 --- a/android/src/androidTest/java/io/github/webview_native_ui/engine/NativeUIEngineTest.kt +++ b/android/src/androidTest/java/io/github/webview_native_ui/engine/NativeUIEngineTest.kt @@ -219,9 +219,28 @@ class NativeUIEngineTest { .put("imageSurface", "web") ) val webToken = webCapture.getString("token") - val root = activity.window.decorView as android.view.ViewGroup - var before = 0 - main { before = root.childCount } + fun assertHandoffPixels(message: String, visible: Boolean) = main { + assertEquals(View.INVISIBLE, native.visibility) + val root = activity.window.decorView + val pixels = + android.graphics.Bitmap.createBitmap( + root.width, + root.height, + android.graphics.Bitmap.Config.ARGB_8888, + ) + root.draw(android.graphics.Canvas(pixels)) + val origin = IntArray(2) + val rootOrigin = IntArray(2) + activity.web.getLocationInWindow(origin) + root.getLocationInWindow(rootOrigin) + val color = + pixels.getPixel( + origin[0] - rootOrigin[0] + 100, + origin[1] - rootOrigin[1] + 150, + ) + pixels.recycle() + assertEquals(message, visible, color == Color.RED) + } assertTrue( send( JSONObject() @@ -231,14 +250,9 @@ class NativeUIEngineTest { ) .getBoolean("shown") ) - main { - assertEquals(View.INVISIBLE, native.visibility) - assertEquals( - "Web surface must not install a fixed native entry image", - before, - root.childCount, - ) - } + assertHandoffPixels("Entry image bridges the cover acknowledgement", true) + send(JSONObject().put("action", "painted").put("token", webToken)) + assertHandoffPixels("Painted acknowledgement retires the entry image", false) assertTrue( send( JSONObject() @@ -249,12 +263,10 @@ class NativeUIEngineTest { ) .getBoolean("shown") ) + assertHandoffPixels("Return image bridges the restore acknowledgement", true) + send(JSONObject().put("action", "restored").put("token", webToken)) + assertHandoffPixels("Restored acknowledgement retires the return image", false) main { - assertEquals( - "Web surface must not install a fixed native return image", - before, - root.childCount, - ) snapshots.restoreViews() assertEquals(View.VISIBLE, native.visibility) } diff --git a/android/src/main/java/io/github/webview_native_ui/engine/NativeUISnapshots.kt b/android/src/main/java/io/github/webview_native_ui/engine/NativeUISnapshots.kt index 0bf7f44..a3128bb 100644 --- a/android/src/main/java/io/github/webview_native_ui/engine/NativeUISnapshots.kt +++ b/android/src/main/java/io/github/webview_native_ui/engine/NativeUISnapshots.kt @@ -36,7 +36,6 @@ class NativeUISnapshots(private val directory: File) : AutoCloseable { val referenceWidth: Int, val referenceHeight: Int, val parts: List, - var webSurface: Boolean = false, ) private data class Copy( @@ -134,11 +133,7 @@ class NativeUISnapshots(private val directory: File) : AutoCloseable { initial = null measured.takeIf { it.keys == expected } ?: emptyMap() }, - complete = { result -> - captures[result.optString("token")]?.webSurface = - request.optString("imageSurface") == "web" - respond(result) - }, + complete = respond, ) } "cover" -> { @@ -546,11 +541,7 @@ class NativeUISnapshots(private val directory: File) : AutoCloseable { // Composite pixels cover both the live translucent view and its Web replacement. // Install the entry copy before changing native visibility in the same UI turn. expiredEntries.remove(token) - if (captures[token]?.webSurface == true) { - val capture = valid(token, window, reference) ?: return false - if (frames != null && capture.parts.any { frames[it.id] == null }) return false - clearCopy() - } else if (!restore(token, window, reference, frames = frames, entry = true, clips = clips)) + if (!restore(token, window, reference, frames = frames, entry = true, clips = clips)) return false if (!transient) coveringToken = token hide(token, views) @@ -612,7 +603,6 @@ class NativeUISnapshots(private val directory: File) : AutoCloseable { ) return false clearCopy() - if (capture.webSurface) return true val root = window.decorView as? ViewGroup ?: return false val rootLocation = origin(root) val sourceLocation = origin(reference) diff --git a/docs/advanced/native-handoff.md b/docs/advanced/native-handoff.md index d630a23..56dcf8d 100644 --- a/docs/advanced/native-handoff.md +++ b/docs/advanced/native-handoff.md @@ -92,8 +92,8 @@ This uses WKWebView's internal overflow hierarchy without private class names. M A `prepare` request may specify `captureMode: 'view'` to draw only the renderer views returned by the view selector, preserving transparent pixels outside their material. The default remains composited window capture. Renderer-only capture can change or omit backdrop-dependent blur and refraction; it does not guarantee transparency inside a glass surface. Missing, hidden or detached renderer views reject preparation. Android and older native binaries keep their existing capture route. -Moving DOM surfaces can provide a fixed viewport transfer layer through `WebProjectionOptions.imageSurface` (`HTMLElement`). The Web projection marks its prepare requests with `imageSurface: 'web'`; these tokens do not install fixed Native image copies. During motion, the decoded DOM images live on their source surface so its transform carries them. Translation does not invalidate their reusable capture; dimensions are compared at display-pixel resolution, while actual size and source changes still invalidate it. +Moving DOM surfaces can provide a fixed viewport transfer layer through `WebProjectionOptions.imageSurface` (`HTMLElement`). The Web projection marks its prepare requests with `imageSurface: 'web'` so Native concealment preserves renderer layout. This changes placement, not the handoff protocol: every surface uses the same temporary Native entry and return copies. During motion, the decoded DOM images live on their source surface so its transform carries them. Translation does not invalidate their reusable capture; dimensions are compared at display-pixel resolution, while actual size and source changes still invalidate it. On iOS, each image part includes optional `anchorBounds` relative to the image crop, in viewport units. Renderers can supply `anchorFrames` for composite controls, such as the first actual toolbar item in a group. Moving targets use these bounds to retain capture padding without mixing capture-time Native coordinates with later DOM placement. -Keep the Native renderer and source ownership alive while DOM images own the pixels. For entry, the Engine temporarily places those same images in the transfer layer at the captured Native coordinates, waits for Web painting, covers Native pixels, and returns the images to their source surface. For return, first lay out Native controls while keeping their pixels concealed. Read their actual frames and call `prepareNativeReturn(frames, revision)`. When the frames are usable, the Engine parks the same DOM images there; otherwise, pass `undefined` and it keeps their current placement. Both paths set `nativeReady` after Web painting. Missing alignment is not a failure of an accepted Native renderer. Reveal Native controls in the following update, then call `updated(token, ids, revision)` on its accepted acknowledgement to retire the DOM images. Only a revision newer than the staged layout can retire them; an acknowledgement for the layout stage must not. `prepareNativeReturn` ignores obsolete measurements. The displayed image remains leased until the reveal acknowledgement or explicit retirement, independently of whether its cached capture is still reusable. No snapshot is captured during either transfer. +Keep the Native renderer and source ownership alive while DOM images own the pixels. For entry, the common handoff installs prepared Native pixels and conceals the live renderer in the same Native turn, then displays the DOM images on their source surface. During that transfer the DOM image shares the temporary Native copy’s viewport coordinates. After Web painting, the Engine retires the Native copy and returns the DOM image to its source surface. Live translucent controls never sample their own Web image during entry. The optional `layoutFrame(target, part, parts)` adapter supplies current Native anchor placement from the captured cohort; it does not own visibility or handoff timing. Return uses that placement to freeze both prepared copies at the same destination until the accepted Native update. The renderer applies its ordinary Native layout and calls `updated(token, ids, revision)` on the accepted acknowledgement. Only a revision newer than the return request can retire the DOM images. The displayed image remains leased until the reveal acknowledgement or explicit retirement, independently of whether its cached capture is still reusable. No snapshot is captured during either transfer. -On iOS, use `NativeUICoveredSnapshots.concealPixels(in:)` on the main thread when layout must remain measurable. It masks pixels and disables interaction/accessibility without changing `isHidden` or geometry. Its returned restoration closure is idempotent; overlapping leases restore the original mask and flags only after the last lease ends. The default composited handoff retains its existing Native copy and drawing-completion contract. +On iOS, use `NativeUICoveredSnapshots.concealPixels(in:)` on the main thread when layout must remain measurable. It masks pixels and disables interaction/accessibility without changing `isHidden` or geometry. Its returned restoration closure is idempotent; overlapping leases restore the original mask and flags only after the last lease ends. Both composited and renderer-only capture use the same Native copy and drawing-completion contract. diff --git a/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift b/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift index ee62003..46c17ed 100644 --- a/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift +++ b/ios/Sources/WebViewNativeUIEngine/CoveredSnapshots.swift @@ -8,6 +8,7 @@ public final class NativeUICoveredSnapshots { private struct Part { let ids: [String] let frame: CGRect + let anchorFrame: CGRect let image: UIImage let file: URL } @@ -15,7 +16,7 @@ public final class NativeUICoveredSnapshots { let size: CGSize let webFrame: CGRect let parts: [Part] - var webSurface = false + var preservesLayout = false } private final class PixelVisibility { let originalMask: CALayer? @@ -46,6 +47,7 @@ public final class NativeUICoveredSnapshots { /// A nil view selection rejects the handoff; an empty selection means the valid image has no live views to retire. 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) { let token = request["token"] as? String ?? "" switch request["action"] as? String { @@ -64,8 +66,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, complete: { result in - if let token = result["token"] as? String { self.captures[token]?.webSurface = request["imageSurface"] as? String == "web" } + }, in: webView, viewportWidth: width, views: selected, anchorFrames: anchorFrames, complete: { result in + if let token = result["token"] as? String { self.captures[token]?.preservesLayout = request["imageSurface"] as? String == "web" } complete(result) }) case "cover": @@ -127,7 +129,7 @@ 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, complete: @escaping ([String: Any]) -> Void) { + views: [UIView]? = nil, anchorFrames: (() -> [String: CGRect])? = nil, complete: @escaping ([String: Any]) -> Void) { let stamp = generation let start = CACurrentMediaTime() func sample(_ previous: [String: CGRect], _ stable: Int) { @@ -135,7 +137,7 @@ public final class NativeUICoveredSnapshots { let current = frames() let count = !current.isEmpty && current == previous ? stable + 1 : 0 if count >= 3 { - capture(frames: current, in: webView, viewportWidth: viewportWidth, views: views, complete: complete) + capture(frames: current, anchorFrames: anchorFrames?() ?? [:], in: webView, viewportWidth: viewportWidth, views: views, complete: complete) } else { DispatchQueue.main.asyncAfter(deadline: .now() + 1.0 / 60.0) { sample(current, count) } } @@ -143,7 +145,7 @@ public final class NativeUICoveredSnapshots { sample([:], 0) } - private func capture(frames: [String: CGRect], in webView: UIView, viewportWidth: CGFloat, + private func capture(frames: [String: CGRect], anchorFrames: [String: CGRect], in webView: UIView, viewportWidth: CGFloat, views: [UIView]?, complete: @escaping ([String: Any]) -> Void) { guard !touching, let window = webView.window, !frames.isEmpty, viewportWidth > 0, webView.bounds.width > 0 else { complete([:]); return } @@ -154,14 +156,18 @@ public final class NativeUICoveredSnapshots { let webFrame = webView.convert(webView.bounds, to: window) // Each crop belongs to one source component. Overlapping crops share // the same captured pixels and never combine unrelated components. - var regions: [(ids: [String], rect: CGRect)] = [] + var regions: [(ids: [String], rect: CGRect, anchorFrame: CGRect)] = [] for (id, frame) in frames.sorted(by: { $0.key < $1.key }) { var rect = webView.convert(frame, to: window).insetBy(dx: -12, dy: -12).intersection(window.bounds) guard !rect.isNull, rect.width > 0, rect.height > 0 else { complete([:]); return } rect = CGRect(x: floor(rect.minX * scale) / scale, y: floor(rect.minY * scale) / scale, width: (ceil(rect.maxX * scale) - floor(rect.minX * scale)) / scale, height: (ceil(rect.maxY * scale) - floor(rect.minY * scale)) / scale) - regions.append(([id], rect)) + let anchor = anchorFrames[id] ?? frame + guard [anchor.minX, anchor.minY, anchor.width, anchor.height].allSatisfy(\.isFinite), anchor.width > 0, anchor.height > 0 else { + complete([:]); return + } + regions.append(([id], rect, anchor)) } let bounds = regions.reduce(CGRect.null) { $0.union($1.rect) } // Preserve wide-gamut precision during capture. Encode to integer 16-bit @@ -199,11 +205,11 @@ public final class NativeUICoveredSnapshots { guard drawn, let pixels = context.makeImage() else { complete([:]); return } let token = UUID().uuidString let directory = self.directory - let crops = regions.compactMap { region -> (ids: [String], frame: CGRect, image: UIImage)? in + let crops = regions.compactMap { region -> (ids: [String], frame: CGRect, anchorFrame: CGRect, image: UIImage)? in let crop = CGRect(x: (region.rect.minX - bounds.minX) * scale, y: (region.rect.minY - bounds.minY) * scale, width: region.rect.width * scale, height: region.rect.height * scale) guard let part = pixels.cropping(to: crop) else { return nil } - return (region.ids, window.convert(region.rect, to: webView), UIImage(cgImage: part, scale: scale, orientation: .up)) + return (region.ids, window.convert(region.rect, to: webView), region.anchorFrame, UIImage(cgImage: part, scale: scale, orientation: .up)) } guard crops.count == regions.count else { complete([:]); return } let imageContext = self.imageContext @@ -224,7 +230,7 @@ public final class NativeUICoveredSnapshots { let image = UIImage(cgImage: decoded, scale: scale, orientation: .up) let file = directory.appendingPathComponent("\(token)-\(index).png") try png.write(to: file, options: .atomic) - parts.append(Part(ids: crop.ids, frame: crop.frame, image: image, file: file)) + parts.append(Part(ids: crop.ids, frame: crop.frame, anchorFrame: crop.anchorFrame, image: image, file: file)) } } catch { parts.forEach { try? FileManager.default.removeItem(at: $0.file) } @@ -240,7 +246,10 @@ public final class NativeUICoveredSnapshots { complete(["token": token, "parts": parts.map { part in ["ids": part.ids, "url": part.file.absoluteString, "x": part.frame.minX * ratio, "y": part.frame.minY * ratio, - "width": part.frame.width * ratio, "height": part.frame.height * ratio] as [String: Any] + "width": part.frame.width * ratio, "height": part.frame.height * ratio, + "anchorBounds": ["x": (part.anchorFrame.minX - part.frame.minX) * ratio, + "y": (part.anchorFrame.minY - part.frame.minY) * ratio, + "width": part.anchorFrame.width * ratio, "height": part.anchorFrame.height * ratio]] as [String: Any] }]) } } @@ -249,12 +258,7 @@ public final class NativeUICoveredSnapshots { /// Install prepared pixels before hiding live views, within the same main-thread turn. /// A transient (moving) surface does not replace background covering ownership. @discardableResult public func cover(_ token: String, in webView: UIView, views: [UIView], transient: Bool = false, frames: [String: CGRect]? = nil) -> Bool { - if captures[token]?.webSurface == true { - guard let capture = captures[token], let window = webView.window, - window.bounds.size == capture.size, webView.convert(webView.bounds, to: window) == capture.webFrame, - frames == nil || capture.parts.allSatisfy({ part in part.ids.allSatisfy { frames?[$0] != nil } }) else { return false } - clearRestoration() - } else if !restore(in: webView, token: token, frames: frames, entry: true) { return false } + if !restore(in: webView, token: token, frames: frames, entry: true) { return false } if !transient { coveringToken = token } hide(views, for: token) return true @@ -288,7 +292,7 @@ public final class NativeUICoveredSnapshots { generation += 1 restoreControls() hiddenControls = (token, views.map { view in - if captures[token]?.webSurface == true { return Self.concealPixels(in: view) } + if captures[token]?.preservesLayout == true { return Self.concealPixels(in: view) } let hidden = view.isHidden view.isHidden = true return { [weak view] in view?.isHidden = hidden } @@ -310,7 +314,6 @@ public final class NativeUICoveredSnapshots { window.bounds.size == capture.size, webView.convert(webView.bounds, to: window) == capture.webFrame else { return false } if let frames, capture.parts.contains(where: { part in !part.ids.allSatisfy { frames[$0] != nil } }) { return false } clearRestoration() - if capture.webSurface { return true } let views = capture.parts.map { part in let view = UIImageView(image: part.image) view.frame = webView.convert(frames?[part.ids[0]] ?? part.frame, to: window) diff --git a/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift b/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift index f3389ad..8a1da8f 100644 --- a/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift +++ b/ios/Tests/WebViewNativeUIEngineTests/CoveredSnapshotsTests.swift @@ -26,7 +26,7 @@ final class NativeUICoveredSnapshotsTests: XCTestCase { } } - @MainActor func testWebSurfaceHandoffUsesDOMPixelsWithoutFixedNativeCopies() async throws { + @MainActor func testMovingSurfaceSharesEntryAndReturnCovers() async throws { let window = SnapshotTestWindow(frame: UIScreen.main.bounds) window.rootViewController = UIViewController() window.makeKeyAndVisible() @@ -39,23 +39,30 @@ final class NativeUICoveredSnapshotsTests: XCTestCase { let result: [String: Any] = await withCheckedContinuation { continuation in snapshots.handle(["action": "prepare", "ids": ["button"], "revision": 1, "viewportWidth": Double(web.bounds.width), "imageSurface": "web"], in: web, - frames: { _, _ in ["button": CGRect(x: 20, y: 80, width: 48, height: 48)] }, + frames: { _, _ in ["button": CGRect(x: 20, y: 80, width: 60, height: 108)] }, + anchorFrames: { ["button": CGRect(x: 20, y: 80, width: 60, height: 40)] }, views: { _ in [control] }, complete: { continuation.resume(returning: $0) }) } + let packet = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONSerialization.data(withJSONObject: result)) as? [String: Any]) + let anchor = (packet["parts"] as? [[String: Any]])?.first?["anchorBounds"] as? [String: Double] + XCTAssertEqual(anchor, ["x": 12, "y": 12, "width": 60, "height": 40]) let token = try XCTUnwrap(result["token"] as? String) XCTAssertTrue(snapshots.cover(token, in: web, views: [control], transient: true)) XCTAssertFalse(control.isHidden) XCTAssertNotNil(control.layer.mask) XCTAssertFalse(control.isUserInteractionEnabled) XCTAssertTrue(control.accessibilityElementsHidden) + XCTAssertEqual(window.subviews.compactMap { $0 as? UIImageView }.count, 1) + snapshots.retireCopy(token, entry: true) XCTAssertTrue(window.subviews.compactMap { $0 as? UIImageView }.isEmpty) XCTAssertTrue(snapshots.restore(in: web, token: token)) - XCTAssertTrue(window.subviews.compactMap { $0 as? UIImageView }.isEmpty) + XCTAssertEqual(window.subviews.compactMap { $0 as? UIImageView }.count, 1) snapshots.restoreControls() XCTAssertFalse(control.isHidden) XCTAssertNil(control.layer.mask) XCTAssertTrue(control.isUserInteractionEnabled) XCTAssertFalse(control.accessibilityElementsHidden) + snapshots.retireCopy(token, entry: false) XCTAssertTrue(window.subviews.compactMap { $0 as? UIImageView }.isEmpty) } @@ -85,6 +92,7 @@ final class NativeUICoveredSnapshotsTests: XCTestCase { let token = try XCTUnwrap(capture["token"] as? String) let wire = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONSerialization.data(withJSONObject: capture)) as? [String: Any]) var part = try XCTUnwrap((wire["parts"] as? [[String: Any]])?.first) + XCTAssertEqual(part["anchorBounds"] as? [String: Double], ["x": 6, "y": 6, "width": 30, "height": 22]) part["y"] = (part["y"] as! Double) + 30 let request: [String: Any] = ["action": "cover", "token": token, "viewportWidth": Double(web.bounds.width / 2), "parts": [part]] var invalid = request diff --git a/src/controls/definitions.ts b/src/controls/definitions.ts index f42cd72..f4ade1e 100644 --- a/src/controls/definitions.ts +++ b/src/controls/definitions.ts @@ -280,7 +280,7 @@ export interface NativeUIConnectionState { /** @internal Prepared native pixels, served through Capacitor's local resource handler. */ export interface CoveredSnapshot { token: string; - parts: (Frame & { ids: string[]; url: string })[]; + parts: (Frame & { ids: string[]; url: string; anchorBounds?: Frame })[]; } export interface NativeUIPlugin { diff --git a/src/projection/web.ts b/src/projection/web.ts index 4ece5af..0273cb2 100644 --- a/src/projection/web.ts +++ b/src/projection/web.ts @@ -30,8 +30,14 @@ export interface WebProjectionOptions { current?(sources: HTMLElement[]): boolean; /** Temporary movement handoff: rebase return pixels to the current display DOM. */ transient?: boolean; - /** Keep moving-surface handoff pixels in their DOM, without fixed native image copies. */ + /** Keep moving-surface images in their DOM; temporary Native covers only bridge presentation acknowledgements. */ imageSurface?: HTMLElement; + /** Optional Native anchor layout; capture-cohort parts preserve component spacing without using later DOM coordinates. */ + layoutFrame?( + target: HTMLElement, + part: CoveredSnapshot['parts'][number], + parts: readonly CoveredSnapshot['parts'][number][], + ): Frame | undefined; /** Opaque full-view pixels map to the host border box and may paint beneath native. */ opaqueUnderlay?: boolean; } @@ -51,8 +57,20 @@ interface Prepared { /** One pixel handoff; requests and renderer source ownership remain independent. */ type ImageHandoff = - | { phase: 'covering' | 'image'; image: Prepared } - | { phase: 'restoring'; image: Prepared; visible: boolean; revision: number; nativeReady?: boolean; layoutRevision?: number }; + { phase: 'covering' | 'image'; image: Prepared } | { phase: 'restoring'; image: Prepared; visible: boolean; revision: number }; + +/** Map intrinsic crop padding to a destination anchor, independently of DOM placement. */ +const imageFrameAtAnchor = (part: CoveredSnapshot['parts'][number], anchor: Frame): Frame => { + const bounds = part.anchorBounds!; + const sx = anchor.width / bounds.width; + const sy = anchor.height / bounds.height; + return { + x: anchor.x - bounds.x * sx, + y: anchor.y - bounds.y * sy, + width: part.width * sx, + height: part.height * sy, + }; +}; const layout = ({ control: c }: Candidate, positionIndependent = false) => [ c.id, @@ -287,9 +305,9 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP let expired = false; const capture = call({ action: 'prepare', - ...(callbacks.imageSurface ? { imageSurface: 'web' as const } : {}), revision: callbacks.captureRevision?.() ?? revision, viewportWidth: win.innerWidth, + ...(callbacks.imageSurface ? { imageSurface: 'web' as const } : {}), ids: selected.map((item) => item.control.id), }); void capture @@ -313,7 +331,12 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP part.ids.some((id) => !ids.has(id)) || ![part.x, part.y, part.width, part.height].every(Number.isFinite) || part.width <= 0 || - part.height <= 0, + part.height <= 0 || + (part.anchorBounds !== undefined && + (!part.anchorBounds || + ![part.anchorBounds.x, part.anchorBounds.y, part.anchorBounds.width, part.anchorBounds.height].every(Number.isFinite) || + part.anchorBounds.width <= 0 || + part.anchorBounds.height <= 0)), ) ) return; @@ -491,14 +514,19 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP finishVisibilityTransitions(target); shadowHosts.delete(target); }; - const detach = () => { - presentation++; + // Every new presentation or retirement cancels the previous asynchronous work. + const resetPresentation = () => { win.cancelAnimationFrame(paintFrame); + win.clearTimeout(restoreTimeout); + paintFrame = 0; + handoffPending = false; + return ++presentation; + }; + const detach = () => { + resetPresentation(); for (const element of offsets.keys()) element.style.setProperty('opacity', '0', 'important'); unparkImages(); offsets.clear(); - paintFrame = 0; - handoffPending = false; clearPrepared(); handoff = undefined; rendered.forEach(clearTarget); @@ -515,7 +543,6 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP const previous = new Set([handoff?.image, cached]); detach(); cached = undefined; - win.clearTimeout(restoreTimeout); previous.forEach((item) => dispose(item, afterPaint)); callbacks.wake(); }; @@ -528,18 +555,14 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP return; } const image = handoff.image; - presentation++; - win.cancelAnimationFrame(paintFrame); - paintFrame = 0; - handoffPending = false; + resetPresentation(); rendered.forEach((target) => target.removeAttribute('data-native-ui-snapshot-source')); showImages(image, !!callbacks.opaqueUnderlay); - if (callbacks.imageSurface) { + if (parked.size) { unparkImages(); updateOffsets(); } handoff = undefined; - win.clearTimeout(restoreTimeout); // Retire only the temporary return copy. Keep the pre-interaction image // available until a replacement finishes, including rapid overlay reopening. // Native's update acknowledgement precedes WebKit's paint. Keep its return @@ -578,7 +601,6 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP for (const [property, value] of Object.entries({ left: frame.x, top: frame.y, width: frame.width, height: frame.height })) element.style.setProperty(property, `${value}px`, 'important'); }); - showImages(image, true); }; const unparkImages = () => { for (const [element, saved] of parked) { @@ -679,11 +701,20 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP for (const index of [...indices].reverse()) { const element = image.images[index]; const part = image.data.parts[index]; + // Moving targets own placement. Capture padding is intrinsic to the + // artwork; never subtract a later DOM position from an earlier capture. + const content = callbacks.imageSurface ? part.anchorBounds : undefined; + const destination = content && !image.frames[index] ? layoutFrame(target, part, image.data.parts) : undefined; + const intrinsic = content && (destination || !callbacks.layoutFrame) ? content : undefined; + const anchor = destination ? target.getBoundingClientRect() : box; + const viewportFrame = intrinsic + ? imageFrameAtAnchor(part, destination ?? { x: anchor.x, y: anchor.y, width: intrinsic.width, height: intrinsic.height }) + : part; const frame = image.frames[index] ?? { - x: callbacks.opaqueUnderlay ? 0 : part.x - box.x, - y: callbacks.opaqueUnderlay ? 0 : part.y - box.y, - width: part.width, - height: part.height, + x: callbacks.opaqueUnderlay ? 0 : viewportFrame.x - anchor.x, + y: callbacks.opaqueUnderlay ? 0 : viewportFrame.y - anchor.y, + width: viewportFrame.width, + height: viewportFrame.height, }; image.frames[index] = frame; preparedKeys.set(image.targets[index], key); @@ -706,6 +737,17 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP return true; }; + const layoutFrame = (target: HTMLElement, part: CoveredSnapshot['parts'][number], parts: CoveredSnapshot['parts']) => { + try { + const frame = callbacks.layoutFrame?.(target, part, parts); + return frame && [frame.x, frame.y, frame.width, frame.height].every(Number.isFinite) && frame.width > 0 && frame.height > 0 + ? frame + : undefined; + } catch { + return undefined; + } + }; + const currentParts = (image: Prepared) => image.images.map((element, index) => { const rect = element.getBoundingClientRect(); @@ -713,7 +755,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP }); const present = (force = false) => { - const returning = !!callbacks.imageSurface && restoring(); + const returning = restoring(); if (!returning) retireRestoration(); if (stopped || destination === 'none' || (handoff && !force && !returning)) return; // The active pixel lease outlives cache reuse eligibility. @@ -730,16 +772,12 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP if (returning) abandon(handoff?.image, false); return; } - if (returning) { - win.cancelAnimationFrame(paintFrame); - win.clearTimeout(restoreTimeout); - } // Reparenting is renderer-owned. Only that case needs a fresh coordinate conversion; // ordinary presentation keeps the prepared layout off the critical path. if (image.scopes.some((scope, index) => scope.element.parentNode !== image.parents[index])) render(); updateOffsets(); // A cached image may span multiple presentations; bridge replies may not. - const currentPresentation = ++presentation; + const currentPresentation = resetPresentation(); const isCurrent = () => presentation === currentPresentation; handoffPending = true; handoff = { phase: 'covering', image }; @@ -750,70 +788,62 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP showImages(image, imageVisible()); // Reconciliation may continue after one frame even if the optional bridge reply // is delayed. The ordered native transport processes cover before that update. - if (!callbacks.imageSurface) - paintFrame = win.requestAnimationFrame(() => { - paintFrame = 0; + paintFrame = win.requestAnimationFrame(() => { + paintFrame = 0; + if (!isCurrent()) return; + handoffPending = false; + callbacks.wake(); + }); + const entryParts = callbacks.transient ? currentParts(image) : image.data.parts; + void bounded( + call({ + action: 'cover', + token: image.data.token, + ...(callbacks.transient ? { transient: true, viewportWidth: win.innerWidth, parts: entryParts } : {}), + }), + ) + .then((result) => { if (!isCurrent()) return; handoffPending = false; - callbacks.wake(); - }); - const cover = () => - bounded( - call({ - action: 'cover', - token: image.data.token, - ...(callbacks.transient ? { transient: true, viewportWidth: win.innerWidth, parts: currentParts(image) } : {}), - }), - ) - .then((result) => { - if (!isCurrent()) return; - handoffPending = false; - if (result?.shown !== true) { - abandon(image, false); - return; - } - // Live translucent Native must be hidden before its composited Web image - // becomes visible. Native's prepared entry copy spans this bridge interval. - if (!restoring()) { - unparkImages(); - handoff = { phase: 'image', image }; - render(); - updateOffsets(); - } - if (callbacks.imageSurface) { - callbacks.wake(); - return; - } - win.cancelAnimationFrame(paintFrame); - // rAF callbacks run before paint. Cross a paint boundary before retiring - // Native's entry copy, keeping it over WebKit's compositing interval. + if (result?.shown !== true) { + abandon(image, false); + return; + } + // Live translucent Native must be hidden before its composited Web image + // becomes visible. Native's prepared entry copy spans this bridge interval. + if (!restoring()) { + unparkImages(); + handoff = { phase: 'image', image }; + render(); + updateOffsets(); + parkImages(image, entryParts); + } + win.cancelAnimationFrame(paintFrame); + // rAF callbacks run before paint. Cross a paint boundary before retiring + // Native's entry copy, keeping it over WebKit's compositing interval. + paintFrame = win.requestAnimationFrame(() => { paintFrame = win.requestAnimationFrame(() => { - paintFrame = win.requestAnimationFrame(() => { - paintFrame = 0; - if (!isCurrent()) return; - void call({ action: 'painted', token: image.data.token }) - .then((result) => { - if (isCurrent() && result?.shown === false) abandon(image, false); - }) - .catch(() => { - if (isCurrent()) abandon(image, false); - }); - }); + paintFrame = 0; + if (!isCurrent()) return; + void call({ action: 'painted', token: image.data.token }) + .then((result) => { + if (!isCurrent()) return; + if (result?.shown === false) abandon(image, false); + else if (!restoring()) { + unparkImages(); + updateOffsets(); + } + }) + .catch(() => { + if (isCurrent()) abandon(image, false); + }); }); - callbacks.wake(); - }) - .catch(() => { - if (isCurrent()) abandon(image, false); - }); - if (callbacks.imageSurface) { - parkImages(image, image.data.parts); - paintFrame = win.requestAnimationFrame(() => { - paintFrame = win.requestAnimationFrame(() => { - paintFrame = 0; - if (isCurrent()) void cover(); }); + callbacks.wake(); + }) + .catch(() => { + if (isCurrent()) abandon(image, false); }); - } else void cover(); }; const restore = () => { if (!handoff || stopped || restoring()) return; @@ -827,15 +857,20 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP return; } if (callbacks.blocked()) return; + unparkImages(); updateOffsets(); if (callbacks.transient) { const image = handoff.image; const targets = targetsFor(image); - if (targets.some((target) => !target?.isConnected)) { - abandon(image, false); - return; - } - const parts = currentParts(image); + const parts = currentParts(image).map((part, index) => { + const content = part.anchorBounds; + const target = targets[index]; + if (!content || !target) return part; + const frame = layoutFrame(target, image.data.parts[index], image.data.parts); + if (!frame) return part; + return { ...part, ...imageFrameAtAnchor(image.data.parts[index], frame) }; + }); + parkImages(image, parts); // Translation changes placement, not pixels. Keep the pre-interaction // image until its replacement is ready; resizing still invalidates it. if ( @@ -854,8 +889,7 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP image.data = { ...image.data, parts }; else cached = undefined; // Both requests use the same ordered bridge. Reprojection need not await this optional copy. - if (!callbacks.imageSurface) - void call({ action: 'restore', token: image.data.token, viewportWidth: win.innerWidth, parts }).catch(() => {}); + void call({ action: 'restore', token: image.data.token, viewportWidth: win.innerWidth, parts }).catch(() => {}); } handoff = { phase: 'restoring', @@ -863,59 +897,14 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP visible: handoff.phase === 'image', revision: callbacks.revision?.() ?? latest?.revision ?? 0, }; - restoreTimeout = win.setTimeout( - () => { - if (handoff?.phase === 'restoring') abandon(handoff.image, false); - }, - callbacks.imageSurface ? 2000 : 250, - ); - callbacks.wake(); - }; - // Alignment improves the visual transfer; it is not proof of Native ownership. - const returnParts = (image: Prepared, frames: Map | undefined): Frame[] | undefined => { - if (!frames) return; - const parts: Frame[] = []; - for (const part of image.data.parts) { - const rects = part.ids.map((id) => frames.get(id)); - if ( - rects.some( - (rect) => !rect || ![rect.x, rect.y, rect.width, rect.height].every(Number.isFinite) || rect.width <= 0 || rect.height <= 0, - ) - ) - return; - const values = rects as Frame[]; - const x = Math.min(...values.map((rect) => rect.x)); - const y = Math.min(...values.map((rect) => rect.y)); - const width = Math.max(...values.map((rect) => rect.x + rect.width)) - x; - const height = Math.max(...values.map((rect) => rect.y + rect.height)) - y; - parts.push({ x: x - (part.width - width) / 2, y: y - (part.height - height) / 2, width: part.width, height: part.height }); - } - return parts; - }; - const prepareNativeReturn = (frames: Map | undefined, revision: number) => { - // A delayed measurement belongs to its submitted layout, never a later return. - if (!callbacks.imageSurface || handoff?.phase !== 'restoring' || handoff.nativeReady || revision <= handoff.revision) return; - const image = handoff.image; const returning = handoff; - returning.layoutRevision = revision; - const parts = returnParts(image, frames); - if (parts) { - parkImages(image, parts); - image.data = { ...image.data, parts: image.data.parts.map((part, index) => ({ ...part, ...parts[index] })) }; - } - win.cancelAnimationFrame(paintFrame); - paintFrame = win.requestAnimationFrame(() => { - paintFrame = win.requestAnimationFrame(() => { - paintFrame = 0; - if (handoff !== returning) return; - returning.nativeReady = true; - callbacks.wake(); - }); - }); + restoreTimeout = win.setTimeout(() => { + if (handoff === returning) abandon(returning.image, false); + }, 250); + callbacks.wake(); }; const updated = (token: string | undefined, restoredIDs?: string[], revision?: number) => { if (handoff?.phase !== 'restoring' || revision === undefined || revision <= handoff.revision) return; - if (callbacks.imageSurface && (!handoff.nativeReady || revision <= (handoff.layoutRevision ?? Infinity))) return; if (!callbacks.transient && token) return; if (!handoff.image.data.parts.every((part) => part.ids.every((id) => restoredIDs?.includes(id)))) return; // Retire the Web image at the native restore acknowledgement, not after @@ -1029,15 +1018,11 @@ const createProjectionEngine = async (scope: WebProjectionScope, transport: WebP get restoring() { return restoring(); }, - get nativeReady() { - return handoff?.phase === 'restoring' && !!handoff.nativeReady; - }, get handoffPending() { return handoffPending; }, retireRestoration, end, - prepareNativeReturn, updated, invalidate, async destroy() { @@ -1101,9 +1086,6 @@ export const createWebProjectionBatch = async ( get restoring() { return engine?.restoring ?? false; }, - get nativeReady() { - return engine?.nativeReady ?? false; - }, get handoffPending() { return engine?.handoffPending ?? false; }, @@ -1152,7 +1134,6 @@ export const createWebProjectionBatch = async ( pendingRequest = false; engine?.end(); }, - prepareNativeReturn: (frames, revision) => engine?.prepareNativeReturn(frames, revision), updated: (token, ids, revision) => engine?.updated(token, ids, revision), retireRestoration: () => engine?.retireRestoration(), invalidate: (nativeHidden?: boolean) => engine?.invalidate(nativeHidden), diff --git a/src/types.ts b/src/types.ts index d63fa62..d726094 100644 --- a/src/types.ts +++ b/src/types.ts @@ -32,7 +32,8 @@ export interface ScopedWebProjectionSource extends WebProjectionSource { } export interface PreparedWebProjection { token: string; - parts: (Frame & { ids: string[]; url: string })[]; + /** anchorBounds locates the renderer’s layout anchor within the image crop, in viewport units. */ + parts: (Frame & { ids: string[]; url: string; anchorBounds?: Frame })[]; } /** Each action carries only the fields required by that phase of handoff. */ export type WebProjectionRequest = diff --git a/tests/controls/native-ui-covered-snapshot.spec.ts b/tests/controls/native-ui-covered-snapshot.spec.ts index cb22cf4..a0523c0 100644 --- a/tests/controls/native-ui-covered-snapshot.spec.ts +++ b/tests/controls/native-ui-covered-snapshot.spec.ts @@ -283,7 +283,7 @@ test('invalidation during restoration does not strand the next prepared image', f.manager.begin(); expect(f.manager.token).toBe('image-2'); }); -test.each([false, true])('another overlay retires only the return copy (acknowledged: %s)', async (acknowledged) => { +test.each([false, true])('reopening retires only accepted returns (acknowledged: %s)', async (acknowledged) => { const f = await fixture(); f.manager.consider(1, [f.candidate]); await drain(); @@ -295,7 +295,10 @@ test.each([false, true])('another overlay retires only the return copy (acknowle } f.manager.begin(); expect(f.manager.token).toBe('image-1'); - expect(f.bridge).toHaveBeenCalledWith({ action: 'restored', token: 'image-1' }); + if (acknowledged) expect(f.bridge).toHaveBeenCalledWith({ action: 'restored', token: 'image-1' }); + else expect(f.bridge).not.toHaveBeenCalledWith({ action: 'restored', token: 'image-1' }); + expect(f.bridge.mock.calls.filter(([request]) => request.action === 'cover')).toHaveLength(2); + expect(f.bridge).not.toHaveBeenCalledWith({ action: 'release', token: 'image-1' }); }); test('a stalled native restoration releases the image and Web ownership promptly', async () => { diff --git a/tests/projection/web.spec.ts b/tests/projection/web.spec.ts index 02dd788..ffdd100 100644 --- a/tests/projection/web.spec.ts +++ b/tests/projection/web.spec.ts @@ -250,31 +250,34 @@ test('returning images follow host motion until native acknowledges restoration' ).toBe(0); }); -test('prepared pixels appear without a new capture; only matching restore ACK retires them', async ({ page }) => { - await prepare(page); - const ownership = () => - page.evaluate(() => { - const manager = (window as any).manager; - return [manager.owners.length, manager.visibleOwners.length, manager.preparedOwners.length]; +for (const moving of [false, true]) { + test(`prepared pixels persist until matching restore ACK (moving surface: ${moving})`, async ({ page }) => { + await prepare(page, false, false, false, false, 50, moving, moving); + const ownership = () => + page.evaluate(() => { + const manager = (window as any).manager; + return [manager.owners.length, manager.visibleOwners.length, manager.preparedOwners.length]; + }); + expect(await ownership()).toEqual([0, 0, 1]); + await page.evaluate(() => (window as any).manager.begin()); + await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot-visible', ''); + expect(await ownership()).toEqual([1, 1, 1]); + expect(await page.evaluate(() => getComputedStyle(document.querySelector('img[data-native-ui-projection-image]')!).opacity)).toBe('1'); + expect(await page.evaluate(() => (window as any).calls.filter((c: any) => c.action === 'prepare').length)).toBe(1); + await page.evaluate(() => { + const w = window as any; + w.manager.end(); + w.manager.updated(undefined, ['counter'], 1); }); - expect(await ownership()).toEqual([0, 0, 1]); - await page.evaluate(() => (window as any).manager.begin()); - await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot-visible', ''); - expect(await ownership()).toEqual([1, 1, 1]); - expect(await page.evaluate(() => getComputedStyle(document.querySelector('#source > img')!).opacity)).toBe('1'); - expect(await page.evaluate(() => (window as any).calls.filter((c: any) => c.action === 'prepare').length)).toBe(1); - await page.evaluate(() => { - const w = window as any; - w.manager.end(); - w.manager.updated(undefined, ['counter'], 1); + await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot-visible', ''); + expect(await ownership()).toEqual([0, 1, 1]); + await page.evaluate(() => (window as any).manager.updated(undefined, ['counter'], 2)); + await expect(page.locator('#source')).not.toHaveAttribute('data-native-ui-snapshot-visible'); + await expect(page.locator('button')).toBeVisible(); + expect(await ownership()).toEqual([0, 0, 1]); + if (moving) expect(await page.evaluate(() => (window as any).calls.filter((c: any) => c.action === 'restore').length)).toBe(1); }); - await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot-visible', ''); - expect(await ownership()).toEqual([0, 1, 1]); - await page.evaluate(() => (window as any).manager.updated(undefined, ['counter'], 2)); - await expect(page.locator('#source')).not.toHaveAttribute('data-native-ui-snapshot-visible'); - await expect(page.locator('button')).toBeVisible(); - expect(await ownership()).toEqual([0, 0, 1]); -}); +} for (const related of [true, false]) test(`image geometry invalidation is owner-scoped (${related})`, async ({ page }) => { await prepare(page); @@ -439,15 +442,17 @@ test('identical native tokens stay isolated across plugin scopes and teardown', expect(await page.evaluate(() => document.querySelectorAll('style[data-native-ui-snapshot-base]').length)).toBe(0); }); -for (const shared of [false, true]) - test(`delayed cover never exposes pixels before Native is hidden (${shared})`, async ({ page }) => { - await prepare(page, false, false, shared); +for (const surface of ['element', 'shared', 'moving'] as const) + test(`delayed cover never exposes pixels before Native is hidden (${surface})`, async ({ page }) => { + const shared = surface === 'shared'; + await prepare(page, false, false, shared, false, 50, surface === 'moving', surface === 'moving'); const host = page.locator(shared ? '#projection-host' : '#source'); await page.evaluate(() => { const w = window as any; w.holdCover = true; w.manager.begin(); }); + expect(await page.evaluate(() => (window as any).calls.filter((call: any) => call.action === 'cover').length)).toBe(1); await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))); await expect(host).not.toHaveAttribute('data-native-ui-snapshot-visible'); expect( @@ -1587,36 +1592,6 @@ for (const localFirst of [true, false]) { }); } -test('moving DOM handoff retains pixels through layout staging and retires after the accepted reveal', async ({ page }) => { - await prepare(page, false, false, false, false, 50, true, true); - await page.evaluate(() => { - const w = window as any; - w.interact({ active: true }); - w.manager.begin(); - }); - await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot-visible', ''); - await page.evaluate(() => (window as any).manager.end()); - await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot-visible', ''); - await page.evaluate(() => { - const w = window as any; - w.manager.updated(undefined, ['counter'], 2); - }); - await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot-visible', ''); - await page.evaluate(() => - (window as any).manager.prepareNativeReturn(new Map([['counter', { x: 8, y: 108, width: 120, height: 40 }]]), 2), - ); - await expect.poll(() => page.evaluate(() => (window as any).manager.nativeReady)).toBe(true); - await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot-visible', ''); - expect(await page.evaluate(() => (window as any).manager.restoring)).toBe(true); - await page.evaluate(() => (window as any).manager.updated(undefined, ['counter'], 2)); - expect(await page.evaluate(() => (window as any).manager.restoring)).toBe(true); - await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot-visible', ''); - await page.evaluate(() => (window as any).manager.updated(undefined, ['counter'], 3)); - expect(await page.evaluate(() => (window as any).manager.restoring)).toBe(false); - expect(await page.evaluate(() => (window as any).calls.filter((c: any) => ['restore', 'painted'].includes(c.action)))).toEqual([]); - await destroyProjection(page); -}); - test('moving DOM handoff ignores an old native acknowledgement after immediate reversal', async ({ page }) => { await prepare(page, false, false, false, false, 50, true, true); await page.evaluate(() => { @@ -1628,7 +1603,6 @@ test('moving DOM handoff ignores an old native acknowledgement after immediate r await page.evaluate(() => { const w = window as any; w.manager.end(); - w.manager.prepareNativeReturn(new Map([['counter', { x: 8, y: 108, width: 120, height: 40 }]]), 2); w.manager.begin(); }); await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot-visible', ''); @@ -1638,7 +1612,7 @@ test('moving DOM handoff ignores an old native acknowledgement after immediate r await destroyProjection(page); }); -test('a delayed Native layout measurement cannot fail or complete a later return', async ({ page }) => { +test('an acknowledgement from an earlier return cannot complete a later return', async ({ page }) => { await prepare(page, false, false, false, false, 50, true, true); await page.evaluate(() => { const w = window as any; @@ -1653,19 +1627,17 @@ test('a delayed Native layout measurement cannot fail or complete a later return w.manager.begin(); }); await expect.poll(() => page.evaluate(() => (window as any).manager.restoring)).toBe(false); - await page.evaluate(() => (window as any).manager.end()); - await page.evaluate(() => (window as any).manager.prepareNativeReturn(new Map(), 2)); - expect(await page.evaluate(() => (window as any).manager.nativeReady)).toBe(false); + await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot-visible', ''); + await page.evaluate(() => { + const w = window as any; + w.manager.end(); + w.manager.updated(undefined, ['counter'], 2); + }); expect(await page.evaluate(() => (window as any).manager.restoring)).toBe(true); await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot-visible', ''); - await page.evaluate(() => - (window as any).manager.prepareNativeReturn(new Map([['counter', { x: 8, y: 108, width: 120, height: 40 }]]), 4), - ); - await expect.poll(() => page.evaluate(() => (window as any).manager.nativeReady)).toBe(true); await page.evaluate(() => (window as any).manager.updated(undefined, ['counter'], 4)); - expect(await page.evaluate(() => (window as any).manager.restoring)).toBe(true); - await page.evaluate(() => (window as any).manager.updated(undefined, ['counter'], 5)); expect(await page.evaluate(() => (window as any).manager.restoring)).toBe(false); + await expect(page.locator('#source')).not.toHaveAttribute('data-native-ui-snapshot-visible', ''); await destroyProjection(page); }); @@ -1693,9 +1665,7 @@ test('a resized active image survives immediate reversal after its reusable cach await page.evaluate(() => { const w = window as any; w.manager.end(); - w.manager.prepareNativeReturn(undefined, 2); }); - await expect.poll(() => page.evaluate(() => (window as any).manager.nativeReady)).toBe(true); await page.evaluate(() => (window as any).manager.updated(undefined, ['counter'], 3)); expect(await page.evaluate(() => (window as any).manager.owners.length)).toBe(0); await expect(page.locator('#source')).not.toHaveAttribute('data-native-ui-snapshot-visible', ''); @@ -1732,35 +1702,6 @@ test('an invalid source ends both its visible image and pixel lease on immediate await destroyProjection(page); }); -for (const geometry of ['missing', 'incomplete'] as const) - test(`a ${geometry} alignment measurement cannot strand the accepted Native return`, async ({ page }) => { - await prepare(page, false, false, false, false, 50, true, true); - await page.evaluate(() => { - const w = window as any; - w.interact({ active: true }); - w.manager.begin(); - }); - await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot-visible', ''); - await page.evaluate((geometry) => { - const w = window as any; - w.manager.end(); - w.manager.prepareNativeReturn(geometry === 'missing' ? undefined : new Map(), 2); - w.manager.updated(undefined, ['counter'], 3); - }, geometry); - expect(await page.evaluate(() => (window as any).manager.restoring)).toBe(true); - await expect.poll(() => page.evaluate(() => (window as any).manager.nativeReady)).toBe(true); - await page.evaluate(() => (window as any).manager.updated(undefined, ['counter'], 2)); - expect(await page.evaluate(() => (window as any).manager.restoring)).toBe(true); - await page.evaluate(() => (window as any).manager.updated(undefined, ['counter'], 3)); - expect(await page.evaluate(() => (window as any).manager.owners.length)).toBe(0); - expect(await page.evaluate(() => (window as any).manager.restoring)).toBe(false); - await expect(page.locator('#source')).not.toHaveAttribute('data-native-ui-snapshot-visible', ''); - expect(await page.evaluate(() => (window as any).calls.filter((call: any) => ['restore', 'painted'].includes(call.action)))).toEqual( - [], - ); - await destroyProjection(page); - }); - for (const change of ['translation', 'subpixel-noise'] as const) { test(`a completed moving-surface return retains its cache through ${change} during the same gesture`, async ({ page }) => { await prepare(page, false, false, false, false, 50, true, true); @@ -1774,9 +1715,7 @@ for (const change of ['translation', 'subpixel-noise'] as const) { await page.evaluate(() => { const w = window as any; w.manager.end(); - w.manager.prepareNativeReturn(new Map([['counter', { x: 8, y: 108, width: 120, height: 40 }]]), 2); }); - await expect.poll(() => page.evaluate(() => (window as any).manager.nativeReady)).toBe(true); await page.evaluate((change) => { const w = window as any; w.manager.updated(undefined, ['counter'], 3); @@ -1798,9 +1737,7 @@ for (const change of ['translation', 'subpixel-noise'] as const) { await page.evaluate(() => { const w = window as any; w.manager.end(); - w.manager.prepareNativeReturn(undefined, 5); }); - await expect.poll(() => page.evaluate(() => (window as any).manager.nativeReady)).toBe(true); await page.evaluate(() => { const w = window as any; w.manager.updated(undefined, ['counter'], 6); @@ -1813,3 +1750,113 @@ for (const change of ['translation', 'subpixel-noise'] as const) { await destroyProjection(page); }); } + +for (const height of [40, 0]) { + test(`capture padding stays relative to the moving target (target height: ${height})`, async ({ page }) => { + await prepare(page); + await page.evaluate(async (height) => { + const w = window as any; + await w.manager.destroy(); + const source = document.querySelector('#source')!; + source.style.height = `${height}px`; + w.manager = await w.createProjection( + source, + 'padding', + async (request: any) => { + if (request.action === 'prepare') + return { + token: 'padding', + parts: [ + { + ids: ['counter'], + url: 'file:///image.png', + x: 330, + y: 345, + width: 72, + height: 72, + anchorBounds: { x: 12, y: 12, width: 48, height: 48 }, + }, + ], + }; + return { shown: true }; + }, + { transient: true, imageSurface: document.body }, + ); + w.manager.consider(2, [w.source]); + }, height); + await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot', /.+/); + const offset = await page.evaluate(() => { + const source = document.querySelector('#source')!; + const image = source.querySelector('img')!; + return { + x: image.getBoundingClientRect().x - source.getBoundingClientRect().x, + y: image.getBoundingClientRect().y - source.getBoundingClientRect().y, + }; + }); + expect(offset).toEqual({ x: -12, y: -12 }); + await destroyProjection(page); + }); +} + +test('layout placement freezes both return copies before the Native acknowledgement', async ({ page }) => { + await prepare(page); + await page.evaluate(async () => { + const w = window as any; + await w.manager.destroy(); + w.requests = []; + const source = document.querySelector('#source')!; + w.manager = await w.createProjection( + source, + 'layout', + async (request: any) => { + w.requests.push(request); + if (request.action === 'prepare') + return { + token: 'layout', + parts: [ + { + ids: ['counter'], + url: 'file:///image.png', + x: 330, + y: 345, + width: 72, + height: 72, + anchorBounds: { x: 12, y: 12, width: 48, height: 48 }, + }, + ], + }; + return { shown: true }; + }, + { + transient: true, + imageSurface: document.body, + layoutFrame: (_target: HTMLElement, part: any, parts: any[]) => ({ + x: 50, + y: 174 + part.y - parts[0].y, + width: 48, + height: 48, + }), + }, + ); + w.manager.consider(2, [w.source]); + }); + await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot', /.+/); + await page.evaluate(() => (window as any).manager.begin()); + await expect(page.locator('#source')).toHaveAttribute('data-native-ui-snapshot-visible', ''); + const transfer = await page.evaluate(() => { + const w = window as any; + w.manager.end(); + document.querySelector('#source')!.style.transform = 'translateY(-100px)'; + const rect = document.querySelector('img[data-native-ui-projection-image]')!.getBoundingClientRect(); + const part = w.requests.find((request: any) => request.action === 'restore').parts[0]; + return { + image: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + native: { x: part.x, y: part.y, width: part.width, height: part.height }, + }; + }); + expect(transfer.image).toEqual({ x: 38, y: 162, width: 72, height: 72 }); + expect(transfer.native).toEqual(transfer.image); + await page.evaluate(() => (window as any).manager.updated(undefined, ['counter'], 3)); + await expect(page.locator('#source')).not.toHaveAttribute('data-native-ui-snapshot-visible'); + await destroyProjection(page); +});