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.
This commit is contained in:
paseo-bot[bot]
2026-11-08 07:55:24 +00:00
parent 4f1eefdba9
commit 858cd5511e
8 changed files with 140 additions and 55 deletions
@@ -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"),
'<svg xmlns="http://www.w3.org/2000/svg" width="160" height="240"><rect width="160" height="240" fill="green"/></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 }) => {
@@ -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 <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.
@@ -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
@@ -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
-22
View File
@@ -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 <agent-device-session>
```
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.
-31
View File
@@ -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 <agent-device-session>
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");
@@ -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<HTMLImageElement>(
'[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();
},
};
}
@@ -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],
);