@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/Extension.d.ts.map +1 -1
- package/dist/Extension.js +86 -44
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/useAssignedExtensionIds.d.ts +1 -1
- package/dist/useAssignedExtensionIds.d.ts.map +1 -1
- package/dist/useAssignedExtensionIds.js +7 -14
- package/dist/useAssignedExtensions.d.ts +13 -1
- package/dist/useAssignedExtensions.d.ts.map +1 -1
- package/dist/useAssignedExtensions.js +31 -4
- package/dist/useCandidateExtensions.d.ts +11 -0
- package/dist/useCandidateExtensions.d.ts.map +1 -0
- package/dist/useCandidateExtensions.js +12 -0
- package/dist/useExtensionSlot.d.ts.map +1 -1
- package/dist/useExtensionSlot.js +8 -13
- package/dist/useExtensionSlotStore.d.ts +0 -1
- package/dist/useExtensionSlotStore.d.ts.map +1 -1
- package/dist/useExtensionSlotStore.js +19 -2
- package/dist/useFhirPagination.d.ts +1 -1
- package/dist/useOpenmrsPagination.d.ts +2 -2
- package/dist/useOpenmrsPagination.d.ts.map +1 -1
- package/dist/useOpenmrsPagination.js +66 -15
- package/dist/usePagination.d.ts +5 -0
- package/dist/usePagination.d.ts.map +1 -1
- package/dist/usePagination.js +23 -21
- package/dist/useShallowStableValue.d.ts +13 -0
- package/dist/useShallowStableValue.d.ts.map +1 -0
- package/dist/useShallowStableValue.js +19 -0
- package/dist/useStore.d.ts.map +1 -1
- package/dist/useStore.js +25 -11
- package/package.json +23 -23
- package/src/Extension.tsx +110 -54
- package/src/extensions.test.tsx +112 -0
- package/src/index.ts +2 -0
- package/src/useAssignedExtensionIds.ts +5 -15
- package/src/useAssignedExtensions.ts +30 -3
- package/src/useCandidateExtensions.ts +15 -0
- package/src/useExtensionSlot.ts +7 -17
- package/src/useExtensionSlotStore.ts +23 -3
- package/src/useOpenmrsPagination.test.ts +297 -1
- package/src/useOpenmrsPagination.ts +72 -10
- package/src/usePagination.test.tsx +315 -0
- package/src/usePagination.ts +27 -15
- package/src/useShallowStableValue.test.ts +69 -0
- package/src/useShallowStableValue.ts +24 -0
- package/src/useStore.test.ts +39 -0
- package/src/useStore.ts +17 -10
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { act, render, renderHook } from '@testing-library/react';
|
|
4
|
+
import { usePagination } from './usePagination';
|
|
5
|
+
|
|
6
|
+
function rows(length: number) {
|
|
7
|
+
return Array.from({ length }, (_, i) => i);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('usePagination', () => {
|
|
11
|
+
describe('callback identity', () => {
|
|
12
|
+
it('keeps the same callbacks as the data grows', () => {
|
|
13
|
+
const { result, rerender } = renderHook(({ data }) => usePagination(data, 20), {
|
|
14
|
+
initialProps: { data: rows(50) },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const { goTo, goToNext, goToPrevious } = result.current;
|
|
18
|
+
expect(result.current.totalPages).toBe(3);
|
|
19
|
+
|
|
20
|
+
for (const length of [100, 150, 200, 250, 300, 350, 373]) {
|
|
21
|
+
rerender({ data: rows(length) });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
expect(result.current.totalPages).toBe(19);
|
|
25
|
+
expect(result.current.goTo).toBe(goTo);
|
|
26
|
+
expect(result.current.goToNext).toBe(goToNext);
|
|
27
|
+
expect(result.current.goToPrevious).toBe(goToPrevious);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('keeps the same callbacks as the page changes', () => {
|
|
31
|
+
const { result } = renderHook(() => usePagination(rows(100), 20));
|
|
32
|
+
|
|
33
|
+
const { goTo, goToNext, goToPrevious } = result.current;
|
|
34
|
+
|
|
35
|
+
act(() => result.current.goTo(3));
|
|
36
|
+
act(() => result.current.goToNext());
|
|
37
|
+
act(() => result.current.goToPrevious());
|
|
38
|
+
|
|
39
|
+
expect(result.current.currentPage).toBe(3);
|
|
40
|
+
expect(result.current.goTo).toBe(goTo);
|
|
41
|
+
expect(result.current.goToNext).toBe(goToNext);
|
|
42
|
+
expect(result.current.goToPrevious).toBe(goToPrevious);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('does not reset the page when a consumer effect depends on goTo', () => {
|
|
46
|
+
// Mirrors the advanced patient search, where results accumulate in 50-row batches while an
|
|
47
|
+
// effect keyed on `goTo` resets to page 1 — the shape that produced the reported snap-back.
|
|
48
|
+
const { result, rerender } = renderHook(
|
|
49
|
+
({ data }) => {
|
|
50
|
+
const pagination = usePagination(data, 20);
|
|
51
|
+
const { goTo } = pagination;
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
goTo(1);
|
|
54
|
+
}, [goTo]);
|
|
55
|
+
return pagination;
|
|
56
|
+
},
|
|
57
|
+
{ initialProps: { data: rows(50) } },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
act(() => result.current.goTo(2));
|
|
61
|
+
expect(result.current.currentPage).toBe(2);
|
|
62
|
+
|
|
63
|
+
for (const length of [100, 150, 200, 250, 300, 350, 373]) {
|
|
64
|
+
rerender({ data: rows(length) });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expect(result.current.currentPage).toBe(2);
|
|
68
|
+
expect(result.current.results).toEqual(rows(40).slice(20));
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('clamping', () => {
|
|
73
|
+
it('clamps the page and results when the data shrinks', () => {
|
|
74
|
+
const { result, rerender } = renderHook(({ data }) => usePagination(data, 20), {
|
|
75
|
+
initialProps: { data: rows(100) },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
act(() => result.current.goTo(5));
|
|
79
|
+
expect(result.current.currentPage).toBe(5);
|
|
80
|
+
expect(result.current.results).toEqual(rows(100).slice(80));
|
|
81
|
+
|
|
82
|
+
rerender({ data: rows(10) });
|
|
83
|
+
|
|
84
|
+
expect(result.current.totalPages).toBe(1);
|
|
85
|
+
expect(result.current.currentPage).toBe(1);
|
|
86
|
+
expect(result.current.results).toEqual(rows(10));
|
|
87
|
+
expect(result.current.showPreviousButton).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('clamps to page 1 when the data empties', () => {
|
|
91
|
+
const { result, rerender } = renderHook(({ data }) => usePagination(data, 20), {
|
|
92
|
+
initialProps: { data: rows(100) },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
act(() => result.current.goTo(4));
|
|
96
|
+
rerender({ data: [] });
|
|
97
|
+
|
|
98
|
+
expect(result.current.totalPages).toBe(1);
|
|
99
|
+
expect(result.current.currentPage).toBe(1);
|
|
100
|
+
expect(result.current.results).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('never goes below page 1', () => {
|
|
104
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
105
|
+
const { result } = renderHook(() => usePagination(rows(100), 20));
|
|
106
|
+
|
|
107
|
+
act(() => result.current.goTo(0));
|
|
108
|
+
expect(result.current.currentPage).toBe(1);
|
|
109
|
+
|
|
110
|
+
act(() => result.current.goTo(-5));
|
|
111
|
+
expect(result.current.currentPage).toBe(1);
|
|
112
|
+
|
|
113
|
+
act(() => result.current.goToPrevious());
|
|
114
|
+
expect(result.current.currentPage).toBe(1);
|
|
115
|
+
warn.mockRestore();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('rejects a non-integer page rather than letting NaN through', () => {
|
|
119
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
120
|
+
const { result } = renderHook(() => usePagination(rows(100), 20));
|
|
121
|
+
|
|
122
|
+
act(() => result.current.goTo(Number('nope')));
|
|
123
|
+
expect(result.current.currentPage).toBe(1);
|
|
124
|
+
expect(result.current.results).toEqual(rows(20));
|
|
125
|
+
expect(warn).toHaveBeenCalled();
|
|
126
|
+
|
|
127
|
+
act(() => result.current.goTo(2.5));
|
|
128
|
+
expect(result.current.currentPage).toBe(1);
|
|
129
|
+
|
|
130
|
+
// the hook is still usable afterwards
|
|
131
|
+
act(() => result.current.goTo(3));
|
|
132
|
+
expect(result.current.currentPage).toBe(3);
|
|
133
|
+
warn.mockRestore();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('out-of-range requests do not linger', () => {
|
|
138
|
+
it('does not follow the tail of a growing data set after an out-of-range goTo', () => {
|
|
139
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
140
|
+
const { result, rerender } = renderHook(({ data }) => usePagination(data, 20), {
|
|
141
|
+
initialProps: { data: rows(60) },
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
act(() => result.current.goTo(999));
|
|
145
|
+
expect(result.current.currentPage).toBe(3);
|
|
146
|
+
|
|
147
|
+
rerender({ data: rows(200) });
|
|
148
|
+
expect(result.current.currentPage).toBe(3);
|
|
149
|
+
|
|
150
|
+
rerender({ data: rows(2000) });
|
|
151
|
+
expect(result.current.currentPage).toBe(3);
|
|
152
|
+
warn.mockRestore();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('does not advance later when Next is pressed at the last page and the data then grows', () => {
|
|
156
|
+
const { result, rerender } = renderHook(({ data }) => usePagination(data, 20), {
|
|
157
|
+
initialProps: { data: rows(60) },
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
act(() => result.current.goTo(3));
|
|
161
|
+
expect(result.current.showNextButton).toBe(false);
|
|
162
|
+
|
|
163
|
+
act(() => result.current.goToNext());
|
|
164
|
+
expect(result.current.currentPage).toBe(3);
|
|
165
|
+
|
|
166
|
+
rerender({ data: rows(100) });
|
|
167
|
+
expect(result.current.currentPage).toBe(3);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('does not restore an old page when the data shrinks and grows again', () => {
|
|
171
|
+
const { result, rerender } = renderHook(({ data }) => usePagination(data, 20), {
|
|
172
|
+
initialProps: { data: rows(380) },
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
act(() => result.current.goTo(19));
|
|
176
|
+
rerender({ data: rows(40) });
|
|
177
|
+
expect(result.current.currentPage).toBe(2);
|
|
178
|
+
|
|
179
|
+
rerender({ data: rows(380) });
|
|
180
|
+
expect(result.current.currentPage).toBe(2);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('stepping', () => {
|
|
185
|
+
it('holds the last page when Next is pressed at the end, and Previous then moves one page', () => {
|
|
186
|
+
const { result } = renderHook(() => usePagination(rows(60), 20));
|
|
187
|
+
|
|
188
|
+
act(() => result.current.goTo(3));
|
|
189
|
+
expect(result.current.currentPage).toBe(3);
|
|
190
|
+
expect(result.current.showNextButton).toBe(false);
|
|
191
|
+
|
|
192
|
+
act(() => result.current.goToNext());
|
|
193
|
+
expect(result.current.currentPage).toBe(3);
|
|
194
|
+
|
|
195
|
+
act(() => result.current.goToNext());
|
|
196
|
+
expect(result.current.currentPage).toBe(3);
|
|
197
|
+
|
|
198
|
+
act(() => result.current.goToPrevious());
|
|
199
|
+
expect(result.current.currentPage).toBe(2);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('moves Previous one page from what is displayed after a large shrink', () => {
|
|
203
|
+
const { result, rerender } = renderHook(({ data }) => usePagination(data, 20), {
|
|
204
|
+
initialProps: { data: rows(380) },
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
act(() => result.current.goTo(19));
|
|
208
|
+
expect(result.current.currentPage).toBe(19);
|
|
209
|
+
|
|
210
|
+
rerender({ data: rows(40) });
|
|
211
|
+
expect(result.current.currentPage).toBe(2);
|
|
212
|
+
|
|
213
|
+
act(() => result.current.goToPrevious());
|
|
214
|
+
expect(result.current.currentPage).toBe(1);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Batching the two calls into one commit is what distinguishes stepping from the latest queued page
|
|
218
|
+
// from stepping from a page captured when the callback was created; the latter is a commit behind.
|
|
219
|
+
it('steps from the latest queued page when batched with a goTo', () => {
|
|
220
|
+
const { result } = renderHook(() => usePagination(rows(200), 20));
|
|
221
|
+
const { goTo, goToNext } = result.current;
|
|
222
|
+
|
|
223
|
+
act(() => {
|
|
224
|
+
goTo(3);
|
|
225
|
+
goToNext();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
expect(result.current.currentPage).toBe(4);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('steps back from the latest queued page when batched with a goTo', () => {
|
|
232
|
+
const { result } = renderHook(() => usePagination(rows(200), 20));
|
|
233
|
+
const { goTo, goToPrevious } = result.current;
|
|
234
|
+
|
|
235
|
+
act(() => {
|
|
236
|
+
goTo(5);
|
|
237
|
+
goToPrevious();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
expect(result.current.currentPage).toBe(4);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('steps correctly when called from a child effect during a shrink', () => {
|
|
244
|
+
const seen: Array<number> = [];
|
|
245
|
+
const box: { goTo?: (n: number) => void } = {};
|
|
246
|
+
let armed = false;
|
|
247
|
+
|
|
248
|
+
function Child({ onPrev }: { onPrev: () => void }) {
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (armed) {
|
|
251
|
+
armed = false;
|
|
252
|
+
onPrev();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function Parent({ data }: { data: Array<number> }) {
|
|
259
|
+
const { currentPage, goTo, goToPrevious } = usePagination(data, 20);
|
|
260
|
+
seen.push(currentPage);
|
|
261
|
+
box.goTo = goTo;
|
|
262
|
+
return <Child onPrev={goToPrevious} />;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const { rerender } = render(<Parent data={rows(380)} />);
|
|
266
|
+
act(() => box.goTo!(19));
|
|
267
|
+
expect(seen[seen.length - 1]).toBe(19);
|
|
268
|
+
|
|
269
|
+
// the data shrinks to 2 pages and Previous is pressed in that same commit
|
|
270
|
+
armed = true;
|
|
271
|
+
rerender(<Parent data={rows(40)} />);
|
|
272
|
+
|
|
273
|
+
expect(seen[seen.length - 1]).toBe(1);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('returned shape', () => {
|
|
278
|
+
it('paginates and reports flags as before', () => {
|
|
279
|
+
const { result } = renderHook(() => usePagination(rows(45), 20));
|
|
280
|
+
|
|
281
|
+
expect(result.current.totalPages).toBe(3);
|
|
282
|
+
expect(result.current.currentPage).toBe(1);
|
|
283
|
+
expect(result.current.paginated).toBe(true);
|
|
284
|
+
expect(result.current.showNextButton).toBe(true);
|
|
285
|
+
expect(result.current.showPreviousButton).toBe(false);
|
|
286
|
+
expect(result.current.results).toEqual(rows(20));
|
|
287
|
+
|
|
288
|
+
act(() => result.current.goToNext());
|
|
289
|
+
expect(result.current.results).toEqual(rows(40).slice(20));
|
|
290
|
+
expect(result.current.showPreviousButton).toBe(true);
|
|
291
|
+
|
|
292
|
+
act(() => result.current.goToNext());
|
|
293
|
+
expect(result.current.results).toEqual(rows(45).slice(40));
|
|
294
|
+
expect(result.current.showNextButton).toBe(false);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('is not paginated when the data fits on one page', () => {
|
|
298
|
+
const { result } = renderHook(() => usePagination(rows(5), 20));
|
|
299
|
+
|
|
300
|
+
expect(result.current.totalPages).toBe(1);
|
|
301
|
+
expect(result.current.paginated).toBe(false);
|
|
302
|
+
expect(result.current.results).toEqual(rows(5));
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Characterises a long-standing quirk rather than endorsing it: an unusable `resultsPerPage` reports
|
|
306
|
+
// a single page but slices no rows out of it, so the caller gets an empty list with no diagnostic.
|
|
307
|
+
it('reports one page and no results when resultsPerPage is not a usable number', () => {
|
|
308
|
+
const { result } = renderHook(() => usePagination(rows(50), 0));
|
|
309
|
+
|
|
310
|
+
expect(result.current.totalPages).toBe(1);
|
|
311
|
+
expect(result.current.currentPage).toBe(1);
|
|
312
|
+
expect(result.current.results).toEqual([]);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
package/src/usePagination.ts
CHANGED
|
@@ -6,6 +6,11 @@ const defaultResultsPerPage = 10;
|
|
|
6
6
|
/**
|
|
7
7
|
* Use this hook to paginate data that already exists on the client side.
|
|
8
8
|
* Note that if the data is obtained from server-side, the caller must handle server-side pagination manually.
|
|
9
|
+
*
|
|
10
|
+
* `goTo`, `goToNext` and `goToPrevious` keep the same identity for the life of the component, so they are
|
|
11
|
+
* safe to list in a dependency array. `currentPage` is bounded by the data, so it can change on its own
|
|
12
|
+
* when the data shrinks; a component reacting to `currentPage` should expect that.
|
|
13
|
+
*
|
|
9
14
|
* @see `useServerPagination` for hook that automatically manages server-side pagination.
|
|
10
15
|
* @see `useServerInfinite` for hook to get all data loaded onto the client-side
|
|
11
16
|
* @param data
|
|
@@ -13,7 +18,7 @@ const defaultResultsPerPage = 10;
|
|
|
13
18
|
* @returns
|
|
14
19
|
*/
|
|
15
20
|
export function usePagination<T>(data: Array<T> = [], resultsPerPage = defaultResultsPerPage) {
|
|
16
|
-
const [
|
|
21
|
+
const [requestedPage, setRequestedPage] = useState(1);
|
|
17
22
|
const totalPages = useMemo(
|
|
18
23
|
() =>
|
|
19
24
|
typeof resultsPerPage === 'number' && resultsPerPage > 0
|
|
@@ -22,29 +27,36 @@ export function usePagination<T>(data: Array<T> = [], resultsPerPage = defaultRe
|
|
|
22
27
|
[data.length, resultsPerPage],
|
|
23
28
|
);
|
|
24
29
|
|
|
30
|
+
// If requested page is past the end, set to the end. Done here to retain referential stability
|
|
31
|
+
// on the functions returned by this hook
|
|
32
|
+
if (requestedPage > totalPages) {
|
|
33
|
+
setRequestedPage(totalPages);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const page = Math.min(Math.max(1, requestedPage), totalPages);
|
|
37
|
+
|
|
25
38
|
const results = useMemo(() => {
|
|
26
39
|
const lowerBound = (page - 1) * resultsPerPage;
|
|
27
40
|
const upperBound = (page + 0) * resultsPerPage;
|
|
28
41
|
return data.slice(lowerBound, upperBound);
|
|
29
42
|
}, [data, page, resultsPerPage]);
|
|
30
43
|
|
|
31
|
-
const goTo = useCallback(
|
|
32
|
-
(page
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
[setPage, totalPages],
|
|
36
|
-
);
|
|
37
|
-
const goToNext = useCallback(() => {
|
|
38
|
-
if (page < totalPages) {
|
|
39
|
-
setPage(page + 1);
|
|
44
|
+
const goTo = useCallback((page: number) => {
|
|
45
|
+
if (!Number.isInteger(page) || page < 1) {
|
|
46
|
+
console.warn(`usePagination: ignoring goTo(${page}); page must be a positive integer.`);
|
|
47
|
+
return;
|
|
40
48
|
}
|
|
41
|
-
|
|
49
|
+
setRequestedPage(page);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
// overflow clamping happens in render; see the setRequestedPage() call above
|
|
53
|
+
const goToNext = useCallback(() => {
|
|
54
|
+
setRequestedPage((p) => p + 1);
|
|
55
|
+
}, []);
|
|
42
56
|
|
|
43
57
|
const goToPrevious = useCallback(() => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
}, [page, setPage]);
|
|
58
|
+
setRequestedPage((p) => Math.max(1, p - 1));
|
|
59
|
+
}, []);
|
|
48
60
|
|
|
49
61
|
const memoisedPaginatedData = useMemo(
|
|
50
62
|
() => ({
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { renderHook } from '@testing-library/react';
|
|
3
|
+
import { useShallowStableValue } from './useShallowStableValue';
|
|
4
|
+
|
|
5
|
+
describe('useShallowStableValue', () => {
|
|
6
|
+
it('keeps its reference across an equal object literal', () => {
|
|
7
|
+
const { result, rerender } = renderHook(({ value }) => useShallowStableValue(value), {
|
|
8
|
+
initialProps: { value: { patientUuid: 'abc' } },
|
|
9
|
+
});
|
|
10
|
+
const first = result.current;
|
|
11
|
+
|
|
12
|
+
rerender({ value: { patientUuid: 'abc' } });
|
|
13
|
+
|
|
14
|
+
expect(result.current).toBe(first);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('takes the new reference when a value changes', () => {
|
|
18
|
+
const { result, rerender } = renderHook(({ value }) => useShallowStableValue(value), {
|
|
19
|
+
initialProps: { value: { patientUuid: 'abc' } },
|
|
20
|
+
});
|
|
21
|
+
const first = result.current;
|
|
22
|
+
const next = { patientUuid: 'def' };
|
|
23
|
+
|
|
24
|
+
rerender({ value: next });
|
|
25
|
+
|
|
26
|
+
expect(result.current).not.toBe(first);
|
|
27
|
+
expect(result.current).toBe(next);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('takes the new reference when a key is added or removed', () => {
|
|
31
|
+
const { result, rerender } = renderHook(({ value }) => useShallowStableValue(value), {
|
|
32
|
+
initialProps: { value: { a: 1 } as Record<string, number> },
|
|
33
|
+
});
|
|
34
|
+
const first = result.current;
|
|
35
|
+
|
|
36
|
+
rerender({ value: { a: 1, b: 2 } });
|
|
37
|
+
expect(result.current).not.toBe(first);
|
|
38
|
+
|
|
39
|
+
const both = result.current;
|
|
40
|
+
rerender({ value: { a: 1 } });
|
|
41
|
+
expect(result.current).not.toBe(both);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('returns the latest of two equal values rather than the first one it ever saw', () => {
|
|
45
|
+
const { result, rerender } = renderHook(({ value }) => useShallowStableValue(value), {
|
|
46
|
+
initialProps: { value: { n: 1 } },
|
|
47
|
+
});
|
|
48
|
+
const first = result.current;
|
|
49
|
+
|
|
50
|
+
rerender({ value: { n: 2 } });
|
|
51
|
+
const third = { n: 1 };
|
|
52
|
+
rerender({ value: third });
|
|
53
|
+
|
|
54
|
+
// Equal to the first value, but the run of equal values was broken, so it is a new reference.
|
|
55
|
+
expect(result.current).toBe(third);
|
|
56
|
+
expect(result.current).not.toBe(first);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('compares only one level deep', () => {
|
|
60
|
+
const { result, rerender } = renderHook(({ value }) => useShallowStableValue(value), {
|
|
61
|
+
initialProps: { value: { patient: { uuid: 'abc' } } },
|
|
62
|
+
});
|
|
63
|
+
const first = result.current;
|
|
64
|
+
|
|
65
|
+
rerender({ value: { patient: { uuid: 'abc' } } });
|
|
66
|
+
|
|
67
|
+
expect(result.current).not.toBe(first);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** @module @category Utility */
|
|
2
|
+
import { useRef } from 'react';
|
|
3
|
+
import { shallowEqual } from '@openmrs/esm-utils';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns `value` with a reference that only changes when its contents do, so that it can be used
|
|
7
|
+
* as a dependency of `useMemo`, `useEffect` and friends.
|
|
8
|
+
*
|
|
9
|
+
* This exists for props that are almost always written as object literals — `state={{ patientUuid }}`
|
|
10
|
+
* — which get a new identity on every render while meaning the same thing. Comparison is shallow,
|
|
11
|
+
* so a value nested more than one level deep still reads as a change.
|
|
12
|
+
*
|
|
13
|
+
* @param value The value to stabilize
|
|
14
|
+
* @returns `value`, or the last value this hook returned if the two are shallowly equal
|
|
15
|
+
*/
|
|
16
|
+
export function useShallowStableValue<T>(value: T): T {
|
|
17
|
+
const stable = useRef(value);
|
|
18
|
+
|
|
19
|
+
if (!shallowEqual(stable.current, value)) {
|
|
20
|
+
stable.current = value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return stable.current;
|
|
24
|
+
}
|
package/src/useStore.test.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import React from 'react';
|
|
1
2
|
import { describe, expect, it } from 'vitest';
|
|
2
3
|
import { act, renderHook } from '@testing-library/react';
|
|
3
4
|
import { createGlobalStore } from '@openmrs/esm-state';
|
|
@@ -25,6 +26,44 @@ describe('useStore', () => {
|
|
|
25
26
|
});
|
|
26
27
|
});
|
|
27
28
|
|
|
29
|
+
describe('useStore reference stability', () => {
|
|
30
|
+
it('returns the store state itself when there are no actions to merge in', () => {
|
|
31
|
+
const store = createGlobalStore('stability-plain', { renderings: new Map<string, number>() });
|
|
32
|
+
const { result, rerender } = renderHook(() => useStore(store));
|
|
33
|
+
|
|
34
|
+
// Not a copy: consumers of the extension renderings store depend on this, because the map
|
|
35
|
+
// inside is mutated in place and only the state object around it changes identity.
|
|
36
|
+
expect(result.current).toBe(store.getState());
|
|
37
|
+
|
|
38
|
+
const before = result.current;
|
|
39
|
+
rerender();
|
|
40
|
+
expect(result.current).toBe(before);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('keeps its reference across an unrelated re-render, so it can be a memo dependency', () => {
|
|
44
|
+
const store = createGlobalStore('stability-memo', { a: 1, b: 2 });
|
|
45
|
+
let memoRuns = 0;
|
|
46
|
+
|
|
47
|
+
const { result, rerender } = renderHook(() => {
|
|
48
|
+
const state = useStore(store);
|
|
49
|
+
|
|
50
|
+
const derived = React.useMemo(() => ++memoRuns, [state]);
|
|
51
|
+
return derived;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(memoRuns).toBe(1);
|
|
55
|
+
rerender();
|
|
56
|
+
expect(memoRuns).toBe(1);
|
|
57
|
+
|
|
58
|
+
act(() => {
|
|
59
|
+
store.setState({ a: 2 });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(memoRuns).toBe(2);
|
|
63
|
+
expect(result.current).toBe(2);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
28
67
|
describe('useStoreWithActions', () => {
|
|
29
68
|
it('should correctly bind actions', () => {
|
|
30
69
|
const store = createGlobalStore('counter', { count: 0 });
|
package/src/useStore.ts
CHANGED
|
@@ -44,10 +44,14 @@ function bindActions<T>(store: StoreApi<T>, actions: Actions<T>): BoundActions<T
|
|
|
44
44
|
return bound;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
/**
|
|
48
|
+
* A single shared identity function, rather than one built per call: `getSnapshot` below takes
|
|
49
|
+
* the selector as a dependency, so a fresh default each render would leave it unstable for every
|
|
50
|
+
* caller that doesn't pass a selector of its own.
|
|
51
|
+
*/
|
|
52
|
+
const identitySelect = (x: unknown) => x;
|
|
53
|
+
|
|
54
|
+
const defaultSelectFunction = <T, U>() => identitySelect as (x: T) => U;
|
|
51
55
|
|
|
52
56
|
function useStore<T>(store: StoreApi<T>): T;
|
|
53
57
|
function useStore<T, U>(store: StoreApi<T>, select: (state: T) => U): U;
|
|
@@ -79,12 +83,15 @@ function useStore<T, U, A extends Actions<T>>(
|
|
|
79
83
|
|
|
80
84
|
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
81
85
|
|
|
82
|
-
let boundActions: BoundActions<T, Actions<T>> = useMemo(
|
|
83
|
-
() => (actions ? bindActions(store, actions) :
|
|
86
|
+
let boundActions: BoundActions<T, Actions<T>> | null = useMemo(
|
|
87
|
+
() => (actions ? bindActions(store, actions) : null),
|
|
84
88
|
[store, actions],
|
|
85
89
|
);
|
|
86
90
|
|
|
87
|
-
|
|
91
|
+
// Returned as-is when there are no actions to merge in, so the reference stays stable for as
|
|
92
|
+
// long as the selected value does. That value is not a copy — it is whatever the selector
|
|
93
|
+
// returned, usually the store's own state.
|
|
94
|
+
return useMemo(() => (boundActions ? { ...state, ...boundActions } : state), [state, boundActions]);
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
/**
|
|
@@ -110,12 +117,12 @@ function createUseStore<T>(store: StoreApi<T>) {
|
|
|
110
117
|
const getSnapshot = useCallback(() => store.getState(), []);
|
|
111
118
|
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
112
119
|
|
|
113
|
-
let boundActions: BoundActions<T, Actions<T>> = useMemo(
|
|
114
|
-
() => (actions ? bindActions(store, actions) :
|
|
120
|
+
let boundActions: BoundActions<T, Actions<T>> | null = useMemo(
|
|
121
|
+
() => (actions ? bindActions(store, actions) : null),
|
|
115
122
|
[actions],
|
|
116
123
|
);
|
|
117
124
|
|
|
118
|
-
return { ...state, ...boundActions };
|
|
125
|
+
return useMemo(() => (boundActions ? { ...state, ...boundActions } : state), [state, boundActions]);
|
|
119
126
|
}
|
|
120
127
|
|
|
121
128
|
return useStore;
|