From f174a30eecad2cf1f572a22a605fc7b8ad47855a Mon Sep 17 00:00:00 2001 From: ravdeep kainth Date: Sat, 31 Oct 2026 13:08:06 +1100 Subject: [PATCH 1/2] fix(mobile): markdown images no longer shake on Android Android's expo-image reports the decoded bitmap downsampled for the current frame, so the same file alternates between widths (969 and 970 of 1000). Each load resized the frame, which reloaded the image, in a loop of about 110 renders a second. Keep the first decoded size per source. Co-Authored-By: Claude Opus 5.5 --- .../threads/ThreadMarkdownImage.test.tsx | 49 +++++++++++++++++-- .../features/threads/ThreadMarkdownImage.tsx | 11 ++++- 2 files changed, 56 insertions(+), 4 deletions(-) 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..488d2211bf70 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 })} /> )} From 9db8519abc0898a57f4b0d6287d8aac1867077df Mon Sep 17 00:00:00 2001 From: ravdeep kainth Date: Sat, 31 Oct 2026 13:21:40 +1100 Subject: [PATCH 2/2] fix(mobile): an image edited in place gets its new size Key the pinned decoded size by file revision too, so a workspace file that changes keeps its own frame while a re-signed URL does not reset it. Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadMarkdownImage.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index 488d2211bf70..a0ff3301da46 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -278,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 (