@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
@@ -3,17 +3,28 @@ const defaultResultsPerPage = 10;
3
3
  /**
4
4
  * Use this hook to paginate data that already exists on the client side.
5
5
  * Note that if the data is obtained from server-side, the caller must handle server-side pagination manually.
6
+ *
7
+ * `goTo`, `goToNext` and `goToPrevious` keep the same identity for the life of the component, so they are
8
+ * safe to list in a dependency array. `currentPage` is bounded by the data, so it can change on its own
9
+ * when the data shrinks; a component reacting to `currentPage` should expect that.
10
+ *
6
11
  * @see `useServerPagination` for hook that automatically manages server-side pagination.
7
12
  * @see `useServerInfinite` for hook to get all data loaded onto the client-side
8
13
  * @param data
9
14
  * @param resultsPerPage
10
15
  * @returns
11
16
  */ export function usePagination(data = [], resultsPerPage = defaultResultsPerPage) {
12
- const [page, setPage] = useState(1);
17
+ const [requestedPage, setRequestedPage] = useState(1);
13
18
  const totalPages = useMemo(()=>typeof resultsPerPage === 'number' && resultsPerPage > 0 ? Math.max(1, Math.ceil(data.length / resultsPerPage)) : 1, [
14
19
  data.length,
15
20
  resultsPerPage
16
21
  ]);
22
+ // If requested page is past the end, set to the end. Done here to retain referential stability
23
+ // on the functions returned by this hook
24
+ if (requestedPage > totalPages) {
25
+ setRequestedPage(totalPages);
26
+ }
27
+ const page = Math.min(Math.max(1, requestedPage), totalPages);
17
28
  const results = useMemo(()=>{
18
29
  const lowerBound = (page - 1) * resultsPerPage;
19
30
  const upperBound = (page + 0) * resultsPerPage;
@@ -24,28 +35,19 @@ const defaultResultsPerPage = 10;
24
35
  resultsPerPage
25
36
  ]);
26
37
  const goTo = useCallback((page)=>{
27
- setPage(Math.max(1, Math.min(totalPages, page)));
28
- }, [
29
- setPage,
30
- totalPages
31
- ]);
32
- const goToNext = useCallback(()=>{
33
- if (page < totalPages) {
34
- setPage(page + 1);
38
+ if (!Number.isInteger(page) || page < 1) {
39
+ console.warn(`usePagination: ignoring goTo(${page}); page must be a positive integer.`);
40
+ return;
35
41
  }
36
- }, [
37
- page,
38
- totalPages,
39
- setPage
40
- ]);
42
+ setRequestedPage(page);
43
+ }, []);
44
+ // overflow clamping happens in render; see the setRequestedPage() call above
45
+ const goToNext = useCallback(()=>{
46
+ setRequestedPage((p)=>p + 1);
47
+ }, []);
41
48
  const goToPrevious = useCallback(()=>{
42
- if (page > 1) {
43
- setPage(page - 1);
44
- }
45
- }, [
46
- page,
47
- setPage
48
- ]);
49
+ setRequestedPage((p)=>Math.max(1, p - 1));
50
+ }, []);
49
51
  const memoisedPaginatedData = useMemo(()=>({
50
52
  results,
51
53
  totalPages,
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Returns `value` with a reference that only changes when its contents do, so that it can be used
3
+ * as a dependency of `useMemo`, `useEffect` and friends.
4
+ *
5
+ * This exists for props that are almost always written as object literals — `state={{ patientUuid }}`
6
+ * — which get a new identity on every render while meaning the same thing. Comparison is shallow,
7
+ * so a value nested more than one level deep still reads as a change.
8
+ *
9
+ * @param value The value to stabilize
10
+ * @returns `value`, or the last value this hook returned if the two are shallowly equal
11
+ */
12
+ export declare function useShallowStableValue<T>(value: T): T;
13
+ //# sourceMappingURL=useShallowStableValue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useShallowStableValue.d.ts","sourceRoot":"","sources":["../src/useShallowStableValue.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;GAUG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,GAAG,CAAC,CAQpD"}
@@ -0,0 +1,19 @@
1
+ /** @module @category Utility */ import { useRef } from "react";
2
+ import { shallowEqual } from "@openmrs/esm-utils";
3
+ /**
4
+ * Returns `value` with a reference that only changes when its contents do, so that it can be used
5
+ * as a dependency of `useMemo`, `useEffect` and friends.
6
+ *
7
+ * This exists for props that are almost always written as object literals — `state={{ patientUuid }}`
8
+ * — which get a new identity on every render while meaning the same thing. Comparison is shallow,
9
+ * so a value nested more than one level deep still reads as a change.
10
+ *
11
+ * @param value The value to stabilize
12
+ * @returns `value`, or the last value this hook returned if the two are shallowly equal
13
+ */ export function useShallowStableValue(value) {
14
+ const stable = useRef(value);
15
+ if (!shallowEqual(stable.current, value)) {
16
+ stable.current = value;
17
+ }
18
+ return stable.current;
19
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"useStore.d.ts","sourceRoot":"","sources":["../src/useStore.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"useStore.d.ts","sourceRoot":"","sources":["../src/useStore.ts"],"names":[],"mappings":"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"}
package/dist/useStore.js CHANGED
@@ -14,7 +14,12 @@ function bindActions(store, actions) {
14
14
  }
15
15
  return bound;
16
16
  }
17
- const defaultSelectFunction = ()=>(x)=>x;
17
+ /**
18
+ * A single shared identity function, rather than one built per call: `getSnapshot` below takes
19
+ * the selector as a dependency, so a fresh default each render would leave it unstable for every
20
+ * caller that doesn't pass a selector of its own.
21
+ */ const identitySelect = (x)=>x;
22
+ const defaultSelectFunction = ()=>identitySelect;
18
23
  function useStore(store, select = defaultSelectFunction(), actions) {
19
24
  // Use useSyncExternalStore to subscribe synchronously during render
20
25
  // This ensures React can properly track all state updates
@@ -28,14 +33,20 @@ function useStore(store, select = defaultSelectFunction(), actions) {
28
33
  select
29
34
  ]);
30
35
  const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
31
- let boundActions = useMemo(()=>actions ? bindActions(store, actions) : {}, [
36
+ let boundActions = useMemo(()=>actions ? bindActions(store, actions) : null, [
32
37
  store,
33
38
  actions
34
39
  ]);
35
- return {
36
- ...state,
37
- ...boundActions
38
- };
40
+ // Returned as-is when there are no actions to merge in, so the reference stays stable for as
41
+ // long as the selected value does. That value is not a copy — it is whatever the selector
42
+ // returned, usually the store's own state.
43
+ return useMemo(()=>boundActions ? {
44
+ ...state,
45
+ ...boundActions
46
+ } : state, [
47
+ state,
48
+ boundActions
49
+ ]);
39
50
  }
40
51
  /**
41
52
  *
@@ -53,13 +64,16 @@ function useStore(store, select = defaultSelectFunction(), actions) {
53
64
  const subscribe = useCallback((callback)=>store.subscribe(callback), []);
54
65
  const getSnapshot = useCallback(()=>store.getState(), []);
55
66
  const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
56
- let boundActions = useMemo(()=>actions ? bindActions(store, actions) : {}, [
67
+ let boundActions = useMemo(()=>actions ? bindActions(store, actions) : null, [
57
68
  actions
58
69
  ]);
59
- return {
60
- ...state,
61
- ...boundActions
62
- };
70
+ return useMemo(()=>boundActions ? {
71
+ ...state,
72
+ ...boundActions
73
+ } : state, [
74
+ state,
75
+ boundActions
76
+ ]);
63
77
  }
64
78
  return useStore;
65
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-react-utils",
3
- "version": "10.0.1-pre.5263",
3
+ "version": "10.0.1-pre.5299",
4
4
  "license": "MPL-2.0",
5
5
  "description": "React utilities for OpenMRS.",
6
6
  "type": "module",
@@ -60,17 +60,17 @@
60
60
  "single-spa-react": "^6.0.2"
61
61
  },
62
62
  "peerDependencies": {
63
- "@openmrs/esm-api": "^10.0.1-pre.5263",
64
- "@openmrs/esm-config": "^10.0.1-pre.5263",
65
- "@openmrs/esm-context": "^10.0.1-pre.5263",
66
- "@openmrs/esm-emr-api": "^10.0.1-pre.5263",
67
- "@openmrs/esm-error-handling": "^10.0.1-pre.5263",
68
- "@openmrs/esm-extensions": "^10.0.1-pre.5263",
69
- "@openmrs/esm-feature-flags": "^10.0.1-pre.5263",
70
- "@openmrs/esm-globals": "^10.0.1-pre.5263",
71
- "@openmrs/esm-navigation": "^10.0.1-pre.5263",
72
- "@openmrs/esm-state": "^10.0.1-pre.5263",
73
- "@openmrs/esm-utils": "^10.0.1-pre.5263",
63
+ "@openmrs/esm-api": "^10.0.1-pre.5299",
64
+ "@openmrs/esm-config": "^10.0.1-pre.5299",
65
+ "@openmrs/esm-context": "^10.0.1-pre.5299",
66
+ "@openmrs/esm-emr-api": "^10.0.1-pre.5299",
67
+ "@openmrs/esm-error-handling": "^10.0.1-pre.5299",
68
+ "@openmrs/esm-extensions": "^10.0.1-pre.5299",
69
+ "@openmrs/esm-feature-flags": "^10.0.1-pre.5299",
70
+ "@openmrs/esm-globals": "^10.0.1-pre.5299",
71
+ "@openmrs/esm-navigation": "^10.0.1-pre.5299",
72
+ "@openmrs/esm-state": "^10.0.1-pre.5299",
73
+ "@openmrs/esm-utils": "^10.0.1-pre.5299",
74
74
  "dayjs": "1.x",
75
75
  "i18next": "25.x",
76
76
  "react": "18.x",
@@ -80,17 +80,17 @@
80
80
  "swr": "2.x"
81
81
  },
82
82
  "devDependencies": {
83
- "@openmrs/esm-api": "10.0.1-pre.5263",
84
- "@openmrs/esm-config": "10.0.1-pre.5263",
85
- "@openmrs/esm-context": "10.0.1-pre.5263",
86
- "@openmrs/esm-emr-api": "10.0.1-pre.5263",
87
- "@openmrs/esm-error-handling": "10.0.1-pre.5263",
88
- "@openmrs/esm-extensions": "10.0.1-pre.5263",
89
- "@openmrs/esm-feature-flags": "10.0.1-pre.5263",
90
- "@openmrs/esm-globals": "10.0.1-pre.5263",
91
- "@openmrs/esm-navigation": "10.0.1-pre.5263",
92
- "@openmrs/esm-state": "10.0.1-pre.5263",
93
- "@openmrs/esm-utils": "10.0.1-pre.5263",
83
+ "@openmrs/esm-api": "10.0.1-pre.5299",
84
+ "@openmrs/esm-config": "10.0.1-pre.5299",
85
+ "@openmrs/esm-context": "10.0.1-pre.5299",
86
+ "@openmrs/esm-emr-api": "10.0.1-pre.5299",
87
+ "@openmrs/esm-error-handling": "10.0.1-pre.5299",
88
+ "@openmrs/esm-extensions": "10.0.1-pre.5299",
89
+ "@openmrs/esm-feature-flags": "10.0.1-pre.5299",
90
+ "@openmrs/esm-globals": "10.0.1-pre.5299",
91
+ "@openmrs/esm-navigation": "10.0.1-pre.5299",
92
+ "@openmrs/esm-state": "10.0.1-pre.5299",
93
+ "@openmrs/esm-utils": "10.0.1-pre.5299",
94
94
  "@swc/cli": "0.8.1",
95
95
  "@swc/core": "1.15.21",
96
96
  "@vitest/coverage-v8": "^4.1.2",
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
+ }