diff --git a/packages/dashboard-ui-components/src/components/data-grid/data-grid.test.tsx b/packages/dashboard-ui-components/src/components/data-grid/data-grid.test.tsx index 08a76d51a..dbccca860 100644 --- a/packages/dashboard-ui-components/src/components/data-grid/data-grid.test.tsx +++ b/packages/dashboard-ui-components/src/components/data-grid/data-grid.test.tsx @@ -166,6 +166,24 @@ function DataGridHarness(props: { fillHeight?: boolean }) { ); } +function PaginatedDataGridHarness() { + const [state, setState] = useState(() => createDefaultDataGridState(columns)); + + return ( +
+ + columns={columns} + rows={[{ id: "row-1", name: "Row 1" }]} + getRowId={(row) => row.id} + state={state} + onChange={setState} + paginationMode="paginated" + fillHeight={false} + /> +
+ ); +} + function InteractiveDataGridHarness(props: { onSortChange?: DataGridProps["onSortChange"], onSelectionChange?: DataGridProps["onSelectionChange"], @@ -259,14 +277,39 @@ describe("DataGrid infinite scroll observer", () => { expect(intersectionObserverRecords.at(-1)?.options?.root).toBe(scrollContainer); }); - it("falls back to the viewport when the page owns vertical scrolling", async () => { - render(); + // Regression: an infinite grid left unbounded (`fillHeight={false}`, no `maxHeight`) used to + // grow its scroll container to fit every loaded row, which defeats virtualization (the + // virtualizer measures the container as fully visible and mounts every row) and OOMs the tab on + // large datasets. Such grids now fall back to a default `maxHeight`, so the grid owns its own + // bounded scroll container and observes against it rather than the viewport. + it("bounds an unbounded infinite grid and observes against its own scroll container", async () => { + const { container } = render(); await waitFor(() => { expect(intersectionObserverRecords.length).toBeGreaterThan(0); }); - expect(intersectionObserverRecords.at(-1)?.options?.root ?? null).toBeNull(); + const grid = container.querySelector('[role="grid"]'); + expect(grid).not.toBeNull(); + const scrollContainer = grid?.children.item(1); + + expect(intersectionObserverRecords.at(-1)?.options?.root).toBe(scrollContainer); + }); + + it("applies a default maxHeight to an otherwise-unbounded infinite grid", () => { + const { container } = render(); + + const grid = container.querySelector('[role="grid"]'); + expect(grid).not.toBeNull(); + expect(grid?.style.maxHeight).toBe("calc(100dvh - 16rem)"); + }); + + it("does not force a maxHeight onto a paginated grid", () => { + const { container } = render(); + + const grid = container.querySelector('[role="grid"]'); + expect(grid).not.toBeNull(); + expect(grid?.style.maxHeight).toBe(""); }); }); diff --git a/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx b/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx index 7c4522e08..263a5452d 100644 --- a/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx +++ b/packages/dashboard-ui-components/src/components/data-grid/data-grid.tsx @@ -58,6 +58,11 @@ import type { RowId, } from "./types"; +// Viewport-relative fallback height for infinite-scroll grids that the caller left unbounded +// (no `fillHeight`, no `maxHeight`). Leaves ~16rem of room for the top bar, page header, and grid +// toolbar. See the `effectiveMaxHeight` comment in DataGrid for why an infinite grid must be bounded. +const DEFAULT_INFINITE_MAX_HEIGHT = "calc(100dvh - 16rem)"; + // ─── Row click target ──────────────────────────────────────────────── function getEventTargetElement(target: EventTarget | null): Element | null { @@ -997,8 +1002,18 @@ export function DataGrid(props: DataGridProps) { const allSelected = rowIds.length > 0 && rowIds.every((id) => state.selection.selectedIds.has(id)); const someSelected = !allSelected && rowIds.some((id) => state.selection.selectedIds.has(id)); + + // An infinite-scroll grid MUST have a height-bounded scroll container, otherwise the container + // grows to fit every loaded row, the virtualizer measures it as fully visible, and it renders + // all rows into the DOM — unbounded memory growth that eventually OOMs the tab (rows never stop + // accumulating as the user scrolls). When the caller didn't bound it (no fillHeight, no maxHeight), + // fall back to a viewport-relative cap so virtualization can actually window. Only applied to + // infinite mode; paginated grids cap rows at the page size and are safe to render unbounded. + const effectiveMaxHeight = + maxHeight ?? (paginationMode === "infinite" && !fillHeight ? DEFAULT_INFINITE_MAX_HEIGHT : undefined); + const infiniteScrollRootRef = - paginationMode === "infinite" && (fillHeight || maxHeight != null) + paginationMode === "infinite" && (fillHeight || effectiveMaxHeight != null) ? scrollContainerRef : undefined; @@ -1016,7 +1031,7 @@ export function DataGrid(props: DataGridProps) { return m; }, [headers]); - const isBounded = fillHeight || maxHeight != null; + const isBounded = fillHeight || effectiveMaxHeight != null; return ( <> @@ -1036,7 +1051,7 @@ export function DataGrid(props: DataGridProps) { isBounded && "overflow-hidden", className, )} - style={maxHeight != null ? { ...cssVars, maxHeight } : cssVars} + style={effectiveMaxHeight != null ? { ...cssVars, maxHeight: effectiveMaxHeight } : cssVars} role="grid" aria-rowcount={totalRowCount ?? rows.length} aria-colcount={visibleColumns.length} @@ -1044,7 +1059,7 @@ export function DataGrid(props: DataGridProps) {
{toolbar !== false && (