Skip to content

Commit 8527595

Browse files
author
Brian Vaughn
committed
Improve error boundary handling for unmounted subtrees
A passive effect's cleanup function may throw after an unmount. Prior to this commit, such an error would be ignored. (React would not notify any error boundaries.) After this commit, React's behavior varies depending on which reconciler fork is being used. For the old reconciler, React will call componentDidCatch for the nearest unmounted error boundary (if there is one). If there are no unmounted error boundaries, React will still swallow the error because the return pointer has been disconnected, so the normal error handling logic does not know how to traverse the tree to find the nearest still-mounted ancestor. For the new reconciler, React will skip any unmounted boundaries and look for a still-mounted boundary. If one is found, it will call getDerivedStateFromError and/or componentDidCatch (depending on the type of boundary). Tests have been added for both reconciler variants for now.
1 parent f77c7b9 commit 8527595

File tree

4 files changed

+443
-10
lines changed

4 files changed

+443
-10
lines changed

packages/react-reconciler/src/ReactFiberCommitWork.new.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ import {
112112
} from './ReactFiberHostConfig';
113113
import {
114114
captureCommitPhaseError,
115+
captureCommitPhaseErrorForUnmountedFiber,
115116
resolveRetryWakeable,
116117
markCommitTimeOfFallback,
117118
enqueuePendingPassiveProfilerEffect,
@@ -216,6 +217,34 @@ export function safelyCallDestroy(current: Fiber, destroy: () => void) {
216217
}
217218
}
218219

220+
export function safelyCallDestroyForUnmountedFiber(
221+
current: Fiber,
222+
nearestMountedAncestor: Fiber,
223+
destroy: () => void,
224+
) {
225+
if (__DEV__) {
226+
invokeGuardedCallback(null, destroy, null);
227+
if (hasCaughtError()) {
228+
const error = clearCaughtError();
229+
captureCommitPhaseErrorForUnmountedFiber(
230+
current,
231+
nearestMountedAncestor,
232+
error,
233+
);
234+
}
235+
} else {
236+
try {
237+
destroy();
238+
} catch (error) {
239+
captureCommitPhaseErrorForUnmountedFiber(
240+
current,
241+
nearestMountedAncestor,
242+
error,
243+
);
244+
}
245+
}
246+
}
247+
219248
function commitBeforeMutationLifeCycles(
220249
current: Fiber | null,
221250
finishedWork: Fiber,

packages/react-reconciler/src/ReactFiberWorkLoop.new.js

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ import {
212212
commitResetTextContent,
213213
isSuspenseBoundaryBeingHidden,
214214
safelyCallDestroy,
215+
safelyCallDestroyForUnmountedFiber,
215216
} from './ReactFiberCommitWork.new';
216217
import {enqueueUpdate} from './ReactUpdateQueue.new';
217218
import {resetContextDependencies} from './ReactFiberNewContext.new';
@@ -2790,7 +2791,7 @@ function flushPassiveUnmountEffects(firstChild: Fiber): void {
27902791
if (deletions !== null) {
27912792
for (let i = 0; i < deletions.length; i++) {
27922793
const fiberToDelete = deletions[i];
2793-
flushPassiveUnmountEffectsInsideOfDeletedTree(fiberToDelete);
2794+
flushPassiveUnmountEffectsForUnmountedFiber(fiberToDelete, fiber);
27942795

27952796
// Now that passive effects have been processed, it's safe to detach lingering pointers.
27962797
detachFiberAfterEffects(fiberToDelete);
@@ -2825,8 +2826,9 @@ function flushPassiveUnmountEffects(firstChild: Fiber): void {
28252826
}
28262827
}
28272828

2828-
function flushPassiveUnmountEffectsInsideOfDeletedTree(
2829+
function flushPassiveUnmountEffectsForUnmountedFiber(
28292830
fiberToDelete: Fiber,
2831+
nearestMountedAncestor: Fiber,
28302832
): void {
28312833
if ((fiberToDelete.subtreeTag & PassiveStaticSubtreeTag) !== NoSubtreeTag) {
28322834
// If any children have passive effects then traverse the subtree.
@@ -2835,7 +2837,10 @@ function flushPassiveUnmountEffectsInsideOfDeletedTree(
28352837
// since that would not cover passive effects in siblings.
28362838
let child = fiberToDelete.child;
28372839
while (child !== null) {
2838-
flushPassiveUnmountEffectsInsideOfDeletedTree(child);
2840+
flushPassiveUnmountEffectsForUnmountedFiber(
2841+
child,
2842+
nearestMountedAncestor,
2843+
);
28392844
child = child.sibling;
28402845
}
28412846
}
@@ -2846,16 +2851,64 @@ function flushPassiveUnmountEffectsInsideOfDeletedTree(
28462851
case ForwardRef:
28472852
case SimpleMemoComponent:
28482853
case Block: {
2849-
flushPassiveUnmountEffectsImpl(fiberToDelete, HookPassive);
2854+
flushPassiveUnmountEffectsForUnmountedFiberImpl(
2855+
fiberToDelete,
2856+
nearestMountedAncestor,
2857+
);
28502858
}
28512859
}
28522860
}
28532861
}
28542862

2863+
function flushPassiveUnmountEffectsForUnmountedFiberImpl(
2864+
fiber: Fiber,
2865+
nearestMountedAncestor: Fiber,
2866+
): void {
2867+
const updateQueue: FunctionComponentUpdateQueue | null = (fiber.updateQueue: any);
2868+
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
2869+
if (lastEffect !== null) {
2870+
setCurrentDebugFiberInDEV(fiber);
2871+
2872+
const firstEffect = lastEffect.next;
2873+
let effect = firstEffect;
2874+
do {
2875+
const {next, tag} = effect;
2876+
if ((tag & HookPassive) === HookPassive) {
2877+
const destroy = effect.destroy;
2878+
if (destroy !== undefined) {
2879+
effect.destroy = undefined;
2880+
2881+
if (
2882+
enableProfilerTimer &&
2883+
enableProfilerCommitHooks &&
2884+
fiber.mode & ProfileMode
2885+
) {
2886+
startPassiveEffectTimer();
2887+
safelyCallDestroyForUnmountedFiber(
2888+
fiber,
2889+
nearestMountedAncestor,
2890+
destroy,
2891+
);
2892+
recordPassiveEffectDuration(fiber);
2893+
} else {
2894+
safelyCallDestroyForUnmountedFiber(
2895+
fiber,
2896+
nearestMountedAncestor,
2897+
destroy,
2898+
);
2899+
}
2900+
}
2901+
}
2902+
effect = next;
2903+
} while (effect !== firstEffect);
2904+
2905+
resetCurrentDebugFiberInDEV();
2906+
}
2907+
}
2908+
28552909
function flushPassiveUnmountEffectsImpl(
28562910
fiber: Fiber,
2857-
// Tags to check for when deciding whether to unmount. e.g. to skip over
2858-
// layout effects
2911+
// Tags to check for when deciding whether to unmount. e.g. to skip over layout effects
28592912
hookEffectTag: HookEffectTag,
28602913
): void {
28612914
const updateQueue: FunctionComponentUpdateQueue | null = (fiber.updateQueue: any);
@@ -3055,6 +3108,45 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
30553108
}
30563109
}
30573110

3111+
export function captureCommitPhaseErrorForUnmountedFiber(
3112+
sourceFiber: Fiber,
3113+
nearestMountedAncestor: Fiber,
3114+
error: mixed,
3115+
) {
3116+
let fiber = nearestMountedAncestor.return;
3117+
while (fiber !== null) {
3118+
if (fiber.tag === HostRoot) {
3119+
captureCommitPhaseErrorOnRoot(fiber, sourceFiber, error);
3120+
return;
3121+
} else if (fiber.tag === ClassComponent) {
3122+
const ctor = fiber.type;
3123+
const instance = fiber.stateNode;
3124+
if (
3125+
typeof ctor.getDerivedStateFromError === 'function' ||
3126+
(typeof instance.componentDidCatch === 'function' &&
3127+
!isAlreadyFailedLegacyErrorBoundary(instance))
3128+
) {
3129+
const errorInfo = createCapturedValue(error, sourceFiber);
3130+
const update = createClassErrorUpdate(
3131+
fiber,
3132+
errorInfo,
3133+
(SyncLane: Lane),
3134+
);
3135+
enqueueUpdate(fiber, update);
3136+
const eventTime = requestEventTime();
3137+
const root = markUpdateLaneFromFiberToRoot(fiber, (SyncLane: Lane));
3138+
if (root !== null) {
3139+
markRootUpdated(root, SyncLane, eventTime);
3140+
ensureRootIsScheduled(root, eventTime);
3141+
schedulePendingInteractions(root, SyncLane);
3142+
}
3143+
return;
3144+
}
3145+
}
3146+
fiber = fiber.return;
3147+
}
3148+
}
3149+
30583150
export function pingSuspendedRoot(
30593151
root: FiberRoot,
30603152
wakeable: Wakeable,

packages/react-reconciler/src/ReactFiberWorkLoop.old.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2806,6 +2806,24 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
28062806
markRootUpdated(root, SyncLane, eventTime);
28072807
ensureRootIsScheduled(root, eventTime);
28082808
schedulePendingInteractions(root, SyncLane);
2809+
} else {
2810+
// This component has already been unmounted.
2811+
// We can't schedule any follow up work for the root because the fiber is already unmounted,
2812+
// but we can still call the log-only boundary so the error isn't swallowed.
2813+
//
2814+
// TODO This is only a temporary bandaid for the old reconciler fork.
2815+
// We can delete this special case once the new fork is merged.
2816+
if (
2817+
typeof instance.componentDidCatch === 'function' &&
2818+
!isAlreadyFailedLegacyErrorBoundary(instance)
2819+
) {
2820+
try {
2821+
instance.componentDidCatch(error, errorInfo);
2822+
} catch (errorToIgnore) {
2823+
// TODO Ignore this error? Rethrow it?
2824+
// This is kind of an edge case.
2825+
}
2826+
}
28092827
}
28102828
return;
28112829
}

0 commit comments

Comments
 (0)