diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 9078b0f1c3..1f61d0a14c 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -32,7 +32,7 @@ vi.mock("react-native-unistyles", () => ({ withUnistyles: (Component: React.ComponentType) => Component, })); -function userMessage(index: number): StreamItem { +function userMessage(index: number): Extract { return { kind: "user_message", id: `message-${index}`, @@ -55,6 +55,30 @@ function createRenderers(onRowRender: () => void): StreamSegmentRenderers { }; } +function MeasuredTestRow({ item }: { item: StreamItem }) { + const [loaded, setLoaded] = React.useState(false); + const load = React.useCallback(() => setLoaded(true), []); + const height = item.kind === "user_message" && item.text === "grown" ? 120 : 24; + const measure = React.useCallback( + (node: HTMLDivElement | null) => { + if (!node?.parentElement) return; + Object.defineProperty(node.parentElement, "offsetHeight", { + configurable: true, + value: height, + }); + }, + [height], + ); + return ( +
+ + {item.id} +
+ ); +} + describe("createWebStreamStrategy", () => { let root: Root | null = null; let container: HTMLDivElement | null = null; @@ -155,6 +179,75 @@ describe("createWebStreamStrategy", () => { expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length); }); + it("retains a row's DOM and local state when recent history becomes virtualized", () => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const item = userMessage(0); + const renderRow = (rowItem: StreamItem) => ; + const input: StreamRenderInput = { + agentId: "agent", + segments: { historyVirtualized: [], historyMounted: [item], liveHead: [] }, + boundary: { hasVirtualizedHistory: false, hasMountedHistory: true, hasLiveHead: false }, + renderers: { + renderHistoryVirtualizedRow: renderRow, + renderHistoryMountedRow: renderRow, + renderLiveHeadRow: renderRow, + renderLiveAuxiliary: () => null, + }, + listEmptyComponent: null, + viewportRef: React.createRef(), + routeBottomAnchorRequest: null, + isAuthoritativeHistoryReady: true, + onNearBottomChange: () => {}, + onNearHistoryStart: () => true, + isLoadingOlderHistory: false, + hasOlderHistory: false, + olderHistoryProgressKey: null, + scrollEnabled: true, + listStyle: null, + baseListContentContainerStyle: null, + forwardListContentContainerStyle: null, + contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH, + }; + act(() => root?.render(strategy.render(input))); + const row = container.querySelector('[data-history-row-id="message-0"]')!; + act(() => row.querySelector("button")!.click()); + expect(row.querySelector("button")!.textContent).toBe("Loaded"); + const viewport = container.querySelector('[data-testid="agent-chat-scroll"]')!; + Object.defineProperty(viewport, "clientHeight", { configurable: true, value: 500 }); + act(() => + root?.render( + strategy.render({ + ...input, + segments: { historyVirtualized: [item], historyMounted: [userMessage(1)], liveHead: [] }, + boundary: { ...input.boundary, hasVirtualizedHistory: true }, + }), + ), + ); + expect(container.querySelector('[data-history-row-id="message-0"]')).toBe(row); + expect(row.isConnected).toBe(true); + expect(row.querySelector("button")!.textContent).toBe("Loaded"); + act(() => + root?.render( + strategy.render({ + ...input, + segments: { + historyVirtualized: [{ ...item, text: "grown" }, userMessage(1)], + historyMounted: [userMessage(2)], + liveHead: [], + }, + boundary: { ...input.boundary, hasVirtualizedHistory: true }, + }), + ), + ); + expect( + (container.querySelector('[data-history-row-id="message-1"]') as HTMLElement).style.transform, + ).toBe("translateY(120px)"); + expect(row.querySelector("button")!.textContent).toBe("Loaded"); + }); + it("keeps the timeline width stable with an overlay scrollbar", () => { const strategy = createWebStreamStrategy({ isMobileBreakpoint: false }); container = document.createElement("div"); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index d0b7eda0df..226b7c1e2a 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -20,6 +20,7 @@ import { withUnistyles } from "react-native-unistyles"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { useStableEvent } from "@/hooks/use-stable-event"; +import type { StreamItem } from "@/types/stream"; import type { Theme } from "@/styles/theme"; import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar"; @@ -76,6 +77,8 @@ const historyStartSlotStyle: CSSProperties = { flexShrink: 0, }; +const streamRowsHostStyle: CSSProperties = { position: "relative", width: "100%" }; + const streamRowStyle: CSSProperties = { display: "flex", flexDirection: "column", @@ -268,6 +271,23 @@ function isScrollContainerOverscrolledPastBottom( return getScrollContainerDistanceFromBottom(scrollContainer) < -BOTTOM_OVERSCROLL_TOLERANCE_PX; } +// Position updates do not change a row's content. Keep renderer work separate +// from virtual measurements while retaining the same component across lanes. +const StreamRowContent = React.memo(function StreamRowContent({ + item, + index, + items, + render, +}: { + revision?: StreamRenderInput["liveHeadRowRevision"]; + item: StreamItem; + index: number; + items: StreamItem[]; + render: StreamRenderInput["renderers"]["renderHistoryVirtualizedRow"]; +}) { + return render(item, index, items); +}); + function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: boolean }) { const { segments: inputSegments, @@ -425,6 +445,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool const rowVirtualizer = useVirtualizer({ count: segments.historyVirtualized.length, enabled: shouldUseVirtualizer, + initialRect: { + width: scrollContainerRef.current?.clientWidth ?? 0, + height: scrollContainerRef.current?.clientHeight ?? 0, + }, getScrollElement: () => scrollContainerRef.current, getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index, estimateSize: (index: number) => { @@ -536,25 +560,31 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scheduler.schedule(); }); - // ResizeObserver runs before paint. Batch measurements and commit React's new - // positions here so a resized image cannot overlap rows for an extra frame. - // Use resizeItem exclusively; measureElement would install a second observer. + const measureVirtualizedRows = useStableEvent(() => { + let resized = false; + for (const element of contentRef.current?.querySelectorAll( + "[data-history-row-id][data-index]", + ) ?? []) { + const index = Number(element.dataset.index); + const height = measureVirtualElement(element, undefined, rowVirtualizer); + if (rowVirtualizer.measurementsCache[index]?.size === height) continue; + rowVirtualizer.resizeItem(index, height); + resized = true; + } + return resized; + }); + + // Measure the committed DOM rather than queued observer entries: flushSync can + // also commit an image's pending intrinsic dimensions, invalidating those entries. const virtualRowObserver = useMemo( () => - new ResizeObserver((entries) => { + new ResizeObserver(() => { if (!isActiveRef.current) return; flushSync(() => { - for (const entry of entries) { - const element = entry.target as HTMLElement; - if (!element.isConnected) continue; - rowVirtualizer.resizeItem( - Number(element.dataset.index), - measureVirtualElement(element, entry, rowVirtualizer), - ); - } + measureVirtualizedRows(); }); }), - [rowVirtualizer], + [measureVirtualizedRows], ); const measureVirtualizedRowElement = useCallback( (node: HTMLDivElement | null) => { @@ -731,8 +761,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool // Apply row positions and their scroll correction in the same paint, including // the ordinary-to-virtualized handoff and measurements of newly mounted rows. useLayoutEffect(() => { + // Commit positions for every resized row before accepting reading geometry. + // Child layout can change during this commit, ahead of ResizeObserver delivery. + if (!isActive || measureVirtualizedRows()) return; reconcileReadingPosition(); - }, [contentWidth, isActive, segments, virtualRows, reconcileReadingPosition]); + }, [ + contentWidth, + isActive, + segments, + virtualRows, + measureVirtualizedRows, + reconcileReadingPosition, + ]); const stopFollowingOutputFromUserIntent = useStableEvent(() => { cancelPendingStickToBottom(); @@ -915,6 +955,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool evaluateHistoryStart(); } const observer = new ResizeObserver(() => { + flushSync(() => { + measureVirtualizedRows(); + }); const nextGeometry = getObservedViewportGeometry(scrollContainer); if (pendingResumeGeometryCheckRef.current) { pendingResumeGeometryCheckRef.current = false; @@ -953,6 +996,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool scheduleHistoryStartPrependSettle, scheduleStickToBottom, updateScrollMetrics, + measureVirtualizedRows, ]); useLayoutEffect(() => { @@ -1171,13 +1215,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool }), [], ); - const virtualRowsContainerStyle = useMemo((): CSSProperties => { - return { - position: "relative", - width: "100%", + const virtualRowsContainerStyle = useMemo( + (): CSSProperties => ({ height: virtualTotalSize, - }; - }, [virtualTotalSize]); + flexShrink: 0, + }), + [virtualTotalSize], + ); const renderVirtualRowStyle = useCallback( (start: number): CSSProperties => ({ position: "absolute", @@ -1198,12 +1242,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool data-message-id={getStreamItemMessageId(item)} style={streamRowStyle} > - {renderHistoryMountedRow(item, index, segments.historyMounted)} + )); }, [renderHistoryMountedRow, segments.historyMounted]); const liveHeadRows = useMemo(() => { - void liveHeadRowRevision; return segments.liveHead.map((item, index) => (
- {renderLiveHeadRow(item, index, segments.liveHead)} +
)); }, [liveHeadRowRevision, renderLiveHeadRow, segments.liveHead]); @@ -1251,13 +1305,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool >
{historyStartSlot} - {shouldUseVirtualizer ? ( -
- {virtualRows.map((virtualRow) => { +
+
+ {[ + ...virtualRows.map((virtualRow) => { const item = segments.historyVirtualized[virtualRow.index]; - if (!item) { - return null; - } + if (!item) return null; return (
- {renderHistoryVirtualizedRow( - item, - virtualRow.index, - segments.historyVirtualized, - )} +
); - })} -
- ) : null} - {mountedRows} + }), + ...mountedRows, + ]} +
{liveAuxiliary} {shouldRenderEmpty ? listEmptyComponent : null}