@openmrs/esm-react-utils 10.0.1-pre.5263 → 10.0.1-pre.5299

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.
Files changed (49) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/Extension.d.ts.map +1 -1
  3. package/dist/Extension.js +86 -44
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -0
  7. package/dist/useAssignedExtensionIds.d.ts +1 -1
  8. package/dist/useAssignedExtensionIds.d.ts.map +1 -1
  9. package/dist/useAssignedExtensionIds.js +7 -14
  10. package/dist/useAssignedExtensions.d.ts +13 -1
  11. package/dist/useAssignedExtensions.d.ts.map +1 -1
  12. package/dist/useAssignedExtensions.js +31 -4
  13. package/dist/useCandidateExtensions.d.ts +11 -0
  14. package/dist/useCandidateExtensions.d.ts.map +1 -0
  15. package/dist/useCandidateExtensions.js +12 -0
  16. package/dist/useExtensionSlot.d.ts.map +1 -1
  17. package/dist/useExtensionSlot.js +8 -13
  18. package/dist/useExtensionSlotStore.d.ts +0 -1
  19. package/dist/useExtensionSlotStore.d.ts.map +1 -1
  20. package/dist/useExtensionSlotStore.js +19 -2
  21. package/dist/useFhirPagination.d.ts +1 -1
  22. package/dist/useOpenmrsPagination.d.ts +2 -2
  23. package/dist/useOpenmrsPagination.d.ts.map +1 -1
  24. package/dist/useOpenmrsPagination.js +66 -15
  25. package/dist/usePagination.d.ts +5 -0
  26. package/dist/usePagination.d.ts.map +1 -1
  27. package/dist/usePagination.js +23 -21
  28. package/dist/useShallowStableValue.d.ts +13 -0
  29. package/dist/useShallowStableValue.d.ts.map +1 -0
  30. package/dist/useShallowStableValue.js +19 -0
  31. package/dist/useStore.d.ts.map +1 -1
  32. package/dist/useStore.js +25 -11
  33. package/package.json +23 -23
  34. package/src/Extension.tsx +110 -54
  35. package/src/extensions.test.tsx +112 -0
  36. package/src/index.ts +2 -0
  37. package/src/useAssignedExtensionIds.ts +5 -15
  38. package/src/useAssignedExtensions.ts +30 -3
  39. package/src/useCandidateExtensions.ts +15 -0
  40. package/src/useExtensionSlot.ts +7 -17
  41. package/src/useExtensionSlotStore.ts +23 -3
  42. package/src/useOpenmrsPagination.test.ts +297 -1
  43. package/src/useOpenmrsPagination.ts +72 -10
  44. package/src/usePagination.test.tsx +315 -0
  45. package/src/usePagination.ts +27 -15
  46. package/src/useShallowStableValue.test.ts +69 -0
  47. package/src/useShallowStableValue.ts +24 -0
  48. package/src/useStore.test.ts +39 -0
  49. package/src/useStore.ts +17 -10
@@ -1,33 +1,23 @@
1
- import { useContext, useEffect, useRef } from 'react';
2
- import {
3
- type ExtensionSlotCustomState,
4
- registerExtensionSlot,
5
- updateExtensionSlotState,
6
- } from '@openmrs/esm-extensions';
1
+ import { useContext, useEffect } from 'react';
2
+ import { type ExtensionSlotCustomState, registerExtensionSlot } from '@openmrs/esm-extensions';
7
3
  import { ComponentContext } from './ComponentContext';
8
4
  import { useAssignedExtensions } from './useAssignedExtensions';
9
5
 
10
6
  /** @internal */
11
7
  export function useExtensionSlot(slotName: string, state?: ExtensionSlotCustomState) {
12
8
  const { moduleName } = useContext(ComponentContext);
13
- const isInitialRender = useRef(true);
14
9
 
15
10
  if (!moduleName) {
16
11
  throw Error('ComponentContext has not been provided. This should come from @openmrs/esm-react-utils.');
17
12
  }
18
13
 
19
14
  useEffect(() => {
20
- registerExtensionSlot(moduleName, slotName, state);
21
- isInitialRender.current = false;
22
- }, []);
15
+ registerExtensionSlot(moduleName, slotName);
16
+ }, [moduleName, slotName]);
23
17
 
24
- useEffect(() => {
25
- if (!isInitialRender.current) {
26
- updateExtensionSlotState(slotName, state);
27
- }
28
- }, [slotName, state]);
29
-
30
- const extensions = useAssignedExtensions(slotName);
18
+ // `state` must be local to the rendering rather than written to the store as state is
19
+ // render-specific
20
+ const extensions = useAssignedExtensions(slotName, state);
31
21
 
32
22
  return {
33
23
  extensions,
@@ -1,6 +1,26 @@
1
1
  /** @module @category Extension */
2
- import { type ExtensionSlotState, type ExtensionStore, getExtensionStore } from '@openmrs/esm-extensions';
2
+ import { useCallback } from 'react';
3
+ import {
4
+ type AssignedExtension,
5
+ type ExtensionSlotState,
6
+ type ExtensionStore,
7
+ getExtensionStore,
8
+ } from '@openmrs/esm-extensions';
3
9
  import { useStore } from './useStore';
4
10
 
5
- export const useExtensionSlotStore = (slot: string) =>
6
- useStore<ExtensionStore, ExtensionSlotState>(getExtensionStore(), (state) => state.slots?.[slot]);
11
+ /**
12
+ * Stands in for a slot that has not been registered or attached to. Shared rather than built per
13
+ * call, so the snapshot stays reference-stable across renders — and frozen because that sharing
14
+ * means an in-place sort or push would otherwise corrupt every unregistered slot in the page.
15
+ */
16
+ const emptyCandidates: Array<AssignedExtension> = [];
17
+ Object.freeze(emptyCandidates);
18
+
19
+ const emptySlotState: ExtensionSlotState = { candidateExtensions: emptyCandidates };
20
+ Object.freeze(emptySlotState);
21
+
22
+ export const useExtensionSlotStore = (slot: string) => {
23
+ // Memoized so that `useStore`'s snapshot function is stable across renders.
24
+ const select = useCallback((state: ExtensionStore) => state.slots?.[slot] ?? emptySlotState, [slot]);
25
+ return useStore<ExtensionStore, ExtensionSlotState>(getExtensionStore(), select);
26
+ };
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { renderHook, waitFor, act } from '@testing-library/react';
3
3
  import { useOpenmrsPagination, type OpenMRSPaginatedResponse } from './useOpenmrsPagination';
4
4
 
@@ -25,6 +25,11 @@ export async function getTestData(url: string, totalCount: number): Promise<Open
25
25
  return { results, links, totalCount } as OpenMRSPaginatedResponse<number>;
26
26
  }
27
27
 
28
+ /** Whether any warning mentions `page`, so the exact wording stays free to change. */
29
+ function warned(warn: { mock: { calls: Array<Array<unknown>> } }, page: string) {
30
+ return warn.mock.calls.some((call) => String(call[0]).includes(page));
31
+ }
32
+
28
33
  describe('useOpenmrsPagination', () => {
29
34
  it('should not fetch anything if url is null', async () => {
30
35
  const { result } = renderHook(() =>
@@ -53,6 +58,24 @@ describe('useOpenmrsPagination', () => {
53
58
  expect(result.current.data).toEqual(getIntArray(0, 17));
54
59
  });
55
60
 
61
+ it('reports currentPageSize as a number, matching the rows on the current page', async () => {
62
+ const pageSize = 20;
63
+ const expectedRowCount = 50;
64
+ const { result } = renderHook(() =>
65
+ useOpenmrsPagination('http://localhost/page-size', pageSize, {
66
+ fetcher: (url) => getTestData(url, expectedRowCount).then((data) => ({ data }) as any),
67
+ }),
68
+ );
69
+
70
+ await waitFor(() => expect(result.current.isLoading).toBeFalsy());
71
+ expect(result.current.currentPageSize).toBe(20);
72
+
73
+ // the last page is short, and the reported size follows it
74
+ act(() => result.current.goTo(3));
75
+ await waitFor(() => expect(result.current.currentPage).toBe(3));
76
+ await waitFor(() => expect(result.current.currentPageSize).toBe(10));
77
+ });
78
+
56
79
  it('should fetch 2 pages if pageSize < number of rows <= 2 * pageSize', async () => {
57
80
  const pageSize = 20;
58
81
  const expectedRowCount = 40;
@@ -134,4 +157,277 @@ describe('useOpenmrsPagination', () => {
134
157
  expect(result.current.totalCount).toBe(expectedRowCount);
135
158
  expect(result.current.data).toEqual(getIntArray(1320, 17));
136
159
  });
160
+
161
+ describe('callback identity', () => {
162
+ it('keeps the same callbacks when the total count arrives and when the page changes', async () => {
163
+ const { result } = renderHook(() =>
164
+ useOpenmrsPagination('http://localhost/identity', 20, {
165
+ fetcher: (url) => getTestData(url, 1337).then((data) => ({ data }) as any),
166
+ }),
167
+ );
168
+
169
+ // captured before any response, while totalCount is still NaN
170
+ const { goTo, goToNext, goToPrevious } = result.current;
171
+ expect(result.current.totalPages).toBeNaN();
172
+
173
+ await waitFor(() => expect(result.current.isLoading).toBeFalsy());
174
+ expect(result.current.totalPages).toBe(67);
175
+
176
+ act(() => result.current.goTo(4));
177
+ await waitFor(() => expect(result.current.currentPage).toBe(4));
178
+
179
+ act(() => result.current.goToNext());
180
+ await waitFor(() => expect(result.current.currentPage).toBe(5));
181
+
182
+ expect(result.current.goTo).toBe(goTo);
183
+ expect(result.current.goToNext).toBe(goToNext);
184
+ expect(result.current.goToPrevious).toBe(goToPrevious);
185
+ });
186
+ });
187
+
188
+ describe('out of bounds navigation', () => {
189
+ it('warns, stays put and issues no further request', async () => {
190
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
191
+ const fetcher = vi.fn((url: string) => getTestData(url, 40).then((data) => ({ data }) as any));
192
+
193
+ const { result } = renderHook(() => useOpenmrsPagination('http://localhost/bounds', 20, { fetcher }));
194
+
195
+ await waitFor(() => expect(result.current.isLoading).toBeFalsy());
196
+ expect(result.current.totalPages).toBe(2);
197
+
198
+ const fetchCount = fetcher.mock.calls.length;
199
+
200
+ act(() => result.current.goTo(999));
201
+ act(() => result.current.goTo(0));
202
+
203
+ expect(result.current.currentPage).toBe(1);
204
+ // asserted as substrings so the wording can be improved without breaking these
205
+ expect(warned(warn, '999')).toBe(true);
206
+ expect(warned(warn, '0')).toBe(true);
207
+ expect(fetcher.mock.calls.length).toBe(fetchCount);
208
+
209
+ warn.mockRestore();
210
+ });
211
+
212
+ it('warns when going to the previous page from the first page', async () => {
213
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
214
+
215
+ const { result } = renderHook(() =>
216
+ useOpenmrsPagination('http://localhost/previous', 20, {
217
+ fetcher: (url) => getTestData(url, 40).then((data) => ({ data }) as any),
218
+ }),
219
+ );
220
+
221
+ await waitFor(() => expect(result.current.isLoading).toBeFalsy());
222
+
223
+ act(() => result.current.goToPrevious());
224
+
225
+ expect(result.current.currentPage).toBe(1);
226
+ expect(warned(warn, '0')).toBe(true);
227
+
228
+ warn.mockRestore();
229
+ });
230
+ });
231
+
232
+ describe('stepping', () => {
233
+ // Batching the two calls into one commit is what distinguishes stepping from the page as it stands
234
+ // from stepping from a page captured when the callback was created; the latter is a commit behind.
235
+ it('steps from the latest requested page when batched with a goTo', async () => {
236
+ const { result } = renderHook(() =>
237
+ useOpenmrsPagination('http://localhost/batched-next', 20, {
238
+ fetcher: (url) => getTestData(url, 200).then((data) => ({ data }) as any),
239
+ }),
240
+ );
241
+ await waitFor(() => expect(result.current.isLoading).toBeFalsy());
242
+
243
+ const { goTo, goToNext } = result.current;
244
+ act(() => {
245
+ goTo(3);
246
+ goToNext();
247
+ });
248
+
249
+ await waitFor(() => expect(result.current.isValidating).toBeFalsy());
250
+ expect(result.current.currentPage).toBe(4);
251
+ expect(result.current.data).toEqual(getIntArray(60, 20));
252
+ });
253
+
254
+ it('steps back from the latest requested page when batched with a goTo', async () => {
255
+ const { result } = renderHook(() =>
256
+ useOpenmrsPagination('http://localhost/batched-prev', 20, {
257
+ fetcher: (url) => getTestData(url, 200).then((data) => ({ data }) as any),
258
+ }),
259
+ );
260
+ await waitFor(() => expect(result.current.isLoading).toBeFalsy());
261
+
262
+ const { goTo, goToPrevious } = result.current;
263
+ act(() => {
264
+ goTo(5);
265
+ goToPrevious();
266
+ });
267
+
268
+ await waitFor(() => expect(result.current.isValidating).toBeFalsy());
269
+ expect(result.current.currentPage).toBe(4);
270
+ });
271
+ });
272
+
273
+ describe('changing the url', () => {
274
+ it('returns to page 1 and does not request the old offset against the new url', async () => {
275
+ const urls: Array<string> = [];
276
+ const fetcher = (url: string) => {
277
+ urls.push(url);
278
+ return getTestData(url, url.includes('wide') ? 1337 : 10).then((data) => ({ data }) as any);
279
+ };
280
+
281
+ const { result, rerender } = renderHook(({ url }) => useOpenmrsPagination(url, 20, { fetcher }), {
282
+ initialProps: { url: 'http://localhost/search?q=wide' },
283
+ });
284
+ await waitFor(() => expect(result.current.isLoading).toBeFalsy());
285
+
286
+ act(() => result.current.goTo(5));
287
+ await waitFor(() => expect(result.current.currentPage).toBe(5));
288
+
289
+ rerender({ url: 'http://localhost/search?q=narrow' });
290
+ await waitFor(() => expect(result.current.isValidating).toBeFalsy());
291
+
292
+ expect(result.current.currentPage).toBe(1);
293
+ expect(result.current.totalCount).toBe(10);
294
+ expect(result.current.data).toEqual(getIntArray(0, 10));
295
+ expect(urls.filter((u) => u.includes('narrow') && u.includes('startIndex=80'))).toEqual([]);
296
+ });
297
+ });
298
+
299
+ describe('navigation before the first response', () => {
300
+ it('applies a page requested while the total count is still unknown', async () => {
301
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
302
+
303
+ let release: () => void;
304
+ const firstResponse = new Promise<void>((resolve) => {
305
+ release = resolve;
306
+ });
307
+
308
+ const { result } = renderHook(() =>
309
+ useOpenmrsPagination('http://localhost/race', 20, {
310
+ fetcher: async (url) => {
311
+ await firstResponse;
312
+ return { data: await getTestData(url, 1337) } as any;
313
+ },
314
+ }),
315
+ );
316
+
317
+ expect(result.current.totalCount).toBeNaN();
318
+ expect(result.current.totalPages).toBeNaN();
319
+
320
+ act(() => result.current.goTo(2));
321
+ expect(result.current.currentPage).toBe(2);
322
+ expect(warn).not.toHaveBeenCalled();
323
+
324
+ release!();
325
+
326
+ await waitFor(() => expect(result.current.data).toBeDefined());
327
+ await waitFor(() => expect(result.current.isLoading).toBeFalsy());
328
+
329
+ expect(result.current.currentPage).toBe(2);
330
+ expect(result.current.totalPages).toBe(67);
331
+ expect(result.current.data).toEqual(getIntArray(20, 20));
332
+
333
+ warn.mockRestore();
334
+ });
335
+
336
+ it('returns to the last real page when the requested one turns out not to exist', async () => {
337
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
338
+
339
+ let release: () => void;
340
+ const firstResponse = new Promise<void>((resolve) => {
341
+ release = resolve;
342
+ });
343
+
344
+ const { result } = renderHook(() =>
345
+ useOpenmrsPagination('http://localhost/strand', 20, {
346
+ fetcher: async (url) => {
347
+ await firstResponse;
348
+ return { data: await getTestData(url, 40) } as any;
349
+ },
350
+ }),
351
+ );
352
+
353
+ act(() => result.current.goTo(999999));
354
+ expect(result.current.currentPage).toBe(999999);
355
+
356
+ release!();
357
+ await waitFor(() => expect(result.current.totalPages).toBe(2));
358
+ await waitFor(() => expect(result.current.currentPage).toBe(2));
359
+
360
+ expect(result.current.data).toEqual(getIntArray(20, 20));
361
+ expect(result.current.showPreviousButton).toBe(true);
362
+ expect(warn).toHaveBeenCalled();
363
+
364
+ // and the user is not stuck: navigation still works
365
+ act(() => result.current.goToPrevious());
366
+ await waitFor(() => expect(result.current.currentPage).toBe(1));
367
+
368
+ warn.mockRestore();
369
+ });
370
+
371
+ it('rejects a non-integer page instead of putting NaN in the request url', async () => {
372
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
373
+ const urls: Array<string> = [];
374
+
375
+ let release: () => void;
376
+ const firstResponse = new Promise<void>((resolve) => {
377
+ release = resolve;
378
+ });
379
+
380
+ const { result } = renderHook(() =>
381
+ useOpenmrsPagination('http://localhost/nan', 20, {
382
+ fetcher: async (url) => {
383
+ urls.push(url);
384
+ await firstResponse;
385
+ return { data: await getTestData(url, 100) } as any;
386
+ },
387
+ }),
388
+ );
389
+
390
+ act(() => result.current.goTo(Number('nope')));
391
+ expect(result.current.currentPage).toBe(1);
392
+ expect(warn).toHaveBeenCalled();
393
+
394
+ release!();
395
+ await waitFor(() => expect(result.current.isLoading).toBeFalsy());
396
+
397
+ expect(urls.some((u) => u.includes('startIndex=NaN'))).toBe(false);
398
+ expect(result.current.currentPage).toBe(1);
399
+
400
+ warn.mockRestore();
401
+ });
402
+ });
403
+
404
+ describe('a server that reports no total count', () => {
405
+ it('warns once and keeps navigation usable rather than silently dropping every request', async () => {
406
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
407
+
408
+ const { result } = renderHook(() =>
409
+ useOpenmrsPagination('http://localhost/nototal', 20, {
410
+ fetcher: async (url) => {
411
+ const u = new URL(url, window.location.toString());
412
+ const startIndex = Number.parseInt(u.searchParams.get('startIndex') ?? '0');
413
+ // no totalCount field, as a FHIR bundle without `total` would give
414
+ return { data: { results: getIntArray(startIndex, 20), links: [] } } as any;
415
+ },
416
+ }),
417
+ );
418
+
419
+ await waitFor(() => expect(result.current.isLoading).toBeFalsy());
420
+ expect(result.current.totalCount).toBeNaN();
421
+
422
+ await waitFor(() => expect(warn).toHaveBeenCalled());
423
+ const missingTotalWarnings = warn.mock.calls.filter((c) => String(c[0]).includes('no total count'));
424
+ expect(missingTotalWarnings).toHaveLength(1);
425
+
426
+ // navigation still works, since there is no bound to check against
427
+ act(() => result.current.goTo(3));
428
+ await waitFor(() => expect(result.current.currentPage).toBe(3));
429
+
430
+ warn.mockRestore();
431
+ });
432
+ });
137
433
  });
@@ -1,6 +1,6 @@
1
1
  /** @module @category UI */
2
2
  import { type FetchResponse, makeUrl, openmrsFetch } from '@openmrs/esm-api';
3
- import { useCallback, useMemo, useRef, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import useSWR, { type SWRConfiguration } from 'swr';
5
5
  import useSWRImmutable from 'swr/immutable';
6
6
 
@@ -113,13 +113,35 @@ export function useServerPagination<T, R>(
113
113
  const { getPaginatedUrl, getTotalCount, getCurrentPageSize, getData } = serverPaginationHandlers;
114
114
  const { immutable, swrConfig } = options;
115
115
  const fetcher: (key: string) => Promise<FetchResponse<R>> = options.fetcher ?? openmrsFetch;
116
- const [currentPage, setCurrentPage] = useState<number>(1); // 1-indexing instead of 0-indexing, to keep consistency with `usePagination`
116
+ // 1-indexing instead of 0-indexing, to keep consistency with `usePagination`
117
+ const [currentPage, setCurrentPage] = useState<number>(1);
117
118
 
118
119
  // Cache the totalCount and currentPageSize so we don't lose them
119
120
  // as we wait for next page's result to load while navigating to a different page.
120
121
  // This can be used to prevent jarring UI changes while loading
121
122
  const totalCount = useRef<number>(Number.NaN);
122
- const currentPageSize = useRef<number>(Number.NaN); // this value is usually same as pageSize, except when currentPage is last page.
123
+ // this value is usually same as pageSize, except when currentPage is last page.
124
+ const currentPageSize = useRef<number>(Number.NaN);
125
+
126
+ // tracked as a ref so we don't need to make the current page a dependency of `goTo()`, etc.
127
+ const currentPageRef = useRef(currentPage);
128
+
129
+ // use to differentiate "endpoint doesn't return a total" from "we haven't run a request yet"
130
+ const hasResponded = useRef(false);
131
+ const warnedMissingTotal = useRef(false);
132
+
133
+ // if the URL we're querying changes, reset to page 1
134
+ const urlKey = url?.toString() ?? '';
135
+ const [lastUrlKey, setLastUrlKey] = useState(urlKey);
136
+ if (urlKey !== lastUrlKey) {
137
+ setLastUrlKey(urlKey);
138
+ setCurrentPage(1);
139
+ currentPageRef.current = 1;
140
+ totalCount.current = Number.NaN;
141
+ currentPageSize.current = Number.NaN;
142
+ hasResponded.current = false;
143
+ warnedMissingTotal.current = false;
144
+ }
123
145
 
124
146
  const limit = pageSize;
125
147
  const startIndex = (currentPage - 1) * pageSize;
@@ -134,34 +156,74 @@ export function useServerPagination<T, R>(
134
156
  if (data?.data) {
135
157
  totalCount.current = getTotalCount(data?.data);
136
158
  currentPageSize.current = getCurrentPageSize(data?.data);
159
+ hasResponded.current = true;
137
160
  }
138
161
 
139
162
  const totalPages = Math.ceil(totalCount.current / pageSize);
140
163
 
164
+ // The bound is read from `totalCount` at call time rather than captured from the creating render,
165
+ // which is what keeps this callback referentially stable, apart from a change in `pageSize`.
141
166
  const goTo = useCallback(
142
167
  (page: number) => {
143
- if (0 < page && page <= totalPages) {
168
+ if (!Number.isInteger(page) || page < 1) {
169
+ console.warn(`useServerPagination: ignoring goTo(${page}); page must be a positive integer.`);
170
+ return;
171
+ }
172
+
173
+ // page count is not known prior to running any query when `bound` will be `NaN`, so in this
174
+ // case we assume the requested page is fine; after results return, things will settle properly
175
+ // clamped
176
+ const bound = Math.ceil(totalCount.current / pageSize);
177
+ if (!hasResponded.current || Number.isNaN(bound) || page <= bound) {
178
+ currentPageRef.current = page;
144
179
  setCurrentPage(page);
145
180
  } else {
146
181
  console.warn('Invalid attempt to go to out of bounds page: ' + page);
147
182
  }
148
183
  },
149
- [url, currentPage, totalPages],
184
+ [pageSize],
150
185
  );
186
+
187
+ // A page accepted before the total was known may turn out not to exist, so put the user on the last
188
+ // real page once the response settles it. Costs an extra request, but only for a page that was out of
189
+ // range to begin with; without it the hook sits on an empty page with no way back.
190
+ useEffect(() => {
191
+ if (!hasResponded.current) {
192
+ return;
193
+ }
194
+
195
+ if (Number.isNaN(totalCount.current)) {
196
+ if (!warnedMissingTotal.current) {
197
+ warnedMissingTotal.current = true;
198
+ console.warn(
199
+ `useServerPagination: ${urlKey} returned no total count, so out-of-range pages cannot be detected.`,
200
+ );
201
+ }
202
+ return;
203
+ }
204
+
205
+ const lastPage = Math.max(1, Math.ceil(totalCount.current / pageSize));
206
+ if (currentPage > lastPage) {
207
+ console.warn(`Invalid attempt to go to out of bounds page: ${currentPage}; returning to page ${lastPage}.`);
208
+ currentPageRef.current = lastPage;
209
+ setCurrentPage(lastPage);
210
+ }
211
+ }, [currentPage, pageSize, urlKey, data]);
212
+
151
213
  const goToNext = useCallback(() => {
152
- goTo(currentPage + 1);
153
- }, [url, currentPage, totalPages]);
214
+ goTo(currentPageRef.current + 1);
215
+ }, [goTo]);
154
216
 
155
217
  const goToPrevious = useCallback(() => {
156
- goTo(currentPage - 1);
157
- }, [url, currentPage, totalPages]);
218
+ goTo(currentPageRef.current - 1);
219
+ }, [goTo]);
158
220
 
159
221
  return {
160
222
  data: getData(data?.data),
161
223
  totalPages,
162
224
  totalCount: totalCount.current,
163
225
  currentPage,
164
- currentPageSize,
226
+ currentPageSize: currentPageSize.current,
165
227
  paginated: totalPages > 1,
166
228
  showNextButton: currentPage < totalPages,
167
229
  showPreviousButton: currentPage > 1,