Skip to content

Commit 3fec677

Browse files
fix(mobile): a failed markdown image retries when its URI returns
Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 01864e8 commit 3fec677

3 files changed

Lines changed: 73 additions & 3 deletions

File tree

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { ReactNode } from "react";
2+
import { act } from "react";
3+
import { createRoot } from "react-dom/client";
4+
import { expect, it, vi } from "vite-plus/test";
5+
6+
const harness = vi.hoisted(() => ({
7+
failImage: () => {},
8+
Passthrough: ({ children }: { children?: unknown }) => children,
9+
}));
10+
11+
vi.mock("react-native", () => ({
12+
Pressable: harness.Passthrough,
13+
StyleSheet: { absoluteFill: {} },
14+
View: harness.Passthrough,
15+
}));
16+
vi.mock("expo-image", () => ({
17+
Image: (props: { onError: () => void }) => {
18+
harness.failImage = props.onError;
19+
return <img alt="" />;
20+
},
21+
}));
22+
vi.mock("expo/fetch", () => ({ fetch: vi.fn() }));
23+
vi.mock("react-native-svg", () => ({ parse: vi.fn(), SvgAst: () => null }));
24+
vi.mock("@t3tools/client-runtime/state/assets", () => ({ MISSING_FILE_ACCESS_MESSAGE: "" }));
25+
vi.mock("../../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: () => null }));
26+
vi.mock("../../components/FilePreviewGallery", () => ({
27+
useFilePreviewGallery: () => (source: unknown) => source,
28+
}));
29+
vi.mock("../../components/ui/AppText", () => ({
30+
AppText: ({ children }: { children?: ReactNode }) => <span>{children}</span>,
31+
}));
32+
vi.mock("../../components/MediaActionsMenu", () => ({ MediaActionsMenu: harness.Passthrough }));
33+
vi.mock("../../components/ui/NativePresentation", () => ({
34+
PresentationSource: harness.Passthrough,
35+
}));
36+
vi.mock("../../state/mediaActions", () => ({ useMediaActions: () => ({ actions: [] }) }));
37+
vi.mock("../../state/assets", () => ({ useAssetUrlState: vi.fn() }));
38+
39+
import { ThreadMarkdownImageView } from "./ThreadMarkdownImage";
40+
41+
it("retries a failed image once its URI comes back through null", async () => {
42+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
43+
const container = document.createElement("div");
44+
const root = createRoot(container);
45+
const view = (uri: string | null) => (
46+
<ThreadMarkdownImageView
47+
uri={uri}
48+
sourceKey="attachment:a"
49+
unavailable={false}
50+
alt={null}
51+
onPressPreview={() => {}}
52+
/>
53+
);
54+
try {
55+
await act(async () => root.render(view("https://example.test/a.png")));
56+
await act(async () => harness.failImage());
57+
expect(container.textContent).toContain("Image unavailable");
58+
59+
await act(async () => root.render(view(null)));
60+
await act(async () => root.render(view("https://example.test/a.png")));
61+
expect(container.textContent).not.toContain("Image unavailable");
62+
expect(container.querySelector("img")).not.toBeNull();
63+
} finally {
64+
await act(async () => root.unmount());
65+
}
66+
});

‎apps/mobile/src/features/threads/ThreadMarkdownImage.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ export function ThreadMarkdownImageView(props: {
5151
size: { width: number; height: number };
5252
} | null>(null);
5353
const decodedSize = decoded?.sourceKey === props.sourceKey ? decoded.size : null;
54-
const [failedUri, setFailedUri] = useState<string | null>(null);
54+
// A failure belongs to one URI; any URI change, including through null, retries.
55+
const [failure, setFailure] = useState({ uri: props.uri, failed: false });
56+
if (failure.uri !== props.uri) setFailure({ uri: props.uri, failed: false });
5557

5658
const sourceSize = decodedSize ?? props.knownSize ?? null;
5759
const displaySize: MarkdownImageDisplaySize | null =
@@ -62,7 +64,8 @@ export function ThreadMarkdownImageView(props: {
6264
sourceHeight: sourceSize.height,
6365
availableWidth,
6466
});
65-
const failed = props.unavailable || (props.uri !== null && failedUri === props.uri);
67+
const failed =
68+
props.unavailable || (props.uri !== null && failure.uri === props.uri && failure.failed);
6669
const placeholderWidth: ViewStyle["width"] =
6770
availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%";
6871
const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 };
@@ -125,7 +128,7 @@ export function ThreadMarkdownImageView(props: {
125128
uri={props.uri}
126129
format={props.format}
127130
onLoad={(size) => setDecoded({ sourceKey: props.sourceKey, size })}
128-
onError={() => setFailedUri(props.uri)}
131+
onError={() => setFailure({ uri: props.uri, failed: true })}
129132
/>
130133
</View>
131134
</Pressable>

‎apps/mobile/vite.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const reactTests = [
1010
"src/components/ui/Dialog.android.test.tsx",
1111
"src/features/settings/SettingsServerControlsRouteScreen.test.tsx",
1212
"src/features/settings/components/BranchNamingSettings.test.tsx",
13+
"src/features/threads/ThreadMarkdownImage.test.tsx",
1314
"src/features/threads/UsageLimitRecoveryCard.test.tsx",
1415
"src/native/NativeHeaderToolbar.test.tsx",
1516
"src/native/useRestingTopOffset.test.tsx",

0 commit comments

Comments
 (0)