From 4f1eefdba979bd539f2977973b8e0bf2ee6051a1 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:36:45 +0000
Subject: [PATCH] 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.
---
packages/app/e2e/browser/file-editing.spec.ts | 10 ++++
.../fixtures/native-preview-viewbox.svg | 1 +
.../e2e/mobile/fixtures/native-preview.svg | 1 +
packages/app/e2e/mobile/svg-preview.md | 22 +++++++++
packages/app/e2e/mobile/svg-preview.mjs | 31 +++++++++++++
.../components/zoomable-viewport/image.tsx | 46 ++++++++-----------
6 files changed, 84 insertions(+), 27 deletions(-)
create mode 100644 packages/app/e2e/mobile/fixtures/native-preview-viewbox.svg
create mode 100644 packages/app/e2e/mobile/fixtures/native-preview.svg
create mode 100644 packages/app/e2e/mobile/svg-preview.md
create mode 100644 packages/app/e2e/mobile/svg-preview.mjs
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 (
-
-