@openmrs/esm-extensions 10.0.1-pre.5393 → 10.0.1-pre.5420

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 (135.83ms)
1
+ [0] Successfully compiled: 11 files with swc (153.69ms)
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;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"}
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;AAyR3C;;;;;;;;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,CAQlC;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
@@ -32,11 +32,100 @@ let parcelMounter = null;
32
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
33
  }), Promise.resolve(undefined));
34
34
  }
35
+ /**
36
+ * Empties the props object single-spa is holding for a parcel.
37
+ *
38
+ * If a parcel's `unmount()` rejects, single-spa retains it in the `SKIP_BECAUSE_BROKEN` state,
39
+ * including holding on to the props, which can include the DOM node if it was successfully
40
+ * mounted. Since we hand single-spa the props, we can clear that object here which helps reduce
41
+ * the memory pressure of the retained parcels
42
+ */ function releaseProps(props) {
43
+ for (const key of Object.keys(props)){
44
+ delete props[key];
45
+ }
46
+ }
47
+ /**
48
+ * Watches a parcel's lifecycles and releases its props once single-spa has no further use for them,
49
+ * which is only ever after a failure: a parcel that unmounts cleanly is dropped by single-spa
50
+ * itself, taking its props with it.
51
+ *
52
+ * The timing matters, because single-spa keeps calling lifecycles past the failure that broke the
53
+ * parcel and they are handed these same props.
54
+ *
55
+ * Which object gets released moves over the parcel's life, since `update()` replaces the props
56
+ * single-spa holds rather than merging into them. See {@link trackUpdates}.
57
+ *
58
+ * @param props The object single-spa was mounted with, which must not be the caller's own
59
+ */ function trackProps(props) {
60
+ let tracked = props;
61
+ let mountFailed = false;
62
+ const release = ()=>releaseProps(tracked);
63
+ return {
64
+ retarget (next) {
65
+ tracked = {
66
+ ...next
67
+ };
68
+ return tracked;
69
+ },
70
+ release,
71
+ onSettled (which, failed) {
72
+ switch(which){
73
+ case 'bootstrap':
74
+ // single-spa breaks the parcel there and then, without calling anything else.
75
+ if (failed) {
76
+ release();
77
+ }
78
+ return;
79
+ case 'mount':
80
+ // A parcel whose mount fails is unmounted before it is broken, so that the extension gets
81
+ // to tear down whatever it managed to render. The release waits for that unmount.
82
+ mountFailed || (mountFailed = failed);
83
+ return;
84
+ case 'unmount':
85
+ if (failed || mountFailed) {
86
+ release();
87
+ }
88
+ return;
89
+ }
90
+ }
91
+ };
92
+ }
93
+ /**
94
+ * Points a parcel's `update()` at a copy of the props it is given, which {@link trackProps} then
95
+ * follows in place of the ones the parcel was mounted with.
96
+ *
97
+ * single-spa's `update()` assigns the props it is given over the ones it holds, so without this a
98
+ * later failure would empty the mount-time copy while single-spa kept the updated one — which, for
99
+ * the workspaces' `<Parcel>`, carries the `domElement` and everything rendered into it.
100
+ *
101
+ * single-spa attaches `update()` to the parcel only once its config has loaded, and only for a
102
+ * config that has an update lifecycle, so this intercepts the assignment rather than the method:
103
+ * callers such as `<Extension>` read `update` to decide whether the parcel can be updated at all.
104
+ */ function trackUpdates(parcel, tracker) {
105
+ const wrap = (update)=>(customProps)=>update.call(parcel, tracker.retarget(customProps)).catch((err)=>{
106
+ if (parcel.getStatus() === 'SKIP_BECAUSE_BROKEN') {
107
+ tracker.release();
108
+ }
109
+ throw err;
110
+ });
111
+ let tracked = parcel.update && wrap(parcel.update);
112
+ return Object.defineProperty(parcel, 'update', {
113
+ configurable: true,
114
+ enumerable: true,
115
+ get: ()=>tracked,
116
+ set: (update)=>{
117
+ tracked = update && wrap(update);
118
+ }
119
+ });
120
+ }
35
121
  /**
36
122
  * Wraps a lifecycle so that it rejects once `millis` have elapsed, clearing the timer as soon as it
37
123
  * settles either way. Rejecting puts the parcel into the same broken state single-spa's own
38
124
  * `dieOnTimeout` would, but without leaving a timer holding the parcel for the full deadline.
39
- */ function withDeadline(lifecycle, millis, name, which) {
125
+ *
126
+ * Reporting each outcome to `onSettled` from here rather than from the parcel's own promises leaves
127
+ * a rejection no caller handles free to reach the global unhandled rejection handler.
128
+ */ function withDeadline(lifecycle, millis, name, which, onSettled) {
40
129
  const run = toSingleFn(lifecycle, name, which);
41
130
  return (props)=>{
42
131
  let timer;
@@ -46,10 +135,16 @@ let parcelMounter = null;
46
135
  return Promise.race([
47
136
  run(props),
48
137
  deadline
49
- ]).finally(()=>clearTimeout(timer));
138
+ ]).finally(()=>clearTimeout(timer)).then((value)=>{
139
+ onSettled(which, false);
140
+ return value;
141
+ }, (err)=>{
142
+ onSettled(which, true);
143
+ throw err;
144
+ });
50
145
  };
51
146
  }
52
- /** Applies {@link lifecycleDeadlines} to a resolved parcel config. */ function boundLifecycles(parcelConfig) {
147
+ /** Applies {@link lifecycleDeadlines} to a resolved parcel config. */ function boundLifecycles(parcelConfig, onSettled) {
53
148
  // A parcel that declares its own timeouts is bounding itself, so it is left to single-spa.
54
149
  if (parcelConfig.timeouts) {
55
150
  return parcelConfig;
@@ -57,7 +152,7 @@ let parcelMounter = null;
57
152
  const name = parcelConfig.name ?? 'parcel';
58
153
  const bounded = Object.fromEntries(Object.keys(lifecycleDeadlines).filter((which)=>parcelConfig[which]).map((which)=>[
59
154
  which,
60
- withDeadline(parcelConfig[which], lifecycleDeadlines[which], name, which)
155
+ withDeadline(parcelConfig[which], lifecycleDeadlines[which], name, which, onSettled)
61
156
  ]));
62
157
  return {
63
158
  ...parcelConfig,
@@ -67,11 +162,11 @@ let parcelMounter = null;
67
162
  /**
68
163
  * Applies {@link boundLifecycles} to a parcel config, resolving the function form first so that a
69
164
  * lazily loaded config gets the deadlines too.
70
- */ function withLifecycleDeadlines(parcelConfig) {
165
+ */ function withLifecycleDeadlines(parcelConfig, onSettled) {
71
166
  if (typeof parcelConfig === 'function') {
72
- return ()=>parcelConfig().then(boundLifecycles);
167
+ return ()=>parcelConfig().then((resolved)=>boundLifecycles(resolved, onSettled));
73
168
  }
74
- return boundLifecycles(parcelConfig);
169
+ return boundLifecycles(parcelConfig, onSettled);
75
170
  }
76
171
  /**
77
172
  * Resolves the function used to mount extensions, which is the `mountParcel()` of a long-lived
@@ -117,7 +212,13 @@ let parcelMounter = null;
117
212
  * @returns The parcel handle; mounting completes with its `mountPromise`
118
213
  */ export async function renderParcel(parcelConfig, customProps) {
119
214
  const mountParcel = await getParcelMounter();
120
- return mountParcel(withLifecycleDeadlines(parcelConfig), customProps);
215
+ // Copied because single-spa holds onto this object for as long as it holds the parcel, and
216
+ // {@link releaseProps} empties it; the caller's own object is unchanged.
217
+ const props = {
218
+ ...customProps
219
+ };
220
+ const tracker = trackProps(props);
221
+ return trackUpdates(mountParcel(withLifecycleDeadlines(parcelConfig, tracker.onSettled), props), tracker);
121
222
  }
122
223
  /**
123
224
  * 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.5393",
3
+ "version": "10.0.1-pre.5420",
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.5393",
61
- "@openmrs/esm-config": "^10.0.1-pre.5393",
62
- "@openmrs/esm-expression-evaluator": "^10.0.1-pre.5393",
63
- "@openmrs/esm-feature-flags": "^10.0.1-pre.5393",
64
- "@openmrs/esm-state": "^10.0.1-pre.5393",
65
- "@openmrs/esm-utils": "^10.0.1-pre.5393",
60
+ "@openmrs/esm-api": "^10.0.1-pre.5420",
61
+ "@openmrs/esm-config": "^10.0.1-pre.5420",
62
+ "@openmrs/esm-expression-evaluator": "^10.0.1-pre.5420",
63
+ "@openmrs/esm-feature-flags": "^10.0.1-pre.5420",
64
+ "@openmrs/esm-state": "^10.0.1-pre.5420",
65
+ "@openmrs/esm-utils": "^10.0.1-pre.5420",
66
66
  "single-spa": "6.x"
67
67
  },
68
68
  "devDependencies": {
69
- "@openmrs/esm-api": "10.0.1-pre.5393",
70
- "@openmrs/esm-config": "10.0.1-pre.5393",
71
- "@openmrs/esm-expression-evaluator": "10.0.1-pre.5393",
72
- "@openmrs/esm-feature-flags": "10.0.1-pre.5393",
73
- "@openmrs/esm-state": "10.0.1-pre.5393",
74
- "@openmrs/esm-utils": "10.0.1-pre.5393",
69
+ "@openmrs/esm-api": "10.0.1-pre.5420",
70
+ "@openmrs/esm-config": "10.0.1-pre.5420",
71
+ "@openmrs/esm-expression-evaluator": "10.0.1-pre.5420",
72
+ "@openmrs/esm-feature-flags": "10.0.1-pre.5420",
73
+ "@openmrs/esm-state": "10.0.1-pre.5420",
74
+ "@openmrs/esm-utils": "10.0.1-pre.5420",
75
75
  "@swc/cli": "0.8.1",
76
76
  "@swc/core": "1.15.21",
77
77
  "@vitest/coverage-v8": "^4.1.2",
@@ -17,6 +17,11 @@ function goodLifecycles(): LifeCycles {
17
17
  };
18
18
  }
19
19
 
20
+ /** The `domElement` a lifecycle was handed, which is whichever props single-spa held at the time. */
21
+ function elementOf(props: unknown) {
22
+ return (props as { domElement?: HTMLElement }).domElement;
23
+ }
24
+
20
25
  afterEach(() => {
21
26
  vi.restoreAllMocks();
22
27
  });
@@ -28,10 +33,40 @@ describe('renderParcel against the real single-spa', () => {
28
33
  await parcel.mountPromise;
29
34
  expect(parcel.getStatus()).toBe('MOUNTED');
30
35
 
36
+ // single-spa leaves `update` off a parcel whose config has no update lifecycle, and `<Extension>`
37
+ // reads it to decide whether the parcel can be updated at all.
38
+ expect(parcel.update).toBeUndefined();
39
+
31
40
  await parcel.unmount();
32
41
  expect(parcel.getStatus()).toBe('NOT_MOUNTED');
33
42
  });
34
43
 
44
+ it('leaves the props intact for the unmount single-spa runs after a failed mount', async () => {
45
+ const domElement = document.createElement('div');
46
+ const callerProps = { domElement };
47
+ const unmountedWith: Array<unknown> = [];
48
+
49
+ const parcel = await renderParcel(
50
+ {
51
+ ...goodLifecycles(),
52
+ mount: () => Promise.reject(new Error('mount failed')),
53
+ unmount: (props) => {
54
+ unmountedWith.push((props as { domElement?: HTMLElement }).domElement);
55
+ return Promise.resolve();
56
+ },
57
+ },
58
+ callerProps,
59
+ );
60
+
61
+ await expect(parcel.mountPromise).rejects.toThrow(/mount failed/);
62
+
63
+ // single-spa unmounts a parcel whose mount failed so the extension can tear down whatever it
64
+ // rendered, so the props it retains cannot be released until that unmount has had them.
65
+ expect(unmountedWith).toEqual([domElement]);
66
+ expect(parcel.getStatus()).toBe('SKIP_BECAUSE_BROKEN');
67
+ expect(callerProps).toEqual({ domElement });
68
+ });
69
+
35
70
  it('breaks a parcel whose mount does not return a promise', async () => {
36
71
  // single-spa would fail this parcel on its own; the deadline wrapper has to not paper over it.
37
72
  const lifecycles = { ...goodLifecycles(), mount: (() => undefined) as never };
@@ -58,6 +93,95 @@ describe('renderParcel against the real single-spa', () => {
58
93
  expect(parcel.getStatus()).toBe('SKIP_BECAUSE_BROKEN');
59
94
  });
60
95
 
96
+ it('hands the props of the latest update to the unmount that breaks the parcel', async () => {
97
+ const mounted = document.createElement('div');
98
+ const updated = document.createElement('div');
99
+ const callerProps = { domElement: updated, someProp: 'updated' };
100
+ const unmountedWith: Array<HTMLElement | undefined> = [];
101
+
102
+ const parcel = await renderParcel(
103
+ {
104
+ ...goodLifecycles(),
105
+ update: () => Promise.resolve(),
106
+ unmount: (props) => {
107
+ unmountedWith.push(elementOf(props));
108
+ return Promise.reject(new Error('unmount failed'));
109
+ },
110
+ },
111
+ { domElement: mounted },
112
+ );
113
+
114
+ await parcel.mountPromise;
115
+ await parcel.update?.(callerProps);
116
+
117
+ // single-spa rejects `unmountPromise` separately from the call, so both are asserted against.
118
+ const brokeUnmount = expect(parcel.unmountPromise).rejects.toThrow(/unmount failed/);
119
+ await expect(parcel.unmount()).rejects.toThrow(/unmount failed/);
120
+ await brokeUnmount;
121
+
122
+ // single-spa assigns the props of an update over the ones it holds rather than merging into
123
+ // them, so from here on it is the updated copy that a failure has to empty, and the mount-time
124
+ // copy that nothing is holding.
125
+ expect(unmountedWith).toEqual([updated]);
126
+ expect(parcel.getStatus()).toBe('SKIP_BECAUSE_BROKEN');
127
+ expect(callerProps).toEqual({ domElement: updated, someProp: 'updated' });
128
+ });
129
+
130
+ it('breaks a parcel whose update lifecycle fails', async () => {
131
+ const domElement = document.createElement('div');
132
+ const updatedWith: Array<HTMLElement | undefined> = [];
133
+
134
+ const parcel = await renderParcel(
135
+ {
136
+ ...goodLifecycles(),
137
+ update: (props) => {
138
+ updatedWith.push(elementOf(props));
139
+ return Promise.reject(new Error('update failed'));
140
+ },
141
+ },
142
+ { domElement },
143
+ );
144
+
145
+ await parcel.mountPromise;
146
+ await expect(parcel.update?.({ domElement })).rejects.toThrow(/update failed/);
147
+
148
+ // Nothing follows this: single-spa neither unmounts the parcel nor runs another lifecycle, so
149
+ // the props it is left holding are the ones the failed update swapped in.
150
+ expect(updatedWith).toEqual([domElement]);
151
+ expect(parcel.getStatus()).toBe('SKIP_BECAUSE_BROKEN');
152
+ });
153
+
154
+ it('keeps the props of an update rejected on an unmounted parcel for its next mount', async () => {
155
+ const mounted = document.createElement('div');
156
+ const updated = document.createElement('div');
157
+ const mountedWith: Array<HTMLElement | undefined> = [];
158
+
159
+ const parcel = await renderParcel(
160
+ {
161
+ ...goodLifecycles(),
162
+ update: () => Promise.resolve(),
163
+ mount: (props) => {
164
+ mountedWith.push(elementOf(props));
165
+ return Promise.resolve();
166
+ },
167
+ },
168
+ { domElement: mounted },
169
+ );
170
+
171
+ await parcel.mountPromise;
172
+ await parcel.unmount();
173
+
174
+ // single-spa swaps the props in before it checks the status, so this rejection still leaves it
175
+ // holding them — and a parcel that is merely unmounted can be mounted again.
176
+ await expect(parcel.update?.({ domElement: updated })).rejects.toThrow(/not mounted/);
177
+ expect(parcel.getStatus()).toBe('NOT_MOUNTED');
178
+
179
+ await parcel.mount();
180
+
181
+ expect(parcel.getStatus()).toBe('MOUNTED');
182
+ expect(mountedWith).toEqual([mounted, updated]);
183
+ });
184
+
61
185
  it('breaks a parcel whose mount overruns its deadline', async () => {
62
186
  vi.useFakeTimers();
63
187
 
@@ -56,6 +56,16 @@ function fakeParcel({ hasUpdate = true } = {}): Parcel {
56
56
  } as unknown as Parcel;
57
57
  }
58
58
 
59
+ /**
60
+ * A fake parcel paired with its own `update` mock. `renderParcel()` replaces `update` on the parcel
61
+ * it is given, so this is the only way left to see the props single-spa was actually handed.
62
+ */
63
+ function fakeParcelWithUpdate() {
64
+ const update = vi.fn((_: Record<string, unknown>) => Promise.resolve());
65
+
66
+ return { parcel: { ...fakeParcel(), update } as unknown as Parcel, update };
67
+ }
68
+
59
69
  /**
60
70
  * Loads a fresh copy of the module under test, which caches the host parcel's mounter in module
61
71
  * scope, wired to a fake single-spa whose host parcel either mounts or fails to mount.
@@ -357,6 +367,169 @@ describe('renderParcel', () => {
357
367
  );
358
368
  });
359
369
 
370
+ it('holds the props single-spa retains until the unmount that follows a failed mount', async () => {
371
+ const { hostMountParcel, renderParcel } = await loadRenderModule();
372
+ const domElement = document.createElement('div');
373
+ const callerProps = { domElement, someProp: 'value' };
374
+ const failure = new Error('mount failed');
375
+
376
+ await renderParcel({ ...lifecycles, mount: () => Promise.reject(failure) }, callerProps);
377
+
378
+ const [bounded, retained] = hostMountParcel.mock.calls[0] as [typeof lifecycles, Record<string, unknown>];
379
+
380
+ await expect(bounded.mount({ domElement })).rejects.toBe(failure);
381
+
382
+ // single-spa unmounts a parcel whose mount failed before breaking it, and hands that unmount
383
+ // these props, so releasing them any earlier would deny the extension its own cleanup.
384
+ expect(retained).toEqual({ domElement, someProp: 'value' });
385
+
386
+ await bounded.unmount({ domElement });
387
+
388
+ // single-spa keeps a broken parcel, and with it these props, for the lifetime of the page, so
389
+ // emptying them is the only way to stop it retaining the element and everything rendered into it.
390
+ expect(retained).toEqual({});
391
+ expect(callerProps).toEqual({ domElement, someProp: 'value' });
392
+ });
393
+
394
+ it('empties the retained props as soon as bootstrap fails', async () => {
395
+ const { hostMountParcel, renderParcel } = await loadRenderModule();
396
+ const domElement = document.createElement('div');
397
+ const failure = new Error('bootstrap failed');
398
+
399
+ await renderParcel({ ...lifecycles, bootstrap: () => Promise.reject(failure) }, { domElement });
400
+
401
+ const [bounded, retained] = hostMountParcel.mock.calls[0] as [typeof lifecycles, Record<string, unknown>];
402
+
403
+ // Nothing follows a failed bootstrap, so there is no later lifecycle to hold the props for.
404
+ await expect(bounded.bootstrap({ domElement })).rejects.toBe(failure);
405
+
406
+ expect(retained).toEqual({});
407
+ });
408
+
409
+ it('empties the retained props when unmount fails', async () => {
410
+ const { hostMountParcel, renderParcel } = await loadRenderModule();
411
+ const domElement = document.createElement('div');
412
+ const failure = new Error('unmount failed');
413
+
414
+ await renderParcel({ ...lifecycles, unmount: () => Promise.reject(failure) }, { domElement });
415
+
416
+ const [bounded, retained] = hostMountParcel.mock.calls[0] as [typeof lifecycles, Record<string, unknown>];
417
+
418
+ await bounded.mount({ domElement });
419
+ await expect(bounded.unmount({ domElement })).rejects.toBe(failure);
420
+
421
+ expect(retained).toEqual({});
422
+ });
423
+
424
+ it('empties the retained props when a lifecycle overruns its deadline', async () => {
425
+ vi.useFakeTimers();
426
+
427
+ try {
428
+ const { hostMountParcel, renderParcel } = await loadRenderModule();
429
+ const domElement = document.createElement('div');
430
+
431
+ await renderParcel({ ...lifecycles, unmount: () => new Promise(() => {}) }, { domElement });
432
+
433
+ const [hung, retained] = hostMountParcel.mock.calls[0] as [typeof lifecycles, Record<string, unknown>];
434
+
435
+ await hung.mount({ domElement });
436
+
437
+ // Asserted against before the clock is advanced, so the rejection is never unhandled.
438
+ const overran = expect(hung.unmount({ domElement })).rejects.toThrow(/did not settle within 15000ms/);
439
+ await vi.advanceTimersByTimeAsync(15_000);
440
+ await overran;
441
+
442
+ expect(retained).toEqual({});
443
+ } finally {
444
+ vi.useRealTimers();
445
+ }
446
+ });
447
+
448
+ it('leaves the retained props alone while the parcel is healthy', async () => {
449
+ const { hostMountParcel, renderParcel } = await loadRenderModule();
450
+ const domElement = document.createElement('div');
451
+
452
+ await renderParcel(lifecycles, { domElement });
453
+
454
+ const [bounded, retained] = hostMountParcel.mock.calls[0] as [typeof lifecycles, Record<string, unknown>];
455
+
456
+ await bounded.mount({ domElement });
457
+ await bounded.unmount({ domElement });
458
+
459
+ expect(retained).toEqual({ domElement });
460
+ });
461
+
462
+ it('tracks a copy of the props update() replaces the retained ones with', async () => {
463
+ const { hostMountParcel, renderParcel } = await loadRenderModule();
464
+ const domElement = document.createElement('div');
465
+ const updatedElement = document.createElement('div');
466
+ const callerProps = { domElement: updatedElement, someProp: 'updated' };
467
+ const failure = new Error('unmount failed');
468
+
469
+ const { parcel: hosted, update } = fakeParcelWithUpdate();
470
+
471
+ hostMountParcel.mockImplementationOnce(() => hosted);
472
+
473
+ const parcel = await renderParcel({ ...lifecycles, unmount: () => Promise.reject(failure) }, { domElement });
474
+ const [bounded] = hostMountParcel.mock.calls[0] as [typeof lifecycles, Record<string, unknown>];
475
+
476
+ await parcel.update?.(callerProps);
477
+
478
+ // single-spa assigns these over the props it holds rather than merging into them, so it is this
479
+ // copy — the one carrying the updated `domElement` — that a failure has to empty.
480
+ const [retained] = update.mock.calls[0];
481
+ expect(retained).toEqual(callerProps);
482
+ expect(retained).not.toBe(callerProps);
483
+
484
+ await bounded.mount({ domElement });
485
+ await expect(bounded.unmount({ domElement })).rejects.toBe(failure);
486
+
487
+ expect(retained).toEqual({});
488
+ expect(callerProps).toEqual({ domElement: updatedElement, someProp: 'updated' });
489
+ });
490
+
491
+ it('empties the props of an update that leaves the parcel broken', async () => {
492
+ const { hostMountParcel, renderParcel } = await loadRenderModule();
493
+ const domElement = document.createElement('div');
494
+ const failure = new Error('update failed');
495
+
496
+ const { parcel: hosted, update } = fakeParcelWithUpdate();
497
+
498
+ hostMountParcel.mockImplementationOnce(() => hosted);
499
+
500
+ const parcel = await renderParcel(lifecycles, { domElement });
501
+
502
+ update.mockImplementationOnce(() => Promise.reject(failure));
503
+ vi.spyOn(hosted, 'getStatus').mockReturnValue('SKIP_BECAUSE_BROKEN');
504
+
505
+ await expect(parcel.update?.({ domElement })).rejects.toBe(failure);
506
+
507
+ // A broken parcel never runs another lifecycle, so nothing later would release these.
508
+ const [retained] = update.mock.calls[0];
509
+ expect(retained).toEqual({});
510
+ });
511
+
512
+ it('keeps the props of an update rejected because the parcel is not mounted', async () => {
513
+ const { hostMountParcel, renderParcel } = await loadRenderModule();
514
+ const domElement = document.createElement('div');
515
+ const failure = new Error('Cannot update parcel because it is not mounted');
516
+
517
+ const { parcel: hosted, update } = fakeParcelWithUpdate();
518
+
519
+ hostMountParcel.mockImplementationOnce(() => hosted);
520
+
521
+ const parcel = await renderParcel(lifecycles, { domElement });
522
+
523
+ update.mockImplementationOnce(() => Promise.reject(failure));
524
+ vi.spyOn(hosted, 'getStatus').mockReturnValue('NOT_MOUNTED');
525
+
526
+ await expect(parcel.update?.({ domElement, someProp: 'value' })).rejects.toBe(failure);
527
+
528
+ // These are the props a remount would use, and an unmounted parcel can still be mounted again.
529
+ const [retained] = update.mock.calls[0];
530
+ expect(retained).toEqual({ domElement, someProp: 'value' });
531
+ });
532
+
360
533
  it('leaves a parcel that declares its own timeouts to single-spa', async () => {
361
534
  const { hostMountParcel, renderParcel } = await loadRenderModule();
362
535
  const domElement = document.createElement('div');
@@ -389,14 +562,17 @@ describe('createParcelMounter', () => {
389
562
  const { hostMountParcel, createParcelMounter } = await loadRenderModule();
390
563
  const domElement = document.createElement('div');
391
564
 
565
+ const { parcel: hosted, update } = fakeParcelWithUpdate();
566
+
567
+ hostMountParcel.mockImplementationOnce(() => hosted);
568
+
392
569
  const parcel = createParcelMounter()(lifecycles, { domElement });
393
570
  await parcel.mountPromise;
394
571
  await parcel.update?.({ domElement, someProp: 'value' });
395
572
  await parcel.unmount();
396
573
 
397
- const realParcel = hostMountParcel.mock.results[0].value;
398
- expect(realParcel.update).toHaveBeenCalledWith({ domElement, someProp: 'value' });
399
- expect(realParcel.unmount).toHaveBeenCalledTimes(1);
574
+ expect(update).toHaveBeenCalledWith({ domElement, someProp: 'value' });
575
+ expect(hosted.unmount).toHaveBeenCalledTimes(1);
400
576
  await expect(parcel.unmountPromise).resolves.toBeUndefined();
401
577
  });
402
578
 
package/src/render.ts CHANGED
@@ -25,6 +25,19 @@ type ParcelConfigObject = Extract<ParcelConfig, LifeCycles>;
25
25
  type LifecycleFn = Exclude<LifeCycles['mount'], readonly unknown[]>;
26
26
  type LifecycleName = keyof typeof lifecycleDeadlines;
27
27
 
28
+ /** Reports how one of a parcel's lifecycles settled. */
29
+ type LifecycleSettled = (which: LifecycleName, failed: boolean) => void;
30
+
31
+ /** Follows the props object single-spa holds for a parcel, so it can be emptied on failure. */
32
+ interface PropsTracker {
33
+ /** Reports how one of the parcel's lifecycles settled. */
34
+ onSettled: LifecycleSettled;
35
+ /** Tracks a copy of `next` in place of the props tracked so far, and returns it to pass on. */
36
+ retarget(next: Record<string, unknown>): Record<string, unknown>;
37
+ /** Empties the tracked props. */
38
+ release(): void;
39
+ }
40
+
28
41
  /**
29
42
  * How long each lifecycle gets before the parcel is marked dead. Loading and unloading are left
30
43
  * out, as their timing is unpredictable. These are meant to be generous.
@@ -70,16 +83,121 @@ function toSingleFn(lifecycle: LifecycleFn | Array<LifecycleFn>, name: string, w
70
83
  );
71
84
  }
72
85
 
86
+ /**
87
+ * Empties the props object single-spa is holding for a parcel.
88
+ *
89
+ * If a parcel's `unmount()` rejects, single-spa retains it in the `SKIP_BECAUSE_BROKEN` state,
90
+ * including holding on to the props, which can include the DOM node if it was successfully
91
+ * mounted. Since we hand single-spa the props, we can clear that object here which helps reduce
92
+ * the memory pressure of the retained parcels
93
+ */
94
+ function releaseProps(props: Record<string, unknown>) {
95
+ for (const key of Object.keys(props)) {
96
+ delete props[key];
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Watches a parcel's lifecycles and releases its props once single-spa has no further use for them,
102
+ * which is only ever after a failure: a parcel that unmounts cleanly is dropped by single-spa
103
+ * itself, taking its props with it.
104
+ *
105
+ * The timing matters, because single-spa keeps calling lifecycles past the failure that broke the
106
+ * parcel and they are handed these same props.
107
+ *
108
+ * Which object gets released moves over the parcel's life, since `update()` replaces the props
109
+ * single-spa holds rather than merging into them. See {@link trackUpdates}.
110
+ *
111
+ * @param props The object single-spa was mounted with, which must not be the caller's own
112
+ */
113
+ function trackProps(props: Record<string, unknown>): PropsTracker {
114
+ let tracked = props;
115
+ let mountFailed = false;
116
+ const release = () => releaseProps(tracked);
117
+
118
+ return {
119
+ retarget(next) {
120
+ tracked = { ...next };
121
+
122
+ return tracked;
123
+ },
124
+ release,
125
+ onSettled(which, failed) {
126
+ switch (which) {
127
+ case 'bootstrap':
128
+ // single-spa breaks the parcel there and then, without calling anything else.
129
+ if (failed) {
130
+ release();
131
+ }
132
+
133
+ return;
134
+ case 'mount':
135
+ // A parcel whose mount fails is unmounted before it is broken, so that the extension gets
136
+ // to tear down whatever it managed to render. The release waits for that unmount.
137
+ mountFailed ||= failed;
138
+
139
+ return;
140
+ case 'unmount':
141
+ if (failed || mountFailed) {
142
+ release();
143
+ }
144
+
145
+ return;
146
+ }
147
+ },
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Points a parcel's `update()` at a copy of the props it is given, which {@link trackProps} then
153
+ * follows in place of the ones the parcel was mounted with.
154
+ *
155
+ * single-spa's `update()` assigns the props it is given over the ones it holds, so without this a
156
+ * later failure would empty the mount-time copy while single-spa kept the updated one — which, for
157
+ * the workspaces' `<Parcel>`, carries the `domElement` and everything rendered into it.
158
+ *
159
+ * single-spa attaches `update()` to the parcel only once its config has loaded, and only for a
160
+ * config that has an update lifecycle, so this intercepts the assignment rather than the method:
161
+ * callers such as `<Extension>` read `update` to decide whether the parcel can be updated at all.
162
+ */
163
+ function trackUpdates(parcel: Parcel, tracker: PropsTracker): Parcel {
164
+ const wrap =
165
+ (update: NonNullable<Parcel['update']>): Parcel['update'] =>
166
+ (customProps) =>
167
+ update.call(parcel, tracker.retarget(customProps)).catch((err: unknown) => {
168
+ if (parcel.getStatus() === 'SKIP_BECAUSE_BROKEN') {
169
+ tracker.release();
170
+ }
171
+
172
+ throw err;
173
+ });
174
+
175
+ let tracked = parcel.update && wrap(parcel.update);
176
+
177
+ return Object.defineProperty(parcel, 'update', {
178
+ configurable: true,
179
+ enumerable: true,
180
+ get: () => tracked,
181
+ set: (update: Parcel['update']) => {
182
+ tracked = update && wrap(update);
183
+ },
184
+ });
185
+ }
186
+
73
187
  /**
74
188
  * Wraps a lifecycle so that it rejects once `millis` have elapsed, clearing the timer as soon as it
75
189
  * settles either way. Rejecting puts the parcel into the same broken state single-spa's own
76
190
  * `dieOnTimeout` would, but without leaving a timer holding the parcel for the full deadline.
191
+ *
192
+ * Reporting each outcome to `onSettled` from here rather than from the parcel's own promises leaves
193
+ * a rejection no caller handles free to reach the global unhandled rejection handler.
77
194
  */
78
195
  function withDeadline(
79
196
  lifecycle: LifecycleFn | Array<LifecycleFn>,
80
197
  millis: number,
81
198
  name: string,
82
199
  which: LifecycleName,
200
+ onSettled: LifecycleSettled,
83
201
  ): LifecycleFn {
84
202
  const run = toSingleFn(lifecycle, name, which);
85
203
 
@@ -92,12 +210,23 @@ function withDeadline(
92
210
  );
93
211
  });
94
212
 
95
- return Promise.race([run(props), deadline]).finally(() => clearTimeout(timer));
213
+ return Promise.race([run(props), deadline])
214
+ .finally(() => clearTimeout(timer))
215
+ .then(
216
+ (value) => {
217
+ onSettled(which, false);
218
+ return value;
219
+ },
220
+ (err) => {
221
+ onSettled(which, true);
222
+ throw err;
223
+ },
224
+ );
96
225
  };
97
226
  }
98
227
 
99
228
  /** Applies {@link lifecycleDeadlines} to a resolved parcel config. */
100
- function boundLifecycles(parcelConfig: ParcelConfigObject): ParcelConfigObject {
229
+ function boundLifecycles(parcelConfig: ParcelConfigObject, onSettled: LifecycleSettled): ParcelConfigObject {
101
230
  // A parcel that declares its own timeouts is bounding itself, so it is left to single-spa.
102
231
  if ((parcelConfig as { timeouts?: unknown }).timeouts) {
103
232
  return parcelConfig;
@@ -107,7 +236,7 @@ function boundLifecycles(parcelConfig: ParcelConfigObject): ParcelConfigObject {
107
236
  const bounded = Object.fromEntries(
108
237
  (Object.keys(lifecycleDeadlines) as Array<LifecycleName>)
109
238
  .filter((which) => parcelConfig[which])
110
- .map((which) => [which, withDeadline(parcelConfig[which], lifecycleDeadlines[which], name, which)]),
239
+ .map((which) => [which, withDeadline(parcelConfig[which], lifecycleDeadlines[which], name, which, onSettled)]),
111
240
  );
112
241
 
113
242
  return { ...parcelConfig, ...bounded };
@@ -117,12 +246,12 @@ function boundLifecycles(parcelConfig: ParcelConfigObject): ParcelConfigObject {
117
246
  * Applies {@link boundLifecycles} to a parcel config, resolving the function form first so that a
118
247
  * lazily loaded config gets the deadlines too.
119
248
  */
120
- function withLifecycleDeadlines(parcelConfig: ParcelConfig): ParcelConfig {
249
+ function withLifecycleDeadlines(parcelConfig: ParcelConfig, onSettled: LifecycleSettled): ParcelConfig {
121
250
  if (typeof parcelConfig === 'function') {
122
- return (() => parcelConfig().then(boundLifecycles)) as ParcelConfig;
251
+ return (() => parcelConfig().then((resolved) => boundLifecycles(resolved, onSettled))) as ParcelConfig;
123
252
  }
124
253
 
125
- return boundLifecycles(parcelConfig);
254
+ return boundLifecycles(parcelConfig, onSettled);
126
255
  }
127
256
 
128
257
  /**
@@ -182,7 +311,12 @@ export async function renderParcel<T = CustomProps>(
182
311
  customProps: ParcelProps & T,
183
312
  ): Promise<ReturnType<MountParcel>> {
184
313
  const mountParcel = await getParcelMounter();
185
- return mountParcel(withLifecycleDeadlines(parcelConfig), customProps);
314
+ // Copied because single-spa holds onto this object for as long as it holds the parcel, and
315
+ // {@link releaseProps} empties it; the caller's own object is unchanged.
316
+ const props = { ...customProps };
317
+ const tracker = trackProps(props);
318
+
319
+ return trackUpdates(mountParcel(withLifecycleDeadlines(parcelConfig, tracker.onSettled), props), tracker);
186
320
  }
187
321
 
188
322
  /**