@hexclave/dashboard-ui-components 1.0.46 → 1.0.48
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/dist/components/data-grid/data-grid.js +4 -2
- package/dist/components/data-grid/data-grid.js.map +1 -1
- package/dist/components/data-grid/data-grid.test.js +96 -0
- package/dist/components/data-grid/data-grid.test.js.map +1 -1
- package/dist/dashboard-ui-components.global.js +4 -2
- package/dist/dashboard-ui-components.global.js.map +2 -2
- package/dist/data-grid-AJi1TNDa.d.ts.map +1 -1
- package/dist/esm/components/data-grid/data-grid.d.ts.map +1 -1
- package/dist/esm/components/data-grid/data-grid.js +4 -2
- package/dist/esm/components/data-grid/data-grid.js.map +1 -1
- package/dist/esm/components/data-grid/data-grid.test.js +99 -3
- package/dist/esm/components/data-grid/data-grid.test.js.map +1 -1
- package/package.json +3 -3
- package/src/components/data-grid/data-grid.test.tsx +131 -2
- package/src/components/data-grid/data-grid.tsx +13 -2
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
|
-
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
|
4
|
-
import { useState } from "react";
|
|
3
|
+
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
import {
|
|
7
7
|
createDefaultDataGridState,
|
|
8
8
|
DataGrid,
|
|
9
9
|
isDataGridInteractiveRowClickTarget,
|
|
10
|
+
useDataSource,
|
|
10
11
|
type DataGridColumnDef,
|
|
12
|
+
type DataGridDataSource,
|
|
11
13
|
type DataGridProps,
|
|
12
14
|
} from "./index";
|
|
13
15
|
|
|
@@ -52,6 +54,7 @@ type ObserverRecord = {
|
|
|
52
54
|
};
|
|
53
55
|
|
|
54
56
|
let intersectionObserverRecords: ObserverRecord[] = [];
|
|
57
|
+
let intersectionObserverInstances: MockIntersectionObserver[] = [];
|
|
55
58
|
|
|
56
59
|
class MockIntersectionObserver implements IntersectionObserver {
|
|
57
60
|
readonly root: Element | Document | null;
|
|
@@ -74,6 +77,7 @@ class MockIntersectionObserver implements IntersectionObserver {
|
|
|
74
77
|
: [options?.threshold ?? 0];
|
|
75
78
|
this.record = { options };
|
|
76
79
|
intersectionObserverRecords.push(this.record);
|
|
80
|
+
intersectionObserverInstances.push(this);
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
disconnect() {}
|
|
@@ -266,6 +270,131 @@ describe("DataGrid infinite scroll observer", () => {
|
|
|
266
270
|
});
|
|
267
271
|
});
|
|
268
272
|
|
|
273
|
+
// Drives a real `useDataSource` infinite-scroll grid whose data source
|
|
274
|
+
// always reports `hasMore: true`, mirroring a project with a long
|
|
275
|
+
// transaction / customer history (e.g. the transactions table and customers
|
|
276
|
+
// tab). Used to prove the sentinel doesn't thrash its IntersectionObserver.
|
|
277
|
+
function InfiniteScrollLoadMoreHarness({ onFetch }: { onFetch: () => void }) {
|
|
278
|
+
const [state, setState] = useState(() => createDefaultDataGridState(columns));
|
|
279
|
+
const dataSource = useMemo<DataGridDataSource<Row>>(
|
|
280
|
+
() => {
|
|
281
|
+
let page = 0;
|
|
282
|
+
return async function* () {
|
|
283
|
+
onFetch();
|
|
284
|
+
const current = page++;
|
|
285
|
+
yield {
|
|
286
|
+
rows: [{ id: `row-${current}`, name: `Row ${current}` }],
|
|
287
|
+
hasMore: true,
|
|
288
|
+
nextCursor: `cursor-${current}`,
|
|
289
|
+
};
|
|
290
|
+
};
|
|
291
|
+
},
|
|
292
|
+
[onFetch],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const gridData = useDataSource<Row>({
|
|
296
|
+
dataSource,
|
|
297
|
+
columns,
|
|
298
|
+
getRowId: (row) => row.id,
|
|
299
|
+
sorting: state.sorting,
|
|
300
|
+
quickSearch: state.quickSearch,
|
|
301
|
+
pagination: state.pagination,
|
|
302
|
+
paginationMode: "infinite",
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
return (
|
|
306
|
+
<div style={{ height: 400 }}>
|
|
307
|
+
<DataGrid<Row>
|
|
308
|
+
columns={columns}
|
|
309
|
+
rows={gridData.rows}
|
|
310
|
+
getRowId={(row) => row.id}
|
|
311
|
+
state={state}
|
|
312
|
+
onChange={setState}
|
|
313
|
+
paginationMode="infinite"
|
|
314
|
+
hasMore={gridData.hasMore}
|
|
315
|
+
isLoading={gridData.isLoading}
|
|
316
|
+
isLoadingMore={gridData.isLoadingMore}
|
|
317
|
+
onLoadMore={gridData.loadMore}
|
|
318
|
+
/>
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
describe("DataGrid infinite scroll observer stability", () => {
|
|
324
|
+
beforeEach(() => {
|
|
325
|
+
intersectionObserverRecords = [];
|
|
326
|
+
intersectionObserverInstances = [];
|
|
327
|
+
|
|
328
|
+
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
|
|
329
|
+
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
|
330
|
+
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
|
|
331
|
+
function getBoundingClientRect() {
|
|
332
|
+
return {
|
|
333
|
+
x: 0,
|
|
334
|
+
y: 0,
|
|
335
|
+
width: 320,
|
|
336
|
+
height: 44,
|
|
337
|
+
top: 0,
|
|
338
|
+
left: 0,
|
|
339
|
+
right: 320,
|
|
340
|
+
bottom: 44,
|
|
341
|
+
toJSON() {
|
|
342
|
+
return this;
|
|
343
|
+
},
|
|
344
|
+
} as DOMRect;
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
|
|
348
|
+
configurable: true,
|
|
349
|
+
get() {
|
|
350
|
+
return 400;
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
|
|
354
|
+
configurable: true,
|
|
355
|
+
get() {
|
|
356
|
+
return 400;
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
afterEach(() => {
|
|
362
|
+
cleanup();
|
|
363
|
+
vi.restoreAllMocks();
|
|
364
|
+
vi.unstubAllGlobals();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Regression: the sentinel used to re-create its IntersectionObserver every
|
|
368
|
+
// time the `onLoadMore` callback changed identity (which happens on every
|
|
369
|
+
// `isLoadingMore` / `hasMore` toggle). A freshly-created observer re-reports
|
|
370
|
+
// the sentinel's current intersection state, so a sentinel that stays in
|
|
371
|
+
// view fires `onLoadMore` again after every page — auto-loading the entire
|
|
372
|
+
// history back-to-back and OOM-crashing the tab ("Aw snap") on large
|
|
373
|
+
// datasets. The observer must stay stable across load-more cycles.
|
|
374
|
+
it("does not re-create the observer on load-more cycles", async () => {
|
|
375
|
+
const onFetch = vi.fn();
|
|
376
|
+
render(<InfiniteScrollLoadMoreHarness onFetch={onFetch} />);
|
|
377
|
+
|
|
378
|
+
// Initial page load, after which the sentinel (and its observer) mounts.
|
|
379
|
+
await waitFor(() => expect(onFetch).toHaveBeenCalledTimes(1));
|
|
380
|
+
await waitFor(() => expect(intersectionObserverInstances.length).toBeGreaterThan(0));
|
|
381
|
+
|
|
382
|
+
const observersBeforeLoadMore = intersectionObserverInstances.length;
|
|
383
|
+
|
|
384
|
+
// Simulate the sentinel scrolling into view exactly once.
|
|
385
|
+
await act(async () => {
|
|
386
|
+
intersectionObserverInstances.at(-1)?.trigger();
|
|
387
|
+
await Promise.resolve();
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
await waitFor(() => expect(onFetch).toHaveBeenCalledTimes(2));
|
|
391
|
+
|
|
392
|
+
// A single scroll-in must trigger a single fetch, without spawning new
|
|
393
|
+
// observers — otherwise each new observer would re-fire and runaway.
|
|
394
|
+
expect(intersectionObserverInstances.length).toBe(observersBeforeLoadMore);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
269
398
|
describe("DataGrid controlled callbacks", () => {
|
|
270
399
|
beforeEach(() => {
|
|
271
400
|
vi.stubGlobal("ResizeObserver", MockResizeObserver);
|
|
@@ -455,16 +455,27 @@ function InfiniteScrollSentinel({
|
|
|
455
455
|
strings: DataGridStrings;
|
|
456
456
|
}) {
|
|
457
457
|
const ref = useRef<HTMLDivElement>(null);
|
|
458
|
+
// Read `onIntersect` through a ref so the observer never has to be
|
|
459
|
+
// re-created when the callback identity changes. `onLoadMore` (and thus
|
|
460
|
+
// `onIntersect`) changes on every `isLoadingMore`/`hasMore` toggle; if the
|
|
461
|
+
// observer were re-created each time, a freshly-created observer re-reports
|
|
462
|
+
// the sentinel's *current* intersection state, so a sentinel that stays in
|
|
463
|
+
// view keeps firing `onIntersect` after every page. That auto-loads the
|
|
464
|
+
// entire dataset back-to-back (defeating "load on scroll") and OOM-crashes
|
|
465
|
+
// the tab on long transaction/customer histories. A single stable observer
|
|
466
|
+
// only fires on genuine intersection *changes* (real user scrolls).
|
|
467
|
+
const onIntersectRef = useRef(onIntersect);
|
|
468
|
+
onIntersectRef.current = onIntersect;
|
|
458
469
|
useEffect(() => {
|
|
459
470
|
const el = ref.current;
|
|
460
471
|
if (!el) return;
|
|
461
472
|
const observer = new IntersectionObserver(
|
|
462
|
-
(entries) => { if (entries[0]?.isIntersecting)
|
|
473
|
+
(entries) => { if (entries[0]?.isIntersecting) onIntersectRef.current(); },
|
|
463
474
|
{ root: rootRef?.current ?? null, rootMargin: "200px" },
|
|
464
475
|
);
|
|
465
476
|
observer.observe(el);
|
|
466
477
|
return () => observer.disconnect();
|
|
467
|
-
}, [
|
|
478
|
+
}, [rootRef]);
|
|
468
479
|
return (
|
|
469
480
|
<div ref={ref} className="flex items-center justify-center py-4">
|
|
470
481
|
{isLoading && (
|