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],
);