Skip to content

Commit 85bb7b6

Browse files
acdlitegaearonrickhanloniikassens
authored
Fix: Move destroy field to shared instance object (#26561)
This fixes the "double free" bug illustrated by the regression test added in the previous commit. The underlying issue is that `effect.destroy` field is a mutable field but we read it during render. This is a concurrency bug — if we had a borrow checker, it would not allow this. It's rare in practice today because the field is updated during the commit phase, which takes a lock on the fiber tree until all the effects have fired. But it's still theoretically wrong because you can have multiple Fiber copies each with their own reference to a single destroy function, and indeed we discovered in production a scenario where this happens via our current APIs. In the future these types of scenarios will be much more common because we will introduce features where effects may run concurrently with the render phase — i.e. an imperative `hide` method that synchronously hides a React tree and unmounts all its effects without entering the render phase, and without interrupting a render phase that's already in progress. A future version of React may also be able to run the entire commit phase concurrently with a subsequent render phase. We can't do this now because our data structures are not fully thread safe (see: the Fiber alternate model) but we should be able to do this in the future. The fix I've introduced in this commit is to move the `destroy` field to a separate object. The effect "instance" is a shared object that remains the same for the entire lifetime of an effect. In Rust terms, a RefCell. The field is `undefined` if the effect is unmounted, or if the effect ran but is not stateful. We don't explicitly track whether the effect is mounted or unmounted because that can be inferred by the hiddenness of the fiber in the tree, i.e. whether there is a hidden Offscreen fiber above it. It's unfortunate that this is stored on a separate object, because it adds more memory per effect instance, but it's conceptually sound. I think there's likely a better data structure we could use for effects; perhaps just one array of effect instances per fiber. But I think this is OK for now despite the additional memory and we can follow up with performance optimizations later. --------- Co-authored-by: Dan Abramov <dan.abramov@gmail.com> Co-authored-by: Rick Hanlon <rickhanlonii@gmail.com> Co-authored-by: Jan Kassens <jan@kassens.net>
1 parent 60cfeee commit 85bb7b6

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) {
@@ -2188,9 +2190,12 @@ function commitDeletionEffectsOnFiber(
21882190

21892191
let effect = firstEffect;
21902192
do {
2191-
const {destroy, tag} = effect;
2193+
const tag = effect.tag;
2194+
const inst = effect.inst;
2195+
const destroy = inst.destroy;
21922196
if (destroy !== undefined) {
21932197
if ((tag & HookInsertion) !== NoHookEffect) {
2198+
inst.destroy = undefined;
21942199
safelyCallDestroy(
21952200
deletedFiber,
21962201
nearestMountedAncestor,
@@ -2203,13 +2208,15 @@ function commitDeletionEffectsOnFiber(
22032208

22042209
if (shouldProfile(deletedFiber)) {
22052210
startLayoutEffectTimer();
2211+
inst.destroy = undefined;
22062212
safelyCallDestroy(
22072213
deletedFiber,
22082214
nearestMountedAncestor,
22092215
destroy,
22102216
);
22112217
recordLayoutEffectDuration(deletedFiber);
22122218
} else {
2219+
inst.destroy = undefined;
22132220
safelyCallDestroy(
22142221
deletedFiber,
22152222
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)