@openmrs/esm-extensions 10.0.1-pre.5393 → 10.0.1-pre.5396
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 +109 -8
- package/package.json +13 -13
- package/src/render.single-spa.test.ts +124 -0
- package/src/render.test.ts +179 -3
- package/src/render.ts +141 -7
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;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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "10.0.1-pre.5396",
|
|
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.5396",
|
|
61
|
+
"@openmrs/esm-config": "^10.0.1-pre.5396",
|
|
62
|
+
"@openmrs/esm-expression-evaluator": "^10.0.1-pre.5396",
|
|
63
|
+
"@openmrs/esm-feature-flags": "^10.0.1-pre.5396",
|
|
64
|
+
"@openmrs/esm-state": "^10.0.1-pre.5396",
|
|
65
|
+
"@openmrs/esm-utils": "^10.0.1-pre.5396",
|
|
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.5396",
|
|
70
|
+
"@openmrs/esm-config": "10.0.1-pre.5396",
|
|
71
|
+
"@openmrs/esm-expression-evaluator": "10.0.1-pre.5396",
|
|
72
|
+
"@openmrs/esm-feature-flags": "10.0.1-pre.5396",
|
|
73
|
+
"@openmrs/esm-state": "10.0.1-pre.5396",
|
|
74
|
+
"@openmrs/esm-utils": "10.0.1-pre.5396",
|
|
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
|
|
package/src/render.test.ts
CHANGED
|
@@ -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
|
-
|
|
398
|
-
expect(
|
|
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])
|
|
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
|
-
|
|
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
|
/**
|