From 858cd5511ee8b125ff83ba4a6d008496720c88b3 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 8 Nov 2026 07:55:24 +0000 Subject: [PATCH] fix(app): keep image load dimensions tied to their source Ignore a queued load from an earlier URI after a preview refresh. Replay that event ordering in the existing file preview journey, and move the native SVG regression into the Agent Device suite. --- packages/app/e2e/browser/file-editing.spec.ts | 24 +++++++ .../native-svg-preview-preconditions.md | 24 +++++++ .../native-svg-preview.android.ad | 11 +++ .../agent-device/native-svg-preview.ios.ad | 11 +++ packages/app/e2e/mobile/svg-preview.md | 22 ------ packages/app/e2e/mobile/svg-preview.mjs | 31 --------- .../app/e2e/support/helpers/image-preview.ts | 68 +++++++++++++++++++ .../components/zoomable-viewport/image.tsx | 4 +- 8 files changed, 140 insertions(+), 55 deletions(-) create mode 100644 packages/app/e2e/mobile/agent-device/native-svg-preview-preconditions.md create mode 100644 packages/app/e2e/mobile/agent-device/native-svg-preview.android.ad create mode 100644 packages/app/e2e/mobile/agent-device/native-svg-preview.ios.ad delete mode 100644 packages/app/e2e/mobile/svg-preview.md delete mode 100644 packages/app/e2e/mobile/svg-preview.mjs create mode 100644 packages/app/e2e/support/helpers/image-preview.ts diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts index 15e3f21bc0..2380eb4491 100644 --- a/packages/app/e2e/browser/file-editing.spec.ts +++ b/packages/app/e2e/browser/file-editing.spec.ts @@ -14,6 +14,7 @@ import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock- import { seedWorkspace } from "../support/helpers/seed-client"; import { getServerId } from "../support/helpers/server-id"; import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; +import { holdNextPreviewLoad } from "../support/helpers/image-preview"; const APP_SETTINGS_KEY = "@paseo:app-settings"; @@ -784,6 +785,29 @@ test.describe("CodeMirror workspace file editing", () => { await expect(image).toBeVisible(); await expect(image).toHaveJSProperty("complete", true); await expect(image).toHaveJSProperty("naturalWidth", 240); + + const oldSource = await image.evaluate((element: HTMLImageElement) => ({ + url: element.currentSrc, + width: element.naturalWidth, + height: element.naturalHeight, + })); + const pendingLoad = await holdNextPreviewLoad(page, oldSource.url); + try { + await writeFile( + path.join(workspace.repoPath, "shape.svg"), + '', + ); + await expect.poll(() => image.getAttribute("src")).not.toBe(oldSource.url); + await pendingLoad.wait(); + // Native can deliver the old source's queued event to this new listener. + await pendingLoad.deliver(oldSource); + await expect(imageCanvas).toHaveCount(0); + } finally { + await pendingLoad.release(); + } + await expect(imageCanvas).toBeVisible(); + const tallFrame = await transformedContent.boundingBox(); + expect(tallFrame!.height).toBeGreaterThan(tallFrame!.width); }); test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => { diff --git a/packages/app/e2e/mobile/agent-device/native-svg-preview-preconditions.md b/packages/app/e2e/mobile/agent-device/native-svg-preview-preconditions.md new file mode 100644 index 0000000000..42f6ae0dc9 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-svg-preview-preconditions.md @@ -0,0 +1,24 @@ +# Native SVG preview regression + +Copy `../fixtures/native-preview.svg`, `../fixtures/native-preview-viewbox.svg`, and +`../../../assets/images/icon.png` into a workspace on a test daemon. In the Android +or iOS app, connect to that daemon, open the workspace's Files tab, and tap +`native-preview.svg`. The SVG should show a yellow circle on a green rectangle. + +With that preview open, run: + +```sh +agent-device test packages/app/e2e/mobile/agent-device/native-svg-preview.android.ad \ + --metro-port --artifacts-dir .artifacts/svg-preview +``` + +Run it again with `native-preview-viewbox.svg` open to cover SVG dimensions +inferred from `viewBox`, then with the PNG open to check raster images. Close and +reopen the SVG and rerun to cover cached file previews. Use English app language +for the zoom button selectors. The scripts are discovered by `npm run test:e2e:mobile`; the iOS counterpart is +`native-svg-preview.ios.ad`. They reopen the installed app with its current +preview and leave the daemon connection unchanged. + +Before the fix the SVG remains blank and the canvas wait fails. The PNG passes. +The replay checks successful native dimension decoding and zoom/reset controls; +visually verify the circle and rectangle as well. diff --git a/packages/app/e2e/mobile/agent-device/native-svg-preview.android.ad b/packages/app/e2e/mobile/agent-device/native-svg-preview.android.ad new file mode 100644 index 0000000000..9ab6acb6b4 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-svg-preview.android.ad @@ -0,0 +1,11 @@ +context platform=android timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"image-file-preview-canvas\"" 10000 +wait "role=button label=\"Zoom in\"" 10000 +press "role=button label=\"Zoom in\"" +wait "role=button label=\"Reset view\" enabled=true" 10000 +press "role=button label=\"Reset view\"" +wait "role=button label=\"Reset view\" enabled=false" 10000 +close diff --git a/packages/app/e2e/mobile/agent-device/native-svg-preview.ios.ad b/packages/app/e2e/mobile/agent-device/native-svg-preview.ios.ad new file mode 100644 index 0000000000..ef607db584 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/native-svg-preview.ios.ad @@ -0,0 +1,11 @@ +context platform=ios timeout=60000 retries=0 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"image-file-preview-canvas\"" 10000 +wait "role=button label=\"Zoom in\"" 10000 +press "role=button label=\"Zoom in\"" +wait "role=button label=\"Reset view\" enabled=true" 10000 +press "role=button label=\"Reset view\"" +wait "role=button label=\"Reset view\" enabled=false" 10000 +close diff --git a/packages/app/e2e/mobile/svg-preview.md b/packages/app/e2e/mobile/svg-preview.md deleted file mode 100644 index 514a5c8c6e..0000000000 --- a/packages/app/e2e/mobile/svg-preview.md +++ /dev/null @@ -1,22 +0,0 @@ -# Native SVG preview regression - -Copy `fixtures/native-preview.svg`, `fixtures/native-preview-viewbox.svg`, and -`../../assets/images/icon.png` into a workspace on a test daemon. In the Android -or iOS app, connect to that daemon, open the workspace's Files tab, and tap -`native-preview.svg`. The SVG should show a yellow circle on a green rectangle. - -With that preview open, run: - -```sh -node packages/app/e2e/mobile/svg-preview.mjs -``` - -Run it again with `native-preview-viewbox.svg` open to cover SVG dimensions -inferred from `viewBox`, then with the PNG open to check raster images. Close and -reopen the SVG and rerun to cover cached file previews. Use English app language -for the zoom button selectors. The script drives the existing native session; -it does not launch an app or daemon or change its connection. - -Before the fix the SVG remains blank and the canvas wait fails. The PNG passes. -The script checks successful native dimension decoding and zoom/reset controls; -visually verify the circle and rectangle as well. diff --git a/packages/app/e2e/mobile/svg-preview.mjs b/packages/app/e2e/mobile/svg-preview.mjs deleted file mode 100644 index 2fa6eedfb7..0000000000 --- a/packages/app/e2e/mobile/svg-preview.mjs +++ /dev/null @@ -1,31 +0,0 @@ -// Open an SVG file preview in a native app loaded from this checkout first. -// node packages/app/e2e/mobile/svg-preview.mjs -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; - -const [, , session] = process.argv; -assert(session, "Pass the agent-device session with an SVG preview open"); -function ad(...args) { - return execFileSync("agent-device", [...args, "--session", session], { - encoding: "utf8", - timeout: 30_000, - }); -} - -// The canvas only mounts once the native decoder has returned image dimensions. -// A file name or an image placeholder alone does not establish a successful load. -ad("wait", 'id="image-file-preview-canvas"', "10000"); -ad("wait", 'role=button label="Zoom in"', "10000"); -ad("press", 'role=button label="Zoom in"', "--settle"); -assert.equal( - JSON.parse(ad("get", "attrs", 'role=button label="Reset view"', "--json")).data.node.enabled, - true, - "Zooming enables Reset view", -); -ad("press", 'role=button label="Reset view"', "--settle"); -assert.equal( - JSON.parse(ad("get", "attrs", 'role=button label="Reset view"', "--json")).data.node.enabled, - false, - "Reset returns to the fitted view", -); -console.log("PASS: native SVG decoded, zoomed, and reset"); diff --git a/packages/app/e2e/support/helpers/image-preview.ts b/packages/app/e2e/support/helpers/image-preview.ts new file mode 100644 index 0000000000..9af5bbdac5 --- /dev/null +++ b/packages/app/e2e/support/helpers/image-preview.ts @@ -0,0 +1,68 @@ +import { expect, type Page } from "@playwright/test"; + +function isReady(value: { ready(): boolean }): boolean { + return value.ready(); +} + +/** Replay a queued native load payload after a real file refresh, through the image's load event. */ +export async function holdNextPreviewLoad(page: Page, previousUrl: string) { + const pending = await page.evaluateHandle((oldUrl: string) => { + let image: HTMLImageElement | null = null; + let holding = true; + const hold = (event: Event) => { + if (holding) event.stopImmediatePropagation(); + }; + const observer = new MutationObserver(() => { + if (image) return; + const candidates = document.querySelectorAll( + '[data-testid="image-file-preview-image"] img', + ); + const next = Array.from(candidates).find((candidate) => candidate.src !== oldUrl); + if (!next) return; + image = next; + image.addEventListener("load", hold, true); + observer.disconnect(); + }); + observer.observe(document.body, { subtree: true, childList: true, attributes: true }); + return { + ready: () => image !== null && image.complete && image.naturalWidth > 0, + deliver(source: { url: string; width: number; height: number }) { + if (!image) throw new Error("No pending preview load"); + // Expo's web adapter reads these fields to produce the same public onLoad + // payload that native queues before React dispatches to the current listener. + Object.defineProperties(image, { + currentSrc: { configurable: true, value: source.url }, + naturalWidth: { configurable: true, value: source.width }, + naturalHeight: { configurable: true, value: source.height }, + }); + try { + holding = false; + image.dispatchEvent(new Event("load")); + holding = true; + } finally { + Reflect.deleteProperty(image, "currentSrc"); + Reflect.deleteProperty(image, "naturalWidth"); + Reflect.deleteProperty(image, "naturalHeight"); + } + }, + release() { + holding = false; + observer.disconnect(); + image?.removeEventListener("load", hold, true); + image?.dispatchEvent(new Event("load")); + }, + }; + }, previousUrl); + return { + async wait() { + await expect.poll(() => pending.evaluate(isReady)).toBe(true); + }, + async deliver(source: { url: string; width: number; height: number }) { + await pending.evaluate((value, payload) => value.deliver(payload), source); + }, + async release() { + await pending.evaluate((value) => value.release()); + await pending.dispose(); + }, + }; +} diff --git a/packages/app/src/components/zoomable-viewport/image.tsx b/packages/app/src/components/zoomable-viewport/image.tsx index 3e79041a94..e4535dc7eb 100644 --- a/packages/app/src/components/zoomable-viewport/image.tsx +++ b/packages/app/src/components/zoomable-viewport/image.tsx @@ -41,8 +41,8 @@ export function ZoomableImage({ const resolvedSize = contentSize ?? (loadedImage?.uri === uri ? loadedImage.size : null); const source = useMemo(() => ({ uri }), [uri]); const handleLoad = useCallback( - ({ source: { width, height } }: ImageLoadEventData) => { - if (width > 0 && height > 0) setLoadedImage({ uri, size: { width, height } }); + ({ source: { url, width, height } }: ImageLoadEventData) => { + if (url === uri && width > 0 && height > 0) setLoadedImage({ uri, size: { width, height } }); }, [uri], );