Skip to content

Commit 69b0935

Browse files
authored
Merge branch 'main' into JoviDeCroock/reduce-bundle-size
2 parents 28c9afb + 7c9b0fa commit 69b0935

5 files changed

Lines changed: 322 additions & 20 deletions

File tree

hooks/src/index.js

Lines changed: 59 additions & 13 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

@@ -120,10 +129,31 @@ options.unmount = vnode => {
120129

121130
const c = vnode._component;
122131
if (c && c.__hooks) {
123-
let hasErrored;
132+
let hasErrored,
133+
errorParent = vnode._parent;
134+
// The removed subtree is detached (`_parent` nulled) by flush time, so
135+
// grab the nearest surviving component now; its current vnode can still
136+
// route deferred cleanup errors to a mounted error boundary. Unmounting
137+
// is pre-order and nulls each component's `_parentDom` before its
138+
// children unmount, so removed ancestors are already recognizable here.
139+
while (
140+
errorParent &&
141+
!(errorParent._component && errorParent._component._parentDom)
142+
) {
143+
errorParent = errorParent._parent;
144+
}
124145
c.__hooks._list.some(s => {
125146
try {
126-
invokeCleanup(s);
147+
// Layout cleanups have to run before the new DOM is in place, so
148+
// they stay in the commit phase (see #1886). Passive cleanups run
149+
// after paint, before any new passive effect (see #4299), with
150+
// `_passive` repurposed to hold the error-routing component.
151+
if (s._passive) {
152+
s._passive = errorParent && errorParent._component;
153+
afterPaint(unmountCleanups.push(s));
154+
} else {
155+
invokeCleanup(s);
156+
}
127157
} catch (e) {
128158
hasErrored = e;
129159
}
@@ -254,6 +284,7 @@ export function useEffect(callback, args) {
254284
/** @type {import('./internal').EffectHookState} */
255285
const state = getHookState(currentIndex++, 3);
256286
if (!options._skipEffects && argsChanged(state._args, args)) {
287+
state._passive = true;
257288
state._value = callback;
258289
state._pendingArgs = args;
259290

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

435481
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)