Skip to content

Commit e2b6e4c

Browse files
author
Brian Vaughn
committed
Improve error boundary handling for unmounted subtrees
A passive effect's cleanup function may throw after an unmount. In that event, React should still call the nearest error boundary (even if that error boundary may have also been unmounted) so that it can log the error. Prior to this commit, there were several potential problems: * If the nearest error boundary was the root of the unmounted subtree, React sometimes threw an uncaught runtime error trying to access a property on a null field. * As a result, React never called on this boundary. * Even if React did find the boundary, it didn't schedule any follow up work because the pointer to the root had been disconnected. This commit makes a few improvements: * React now defers nulling out the field until after passive effects have been flushed, to avoid the uncaught runtime error. * React will call the nearested (unmounted) error boundary's method. * If no unmounted boundary was found, React will call the nearest (mounted) error boundary's method in the new reconciler fork only.
1 parent f77c7b9 commit e2b6e4c

File tree

4 files changed

+203
-28
lines changed

4 files changed

+203
-28
lines changed

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,13 @@ function safelyCallComponentWillUnmount(current, instance) {
166166
);
167167
if (hasCaughtError()) {
168168
const unmountError = clearCaughtError();
169-
captureCommitPhaseError(current, unmountError);
169+
captureCommitPhaseError(current, unmountError, null);
170170
}
171171
} else {
172172
try {
173173
callComponentWillUnmountWithTimer(current, instance);
174174
} catch (unmountError) {
175-
captureCommitPhaseError(current, unmountError);
175+
captureCommitPhaseError(current, unmountError, null);
176176
}
177177
}
178178
}
@@ -185,13 +185,13 @@ function safelyDetachRef(current: Fiber) {
185185
invokeGuardedCallback(null, ref, null, null);
186186
if (hasCaughtError()) {
187187
const refError = clearCaughtError();
188-
captureCommitPhaseError(current, refError);
188+
captureCommitPhaseError(current, refError, null);
189189
}
190190
} else {
191191
try {
192192
ref(null);
193193
} catch (refError) {
194-
captureCommitPhaseError(current, refError);
194+
captureCommitPhaseError(current, refError, null);
195195
}
196196
}
197197
} else {
@@ -200,18 +200,22 @@ function safelyDetachRef(current: Fiber) {
200200
}
201201
}
202202

203-
export function safelyCallDestroy(current: Fiber, destroy: () => void) {
203+
export function safelyCallDestroy(
204+
current: Fiber,
205+
destroy: () => void,
206+
nearestMountedAncestor: Fiber | null,
207+
) {
204208
if (__DEV__) {
205209
invokeGuardedCallback(null, destroy, null);
206210
if (hasCaughtError()) {
207211
const error = clearCaughtError();
208-
captureCommitPhaseError(current, error);
212+
captureCommitPhaseError(current, error, nearestMountedAncestor);
209213
}
210214
} else {
211215
try {
212216
destroy();
213217
} catch (error) {
214-
captureCommitPhaseError(current, error);
218+
captureCommitPhaseError(current, error, nearestMountedAncestor);
215219
}
216220
}
217221
}
@@ -877,10 +881,10 @@ function commitUnmount(
877881
current.mode & ProfileMode
878882
) {
879883
startLayoutEffectTimer();
880-
safelyCallDestroy(current, destroy);
884+
safelyCallDestroy(current, destroy, null);
881885
recordLayoutEffectDuration(current);
882886
} else {
883-
safelyCallDestroy(current, destroy);
887+
safelyCallDestroy(current, destroy, null);
884888
}
885889
}
886890
}

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

Lines changed: 74 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2356,14 +2356,14 @@ function commitBeforeMutationEffects(firstChild: Fiber) {
23562356
invokeGuardedCallback(null, commitBeforeMutationEffectsImpl, null, fiber);
23572357
if (hasCaughtError()) {
23582358
const error = clearCaughtError();
2359-
captureCommitPhaseError(fiber, error);
2359+
captureCommitPhaseError(fiber, error, null);
23602360
}
23612361
resetCurrentDebugFiberInDEV();
23622362
} else {
23632363
try {
23642364
commitBeforeMutationEffectsImpl(fiber);
23652365
} catch (error) {
2366-
captureCommitPhaseError(fiber, error);
2366+
captureCommitPhaseError(fiber, error, null);
23672367
}
23682368
}
23692369
fiber = fiber.sibling;
@@ -2453,14 +2453,14 @@ function commitMutationEffects(
24532453
);
24542454
if (hasCaughtError()) {
24552455
const error = clearCaughtError();
2456-
captureCommitPhaseError(fiber, error);
2456+
captureCommitPhaseError(fiber, error, null);
24572457
}
24582458
resetCurrentDebugFiberInDEV();
24592459
} else {
24602460
try {
24612461
commitMutationEffectsImpl(fiber, root, renderPriorityLevel);
24622462
} catch (error) {
2463-
captureCommitPhaseError(fiber, error);
2463+
captureCommitPhaseError(fiber, error, null);
24642464
}
24652465
}
24662466
fiber = fiber.sibling;
@@ -2556,13 +2556,13 @@ function commitMutationEffectsDeletions(
25562556
);
25572557
if (hasCaughtError()) {
25582558
const error = clearCaughtError();
2559-
captureCommitPhaseError(childToDelete, error);
2559+
captureCommitPhaseError(childToDelete, error, null);
25602560
}
25612561
} else {
25622562
try {
25632563
commitDeletion(root, childToDelete, renderPriorityLevel);
25642564
} catch (error) {
2565-
captureCommitPhaseError(childToDelete, error);
2565+
captureCommitPhaseError(childToDelete, error, null);
25662566
}
25672567
}
25682568
}
@@ -2604,14 +2604,14 @@ function commitLayoutEffects(
26042604
);
26052605
if (hasCaughtError()) {
26062606
const error = clearCaughtError();
2607-
captureCommitPhaseError(fiber, error);
2607+
captureCommitPhaseError(fiber, error, null);
26082608
}
26092609
resetCurrentDebugFiberInDEV();
26102610
} else {
26112611
try {
26122612
commitLayoutEffectsImpl(fiber, root, committedLanes);
26132613
} catch (error) {
2614-
captureCommitPhaseError(fiber, error);
2614+
captureCommitPhaseError(fiber, error, null);
26152615
}
26162616
}
26172617
fiber = fiber.sibling;
@@ -2750,7 +2750,7 @@ function flushPassiveMountEffectsImpl(fiber: Fiber): void {
27502750
if (hasCaughtError()) {
27512751
invariant(fiber !== null, 'Should be working on an effect.');
27522752
const error = clearCaughtError();
2753-
captureCommitPhaseError(fiber, error);
2753+
captureCommitPhaseError(fiber, error, null);
27542754
}
27552755
} else {
27562756
try {
@@ -2771,7 +2771,7 @@ function flushPassiveMountEffectsImpl(fiber: Fiber): void {
27712771
}
27722772
} catch (error) {
27732773
invariant(fiber !== null, 'Should be working on an effect.');
2774-
captureCommitPhaseError(fiber, error);
2774+
captureCommitPhaseError(fiber, error, null);
27752775
}
27762776
}
27772777
}
@@ -2790,7 +2790,7 @@ function flushPassiveUnmountEffects(firstChild: Fiber): void {
27902790
if (deletions !== null) {
27912791
for (let i = 0; i < deletions.length; i++) {
27922792
const fiberToDelete = deletions[i];
2793-
flushPassiveUnmountEffectsInsideOfDeletedTree(fiberToDelete);
2793+
flushPassiveUnmountEffectsInsideOfDeletedTree(fiberToDelete, fiber);
27942794

27952795
// Now that passive effects have been processed, it's safe to detach lingering pointers.
27962796
detachFiberAfterEffects(fiberToDelete);
@@ -2816,7 +2816,11 @@ function flushPassiveUnmountEffects(firstChild: Fiber): void {
28162816
case Block: {
28172817
const primaryEffectTag = fiber.effectTag & Passive;
28182818
if (primaryEffectTag !== NoEffect) {
2819-
flushPassiveUnmountEffectsImpl(fiber, HookPassive | HookHasEffect);
2819+
flushPassiveUnmountEffectsImpl(
2820+
fiber,
2821+
HookPassive | HookHasEffect,
2822+
null,
2823+
);
28202824
}
28212825
}
28222826
}
@@ -2827,6 +2831,7 @@ function flushPassiveUnmountEffects(firstChild: Fiber): void {
28272831

28282832
function flushPassiveUnmountEffectsInsideOfDeletedTree(
28292833
fiberToDelete: Fiber,
2834+
nearestMountedAncestor: Fiber,
28302835
): void {
28312836
if ((fiberToDelete.subtreeTag & PassiveStaticSubtreeTag) !== NoSubtreeTag) {
28322837
// If any children have passive effects then traverse the subtree.
@@ -2835,7 +2840,10 @@ function flushPassiveUnmountEffectsInsideOfDeletedTree(
28352840
// since that would not cover passive effects in siblings.
28362841
let child = fiberToDelete.child;
28372842
while (child !== null) {
2838-
flushPassiveUnmountEffectsInsideOfDeletedTree(child);
2843+
flushPassiveUnmountEffectsInsideOfDeletedTree(
2844+
child,
2845+
nearestMountedAncestor,
2846+
);
28392847
child = child.sibling;
28402848
}
28412849
}
@@ -2846,7 +2854,11 @@ function flushPassiveUnmountEffectsInsideOfDeletedTree(
28462854
case ForwardRef:
28472855
case SimpleMemoComponent:
28482856
case Block: {
2849-
flushPassiveUnmountEffectsImpl(fiberToDelete, HookPassive);
2857+
flushPassiveUnmountEffectsImpl(
2858+
fiberToDelete,
2859+
HookPassive,
2860+
nearestMountedAncestor,
2861+
);
28502862
}
28512863
}
28522864
}
@@ -2857,6 +2869,7 @@ function flushPassiveUnmountEffectsImpl(
28572869
// Tags to check for when deciding whether to unmount. e.g. to skip over
28582870
// layout effects
28592871
hookEffectTag: HookEffectTag,
2872+
nearestMountedAncestor: Fiber | null,
28602873
): void {
28612874
const updateQueue: FunctionComponentUpdateQueue | null = (fiber.updateQueue: any);
28622875
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
@@ -2877,10 +2890,10 @@ function flushPassiveUnmountEffectsImpl(
28772890
fiber.mode & ProfileMode
28782891
) {
28792892
startPassiveEffectTimer();
2880-
safelyCallDestroy(fiber, destroy);
2893+
safelyCallDestroy(fiber, destroy, nearestMountedAncestor);
28812894
recordPassiveEffectDuration(fiber);
28822895
} else {
2883-
safelyCallDestroy(fiber, destroy);
2896+
safelyCallDestroy(fiber, destroy, nearestMountedAncestor);
28842897
}
28852898
}
28862899
}
@@ -3013,7 +3026,11 @@ function captureCommitPhaseErrorOnRoot(
30133026
}
30143027
}
30153028

3016-
export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
3029+
export function captureCommitPhaseError(
3030+
sourceFiber: Fiber,
3031+
error: mixed,
3032+
nearestMountedAncestor: Fiber | null,
3033+
) {
30173034
if (sourceFiber.tag === HostRoot) {
30183035
// Error was thrown at the root. There is no parent, so the root
30193036
// itself should capture it.
@@ -3022,6 +3039,7 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
30223039
}
30233040

30243041
let fiber = sourceFiber.return;
3042+
let foundErrorBoundary = false;
30253043
while (fiber !== null) {
30263044
if (fiber.tag === HostRoot) {
30273045
captureCommitPhaseErrorOnRoot(fiber, sourceFiber, error);
@@ -3034,6 +3052,8 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
30343052
(typeof instance.componentDidCatch === 'function' &&
30353053
!isAlreadyFailedLegacyErrorBoundary(instance))
30363054
) {
3055+
foundErrorBoundary = true;
3056+
30373057
const errorInfo = createCapturedValue(error, sourceFiber);
30383058
const update = createClassErrorUpdate(
30393059
fiber,
@@ -3047,12 +3067,49 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
30473067
markRootUpdated(root, SyncLane, eventTime);
30483068
ensureRootIsScheduled(root, eventTime);
30493069
schedulePendingInteractions(root, SyncLane);
3070+
} else {
3071+
// This component has already been unmounted.
3072+
// We can't schedule any follow up work for the root because the fiber is already unmounted,
3073+
// but we can still call the log-only boundary so the error isn't swallowed.
3074+
// TODO We will need to rethink this for function component boundaries
3075+
if (
3076+
typeof instance.componentDidCatch === 'function' &&
3077+
!isAlreadyFailedLegacyErrorBoundary(instance)
3078+
) {
3079+
try {
3080+
instance.componentDidCatch(error, errorInfo);
3081+
} catch {}
3082+
}
30503083
}
30513084
return;
30523085
}
30533086
}
30543087
fiber = fiber.return;
30553088
}
3089+
3090+
if (!foundErrorBoundary) {
3091+
if (nearestMountedAncestor !== null) {
3092+
// An error was thrown inside of an unmounted subtree that contained no error boundaries.
3093+
// Bubble up to the nearest still-mounted error boundary for loggin purposes.
3094+
fiber = nearestMountedAncestor;
3095+
while (fiber !== null) {
3096+
if (fiber.tag === ClassComponent) {
3097+
const instance = fiber.stateNode;
3098+
if (
3099+
typeof instance.componentDidCatch === 'function' &&
3100+
!isAlreadyFailedLegacyErrorBoundary(instance)
3101+
) {
3102+
const errorInfo = createCapturedValue(error, sourceFiber);
3103+
try {
3104+
instance.componentDidCatch(error, errorInfo);
3105+
} catch {}
3106+
return;
3107+
}
3108+
}
3109+
fiber = fiber.return;
3110+
}
3111+
}
3112+
}
30563113
}
30573114

30583115
export function pingSuspendedRoot(

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2806,6 +2806,19 @@ 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+
// TODO We will need to rethink this for function component boundaries
2814+
if (
2815+
typeof instance.componentDidCatch === 'function' &&
2816+
!isAlreadyFailedLegacyErrorBoundary(instance)
2817+
) {
2818+
try {
2819+
instance.componentDidCatch(error, errorInfo);
2820+
} catch {}
2821+
}
28092822
}
28102823
return;
28112824
}

0 commit comments

Comments
 (0)