From 358b8911d94a65d741cfb6638cc8ffc0ba24ffdb Mon Sep 17 00:00:00 2001
From: Alex Southwell <4596216+saphid@users.noreply.github.com>
Date: Mon, 26 Oct 2026 15:36:24 +1100
Subject: [PATCH] perf(mobile): skip canceled SVG parsing
---
.../threads/ThreadMarkdownImage.test.tsx | 181 ++++++++++++++++++
.../features/threads/ThreadMarkdownImage.tsx | 1 +
apps/mobile/vite.config.ts | 1 +
3 files changed, 183 insertions(+)
create mode 100644 apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx
diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx
new file mode 100644
index 0000000000000..297b80470559f
--- /dev/null
+++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx
@@ -0,0 +1,181 @@
+import { act, type CSSProperties, type ReactNode } from "react";
+import { createRoot } from "react-dom/client";
+import { parse } from "react-native-svg";
+import { fetch } from "expo/fetch";
+import { expect, it, vi } from "vite-plus/test";
+
+vi.mock("react-native", () => ({
+ View: ({ children, style }: { children: ReactNode; style?: CSSProperties }) => (
+
{children}
+ ),
+ Pressable: ({ children }: { children: ReactNode }) => {children}
,
+ StyleSheet: { absoluteFill: {} },
+}));
+vi.mock("react-native-svg/lib/module/xmlTags", () => ({
+ tags: { svg: "svg", rect: "rect", circle: "circle", g: "g" },
+}));
+vi.mock("react-native-svg/lib/module/utils/fetchData", () => ({ fetchText: vi.fn() }));
+vi.mock("react-native-svg", async () => {
+ const xml = await vi.importActual>(
+ "react-native-svg/lib/module/xml.js",
+ );
+ return { parse: vi.fn(xml.parse), SvgAst: xml.SvgAst };
+});
+vi.mock("expo/fetch", () => ({ fetch: vi.fn() }));
+vi.mock("expo-image", () => ({ Image: () => null }));
+vi.mock("../../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: () => null }));
+vi.mock("../../components/ui/AppText", () => ({
+ AppText: ({ children }: { children: ReactNode }) => {children},
+}));
+vi.mock("../../components/FilePreviewGallery", () => ({
+ useFilePreviewGallery: () => (source: unknown) => source,
+}));
+vi.mock("../../components/MediaActionsMenu", () => ({
+ MediaActionsMenu: ({ children }: { children: ReactNode }) => children,
+}));
+vi.mock("../../components/ui/NativePresentation", () => ({
+ PresentationSource: ({ children }: { children: ReactNode }) => children,
+}));
+vi.mock("../../state/mediaActions", () => ({ useMediaActions: () => ({ actions: [] }) }));
+vi.mock("../../state/assets", () => ({ useAssetUrlState: vi.fn() }));
+vi.mock("@t3tools/client-runtime/state/assets", () => ({
+ MISSING_FILE_ACCESS_MESSAGE: "No access",
+}));
+
+import { MarkdownImageAvailableWidthContext, ThreadMarkdownImageView } from "./ThreadMarkdownImage";
+
+const firstXml = '';
+const secondXml = '';
+const malformedXml = "";
+const dataUri = (xml: string) => `data:image/svg+xml,${encodeURIComponent(xml)}`;
+type FetchResponse = Awaited>;
+
+function setup() {
+ vi.mocked(parse).mockClear();
+ vi.mocked(fetch).mockReset();
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const container = document.createElement("div");
+ const root = createRoot(container);
+ let mounted = true;
+ const unmount = () => {
+ if (mounted) act(() => root.unmount());
+ mounted = false;
+ };
+ return {
+ container,
+ render(uri: string) {
+ act(() =>
+ root.render(
+
+ undefined}
+ />
+ ,
+ ),
+ );
+ },
+ unmount,
+ [Symbol.dispose]() {
+ unmount();
+ vi.unstubAllGlobals();
+ },
+ };
+}
+
+function expectLoaded(container: HTMLDivElement, width: number, height: number) {
+ const svg = container.querySelector("svg");
+ expect(svg).not.toBeNull();
+ expect(svg?.parentElement?.style.width).toBe(`${width}px`);
+ expect(svg?.parentElement?.style.height).toBe(`${height}px`);
+ expect(container.textContent).not.toContain("Loading image");
+ expect(container.textContent).not.toContain("Image unavailable");
+}
+
+it("does not parse a data URI after its mounted effect is unmounted", async () => {
+ using view = setup();
+ view.render(dataUri(firstXml));
+ view.unmount();
+ await act(async () => {});
+ expect(parse).not.toHaveBeenCalled();
+ expect(view.container.innerHTML).toBe("");
+});
+
+it("parses only the replacement URI when effects change before the continuation", async () => {
+ using view = setup();
+ view.render(dataUri(firstXml));
+ view.render(dataUri(secondXml));
+ await act(async () => {});
+ expect(parse).toHaveBeenCalledExactlyOnceWith(secondXml);
+ expectLoaded(view.container, 8, 9);
+ expect(view.container.querySelector("circle")).not.toBeNull();
+ expect(view.container.querySelector("rect")).toBeNull();
+});
+
+it("loads a current valid SVG with its parsed dimensions and content", async () => {
+ using view = setup();
+ view.render(dataUri(firstXml));
+ expect(view.container.textContent).toContain("Loading image");
+ await act(async () => {});
+ expect(parse).toHaveBeenCalledExactlyOnceWith(firstXml);
+ expectLoaded(view.container, 4, 5);
+ expect(view.container.querySelector("rect")?.getAttribute("width")).toBe("2");
+});
+
+it("shows an error for malformed current XML", async () => {
+ using view = setup();
+ view.render(dataUri(malformedXml));
+ await act(async () => {});
+ expect(parse).toHaveBeenCalledExactlyOnceWith(malformedXml);
+ expect(view.container.textContent).toBe("Image unavailable");
+ expect(view.container.querySelector("svg")).toBeNull();
+});
+
+it.each([firstXml, malformedXml])("ignores obsolete response text: %s", async (xml) => {
+ using view = setup();
+ const body = Promise.withResolvers();
+ const reading = Promise.withResolvers();
+ vi.mocked(fetch).mockResolvedValue({
+ ok: true,
+ text: () => {
+ reading.resolve();
+ return body.promise;
+ },
+ } as FetchResponse);
+ view.render("https://example.test/old.svg");
+ await act(async () => reading.promise);
+ const signal = vi.mocked(fetch).mock.calls[0]?.[1]?.signal;
+ view.render(dataUri(secondXml));
+ expect(signal?.aborted).toBe(true);
+ await act(async () => body.resolve(xml));
+ expect(parse).toHaveBeenCalledExactlyOnceWith(secondXml);
+ expectLoaded(view.container, 8, 9);
+ expect(view.container.querySelector("circle")).not.toBeNull();
+});
+
+it("ignores a late fetch rejection after replacement and aborts its request", async () => {
+ using view = setup();
+ const response = Promise.withResolvers();
+ vi.mocked(fetch).mockReturnValue(response.promise);
+ view.render("https://example.test/old.svg");
+ const signal = vi.mocked(fetch).mock.calls[0]?.[1]?.signal;
+ view.render(dataUri(secondXml));
+ expect(signal?.aborted).toBe(true);
+ await act(async () => response.reject(new Error("aborted request")));
+ expect(parse).toHaveBeenCalledExactlyOnceWith(secondXml);
+ expectLoaded(view.container, 8, 9);
+});
+
+it("shows a current request failure without entering the parser", async () => {
+ using view = setup();
+ const response = Promise.withResolvers();
+ vi.mocked(fetch).mockReturnValue(response.promise);
+ view.render("https://example.test/current.svg");
+ await act(async () => response.reject(new Error("offline")));
+ expect(parse).not.toHaveBeenCalled();
+ expect(view.container.textContent).toBe("Image unavailable");
+});
diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx
index 6c054c4a743bd..6464034ac9d79 100644
--- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx
+++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx
@@ -217,6 +217,7 @@ function ThreadMarkdownSvg(props: {
};
void load()
.then((xml) => {
+ if (controller.signal.aborted) return;
const parsed = parse(xml);
if (!parsed) throw new Error("SVG has no root element");
if (controller.signal.aborted) return;
diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts
index 77fffb6cb6117..619fce80ff8f3 100644
--- a/apps/mobile/vite.config.ts
+++ b/apps/mobile/vite.config.ts
@@ -2,6 +2,7 @@ import { defineConfig } from "vite-plus";
import "vite-plus/test/config";
const reactTests = [
+ "src/features/threads/ThreadMarkdownImage.test.tsx",
"src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx",
"src/lib/filePreview.test.ts",
"src/features/threads/thread-inspector-navigation.test.tsx",