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);