fix(app): render SVG files in native image previews

The shared preview used the core native image decoder, which cannot load SVG files. Use the existing image renderer with SVG decoding and obtain viewport dimensions from its successful load event.
This commit is contained in:
paseo-bot[bot]
2026-11-08 07:36:45 +00:00
parent b509d499f7
commit 4f1eefdba9
6 changed files with 84 additions and 27 deletions
@@ -729,6 +729,10 @@ test.describe("CodeMirror workspace file editing", () => {
const imagePath = path.join(workspace.repoPath, "pixel.png");
await writeFile(markdownPath, "# First heading\n", "utf8");
await writeFile(imagePath, RED_PIXEL);
await writeFile(
path.join(workspace.repoPath, "shape.svg"),
await readFile(path.resolve(__dirname, "../mobile/fixtures/native-preview.svg")),
);
await workspace.navigateTo();
await openWorkspaceFile(page, "notes.md");
@@ -774,6 +778,12 @@ test.describe("CodeMirror workspace file editing", () => {
const initialSource = await image.getAttribute("src");
await writeFile(imagePath, BLUE_PIXEL);
await expect.poll(() => image.getAttribute("src")).not.toBe(initialSource);
await openWorkspaceFile(page, "shape.svg");
await expect(imageCanvas).toBeVisible();
await expect(image).toBeVisible();
await expect(image).toHaveJSProperty("complete", true);
await expect(image).toHaveJSProperty("naturalWidth", 240);
});
test("renders local relative images inside Markdown preview", async ({ page, withWorkspace }) => {
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 160"><rect width="240" height="160" fill="#08a040"/><circle cx="120" cy="80" r="50" fill="#ffcc00"/></svg>

After

Width:  |  Height:  |  Size: 164 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160"><rect width="240" height="160" fill="#08a040"/><circle cx="120" cy="80" r="50" fill="#ffcc00"/></svg>

After

Width:  |  Height:  |  Size: 188 B

+22
View File
@@ -0,0 +1,22 @@
# 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
@@ -0,0 +1,31 @@
// 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");
@@ -1,10 +1,13 @@
import { useEffect, useMemo, useState } from "react";
import { Image, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useCallback, useMemo, useState } from "react";
import { Image, type ImageLoadEventData } from "expo-image";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { ViewportFitOptions, ViewportSize } from "./geometry";
import { ZoomableViewport } from "./index";
import type { ZoomableViewportAction } from "./types";
const ViewportImage = withUnistyles(Image);
interface ZoomableImageProps {
uri: string;
accessibilityLabel?: string;
@@ -34,36 +37,25 @@ export function ZoomableImage({
testID = "zoomable-image",
wheelActivation = "always",
}: ZoomableImageProps) {
const [loadedSize, setLoadedSize] = useState<ViewportSize | null>(null);
const resolvedSize = contentSize ?? loadedSize;
const [loadedImage, setLoadedImage] = useState<{ uri: string; size: ViewportSize } | null>(null);
const resolvedSize = contentSize ?? (loadedImage?.uri === uri ? loadedImage.size : null);
const source = useMemo(() => ({ uri }), [uri]);
useEffect(() => {
if (contentSize) return;
let active = true;
setLoadedSize(null);
Image.getSize(
uri,
(width, height) => {
if (active && width > 0 && height > 0) setLoadedSize({ width, height });
},
() => {
if (active) onError?.();
},
);
return () => {
active = false;
};
}, [contentSize, onError, uri]);
const handleLoad = useCallback(
({ source: { width, height } }: ImageLoadEventData) => {
if (width > 0 && height > 0) setLoadedImage({ uri, size: { width, height } });
},
[uri],
);
if (!resolvedSize) {
return (
<View style={[styles.root, style]} testID={testID}>
<Image
<ViewportImage
accessibilityLabel={accessibilityLabel}
accessibilityRole="image"
onError={onError}
resizeMode="contain"
onLoad={handleLoad}
contentFit="contain"
source={source}
style={styles.image}
testID={`${testID}-image`}
@@ -85,9 +77,9 @@ export function ZoomableImage({
testID={testID}
wheelActivation={wheelActivation}
>
<Image
<ViewportImage
onError={onError}
resizeMode="contain"
contentFit="contain"
source={source}
style={styles.image}
testID={`${testID}-image`}