diff --git a/packages/app/e2e/browser/file-editing.spec.ts b/packages/app/e2e/browser/file-editing.spec.ts
index a395a517c2..15e3f21bc0 100644
--- a/packages/app/e2e/browser/file-editing.spec.ts
+++ b/packages/app/e2e/browser/file-editing.spec.ts
@@ -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 }) => {
diff --git a/packages/app/e2e/mobile/fixtures/native-preview-viewbox.svg b/packages/app/e2e/mobile/fixtures/native-preview-viewbox.svg
new file mode 100644
index 0000000000..6b107b80e8
--- /dev/null
+++ b/packages/app/e2e/mobile/fixtures/native-preview-viewbox.svg
@@ -0,0 +1 @@
+
diff --git a/packages/app/e2e/mobile/fixtures/native-preview.svg b/packages/app/e2e/mobile/fixtures/native-preview.svg
new file mode 100644
index 0000000000..67c1c66ae8
--- /dev/null
+++ b/packages/app/e2e/mobile/fixtures/native-preview.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/packages/app/e2e/mobile/svg-preview.md b/packages/app/e2e/mobile/svg-preview.md
new file mode 100644
index 0000000000..514a5c8c6e
--- /dev/null
+++ b/packages/app/e2e/mobile/svg-preview.md
@@ -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
+```
+
+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
new file mode 100644
index 0000000000..2fa6eedfb7
--- /dev/null
+++ b/packages/app/e2e/mobile/svg-preview.mjs
@@ -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
+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/src/components/zoomable-viewport/image.tsx b/packages/app/src/components/zoomable-viewport/image.tsx
index 3c59eee4bf..3e79041a94 100644
--- a/packages/app/src/components/zoomable-viewport/image.tsx
+++ b/packages/app/src/components/zoomable-viewport/image.tsx
@@ -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(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 (
-
-