@openmrs/esm-react-utils 10.0.1-pre.5738 → 10.0.1-pre.5741

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.
@@ -1,3 +1,3 @@
1
- [0] Successfully compiled: 55 files with swc (167.05ms)
1
+ [0] Successfully compiled: 55 files with swc (207.75ms)
2
2
  [0] swc --strip-leading-paths src -d dist exited with code 0
3
3
  [1] tsc --project tsconfig.build.json exited with code 0
@@ -1 +1 @@
1
- {"version":3,"file":"useConfig.d.ts","sourceRoot":"","sources":["../src/useConfig.ts"],"names":[],"mappings":"AAqIA,MAAM,WAAW,gBAAgB;IAC/B;gGAC4F;IAC5F,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAqB1D,CAAC,CACnB"}
1
+ {"version":3,"file":"useConfig.d.ts","sourceRoot":"","sources":["../src/useConfig.ts"],"names":[],"mappings":"AAqJA,MAAM,WAAW,gBAAgB;IAC/B;gGAC4F;IAC5F,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,CAAC,EAAE,gBAAgB,GAqB1D,CAAC,CACnB"}
package/dist/useConfig.js CHANGED
@@ -26,26 +26,39 @@ function readInitialExtensionConfig(store, extension) {
26
26
  }
27
27
  return null;
28
28
  }
29
- function createConfigPromise(store) {
30
- return new Promise((resolve)=>{
29
+ /**
30
+ * The promise a suspended `useConfig()` throws, so every component waiting on the same config waits on
31
+ * one promise and a re-render throws the same one rather than starting again.
32
+ *
33
+ * The entry is dropped once it settles: an extension's config exists only while that extension is
34
+ * mounted, so the same id can have to wait a second time and a settled promise cannot describe that.
35
+ */ function whenConfigReady(cacheId, store, read) {
36
+ const cached = promises[cacheId];
37
+ if (cached) {
38
+ return cached;
39
+ }
40
+ const promise = new Promise((resolve)=>{
41
+ const current = read(store.getState());
42
+ if (current) {
43
+ resolve(current);
44
+ return;
45
+ }
31
46
  const unsubscribe = store.subscribe((state)=>{
32
- if (state.loaded && state.config) {
33
- resolve(state.config);
47
+ const config = read(state);
48
+ if (config) {
34
49
  unsubscribe();
50
+ resolve(config);
35
51
  }
36
52
  });
37
53
  });
38
- }
39
- function createExtensionConfigPromise(store, extension) {
40
- return new Promise((resolve)=>{
41
- const unsubscribe = store.subscribe((state)=>{
42
- const extConfig = getExtensionConfigFromStore(state, extension.extensionSlotName, extension.extensionId);
43
- if (extConfig.loaded && extConfig.config) {
44
- resolve(extConfig.config);
45
- unsubscribe();
46
- }
47
- });
54
+ promises[cacheId] = promise;
55
+ void promise.then(()=>{
56
+ // Guarded so that a later wait under the same id, started after this one settled, is left alone.
57
+ if (promises[cacheId] === promise) {
58
+ delete promises[cacheId];
59
+ }
48
60
  });
61
+ return promise;
49
62
  }
50
63
  function useConfigStore(store) {
51
64
  const [state, setState] = useState(readInitialConfig(store));
@@ -82,12 +95,11 @@ function useExtensionConfig(extension) {
82
95
  const store = useMemo(getExtensionsConfigStore, []);
83
96
  const state = useExtensionConfigStore(store, extension);
84
97
  if (!state && extension) {
85
- const cacheId = `${extension.extensionSlotName}-${extension.extensionId}`;
86
- if (!promises[cacheId] && store) {
87
- promises[cacheId] = createExtensionConfigPromise(store, extension);
88
- }
89
98
  // React will prevent the client component from rendering until the promise resolves
90
- throw promises[cacheId];
99
+ throw whenConfigReady(`${extension.extensionSlotName}-${extension.extensionId}`, store, (storeState)=>{
100
+ const extConfig = getExtensionConfigFromStore(storeState, extension.extensionSlotName, extension.extensionId);
101
+ return extConfig.loaded ? extConfig.config : null;
102
+ });
91
103
  }
92
104
  return state || {};
93
105
  }
@@ -95,14 +107,10 @@ function useNormalConfig(moduleName) {
95
107
  const store = useMemo(()=>getConfigStore(moduleName), [
96
108
  moduleName
97
109
  ]);
98
- const cacheId = moduleName;
99
110
  const state = useConfigStore(store);
100
111
  if (!state) {
101
- if (!promises[cacheId]) {
102
- promises[cacheId] = createConfigPromise(store);
103
- }
104
112
  // React will prevent the client component from rendering until the promise resolves
105
- throw promises[cacheId];
113
+ throw whenConfigReady(moduleName, store, (storeState)=>storeState.loaded ? storeState.config : null);
106
114
  }
107
115
  return state;
108
116
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-react-utils",
3
- "version": "10.0.1-pre.5738",
3
+ "version": "10.0.1-pre.5741",
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.5738",
64
- "@openmrs/esm-config": "^10.0.1-pre.5738",
65
- "@openmrs/esm-context": "^10.0.1-pre.5738",
66
- "@openmrs/esm-emr-api": "^10.0.1-pre.5738",
67
- "@openmrs/esm-error-handling": "^10.0.1-pre.5738",
68
- "@openmrs/esm-extensions": "^10.0.1-pre.5738",
69
- "@openmrs/esm-feature-flags": "^10.0.1-pre.5738",
70
- "@openmrs/esm-globals": "^10.0.1-pre.5738",
71
- "@openmrs/esm-navigation": "^10.0.1-pre.5738",
72
- "@openmrs/esm-state": "^10.0.1-pre.5738",
73
- "@openmrs/esm-utils": "^10.0.1-pre.5738",
63
+ "@openmrs/esm-api": "^10.0.1-pre.5741",
64
+ "@openmrs/esm-config": "^10.0.1-pre.5741",
65
+ "@openmrs/esm-context": "^10.0.1-pre.5741",
66
+ "@openmrs/esm-emr-api": "^10.0.1-pre.5741",
67
+ "@openmrs/esm-error-handling": "^10.0.1-pre.5741",
68
+ "@openmrs/esm-extensions": "^10.0.1-pre.5741",
69
+ "@openmrs/esm-feature-flags": "^10.0.1-pre.5741",
70
+ "@openmrs/esm-globals": "^10.0.1-pre.5741",
71
+ "@openmrs/esm-navigation": "^10.0.1-pre.5741",
72
+ "@openmrs/esm-state": "^10.0.1-pre.5741",
73
+ "@openmrs/esm-utils": "^10.0.1-pre.5741",
74
74
  "dayjs": "1.x",
75
75
  "i18next": "25.x",
76
76
  "react": "18.x",
@@ -79,17 +79,17 @@
79
79
  "swr": "2.x"
80
80
  },
81
81
  "devDependencies": {
82
- "@openmrs/esm-api": "10.0.1-pre.5738",
83
- "@openmrs/esm-config": "10.0.1-pre.5738",
84
- "@openmrs/esm-context": "10.0.1-pre.5738",
85
- "@openmrs/esm-emr-api": "10.0.1-pre.5738",
86
- "@openmrs/esm-error-handling": "10.0.1-pre.5738",
87
- "@openmrs/esm-extensions": "10.0.1-pre.5738",
88
- "@openmrs/esm-feature-flags": "10.0.1-pre.5738",
89
- "@openmrs/esm-globals": "10.0.1-pre.5738",
90
- "@openmrs/esm-navigation": "10.0.1-pre.5738",
91
- "@openmrs/esm-state": "10.0.1-pre.5738",
92
- "@openmrs/esm-utils": "10.0.1-pre.5738",
82
+ "@openmrs/esm-api": "10.0.1-pre.5741",
83
+ "@openmrs/esm-config": "10.0.1-pre.5741",
84
+ "@openmrs/esm-context": "10.0.1-pre.5741",
85
+ "@openmrs/esm-emr-api": "10.0.1-pre.5741",
86
+ "@openmrs/esm-error-handling": "10.0.1-pre.5741",
87
+ "@openmrs/esm-extensions": "10.0.1-pre.5741",
88
+ "@openmrs/esm-feature-flags": "10.0.1-pre.5741",
89
+ "@openmrs/esm-globals": "10.0.1-pre.5741",
90
+ "@openmrs/esm-navigation": "10.0.1-pre.5741",
91
+ "@openmrs/esm-state": "10.0.1-pre.5741",
92
+ "@openmrs/esm-utils": "10.0.1-pre.5741",
93
93
  "@swc/cli": "0.8.1",
94
94
  "@swc/core": "1.15.21",
95
95
  "@vitest/coverage-v8": "^4.1.2",
@@ -0,0 +1,226 @@
1
+ import React, { Suspense } from 'react';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { act, render, screen } from '@testing-library/react';
4
+ import {
5
+ configExtensionStore,
6
+ configInternalStore,
7
+ defineConfigSchema,
8
+ getConfigStore,
9
+ getExtensionsConfigStore,
10
+ } from '@openmrs/esm-config';
11
+ import { ComponentContext } from './ComponentContext';
12
+ import { useConfig } from './useConfig';
13
+
14
+ const slotName = 'test-slot';
15
+ const slotModuleName = 'slot-module';
16
+ const extensionId = 'thing#instance';
17
+ const extensionModuleName = 'ext-module';
18
+
19
+ /** How many times `ShowConfig` has started rendering, including attempts that suspended. */
20
+ let renders = 0;
21
+
22
+ function ShowConfig() {
23
+ renders++;
24
+ const config = useConfig<{ thing: string }>();
25
+ return <div>{config.thing}</div>;
26
+ }
27
+
28
+ function renderExtension() {
29
+ return render(
30
+ <Suspense fallback={<div>suspended</div>}>
31
+ <ComponentContext.Provider
32
+ value={{
33
+ moduleName: extensionModuleName,
34
+ featureName: 'ext-feature',
35
+ extension: { extensionSlotName: slotName, extensionSlotModuleName: slotModuleName, extensionId },
36
+ }}
37
+ >
38
+ <ShowConfig />
39
+ </ComponentContext.Provider>
40
+ </Suspense>,
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Lets a suspended component sit for a while and returns how many times it tried to render meanwhile.
46
+ * Re-throwing a promise that has already settled makes React retry at once, over and over, rather
47
+ * than wait, which is how a stale cached promise shows up.
48
+ */
49
+ async function rendersWhileWaiting() {
50
+ renders = 0;
51
+ await act(() => new Promise((resolve) => setTimeout(resolve, 50)));
52
+ return renders;
53
+ }
54
+
55
+ function renderModule(moduleName: string) {
56
+ return render(
57
+ <Suspense fallback={<div>suspended</div>}>
58
+ <ComponentContext.Provider value={{ moduleName, featureName: 'f' }}>
59
+ <ShowConfig />
60
+ </ComponentContext.Provider>
61
+ </Suspense>,
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Counts the subscriptions on `store` made from now on that are still live, so a leak shows up as a
67
+ * non-zero count. The config system's own subscriptions, made before this is called, are not counted.
68
+ */
69
+ function countSubscriptions(store: { subscribe: (listener: any) => () => void }) {
70
+ const realSubscribe = store.subscribe.bind(store);
71
+ const counter = { live: 0 };
72
+
73
+ store.subscribe = (listener) => {
74
+ counter.live++;
75
+ const unsubscribe = realSubscribe(listener);
76
+
77
+ return () => {
78
+ counter.live--;
79
+ unsubscribe();
80
+ };
81
+ };
82
+
83
+ return counter;
84
+ }
85
+
86
+ /** Registers the extension as mounted, which is what drives the config system to derive its config. */
87
+ function mountExtension() {
88
+ act(() => {
89
+ configExtensionStore.setState({
90
+ mountedExtensions: [{ slotName, slotModuleName, extensionId, extensionModuleName }],
91
+ });
92
+ });
93
+ }
94
+
95
+ /** Unregisters it, which drops its entry from the extensions config store. */
96
+ function unmountExtension() {
97
+ act(() => {
98
+ configExtensionStore.setState({ mountedExtensions: [] });
99
+ });
100
+ }
101
+
102
+ afterEach(() => {
103
+ configExtensionStore.setState({ mountedExtensions: [] });
104
+ configInternalStore.setState({ ...configInternalStore.getState(), providedConfigs: [] });
105
+ });
106
+
107
+ describe('useConfig suspense', () => {
108
+ it('renders an extension whose config arrives after the first render', async () => {
109
+ defineConfigSchema(extensionModuleName, { thing: { _default: 'the thing' } });
110
+
111
+ renderExtension();
112
+ expect(screen.getByText('suspended')).toBeInTheDocument();
113
+
114
+ mountExtension();
115
+
116
+ expect(await screen.findByText('the thing')).toBeInTheDocument();
117
+ });
118
+
119
+ it('renders an extension that mounts, unmounts and mounts again', async () => {
120
+ defineConfigSchema(extensionModuleName, { thing: { _default: 'the thing' } });
121
+
122
+ // First cycle: suspends, config arrives, renders.
123
+ const view = renderExtension();
124
+ mountExtension();
125
+ expect(await screen.findByText('the thing')).toBeInTheDocument();
126
+
127
+ // The extension goes away, taking its config store entry with it.
128
+ view.unmount();
129
+ unmountExtension();
130
+
131
+ // Second cycle: the same extension in the same slot waits under the same cache id as the first.
132
+ renderExtension();
133
+ expect(screen.getByText('suspended')).toBeInTheDocument();
134
+ expect(await rendersWhileWaiting()).toBeLessThanOrEqual(2);
135
+
136
+ mountExtension();
137
+
138
+ expect(await screen.findByText('the thing')).toBeInTheDocument();
139
+ });
140
+
141
+ it('renders a module whose config store loads while it is suspended', async () => {
142
+ const moduleName = 'preloaded-module';
143
+ defineConfigSchema(moduleName, { thing: { _default: 'preloaded thing' } });
144
+
145
+ const store = getConfigStore(moduleName);
146
+ const loaded = store.getState();
147
+
148
+ act(() => store.setState({ ...loaded, loaded: false, config: null }));
149
+
150
+ renderModule(moduleName);
151
+ expect(screen.getByText('suspended')).toBeInTheDocument();
152
+
153
+ act(() => store.setState(loaded));
154
+
155
+ expect(await screen.findByText('preloaded thing')).toBeInTheDocument();
156
+ });
157
+
158
+ it('renders a module whose config has already loaded without suspending', () => {
159
+ defineConfigSchema('loaded-module', { thing: { _default: 'already there' } });
160
+
161
+ renderModule('loaded-module');
162
+
163
+ expect(screen.getByText('already there')).toBeInTheDocument();
164
+ });
165
+
166
+ it('renders an extension whose config has already loaded without suspending', () => {
167
+ defineConfigSchema(extensionModuleName, { thing: { _default: 'the thing' } });
168
+ mountExtension();
169
+
170
+ renderExtension();
171
+
172
+ expect(screen.getByText('the thing')).toBeInTheDocument();
173
+ });
174
+
175
+ it('renders a module that suspends again after an earlier wait for it settled', async () => {
176
+ const moduleName = 'reloaded-module';
177
+ defineConfigSchema(moduleName, { thing: { _default: 'first' } });
178
+
179
+ const store = getConfigStore(moduleName);
180
+ const first = store.getState();
181
+
182
+ act(() => store.setState({ ...first, loaded: false, config: null }));
183
+ const view = renderModule(moduleName);
184
+ act(() => store.setState(first));
185
+ expect(await screen.findByText('first')).toBeInTheDocument();
186
+ view.unmount();
187
+
188
+ act(() => store.setState({ ...first, loaded: false, config: null }));
189
+ renderModule(moduleName);
190
+ expect(screen.getByText('suspended')).toBeInTheDocument();
191
+ expect(await rendersWhileWaiting()).toBeLessThanOrEqual(2);
192
+
193
+ act(() => store.setState({ ...first, config: { ...first.config, thing: 'second' } }));
194
+
195
+ expect(await screen.findByText('second')).toBeInTheDocument();
196
+ });
197
+
198
+ it('leaves no subscription on a module store once its wait settles and the component unmounts', async () => {
199
+ const moduleName = 'counted-module';
200
+ defineConfigSchema(moduleName, { thing: { _default: 'counted' } });
201
+
202
+ const store = getConfigStore(moduleName);
203
+ const loaded = store.getState();
204
+ act(() => store.setState({ ...loaded, loaded: false, config: null }));
205
+ const subscriptions = countSubscriptions(store);
206
+
207
+ const view = renderModule(moduleName);
208
+ act(() => store.setState(loaded));
209
+ expect(await screen.findByText('counted')).toBeInTheDocument();
210
+ view.unmount();
211
+
212
+ expect(subscriptions.live).toBe(0);
213
+ });
214
+
215
+ it('leaves no subscription on the extensions store once its wait settles and the component unmounts', async () => {
216
+ defineConfigSchema(extensionModuleName, { thing: { _default: 'the thing' } });
217
+ const subscriptions = countSubscriptions(getExtensionsConfigStore());
218
+
219
+ const view = renderExtension();
220
+ mountExtension();
221
+ expect(await screen.findByText('the thing')).toBeInTheDocument();
222
+ view.unmount();
223
+
224
+ expect(subscriptions.live).toBe(0);
225
+ });
226
+ });
package/src/useConfig.ts CHANGED
@@ -42,27 +42,51 @@ function readInitialExtensionConfig(store: StoreApi<ExtensionsConfigStore>, exte
42
42
  return null;
43
43
  }
44
44
 
45
- function createConfigPromise(store: StoreApi<ConfigStore>) {
46
- return new Promise<ConfigObject>((resolve) => {
45
+ /**
46
+ * The promise a suspended `useConfig()` throws, so every component waiting on the same config waits on
47
+ * one promise and a re-render throws the same one rather than starting again.
48
+ *
49
+ * The entry is dropped once it settles: an extension's config exists only while that extension is
50
+ * mounted, so the same id can have to wait a second time and a settled promise cannot describe that.
51
+ */
52
+ function whenConfigReady<S>(
53
+ cacheId: string,
54
+ store: StoreApi<S>,
55
+ read: (state: S) => ConfigObject | null | undefined,
56
+ ): Promise<ConfigObject> {
57
+ const cached = promises[cacheId];
58
+
59
+ if (cached) {
60
+ return cached;
61
+ }
62
+
63
+ const promise = new Promise<ConfigObject>((resolve) => {
64
+ const current = read(store.getState());
65
+
66
+ if (current) {
67
+ resolve(current);
68
+ return;
69
+ }
70
+
47
71
  const unsubscribe = store.subscribe((state) => {
48
- if (state.loaded && state.config) {
49
- resolve(state.config);
72
+ const config = read(state);
73
+
74
+ if (config) {
50
75
  unsubscribe();
76
+ resolve(config);
51
77
  }
52
78
  });
53
79
  });
54
- }
55
80
 
56
- function createExtensionConfigPromise(store: StoreApi<ExtensionsConfigStore>, extension: ExtensionData) {
57
- return new Promise<ConfigObject>((resolve) => {
58
- const unsubscribe = store.subscribe((state) => {
59
- const extConfig = getExtensionConfigFromStore(state, extension.extensionSlotName, extension.extensionId);
60
- if (extConfig.loaded && extConfig.config) {
61
- resolve(extConfig.config);
62
- unsubscribe();
63
- }
64
- });
81
+ promises[cacheId] = promise;
82
+ void promise.then(() => {
83
+ // Guarded so that a later wait under the same id, started after this one settled, is left alone.
84
+ if (promises[cacheId] === promise) {
85
+ delete promises[cacheId];
86
+ }
65
87
  });
88
+
89
+ return promise;
66
90
  }
67
91
 
68
92
  function useConfigStore(store: StoreApi<ConfigStore>) {
@@ -101,14 +125,11 @@ function useExtensionConfig(extension: ExtensionData | undefined) {
101
125
  const state = useExtensionConfigStore(store, extension);
102
126
 
103
127
  if (!state && extension) {
104
- const cacheId = `${extension.extensionSlotName}-${extension.extensionId}`;
105
-
106
- if (!promises[cacheId] && store) {
107
- promises[cacheId] = createExtensionConfigPromise(store, extension);
108
- }
109
-
110
128
  // React will prevent the client component from rendering until the promise resolves
111
- throw promises[cacheId];
129
+ throw whenConfigReady(`${extension.extensionSlotName}-${extension.extensionId}`, store, (storeState) => {
130
+ const extConfig = getExtensionConfigFromStore(storeState, extension.extensionSlotName, extension.extensionId);
131
+ return extConfig.loaded ? extConfig.config : null;
132
+ });
112
133
  }
113
134
 
114
135
  return state || {};
@@ -116,16 +137,11 @@ function useExtensionConfig(extension: ExtensionData | undefined) {
116
137
 
117
138
  function useNormalConfig(moduleName: string) {
118
139
  const store = useMemo(() => getConfigStore(moduleName), [moduleName]);
119
- const cacheId = moduleName;
120
140
  const state = useConfigStore(store);
121
141
 
122
142
  if (!state) {
123
- if (!promises[cacheId]) {
124
- promises[cacheId] = createConfigPromise(store);
125
- }
126
-
127
143
  // React will prevent the client component from rendering until the promise resolves
128
- throw promises[cacheId];
144
+ throw whenConfigReady(moduleName, store, (storeState) => (storeState.loaded ? storeState.config : null));
129
145
  }
130
146
 
131
147
  return state;
@@ -99,9 +99,7 @@ describe('isDesktop', () => {
99
99
  });
100
100
 
101
101
  it('identifies non-desktop layouts correctly', () => {
102
- const nonDesktopLayouts = LAYOUT_CASES.map(([, layout]) => layout).filter(
103
- (layout) => !isDesktop(layout),
104
- );
102
+ const nonDesktopLayouts = LAYOUT_CASES.map(([, layout]) => layout).filter((layout) => !isDesktop(layout));
105
103
  expect(nonDesktopLayouts).toEqual(['phone']);
106
104
  });
107
105
  });