@hexclave/dashboard-ui-components 1.0.48 → 1.0.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hexclave/dashboard-ui-components",
3
- "version": "1.0.48",
3
+ "version": "1.0.51",
4
4
  "repository": "https://github.com/hexclave/hexclave",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "@tanstack/react-table": "^8.21.3",
48
48
  "@tanstack/react-virtual": "^3.13.0",
49
49
  "class-variance-authority": "^0.7.0",
50
- "@hexclave/shared": "1.0.48",
51
- "@hexclave/ui": "1.0.48"
50
+ "@hexclave/shared": "1.0.51",
51
+ "@hexclave/ui": "1.0.51"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/react": "^19.0.0",
@@ -166,6 +166,24 @@ function DataGridHarness(props: { fillHeight?: boolean }) {
166
166
  );
167
167
  }
168
168
 
169
+ function PaginatedDataGridHarness() {
170
+ const [state, setState] = useState(() => createDefaultDataGridState(columns));
171
+
172
+ return (
173
+ <div style={{ height: 400 }}>
174
+ <DataGrid<Row>
175
+ columns={columns}
176
+ rows={[{ id: "row-1", name: "Row 1" }]}
177
+ getRowId={(row) => row.id}
178
+ state={state}
179
+ onChange={setState}
180
+ paginationMode="paginated"
181
+ fillHeight={false}
182
+ />
183
+ </div>
184
+ );
185
+ }
186
+
169
187
  function InteractiveDataGridHarness(props: {
170
188
  onSortChange?: DataGridProps<Row>["onSortChange"],
171
189
  onSelectionChange?: DataGridProps<Row>["onSelectionChange"],
@@ -259,14 +277,39 @@ describe("DataGrid infinite scroll observer", () => {
259
277
  expect(intersectionObserverRecords.at(-1)?.options?.root).toBe(scrollContainer);
260
278
  });
261
279
 
262
- it("falls back to the viewport when the page owns vertical scrolling", async () => {
263
- render(<DataGridHarness fillHeight={false} />);
280
+ // Regression: an infinite grid left unbounded (`fillHeight={false}`, no `maxHeight`) used to
281
+ // grow its scroll container to fit every loaded row, which defeats virtualization (the
282
+ // virtualizer measures the container as fully visible and mounts every row) and OOMs the tab on
283
+ // large datasets. Such grids now fall back to a default `maxHeight`, so the grid owns its own
284
+ // bounded scroll container and observes against it rather than the viewport.
285
+ it("bounds an unbounded infinite grid and observes against its own scroll container", async () => {
286
+ const { container } = render(<DataGridHarness fillHeight={false} />);
264
287
 
265
288
  await waitFor(() => {
266
289
  expect(intersectionObserverRecords.length).toBeGreaterThan(0);
267
290
  });
268
291
 
269
- expect(intersectionObserverRecords.at(-1)?.options?.root ?? null).toBeNull();
292
+ const grid = container.querySelector('[role="grid"]');
293
+ expect(grid).not.toBeNull();
294
+ const scrollContainer = grid?.children.item(1);
295
+
296
+ expect(intersectionObserverRecords.at(-1)?.options?.root).toBe(scrollContainer);
297
+ });
298
+
299
+ it("applies a default maxHeight to an otherwise-unbounded infinite grid", () => {
300
+ const { container } = render(<DataGridHarness fillHeight={false} />);
301
+
302
+ const grid = container.querySelector<HTMLElement>('[role="grid"]');
303
+ expect(grid).not.toBeNull();
304
+ expect(grid?.style.maxHeight).toBe("calc(100dvh - 16rem)");
305
+ });
306
+
307
+ it("does not force a maxHeight onto a paginated grid", () => {
308
+ const { container } = render(<PaginatedDataGridHarness />);
309
+
310
+ const grid = container.querySelector<HTMLElement>('[role="grid"]');
311
+ expect(grid).not.toBeNull();
312
+ expect(grid?.style.maxHeight).toBe("");
270
313
  });
271
314
  });
272
315
 
@@ -58,6 +58,11 @@ import type {
58
58
  RowId,
59
59
  } from "./types";
60
60
 
61
+ // Viewport-relative fallback height for infinite-scroll grids that the caller left unbounded
62
+ // (no `fillHeight`, no `maxHeight`). Leaves ~16rem of room for the top bar, page header, and grid
63
+ // toolbar. See the `effectiveMaxHeight` comment in DataGrid for why an infinite grid must be bounded.
64
+ const DEFAULT_INFINITE_MAX_HEIGHT = "calc(100dvh - 16rem)";
65
+
61
66
  // ─── Row click target ────────────────────────────────────────────────
62
67
 
63
68
  function getEventTargetElement(target: EventTarget | null): Element | null {
@@ -997,8 +1002,18 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
997
1002
 
998
1003
  const allSelected = rowIds.length > 0 && rowIds.every((id) => state.selection.selectedIds.has(id));
999
1004
  const someSelected = !allSelected && rowIds.some((id) => state.selection.selectedIds.has(id));
1005
+
1006
+ // An infinite-scroll grid MUST have a height-bounded scroll container, otherwise the container
1007
+ // grows to fit every loaded row, the virtualizer measures it as fully visible, and it renders
1008
+ // all rows into the DOM — unbounded memory growth that eventually OOMs the tab (rows never stop
1009
+ // accumulating as the user scrolls). When the caller didn't bound it (no fillHeight, no maxHeight),
1010
+ // fall back to a viewport-relative cap so virtualization can actually window. Only applied to
1011
+ // infinite mode; paginated grids cap rows at the page size and are safe to render unbounded.
1012
+ const effectiveMaxHeight =
1013
+ maxHeight ?? (paginationMode === "infinite" && !fillHeight ? DEFAULT_INFINITE_MAX_HEIGHT : undefined);
1014
+
1000
1015
  const infiniteScrollRootRef =
1001
- paginationMode === "infinite" && (fillHeight || maxHeight != null)
1016
+ paginationMode === "infinite" && (fillHeight || effectiveMaxHeight != null)
1002
1017
  ? scrollContainerRef
1003
1018
  : undefined;
1004
1019
 
@@ -1016,7 +1031,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
1016
1031
  return m;
1017
1032
  }, [headers]);
1018
1033
 
1019
- const isBounded = fillHeight || maxHeight != null;
1034
+ const isBounded = fillHeight || effectiveMaxHeight != null;
1020
1035
 
1021
1036
  return (
1022
1037
  <>
@@ -1036,7 +1051,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
1036
1051
  isBounded && "overflow-hidden",
1037
1052
  className,
1038
1053
  )}
1039
- style={maxHeight != null ? { ...cssVars, maxHeight } : cssVars}
1054
+ style={effectiveMaxHeight != null ? { ...cssVars, maxHeight: effectiveMaxHeight } : cssVars}
1040
1055
  role="grid"
1041
1056
  aria-rowcount={totalRowCount ?? rows.length}
1042
1057
  aria-colcount={visibleColumns.length}
@@ -1044,7 +1059,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
1044
1059
  <div
1045
1060
  ref={stickyChromeRef}
1046
1061
  className="sticky z-30 w-full min-w-0 shrink-0 overflow-visible rounded-t-[calc(var(--radius)*2)] bg-white/90 dark:bg-background backdrop-blur-xl"
1047
- style={{ top: stickyTop ?? (maxHeight != null ? 0 : "var(--data-grid-sticky-top, 0px)") }}
1062
+ style={{ top: stickyTop ?? (effectiveMaxHeight != null ? 0 : "var(--data-grid-sticky-top, 0px)") }}
1048
1063
  >
1049
1064
  {toolbar !== false && (
1050
1065
  <div className="relative bg-transparent">