Skip to content

Commit 9aafde8

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 3367298 commit 9aafde8

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';
@@ -2847,7 +2848,7 @@ function flushPassiveUnmountEffects(firstChild: Fiber): void {
28472848
if (deletions !== null) {
28482849
for (let i = 0; i < deletions.length; i++) {
28492850
const fiberToDelete = deletions[i];
2850-
flushPassiveUnmountEffectsInsideOfDeletedTree(fiberToDelete);
2851+
flushPassiveUnmountEffectsForUnmountedFiber(fiberToDelete, fiber);
28512852

28522853
// Now that passive effects have been processed, it's safe to detach lingering pointers.
28532854
detachFiberAfterEffects(fiberToDelete);
@@ -2882,8 +2883,9 @@ function flushPassiveUnmountEffects(firstChild: Fiber): void {
28822883
}
28832884
}
28842885

2885-
function flushPassiveUnmountEffectsInsideOfDeletedTree(
2886+
function flushPassiveUnmountEffectsForUnmountedFiber(
28862887
fiberToDelete: Fiber,
2888+
nearestMountedAncestor: Fiber,
28872889
): void {
28882890
if ((fiberToDelete.subtreeTag & PassiveStaticSubtreeTag) !== NoSubtreeTag) {
28892891
// If any children have passive effects then traverse the subtree.
@@ -2892,7 +2894,10 @@ function flushPassiveUnmountEffectsInsideOfDeletedTree(
28922894
// since that would not cover passive effects in siblings.
28932895
let child = fiberToDelete.child;
28942896
while (child !== null) {
2895-
flushPassiveUnmountEffectsInsideOfDeletedTree(child);
2897+
flushPassiveUnmountEffectsForUnmountedFiber(
2898+
child,
2899+
nearestMountedAncestor,
2900+
);
28962901
child = child.sibling;
28972902
}
28982903
}
@@ -2903,16 +2908,64 @@ function flushPassiveUnmountEffectsInsideOfDeletedTree(
29032908
case ForwardRef:
29042909
case SimpleMemoComponent:
29052910
case Block: {
2906-
flushPassiveUnmountEffectsImpl(fiberToDelete, HookPassive);
2911+
flushPassiveUnmountEffectsForUnmountedFiberImpl(
2912+
fiberToDelete,
2913+
nearestMountedAncestor,
2914+
);
29072915
}
29082916
}
29092917
}
29102918
}
29112919

2920+
function flushPassiveUnmountEffectsForUnmountedFiberImpl(
2921+
fiber: Fiber,
2922+
nearestMountedAncestor: Fiber,
2923+
): void {
2924+
const updateQueue: FunctionComponentUpdateQueue | null = (fiber.updateQueue: any);
2925+
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
2926+
if (lastEffect !== null) {
2927+
setCurrentDebugFiberInDEV(fiber);
2928+
2929+
const firstEffect = lastEffect.next;
2930+
let effect = firstEffect;
2931+
do {
2932+
const {next, tag} = effect;
2933+
if ((tag & HookPassive) === HookPassive) {
2934+
const destroy = effect.destroy;
2935+
if (destroy !== undefined) {
2936+
effect.destroy = undefined;
2937+
2938+
if (
2939+
enableProfilerTimer &&
2940+
enableProfilerCommitHooks &&
2941+
fiber.mode & ProfileMode
2942+
) {
2943+
startPassiveEffectTimer();
2944+
safelyCallDestroyForUnmountedFiber(
2945+
fiber,
2946+
nearestMountedAncestor,
2947+
destroy,
2948+
);
2949+
recordPassiveEffectDuration(fiber);
2950+
} else {
2951+
safelyCallDestroyForUnmountedFiber(
2952+
fiber,
2953+
nearestMountedAncestor,
2954+
destroy,
2955+
);
2956+
}
2957+
}
2958+
}
2959+
effect = next;
2960+
} while (effect !== firstEffect);
2961+
2962+
resetCurrentDebugFiberInDEV();
2963+
}
2964+
}
2965+
29122966
function flushPassiveUnmountEffectsImpl(
29132967
fiber: Fiber,
2914-
// Tags to check for when deciding whether to unmount. e.g. to skip over
2915-
// layout effects
2968+
// Tags to check for when deciding whether to unmount. e.g. to skip over layout effects
29162969
hookEffectTag: HookEffectTag,
29172970
): void {
29182971
const updateQueue: FunctionComponentUpdateQueue | null = (fiber.updateQueue: any);
@@ -3112,6 +3165,45 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
31123165
}
31133166
}
31143167

3168+
export function captureCommitPhaseErrorForUnmountedFiber(
3169+
sourceFiber: Fiber,
3170+
nearestMountedAncestor: Fiber,
3171+
error: mixed,
3172+
) {
3173+
let fiber = nearestMountedAncestor.return;
3174+
while (fiber !== null) {
3175+
if (fiber.tag === HostRoot) {
3176+
captureCommitPhaseErrorOnRoot(fiber, sourceFiber, error);
3177+
return;
3178+
} else if (fiber.tag === ClassComponent) {
3179+
const ctor = fiber.type;
3180+
const instance = fiber.stateNode;
3181+
if (
3182+
typeof ctor.getDerivedStateFromError === 'function' ||
3183+
(typeof instance.componentDidCatch === 'function' &&
3184+
!isAlreadyFailedLegacyErrorBoundary(instance))
3185+
) {
3186+
const errorInfo = createCapturedValue(error, sourceFiber);
3187+
const update = createClassErrorUpdate(
3188+
fiber,
3189+
errorInfo,
3190+
(SyncLane: Lane),
3191+
);
3192+
enqueueUpdate(fiber, update);
3193+
const eventTime = requestEventTime();
3194+
const root = markUpdateLaneFromFiberToRoot(fiber, (SyncLane: Lane));
3195+
if (root !== null) {
3196+
markRootUpdated(root, SyncLane, eventTime);
3197+
ensureRootIsScheduled(root, eventTime);
3198+
schedulePendingInteractions(root, SyncLane);
3199+
}
3200+
return;
3201+
}
3202+
}
3203+
fiber = fiber.return;
3204+
}
3205+
}
3206+
31153207
export function pingSuspendedRoot(
31163208
root: FiberRoot,
31173209
wakeable: Wakeable,

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2863,6 +2863,24 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
28632863
markRootUpdated(root, SyncLane, eventTime);
28642864
ensureRootIsScheduled(root, eventTime);
28652865
schedulePendingInteractions(root, SyncLane);
2866+
} else {
2867+
// This component has already been unmounted.
2868+
// We can't schedule any follow up work for the root because the fiber is already unmounted,
2869+
// but we can still call the log-only boundary so the error isn't swallowed.
2870+
//
2871+
// TODO This is only a temporary bandaid for the old reconciler fork.
2872+
// We can delete this special case once the new fork is merged.
2873+
if (
2874+
typeof instance.componentDidCatch === 'function' &&
2875+
!isAlreadyFailedLegacyErrorBoundary(instance)
2876+
) {
2877+
try {
2878+
instance.componentDidCatch(error, errorInfo);
2879+
} catch (errorToIgnore) {
2880+
// TODO Ignore this error? Rethrow it?
2881+
// This is kind of an edge case.
2882+
}
2883+
}
28662884
}
28672885
return;
28682886
}

0 commit comments

Comments
 (0)