@openmrs/esm-framework 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 (36) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/docs/functions/attach.md +1 -1
  3. package/docs/functions/createUseStore.md +1 -1
  4. package/docs/functions/defineConfigSchema.md +1 -1
  5. package/docs/functions/defineExtensionConfigSchema.md +1 -1
  6. package/docs/functions/detach.md +1 -1
  7. package/docs/functions/detachAll.md +1 -1
  8. package/docs/functions/getAssignedExtensions.md +17 -5
  9. package/docs/functions/getConfig.md +1 -1
  10. package/docs/functions/getExtensionNameFromId.md +1 -1
  11. package/docs/functions/getExtensionStore.md +1 -1
  12. package/docs/functions/provide.md +1 -1
  13. package/docs/functions/renderExtension.md +1 -1
  14. package/docs/functions/showModal.md +1 -1
  15. package/docs/functions/useAssignedExtensionIds.md +1 -2
  16. package/docs/functions/useAssignedExtensions.md +17 -2
  17. package/docs/functions/useExtensionSlotStore.md +1 -1
  18. package/docs/functions/useFhirPagination.md +1 -1
  19. package/docs/functions/useOpenmrsPagination.md +1 -1
  20. package/docs/functions/usePagination.md +5 -1
  21. package/docs/functions/useStore.md +4 -4
  22. package/docs/functions/useStoreWithActions.md +1 -1
  23. package/docs/interfaces/AssignedExtension.md +19 -9
  24. package/docs/interfaces/CancelLoading.md +2 -2
  25. package/docs/interfaces/ConnectedExtension.md +6 -6
  26. package/docs/interfaces/ExtensionMeta.md +1 -1
  27. package/docs/interfaces/ExtensionRegistration.md +11 -11
  28. package/docs/interfaces/ExtensionSlotState.md +8 -13
  29. package/docs/interfaces/ExtensionStore.md +2 -2
  30. package/docs/type-aliases/ExtensionSlotCustomState.md +11 -0
  31. package/package.json +20 -20
  32. package/src/integration-tests/extension-config-expressions.test.tsx +64 -2
  33. package/src/integration-tests/extension-config-propagation.test.tsx +185 -0
  34. package/src/integration-tests/extension-config.test.tsx +57 -2
  35. package/src/integration-tests/extension-recomputation.test.tsx +652 -0
  36. package/vitest.config.ts +22 -0
@@ -0,0 +1,652 @@
1
+ /*
2
+ * Rendering is wrapped in `act` because the extension system settles across several store updates,
3
+ * and the store resets in `beforeEach` require the previous render to be torn down first.
4
+ *
5
+ * `render-result-naming-convention` misreads this file's vocabulary: a `rendering` is one live copy
6
+ * of an extension, not a testing-library render result.
7
+ */
8
+ /* eslint-disable testing-library/no-unnecessary-act, testing-library/no-manual-cleanup, testing-library/render-result-naming-convention */
9
+ import React from 'react';
10
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import '@testing-library/jest-dom/vitest';
12
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
13
+ import { mockSessionStore } from '@openmrs/esm-api/mock';
14
+ import {
15
+ attach,
16
+ type ExtensionRegistration,
17
+ getAssignedExtensions,
18
+ getExtensionRenderingsStore,
19
+ getExtensionInternalStore,
20
+ getExtensionStore,
21
+ registerExtension,
22
+ renderExtension,
23
+ updateInternalExtensionStore,
24
+ } from '../../../esm-extensions/src';
25
+ import {
26
+ ExtensionSlot,
27
+ getSyncLifecycle,
28
+ openmrsComponentDecorator,
29
+ useAssignedExtensions,
30
+ } from '../../../esm-react-utils/src';
31
+ import {
32
+ type ConfigObject,
33
+ configExtensionStore,
34
+ configInternalStore,
35
+ defineConfigSchema,
36
+ defineExtensionConfigSchema,
37
+ getConfigStore,
38
+ getExtensionSlotsConfigStore,
39
+ getExtensionsConfigStore,
40
+ provide,
41
+ registerModuleLoad,
42
+ resetConfigSystem,
43
+ temporaryConfigStore,
44
+ } from '../../../esm-config/src';
45
+
46
+ vi.mock('@openmrs/esm-api', async () => {
47
+ const original = await import('@openmrs/esm-api');
48
+ return { ...original, sessionStore: mockSessionStore, refetchCurrentUser: vi.fn() };
49
+ });
50
+
51
+ const moduleName = 'esm-flintstone';
52
+
53
+ function registerSimpleExtension(name: string, extensionModuleName = moduleName) {
54
+ const registration: ExtensionRegistration = {
55
+ name,
56
+ moduleName: extensionModuleName,
57
+ load: getSyncLifecycle(() => <div>{name}</div>, { moduleName: extensionModuleName, featureName: name }),
58
+ meta: {},
59
+ online: true,
60
+ offline: true,
61
+ };
62
+ registerExtension(registration);
63
+ }
64
+
65
+ function decorate(Component: React.ComponentType) {
66
+ return openmrsComponentDecorator({ moduleName, featureName: 'The Flintstones', disableTranslations: true })(
67
+ Component,
68
+ );
69
+ }
70
+
71
+ describe('Extension system recomputation', () => {
72
+ beforeEach(() => {
73
+ // eslint-disable-next-line testing-library/no-render-in-lifecycle -- not a render; the rule matches any callee name containing "render"
74
+ getExtensionRenderingsStore().setState({ renderings: new Map() });
75
+ temporaryConfigStore.setState({ config: {} });
76
+ configInternalStore.setState({ providedConfigs: [], schemas: {}, moduleLoaded: {} });
77
+ mockSessionStore.setState({});
78
+ getExtensionSlotsConfigStore().setState({ slots: {} });
79
+ getExtensionsConfigStore().setState({ configs: {} });
80
+ getExtensionStore().setState({ slots: {} });
81
+ configExtensionStore.setState({ mountedExtensions: [] });
82
+ updateInternalExtensionStore(() => ({ slots: {}, extensions: {} }));
83
+ resetConfigSystem();
84
+ defineConfigSchema(moduleName, {});
85
+ registerModuleLoad(moduleName);
86
+ cleanup();
87
+ });
88
+
89
+ it('notifies subscribers when an extension is registered', () => {
90
+ const internalStore = getExtensionInternalStore();
91
+ let notifications = 0;
92
+ const unsubscribe = internalStore.subscribe(() => notifications++);
93
+
94
+ registerSimpleExtension('Fred');
95
+ registerSimpleExtension('Wilma');
96
+ unsubscribe();
97
+
98
+ // zustand skips notification when an updater returns the same object, so a registration has
99
+ // to produce a new state or nothing downstream learns about it.
100
+ expect(notifications).toBe(2);
101
+ expect(Object.keys(internalStore.getState().extensions)).toEqual(['Fred', 'Wilma']);
102
+ });
103
+
104
+ it('does not write to the extension store when a slot re-renders with an equal state object', async () => {
105
+ registerSimpleExtension('Fred');
106
+ attach('state-slot', 'Fred');
107
+
108
+ const internalStore = getExtensionInternalStore();
109
+ let setCount: (n: number) => void = () => {};
110
+
111
+ function Host() {
112
+ const [count, setCountState] = React.useState(0);
113
+ setCount = setCountState;
114
+ // A fresh object literal every render — overwhelmingly the most common call pattern.
115
+ return <ExtensionSlot name="state-slot" state={{ patientUuid: 'abc-123' }} data-count={count} />;
116
+ }
117
+
118
+ const App = decorate(Host);
119
+
120
+ await act(async () => {
121
+ render(<App />);
122
+ });
123
+
124
+ let writes = 0;
125
+ const unsubscribe = internalStore.subscribe(() => writes++);
126
+
127
+ await act(async () => {
128
+ setCount(1);
129
+ });
130
+ await act(async () => {
131
+ setCount(2);
132
+ });
133
+ unsubscribe();
134
+
135
+ expect(writes).toBe(0);
136
+ expect(internalStore.getState().slots['state-slot']).toBeDefined();
137
+ });
138
+
139
+ it('settles when two slots share a name and disagree about state', async () => {
140
+ registerSimpleExtension('Fred');
141
+ attach('shared-name-slot', 'Fred');
142
+
143
+ const internalStore = getExtensionInternalStore();
144
+ let bump: (n: number) => void = () => {};
145
+
146
+ function Host() {
147
+ const [count, setCount] = React.useState(0);
148
+ bump = setCount;
149
+ return (
150
+ <div data-count={count}>
151
+ <ExtensionSlot name="shared-name-slot" state={{ patientUuid: 'abc' }} />
152
+ <ExtensionSlot name="shared-name-slot" />
153
+ </div>
154
+ );
155
+ }
156
+
157
+ await act(async () => {
158
+ render(React.createElement(decorate(Host)));
159
+ });
160
+
161
+ let writes = 0;
162
+ const unsubscribe = internalStore.subscribe(() => writes++);
163
+
164
+ await act(async () => {
165
+ bump(1);
166
+ });
167
+ await act(async () => {
168
+ bump(2);
169
+ });
170
+ unsubscribe();
171
+
172
+ // Each slot writes only when its own state changes, so they stop overwriting each other
173
+ // rather than trading the slot's state back and forth on every render.
174
+ expect(writes).toBe(0);
175
+ });
176
+
177
+ it('keeps slot state out of the store while still letting it reach the extensions', async () => {
178
+ registerExtension({
179
+ name: 'OnlyWhenFlagged',
180
+ moduleName,
181
+ load: getSyncLifecycle(() => <div>OnlyWhenFlagged</div>, { moduleName, featureName: 'flagged' }),
182
+ meta: {},
183
+ online: true,
184
+ offline: true,
185
+ displayExpression: 'flagged',
186
+ });
187
+ attach('changing-state-slot', 'OnlyWhenFlagged');
188
+
189
+ const internalStore = getExtensionInternalStore();
190
+ let setFlagged: (flagged: boolean) => void = () => {};
191
+
192
+ function Host() {
193
+ const [flagged, setFlaggedState] = React.useState(false);
194
+ setFlagged = setFlaggedState;
195
+ return <ExtensionSlot name="changing-state-slot" state={{ flagged }} />;
196
+ }
197
+
198
+ await act(async () => {
199
+ render(React.createElement(decorate(Host)));
200
+ });
201
+
202
+ expect(screen.queryByText('OnlyWhenFlagged')).not.toBeInTheDocument();
203
+
204
+ let writes = 0;
205
+ const unsubscribe = internalStore.subscribe(() => writes++);
206
+
207
+ await act(async () => {
208
+ setFlagged(true);
209
+ });
210
+ unsubscribe();
211
+
212
+ // The state belongs to this rendering of the slot, not to the slot, so changing it re-resolves
213
+ // the display condition without touching the store every other rendering shares.
214
+ expect(await screen.findByText('OnlyWhenFlagged')).toBeInTheDocument();
215
+ expect(writes).toBe(0);
216
+ });
217
+
218
+ it('does not re-render a slot consumer when an unrelated slot changes', async () => {
219
+ registerSimpleExtension('Fred');
220
+ attach('watched-slot', 'Fred');
221
+
222
+ let watcherPasses = 0;
223
+
224
+ function Watcher() {
225
+ watcherPasses++;
226
+ const extensions = useAssignedExtensions('watched-slot');
227
+ return <span>{extensions.length}</span>;
228
+ }
229
+
230
+ await act(async () => {
231
+ render(React.createElement(decorate(Watcher)));
232
+ });
233
+
234
+ const countAfterMount = watcherPasses;
235
+
236
+ await act(async () => {
237
+ registerSimpleExtension('Barney');
238
+ attach('unrelated-slot', 'Barney');
239
+ });
240
+
241
+ expect(watcherPasses).toBe(countAfterMount);
242
+
243
+ // The watched slot itself changing must still propagate.
244
+ await act(async () => {
245
+ registerSimpleExtension('Wilma');
246
+ attach('watched-slot', 'Wilma');
247
+ });
248
+
249
+ expect(watcherPasses).toBeGreaterThan(countAfterMount);
250
+ });
251
+
252
+ it('preserves the identity of slots whose assigned extensions did not change', async () => {
253
+ registerSimpleExtension('Fred');
254
+ attach('stable-slot', 'Fred');
255
+
256
+ await act(async () => {
257
+ render(React.createElement(decorate(() => <ExtensionSlot name="stable-slot" />)));
258
+ });
259
+
260
+ const extensionStore = getExtensionStore();
261
+ const before = extensionStore.getState().slots['stable-slot'];
262
+
263
+ await act(async () => {
264
+ registerSimpleExtension('Barney');
265
+ attach('another-slot', 'Barney');
266
+ });
267
+
268
+ expect(extensionStore.getState().slots['stable-slot']).toBe(before);
269
+ });
270
+
271
+ it('releases an extension rendering when it unmounts', async () => {
272
+ registerSimpleExtension('Fred');
273
+ attach('mounting-slot', 'Fred');
274
+
275
+ const renderingsStore = getExtensionRenderingsStore();
276
+ const App = decorate(() => <ExtensionSlot name="mounting-slot" />);
277
+
278
+ for (let i = 0; i < 3; i++) {
279
+ const { unmount } = render(<App />);
280
+ await waitFor(() => expect(renderingsStore.getState().renderings.size).toBe(1));
281
+
282
+ unmount();
283
+
284
+ // A rendering record must not outlive its parcel.
285
+ await waitFor(() => expect(renderingsStore.getState().renderings.size).toBe(0));
286
+ expect(configExtensionStore.getState().mountedExtensions).toEqual([]);
287
+ }
288
+ });
289
+
290
+ it('renders an extension whose parcel resolves after the StrictMode remount', async () => {
291
+ registerSimpleExtension('Fred');
292
+ attach('strict-slot', 'Fred');
293
+
294
+ const App = decorate(() => <ExtensionSlot data-testid="strict-slot" name="strict-slot" />);
295
+
296
+ await act(async () => {
297
+ render(<App />);
298
+ });
299
+
300
+ // StrictMode mounts, tears down and mounts again before the parcel resolves. Teardown state
301
+ // has to be per-mount, or the live component's parcel is unmounted as soon as it arrives.
302
+ await waitFor(() => expect(screen.getByTestId('strict-slot')).toHaveTextContent('Fred'));
303
+ expect(getExtensionRenderingsStore().getState().renderings.size).toBe(1);
304
+ });
305
+
306
+ it('releases an extension rendering when the slot unmounts before the bundle loads', async () => {
307
+ let releaseLoad: () => void = () => {};
308
+ const loadGate = new Promise<void>((resolve) => {
309
+ releaseLoad = resolve;
310
+ });
311
+
312
+ registerExtension({
313
+ name: 'Slow',
314
+ moduleName,
315
+ meta: {},
316
+ online: true,
317
+ offline: true,
318
+ load: async () => {
319
+ await loadGate;
320
+ return { bootstrap: async () => {}, mount: async () => {}, unmount: async () => {} };
321
+ },
322
+ });
323
+ attach('slow-slot', 'Slow');
324
+
325
+ const renderingsStore = getExtensionRenderingsStore();
326
+ const App = decorate(() => <ExtensionSlot name="slow-slot" />);
327
+ const { unmount } = render(<App />);
328
+
329
+ // Registered before the load starts, so this settles while the bundle is still gated.
330
+ await waitFor(() => expect(renderingsStore.getState().renderings.size).toBe(1));
331
+
332
+ unmount();
333
+ await act(async () => {
334
+ releaseLoad();
335
+ });
336
+
337
+ // Navigating away while a lazy bundle is still downloading is routine; the record has to be
338
+ // released even though the component never saw a parcel.
339
+ await waitFor(() => expect(renderingsStore.getState().renderings.size).toBe(0));
340
+ expect(configExtensionStore.getState().mountedExtensions).toEqual([]);
341
+ });
342
+
343
+ it('applies a state change that arrived while the extension bundle was still loading', async () => {
344
+ let releaseLoad: () => void = () => {};
345
+ const loadGate = new Promise<void>((resolve) => {
346
+ releaseLoad = resolve;
347
+ });
348
+ const labelsSeen: Array<string> = [];
349
+ let container: HTMLElement | null = null;
350
+
351
+ registerExtension({
352
+ name: 'Gated',
353
+ moduleName,
354
+ meta: {},
355
+ online: true,
356
+ offline: true,
357
+ load: async () => {
358
+ await loadGate;
359
+ return {
360
+ bootstrap: async () => {},
361
+ // `domElement` is captured here rather than read from the update props, which carry only
362
+ // what the update was called with.
363
+ mount: async (props) => {
364
+ container = props.domElement as HTMLElement;
365
+ container.textContent = props.label as string;
366
+ labelsSeen.push(props.label as string);
367
+ },
368
+ update: async (props) => {
369
+ labelsSeen.push(props.label as string);
370
+
371
+ if (container) {
372
+ container.textContent = props.label as string;
373
+ }
374
+ },
375
+ unmount: async () => {
376
+ container = null;
377
+ },
378
+ };
379
+ },
380
+ });
381
+ attach('gated-slot', 'Gated');
382
+
383
+ let setLabel: (label: string) => void = () => {};
384
+
385
+ function Host() {
386
+ const [label, setLabelState] = React.useState('before');
387
+ setLabel = setLabelState;
388
+ return <ExtensionSlot name="gated-slot" state={{ label }} />;
389
+ }
390
+
391
+ await act(async () => {
392
+ render(React.createElement(decorate(Host)));
393
+ });
394
+
395
+ // The row's state moves on while the bundle is still in flight, so there is no parcel for the
396
+ // update to reach and no later render to retry it.
397
+ await act(async () => {
398
+ setLabel('after');
399
+ });
400
+ await act(async () => {
401
+ releaseLoad();
402
+ });
403
+
404
+ // Mounting with the state captured when the node was attached is fine; being left there is not.
405
+ expect(await screen.findByText('after')).toBeInTheDocument();
406
+ expect(labelsSeen).toEqual(['before', 'after']);
407
+ });
408
+
409
+ it('releases an extension rendering when the extension bundle fails to load', async () => {
410
+ registerExtension({
411
+ name: 'Unloadable',
412
+ moduleName,
413
+ meta: {},
414
+ online: true,
415
+ offline: true,
416
+ load: () => Promise.reject(new Error('chunk load failed')),
417
+ });
418
+ attach('unloadable-slot', 'Unloadable');
419
+
420
+ const renderingsStore = getExtensionRenderingsStore();
421
+
422
+ // Driven directly rather than through <ExtensionSlot>: `renderExtension` rethrows and
423
+ // `Extension` has no rejection handler, so a failed load reaches the app's global handler.
424
+ await expect(
425
+ renderExtension(document.createElement('div'), 'unloadable-slot', moduleName, 'Unloadable'),
426
+ ).rejects.toThrow('chunk load failed');
427
+
428
+ // The record is registered before the load starts, so the failure has to release it.
429
+ expect(renderingsStore.getState().renderings.size).toBe(0);
430
+ expect(configExtensionStore.getState().mountedExtensions).toEqual([]);
431
+ });
432
+
433
+ it('releases an extension rendering when the slot unmounts while the parcel is bootstrapping', async () => {
434
+ let releaseBootstrap: () => void = () => {};
435
+ const bootstrapGate = new Promise<void>((resolve) => {
436
+ releaseBootstrap = resolve;
437
+ });
438
+
439
+ registerExtension({
440
+ name: 'SlowBootstrap',
441
+ moduleName,
442
+ meta: {},
443
+ online: true,
444
+ offline: true,
445
+ load: async () => ({
446
+ bootstrap: async () => {
447
+ await bootstrapGate;
448
+ },
449
+ mount: async () => {},
450
+ unmount: async () => {},
451
+ }),
452
+ });
453
+ attach('bootstrapping-slot', 'SlowBootstrap');
454
+
455
+ const renderingsStore = getExtensionRenderingsStore();
456
+ const App = decorate(() => <ExtensionSlot name="bootstrapping-slot" />);
457
+ const { unmount } = render(<App />);
458
+
459
+ await waitFor(() => expect(renderingsStore.getState().renderings.size).toBe(1));
460
+
461
+ // The parcel exists but hasn't reached MOUNTED, so teardown has to wait for the mount to
462
+ // settle rather than assuming a status it can unmount from.
463
+ unmount();
464
+ await act(async () => {
465
+ releaseBootstrap();
466
+ });
467
+
468
+ await waitFor(() => expect(renderingsStore.getState().renderings.size).toBe(0));
469
+ expect(configExtensionStore.getState().mountedExtensions).toEqual([]);
470
+ });
471
+
472
+ it('releases an extension rendering when the extension fails to mount', async () => {
473
+ registerExtension({
474
+ name: 'Broken',
475
+ moduleName,
476
+ meta: {},
477
+ online: true,
478
+ offline: true,
479
+ load: async () => ({
480
+ bootstrap: async () => {},
481
+ mount: async () => {
482
+ throw new Error('mount failed');
483
+ },
484
+ unmount: async () => {},
485
+ }),
486
+ });
487
+ attach('broken-slot', 'Broken');
488
+
489
+ const renderingsStore = getExtensionRenderingsStore();
490
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
491
+ const App = decorate(() => <ExtensionSlot name="broken-slot" />);
492
+
493
+ await act(async () => {
494
+ render(<App />);
495
+ });
496
+
497
+ // The record is registered and released within the same render, so there is no moment to
498
+ // observe it held. Waiting on the failure itself is what makes the assertion below meaningful
499
+ // rather than a reading of the empty state this started in.
500
+ await waitFor(() =>
501
+ expect(consoleError).toHaveBeenCalledWith(
502
+ expect.stringContaining("Extension 'Broken' in slot 'broken-slot' failed to mount"),
503
+ expect.anything(),
504
+ ),
505
+ );
506
+
507
+ // A parcel that never mounts never settles `unmountPromise`, so the mount rejection is the
508
+ // only thing that can release the record.
509
+ expect(renderingsStore.getState().renderings.size).toBe(0);
510
+ expect(configExtensionStore.getState().mountedExtensions).toEqual([]);
511
+ consoleError.mockRestore();
512
+ });
513
+
514
+ it('does not rewrite a module config store when nothing about the config changed', () => {
515
+ const moduleStore = getConfigStore(moduleName);
516
+ let writes = 0;
517
+ const unsubscribe = moduleStore.subscribe(() => writes++);
518
+
519
+ // Poking an input store recomputes every module's config, so an unchanged config must not
520
+ // reach the store.
521
+
522
+ for (let i = 0; i < 10; i++) {
523
+ configExtensionStore.setState({ mountedExtensions: [...configExtensionStore.getState().mountedExtensions] });
524
+ }
525
+ unsubscribe();
526
+
527
+ expect(writes).toBe(0);
528
+ });
529
+
530
+ it('does not mutate the config objects it reads while deriving assigned extensions', () => {
531
+ registerSimpleExtension('widget');
532
+ attach('merge-slot', 'widget');
533
+
534
+ // Deriving only merges when the slot configures the extension, so without this the merge
535
+ // branch is never taken and the assertions below hold vacuously.
536
+ provide({
537
+ [moduleName]: {
538
+ extensionSlots: { 'merge-slot': { configure: { widget: { fromSlotConfig: true } } } },
539
+ },
540
+ });
541
+
542
+ const storedConfig = { untouched: true };
543
+ getExtensionsConfigStore().setState({
544
+ configs: {
545
+ 'merge-slot': {
546
+ widget: { loaded: true, translationOverridesLoaded: true, config: storedConfig as ConfigObject },
547
+ },
548
+ },
549
+ });
550
+
551
+ // The slot's `configure` block still reaches the extension...
552
+ expect(getAssignedExtensions('merge-slot')[0]?.config).toEqual({
553
+ untouched: true,
554
+ fromSlotConfig: true,
555
+ });
556
+
557
+ // ...but as a fresh object. The config store owns `storedConfig`, and deriving must not
558
+ // write the slot's overrides into it.
559
+ expect(storedConfig).toEqual({ untouched: true });
560
+ expect(getExtensionsConfigStore().getState().configs['merge-slot']?.['widget']?.config).toEqual({
561
+ untouched: true,
562
+ });
563
+ });
564
+
565
+ it("does not remount other rows' extensions when a row with different state arrives", async () => {
566
+ registerSimpleExtension('always');
567
+ registerExtension({
568
+ name: 'conditional',
569
+ moduleName,
570
+ load: getSyncLifecycle(() => <div>conditional</div>, { moduleName, featureName: 'conditional' }),
571
+ meta: {},
572
+ online: true,
573
+ offline: true,
574
+ displayExpression: 'patient.flagged',
575
+ });
576
+ attach('rows-slot', 'always');
577
+ attach('rows-slot', 'conditional');
578
+
579
+ let setRows: (n: number) => void = () => {};
580
+
581
+ function List() {
582
+ const [rows, setRowsState] = React.useState(1);
583
+ setRows = setRowsState;
584
+ return (
585
+ <div>
586
+ {Array.from({ length: rows }, (_, i) => (
587
+ <ExtensionSlot key={i} name="rows-slot" state={{ patient: { flagged: i % 2 === 0 } }} />
588
+ ))}
589
+ </div>
590
+ );
591
+ }
592
+
593
+ await act(async () => {
594
+ render(React.createElement(decorate(List)));
595
+ });
596
+
597
+ const everMounted = new Set<string>();
598
+ const unsubscribe = getExtensionRenderingsStore().subscribe((state) => {
599
+ for (const renderingId of state.renderings.keys()) {
600
+ everMounted.add(renderingId);
601
+ }
602
+ });
603
+
604
+ // Rows scroll in one at a time, alternating whether their patient satisfies the condition.
605
+ for (let rows = 2; rows <= 12; rows++) {
606
+ await act(async () => {
607
+ setRows(rows);
608
+ });
609
+ }
610
+ unsubscribe();
611
+
612
+ const live = getExtensionRenderingsStore().getState().renderings.size;
613
+
614
+ // Resolving the condition per rendering rather than per slot is what makes this hold: when it
615
+ // was resolved once for the whole slot, each arriving row flipped the answer for every row
616
+ // already on screen, mounting and unmounting their parcels again each time.
617
+ expect(everMounted.size).toBe(live);
618
+ expect(live).toBe(12 + 6);
619
+ });
620
+
621
+ it('derives an extension config once however many rows render the same slot', async () => {
622
+ defineExtensionConfigSchema('widget', { greeting: { _default: 'yabba' } });
623
+ provide({ widget: { greeting: 'dabba' } });
624
+ registerSimpleExtension('widget');
625
+ attach('row-slot', 'widget');
626
+
627
+ const rows = 25;
628
+ const App = decorate(() => (
629
+ <div>
630
+ {Array.from({ length: rows }, (_, i) => (
631
+ <ExtensionSlot key={i} name="row-slot" />
632
+ ))}
633
+ </div>
634
+ ));
635
+
636
+ let extensionConfigWrites = 0;
637
+ const unsubscribe = getExtensionsConfigStore().subscribe(() => extensionConfigWrites++);
638
+
639
+ await act(async () => {
640
+ render(<App />);
641
+ });
642
+ unsubscribe();
643
+
644
+ expect(await screen.findAllByText('widget')).toHaveLength(rows);
645
+ // A list renders the same slot once per row, but the config system derives one config per
646
+ // slot and extension, so the work must not grow with the number of rows on screen.
647
+ expect(getExtensionsConfigStore().getState().configs['row-slot']).toEqual({
648
+ widget: { loaded: true, config: expect.objectContaining({ greeting: 'dabba' }) },
649
+ });
650
+ expect(extensionConfigWrites).toBe(1);
651
+ });
652
+ });
package/vitest.config.ts CHANGED
@@ -1,6 +1,28 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
1
4
  import { defineConfig } from 'vitest/config';
2
5
 
6
+ const frameworkRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
7
+
8
+ /*
9
+ * These tests exercise the framework end to end, so every package involved has to be the *same*
10
+ * copy the components under test import. Without this, a test importing `../../../esm-extensions/src`
11
+ * renders components that resolve `@openmrs/esm-extensions` through the workspace symlink to
12
+ * `dist/` — two module instances, each with its own recomputation state, caches and subscriptions,
13
+ * so a regression in `src` is masked by the previously built `dist`.
14
+ *
15
+ * Bare specifiers only: subpaths like `@openmrs/esm-api/mock` keep their own resolution.
16
+ */
17
+ const frameworkSourceAliases = readdirSync(frameworkRoot, { withFileTypes: true })
18
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith('esm-'))
19
+ .map((entry) => ({
20
+ find: new RegExp(`^@openmrs/${entry.name}$`),
21
+ replacement: resolve(frameworkRoot, entry.name, 'src/index.ts'),
22
+ }));
23
+
3
24
  export default defineConfig({
25
+ resolve: { alias: frameworkSourceAliases },
4
26
  test: {
5
27
  environment: 'happy-dom',
6
28
  mockReset: true,