diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx index 14d595950e22..7c0b08c2c5c1 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx @@ -5,17 +5,24 @@ import { expect, it, vi } from "vite-plus/test"; const harness = vi.hoisted(() => ({ failImage: () => {}, + loadImage: (_event: { source: { width: number; height: number } }) => {}, Passthrough: ({ children }: { children?: unknown }) => children, })); vi.mock("react-native", () => ({ Pressable: harness.Passthrough, StyleSheet: { absoluteFill: {} }, - View: harness.Passthrough, + View: ({ children, style }: { children?: ReactNode; style?: unknown }) => ( +
{children}
+ ), })); vi.mock("expo-image", () => ({ - Image: (props: { onError: () => void }) => { + Image: (props: { + onError: () => void; + onLoad: (event: { source: { width: number; height: number } }) => void; + }) => { harness.failImage = props.onError; + harness.loadImage = props.onLoad; return ; }, })); @@ -41,7 +48,43 @@ import { AtomRegistry } from "effect/reactivity"; import { fetch } from "expo/fetch"; import { parse } from "react-native-svg"; -import { ThreadMarkdownImageView } from "./ThreadMarkdownImage"; +import { MarkdownImageAvailableWidthContext, ThreadMarkdownImageView } from "./ThreadMarkdownImage"; + +it("keeps the frame size when the same image reports a different decoded width", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const container = document.createElement("div"); + const root = createRoot(container); + const frameSize = () => + [...container.querySelectorAll("div[data-style]")] + .map((element) => JSON.parse(element.dataset.style ?? "null") as Record) + .find((style) => typeof style?.width === "number"); + try { + await act(async () => + root.render( + + {}} + /> + , + ), + ); + // Android reports the bitmap downsampled for the current frame, so the same file + // alternates between widths (969 and 970 of 1000) as the frame changes size. + await act(async () => harness.loadImage({ source: { width: 970, height: 567 } })); + const first = frameSize(); + expect(first).toBeDefined(); + + await act(async () => harness.loadImage({ source: { width: 969, height: 567 } })); + expect(frameSize()).toEqual(first); + } finally { + await act(async () => root.unmount()); + } +}); it("retries a failed image once its URI comes back through null", async () => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index 88c64e4deda7..a0ff3301da46 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -192,7 +192,16 @@ export function ThreadMarkdownImageView(props: { key={props.sourceKey} uri={props.uri} cacheKey={props.cacheKey} - onLoad={(size) => setDecoded({ sourceKey: props.sourceKey, size })} + onLoad={(size) => + // Only the first decode counts: later loads report the bitmap + // downsampled for the current frame (969 vs 970 wide), so keeping + // them resizes the frame, which reloads the image, and so on. + setDecoded((current) => + current?.sourceKey === props.sourceKey + ? current + : { sourceKey: props.sourceKey, size }, + ) + } onError={() => setFailure({ uri: props.uri, failed: true })} /> )} @@ -269,6 +278,18 @@ export function ThreadMarkdownImage(props: { }) { const withGallery = useFilePreviewGallery(); const assetUrl = useAssetUrlState(props.environmentId, props.resource); + const resourceKey = + props.resource._tag === "attachment" + ? `attachment:${props.resource.attachmentId}` + : props.resource._tag === "tool-output-image" + ? `tool-output:${props.resource.itemId}:${props.resource.index}` + : `workspace:${props.resource.path}`; + // The view keeps the first decoded size per key, so a file edited in place needs a + // new key; a re-signed URL for the same revision must not get one. + const sourceKey = + assetUrl._tag === "Success" && assetUrl.fileRevision !== undefined + ? `${resourceKey}@${assetUrl.fileRevision}` + : resourceKey; return (