@hexclave/dashboard-ui-components 1.0.51 → 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/button.d.ts +4 -4
- package/dist/components/data-grid/data-grid.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 +111 -67
- package/dist/dashboard-ui-components.global.js.map +4 -4
- package/dist/data-grid-AJi1TNDa.d.ts.map +1 -1
- package/dist/esm/components/button.d.ts +4 -4
- package/dist/esm/components/data-grid/data-grid.d.ts.map +1 -1
- package/dist/esm/components/data-grid/data-grid.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.tsx +3 -1
- package/src/components/data-grid/use-data-source.test.tsx +289 -0
- package/src/components/data-grid/use-data-source.ts +120 -18
|
@@ -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 {
|