diff --git a/apps/mobile/src/features/threads/CopyTextButton.tsx b/apps/mobile/src/features/threads/CopyTextButton.tsx index e99f60b30d3d2..ee3d1ef9d1841 100644 --- a/apps/mobile/src/features/threads/CopyTextButton.tsx +++ b/apps/mobile/src/features/threads/CopyTextButton.tsx @@ -1,8 +1,9 @@ import * as Dialog from "../../components/ui/Dialog"; import { SymbolView } from "../../components/ui/AppSymbol"; -import { memo, useEffect, useRef, useState } from "react"; +import { memo, useState } from "react"; import { Pressable, type ColorValue } from "react-native"; +import { useDeadlinePassed } from "../../lib/clock"; import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; const COPY_FEEDBACK_DURATION_MS = 1200; @@ -20,17 +21,11 @@ export const CopyTextButton = memo(function CopyTextButton(props: { readonly iconSize?: number; readonly buttonSize?: number; }) { - const [copied, setCopied] = useState(false); - const resetTimeoutRef = useRef | null>(null); - - useEffect( - () => () => { - if (resetTimeoutRef.current) { - clearTimeout(resetTimeoutRef.current); - } - }, - [], + const [copiedAtMs, setCopiedAtMs] = useState(null); + const feedbackEnded = useDeadlinePassed( + copiedAtMs === null ? null : copiedAtMs + COPY_FEEDBACK_DURATION_MS, ); + const copied = copiedAtMs !== null && !feedbackEnded; return ( { - setCopied(false); - resetTimeoutRef.current = null; - }, COPY_FEEDBACK_DURATION_MS); + setCopiedAtMs(Date.now()); }} style={({ pressed }) => ({ width: props.buttonSize ?? 30, diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx index 88ebb7109c9bf..14d595950e223 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.test.tsx @@ -36,6 +36,11 @@ vi.mock("../../components/ui/NativePresentation", () => ({ vi.mock("../../state/mediaActions", () => ({ useMediaActions: () => ({ actions: [] }) })); vi.mock("../../state/assets", () => ({ useAssetUrlState: vi.fn() })); +import { RegistryContext } from "@effect/atom-react"; +import { AtomRegistry } from "effect/reactivity"; +import { fetch } from "expo/fetch"; +import { parse } from "react-native-svg"; + import { ThreadMarkdownImageView } from "./ThreadMarkdownImage"; it("retries a failed image once its URI comes back through null", async () => { @@ -64,3 +69,52 @@ it("retries a failed image once its URI comes back through null", async () => { await act(async () => root.unmount()); } }); + +it("fetches a failed SVG again on the next mount but keeps a parsed one", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + // Microtask scheduling lets act() flush an unmounted atom's removal before the next render. + const registry = AtomRegistry.make({ + scheduleTask: (task) => { + queueMicrotask(task); + return () => {}; + }, + }); + const fetchSvg = vi.mocked(fetch); + vi.mocked(parse).mockReturnValue({ props: {} } as ReturnType); + const container = document.createElement("div"); + const root = createRoot(container); + const uri = "https://example.test/a.svg"; + const view = (mounted: boolean) => ( + + {mounted ? ( + {}} + /> + ) : null} + + ); + try { + fetchSvg.mockRejectedValueOnce(new Error("offline")); + await act(async () => root.render(view(true))); + await vi.waitFor(() => expect(container.textContent).toContain("Image unavailable")); + + await act(async () => root.render(view(false))); + fetchSvg.mockResolvedValue({ ok: true, status: 200, text: async () => "" } as never); + await act(async () => root.render(view(true))); + await vi.waitFor(() => expect(container.textContent).not.toContain("Image unavailable")); + expect(fetchSvg).toHaveBeenCalledTimes(2); + + await act(async () => root.render(view(false))); + await act(async () => root.render(view(true))); + expect(container.textContent).not.toContain("Image unavailable"); + expect(fetchSvg).toHaveBeenCalledTimes(2); + } finally { + await act(async () => root.unmount()); + registry.dispose(); + } +}); diff --git a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx index 9b6075615aa4c..bad77ec4edd8b 100644 --- a/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx +++ b/apps/mobile/src/features/threads/ThreadMarkdownImage.tsx @@ -1,7 +1,11 @@ +import { useAtomValue } from "@effect/atom-react"; import { MISSING_FILE_ACCESS_MESSAGE } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom } from "effect/reactivity"; import { Image } from "expo-image"; -import { createContext, useContext, useEffect, useEffectEvent, useId, useState } from "react"; +import { createContext, useContext, useId, useState } from "react"; import { fetch } from "expo/fetch"; import { parse, SvgAst, type JsxAST } from "react-native-svg"; import { Pressable, StyleSheet, View, type ViewStyle } from "react-native"; @@ -25,6 +29,58 @@ import { /** Use known feed width immediately; later image measurement would move already-positioned rows. */ export const MarkdownImageAvailableWidthContext = createContext(0); +type ImageSize = { readonly width: number; readonly height: number }; + +class SvgLoadError extends Data.TaggedError("SvgLoadError")<{ readonly cause: unknown }> {} + +async function readSvgXml(uri: string, signal: AbortSignal): Promise { + if (uri.startsWith("data:image/svg+xml")) { + const comma = uri.indexOf(","); + if (comma < 0) throw new Error("SVG data URI has no content"); + const content = decodeURIComponent(uri.slice(comma + 1)); + return uri.slice(0, comma).includes(";base64") + ? new TextDecoder().decode( + Uint8Array.from(atob(content), (character) => character.charCodeAt(0)), + ) + : content; + } + // expo/fetch, not the runtime HttpClient: it is the one that reads file:// URIs. + const response = await fetch(uri, { signal }); + if (!response.ok && !(response.status === 0 && uri.startsWith("file://"))) + throw new Error(`SVG request failed: ${response.status}`); + return response.text(); +} + +type SvgDocument = { readonly ast: JsxAST; readonly size: ImageSize | null }; + +// Feed rows unmount as they scroll away. Only parsed documents outlive their +// readers (for the row that scrolls back); a failed load is dropped with its last +// reader so the next mount fetches again. +const parsedSvgDocumentAtom = Atom.family((_uri: string) => + Atom.make(null).pipe( + Atom.setIdleTTL(60_000), + Atom.withLabel("mobile:thread-markdown-svg-parsed"), + ), +); + +const svgDocumentAtom = Atom.family((uri: string) => + Atom.make((get) => { + const parsed = parsedSvgDocumentAtom(uri); + const cached = get(parsed); + if (cached !== null) return Effect.succeed(cached); + return Effect.tryPromise({ + try: async (signal): Promise => { + const ast = parse(await readSvgXml(uri, signal)); + if (!ast) throw new Error("SVG has no root element"); + return { ast, size: resolveSvgImageSize(ast.props) }; + }, + catch: (cause) => new SvgLoadError({ cause }), + }).pipe(Effect.tap((document) => Effect.sync(() => get.set(parsed, document)))); + }).pipe(Atom.withLabel("mobile:thread-markdown-svg")), +); + +const noSvgDocumentAtom = Atom.make(AsyncResult.initial()); + export function ThreadMarkdownImageView(props: { readonly uri: string | null; readonly sourceKey: string; @@ -45,12 +101,14 @@ export function ThreadMarkdownImageView(props: { measuredWidth > 0 && contextWidth > 0 ? Math.min(contextWidth, measuredWidth) : contextWidth || measuredWidth; + const svg = useAtomValue( + props.format === "svg" && props.uri !== null ? svgDocumentAtom(props.uri) : noSvgDocumentAtom, + ); + const svgDocument = AsyncResult.isSuccess(svg) ? svg.value : null; // Keyed by source so a new source drops the previous image's decoded dimensions. - const [decoded, setDecoded] = useState<{ - sourceKey: string; - size: { width: number; height: number }; - } | null>(null); - const decodedSize = decoded?.sourceKey === props.sourceKey ? decoded.size : null; + const [decoded, setDecoded] = useState<{ sourceKey: string; size: ImageSize } | null>(null); + const decodedSize = + svgDocument?.size ?? (decoded?.sourceKey === props.sourceKey ? decoded.size : null); // A failure belongs to one URI; any URI change, including through null, retries. const [failure, setFailure] = useState({ uri: props.uri, failed: false }); if (failure.uri !== props.uri) setFailure({ uri: props.uri, failed: false }); @@ -65,7 +123,9 @@ export function ThreadMarkdownImageView(props: { availableWidth, }); const failed = - props.unavailable || (props.uri !== null && failure.uri === props.uri && failure.failed); + props.unavailable || + (props.uri !== null && failure.uri === props.uri && failure.failed) || + AsyncResult.isFailure(svg); const placeholderWidth: ViewStyle["width"] = availableWidth > 0 ? Math.min(availableWidth, MARKDOWN_IMAGE_MAX_WIDTH) : "100%"; const frameStyle: ViewStyle = displaySize ?? { width: placeholderWidth, aspectRatio: 16 / 9 }; @@ -123,13 +183,16 @@ export function ThreadMarkdownImageView(props: { className="items-center justify-center overflow-hidden rounded-[10px] bg-md-code-bg" style={frameStyle} > - setDecoded({ sourceKey: props.sourceKey, size })} - onError={() => setFailure({ uri: props.uri, failed: true })} - /> + {props.format === "svg" ? ( + + ) : ( + setDecoded({ sourceKey: props.sourceKey, size })} + onError={() => setFailure({ uri: props.uri, failed: true })} + /> + )} @@ -146,89 +209,47 @@ export function ThreadMarkdownImageView(props: { function ThreadMarkdownImageRequest(props: { readonly uri: string; - readonly onLoad: (sourceSize: { width: number; height: number }) => void; + readonly onLoad: (sourceSize: ImageSize) => void; readonly onError: () => void; - readonly format?: "svg"; }) { const [loaded, setLoaded] = useState(false); return ( <> - {props.format === "svg" ? ( - { - setLoaded(true); - if (size) props.onLoad(size); - }} - onError={props.onError} - /> - ) : ( - { - setLoaded(true); - props.onLoad(event.source); - }} - onError={props.onError} - style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} - /> - )} - {loaded ? null : ( - - Loading image… - - )} + { + setLoaded(true); + props.onLoad(event.source); + }} + onError={props.onError} + style={{ width: "100%", height: "100%", opacity: loaded ? 1 : 0 }} + /> + {loaded ? null : } ); } -function ThreadMarkdownSvg(props: { - readonly uri: string; - readonly onLoad: (size: { width: number; height: number } | null) => void; - readonly onError: () => void; -}) { - const [ast, setAst] = useState(null); - const onLoad = useEffectEvent(props.onLoad); - const onError = useEffectEvent(props.onError); - useEffect(() => { - const controller = new AbortController(); - const load = async () => { - if (props.uri.startsWith("data:image/svg+xml")) { - const comma = props.uri.indexOf(","); - if (comma < 0) throw new Error("SVG data URI has no content"); - const content = decodeURIComponent(props.uri.slice(comma + 1)); - return props.uri.slice(0, comma).includes(";base64") - ? new TextDecoder().decode( - Uint8Array.from(atob(content), (character) => character.charCodeAt(0)), - ) - : content; - } - const response = await fetch(props.uri, { signal: controller.signal }); - if (!response.ok && !(response.status === 0 && props.uri.startsWith("file://"))) - throw new Error(`SVG request failed: ${response.status}`); - return response.text(); - }; - void load() - .then((xml) => { - const parsed = parse(xml); - if (!parsed) throw new Error("SVG has no root element"); - if (controller.signal.aborted) return; - setAst(parsed); - onLoad(resolveSvgImageSize(parsed.props)); - }) - .catch(() => { - if (!controller.signal.aborted) onError(); - }); - return () => controller.abort(); - }, [props.uri]); - return ; +function ThreadMarkdownImageLoading() { + return ( + + Loading image… + + ); +} + +function ThreadMarkdownSvg(props: { readonly ast: JsxAST | null }) { + return props.ast === null ? ( + + ) : ( + + ); } export function ThreadMarkdownImage(props: { diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 6c270a0144ccf..696c4b7b6c373 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -28,7 +28,7 @@ import { import { AuthOrchestrationOperateScope, type EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; -import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; +import { memo, useCallback, useMemo, useState, type ComponentProps } from "react"; import { Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -39,6 +39,7 @@ import { Menu } from "../../components/ui/Menu"; import { EnvironmentMachineSymbol } from "../../components/EnvironmentMachineSymbol"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { ProviderIcon, ProviderInstanceIcon } from "../../components/ProviderIcon"; +import { useNowAfter } from "../../lib/clock"; import { cn } from "../../lib/cn"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; @@ -712,22 +713,15 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); const canUnsettle = variant === "slim"; - // Keep time in state; cached render-time reads freeze the gate and trigger 50ms expiry loops. - const [snoozeGateClock, setSnoozeGateClock] = useState(() => ({ thread, now: Date.now() })); - // oxlint-disable-next-line react/purity -- Reseeds the snooze gate clock only in the render-phase adjustment that runs when the thread changes. - if (snoozeGateClock.thread !== thread) setSnoozeGateClock({ thread, now: Date.now() }); - const snoozeGateNow = new Date(snoozeGateClock.now).toISOString(); + // The gate's only time-dependent edge is the queued-turn grace window, so its + // expiry is known without a clock: evaluate at the message's own timestamp. + // A gate held by runtime status has no expiry, and no clock tick opens it. const snoozeGateExpiryMs = props.snoozeSupported - ? resolveThreadListV2SnoozeGateExpiryMs(thread, { now: snoozeGateNow }) + ? resolveThreadListV2SnoozeGateExpiryMs(thread, { now: thread.latestUserMessageAt ?? "" }) : null; - useEffect(() => { - // A gate still closed past its expiry is held by runtime status, not time, - // so no clock tick can open it. - if (snoozeGateExpiryMs === null || snoozeGateExpiryMs <= snoozeGateClock.now) return; - const delayMs = Math.min(snoozeGateExpiryMs - Date.now() + 50, 2_147_483_647); - const id = setTimeout(() => setSnoozeGateClock({ thread, now: Date.now() }), delayMs); - return () => clearTimeout(id); - }, [snoozeGateExpiryMs, snoozeGateClock.now, thread]); + // The window is inclusive, so wake just past it. + const snoozeGateNowMs = useNowAfter(snoozeGateExpiryMs === null ? null : snoozeGateExpiryMs + 1); + const snoozeGateNow = new Date(snoozeGateNowMs).toISOString(); const swipeActions = resolveThreadListV2SwipeActions({ variant, settlementSupported: props.settlementSupported, diff --git a/apps/mobile/src/features/threads/use-visible-second-clock.ts b/apps/mobile/src/features/threads/use-visible-second-clock.ts index 9abddf5ffcbf5..e48ab5a986948 100644 --- a/apps/mobile/src/features/threads/use-visible-second-clock.ts +++ b/apps/mobile/src/features/threads/use-visible-second-clock.ts @@ -1,27 +1,24 @@ import { useIsFocused } from "@react-navigation/native"; -import { useEffect, useLayoutEffect, useState } from "react"; -import { AppState } from "react-native"; +import { secondTick } from "@t3tools/client-runtime/clock"; +import { useSyncExternalStore } from "react"; -/** Hidden retained routes stay mounted; gate intervals to avoid background re-renders. */ +import { useAppActive } from "../../lib/appState"; + +const subscribeNever = () => () => undefined; + +/** + * Retained routes stay mounted when hidden, so an ungated tick would keep + * re-rendering them. Every visible label shares one module-level tick. + */ export function useVisibleSecondClock(enabled: boolean): number { const focused = useIsFocused(); - const [appActive, setAppActive] = useState(() => AppState.currentState === "active"); - const [nowMs, setNowMs] = useState(() => Date.now()); - useEffect(() => { - const subscription = AppState.addEventListener("change", (state) => - setAppActive(state === "active"), - ); - // oxlint-disable-next-line react/set-state-in-effect -- Syncs state the listener missed. - setAppActive(AppState.currentState === "active"); - return () => subscription.remove(); - }, []); + const appActive = useAppActive(); const ticking = enabled && focused && appActive; - useLayoutEffect(() => { - if (!ticking) return; - // oxlint-disable-next-line react/set-state-in-effect -- Resuming reads the clock that stood still. - setNowMs(Date.now()); - const intervalId = setInterval(() => setNowMs(Date.now()), 1_000); - return () => clearInterval(intervalId); - }, [ticking]); - return nowMs; + // While paused the snapshot still reads the shared tick, so resuming shows + // the current second rather than the one the clock stopped on. + return useSyncExternalStore( + ticking ? secondTick.subscribe : subscribeNever, + secondTick.getSnapshot, + secondTick.getSnapshot, + ); } diff --git a/apps/mobile/src/lib/clock.ts b/apps/mobile/src/lib/clock.ts index 0bdbb8ee1db1f..6d33a93148cc4 100644 --- a/apps/mobile/src/lib/clock.ts +++ b/apps/mobile/src/lib/clock.ts @@ -1,5 +1,5 @@ import { deadlineStore, minuteTick } from "@t3tools/client-runtime/clock"; -import { useSyncExternalStore } from "react"; +import { useState, useSyncExternalStore } from "react"; /** Re-renders once when `deadlineMs` (epoch ms) is reached; `null` never passes. */ export function useDeadlinePassed(deadlineMs: number | null): boolean { @@ -7,6 +7,22 @@ export function useDeadlinePassed(deadlineMs: number | null): boolean { return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); } +/** + * `Date.now()` captured at mount and re-read when `deadlineMs` changes or + * passes, so comparisons against the deadline flip exactly once. + */ +export function useNowAfter(deadlineMs: number | null): number { + const passed = useDeadlinePassed(deadlineMs); + const [snapshot, setSnapshot] = useState(() => ({ deadlineMs, passed, nowMs: Date.now() })); + if (snapshot.deadlineMs !== deadlineMs || snapshot.passed !== passed) { + // oxlint-disable-next-line react/purity -- Re-reads the clock only in the render-phase adjustment that runs when the deadline changes or passes. + const next = { deadlineMs, passed, nowMs: Date.now() }; + setSnapshot(next); + return next.nowMs; + } + return snapshot.nowMs; +} + /** * `minuteTick`'s UTC `YYYY-MM-DDTHH:mm` as epoch ms. The snapshot carries no * offset, and `Date.parse` reads offset-less date-times as local time. diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index 549c49f5d56fc..bab1dd96e55d9 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -131,3 +131,20 @@ export function resolveThreadPrBadgeAction(pr: ThreadPrPresentation) { ? { kind: "list" as const, accessibilityLabel: `Show ${pr.accessibilityLabel}` } : { kind: "open" as const, accessibilityLabel: `Open ${pr.accessibilityLabel}` }; } + +/** Records `value` as the newest entry, evicting the least recently answered beyond `limit`. */ +export function rememberBounded( + current: ReadonlyMap, + key: string, + value: V, + limit: number, +): ReadonlyMap { + const next = new Map(current); + next.delete(key); + next.set(key, value); + for (const oldest of next.keys()) { + if (next.size <= limit) break; + next.delete(oldest); + } + return next; +} diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index 771b49a094bca..5664312072302 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -1,7 +1,11 @@ import { ProjectId, type ThreadPullRequestLink } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { presentThreadLinkedPullRequests, resolveThreadPrSource } from "./thread-pr-presentation"; +import { + presentThreadLinkedPullRequests, + rememberBounded, + resolveThreadPrSource, +} from "./thread-pr-presentation"; function linkedPr( number: number, @@ -117,3 +121,17 @@ describe("resolveThreadPrSource compatibility", () => { ).toMatchObject({ linkedPresentation: { number: 3, state: null }, pullRequestRef: null }); }); }); + +describe("rememberBounded", () => { + it("moves a re-answered entry to the newest slot and evicts the oldest past the limit", () => { + let snapshots: ReadonlyMap = new Map(); + snapshots = rememberBounded(snapshots, "a", 1, 2); + snapshots = rememberBounded(snapshots, "b", 2, 2); + snapshots = rememberBounded(snapshots, "a", 3, 2); + snapshots = rememberBounded(snapshots, "c", 4, 2); + expect([...snapshots]).toEqual([ + ["a", 3], + ["c", 4], + ]); + }); +}); diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index 566a5f6981f4f..e938511b88979 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -1,37 +1,61 @@ import { useAtomValue } from "@effect/atom-react"; -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { createLinkedPullRequestSummaryAtomFamily, pullRequestDetailToVcsStatus, } from "@t3tools/client-runtime/state/pull-requests"; +import type { EnvironmentId, PullRequestSummary } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; import { Atom } from "effect/reactivity"; -import { useCallback, useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { connectionAtomRuntime } from "../connection/runtime"; -import { appAtomRegistry } from "./atom-registry"; import { serverEnvironment } from "./server"; import { useEnvironmentQuery } from "./query"; import { + rememberBounded, resolveThreadPrSource, presentThreadPr, type ThreadPrPresentation, } from "./thread-pr-presentation"; -const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); -const MAX_THREAD_PR_SNAPSHOTS = 500; +const MAX_PULL_REQUEST_SNAPSHOTS = 500; -interface ThreadPrSnapshot { - readonly identity: string; - readonly presentation: ThreadPrPresentation; -} - -// Bound the cache across row virtualization instead of retaining live atoms for every past thread or PR. -const threadPrSnapshotsAtom = Atom.make>(new Map()).pipe( +// One bounded cache survives row virtualization (the summary query is dropped once idle) without +// retaining one live atom for every pull request ever seen. +const pullRequestSnapshotsAtom = Atom.make>(new Map()).pipe( Atom.keepAlive, Atom.withLabel("mobile:thread-pr-snapshots"), ); +function snapshotKey( + environmentId: EnvironmentId, + reference: { readonly projectId: string; readonly repository: string; readonly number: number }, +): string { + return JSON.stringify([ + environmentId, + reference.projectId, + reference.repository, + reference.number, + ]); +} + +const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily( + connectionAtomRuntime, + undefined, + ({ environmentId, input }, summary, registry) => + Effect.sync(() => + registry.update(pullRequestSnapshotsAtom, (current) => + rememberBounded( + current, + snapshotKey(environmentId, input), + summary, + MAX_PULL_REQUEST_SNAPSHOTS, + ), + ), + ), +); + export { presentThreadPr, type ThreadPr, @@ -55,16 +79,11 @@ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentatio ), [thread.pullRequests, thread.linkedPullRequest, thread.branchPullRequest, supportsLinks], ); - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const snapshotIdentity = JSON.stringify(pullRequestRef); - const snapshotEntry = useAtomValue( - threadPrSnapshotsAtom, - useCallback( - (current: ReadonlyMap) => current.get(threadKey), - [threadKey], - ), + const key = pullRequestRef === null ? null : snapshotKey(thread.environmentId, pullRequestRef); + // Select this row's entry so answers for other pull requests do not re-render it. + const snapshot = useAtomValue(pullRequestSnapshotsAtom, (current) => + key === null ? undefined : current.get(key), ); - const snapshot = snapshotEntry?.identity === snapshotIdentity ? snapshotEntry.presentation : null; const pullRequestSummary = useEnvironmentQuery( pullRequestRef === null ? null @@ -77,43 +96,18 @@ export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentatio }, }), ); - - const live = useMemo(() => { - if (pullRequestRef === null) return null; - const summary = pullRequestSummary.data; - return summary === null - ? undefined - : presentThreadPr(pullRequestDetailToVcsStatus(summary), { - kind: summary.provider, - name: summary.provider, - baseUrl: "", - }); - }, [pullRequestRef, pullRequestSummary.data]); - - useEffect(() => { - if (live === undefined) return; - appAtomRegistry.modify(threadPrSnapshotsAtom, (current) => { - const existing = current.get(threadKey); - if (live === null) { - if (existing === undefined) return [false, current]; - const next = new Map(current); - next.delete(threadKey); - return [true, next]; - } - if (existing?.identity === snapshotIdentity && existing.presentation === live) { - return [false, current]; - } - const next = new Map(current); - next.delete(threadKey); - next.set(threadKey, { identity: snapshotIdentity, presentation: live }); - while (next.size > MAX_THREAD_PR_SNAPSHOTS) { - const oldestKey = next.keys().next().value; - if (oldestKey === undefined) break; - next.delete(oldestKey); - } - return [true, next]; - }); - }, [live, snapshotIdentity, threadKey]); - - return linkedPresentation ?? (live === undefined ? snapshot : live); + const summary = pullRequestRef === null ? undefined : (pullRequestSummary.data ?? snapshot); + // Rows pass `pr` into callbacks; a fresh object per render would invalidate them. + const summaryPresentation = useMemo( + () => + summary === undefined + ? null + : presentThreadPr(pullRequestDetailToVcsStatus(summary), { + kind: summary.provider, + name: summary.provider, + baseUrl: "", + }), + [summary], + ); + return linkedPresentation ?? summaryPresentation; }