@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.
@@ -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 abortRef = useRef<AbortController | null>(null);
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
- abortRef.current?.abort();
175
+ inFlightFetchRef.current?.abort();
153
176
  const controller = new AbortController();
154
- abortRef.current = controller;
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: pageIndexRef.current, pageSize: currentPagination.pageSize }
219
+ ? { pageIndex, pageSize: currentPagination.pageSize }
178
220
  : currentPagination,
179
- cursor: cursorRef.current,
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
- cursorRef.current = result.nextCursor;
233
+ cursor = result.nextCursor;
192
234
  }
193
- setHasMore(result.hasMore !== false);
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
- pageIndexRef.current++;
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 (!controller.signal.aborted) {
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
- fetchPage(false).catch(() => {});
231
- return () => abortRef.current?.abort();
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).catch(() => {});
340
+ runAsynchronously(fetchPage(false));
247
341
  }, [fetchPage, paginationMode, pagination.pageIndex]);
248
342
 
249
343
  const loadMore = useCallback(() => {
250
- if (!isLoadingMore && hasMore && paginationMode === "infinite") {
251
- fetchPage(true).catch(() => {});
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
- }, [isLoadingMore, hasMore, paginationMode, fetchPage]);
354
+ runAsynchronously(fetchPage(true));
355
+ }, [hasMore, paginationMode, fetchPage]);
254
356
 
255
357
  const reload = useCallback(() => {
256
- fetchPage(false).catch(() => {});
358
+ runAsynchronously(fetchPage(false));
257
359
  }, [fetchPage]);
258
360
 
259
361
  return {