From d2c372d78b40f6b9b429ed92a123af0537593862 Mon Sep 17 00:00:00 2001 From: Kevin Le Date: Fri, 23 Oct 2026 22:08:33 +0700 Subject: [PATCH] fix(app): keep chat history from crashing when two rows share an id The chat virtualizer caches measured row sizes by key, and the key was the row id. When two history rows had the same id, both rows wrote the same cache entry. Each measurement pass set the size to the other row's height, so the layout effect re-rendered without end and React aborted with "Maximum update depth exceeded" (minified error #185). Give each virtualized row a unique key. A repeated id gets a numeric suffix, and the first occurrence keeps its plain id so the reading anchor still matches it. --- .../src/agent-stream/strategy-web.test.tsx | 60 +++++++++++++++++++ .../app/src/agent-stream/strategy-web.tsx | 14 ++++- 2 files changed, 73 insertions(+), 1 deletion(-) 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);