mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user