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.
This commit is contained in:
Kevin Le
2026-11-01 22:27:28 +00:00
committed by paseo-bot[bot]
parent 7f318bf7cf
commit d2c372d78b
2 changed files with 73 additions and 1 deletions
@@ -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) => <MeasuredTestRow item={rowItem} />;
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<StreamViewportHandle>(),
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<HTMLElement>("[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");
+13 -1
View File
@@ -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<string>();
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);