Skip to content

Commit

Permalink
Improve error boundary handling for unmounted subtrees
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
Brian Vaughn committed Aug 7, 2020
1 parent 3367298 commit 9aafde8
Show file tree
Hide file tree
Showing 4 changed files with 443 additions and 10 deletions.
29 changes: 29 additions & 0 deletions packages/react-reconciler/src/ReactFiberCommitWork.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import {
} from './ReactFiberHostConfig';
import {
captureCommitPhaseError,
captureCommitPhaseErrorForUnmountedFiber,
resolveRetryWakeable,
markCommitTimeOfFallback,
enqueuePendingPassiveProfilerEffect,
Expand Down Expand Up @@ -216,6 +217,34 @@ export function safelyCallDestroy(current: Fiber, destroy: () => void) {
}
}

export function safelyCallDestroyForUnmountedFiber(
current: Fiber,
nearestMountedAncestor: Fiber,
destroy: () => void,
) {
if (__DEV__) {
invokeGuardedCallback(null, destroy, null);
if (hasCaughtError()) {
const error = clearCaughtError();
captureCommitPhaseErrorForUnmountedFiber(
current,
nearestMountedAncestor,
error,
);
}
} else {
try {
destroy();
} catch (error) {
captureCommitPhaseErrorForUnmountedFiber(
current,
nearestMountedAncestor,
error,
);
}
}
}

function commitBeforeMutationLifeCycles(
current: Fiber | null,
finishedWork: Fiber,
Expand Down
104 changes: 98 additions & 6 deletions packages/react-reconciler/src/ReactFiberWorkLoop.new.js
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ import {
commitResetTextContent,
isSuspenseBoundaryBeingHidden,
safelyCallDestroy,
safelyCallDestroyForUnmountedFiber,
} from './ReactFiberCommitWork.new';
import {enqueueUpdate} from './ReactUpdateQueue.new';
import {resetContextDependencies} from './ReactFiberNewContext.new';
Expand Down Expand Up @@ -2847,7 +2848,7 @@ function flushPassiveUnmountEffects(firstChild: Fiber): void {
if (deletions !== null) {
for (let i = 0; i < deletions.length; i++) {
const fiberToDelete = deletions[i];
flushPassiveUnmountEffectsInsideOfDeletedTree(fiberToDelete);
flushPassiveUnmountEffectsForUnmountedFiber(fiberToDelete, fiber);

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

function flushPassiveUnmountEffectsInsideOfDeletedTree(
function flushPassiveUnmountEffectsForUnmountedFiber(
fiberToDelete: Fiber,
nearestMountedAncestor: Fiber,
): void {
if ((fiberToDelete.subtreeTag & PassiveStaticSubtreeTag) !== NoSubtreeTag) {
// If any children have passive effects then traverse the subtree.
Expand All @@ -2892,7 +2894,10 @@ function flushPassiveUnmountEffectsInsideOfDeletedTree(
// since that would not cover passive effects in siblings.
let child = fiberToDelete.child;
while (child !== null) {
flushPassiveUnmountEffectsInsideOfDeletedTree(child);
flushPassiveUnmountEffectsForUnmountedFiber(
child,
nearestMountedAncestor,
);
child = child.sibling;
}
}
Expand All @@ -2903,16 +2908,64 @@ function flushPassiveUnmountEffectsInsideOfDeletedTree(
case ForwardRef:
case SimpleMemoComponent:
case Block: {
flushPassiveUnmountEffectsImpl(fiberToDelete, HookPassive);
flushPassiveUnmountEffectsForUnmountedFiberImpl(
fiberToDelete,
nearestMountedAncestor,
);
}
}
}
}

function flushPassiveUnmountEffectsForUnmountedFiberImpl(
fiber: Fiber,
nearestMountedAncestor: Fiber,
): void {
const updateQueue: FunctionComponentUpdateQueue | null = (fiber.updateQueue: any);
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
setCurrentDebugFiberInDEV(fiber);

const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
const {next, tag} = effect;
if ((tag & HookPassive) === HookPassive) {
const destroy = effect.destroy;
if (destroy !== undefined) {
effect.destroy = undefined;

if (
enableProfilerTimer &&
enableProfilerCommitHooks &&
fiber.mode & ProfileMode
) {
startPassiveEffectTimer();
safelyCallDestroyForUnmountedFiber(
fiber,
nearestMountedAncestor,
destroy,
);
recordPassiveEffectDuration(fiber);
} else {
safelyCallDestroyForUnmountedFiber(
fiber,
nearestMountedAncestor,
destroy,
);
}
}
}
effect = next;
} while (effect !== firstEffect);

resetCurrentDebugFiberInDEV();
}
}

function flushPassiveUnmountEffectsImpl(
fiber: Fiber,
// Tags to check for when deciding whether to unmount. e.g. to skip over
// layout effects
// Tags to check for when deciding whether to unmount. e.g. to skip over layout effects
hookEffectTag: HookEffectTag,
): void {
const updateQueue: FunctionComponentUpdateQueue | null = (fiber.updateQueue: any);
Expand Down Expand Up @@ -3112,6 +3165,45 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
}
}

export function captureCommitPhaseErrorForUnmountedFiber(
sourceFiber: Fiber,
nearestMountedAncestor: Fiber,
error: mixed,
) {
let fiber = nearestMountedAncestor.return;
while (fiber !== null) {
if (fiber.tag === HostRoot) {
captureCommitPhaseErrorOnRoot(fiber, sourceFiber, error);
return;
} else if (fiber.tag === ClassComponent) {
const ctor = fiber.type;
const instance = fiber.stateNode;
if (
typeof ctor.getDerivedStateFromError === 'function' ||
(typeof instance.componentDidCatch === 'function' &&
!isAlreadyFailedLegacyErrorBoundary(instance))
) {
const errorInfo = createCapturedValue(error, sourceFiber);
const update = createClassErrorUpdate(
fiber,
errorInfo,
(SyncLane: Lane),
);
enqueueUpdate(fiber, update);
const eventTime = requestEventTime();
const root = markUpdateLaneFromFiberToRoot(fiber, (SyncLane: Lane));
if (root !== null) {
markRootUpdated(root, SyncLane, eventTime);
ensureRootIsScheduled(root, eventTime);
schedulePendingInteractions(root, SyncLane);
}
return;
}
}
fiber = fiber.return;
}
}

export function pingSuspendedRoot(
root: FiberRoot,
wakeable: Wakeable,
Expand Down
18 changes: 18 additions & 0 deletions packages/react-reconciler/src/ReactFiberWorkLoop.old.js
Original file line number Diff line number Diff line change
Expand Up @@ -2863,6 +2863,24 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
markRootUpdated(root, SyncLane, eventTime);
ensureRootIsScheduled(root, eventTime);
schedulePendingInteractions(root, SyncLane);
} else {
// This component has already been unmounted.
// We can't schedule any follow up work for the root because the fiber is already unmounted,
// but we can still call the log-only boundary so the error isn't swallowed.
//
// TODO This is only a temporary bandaid for the old reconciler fork.
// We can delete this special case once the new fork is merged.
if (
typeof instance.componentDidCatch === 'function' &&
!isAlreadyFailedLegacyErrorBoundary(instance)
) {
try {
instance.componentDidCatch(error, errorInfo);
} catch (errorToIgnore) {
// TODO Ignore this error? Rethrow it?
// This is kind of an edge case.
}
}
}
return;
}
Expand Down
Loading

0 comments on commit 9aafde8

Please sign in to comment.