@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/render.d.ts +24 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +91 -1
- package/mock.ts +0 -1
- package/package.json +13 -13
- package/src/render.test.ts +223 -0
- package/src/render.ts +120 -2
package/.turbo/turbo-build.log
CHANGED
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
|
package/dist/render.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,OAAO,
|
|
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 =
|
|
133
|
+
parcel = await renderParcel(renderFunction({
|
|
44
134
|
...lifecycle,
|
|
45
135
|
name: `${extensionSlotName}/${extensionName}-${id}`
|
|
46
136
|
}), {
|
package/mock.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openmrs/esm-extensions",
|
|
3
|
-
"version": "10.0.1-pre.
|
|
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.
|
|
61
|
-
"@openmrs/esm-config": "^10.0.1-pre.
|
|
62
|
-
"@openmrs/esm-expression-evaluator": "^10.0.1-pre.
|
|
63
|
-
"@openmrs/esm-feature-flags": "^10.0.1-pre.
|
|
64
|
-
"@openmrs/esm-state": "^10.0.1-pre.
|
|
65
|
-
"@openmrs/esm-utils": "^10.0.1-pre.
|
|
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.
|
|
70
|
-
"@openmrs/esm-config": "10.0.1-pre.
|
|
71
|
-
"@openmrs/esm-expression-evaluator": "10.0.1-pre.
|
|
72
|
-
"@openmrs/esm-feature-flags": "10.0.1-pre.
|
|
73
|
-
"@openmrs/esm-state": "10.0.1-pre.
|
|
74
|
-
"@openmrs/esm-utils": "10.0.1-pre.
|
|
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 {
|
|
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 =
|
|
177
|
+
parcel = await renderParcel(
|
|
60
178
|
renderFunction({
|
|
61
179
|
...lifecycle,
|
|
62
180
|
name: `${extensionSlotName}/${extensionName}-${id}`,
|