@openmrs/esm-extensions 10.0.1-pre.5378 → 10.0.1-pre.5389
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.map +1 -1
- package/dist/render.js +63 -31
- package/package.json +13 -13
- package/src/render.single-spa.test.ts +80 -0
- package/src/render.test.ts +95 -15
- package/src/render.ts +94 -17
package/.turbo/turbo-build.log
CHANGED
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,EAEL,KAAK,QAAQ,EACb,KAAK,WAAW,EAEhB,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;
|
|
1
|
+
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../src/render.ts"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,OAAO,EAEL,KAAK,QAAQ,EACb,KAAK,WAAW,EAEhB,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;AAwJ3C;;;;;;;;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,CAsGxB"}
|
package/dist/render.js
CHANGED
|
@@ -5,42 +5,74 @@ import { registerExtensionRendering, unregisterExtensionRendering } from "./stor
|
|
|
5
5
|
let parcelCount = 0;
|
|
6
6
|
let parcelMounter = null;
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
mount:
|
|
17
|
-
|
|
18
|
-
dieOnTimeout: true
|
|
19
|
-
},
|
|
20
|
-
unmount: {
|
|
21
|
-
millis: 15000,
|
|
22
|
-
dieOnTimeout: true
|
|
23
|
-
}
|
|
8
|
+
* How long each lifecycle gets before the parcel is marked dead. Loading and unloading are left
|
|
9
|
+
* out, as their timing is unpredictable. These are meant to be generous.
|
|
10
|
+
*
|
|
11
|
+
* These are enforced by {@link withDeadline} rather than through single-spa's `timeouts`, because
|
|
12
|
+
* `reasonableTime()` never clears the timers it schedules from that config: until one fires its
|
|
13
|
+
* closure retains the parcel, the `domElement` it was given and the whole subtree rendered into it.
|
|
14
|
+
*/ const lifecycleDeadlines = {
|
|
15
|
+
bootstrap: 15000,
|
|
16
|
+
mount: 15000,
|
|
17
|
+
unmount: 15000
|
|
24
18
|
};
|
|
19
|
+
/** single-spa's own test for a thenable, which is all it requires a lifecycle to return. */ function isPromiseLike(value) {
|
|
20
|
+
const thenable = value;
|
|
21
|
+
return typeof thenable?.then === 'function' && typeof thenable?.catch === 'function';
|
|
22
|
+
}
|
|
25
23
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
24
|
+
* Collapses single-spa's "function or array of functions" lifecycle shape into one function that
|
|
25
|
+
* runs each in turn, rejecting if any of them returns something that is not a promise.
|
|
26
|
+
*/ function toSingleFn(lifecycle, name, which) {
|
|
27
|
+
const fns = Array.isArray(lifecycle) ? lifecycle : [
|
|
28
|
+
lifecycle
|
|
29
|
+
];
|
|
30
|
+
return (props)=>fns.reduce((chain, fn, index)=>chain.then(()=>{
|
|
31
|
+
const result = fn(props);
|
|
32
|
+
return isPromiseLike(result) ? result : Promise.reject(new Error(`Lifecycle function ${which} at array index ${index} for parcel ${name} did not return a promise`));
|
|
33
|
+
}), Promise.resolve(undefined));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Wraps a lifecycle so that it rejects once `millis` have elapsed, clearing the timer as soon as it
|
|
37
|
+
* settles either way. Rejecting puts the parcel into the same broken state single-spa's own
|
|
38
|
+
* `dieOnTimeout` would, but without leaving a timer holding the parcel for the full deadline.
|
|
39
|
+
*/ function withDeadline(lifecycle, millis, name, which) {
|
|
40
|
+
const run = toSingleFn(lifecycle, name, which);
|
|
41
|
+
return (props)=>{
|
|
42
|
+
let timer;
|
|
43
|
+
const deadline = new Promise((_, reject)=>{
|
|
44
|
+
timer = setTimeout(()=>reject(new Error(`Lifecycle function ${which} for parcel ${name} did not settle within ${millis}ms`)), millis);
|
|
45
|
+
});
|
|
46
|
+
return Promise.race([
|
|
47
|
+
run(props),
|
|
48
|
+
deadline
|
|
49
|
+
]).finally(()=>clearTimeout(timer));
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Applies {@link lifecycleDeadlines} to a resolved parcel config. */ function boundLifecycles(parcelConfig) {
|
|
53
|
+
// A parcel that declares its own timeouts is bounding itself, so it is left to single-spa.
|
|
54
|
+
if (parcelConfig.timeouts) {
|
|
55
|
+
return parcelConfig;
|
|
38
56
|
}
|
|
57
|
+
const name = parcelConfig.name ?? 'parcel';
|
|
58
|
+
const bounded = Object.fromEntries(Object.keys(lifecycleDeadlines).filter((which)=>parcelConfig[which]).map((which)=>[
|
|
59
|
+
which,
|
|
60
|
+
withDeadline(parcelConfig[which], lifecycleDeadlines[which], name, which)
|
|
61
|
+
]));
|
|
39
62
|
return {
|
|
40
|
-
|
|
41
|
-
...
|
|
63
|
+
...parcelConfig,
|
|
64
|
+
...bounded
|
|
42
65
|
};
|
|
43
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Applies {@link boundLifecycles} to a parcel config, resolving the function form first so that a
|
|
69
|
+
* lazily loaded config gets the deadlines too.
|
|
70
|
+
*/ function withLifecycleDeadlines(parcelConfig) {
|
|
71
|
+
if (typeof parcelConfig === 'function') {
|
|
72
|
+
return ()=>parcelConfig().then(boundLifecycles);
|
|
73
|
+
}
|
|
74
|
+
return boundLifecycles(parcelConfig);
|
|
75
|
+
}
|
|
44
76
|
/**
|
|
45
77
|
* Resolves the function used to mount extensions, which is the `mountParcel()` of a long-lived
|
|
46
78
|
* parcel of our own rather than single-spa's `mountRootParcel()`.
|
|
@@ -85,7 +117,7 @@ let parcelMounter = null;
|
|
|
85
117
|
* @returns The parcel handle; mounting completes with its `mountPromise`
|
|
86
118
|
*/ export async function renderParcel(parcelConfig, customProps) {
|
|
87
119
|
const mountParcel = await getParcelMounter();
|
|
88
|
-
return mountParcel(
|
|
120
|
+
return mountParcel(withLifecycleDeadlines(parcelConfig), customProps);
|
|
89
121
|
}
|
|
90
122
|
/**
|
|
91
123
|
* Provides the equivalent of {@link renderParcel} for callers that need a `mountParcel()` they can
|
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.5389",
|
|
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.5389",
|
|
61
|
+
"@openmrs/esm-config": "^10.0.1-pre.5389",
|
|
62
|
+
"@openmrs/esm-expression-evaluator": "^10.0.1-pre.5389",
|
|
63
|
+
"@openmrs/esm-feature-flags": "^10.0.1-pre.5389",
|
|
64
|
+
"@openmrs/esm-state": "^10.0.1-pre.5389",
|
|
65
|
+
"@openmrs/esm-utils": "^10.0.1-pre.5389",
|
|
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.5389",
|
|
70
|
+
"@openmrs/esm-config": "10.0.1-pre.5389",
|
|
71
|
+
"@openmrs/esm-expression-evaluator": "10.0.1-pre.5389",
|
|
72
|
+
"@openmrs/esm-feature-flags": "10.0.1-pre.5389",
|
|
73
|
+
"@openmrs/esm-state": "10.0.1-pre.5389",
|
|
74
|
+
"@openmrs/esm-utils": "10.0.1-pre.5389",
|
|
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,80 @@
|
|
|
1
|
+
/* eslint-disable testing-library/render-result-naming-convention -- these tests render parcels, not components */
|
|
2
|
+
/*
|
|
3
|
+
* Unlike `render.test.ts`, these run against the real single-spa. They cover the parts of
|
|
4
|
+
* `renderParcel()` whose whole purpose is to interact with single-spa's internals — the lifecycle
|
|
5
|
+
* wrapping in particular, which stands between single-spa and every lifecycle it would otherwise
|
|
6
|
+
* have validated itself.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import type { LifeCycles } from 'single-spa';
|
|
10
|
+
import { renderParcel } from './render';
|
|
11
|
+
|
|
12
|
+
function goodLifecycles(): LifeCycles {
|
|
13
|
+
return {
|
|
14
|
+
bootstrap: () => Promise.resolve(),
|
|
15
|
+
mount: () => Promise.resolve(),
|
|
16
|
+
unmount: () => Promise.resolve(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('renderParcel against the real single-spa', () => {
|
|
25
|
+
it('mounts and unmounts a well-behaved parcel', async () => {
|
|
26
|
+
const parcel = await renderParcel(goodLifecycles(), { domElement: document.createElement('div') });
|
|
27
|
+
|
|
28
|
+
await parcel.mountPromise;
|
|
29
|
+
expect(parcel.getStatus()).toBe('MOUNTED');
|
|
30
|
+
|
|
31
|
+
await parcel.unmount();
|
|
32
|
+
expect(parcel.getStatus()).toBe('NOT_MOUNTED');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('breaks a parcel whose mount does not return a promise', async () => {
|
|
36
|
+
// single-spa would fail this parcel on its own; the deadline wrapper has to not paper over it.
|
|
37
|
+
const lifecycles = { ...goodLifecycles(), mount: (() => undefined) as never };
|
|
38
|
+
|
|
39
|
+
const parcel = await renderParcel(lifecycles, { domElement: document.createElement('div') });
|
|
40
|
+
|
|
41
|
+
await expect(parcel.mountPromise).rejects.toThrow(
|
|
42
|
+
/Lifecycle function mount at array index 0 for parcel .* did not return a promise/,
|
|
43
|
+
);
|
|
44
|
+
expect(parcel.getStatus()).toBe('SKIP_BECAUSE_BROKEN');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('breaks a parcel one of whose array lifecycle functions does not return a promise', async () => {
|
|
48
|
+
const lifecycles = {
|
|
49
|
+
...goodLifecycles(),
|
|
50
|
+
mount: [() => Promise.resolve(), (() => undefined) as never],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const parcel = await renderParcel(lifecycles, { domElement: document.createElement('div') });
|
|
54
|
+
|
|
55
|
+
await expect(parcel.mountPromise).rejects.toThrow(
|
|
56
|
+
/Lifecycle function mount at array index 1 for parcel .* did not return a promise/,
|
|
57
|
+
);
|
|
58
|
+
expect(parcel.getStatus()).toBe('SKIP_BECAUSE_BROKEN');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('breaks a parcel whose mount overruns its deadline', async () => {
|
|
62
|
+
vi.useFakeTimers();
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const parcel = await renderParcel(
|
|
66
|
+
{ ...goodLifecycles(), mount: () => new Promise(() => {}) },
|
|
67
|
+
{ domElement: document.createElement('div') },
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Asserted against before the clock is advanced, so the rejection is never unhandled.
|
|
71
|
+
const overran = expect(parcel.mountPromise).rejects.toThrow(/did not settle within 15000ms/);
|
|
72
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
73
|
+
await overran;
|
|
74
|
+
|
|
75
|
+
expect(parcel.getStatus()).toBe('SKIP_BECAUSE_BROKEN');
|
|
76
|
+
} finally {
|
|
77
|
+
vi.useRealTimers();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/render.test.ts
CHANGED
|
@@ -30,13 +30,13 @@ const lifecycles = {
|
|
|
30
30
|
};
|
|
31
31
|
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
* The config single-spa is handed. Each lifecycle is replaced by a deadline-wrapped stand-in, and
|
|
34
|
+
* no `timeouts` are set, so single-spa keeps its own (short, warn-only) defaults.
|
|
35
35
|
*/
|
|
36
|
-
const
|
|
37
|
-
bootstrap:
|
|
38
|
-
mount:
|
|
39
|
-
unmount:
|
|
36
|
+
const boundedConfig = {
|
|
37
|
+
bootstrap: expect.any(Function),
|
|
38
|
+
mount: expect.any(Function),
|
|
39
|
+
unmount: expect.any(Function),
|
|
40
40
|
};
|
|
41
41
|
|
|
42
42
|
/**
|
|
@@ -262,10 +262,7 @@ describe('renderParcel', () => {
|
|
|
262
262
|
|
|
263
263
|
const parcel = await renderParcel(lifecycles, { domElement, someProp: 'value' });
|
|
264
264
|
|
|
265
|
-
expect(hostMountParcel).toHaveBeenCalledWith(
|
|
266
|
-
{ ...lifecycles, timeouts: lifecycleTimeouts },
|
|
267
|
-
{ domElement, someProp: 'value' },
|
|
268
|
-
);
|
|
265
|
+
expect(hostMountParcel).toHaveBeenCalledWith(boundedConfig, { domElement, someProp: 'value' });
|
|
269
266
|
expect(parcel).toBe(hostMountParcel.mock.results[0].value);
|
|
270
267
|
expect(mountRootParcel).toHaveBeenCalledTimes(1);
|
|
271
268
|
expect(mountRootParcel).toHaveBeenCalledWith(expect.objectContaining({ name: hostParcelName }), expect.anything());
|
|
@@ -280,11 +277,94 @@ describe('renderParcel', () => {
|
|
|
280
277
|
|
|
281
278
|
// Without these, a lifecycle that never settles leaves a parcel single-spa can never unmount,
|
|
282
279
|
// so nothing the caller tracks against it is ever released.
|
|
283
|
-
const [objectForm] = hostMountParcel.mock.calls[0] as [
|
|
284
|
-
const [functionForm] = hostMountParcel.mock.calls[1] as [() => Promise<
|
|
280
|
+
const [objectForm] = hostMountParcel.mock.calls[0] as [typeof lifecycles];
|
|
281
|
+
const [functionForm] = hostMountParcel.mock.calls[1] as [() => Promise<typeof lifecycles>];
|
|
282
|
+
|
|
283
|
+
expect(objectForm).toEqual(boundedConfig);
|
|
284
|
+
expect(objectForm.mount).not.toBe(lifecycles.mount);
|
|
285
|
+
expect(await functionForm()).toEqual(boundedConfig);
|
|
286
|
+
expect((await functionForm()).mount).not.toBe(lifecycles.mount);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('fails a lifecycle that overruns its deadline, and clears the deadline when it does not', async () => {
|
|
290
|
+
vi.useFakeTimers();
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
const { hostMountParcel, renderParcel } = await loadRenderModule();
|
|
294
|
+
const domElement = document.createElement('div');
|
|
295
|
+
|
|
296
|
+
await renderParcel({ ...lifecycles, mount: () => new Promise(() => {}) }, { domElement });
|
|
297
|
+
await renderParcel(lifecycles, { domElement });
|
|
298
|
+
|
|
299
|
+
const [hung] = hostMountParcel.mock.calls[0] as [typeof lifecycles];
|
|
300
|
+
const [settles] = hostMountParcel.mock.calls[1] as [typeof lifecycles];
|
|
301
|
+
|
|
302
|
+
const timersBefore = vi.getTimerCount();
|
|
303
|
+
await expect(settles.mount({ domElement })).resolves.toBeUndefined();
|
|
304
|
+
// The deadline is cleared as soon as the lifecycle settles, so nothing is left holding the
|
|
305
|
+
// parcel — and with it the DOM it rendered into — until the deadline would have elapsed.
|
|
306
|
+
expect(vi.getTimerCount()).toBe(timersBefore);
|
|
307
|
+
|
|
308
|
+
// Asserted against before the clock is advanced, so the rejection is never unhandled.
|
|
309
|
+
const overran = expect(hung.mount({ domElement })).rejects.toThrow(/did not settle within 15000ms/);
|
|
310
|
+
expect(vi.getTimerCount()).toBe(timersBefore + 1);
|
|
311
|
+
await vi.advanceTimersByTimeAsync(15_000);
|
|
312
|
+
await overran;
|
|
313
|
+
} finally {
|
|
314
|
+
vi.useRealTimers();
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('rejects a lifecycle that does not return a promise', async () => {
|
|
319
|
+
const { hostMountParcel, renderParcel } = await loadRenderModule();
|
|
320
|
+
const domElement = document.createElement('div');
|
|
321
|
+
|
|
322
|
+
await renderParcel({ ...lifecycles, mount: (() => undefined) as never }, { domElement });
|
|
323
|
+
|
|
324
|
+
const [bounded] = hostMountParcel.mock.calls[0] as [typeof lifecycles];
|
|
325
|
+
|
|
326
|
+
// single-spa makes this check itself, but only ever sees the deadline wrapper's own promise, so
|
|
327
|
+
// without it a mount that forgets to return its work would report as instantly complete.
|
|
328
|
+
await expect(bounded.mount({ domElement })).rejects.toThrow(
|
|
329
|
+
/Lifecycle function mount at array index 0 for parcel .* did not return a promise/,
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('runs the functions of an array lifecycle in turn, checking each for a promise', async () => {
|
|
334
|
+
const { hostMountParcel, renderParcel } = await loadRenderModule();
|
|
335
|
+
const domElement = document.createElement('div');
|
|
336
|
+
const order: Array<string> = [];
|
|
337
|
+
|
|
338
|
+
await renderParcel(
|
|
339
|
+
{
|
|
340
|
+
...lifecycles,
|
|
341
|
+
mount: [
|
|
342
|
+
() => Promise.resolve().then(() => void order.push('first')),
|
|
343
|
+
() => Promise.resolve().then(() => void order.push('second')),
|
|
344
|
+
],
|
|
345
|
+
},
|
|
346
|
+
{ domElement },
|
|
347
|
+
);
|
|
348
|
+
await renderParcel({ ...lifecycles, mount: [() => Promise.resolve(), (() => undefined) as never] }, { domElement });
|
|
349
|
+
|
|
350
|
+
const [chained] = hostMountParcel.mock.calls[0] as [typeof lifecycles];
|
|
351
|
+
const [broken] = hostMountParcel.mock.calls[1] as [typeof lifecycles];
|
|
352
|
+
|
|
353
|
+
await expect(chained.mount({ domElement })).resolves.toBeUndefined();
|
|
354
|
+
expect(order).toEqual(['first', 'second']);
|
|
355
|
+
await expect(broken.mount({ domElement })).rejects.toThrow(
|
|
356
|
+
/Lifecycle function mount at array index 1 for parcel .* did not return a promise/,
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('leaves a parcel that declares its own timeouts to single-spa', async () => {
|
|
361
|
+
const { hostMountParcel, renderParcel } = await loadRenderModule();
|
|
362
|
+
const domElement = document.createElement('div');
|
|
363
|
+
const selfBounded = { ...lifecycles, timeouts: { mount: { millis: 500, dieOnTimeout: true } } };
|
|
364
|
+
|
|
365
|
+
await renderParcel(selfBounded, { domElement });
|
|
285
366
|
|
|
286
|
-
expect(
|
|
287
|
-
expect((await functionForm()).timeouts).toEqual(lifecycleTimeouts);
|
|
367
|
+
expect(hostMountParcel).toHaveBeenCalledWith(selfBounded, { domElement });
|
|
288
368
|
});
|
|
289
369
|
});
|
|
290
370
|
|
|
@@ -301,7 +381,7 @@ describe('createParcelMounter', () => {
|
|
|
301
381
|
|
|
302
382
|
await parcel.mountPromise;
|
|
303
383
|
|
|
304
|
-
expect(hostMountParcel).toHaveBeenCalledWith(
|
|
384
|
+
expect(hostMountParcel).toHaveBeenCalledWith(boundedConfig, { domElement });
|
|
305
385
|
expect(parcel.getStatus()).toBe('MOUNTED');
|
|
306
386
|
});
|
|
307
387
|
|
package/src/render.ts
CHANGED
|
@@ -21,31 +21,108 @@ type MountParcel = AppProps['mountParcel'];
|
|
|
21
21
|
let parcelCount = 0;
|
|
22
22
|
let parcelMounter: Promise<MountParcel> | null = null;
|
|
23
23
|
|
|
24
|
+
type ParcelConfigObject = Extract<ParcelConfig, LifeCycles>;
|
|
25
|
+
type LifecycleFn = Exclude<LifeCycles['mount'], readonly unknown[]>;
|
|
26
|
+
type LifecycleName = keyof typeof lifecycleDeadlines;
|
|
27
|
+
|
|
24
28
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
29
|
+
* How long each lifecycle gets before the parcel is marked dead. Loading and unloading are left
|
|
30
|
+
* out, as their timing is unpredictable. These are meant to be generous.
|
|
31
|
+
*
|
|
32
|
+
* These are enforced by {@link withDeadline} rather than through single-spa's `timeouts`, because
|
|
33
|
+
* `reasonableTime()` never clears the timers it schedules from that config: until one fires its
|
|
34
|
+
* closure retains the parcel, the `domElement` it was given and the whole subtree rendered into it.
|
|
28
35
|
*/
|
|
29
|
-
const
|
|
30
|
-
bootstrap:
|
|
31
|
-
mount:
|
|
32
|
-
unmount:
|
|
36
|
+
const lifecycleDeadlines = {
|
|
37
|
+
bootstrap: 15_000,
|
|
38
|
+
mount: 15_000,
|
|
39
|
+
unmount: 15_000,
|
|
33
40
|
};
|
|
34
41
|
|
|
42
|
+
/** single-spa's own test for a thenable, which is all it requires a lifecycle to return. */
|
|
43
|
+
function isPromiseLike(value: unknown): value is Promise<unknown> {
|
|
44
|
+
const thenable = value as Promise<unknown> | undefined;
|
|
45
|
+
return typeof thenable?.then === 'function' && typeof thenable?.catch === 'function';
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
49
|
+
* Collapses single-spa's "function or array of functions" lifecycle shape into one function that
|
|
50
|
+
* runs each in turn, rejecting if any of them returns something that is not a promise.
|
|
51
|
+
*/
|
|
52
|
+
function toSingleFn(lifecycle: LifecycleFn | Array<LifecycleFn>, name: string, which: LifecycleName): LifecycleFn {
|
|
53
|
+
const fns = Array.isArray(lifecycle) ? lifecycle : [lifecycle];
|
|
54
|
+
|
|
55
|
+
return (props) =>
|
|
56
|
+
fns.reduce<Promise<unknown>>(
|
|
57
|
+
(chain, fn, index) =>
|
|
58
|
+
chain.then(() => {
|
|
59
|
+
const result = fn(props);
|
|
60
|
+
|
|
61
|
+
return isPromiseLike(result)
|
|
62
|
+
? result
|
|
63
|
+
: Promise.reject(
|
|
64
|
+
new Error(
|
|
65
|
+
`Lifecycle function ${which} at array index ${index} for parcel ${name} did not return a promise`,
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
}),
|
|
69
|
+
Promise.resolve<unknown>(undefined),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Wraps a lifecycle so that it rejects once `millis` have elapsed, clearing the timer as soon as it
|
|
75
|
+
* settles either way. Rejecting puts the parcel into the same broken state single-spa's own
|
|
76
|
+
* `dieOnTimeout` would, but without leaving a timer holding the parcel for the full deadline.
|
|
77
|
+
*/
|
|
78
|
+
function withDeadline(
|
|
79
|
+
lifecycle: LifecycleFn | Array<LifecycleFn>,
|
|
80
|
+
millis: number,
|
|
81
|
+
name: string,
|
|
82
|
+
which: LifecycleName,
|
|
83
|
+
): LifecycleFn {
|
|
84
|
+
const run = toSingleFn(lifecycle, name, which);
|
|
85
|
+
|
|
86
|
+
return (props) => {
|
|
87
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
88
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
89
|
+
timer = setTimeout(
|
|
90
|
+
() => reject(new Error(`Lifecycle function ${which} for parcel ${name} did not settle within ${millis}ms`)),
|
|
91
|
+
millis,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return Promise.race([run(props), deadline]).finally(() => clearTimeout(timer));
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Applies {@link lifecycleDeadlines} to a resolved parcel config. */
|
|
100
|
+
function boundLifecycles(parcelConfig: ParcelConfigObject): ParcelConfigObject {
|
|
101
|
+
// A parcel that declares its own timeouts is bounding itself, so it is left to single-spa.
|
|
102
|
+
if ((parcelConfig as { timeouts?: unknown }).timeouts) {
|
|
103
|
+
return parcelConfig;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const name = (parcelConfig as { name?: string }).name ?? 'parcel';
|
|
107
|
+
const bounded = Object.fromEntries(
|
|
108
|
+
(Object.keys(lifecycleDeadlines) as Array<LifecycleName>)
|
|
109
|
+
.filter((which) => parcelConfig[which])
|
|
110
|
+
.map((which) => [which, withDeadline(parcelConfig[which], lifecycleDeadlines[which], name, which)]),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
return { ...parcelConfig, ...bounded };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Applies {@link boundLifecycles} to a parcel config, resolving the function form first so that a
|
|
118
|
+
* lazily loaded config gets the deadlines too.
|
|
41
119
|
*/
|
|
42
|
-
function
|
|
43
|
-
// Spread after ours, so a parcel that declares its own timeouts keeps them.
|
|
120
|
+
function withLifecycleDeadlines(parcelConfig: ParcelConfig): ParcelConfig {
|
|
44
121
|
if (typeof parcelConfig === 'function') {
|
|
45
|
-
return (() => parcelConfig().then(
|
|
122
|
+
return (() => parcelConfig().then(boundLifecycles)) as ParcelConfig;
|
|
46
123
|
}
|
|
47
124
|
|
|
48
|
-
return
|
|
125
|
+
return boundLifecycles(parcelConfig);
|
|
49
126
|
}
|
|
50
127
|
|
|
51
128
|
/**
|
|
@@ -105,7 +182,7 @@ export async function renderParcel<T = CustomProps>(
|
|
|
105
182
|
customProps: ParcelProps & T,
|
|
106
183
|
): Promise<ReturnType<MountParcel>> {
|
|
107
184
|
const mountParcel = await getParcelMounter();
|
|
108
|
-
return mountParcel(
|
|
185
|
+
return mountParcel(withLifecycleDeadlines(parcelConfig), customProps);
|
|
109
186
|
}
|
|
110
187
|
|
|
111
188
|
/**
|