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",