diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 5ad0be03c2..9e0fd81523 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -252,6 +252,66 @@ describe("createWebStreamStrategy", () => { expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length); }); + it("settles row measurement when two history rows share an id", () => { + const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const renderRow = (rowItem: StreamItem) => ; + const duplicate = userMessage(2); + const historyVirtualized = [ + userMessage(0), + userMessage(1), + { ...duplicate, text: "grown" }, + duplicate, + userMessage(3), + ]; + const input: StreamRenderInput = { + agentId: "agent", + segments: { historyVirtualized, historyMounted: [], liveHead: [] }, + boundary: { hasVirtualizedHistory: true, hasMountedHistory: false, 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, + }; + + // Before the fix, this render aborted with "Maximum update depth exceeded". + act(() => root?.render(strategy.render(input))); + + const rows = [...container.querySelectorAll("[data-history-row-id]")]; + expect(rows.map((row) => row.dataset.historyRowId)).toEqual([ + "message-0", + "message-1", + "message-2", + "message-2", + "message-3", + ]); + expect(rows.map((row) => row.style.transform)).toEqual([ + "translateY(0px)", + "translateY(24px)", + "translateY(48px)", + "translateY(168px)", + "translateY(192px)", + ]); + }); + it("retains a row's DOM and local state when recent history becomes virtualized", () => { const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); 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 9dae42bcc0..f63b34c1c9 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -443,6 +443,18 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool unsubscribe?.(); }; }, []); + // The virtualizer caches measured sizes by key. Two rows with one key overwrite + // each other's size on every measurement pass, so the layout never settles and + // React aborts with "Maximum update depth exceeded". A repeated id gets a suffix. + const historyVirtualizedKeys = useMemo(() => { + const used = new Set(); + return segments.historyVirtualized.map((row) => { + let key = row.id; + for (let repeat = 1; used.has(key); repeat++) key = `${row.id}:${repeat}`; + used.add(key); + return key; + }); + }, [segments.historyVirtualized]); const contentWidth = contentRef.current?.getBoundingClientRect().width; const rowVirtualizer = useVirtualizer({ count: segments.historyVirtualized.length, @@ -452,7 +464,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool height: scrollContainerRef.current?.clientHeight ?? 0, }, getScrollElement: () => scrollContainerRef.current, - getItemKey: (index: number) => segments.historyVirtualized[index]?.id ?? index, + getItemKey: (index: number) => historyVirtualizedKeys[index] ?? index, estimateSize: (index: number) => { const row = segments.historyVirtualized[index]; const measured = row && measuredRowHeights.current.get(row.id);