@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.
@@ -1,3 +1,3 @@
1
- [0] Successfully compiled: 11 files with swc (140.22ms)
1
+ [0] Successfully compiled: 11 files with swc (141.95ms)
2
2
  [0] swc --strip-leading-paths src -d dist exited with code 0
3
3
  [1] tsc --project tsconfig.build.json exited with code 0
@@ -1 +1 @@
1
- {"version":3,"file":"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;AA2E3C;;;;;;;;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"}
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
- * Mark parcels as dead if they do not succeed within 15s. Note that loading and unloading is
9
- * skipped as that has an unpredictable timing. Failing here allows us to clean-up correctly
10
- * if the parcel doesn't fully mount.
11
- */ const lifecycleTimeouts = {
12
- bootstrap: {
13
- millis: 15000,
14
- dieOnTimeout: true
15
- },
16
- mount: {
17
- millis: 15000,
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
- * Applies {@link lifecycleTimeouts} to a parcel config, resolving the function form first so that a
27
- * lazily loaded config gets them too.
28
- *
29
- * The cast is needed because single-spa reads `timeouts` off a parcel config but doesn't declare it
30
- * on `ParcelConfigObject`.
31
- */ function withLifecycleTimeouts(parcelConfig) {
32
- // Spread after ours, so a parcel that declares its own timeouts keeps them.
33
- if (typeof parcelConfig === 'function') {
34
- return ()=>parcelConfig().then((resolved)=>({
35
- timeouts: lifecycleTimeouts,
36
- ...resolved
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
- timeouts: lifecycleTimeouts,
41
- ...parcelConfig
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(withLifecycleTimeouts(parcelConfig), customProps);
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.5378",
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.5378",
61
- "@openmrs/esm-config": "^10.0.1-pre.5378",
62
- "@openmrs/esm-expression-evaluator": "^10.0.1-pre.5378",
63
- "@openmrs/esm-feature-flags": "^10.0.1-pre.5378",
64
- "@openmrs/esm-state": "^10.0.1-pre.5378",
65
- "@openmrs/esm-utils": "^10.0.1-pre.5378",
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.5378",
70
- "@openmrs/esm-config": "10.0.1-pre.5378",
71
- "@openmrs/esm-expression-evaluator": "10.0.1-pre.5378",
72
- "@openmrs/esm-feature-flags": "10.0.1-pre.5378",
73
- "@openmrs/esm-state": "10.0.1-pre.5378",
74
- "@openmrs/esm-utils": "10.0.1-pre.5378",
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
+ });
@@ -30,13 +30,13 @@ const lifecycles = {
30
30
  };
31
31
 
32
32
  /**
33
- * Every parcel the framework mounts is given these, so that a lifecycle which never settles fails
34
- * rather than leaving a parcel that can never be unmounted.
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 lifecycleTimeouts = {
37
- bootstrap: { millis: 15_000, dieOnTimeout: true },
38
- mount: { millis: 15_000, dieOnTimeout: true },
39
- unmount: { millis: 15_000, dieOnTimeout: true },
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 [{ timeouts: unknown }];
284
- const [functionForm] = hostMountParcel.mock.calls[1] as [() => Promise<{ timeouts: unknown }>];
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(objectForm.timeouts).toEqual(lifecycleTimeouts);
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({ ...lifecycles, timeouts: lifecycleTimeouts }, { domElement });
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
- * Mark parcels as dead if they do not succeed within 15s. Note that loading and unloading is
26
- * skipped as that has an unpredictable timing. Failing here allows us to clean-up correctly
27
- * if the parcel doesn't fully mount.
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 lifecycleTimeouts = {
30
- bootstrap: { millis: 15_000, dieOnTimeout: true },
31
- mount: { millis: 15_000, dieOnTimeout: true },
32
- unmount: { millis: 15_000, dieOnTimeout: true },
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
- * Applies {@link lifecycleTimeouts} to a parcel config, resolving the function form first so that a
37
- * lazily loaded config gets them too.
38
- *
39
- * The cast is needed because single-spa reads `timeouts` off a parcel config but doesn't declare it
40
- * on `ParcelConfigObject`.
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 withLifecycleTimeouts(parcelConfig: ParcelConfig): ParcelConfig {
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((resolved) => ({ timeouts: lifecycleTimeouts, ...resolved }))) as ParcelConfig;
122
+ return (() => parcelConfig().then(boundLifecycles)) as ParcelConfig;
46
123
  }
47
124
 
48
- return { timeouts: lifecycleTimeouts, ...parcelConfig } as ParcelConfig;
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(withLifecycleTimeouts(parcelConfig), customProps);
185
+ return mountParcel(withLifecycleDeadlines(parcelConfig), customProps);
109
186
  }
110
187
 
111
188
  /**