Skip to content

Commit e584f74

Browse files
committed
Defer passive effect cleanup on unmount
1 parent d598771 commit e584f74

8 files changed

Lines changed: 324 additions & 27 deletions

File tree

compat/src/suspense.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,15 @@ options._catchError = (error, newVNode, oldVNode, errorInfo) => {
2929
};
3030

3131
const oldUnmount = options.unmount;
32-
options.unmount = vnode => {
32+
options.unmount = (vnode, parentVNode) => {
3333
/** @type {import('./internal').Component} */
3434
const component = vnode._component;
3535
if (component) component._unmounted = true;
3636
if (component && component._onResolve) {
3737
component._onResolve();
3838
}
3939

40-
if (oldUnmount) oldUnmount(vnode);
40+
if (oldUnmount) oldUnmount(vnode, parentVNode);
4141
};
4242

4343
function detachedClone(vnode, detachedParent, parentDom) {

hooks/src/index.js

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ let currentHook = 0;
1717
/** @type {Array<import('./internal').Component>} */
1818
let afterPaintEffects = [];
1919

20+
/**
21+
* Passive (useEffect) hook states of unmounted components whose cleanup is
22+
* still pending. Deferred to the after-paint flush to match React, which runs
23+
* passive destroys after the commit has painted. Each state's `_passive` holds
24+
* the surviving component its errors should be routed to.
25+
* @type {Array<import('./internal').EffectHookState>}
26+
*/
27+
let unmountCleanups = [];
28+
2029
// Cast to use internal Options type
2130
const options = /** @type {import('./internal').Options} */ (_options);
2231

@@ -115,16 +124,32 @@ options._commit = (vnode, commitQueue) => {
115124
if (oldCommit) oldCommit(vnode, commitQueue);
116125
};
117126

118-
/** @type {(vnode: import('./internal').VNode) => void} */
119-
options.unmount = vnode => {
120-
if (oldBeforeUnmount) oldBeforeUnmount(vnode);
127+
/** @type {(vnode: import('./internal').VNode, parentVNode?: import('./internal').VNode) => void} */
128+
options.unmount = (vnode, parentVNode) => {
129+
if (oldBeforeUnmount) oldBeforeUnmount(vnode, parentVNode);
121130

122131
const c = vnode._component;
123132
if (c && c.__hooks) {
124-
let hasErrored;
133+
let hasErrored,
134+
errorParent = parentVNode && parentVNode._parent;
135+
// The removed subtree is detached (`_parent` nulled) by flush time, so
136+
// grab the nearest surviving component now; its current vnode can still
137+
// route deferred cleanup errors to a mounted error boundary.
138+
while (errorParent && !errorParent._component) {
139+
errorParent = errorParent._parent;
140+
}
125141
c.__hooks._list.some(s => {
126142
try {
127-
invokeCleanup(s);
143+
// Layout cleanups have to run before the new DOM is in place, so
144+
// they stay in the commit phase (see #1886). Passive cleanups run
145+
// after paint, before any new passive effect (see #4299), with
146+
// `_passive` repurposed to hold the error-routing component.
147+
if (s._passive) {
148+
s._passive = errorParent && errorParent._component;
149+
afterPaint(unmountCleanups.push(s));
150+
} else {
151+
invokeCleanup(s);
152+
}
128153
} catch (e) {
129154
hasErrored = e;
130155
}
@@ -255,6 +280,7 @@ export function useEffect(callback, args) {
255280
/** @type {import('./internal').EffectHookState} */
256281
const state = getHookState(currentIndex++, 3);
257282
if (!options._skipEffects && argsChanged(state._args, args)) {
283+
state._passive = true;
258284
state._value = callback;
259285
state._pendingArgs = args;
260286

@@ -419,18 +445,33 @@ export function useId() {
419445
*/
420446
function flushAfterPaintEffects() {
421447
let component;
422-
while ((component = afterPaintEffects.shift())) {
423-
const hooks = component.__hooks;
424-
if (!component._parentDom || !hooks) continue;
425-
try {
426-
hooks._pendingEffects.some(invokeCleanup);
427-
hooks._pendingEffects.some(invokeEffect);
428-
hooks._pendingEffects = [];
429-
} catch (e) {
430-
hooks._pendingEffects = [];
431-
options._catchError(e, component._vnode);
448+
// The loop picks up components unmounted by an effect we just invoked, which
449+
// don't necessarily schedule a flush of their own.
450+
do {
451+
// Unmounted components' passive cleanups run before any new passive
452+
// effect, mirroring React running all destroys before any create.
453+
while ((component = unmountCleanups.shift())) {
454+
try {
455+
invokeCleanup(component);
456+
} catch (e) {
457+
component = /** @type {any} */ (component._passive);
458+
options._catchError(e, { _parent: component && component._vnode });
459+
}
432460
}
433-
}
461+
462+
while ((component = afterPaintEffects.shift())) {
463+
const hooks = component.__hooks;
464+
if (!component._parentDom || !hooks) continue;
465+
try {
466+
hooks._pendingEffects.some(invokeCleanup);
467+
hooks._pendingEffects.some(invokeEffect);
468+
hooks._pendingEffects = [];
469+
} catch (e) {
470+
hooks._pendingEffects = [];
471+
options._catchError(e, component._vnode);
472+
}
473+
}
474+
} while (unmountCleanups.length);
434475
}
435476

436477
let HAS_RAF = typeof requestAnimationFrame == 'function';

hooks/src/internal.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ export interface EffectHookState extends BaseHookState {
7474
_args?: unknown[];
7575
_pendingArgs?: unknown[];
7676
_cleanup?: Cleanup | void;
77+
/**
78+
* Whether this is a passive effect (useEffect), whose unmount cleanup runs
79+
* after paint. Once unmounted it holds the surviving component that deferred
80+
* cleanup errors are routed to instead.
81+
*/
82+
_passive?: boolean | Component;
7783
}
7884

7985
export interface MemoHookState<T = unknown> extends BaseHookState {

hooks/test/browser/useEffect.test.jsx

Lines changed: 217 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Component, Fragment, createElement, render } from 'preact';
2-
import { useEffect, useRef, useState } from 'preact/hooks';
2+
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
33
import { act, teardown as teardownAct } from 'preact/test-utils';
44
import { vi } from 'vitest';
55
import { setupScratch, teardown } from '../../../test/_util/helpers';
@@ -225,6 +225,52 @@ describe('useEffect', () => {
225225
expect(scratch.innerHTML).to.equal('<p>Error</p>');
226226
});
227227

228+
it('should route deferred cleanup errors after a state update', () => {
229+
const spy = vi.fn();
230+
let hide;
231+
232+
function ThrowOnUnmount() {
233+
useEffect(
234+
() => () => {
235+
throw new Error('err');
236+
},
237+
[]
238+
);
239+
return <span>Child</span>;
240+
}
241+
242+
function StatefulParent() {
243+
const [show, setShow] = useState(true);
244+
hide = () => setShow(false);
245+
return <div>{show ? <ThrowOnUnmount /> : <span>Gone</span>}</div>;
246+
}
247+
248+
class ErrorBoundary extends Component {
249+
componentDidCatch(error) {
250+
spy(error);
251+
this.setState({ error: true });
252+
}
253+
254+
render(props, state) {
255+
return state.error ? <p>Error</p> : props.children;
256+
}
257+
}
258+
259+
act(() =>
260+
render(
261+
<ErrorBoundary>
262+
<StatefulParent />
263+
</ErrorBoundary>,
264+
scratch
265+
)
266+
);
267+
act(() => hide());
268+
269+
expect(spy).toHaveBeenCalledOnce();
270+
expect(spy.mock.calls[0][0]).to.have.property('message', 'err');
271+
expect(scratch.innerHTML).to.equal('<p>Error</p>');
272+
});
273+
228274
it('catches errors when error is invoked during render', () => {
229275
const spy = vi.fn();
230276
let errored;
@@ -261,6 +307,35 @@ describe('useEffect', () => {
261307
expect(scratch.innerHTML).to.equal('<p>Error</p>');
262308
});
263309

310+
it('should flush cleanups of a root unmounted from within an effect', async () => {
311+
const log = [];
312+
const host = document.createElement('div');
313+
const other = document.createElement('div');
314+
scratch.appendChild(host);
315+
scratch.appendChild(other);
316+
317+
function Other() {
318+
useEffect(() => () => log.push('other cleanup'), []);
319+
return <p>other</p>;
320+
}
321+
322+
function Trigger() {
323+
useEffect(() => {
324+
log.push('trigger effect');
325+
render(null, other);
326+
}, []);
327+
return <p>trigger</p>;
328+
}
329+
330+
render(<Other />, other);
331+
await new Promise(r => setTimeout(r, 60));
332+
333+
render(<Trigger />, host);
334+
await new Promise(r => setTimeout(r, 60));
335+
336+
expect(log).to.deep.equal(['trigger effect', 'other cleanup']);
337+
});
338+
264339
it('should allow creating a new root', () => {
265340
const root = document.createElement('div');
266341
const global = document.createElement('div');
@@ -665,6 +740,147 @@ describe('useEffect', () => {
665740
});
666741
});
667742

743+
it('should run cleanup of an unmounted child after the parent commits (#4299)', () => {
744+
const log = [];
745+
746+
function Child() {
747+
useEffect(() => {
748+
log.push('child effect');
749+
return () => {
750+
log.push('child cleanup');
751+
};
752+
}, []);
753+
return null;
754+
}
755+
756+
function Parent({ show }) {
757+
log.push('parent render');
758+
useLayoutEffect(() => {
759+
log.push('parent layout effect');
760+
});
761+
return show ? <Child /> : null;
762+
}
763+
764+
act(() => render(<Parent show={true} />, scratch));
765+
act(() => render(<Parent show={false} />, scratch));
766+
767+
expect(log).to.deep.equal([
768+
'parent render',
769+
'parent layout effect',
770+
'child effect',
771+
'parent render',
772+
'parent layout effect',
773+
'child cleanup'
774+
]);
775+
});
776+
777+
it('should run cleanups of a deep removed subtree in tree order (#4299)', () => {
778+
const log = [];
779+
780+
const Level = ({ name, children }) => {
781+
useLayoutEffect(() => () => log.push(`${name} layout cleanup`), []);
782+
useEffect(() => () => log.push(`${name} passive cleanup`), []);
783+
return <div class={name}>{children}</div>;
784+
};
785+
786+
const App = ({ show }) => (
787+
<section>
788+
{show ? (
789+
<Level name="A">
790+
<Level name="B">
791+
<Level name="C">
792+
<span>leaf</span>
793+
</Level>
794+
</Level>
795+
</Level>
796+
) : null}
797+
</section>
798+
);
799+
800+
act(() => render(<App show />, scratch));
801+
log.length = 0;
802+
act(() => render(<App show={false} />, scratch));
803+
804+
// All layout cleanups run in the commit, then all passive ones after
805+
// paint, each top-down. Matches React 19.
806+
expect(log).to.deep.equal([
807+
'A layout cleanup',
808+
'B layout cleanup',
809+
'C layout cleanup',
810+
'A passive cleanup',
811+
'B passive cleanup',
812+
'C passive cleanup'
813+
]);
814+
});
815+
816+
it('should route a deferred cleanup error past boundaries inside the removed subtree', () => {
817+
const log = [];
818+
819+
class Boundary extends Component {
820+
componentDidCatch(err) {
821+
log.push(`${this.props.name} caught: ${err.message}`);
822+
this.setState({ errored: true });
823+
}
824+
825+
render(props, state) {
826+
return state.errored ? <p>{props.name} error</p> : props.children;
827+
}
828+
}
829+
830+
const Deep = () => {
831+
useEffect(
832+
() => () => {
833+
throw new Error('deep');
834+
},
835+
[]
836+
);
837+
return <i>deep</i>;
838+
};
839+
840+
// `Inner` is itself being removed, so it must not handle the error; the
841+
// still-mounted `Outer` boundary has to.
842+
const Removed = () => (
843+
<Boundary name="Inner">
844+
<div>
845+
<Deep />
846+
</div>
847+
</Boundary>
848+
);
849+
850+
const App = ({ show }) => (
851+
<Boundary name="Outer">
852+
<div>{show ? <Removed /> : null}</div>
853+
</Boundary>
854+
);
855+
856+
act(() => render(<App show />, scratch));
857+
log.length = 0;
858+
act(() => render(<App show={false} />, scratch));
859+
860+
expect(log).to.deep.equal(['Outer caught: deep']);
861+
expect(scratch.innerHTML).to.equal('<p>Outer error</p>');
862+
});
863+
864+
it('should run cleanups of unmounted components before new effects (#4299)', () => {
865+
const log = [];
866+
867+
function Child({ name }) {
868+
useEffect(() => {
869+
log.push(`${name} effect`);
870+
return () => {
871+
log.push(`${name} cleanup`);
872+
};
873+
}, []);
874+
return <p>{name}</p>;
875+
}
876+
877+
act(() => render(<Child key="a" name="A" />, scratch));
878+
log.length = 0;
879+
act(() => render(<Child key="b" name="B" />, scratch));
880+
881+
expect(log).to.deep.equal(['A cleanup', 'B effect']);
882+
});
883+
668884
it('should not rerun when receiving NaN on subsequent renders', () => {
669885
const calls = [];
670886
const Component = ({ value }) => {

hooks/test/browser/useEffectAssertions.jsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,11 @@ export function useEffectAssertions(useEffect, scheduleEffectAssert) {
133133
return scheduleEffectAssert(() => {
134134
render(null, scratch);
135135
rerender();
136-
expect(cleanupFunction).toHaveBeenCalledOnce();
137-
});
136+
}).then(() =>
137+
// Passive cleanups of unmounted components run in the after-paint
138+
// flush (like React); layout cleanups have already run by now.
139+
scheduleEffectAssert(() => expect(cleanupFunction).toHaveBeenCalledOnce())
140+
);
138141
});
139142

140143
it('works with closure effect callbacks capturing props', () => {

0 commit comments

Comments
 (0)