@hexclave/dashboard-ui-components 1.0.49 → 1.0.52
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 +7 -5
- package/dist/components/data-grid/data-grid.js.map +1 -1
- package/dist/components/data-grid/data-grid.test.js +36 -3
- package/dist/components/data-grid/data-grid.test.js.map +1 -1
- package/dist/components/data-grid/use-data-source.js +56 -22
- package/dist/components/data-grid/use-data-source.js.map +1 -1
- package/dist/components/data-grid/use-data-source.test.d.ts +1 -0
- package/dist/components/data-grid/use-data-source.test.js +276 -0
- package/dist/components/data-grid/use-data-source.test.js.map +1 -0
- package/dist/dashboard-ui-components.global.js +74 -28
- 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 +7 -5
- package/dist/esm/components/data-grid/data-grid.js.map +1 -1
- package/dist/esm/components/data-grid/data-grid.test.js +36 -3
- package/dist/esm/components/data-grid/data-grid.test.js.map +1 -1
- package/dist/esm/components/data-grid/use-data-source.d.ts.map +1 -1
- package/dist/esm/components/data-grid/use-data-source.js +56 -22
- package/dist/esm/components/data-grid/use-data-source.js.map +1 -1
- package/dist/esm/components/data-grid/use-data-source.test.d.ts +1 -0
- package/dist/esm/components/data-grid/use-data-source.test.js +277 -0
- package/dist/esm/components/data-grid/use-data-source.test.js.map +1 -0
- package/dist/use-data-source-BVFynerX.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/components/data-grid/data-grid.test.tsx +46 -3
- package/src/components/data-grid/data-grid.tsx +22 -5
- package/src/components/data-grid/use-data-source.test.tsx +289 -0
- package/src/components/data-grid/use-data-source.ts +120 -18
|
@@ -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
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -470,7 +475,9 @@ function InfiniteScrollSentinel({
|
|
|
470
475
|
const el = ref.current;
|
|
471
476
|
if (!el) return;
|
|
472
477
|
const observer = new IntersectionObserver(
|
|
473
|
-
(entries) => {
|
|
478
|
+
(entries) => {
|
|
479
|
+
if (entries[0]?.isIntersecting) onIntersectRef.current();
|
|
480
|
+
},
|
|
474
481
|
{ root: rootRef?.current ?? null, rootMargin: "200px" },
|
|
475
482
|
);
|
|
476
483
|
observer.observe(el);
|
|
@@ -997,8 +1004,18 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
|
|
997
1004
|
|
|
998
1005
|
const allSelected = rowIds.length > 0 && rowIds.every((id) => state.selection.selectedIds.has(id));
|
|
999
1006
|
const someSelected = !allSelected && rowIds.some((id) => state.selection.selectedIds.has(id));
|
|
1007
|
+
|
|
1008
|
+
// An infinite-scroll grid MUST have a height-bounded scroll container, otherwise the container
|
|
1009
|
+
// grows to fit every loaded row, the virtualizer measures it as fully visible, and it renders
|
|
1010
|
+
// all rows into the DOM — unbounded memory growth that eventually OOMs the tab (rows never stop
|
|
1011
|
+
// accumulating as the user scrolls). When the caller didn't bound it (no fillHeight, no maxHeight),
|
|
1012
|
+
// fall back to a viewport-relative cap so virtualization can actually window. Only applied to
|
|
1013
|
+
// infinite mode; paginated grids cap rows at the page size and are safe to render unbounded.
|
|
1014
|
+
const effectiveMaxHeight =
|
|
1015
|
+
maxHeight ?? (paginationMode === "infinite" && !fillHeight ? DEFAULT_INFINITE_MAX_HEIGHT : undefined);
|
|
1016
|
+
|
|
1000
1017
|
const infiniteScrollRootRef =
|
|
1001
|
-
paginationMode === "infinite" && (fillHeight ||
|
|
1018
|
+
paginationMode === "infinite" && (fillHeight || effectiveMaxHeight != null)
|
|
1002
1019
|
? scrollContainerRef
|
|
1003
1020
|
: undefined;
|
|
1004
1021
|
|
|
@@ -1016,7 +1033,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
|
|
1016
1033
|
return m;
|
|
1017
1034
|
}, [headers]);
|
|
1018
1035
|
|
|
1019
|
-
const isBounded = fillHeight ||
|
|
1036
|
+
const isBounded = fillHeight || effectiveMaxHeight != null;
|
|
1020
1037
|
|
|
1021
1038
|
return (
|
|
1022
1039
|
<>
|
|
@@ -1036,7 +1053,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
|
|
1036
1053
|
isBounded && "overflow-hidden",
|
|
1037
1054
|
className,
|
|
1038
1055
|
)}
|
|
1039
|
-
style={
|
|
1056
|
+
style={effectiveMaxHeight != null ? { ...cssVars, maxHeight: effectiveMaxHeight } : cssVars}
|
|
1040
1057
|
role="grid"
|
|
1041
1058
|
aria-rowcount={totalRowCount ?? rows.length}
|
|
1042
1059
|
aria-colcount={visibleColumns.length}
|
|
@@ -1044,7 +1061,7 @@ export function DataGrid<TRow>(props: DataGridProps<TRow>) {
|
|
|
1044
1061
|
<div
|
|
1045
1062
|
ref={stickyChromeRef}
|
|
1046
1063
|
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 ?? (
|
|
1064
|
+
style={{ top: stickyTop ?? (effectiveMaxHeight != null ? 0 : "var(--data-grid-sticky-top, 0px)") }}
|
|
1048
1065
|
>
|
|
1049
1066
|
{toolbar !== false && (
|
|
1050
1067
|
<div className="relative bg-transparent">
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { useDataSource } from "./use-data-source";
|
|
6
|
+
import type { DataGridColumnDef, DataGridDataPaginationMode, DataGridDataSource } from "./types";
|
|
7
|
+
|
|
8
|
+
type Row = {
|
|
9
|
+
id: string,
|
|
10
|
+
name: string,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const columns: DataGridColumnDef<Row>[] = [
|
|
14
|
+
{
|
|
15
|
+
id: "name",
|
|
16
|
+
header: "Name",
|
|
17
|
+
accessor: (row) => row.name,
|
|
18
|
+
width: 160,
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function DataSourceHarness({
|
|
23
|
+
dataSource,
|
|
24
|
+
paginationMode = "infinite",
|
|
25
|
+
}: {
|
|
26
|
+
dataSource: DataGridDataSource<Row>,
|
|
27
|
+
paginationMode?: DataGridDataPaginationMode,
|
|
28
|
+
}) {
|
|
29
|
+
const gridData = useDataSource({
|
|
30
|
+
dataSource,
|
|
31
|
+
columns,
|
|
32
|
+
getRowId: (row) => row.id,
|
|
33
|
+
sorting: [],
|
|
34
|
+
quickSearch: "",
|
|
35
|
+
pagination: { pageIndex: 0, pageSize: 25 },
|
|
36
|
+
paginationMode,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<button onClick={gridData.loadMore}>Load more</button>
|
|
42
|
+
<span data-testid="row-count">{gridData.rows.length}</span>
|
|
43
|
+
<span data-testid="row-name">{gridData.rows[0]?.name ?? ""}</span>
|
|
44
|
+
<span data-testid="error">{gridData.error?.message ?? ""}</span>
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
cleanup();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("useDataSource infinite pagination", () => {
|
|
54
|
+
it("defers loadMore during the initial fetch and replays it once settled", async () => {
|
|
55
|
+
const calls: Array<{ cursor: unknown }> = [];
|
|
56
|
+
let resolveInitial: (value: void) => void = () => {};
|
|
57
|
+
const initialFetch = new Promise<void>((resolve) => {
|
|
58
|
+
resolveInitial = resolve;
|
|
59
|
+
});
|
|
60
|
+
const dataSource: DataGridDataSource<Row> = async function* (params) {
|
|
61
|
+
calls.push({ cursor: params.cursor });
|
|
62
|
+
if (calls.length === 1) {
|
|
63
|
+
await initialFetch;
|
|
64
|
+
yield {
|
|
65
|
+
rows: [{ id: "row-1", name: "Row 1" }],
|
|
66
|
+
nextCursor: "cursor-1",
|
|
67
|
+
hasMore: true,
|
|
68
|
+
};
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
yield {
|
|
72
|
+
rows: [{ id: "row-2", name: "Row 2" }],
|
|
73
|
+
nextCursor: null,
|
|
74
|
+
hasMore: false,
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const { getByRole } = render(<DataSourceHarness dataSource={dataSource} />);
|
|
79
|
+
await waitFor(() => expect(calls).toHaveLength(1));
|
|
80
|
+
|
|
81
|
+
fireEvent.click(getByRole("button", { name: "Load more" }));
|
|
82
|
+
// Not started concurrently — queued behind the in-flight initial fetch.
|
|
83
|
+
expect(calls).toHaveLength(1);
|
|
84
|
+
|
|
85
|
+
await act(async () => {
|
|
86
|
+
resolveInitial();
|
|
87
|
+
});
|
|
88
|
+
// The queued loadMore replays automatically with the completed cursor,
|
|
89
|
+
// so a sentinel that fired during the fetch is not silently dropped.
|
|
90
|
+
await waitFor(() => expect(calls).toHaveLength(2));
|
|
91
|
+
expect(calls[1]).toEqual({ cursor: "cursor-1" });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("keeps the committed cursor when an aborted reset is followed by a skipped refetch", async () => {
|
|
95
|
+
const callsA: Array<{ cursor: unknown }> = [];
|
|
96
|
+
const dataSourceA: DataGridDataSource<Row> = async function* (params) {
|
|
97
|
+
callsA.push({ cursor: params.cursor });
|
|
98
|
+
if (callsA.length === 1) {
|
|
99
|
+
yield {
|
|
100
|
+
rows: [{ id: "row-1", name: "Row 1" }],
|
|
101
|
+
nextCursor: "cursor-1",
|
|
102
|
+
hasMore: true,
|
|
103
|
+
};
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
yield {
|
|
107
|
+
rows: [{ id: "row-2", name: "Row 2" }],
|
|
108
|
+
nextCursor: null,
|
|
109
|
+
hasMore: false,
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
// Stays in flight until we resolve it, after the switch back to A has
|
|
113
|
+
// already aborted it.
|
|
114
|
+
let resolveB: (value: void) => void = () => {};
|
|
115
|
+
const dataSourceB: DataGridDataSource<Row> = async function* () {
|
|
116
|
+
await new Promise<void>((resolve) => {
|
|
117
|
+
resolveB = resolve;
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const { getByRole, getByTestId, rerender } = render(
|
|
122
|
+
<DataSourceHarness dataSource={dataSourceA} />,
|
|
123
|
+
);
|
|
124
|
+
await waitFor(() => expect(getByTestId("row-count").textContent).toBe("1"));
|
|
125
|
+
|
|
126
|
+
// Unstable dataSource identity flipping away and back to a completed
|
|
127
|
+
// reference: the B reset starts (and used to wipe cursor/pageIndex
|
|
128
|
+
// immediately), then gets aborted; the A effect run matches the
|
|
129
|
+
// completed-fetch key and skips.
|
|
130
|
+
rerender(<DataSourceHarness dataSource={dataSourceB} />);
|
|
131
|
+
rerender(<DataSourceHarness dataSource={dataSourceA} />);
|
|
132
|
+
await act(async () => {
|
|
133
|
+
resolveB();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
fireEvent.click(getByRole("button", { name: "Load more" }));
|
|
137
|
+
await waitFor(() => expect(getByTestId("row-count").textContent).toBe("2"));
|
|
138
|
+
// The append must continue from the committed cursor, not restart from
|
|
139
|
+
// the beginning with cursor === undefined.
|
|
140
|
+
expect(callsA).toEqual([{ cursor: undefined }, { cursor: "cursor-1" }]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("does not treat an aborted zero-yield reset as a completed fetch", async () => {
|
|
144
|
+
const dataSourceA: DataGridDataSource<Row> = async function* () {
|
|
145
|
+
yield {
|
|
146
|
+
rows: [{ id: "row-a", name: "A" }],
|
|
147
|
+
nextCursor: null,
|
|
148
|
+
hasMore: false,
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
let bCalls = 0;
|
|
152
|
+
let resolveFirstB: (value: void) => void = () => {};
|
|
153
|
+
const dataSourceB: DataGridDataSource<Row> = async function* () {
|
|
154
|
+
bCalls++;
|
|
155
|
+
if (bCalls === 1) {
|
|
156
|
+
// Aborted before any yield: completing this must not mark B as a
|
|
157
|
+
// successful skip key, or a later render with B would keep A's rows.
|
|
158
|
+
await new Promise<void>((resolve) => {
|
|
159
|
+
resolveFirstB = resolve;
|
|
160
|
+
});
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
yield {
|
|
164
|
+
rows: [{ id: "row-b", name: "B" }],
|
|
165
|
+
nextCursor: null,
|
|
166
|
+
hasMore: false,
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const { getByTestId, rerender } = render(
|
|
171
|
+
<DataSourceHarness dataSource={dataSourceA} />,
|
|
172
|
+
);
|
|
173
|
+
await waitFor(() => expect(getByTestId("row-name").textContent).toBe("A"));
|
|
174
|
+
|
|
175
|
+
rerender(<DataSourceHarness dataSource={dataSourceB} />);
|
|
176
|
+
rerender(<DataSourceHarness dataSource={dataSourceA} />);
|
|
177
|
+
await act(async () => {
|
|
178
|
+
resolveFirstB();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
rerender(<DataSourceHarness dataSource={dataSourceB} />);
|
|
182
|
+
await waitFor(() => expect(getByTestId("row-name").textContent).toBe("B"));
|
|
183
|
+
expect(bCalls).toBe(2);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("does not replay a deferred loadMore after the in-flight fetch errors", async () => {
|
|
187
|
+
const calls: Array<{ cursor: unknown }> = [];
|
|
188
|
+
let rejectInitial: (err: Error) => void = () => {};
|
|
189
|
+
const initialFetch = new Promise<void>((_resolve, reject) => {
|
|
190
|
+
rejectInitial = reject;
|
|
191
|
+
});
|
|
192
|
+
const dataSource: DataGridDataSource<Row> = async function* (params) {
|
|
193
|
+
calls.push({ cursor: params.cursor });
|
|
194
|
+
await initialFetch;
|
|
195
|
+
yield { rows: [], nextCursor: null, hasMore: false };
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const { getByRole, getByTestId } = render(<DataSourceHarness dataSource={dataSource} />);
|
|
199
|
+
await waitFor(() => expect(calls).toHaveLength(1));
|
|
200
|
+
|
|
201
|
+
// Deferred while the (soon-to-fail) fetch is in flight.
|
|
202
|
+
fireEvent.click(getByRole("button", { name: "Load more" }));
|
|
203
|
+
|
|
204
|
+
await act(async () => {
|
|
205
|
+
rejectInitial(new Error("fetch failed"));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// The failed fetch must surface its error and must NOT chain the deferred
|
|
209
|
+
// append (which would fetch against inconsistent state and clear the
|
|
210
|
+
// error again via setError(null)).
|
|
211
|
+
await waitFor(() => expect(getByTestId("error").textContent).toBe("fetch failed"));
|
|
212
|
+
expect(calls).toHaveLength(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("discards a deferred loadMore when pagination mode leaves infinite", async () => {
|
|
216
|
+
const calls: Array<{ cursor: unknown }> = [];
|
|
217
|
+
let resolveInitial: (value: void) => void = () => {};
|
|
218
|
+
const initialFetch = new Promise<void>((resolve) => {
|
|
219
|
+
resolveInitial = resolve;
|
|
220
|
+
});
|
|
221
|
+
const dataSource: DataGridDataSource<Row> = async function* (params) {
|
|
222
|
+
calls.push({ cursor: params.cursor });
|
|
223
|
+
if (calls.length === 1) {
|
|
224
|
+
await initialFetch;
|
|
225
|
+
yield {
|
|
226
|
+
rows: [{ id: "row-1", name: "Row 1" }],
|
|
227
|
+
nextCursor: "cursor-1",
|
|
228
|
+
hasMore: true,
|
|
229
|
+
};
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
yield {
|
|
233
|
+
rows: [{ id: "row-2", name: "Row 2" }],
|
|
234
|
+
nextCursor: null,
|
|
235
|
+
hasMore: false,
|
|
236
|
+
};
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const { getByRole, rerender } = render(
|
|
240
|
+
<DataSourceHarness dataSource={dataSource} paginationMode="infinite" />,
|
|
241
|
+
);
|
|
242
|
+
await waitFor(() => expect(calls).toHaveLength(1));
|
|
243
|
+
|
|
244
|
+
fireEvent.click(getByRole("button", { name: "Load more" }));
|
|
245
|
+
expect(calls).toHaveLength(1);
|
|
246
|
+
|
|
247
|
+
rerender(<DataSourceHarness dataSource={dataSource} paginationMode="server" />);
|
|
248
|
+
|
|
249
|
+
await act(async () => {
|
|
250
|
+
resolveInitial();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// Mode left infinite while the deferred loadMore was queued — do not
|
|
254
|
+
// append against server pagination after the fetch settles.
|
|
255
|
+
await waitFor(() => expect(calls).toHaveLength(1));
|
|
256
|
+
await act(async () => {
|
|
257
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
258
|
+
});
|
|
259
|
+
expect(calls).toHaveLength(1);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("uses the completed page cursor for the next loadMore fetch", async () => {
|
|
263
|
+
const calls: Array<{ cursor: unknown }> = [];
|
|
264
|
+
const dataSource: DataGridDataSource<Row> = async function* (params) {
|
|
265
|
+
calls.push({ cursor: params.cursor });
|
|
266
|
+
if (calls.length === 1) {
|
|
267
|
+
yield {
|
|
268
|
+
rows: [{ id: "row-1", name: "Row 1" }],
|
|
269
|
+
nextCursor: "cursor-1",
|
|
270
|
+
hasMore: true,
|
|
271
|
+
};
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
yield {
|
|
275
|
+
rows: [{ id: "row-2", name: "Row 2" }],
|
|
276
|
+
nextCursor: null,
|
|
277
|
+
hasMore: false,
|
|
278
|
+
};
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const { getByRole, getByTestId } = render(<DataSourceHarness dataSource={dataSource} />);
|
|
282
|
+
await waitFor(() => expect(getByTestId("row-count").textContent).toBe("1"));
|
|
283
|
+
|
|
284
|
+
fireEvent.click(getByRole("button", { name: "Load more" }));
|
|
285
|
+
|
|
286
|
+
await waitFor(() => expect(getByTestId("row-count").textContent).toBe("2"));
|
|
287
|
+
expect(calls).toEqual([{ cursor: undefined }, { cursor: "cursor-1" }]);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
|
|
2
3
|
import type {
|
|
3
4
|
DataGridColumnDef,
|
|
4
5
|
DataGridDataSource,
|
|
@@ -122,10 +123,28 @@ function useAsyncDataSource<TRow>(opts: {
|
|
|
122
123
|
const [error, setError] = useState<Error | null>(null);
|
|
123
124
|
|
|
124
125
|
const cursorRef = useRef<unknown>(undefined);
|
|
125
|
-
const
|
|
126
|
+
const inFlightFetchRef = useRef<AbortController | null>(null);
|
|
127
|
+
// The infinite-scroll sentinel's IntersectionObserver only fires on
|
|
128
|
+
// intersection *changes* (by design, to avoid auto-loading the whole
|
|
129
|
+
// dataset). If `loadMore` is called while another fetch is in flight we
|
|
130
|
+
// can't just drop it: the sentinel won't fire again until the user scrolls
|
|
131
|
+
// away and back. Remember the request and replay it once the in-flight
|
|
132
|
+
// fetch settles.
|
|
133
|
+
const pendingLoadMoreRef = useRef(false);
|
|
134
|
+
const hasMoreRef = useRef(true);
|
|
135
|
+
const paginationModeRef = useRef(paginationMode);
|
|
136
|
+
paginationModeRef.current = paginationMode;
|
|
126
137
|
const pageIndexRef = useRef(0);
|
|
127
138
|
const hasDataRef = useRef(false);
|
|
128
139
|
const hasMountedServerPaginationRef = useRef(false);
|
|
140
|
+
// Effects can be replayed when a descendant suspends. Only a fetch that
|
|
141
|
+
// actually delivered results is safe to reuse as a skip key.
|
|
142
|
+
const lastCompletedNonAppendFetchKeyRef = useRef<{
|
|
143
|
+
dataSource: DataGridDataSource<TRow>,
|
|
144
|
+
sortingKey: string,
|
|
145
|
+
quickSearchKey: string,
|
|
146
|
+
pageSize: number,
|
|
147
|
+
} | null>(null);
|
|
129
148
|
|
|
130
149
|
const latestArgsRef = useRef({
|
|
131
150
|
dataSource,
|
|
@@ -141,6 +160,10 @@ function useAsyncDataSource<TRow>(opts: {
|
|
|
141
160
|
|
|
142
161
|
const fetchPage = useCallback(
|
|
143
162
|
async (append: boolean) => {
|
|
163
|
+
if (append && inFlightFetchRef.current != null) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
144
167
|
const {
|
|
145
168
|
dataSource: currentDataSource,
|
|
146
169
|
getRowId: currentGetRowId,
|
|
@@ -149,9 +172,15 @@ function useAsyncDataSource<TRow>(opts: {
|
|
|
149
172
|
pagination: currentPagination,
|
|
150
173
|
} = latestArgsRef.current;
|
|
151
174
|
|
|
152
|
-
|
|
175
|
+
inFlightFetchRef.current?.abort();
|
|
153
176
|
const controller = new AbortController();
|
|
154
|
-
|
|
177
|
+
inFlightFetchRef.current = controller;
|
|
178
|
+
const fetchKey = {
|
|
179
|
+
dataSource: currentDataSource,
|
|
180
|
+
sortingKey: JSON.stringify(currentSorting),
|
|
181
|
+
quickSearchKey: currentQuickSearch,
|
|
182
|
+
pageSize: currentPagination.pageSize,
|
|
183
|
+
};
|
|
155
184
|
|
|
156
185
|
if (append) {
|
|
157
186
|
setIsLoadingMore(true);
|
|
@@ -162,21 +191,34 @@ function useAsyncDataSource<TRow>(opts: {
|
|
|
162
191
|
} else {
|
|
163
192
|
setIsLoading(true);
|
|
164
193
|
}
|
|
165
|
-
cursorRef.current = undefined;
|
|
166
|
-
pageIndexRef.current = 0;
|
|
167
194
|
}
|
|
168
195
|
// Clear previous error at the start of a new attempt; we'll set it
|
|
169
196
|
// again if this attempt fails.
|
|
170
197
|
setError(null);
|
|
171
198
|
|
|
199
|
+
// Cursor/page-index state is tracked locally and only committed to the
|
|
200
|
+
// shared refs when this fetch actually delivers a result. An aborted
|
|
201
|
+
// reset must not clobber the committed pagination state: if it did, a
|
|
202
|
+
// later effect run that skips via `lastCompletedNonAppendFetchKeyRef`
|
|
203
|
+
// would keep the old rows but leave `cursorRef === undefined`, and the
|
|
204
|
+
// next loadMore would silently restart from page one.
|
|
205
|
+
let cursor = append ? cursorRef.current : undefined;
|
|
206
|
+
let pageIndex = append ? pageIndexRef.current : 0;
|
|
207
|
+
let failed = false;
|
|
208
|
+
// The abort guard lives inside the for-await body, so a generator that
|
|
209
|
+
// completes with zero yields never hits it and would otherwise fall
|
|
210
|
+
// through to the post-loop completion-key write. Only mark a reset
|
|
211
|
+
// complete after it has actually delivered (and committed) a result.
|
|
212
|
+
let committedResult = false;
|
|
213
|
+
|
|
172
214
|
try {
|
|
173
215
|
const params: DataGridFetchParams = {
|
|
174
216
|
sorting: currentSorting,
|
|
175
217
|
quickSearch: currentQuickSearch,
|
|
176
218
|
pagination: append
|
|
177
|
-
? { pageIndex
|
|
219
|
+
? { pageIndex, pageSize: currentPagination.pageSize }
|
|
178
220
|
: currentPagination,
|
|
179
|
-
cursor
|
|
221
|
+
cursor,
|
|
180
222
|
};
|
|
181
223
|
|
|
182
224
|
const gen = currentDataSource(params);
|
|
@@ -188,9 +230,11 @@ function useAsyncDataSource<TRow>(opts: {
|
|
|
188
230
|
setTotalRowCount(result.totalRowCount);
|
|
189
231
|
}
|
|
190
232
|
if (result.nextCursor !== undefined) {
|
|
191
|
-
|
|
233
|
+
cursor = result.nextCursor;
|
|
192
234
|
}
|
|
193
|
-
|
|
235
|
+
cursorRef.current = cursor;
|
|
236
|
+
hasMoreRef.current = result.hasMore !== false;
|
|
237
|
+
setHasMore(hasMoreRef.current);
|
|
194
238
|
|
|
195
239
|
if (append) {
|
|
196
240
|
setRows((prev) => {
|
|
@@ -201,11 +245,20 @@ function useAsyncDataSource<TRow>(opts: {
|
|
|
201
245
|
return [...prev, ...newRows];
|
|
202
246
|
});
|
|
203
247
|
} else {
|
|
248
|
+
// The visible rows now belong to this (possibly still incomplete)
|
|
249
|
+
// reset, so the previously completed fetch key no longer describes
|
|
250
|
+
// them and must not allow a skip.
|
|
251
|
+
lastCompletedNonAppendFetchKeyRef.current = null;
|
|
204
252
|
setRows(result.rows);
|
|
205
253
|
}
|
|
206
254
|
|
|
207
255
|
hasDataRef.current = true;
|
|
208
|
-
|
|
256
|
+
committedResult = true;
|
|
257
|
+
pageIndex++;
|
|
258
|
+
pageIndexRef.current = pageIndex;
|
|
259
|
+
}
|
|
260
|
+
if (!append && committedResult && !controller.signal.aborted) {
|
|
261
|
+
lastCompletedNonAppendFetchKeyRef.current = fetchKey;
|
|
209
262
|
}
|
|
210
263
|
} catch (err) {
|
|
211
264
|
if (controller.signal.aborted) return;
|
|
@@ -214,12 +267,35 @@ function useAsyncDataSource<TRow>(opts: {
|
|
|
214
267
|
// consumer to wire up error rendering.
|
|
215
268
|
// eslint-disable-next-line no-console
|
|
216
269
|
console.error("[DataGrid] Data source error:", err);
|
|
270
|
+
failed = true;
|
|
217
271
|
setError(err instanceof Error ? err : new Error(String(err)));
|
|
218
272
|
} finally {
|
|
219
|
-
if
|
|
273
|
+
// Only the owning fetch resets the loading flags; if this fetch was
|
|
274
|
+
// replaced by a newer one, that fetch manages them instead. This also
|
|
275
|
+
// covers cleanup-initiated aborts, which would otherwise leave
|
|
276
|
+
// `isLoadingMore` stuck and block all future loadMore calls.
|
|
277
|
+
if (inFlightFetchRef.current === controller) {
|
|
278
|
+
inFlightFetchRef.current = null;
|
|
220
279
|
setIsLoading(false);
|
|
221
280
|
setIsRefetching(false);
|
|
222
281
|
setIsLoadingMore(false);
|
|
282
|
+
// Replay a loadMore that was requested (and deferred) while this
|
|
283
|
+
// fetch was in flight — but only if this fetch succeeded while we
|
|
284
|
+
// are still in infinite mode. Skip on failure (the append would run
|
|
285
|
+
// against inconsistent state and its setError(null) would hide this
|
|
286
|
+
// fetch's error), when pagination mode changed away from infinite,
|
|
287
|
+
// and on unmount-cleanup aborts — `inFlightFetchRef.current ===
|
|
288
|
+
// controller` with an aborted signal only happens then.
|
|
289
|
+
const shouldChainLoadMore =
|
|
290
|
+
pendingLoadMoreRef.current
|
|
291
|
+
&& !failed
|
|
292
|
+
&& !controller.signal.aborted
|
|
293
|
+
&& hasMoreRef.current
|
|
294
|
+
&& paginationModeRef.current === "infinite";
|
|
295
|
+
pendingLoadMoreRef.current = false;
|
|
296
|
+
if (shouldChainLoadMore) {
|
|
297
|
+
runAsynchronously(fetchPage(true));
|
|
298
|
+
}
|
|
223
299
|
}
|
|
224
300
|
}
|
|
225
301
|
},
|
|
@@ -227,8 +303,26 @@ function useAsyncDataSource<TRow>(opts: {
|
|
|
227
303
|
);
|
|
228
304
|
|
|
229
305
|
useEffect(() => {
|
|
230
|
-
|
|
231
|
-
|
|
306
|
+
// Deferred loadMore is only meaningful for infinite scroll; drop it when
|
|
307
|
+
// the consumer leaves that mode so a later settle cannot append wrongly.
|
|
308
|
+
if (paginationMode !== "infinite") {
|
|
309
|
+
pendingLoadMoreRef.current = false;
|
|
310
|
+
}
|
|
311
|
+
}, [paginationMode]);
|
|
312
|
+
|
|
313
|
+
useEffect(() => {
|
|
314
|
+
const lastCompletedKey = lastCompletedNonAppendFetchKeyRef.current;
|
|
315
|
+
const isSameCompletedFetch = hasDataRef.current
|
|
316
|
+
&& lastCompletedKey?.dataSource === dataSource
|
|
317
|
+
&& lastCompletedKey.sortingKey === sortingKey
|
|
318
|
+
&& lastCompletedKey.quickSearchKey === quickSearchKey
|
|
319
|
+
&& lastCompletedKey.pageSize === pagination.pageSize;
|
|
320
|
+
if (!isSameCompletedFetch) {
|
|
321
|
+
runAsynchronously(fetchPage(false));
|
|
322
|
+
}
|
|
323
|
+
// Always return the abort cleanup (even when the fetch is skipped) so an
|
|
324
|
+
// in-flight append is cancelled on unmount.
|
|
325
|
+
return () => inFlightFetchRef.current?.abort();
|
|
232
326
|
// Also refetches when `dataSource` identity changes — consumers encode
|
|
233
327
|
// external filter state into the generator's closure, so a new
|
|
234
328
|
// generator reference is the signal that the query changed.
|
|
@@ -243,17 +337,25 @@ function useAsyncDataSource<TRow>(opts: {
|
|
|
243
337
|
hasMountedServerPaginationRef.current = true;
|
|
244
338
|
return;
|
|
245
339
|
}
|
|
246
|
-
fetchPage(false)
|
|
340
|
+
runAsynchronously(fetchPage(false));
|
|
247
341
|
}, [fetchPage, paginationMode, pagination.pageIndex]);
|
|
248
342
|
|
|
249
343
|
const loadMore = useCallback(() => {
|
|
250
|
-
if (
|
|
251
|
-
|
|
344
|
+
if (paginationMode !== "infinite" || !hasMore) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
// Keep pagination single-flight. In particular, do not let the sentinel
|
|
348
|
+
// start an append against a cursor that a concurrent reset is replacing.
|
|
349
|
+
// Queue the request instead of dropping it (see pendingLoadMoreRef).
|
|
350
|
+
if (inFlightFetchRef.current != null) {
|
|
351
|
+
pendingLoadMoreRef.current = true;
|
|
352
|
+
return;
|
|
252
353
|
}
|
|
253
|
-
|
|
354
|
+
runAsynchronously(fetchPage(true));
|
|
355
|
+
}, [hasMore, paginationMode, fetchPage]);
|
|
254
356
|
|
|
255
357
|
const reload = useCallback(() => {
|
|
256
|
-
fetchPage(false)
|
|
358
|
+
runAsynchronously(fetchPage(false));
|
|
257
359
|
}, [fetchPage]);
|
|
258
360
|
|
|
259
361
|
return {
|