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

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 (38) 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/useShallowStableValue.d.ts +13 -0
  22. package/dist/useShallowStableValue.d.ts.map +1 -0
  23. package/dist/useShallowStableValue.js +19 -0
  24. package/dist/useStore.d.ts.map +1 -1
  25. package/dist/useStore.js +25 -11
  26. package/package.json +23 -23
  27. package/src/Extension.tsx +110 -54
  28. package/src/extensions.test.tsx +112 -0
  29. package/src/index.ts +2 -0
  30. package/src/useAssignedExtensionIds.ts +5 -15
  31. package/src/useAssignedExtensions.ts +30 -3
  32. package/src/useCandidateExtensions.ts +15 -0
  33. package/src/useExtensionSlot.ts +7 -17
  34. package/src/useExtensionSlotStore.ts +23 -3
  35. package/src/useShallowStableValue.test.ts +69 -0
  36. package/src/useShallowStableValue.ts +24 -0
  37. package/src/useStore.test.ts +39 -0
  38. package/src/useStore.ts +17 -10
package/src/Extension.tsx CHANGED
@@ -21,72 +21,128 @@ export const Extension: React.FC<ExtensionProps> = ({ state, children, ...divPro
21
21
  const { extension } = useContext(ComponentContext);
22
22
  const parcel = useRef<Parcel | null>(null);
23
23
  const updatePromise = useRef<Promise<void>>(Promise.resolve());
24
+ const isUnmounted = useRef(false);
25
+ const isRendering = useRef(false);
26
+ const latestState = useRef(state);
24
27
 
25
- const ref = useCallback((node: HTMLDivElement) => {
28
+ // Takes the parcel to tear down rather than reading `parcel.current`, which can be reassigned
29
+ // between scheduling this and running it.
30
+ const unmountParcel = useCallback((target: Parcel | null) => {
31
+ if (!target) {
32
+ return;
33
+ }
34
+
35
+ const unmountWhenMounted = () => {
36
+ if (target.getStatus() === 'MOUNTED') {
37
+ target.unmount();
38
+ }
39
+ };
40
+
41
+ switch (target.getStatus()) {
42
+ case 'MOUNTED':
43
+ target.unmount();
44
+ break;
45
+ case 'UPDATING':
46
+ // The rejection is reported by whoever owns `updatePromise`; here it only matters that a
47
+ // failed update still runs the teardown, or the parcel is left broken and mounted.
48
+ updatePromise.current?.then(unmountWhenMounted, unmountWhenMounted);
49
+ break;
50
+ default:
51
+ // Any other status: the parcel either hasn't finished coming up or is already gone.
52
+ // `mountPromise` has settled or will, and `unmountWhenMounted` re-checks the status.
53
+ target.mountPromise.then(unmountWhenMounted, () => {});
54
+ }
55
+ }, []);
56
+
57
+ const applyUpdate = useCallback((target: Parcel, nextState: ExtensionProps['state']) => {
58
+ if (!target.update || target.getStatus() === 'UNMOUNTING') {
59
+ return;
60
+ }
61
+
62
+ // Every later update chains onto this promise, so it has to settle even when an update fails.
63
+ updatePromise.current = Promise.all([target.mountPromise, updatePromise.current])
64
+ .then(() => {
65
+ if (parcel.current?.getStatus() === 'MOUNTED' && parcel.current.update) {
66
+ return parcel.current.update({ ...nextState });
67
+ }
68
+ })
69
+ .catch((err) => {
70
+ // A parcel torn down while its update was in flight rejects, and that race is expected.
71
+ // We use the status to distinguish reject reasons as the message isn't reliable
72
+ const status = parcel.current?.getStatus();
73
+
74
+ if (status !== 'UNMOUNTING' && status !== 'NOT_MOUNTED' && status !== 'UNLOADING') {
75
+ console.error(`The extension '${extension?.extensionId}' failed to update`, err);
76
+ }
77
+ });
78
+ }, []);
79
+
80
+ const ref = useCallback((node: HTMLDivElement | null) => {
81
+ // React detaches a ref by calling it with null. If we render something in response to this,
82
+ // React ignores it and we're left tracking a parcel with no visible DOM, so skip conditions
83
+ // we can't handle.
26
84
  if (
27
- extension?.extensionSlotName &&
28
- extension.extensionSlotModuleName &&
29
- extension.extensionSlotModuleName &&
30
- !parcel.current
85
+ !node ||
86
+ parcel.current ||
87
+ isRendering.current ||
88
+ !extension?.extensionSlotName ||
89
+ !extension.extensionSlotModuleName
31
90
  ) {
32
- renderExtension(
33
- node,
34
- extension.extensionSlotName,
35
- extension.extensionSlotModuleName,
36
- extension.extensionId,
37
- undefined,
38
- state,
39
- ).then((newParcel: Parcel) => {
40
- parcel.current = newParcel;
41
- });
91
+ return;
42
92
  }
93
+
94
+ isRendering.current = true;
95
+
96
+ renderExtension(
97
+ node,
98
+ extension.extensionSlotName,
99
+ extension.extensionSlotModuleName,
100
+ extension.extensionId,
101
+ undefined,
102
+ state,
103
+ ).then(
104
+ (newParcel: Parcel | null) => {
105
+ isRendering.current = false;
106
+ parcel.current = newParcel;
107
+
108
+ // Loading an extension's bundle can outlast the component that asked for it: the cleanup
109
+ // effect has already run and saw no parcel, so teardown has to happen here instead, or the
110
+ // parcel mounts into a detached node and its rendering record outlives the page.
111
+ if (isUnmounted.current) {
112
+ unmountParcel(newParcel);
113
+ return;
114
+ }
115
+
116
+ // Ensure we update the state of the parcel to the latest version we've received
117
+ if (newParcel && latestState.current !== state) {
118
+ applyUpdate(newParcel, latestState.current);
119
+ }
120
+ },
121
+ // Cleared so a later reattach can try again; `renderExtension` reports its own failures.
122
+ () => {
123
+ isRendering.current = false;
124
+ },
125
+ );
43
126
  }, []);
44
127
 
45
128
  useEffect(() => {
129
+ // Reset on every run, not just the first: StrictMode mounts, tears down and mounts again, so
130
+ // a flag only ever set by the cleanup would still read "unmounted" for the live component.
131
+ isUnmounted.current = false;
132
+
46
133
  return () => {
47
- if (parcel && parcel.current) {
48
- const status = parcel.current.getStatus();
49
- switch (status) {
50
- case 'MOUNTING':
51
- parcel.current.mountPromise.then(() => {
52
- if (parcel.current?.getStatus() === 'MOUNTED') {
53
- parcel.current.unmount();
54
- }
55
- });
56
- break;
57
- case 'MOUNTED':
58
- parcel.current.unmount();
59
- break;
60
- case 'UPDATING':
61
- if (updatePromise.current) {
62
- updatePromise.current.then(() => {
63
- if (parcel.current?.getStatus() === 'MOUNTED') {
64
- parcel.current.unmount();
65
- }
66
- });
67
- }
68
- }
69
- }
134
+ isUnmounted.current = true;
135
+ unmountParcel(parcel.current);
70
136
  };
71
137
  }, []);
72
138
 
73
139
  useEffect(() => {
74
- if (parcel.current && parcel.current.update && parcel.current.getStatus() !== 'UNMOUNTING') {
75
- Promise.all([parcel.current.mountPromise, updatePromise.current]).then(() => {
76
- if (parcel?.current?.getStatus() === 'MOUNTED' && parcel.current.update) {
77
- updatePromise.current = parcel.current.update({ ...state }).catch((err) => {
78
- // if we were trying to update but the component was unmounted
79
- // while this was happening, ignore the error
80
- if (
81
- !(err instanceof Error) ||
82
- !err.message.includes('minified message #32') ||
83
- parcel.current?.getStatus() === 'MOUNTED'
84
- ) {
85
- throw err;
86
- }
87
- });
88
- }
89
- });
140
+ // Recorded before the parcel is checked, so that a state that arrives before there is a parcel
141
+ // to receive it is still the one applied once there is.
142
+ latestState.current = state;
143
+
144
+ if (parcel.current) {
145
+ applyUpdate(parcel.current, state);
90
146
  }
91
147
  }, [state]);
92
148
 
@@ -7,6 +7,7 @@ import { act, render, screen, waitFor, within } from '@testing-library/react';
7
7
  import { registerFeatureFlag, setFeatureFlag } from '@openmrs/esm-feature-flags';
8
8
  import {
9
9
  attach,
10
+ getExtensionRenderingsStore,
10
11
  getExtensionNameFromId,
11
12
  registerExtension,
12
13
  updateInternalExtensionStore,
@@ -57,6 +58,69 @@ describe('ExtensionSlot, Extension, and useExtensionSlotMeta', () => {
57
58
  expect(screen.getByText(/English/)).toHaveTextContent('English?');
58
59
  });
59
60
 
61
+ it('Extension reports a failed update instead of rejecting silently', async () => {
62
+ const user = userEvent.setup();
63
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
64
+ const delivered: Array<number> = [];
65
+ let failNextUpdate = true;
66
+
67
+ registerExtension({
68
+ name: 'Flaky',
69
+ moduleName: 'esm-flaky-app',
70
+ meta: {},
71
+ load: async () => ({
72
+ bootstrap: async () => {},
73
+ mount: async (props: any) => {
74
+ props.domElement.textContent = 'flaky extension';
75
+ },
76
+ unmount: async (props: any) => {
77
+ props.domElement.textContent = '';
78
+ },
79
+ update: async (props: any) => {
80
+ if (failNextUpdate) {
81
+ failNextUpdate = false;
82
+ throw new Error('update blew up');
83
+ }
84
+
85
+ delivered.push(props.value);
86
+ },
87
+ }),
88
+ });
89
+ attach('FlakyBox', 'Flaky');
90
+
91
+ const App = openmrsComponentDecorator({
92
+ moduleName: 'esm-flaky-app',
93
+ featureName: 'Flaky',
94
+ disableTranslations: true,
95
+ })(() => {
96
+ const [value, next] = useReducer((value: number) => value + 1, 1);
97
+
98
+ return (
99
+ <div>
100
+ <ExtensionSlot name="FlakyBox" state={{ value }} />
101
+ <button onClick={next}>Next</button>
102
+ </div>
103
+ );
104
+ });
105
+
106
+ render(<App />);
107
+ expect(await screen.findByText('flaky extension')).toBeInTheDocument();
108
+
109
+ // The first of these fails, which makes single-spa hard-fail the parcel, so no later update
110
+ // reaches the extension. What must not happen is the failure going unreported and the promise
111
+ // chain being left rejected, which used to produce an unhandled rejection per later change.
112
+ await user.click(screen.getByText('Next'));
113
+ await user.click(screen.getByText('Next'));
114
+ await user.click(screen.getByText('Next'));
115
+
116
+ await waitFor(() =>
117
+ expect(consoleError).toHaveBeenCalledWith("The extension 'Flaky' failed to update", expect.any(Error)),
118
+ );
119
+ expect(consoleError).toHaveBeenCalledTimes(1);
120
+ expect(delivered).toEqual([]);
121
+ consoleError.mockRestore();
122
+ });
123
+
60
124
  it('Extension receives state changes (using <Extension>)', async () => {
61
125
  const user = userEvent.setup();
62
126
 
@@ -281,6 +345,54 @@ describe('ExtensionSlot, Extension, and useExtensionSlotMeta', () => {
281
345
  });
282
346
  });
283
347
 
348
+ describe('Extension teardown', () => {
349
+ beforeEach(() => {
350
+ updateInternalExtensionStore(() => ({ slots: {}, extensions: {} }));
351
+ getExtensionRenderingsStore().setState({ renderings: new Map() });
352
+ });
353
+
354
+ it('releases the rendering when the slot unmounts while the bundle is still loading', async () => {
355
+ const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {});
356
+ let releaseLoad = () => {};
357
+ const loadGate = new Promise<void>((resolve) => (releaseLoad = resolve));
358
+ const lifecycle = getSyncLifecycle(() => <div>Fred</div>, {
359
+ moduleName: 'esm-flintstone',
360
+ featureName: 'Flintstone',
361
+ disableTranslations: true,
362
+ });
363
+
364
+ registerExtension({
365
+ name: 'Fred',
366
+ moduleName: 'esm-flintstone',
367
+ load: async () => {
368
+ await loadGate;
369
+ return lifecycle();
370
+ },
371
+ meta: {},
372
+ });
373
+ attach('Box', 'Fred');
374
+
375
+ const App = openmrsComponentDecorator({
376
+ moduleName: 'esm-flintstone',
377
+ featureName: 'Flintstone',
378
+ disableTranslations: true,
379
+ })(() => <ExtensionSlot name="Box" />);
380
+
381
+ const { unmount } = render(<App />);
382
+ await act(async () => {});
383
+ expect(getExtensionRenderingsStore().getState().renderings.size).toBe(1);
384
+
385
+ unmount();
386
+ releaseLoad();
387
+
388
+ await waitFor(() => expect(getExtensionRenderingsStore().getState().renderings.size).toBe(0));
389
+ // React detaches the ref by calling it with `null`; taking that for a render request starts a
390
+ // second render that resolves to null and clears the parcel still coming up.
391
+ expect(consoleWarn).not.toHaveBeenCalledWith(expect.stringContaining('no DOM element was available'));
392
+ consoleWarn.mockRestore();
393
+ });
394
+ });
395
+
284
396
  function registerSimpleExtension(
285
397
  name: string,
286
398
  moduleName: string,
package/src/index.ts CHANGED
@@ -10,6 +10,8 @@ export * from './useAbortController';
10
10
  export * from './useAppContext';
11
11
  export * from './useAssignedExtensionIds';
12
12
  export * from './useAssignedExtensions';
13
+ export * from './useCandidateExtensions';
14
+ export * from './useShallowStableValue';
13
15
  export * from './useAttachments';
14
16
  export * from './useBodyScrollLock';
15
17
  export * from './useConfig';
@@ -1,26 +1,16 @@
1
1
  /** @module @category Extension */
2
- import { useEffect, useState } from 'react';
3
- import { getExtensionStore } from '@openmrs/esm-extensions';
4
- import { isEqual } from 'lodash-es';
2
+ import { useMemo } from 'react';
3
+ import { useAssignedExtensions } from './useAssignedExtensions';
5
4
 
6
5
  /**
7
6
  * Gets the assigned extension ids for a given extension slot name.
8
- * Does not consider if offline or online.
7
+ *
9
8
  * @param slotName The name of the slot to get the assigned IDs for.
10
9
  *
11
10
  * @deprecated Use `useAssignedExtensions`
12
11
  */
13
12
  export function useAssignedExtensionIds(slotName: string) {
14
- const [ids, setIds] = useState<Array<string>>([]);
15
-
16
- useEffect(() => {
17
- return getExtensionStore().subscribe((state) => {
18
- const newIds = state.slots[slotName]?.assignedExtensions.map((e) => e.id) ?? [];
19
- if (!isEqual(newIds, ids)) {
20
- setIds(newIds);
21
- }
22
- });
23
- }, []);
13
+ const assignedExtensions = useAssignedExtensions(slotName);
24
14
 
25
- return ids;
15
+ return useMemo(() => assignedExtensions.map((extension) => extension.id), [assignedExtensions]);
26
16
  }
@@ -1,11 +1,38 @@
1
1
  /** @module @category Extension */
2
+ import { useMemo } from 'react';
3
+ import { sessionStore, type SessionStore } from '@openmrs/esm-api';
4
+ import { type ExtensionSlotCustomState, getAssignedExtensions } from '@openmrs/esm-extensions';
5
+ import { useShallowStableValue } from './useShallowStableValue';
2
6
  import { useExtensionSlotStore } from './useExtensionSlotStore';
7
+ import { useStore } from './useStore';
8
+
9
+ const selectSession = (state: SessionStore) => state.session;
3
10
 
4
11
  /**
5
12
  * Gets the assigned extensions for a given extension slot name.
13
+ *
14
+ * The reactive form of `getAssignedExtensions`, and it answers the same thing: display conditions
15
+ * are always applied, so the result is what should actually be displayed. Pass `state` whenever you
16
+ * know it: the same slot can be rendered in several places at once with different state, so
17
+ * conditions are resolved against the state of this rendering. Omitting it resolves them against
18
+ * the session alone, hiding any extension whose condition depends on state.
19
+ *
20
+ * The returned array is a copy, so sorting or filtering it in place can't corrupt the extension
21
+ * store. Its reference is stable for as long as the slot's extensions, the state and the session are.
22
+ *
6
23
  * @param slotName The name of the slot to get the assigned extensions for.
24
+ * @param state The state of this rendering of the slot.
7
25
  */
8
- export function useAssignedExtensions(slotName: string) {
9
- const slotStore = useExtensionSlotStore(slotName);
10
- return slotStore?.assignedExtensions ?? [];
26
+ export function useAssignedExtensions(slotName: string, state?: ExtensionSlotCustomState) {
27
+ // Subscribes so that this re-runs when the slot's extensions change; `getAssignedExtensions` reads
28
+ // the same store, so the value below is what it is about to return.
29
+ const { candidateExtensions } = useExtensionSlotStore(slotName);
30
+ // Display conditions may refer to `session`, so they have to be re-evaluated when it changes.
31
+ const session = useStore(sessionStore, selectSession);
32
+ const stableState = useShallowStableValue(state);
33
+
34
+ return useMemo(
35
+ () => getAssignedExtensions(slotName, stableState),
36
+ [slotName, stableState, session, candidateExtensions],
37
+ );
11
38
  }
@@ -0,0 +1,15 @@
1
+ /** @module @category Extension */
2
+ import { useExtensionSlotStore } from './useExtensionSlotStore';
3
+
4
+ /**
5
+ * The reactive form of `getCandidateExtensions`: everything assigned to a slot, with no display
6
+ * condition evaluated. Intended for tools that present a slot's configuration rather than render it.
7
+ *
8
+ * Use {@link useAssignedExtensions} to decide what to display.
9
+ *
10
+ * @param slotName The name of the slot to get the candidate extensions for.
11
+ * @internal
12
+ */
13
+ export function useCandidateExtensions(slotName: string) {
14
+ return useExtensionSlotStore(slotName).candidateExtensions;
15
+ }
@@ -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
+ };
@@ -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
+ }
@@ -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 });