Skip to content

Commit aa0cf80

Browse files
committed
Preserve mounted hook state on re-suspend with a smaller footprint
Alternative to the approach in #5239: instead of tracking whether the Suspense boundary has committed via lifecycle hooks and threading a preserveHooks flag through detachedClone, discard hooks per component in options._catchError when the suspending vnode never committed (oldVNode has no component). detachedClone then always keeps hook state, runs effect cleanups, and clears effect args so effects re-run on reveal. Also drops effects queued by the aborted render (_pendingEffects and _renderCallbacks), which otherwise made a changed-deps layout effect run twice on reveal. Size: compat +36 B br, hooks +6 B br (vs +70 B / +6 B for #5239).
1 parent be33f7a commit aa0cf80

2 files changed

Lines changed: 78 additions & 15 deletions

File tree

compat/src/suspense.js

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ function initSuspenseHooks() {
1717

1818
while ((vnode = vnode._parent)) {
1919
if ((component = vnode._component) && component._childDidSuspend) {
20+
// A component that suspends before ever committing has no state
21+
// worth keeping; mounted ones keep their hooks while parked.
22+
if (oldVNode && !oldVNode._component)
23+
newVNode._component.__hooks = UNDEFINED;
2024
// Don't call oldCatchError if we found a Suspense
2125
return component._childDidSuspend(error, newVNode);
2226
}
@@ -38,19 +42,22 @@ function initSuspenseHooks() {
3842
};
3943
}
4044

41-
function detachedClone(vnode, detachedParent, parentDom, preserveHooks) {
45+
function detachedClone(vnode, detachedParent, parentDom) {
4246
if (vnode) {
43-
if (vnode._component && vnode._component.__hooks) {
44-
const hooks = vnode._component.__hooks;
47+
const hooks = vnode._component && vnode._component.__hooks;
48+
if (hooks) {
4549
hooks._list.forEach(effect => {
46-
if (!preserveHooks || effect._passive != null) {
47-
const cleanup = effect._cleanup;
50+
// Only effects carry `_passive`; clearing `_args` makes them run
51+
// again when the tree is revealed, memo/ref state stays intact.
52+
if (effect._passive != null) {
53+
if (typeof effect._cleanup == 'function') effect._cleanup();
4854
effect._cleanup = effect._args = UNDEFINED;
49-
if (typeof cleanup == 'function') cleanup();
5055
}
5156
});
52-
if (preserveHooks) hooks._pendingEffects = [];
53-
else vnode._component.__hooks = null;
57+
// Drop effects queued by the aborted render; `options._render` swaps in
58+
// a fresh `_pendingEffects` array before anything is pushed again, so
59+
// sharing one empty array here is safe.
60+
hooks._pendingEffects = vnode._component._renderCallbacks = [];
5461
}
5562

5663
vnode = assign({ constructor: UNDEFINED }, vnode);
@@ -67,7 +74,7 @@ function detachedClone(vnode, detachedParent, parentDom, preserveHooks) {
6774
vnode._children =
6875
vnode._children &&
6976
vnode._children.map(child =>
70-
detachedClone(child, detachedParent, parentDom, preserveHooks)
77+
detachedClone(child, detachedParent, parentDom)
7178
);
7279
}
7380

@@ -191,10 +198,6 @@ function createSuspense() {
191198
Suspense.prototype.componentWillUnmount = function () {
192199
this._suspenders = [];
193200
};
194-
Suspense.prototype.componentDidMount =
195-
Suspense.prototype.componentDidUpdate = function () {
196-
if (!this._pendingSuspensionCount) this._unmounted = false;
197-
};
198201

199202
/**
200203
* @this {import('./internal').SuspenseComponent}
@@ -212,8 +215,7 @@ function createSuspense() {
212215
this._vnode._children[0] = detachedClone(
213216
this._detachOnNextRender,
214217
detachedParent,
215-
(detachedComponent._originalParentDom = detachedComponent._parentDom),
216-
this._unmounted == false
218+
(detachedComponent._originalParentDom = detachedComponent._parentDom)
217219
);
218220
}
219221

compat/test/browser/suspense.test.jsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,67 @@ describe('suspense', () => {
378378
);
379379
});
380380

381+
it('should run a layout effect with changed deps once when revealed', async () => {
382+
let promise = Promise.resolve();
383+
let set;
384+
let first = true;
385+
const setup = vi.fn();
386+
const cleanup = vi.fn();
387+
function App() {
388+
const [n, setN] = useState(0);
389+
set = setN;
390+
useLayoutEffect(() => {
391+
setup(n);
392+
return () => cleanup(n);
393+
}, [n]);
394+
if (n && first) {
395+
first = false;
396+
throw promise;
397+
}
398+
return <p>{n}</p>;
399+
}
400+
401+
render(
402+
<Suspense fallback="loading">
403+
<App />
404+
</Suspense>,
405+
scratch
406+
);
407+
expect(setup).toHaveBeenCalledTimes(1);
408+
409+
set(1);
410+
rerender();
411+
expect(scratch.textContent).to.equal('loading');
412+
expect(cleanup).toHaveBeenCalledTimes(1);
413+
414+
await promise;
415+
await act(() => rerender());
416+
expect(scratch.textContent).to.equal('1');
417+
expect(setup.mock.calls).to.deep.equal([[0], [1]]);
418+
expect(cleanup).toHaveBeenCalledTimes(1);
419+
});
420+
421+
it('should handle a promise thrown from a layout effect', () => {
422+
let threw = false;
423+
function App() {
424+
useLayoutEffect(() => {
425+
if (!threw) {
426+
threw = true;
427+
throw Promise.resolve();
428+
}
429+
});
430+
return <p>x</p>;
431+
}
432+
433+
render(
434+
<Suspense fallback="loading">
435+
<App />
436+
</Suspense>,
437+
scratch
438+
);
439+
rerender();
440+
});
441+
381442
it('should support a call to setState before rendering the fallback', () => {
382443
const LazyComp = ({ name }) => <div>Hello from {name}</div>;
383444

0 commit comments

Comments
 (0)