Skip to content

Commit 1d3ffd9

Browse files
committed
Fix: Move destroy field to shared instance object (#26561)
This reverts commit d41e5c2.
1 parent 2f18eb0 commit 1d3ffd9

File tree

3 files changed

+125
-18
lines changed

3 files changed

+125
-18
lines changed

packages/react-reconciler/src/ReactFiberCommitWork.js

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -592,9 +592,10 @@ function commitHookEffectListUnmount(
592592
do {
593593
if ((effect.tag & flags) === flags) {
594594
// Unmount
595-
const destroy = effect.destroy;
596-
effect.destroy = undefined;
595+
const inst = effect.inst;
596+
const destroy = inst.destroy;
597597
if (destroy !== undefined) {
598+
inst.destroy = undefined;
598599
if (enableSchedulingProfiler) {
599600
if ((flags & HookPassive) !== NoHookEffect) {
600601
markComponentPassiveEffectUnmountStarted(finishedWork);
@@ -653,7 +654,9 @@ function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) {
653654
setIsRunningInsertionEffect(true);
654655
}
655656
}
656-
effect.destroy = create();
657+
const inst = effect.inst;
658+
const destroy = create();
659+
inst.destroy = destroy;
657660
if (__DEV__) {
658661
if ((flags & HookInsertion) !== NoHookEffect) {
659662
setIsRunningInsertionEffect(false);
@@ -669,7 +672,6 @@ function commitHookEffectListMount(flags: HookFlags, finishedWork: Fiber) {
669672
}
670673

671674
if (__DEV__) {
672-
const destroy = effect.destroy;
673675
if (destroy !== undefined && typeof destroy !== 'function') {
674676
let hookName;
675677
if ((effect.tag & HookLayout) !== NoFlags) {
@@ -2190,9 +2192,12 @@ function commitDeletionEffectsOnFiber(
21902192

21912193
let effect = firstEffect;
21922194
do {
2193-
const {destroy, tag} = effect;
2195+
const tag = effect.tag;
2196+
const inst = effect.inst;
2197+
const destroy = inst.destroy;
21942198
if (destroy !== undefined) {
21952199
if ((tag & HookInsertion) !== NoHookEffect) {
2200+
inst.destroy = undefined;
21962201
safelyCallDestroy(
21972202
deletedFiber,
21982203
nearestMountedAncestor,
@@ -2205,13 +2210,15 @@ function commitDeletionEffectsOnFiber(
22052210

22062211
if (shouldProfile(deletedFiber)) {
22072212
startLayoutEffectTimer();
2213+
inst.destroy = undefined;
22082214
safelyCallDestroy(
22092215
deletedFiber,
22102216
nearestMountedAncestor,
22112217
destroy,
22122218
);
22132219
recordLayoutEffectDuration(deletedFiber);
22142220
} else {
2221+
inst.destroy = undefined;
22152222
safelyCallDestroy(
22162223
deletedFiber,
22172224
nearestMountedAncestor,

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -180,11 +180,29 @@ export type Hook = {
180180
next: Hook | null,
181181
};
182182

183+
// The effect "instance" is a shared object that remains the same for the entire
184+
// lifetime of an effect. In Rust terms, a RefCell. We use it to store the
185+
// "destroy" function that is returned from an effect, because that is stateful.
186+
// The field is `undefined` if the effect is unmounted, or if the effect ran
187+
// but is not stateful. We don't explicitly track whether the effect is mounted
188+
// or unmounted because that can be inferred by the hiddenness of the fiber in
189+
// the tree, i.e. whether there is a hidden Offscreen fiber above it.
190+
//
191+
// It's unfortunate that this is stored on a separate object, because it adds
192+
// more memory per effect instance, but it's conceptually sound. I think there's
193+
// likely a better data structure we could use for effects; perhaps just one
194+
// array of effect instances per fiber. But I think this is OK for now despite
195+
// the additional memory and we can follow up with performance
196+
// optimizations later.
197+
type EffectInstance = {
198+
destroy: void | (() => void),
199+
};
200+
183201
export type Effect = {
184202
tag: HookFlags,
185203
create: () => (() => void) | void,
186-
destroy: (() => void) | void,
187-
deps: Array<mixed> | void | null,
204+
inst: EffectInstance,
205+
deps: Array<mixed> | null,
188206
next: Effect,
189207
};
190208

@@ -1662,7 +1680,7 @@ function mountSyncExternalStore<T>(
16621680
pushEffect(
16631681
HookHasEffect | HookPassive,
16641682
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
1665-
undefined,
1683+
createEffectInstance(),
16661684
null,
16671685
);
16681686

@@ -1719,7 +1737,7 @@ function updateSyncExternalStore<T>(
17191737
pushEffect(
17201738
HookHasEffect | HookPassive,
17211739
updateStoreInstance.bind(null, fiber, inst, nextSnapshot, getSnapshot),
1722-
undefined,
1740+
createEffectInstance(),
17231741
null,
17241742
);
17251743

@@ -1860,13 +1878,13 @@ function rerenderState<S>(
18601878
function pushEffect(
18611879
tag: HookFlags,
18621880
create: () => (() => void) | void,
1863-
destroy: (() => void) | void,
1864-
deps: Array<mixed> | void | null,
1881+
inst: EffectInstance,
1882+
deps: Array<mixed> | null,
18651883
): Effect {
18661884
const effect: Effect = {
18671885
tag,
18681886
create,
1869-
destroy,
1887+
inst,
18701888
deps,
18711889
// Circular
18721890
next: (null: any),
@@ -1891,6 +1909,10 @@ function pushEffect(
18911909
return effect;
18921910
}
18931911

1912+
function createEffectInstance(): EffectInstance {
1913+
return {destroy: undefined};
1914+
}
1915+
18941916
let stackContainsErrorMessage: boolean | null = null;
18951917

18961918
function getCallerStackFrame(): string {
@@ -1994,7 +2016,7 @@ function mountEffectImpl(
19942016
hook.memoizedState = pushEffect(
19952017
HookHasEffect | hookFlags,
19962018
create,
1997-
undefined,
2019+
createEffectInstance(),
19982020
nextDeps,
19992021
);
20002022
}
@@ -2007,16 +2029,16 @@ function updateEffectImpl(
20072029
): void {
20082030
const hook = updateWorkInProgressHook();
20092031
const nextDeps = deps === undefined ? null : deps;
2010-
let destroy = undefined;
2032+
const effect: Effect = hook.memoizedState;
2033+
const inst = effect.inst;
20112034

20122035
// currentHook is null when rerendering after a render phase state update.
20132036
if (currentHook !== null) {
2014-
const prevEffect = currentHook.memoizedState;
2015-
destroy = prevEffect.destroy;
20162037
if (nextDeps !== null) {
2038+
const prevEffect: Effect = currentHook.memoizedState;
20172039
const prevDeps = prevEffect.deps;
20182040
if (areHookInputsEqual(nextDeps, prevDeps)) {
2019-
hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
2041+
hook.memoizedState = pushEffect(hookFlags, create, inst, nextDeps);
20202042
return;
20212043
}
20222044
}
@@ -2027,7 +2049,7 @@ function updateEffectImpl(
20272049
hook.memoizedState = pushEffect(
20282050
HookHasEffect | hookFlags,
20292051
create,
2030-
destroy,
2052+
inst,
20312053
nextDeps,
20322054
);
20332055
}

packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemanticsDOM-test.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,4 +496,82 @@ describe('ReactSuspenseEffectsSemanticsDOM', () => {
496496
ReactDOM.render(null, container);
497497
assertLog(['Unmount']);
498498
});
499+
500+
it('does not call cleanup effects twice after a bailout', async () => {
501+
const never = new Promise(resolve => {});
502+
function Never() {
503+
throw never;
504+
}
505+
506+
let setSuspended;
507+
let setLetter;
508+
509+
function App() {
510+
const [suspended, _setSuspended] = React.useState(false);
511+
setSuspended = _setSuspended;
512+
const [letter, _setLetter] = React.useState('A');
513+
setLetter = _setLetter;
514+
515+
return (
516+
<React.Suspense fallback="Loading...">
517+
<Child letter={letter} />
518+
{suspended && <Never />}
519+
</React.Suspense>
520+
);
521+
}
522+
523+
let nextId = 0;
524+
const freed = new Set();
525+
let setStep;
526+
527+
function Child({letter}) {
528+
const [, _setStep] = React.useState(0);
529+
setStep = _setStep;
530+
531+
React.useLayoutEffect(() => {
532+
const localId = nextId++;
533+
Scheduler.log('Did mount: ' + letter + localId);
534+
return () => {
535+
if (freed.has(localId)) {
536+
throw Error('Double free: ' + letter + localId);
537+
}
538+
freed.add(localId);
539+
Scheduler.log('Will unmount: ' + letter + localId);
540+
};
541+
}, [letter]);
542+
}
543+
544+
const root = ReactDOMClient.createRoot(container);
545+
await act(() => {
546+
root.render(<App />);
547+
});
548+
assertLog(['Did mount: A0']);
549+
550+
await act(() => {
551+
setStep(1);
552+
setSuspended(false);
553+
});
554+
assertLog([]);
555+
556+
await act(() => {
557+
setStep(1);
558+
});
559+
assertLog([]);
560+
561+
await act(() => {
562+
setSuspended(true);
563+
});
564+
assertLog(['Will unmount: A0']);
565+
566+
await act(() => {
567+
setSuspended(false);
568+
setLetter('B');
569+
});
570+
assertLog(['Did mount: B1']);
571+
572+
await act(() => {
573+
root.unmount();
574+
});
575+
assertLog(['Will unmount: B1']);
576+
});
499577
});

0 commit comments

Comments
 (0)