@openmrs/esm-extensions 10.0.1-pre.5255 → 10.0.1-pre.5263

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: 11 files with swc (183.17ms)
1
+ [0] Successfully compiled: 11 files with swc (123.7ms)
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
package/dist/render.d.ts CHANGED
@@ -1,12 +1,35 @@
1
1
  /** @module @category Extension */
2
- import { type Parcel, type ParcelConfig } from 'single-spa';
2
+ import { type AppProps, type CustomProps, type Parcel, type ParcelConfig, type ParcelProps } from 'single-spa';
3
3
  export interface CancelLoading {
4
4
  (): void;
5
5
  }
6
+ type MountParcel = AppProps['mountParcel'];
7
+ /**
8
+ * Mounts a parcel through the framework's host parcel, which lets single-spa release the parcel,
9
+ * its `domElement` and everything rendered into it once it unmounts. Prefer this to single-spa's
10
+ * `mountRootParcel()`, which retains all three for the lifetime of the page.
11
+ *
12
+ * @param parcelConfig The parcel config, or a function that loads one
13
+ * @param customProps The props to mount the parcel with, including the `domElement` to render into
14
+ * @returns The parcel handle; mounting completes with its `mountPromise`
15
+ */
16
+ export declare function renderParcel<T = CustomProps>(parcelConfig: ParcelConfig, customProps: ParcelProps & T): Promise<ReturnType<MountParcel>>;
17
+ /**
18
+ * Provides the equivalent of {@link renderParcel} for callers that need a `mountParcel()` they can
19
+ * call synchronously, single-spa-react's `<Parcel mountParcel={...} />` being the usual case.
20
+ *
21
+ * The parcel it returns is a stand-in until the real one exists, so its `getStatus()` reports
22
+ * `LOADING_SOURCE_CODE` rather than the real status for a short while after mounting. Prefer
23
+ * {@link renderParcel} wherever the caller can await it.
24
+ *
25
+ * @returns A `mountParcel()` that mounts through the host parcel
26
+ */
27
+ export declare function createParcelMounter(): MountParcel;
6
28
  /**
7
29
  * Mounts into a DOM node (representing an extension slot)
8
30
  * a lazy-loaded component from *any* frontend module
9
31
  * that registered an extension component for this slot.
10
32
  */
11
33
  export declare function renderExtension(domElement: HTMLElement, extensionSlotName: string, extensionSlotModuleName: string, extensionId: string, renderFunction?: (application: ParcelConfig) => ParcelConfig, additionalProps?: Record<string, any>): Promise<Parcel | null>;
34
+ export {};
12
35
  //# sourceMappingURL=render.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,OAAO,EAAmB,KAAK,MAAM,EAAE,KAAK,YAAY,EAAE,MAAM,YAAY,CAAC;AAK7E,MAAM,WAAW,aAAa;IAC5B,IAAI,IAAI,CAAC;CACV;AAID;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,UAAU,EAAE,WAAW,EACvB,iBAAiB,EAAE,MAAM,EACzB,uBAAuB,EAAE,MAAM,EAC/B,WAAW,EAAE,MAAM,EACnB,cAAc,GAAE,CAAC,WAAW,EAAE,YAAY,KAAK,YAAuB,EACtE,eAAe,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,GACxC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAyDxB"}
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,OAAO,EAEL,KAAK,QAAQ,EACb,KAAK,WAAW,EAChB,KAAK,MAAM,EACX,KAAK,YAAY,EACjB,KAAK,WAAW,EACjB,MAAM,YAAY,CAAC;AAKpB,MAAM,WAAW,aAAa;IAC5B,IAAI,IAAI,CAAC;CACV;AAED,KAAK,WAAW,GAAG,QAAQ,CAAC,aAAa,CAAC,CAAC;AAgD3C;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAAC,CAAC,GAAG,WAAW,EAChD,YAAY,EAAE,YAAY,EAC1B,WAAW,EAAE,WAAW,GAAG,CAAC,GAC3B,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,CAGlC;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,IAAI,WAAW,CAEjD;AAoCD;;;;GAIG;AACH,wBAAsB,eAAe,CACnC,UAAU,EAAE,WAAW,EACvB,iBAAiB,EAAE,MAAM,EACzB,uBAAuB,EAAE,MAAM,EAC/B,WAAW,EAAE,MAAM,EACnB,cAAc,GAAE,CAAC,WAAW,EAAE,YAAY,KAAK,YAAuB,EACtE,eAAe,GAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAM,GACxC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAyDxB"}
package/dist/render.js CHANGED
@@ -3,6 +3,96 @@ import { getExtensionNameFromId, getExtensionRegistration } from "./extensions.j
3
3
  import { checkStatus } from "./helpers.js";
4
4
  import { updateInternalExtensionStore } from "./store.js";
5
5
  let parcelCount = 0;
6
+ let parcelMounter = null;
7
+ /**
8
+ * Resolves the function used to mount extensions, which is the `mountParcel()` of a long-lived
9
+ * parcel of our own rather than single-spa's `mountRootParcel()`.
10
+ *
11
+ * single-spa only removes a parcel from its owner's registry when it unmounts if that owner has a
12
+ * name, and the internal object backing `mountRootParcel()` has none (fixed in single-spa 7).
13
+ * Anything mounted with it is therefore retained for the lifetime of the page, along with the
14
+ * `domElement` it was given and the whole subtree rendered into it. Extensions mounted through a
15
+ * named owner are pruned as they should be.
16
+ *
17
+ * The host parcel is mounted at most once, lazily, and never unmounted; if mounting it fails we
18
+ * fall back to `mountRootParcel()`, since leaking is better than not rendering.
19
+ */ function getParcelMounter() {
20
+ parcelMounter ?? (parcelMounter = new Promise((resolve, reject)=>{
21
+ const host = mountRootParcel({
22
+ name: 'openmrs-extension-host',
23
+ bootstrap: ()=>Promise.resolve(),
24
+ mount: (props)=>{
25
+ // single-spa types a parcel's lifecycle props as only the custom props it was mounted
26
+ // with, but they also carry the props single-spa itself injects, `mountParcel` included.
27
+ resolve(props.mountParcel);
28
+ return Promise.resolve();
29
+ },
30
+ unmount: ()=>Promise.resolve()
31
+ }, {
32
+ domElement: document.createElement('div')
33
+ });
34
+ host.mountPromise.catch(reject);
35
+ }).catch((err)=>{
36
+ console.error('The host parcel used to mount extensions could not be mounted. Falling back to mounting ' + 'extensions as root parcels, which leaks their DOM elements.', err);
37
+ return mountRootParcel;
38
+ }));
39
+ return parcelMounter;
40
+ }
41
+ /**
42
+ * Mounts a parcel through the framework's host parcel, which lets single-spa release the parcel,
43
+ * its `domElement` and everything rendered into it once it unmounts. Prefer this to single-spa's
44
+ * `mountRootParcel()`, which retains all three for the lifetime of the page.
45
+ *
46
+ * @param parcelConfig The parcel config, or a function that loads one
47
+ * @param customProps The props to mount the parcel with, including the `domElement` to render into
48
+ * @returns The parcel handle; mounting completes with its `mountPromise`
49
+ */ export async function renderParcel(parcelConfig, customProps) {
50
+ const mountParcel = await getParcelMounter();
51
+ return mountParcel(parcelConfig, customProps);
52
+ }
53
+ /**
54
+ * Provides the equivalent of {@link renderParcel} for callers that need a `mountParcel()` they can
55
+ * call synchronously, single-spa-react's `<Parcel mountParcel={...} />` being the usual case.
56
+ *
57
+ * The parcel it returns is a stand-in until the real one exists, so its `getStatus()` reports
58
+ * `LOADING_SOURCE_CODE` rather than the real status for a short while after mounting. Prefer
59
+ * {@link renderParcel} wherever the caller can await it.
60
+ *
61
+ * @returns A `mountParcel()` that mounts through the host parcel
62
+ */ export function createParcelMounter() {
63
+ return mountParcel;
64
+ }
65
+ /**
66
+ * Adapts the asynchronous `renderParcel()` to single-spa's synchronous `mountParcel()` signature,
67
+ * by returning an object that stands in for the parcel and forwards each call to the real one once
68
+ * it resolves. A caller that serialises its calls on `mountPromise`, as single-spa-react's
69
+ * `<Parcel>` does, never observes the stand-in; one that doesn't sees `getStatus()` report
70
+ * `LOADING_SOURCE_CODE` until the parcel is mounted, and an unmounted parcel's `update()` and
71
+ * `unmount()` deferred rather than rejected.
72
+ */ function mountParcel(config, props) {
73
+ let mounted;
74
+ const pending = renderParcel(config, props).then((parcel)=>mounted = parcel);
75
+ return {
76
+ mount: ()=>pending.then((parcel)=>parcel.mount()),
77
+ unmount: ()=>pending.then((parcel)=>parcel.unmount()),
78
+ update: (customProps)=>pending.then((parcel)=>parcel.update?.(customProps)),
79
+ getStatus: ()=>mounted?.getStatus() ?? 'LOADING_SOURCE_CODE',
80
+ // Derived lazily so a mount failure surfaces only on the promise the caller actually reads,
81
+ // instead of becoming an unhandled rejection on the ones it ignores.
82
+ get loadPromise () {
83
+ return pending.then((parcel)=>parcel.loadPromise);
84
+ },
85
+ get bootstrapPromise () {
86
+ return pending.then((parcel)=>parcel.bootstrapPromise);
87
+ },
88
+ get mountPromise () {
89
+ return pending.then((parcel)=>parcel.mountPromise);
90
+ },
91
+ get unmountPromise () {
92
+ return pending.then((parcel)=>parcel.unmountPromise);
93
+ }
94
+ };
95
+ }
6
96
  /**
7
97
  * Mounts into a DOM node (representing an extension slot)
8
98
  * a lazy-loaded component from *any* frontend module
@@ -40,7 +130,7 @@ let parcelCount = 0;
40
130
  });
41
131
  const lifecycle = await load();
42
132
  const id = parcelCount++;
43
- parcel = mountRootParcel(renderFunction({
133
+ parcel = await renderParcel(renderFunction({
44
134
  ...lifecycle,
45
135
  name: `${extensionSlotName}/${extensionName}-${id}`
46
136
  }), {
package/mock.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { vi } from 'vitest';
2
2
  import { getGlobalStore } from '@openmrs/esm-state/mock';
3
- import { type WorkspaceGroupRegistration, type WorkspaceRegistration } from '.';
4
3
 
5
4
  export const attach = vi.fn();
6
5
  export const detach = vi.fn();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-extensions",
3
- "version": "10.0.1-pre.5255",
3
+ "version": "10.0.1-pre.5263",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Coordinates extensions and extension points in the OpenMRS Frontend",
6
6
  "type": "module",
@@ -57,21 +57,21 @@
57
57
  "lodash-es": "^4.17.21"
58
58
  },
59
59
  "peerDependencies": {
60
- "@openmrs/esm-api": "^10.0.1-pre.5255",
61
- "@openmrs/esm-config": "^10.0.1-pre.5255",
62
- "@openmrs/esm-expression-evaluator": "^10.0.1-pre.5255",
63
- "@openmrs/esm-feature-flags": "^10.0.1-pre.5255",
64
- "@openmrs/esm-state": "^10.0.1-pre.5255",
65
- "@openmrs/esm-utils": "^10.0.1-pre.5255",
60
+ "@openmrs/esm-api": "^10.0.1-pre.5263",
61
+ "@openmrs/esm-config": "^10.0.1-pre.5263",
62
+ "@openmrs/esm-expression-evaluator": "^10.0.1-pre.5263",
63
+ "@openmrs/esm-feature-flags": "^10.0.1-pre.5263",
64
+ "@openmrs/esm-state": "^10.0.1-pre.5263",
65
+ "@openmrs/esm-utils": "^10.0.1-pre.5263",
66
66
  "single-spa": "6.x"
67
67
  },
68
68
  "devDependencies": {
69
- "@openmrs/esm-api": "10.0.1-pre.5255",
70
- "@openmrs/esm-config": "10.0.1-pre.5255",
71
- "@openmrs/esm-expression-evaluator": "10.0.1-pre.5255",
72
- "@openmrs/esm-feature-flags": "10.0.1-pre.5255",
73
- "@openmrs/esm-state": "10.0.1-pre.5255",
74
- "@openmrs/esm-utils": "10.0.1-pre.5255",
69
+ "@openmrs/esm-api": "10.0.1-pre.5263",
70
+ "@openmrs/esm-config": "10.0.1-pre.5263",
71
+ "@openmrs/esm-expression-evaluator": "10.0.1-pre.5263",
72
+ "@openmrs/esm-feature-flags": "10.0.1-pre.5263",
73
+ "@openmrs/esm-state": "10.0.1-pre.5263",
74
+ "@openmrs/esm-utils": "10.0.1-pre.5263",
75
75
  "@swc/cli": "0.8.1",
76
76
  "@swc/core": "1.15.21",
77
77
  "@vitest/coverage-v8": "^4.1.2",
@@ -0,0 +1,223 @@
1
+ /* eslint-disable testing-library/render-result-naming-convention -- these tests render extensions, not components */
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import type { Parcel, ParcelConfig } from 'single-spa';
4
+
5
+ const hostParcelName = 'openmrs-extension-host';
6
+
7
+ vi.mock('single-spa', () => ({ mountRootParcel: vi.fn() }));
8
+
9
+ vi.mock('./extensions', () => ({
10
+ getExtensionNameFromId: (extensionId: string) => extensionId,
11
+ getExtensionRegistration: () => ({
12
+ name: 'test-extension',
13
+ moduleName: 'test-module',
14
+ meta: {},
15
+ load: () => Promise.resolve(lifecycles),
16
+ }),
17
+ }));
18
+
19
+ vi.mock('./helpers', () => ({ checkStatus: () => true }));
20
+
21
+ vi.mock('./store', () => ({ updateInternalExtensionStore: vi.fn() }));
22
+
23
+ const lifecycles = {
24
+ bootstrap: () => Promise.resolve(),
25
+ mount: () => Promise.resolve(),
26
+ unmount: () => Promise.resolve(),
27
+ };
28
+
29
+ /**
30
+ * A stand-in for the external parcel representation single-spa returns. single-spa only provides
31
+ * `update()` for a config that has an update lifecycle, so `hasUpdate` allows omitting it.
32
+ */
33
+ function fakeParcel({ hasUpdate = true } = {}): Parcel {
34
+ return {
35
+ mount: vi.fn(() => Promise.resolve()),
36
+ unmount: vi.fn(() => Promise.resolve()),
37
+ ...(hasUpdate ? { update: vi.fn(() => Promise.resolve()) } : {}),
38
+ getStatus: () => 'MOUNTED',
39
+ loadPromise: Promise.resolve(),
40
+ bootstrapPromise: Promise.resolve(),
41
+ mountPromise: Promise.resolve(),
42
+ unmountPromise: Promise.resolve(),
43
+ } as unknown as Parcel;
44
+ }
45
+
46
+ /**
47
+ * Loads a fresh copy of the module under test, which caches the host parcel's mounter in module
48
+ * scope, wired to a fake single-spa whose host parcel either mounts or fails to mount.
49
+ */
50
+ async function loadRenderModule({ hostMountFails = false } = {}) {
51
+ vi.resetModules();
52
+
53
+ const { mountRootParcel } = await import('single-spa');
54
+ const hostMountParcel = vi.fn(() => fakeParcel());
55
+
56
+ vi.mocked(mountRootParcel).mockImplementation(((config: ParcelConfig, props: Record<string, unknown>) => {
57
+ if ('name' in config && config.name === hostParcelName) {
58
+ if (hostMountFails) {
59
+ return { ...fakeParcel(), mountPromise: Promise.reject(new Error('mount failed')) };
60
+ }
61
+
62
+ const mountPromise = Promise.resolve()
63
+ .then(() => (config as { bootstrap: (props: unknown) => Promise<void> }).bootstrap(props))
64
+ .then(() =>
65
+ (config as { mount: (props: unknown) => Promise<void> }).mount({ ...props, mountParcel: hostMountParcel }),
66
+ );
67
+
68
+ return { ...fakeParcel(), mountPromise };
69
+ }
70
+
71
+ return fakeParcel();
72
+ }) as typeof mountRootParcel);
73
+
74
+ const { createParcelMounter, renderExtension, renderParcel } = await import('./render');
75
+
76
+ const mountTestExtension = () =>
77
+ renderExtension(document.createElement('div'), 'test-slot', 'slot-module', 'test-extension#instance');
78
+
79
+ return {
80
+ mountRootParcel: vi.mocked(mountRootParcel),
81
+ hostMountParcel,
82
+ mountTestExtension,
83
+ renderParcel,
84
+ createParcelMounter,
85
+ };
86
+ }
87
+
88
+ describe('renderExtension', () => {
89
+ beforeEach(() => {
90
+ vi.spyOn(console, 'error').mockImplementation(() => {});
91
+ });
92
+
93
+ it('mounts extensions through the host parcel rather than as root parcels', async () => {
94
+ const { mountRootParcel, hostMountParcel, mountTestExtension } = await loadRenderModule();
95
+
96
+ const parcel = await mountTestExtension();
97
+
98
+ expect(hostMountParcel).toHaveBeenCalledTimes(1);
99
+ expect(parcel).toBe(hostMountParcel.mock.results[0].value);
100
+ expect(mountRootParcel).toHaveBeenCalledTimes(1);
101
+ expect(mountRootParcel).toHaveBeenCalledWith(
102
+ expect.objectContaining({ name: hostParcelName }),
103
+ expect.objectContaining({ domElement: expect.anything() }),
104
+ );
105
+ });
106
+
107
+ it('mounts the host parcel only once, including for concurrent renders', async () => {
108
+ const { mountRootParcel, hostMountParcel, mountTestExtension } = await loadRenderModule();
109
+
110
+ await Promise.all([mountTestExtension(), mountTestExtension(), mountTestExtension()]);
111
+ await mountTestExtension();
112
+
113
+ expect(mountRootParcel).toHaveBeenCalledTimes(1);
114
+ expect(hostMountParcel).toHaveBeenCalledTimes(4);
115
+ });
116
+
117
+ it('falls back to mounting root parcels if the host parcel cannot be mounted', async () => {
118
+ const { mountRootParcel, hostMountParcel, mountTestExtension } = await loadRenderModule({
119
+ hostMountFails: true,
120
+ });
121
+
122
+ const parcel = await mountTestExtension();
123
+
124
+ expect(parcel).not.toBeNull();
125
+ expect(hostMountParcel).not.toHaveBeenCalled();
126
+ expect(console.error).toHaveBeenCalled();
127
+ expect(mountRootParcel).toHaveBeenCalledTimes(2);
128
+ expect(mountRootParcel).toHaveBeenLastCalledWith(
129
+ expect.objectContaining({ name: 'test-slot/test-extension#instance-0' }),
130
+ expect.objectContaining({
131
+ _extensionContext: expect.objectContaining({ extensionId: 'test-extension#instance' }),
132
+ }),
133
+ );
134
+
135
+ // a second render must not retry mounting the host parcel
136
+ await mountTestExtension();
137
+ expect(mountRootParcel).toHaveBeenCalledTimes(3);
138
+ });
139
+ });
140
+
141
+ describe('renderParcel', () => {
142
+ it('mounts the parcel through the host parcel', async () => {
143
+ const { mountRootParcel, hostMountParcel, renderParcel } = await loadRenderModule();
144
+ const domElement = document.createElement('div');
145
+
146
+ const parcel = await renderParcel(lifecycles, { domElement, someProp: 'value' });
147
+
148
+ expect(hostMountParcel).toHaveBeenCalledWith(lifecycles, { domElement, someProp: 'value' });
149
+ expect(parcel).toBe(hostMountParcel.mock.results[0].value);
150
+ expect(mountRootParcel).toHaveBeenCalledTimes(1);
151
+ expect(mountRootParcel).toHaveBeenCalledWith(expect.objectContaining({ name: hostParcelName }), expect.anything());
152
+ });
153
+ });
154
+
155
+ describe('createParcelMounter', () => {
156
+ it('returns the parcel synchronously and forwards to the real parcel once it has mounted', async () => {
157
+ const { hostMountParcel, createParcelMounter } = await loadRenderModule();
158
+ const domElement = document.createElement('div');
159
+
160
+ const parcel = createParcelMounter()(lifecycles, { domElement });
161
+
162
+ // the host parcel's mounter is only resolved asynchronously, so nothing is mounted yet
163
+ expect(hostMountParcel).not.toHaveBeenCalled();
164
+ expect(parcel.getStatus()).toBe('LOADING_SOURCE_CODE');
165
+
166
+ await parcel.mountPromise;
167
+
168
+ expect(hostMountParcel).toHaveBeenCalledWith(lifecycles, { domElement });
169
+ expect(parcel.getStatus()).toBe('MOUNTED');
170
+ });
171
+
172
+ it('forwards update and unmount to the real parcel', async () => {
173
+ const { hostMountParcel, createParcelMounter } = await loadRenderModule();
174
+ const domElement = document.createElement('div');
175
+
176
+ const parcel = createParcelMounter()(lifecycles, { domElement });
177
+ await parcel.mountPromise;
178
+ await parcel.update?.({ domElement, someProp: 'value' });
179
+ await parcel.unmount();
180
+
181
+ const realParcel = hostMountParcel.mock.results[0].value;
182
+ expect(realParcel.update).toHaveBeenCalledWith({ domElement, someProp: 'value' });
183
+ expect(realParcel.unmount).toHaveBeenCalledTimes(1);
184
+ await expect(parcel.unmountPromise).resolves.toBeUndefined();
185
+ });
186
+
187
+ it('resolves update as a no-op if the real parcel has no update lifecycle', async () => {
188
+ const { hostMountParcel, createParcelMounter } = await loadRenderModule();
189
+ hostMountParcel.mockImplementationOnce(() => fakeParcel({ hasUpdate: false }));
190
+ const domElement = document.createElement('div');
191
+
192
+ const parcel = createParcelMounter()(lifecycles, { domElement });
193
+ await parcel.mountPromise;
194
+
195
+ await expect(parcel.update?.({ domElement })).resolves.toBeUndefined();
196
+ });
197
+
198
+ it('reports a failure to mount on mountPromise rather than throwing synchronously', async () => {
199
+ const { hostMountParcel, createParcelMounter } = await loadRenderModule();
200
+ hostMountParcel.mockImplementationOnce(() => {
201
+ throw new Error('parcel cannot be mounted without a domElement');
202
+ });
203
+
204
+ const parcel = createParcelMounter()(lifecycles, { domElement: document.createElement('div') });
205
+
206
+ await expect(parcel.mountPromise).rejects.toThrow('parcel cannot be mounted without a domElement');
207
+ // callers unmount only a MOUNTED parcel, so a parcel that never mounted must not claim to be
208
+ expect(parcel.getStatus()).not.toBe('MOUNTED');
209
+ });
210
+
211
+ it('mounts through the same host parcel as renderParcel', async () => {
212
+ const { mountRootParcel, hostMountParcel, renderParcel, createParcelMounter } = await loadRenderModule();
213
+ const domElement = document.createElement('div');
214
+
215
+ await Promise.all([
216
+ renderParcel(lifecycles, { domElement }),
217
+ createParcelMounter()(lifecycles, { domElement }).mountPromise,
218
+ ]);
219
+
220
+ expect(mountRootParcel).toHaveBeenCalledTimes(1);
221
+ expect(hostMountParcel).toHaveBeenCalledTimes(2);
222
+ });
223
+ });
package/src/render.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  /** @module @category Extension */
2
- import { mountRootParcel, type Parcel, type ParcelConfig } from 'single-spa';
2
+ import {
3
+ mountRootParcel,
4
+ type AppProps,
5
+ type CustomProps,
6
+ type Parcel,
7
+ type ParcelConfig,
8
+ type ParcelProps,
9
+ } from 'single-spa';
3
10
  import { getExtensionNameFromId, getExtensionRegistration } from './extensions';
4
11
  import { checkStatus } from './helpers';
5
12
  import { updateInternalExtensionStore } from './store';
@@ -8,7 +15,118 @@ export interface CancelLoading {
8
15
  (): void;
9
16
  }
10
17
 
18
+ type MountParcel = AppProps['mountParcel'];
19
+
11
20
  let parcelCount = 0;
21
+ let parcelMounter: Promise<MountParcel> | null = null;
22
+
23
+ /**
24
+ * Resolves the function used to mount extensions, which is the `mountParcel()` of a long-lived
25
+ * parcel of our own rather than single-spa's `mountRootParcel()`.
26
+ *
27
+ * single-spa only removes a parcel from its owner's registry when it unmounts if that owner has a
28
+ * name, and the internal object backing `mountRootParcel()` has none (fixed in single-spa 7).
29
+ * Anything mounted with it is therefore retained for the lifetime of the page, along with the
30
+ * `domElement` it was given and the whole subtree rendered into it. Extensions mounted through a
31
+ * named owner are pruned as they should be.
32
+ *
33
+ * The host parcel is mounted at most once, lazily, and never unmounted; if mounting it fails we
34
+ * fall back to `mountRootParcel()`, since leaking is better than not rendering.
35
+ */
36
+ function getParcelMounter(): Promise<MountParcel> {
37
+ parcelMounter ??= new Promise<MountParcel>((resolve, reject) => {
38
+ const host = mountRootParcel(
39
+ {
40
+ name: 'openmrs-extension-host',
41
+ bootstrap: () => Promise.resolve(),
42
+ mount: (props) => {
43
+ // single-spa types a parcel's lifecycle props as only the custom props it was mounted
44
+ // with, but they also carry the props single-spa itself injects, `mountParcel` included.
45
+ resolve((props as unknown as AppProps).mountParcel);
46
+ return Promise.resolve();
47
+ },
48
+ unmount: () => Promise.resolve(),
49
+ },
50
+ { domElement: document.createElement('div') },
51
+ );
52
+
53
+ host.mountPromise.catch(reject);
54
+ }).catch((err) => {
55
+ console.error(
56
+ 'The host parcel used to mount extensions could not be mounted. Falling back to mounting ' +
57
+ 'extensions as root parcels, which leaks their DOM elements.',
58
+ err,
59
+ );
60
+ return mountRootParcel;
61
+ });
62
+
63
+ return parcelMounter;
64
+ }
65
+
66
+ /**
67
+ * Mounts a parcel through the framework's host parcel, which lets single-spa release the parcel,
68
+ * its `domElement` and everything rendered into it once it unmounts. Prefer this to single-spa's
69
+ * `mountRootParcel()`, which retains all three for the lifetime of the page.
70
+ *
71
+ * @param parcelConfig The parcel config, or a function that loads one
72
+ * @param customProps The props to mount the parcel with, including the `domElement` to render into
73
+ * @returns The parcel handle; mounting completes with its `mountPromise`
74
+ */
75
+ export async function renderParcel<T = CustomProps>(
76
+ parcelConfig: ParcelConfig,
77
+ customProps: ParcelProps & T,
78
+ ): Promise<ReturnType<MountParcel>> {
79
+ const mountParcel = await getParcelMounter();
80
+ return mountParcel(parcelConfig, customProps);
81
+ }
82
+
83
+ /**
84
+ * Provides the equivalent of {@link renderParcel} for callers that need a `mountParcel()` they can
85
+ * call synchronously, single-spa-react's `<Parcel mountParcel={...} />` being the usual case.
86
+ *
87
+ * The parcel it returns is a stand-in until the real one exists, so its `getStatus()` reports
88
+ * `LOADING_SOURCE_CODE` rather than the real status for a short while after mounting. Prefer
89
+ * {@link renderParcel} wherever the caller can await it.
90
+ *
91
+ * @returns A `mountParcel()` that mounts through the host parcel
92
+ */
93
+ export function createParcelMounter(): MountParcel {
94
+ return mountParcel;
95
+ }
96
+
97
+ /**
98
+ * Adapts the asynchronous `renderParcel()` to single-spa's synchronous `mountParcel()` signature,
99
+ * by returning an object that stands in for the parcel and forwards each call to the real one once
100
+ * it resolves. A caller that serialises its calls on `mountPromise`, as single-spa-react's
101
+ * `<Parcel>` does, never observes the stand-in; one that doesn't sees `getStatus()` report
102
+ * `LOADING_SOURCE_CODE` until the parcel is mounted, and an unmounted parcel's `update()` and
103
+ * `unmount()` deferred rather than rejected.
104
+ */
105
+ function mountParcel(config: ParcelConfig, props: ParcelProps & CustomProps): Parcel {
106
+ let mounted: Parcel | undefined;
107
+ const pending = renderParcel(config, props).then((parcel) => (mounted = parcel));
108
+
109
+ return {
110
+ mount: () => pending.then((parcel) => parcel.mount()),
111
+ unmount: () => pending.then((parcel) => parcel.unmount()),
112
+ update: (customProps) => pending.then((parcel) => parcel.update?.(customProps)),
113
+ getStatus: () => mounted?.getStatus() ?? 'LOADING_SOURCE_CODE',
114
+ // Derived lazily so a mount failure surfaces only on the promise the caller actually reads,
115
+ // instead of becoming an unhandled rejection on the ones it ignores.
116
+ get loadPromise() {
117
+ return pending.then((parcel) => parcel.loadPromise);
118
+ },
119
+ get bootstrapPromise() {
120
+ return pending.then((parcel) => parcel.bootstrapPromise);
121
+ },
122
+ get mountPromise() {
123
+ return pending.then((parcel) => parcel.mountPromise);
124
+ },
125
+ get unmountPromise() {
126
+ return pending.then((parcel) => parcel.unmountPromise);
127
+ },
128
+ };
129
+ }
12
130
 
13
131
  /**
14
132
  * Mounts into a DOM node (representing an extension slot)
@@ -56,7 +174,7 @@ export async function renderExtension(
56
174
 
57
175
  const lifecycle = await load();
58
176
  const id = parcelCount++;
59
- parcel = mountRootParcel(
177
+ parcel = await renderParcel(
60
178
  renderFunction({
61
179
  ...lifecycle,
62
180
  name: `${extensionSlotName}/${extensionName}-${id}`,