Always skip unmounted/unmounting error boundaries (#19627)
The behavior of error boundaries for passive effects that throw during cleanup was recently changed so that React ignores boundaries which are also unmounting in favor of still-mounted boundaries. This commit implements that same behavior for layout effects (useLayoutEffect, componentWillUnmount, and ref-detachment). The new, skip-unmounting-boundaries behavior is behind a feature flag (`skipUnmountedBoundaries`).
Brian Vaughn committed
Aug 17, 2020 at 15:01 UTC
bcca5a6ca78b33504e0a328c411b043261e7e303
17 files changed
+480
-73
packages/react-dom/src/__tests__/ReactErrorBoundaries-test.internal.js
+171
@@ -2473,4 +2473,175 @@ describe('ReactErrorBoundaries', () => {
2473
'Caught an error: gotta catch em all.',
2474
);
2475
});
2476
+
2477
+ // @gate skipUnmountedBoundaries
2478
+ it('catches errors thrown in componentWillUnmount', () => {
2479
+ class LocalErrorBoundary extends React.Component {
2480
+ state = {error: null};
2481
+ static getDerivedStateFromError(error) {
2482
+ Scheduler.unstable_yieldValue(
2483
+ `ErrorBoundary static getDerivedStateFromError`,
2484
+ );
2485
+ return {error};
2486
+ }
2487
+ render() {
2488
+ const {children, id, fallbackID} = this.props;
2489
+ const {error} = this.state;
2490
+ if (error) {
2491
+ Scheduler.unstable_yieldValue(`${id} render error`);
2492
+ return <Component id={fallbackID} />;
2493
+ }
2494
+ Scheduler.unstable_yieldValue(`${id} render success`);
2495
+ return children || null;
2496
+ }
2497
+ }
2498
+
2499
+ class Component extends React.Component {
2500
+ render() {
2501
+ const {id} = this.props;
2502
+ Scheduler.unstable_yieldValue('Component render ' + id);
2503
+ return id;
2504
+ }
2505
+ }
2506
+
2507
+ class LocalBrokenComponentWillUnmount extends React.Component {
2508
+ componentWillUnmount() {
2509
+ Scheduler.unstable_yieldValue(
2510
+ 'BrokenComponentWillUnmount componentWillUnmount',
2511
+ );
2512
+ throw Error('Expected');
2513
+ }
2514
+
2515
+ render() {
2516
+ Scheduler.unstable_yieldValue('BrokenComponentWillUnmount render');
2517
+ return 'broken';
2518
+ }
2519
+ }
2520
+
2521
+ const container = document.createElement('div');
2522
+
2523
+ ReactDOM.render(
2524
+ <LocalErrorBoundary id="OuterBoundary" fallbackID="OuterFallback">
2525
+ <Component id="sibling" />
2526
+ <LocalErrorBoundary id="InnerBoundary" fallbackID="InnerFallback">
2527
+ <LocalBrokenComponentWillUnmount />
2528
+ </LocalErrorBoundary>
2529
+ </LocalErrorBoundary>,
2530
+ container,
2531
+ );
2532
+
2533
+ expect(container.firstChild.textContent).toBe('sibling');
2534
+ expect(container.lastChild.textContent).toBe('broken');
2535
+ expect(Scheduler).toHaveYielded([
2536
+ 'OuterBoundary render success',
2537
+ 'Component render sibling',
2538
+ 'InnerBoundary render success',
2539
+ 'BrokenComponentWillUnmount render',
2540
+ ]);
2541
+
2542
+ ReactDOM.render(
2543
+ <LocalErrorBoundary id="OuterBoundary" fallbackID="OuterFallback">
2544
+ <Component id="sibling" />
2545
+ </LocalErrorBoundary>,
2546
+ container,
2547
+ );
2548
+
2549
+ // React should skip over the unmounting boundary and find the nearest still-mounted boundary.
2550
+ expect(container.firstChild.textContent).toBe('OuterFallback');
2551
+ expect(container.lastChild.textContent).toBe('OuterFallback');
2552
+ expect(Scheduler).toHaveYielded([
2553
+ 'OuterBoundary render success',
2554
+ 'Component render sibling',
2555
+ 'BrokenComponentWillUnmount componentWillUnmount',
2556
+ 'ErrorBoundary static getDerivedStateFromError',
2557
+ 'OuterBoundary render error',
2558
+ 'Component render OuterFallback',
2559
+ ]);
2560
+ });
2561
+
2562
+ // @gate skipUnmountedBoundaries
2563
+ it('catches errors thrown while detaching refs', () => {
2564
+ class LocalErrorBoundary extends React.Component {
2565
+ state = {error: null};
2566
+ static getDerivedStateFromError(error) {
2567
+ Scheduler.unstable_yieldValue(
2568
+ `ErrorBoundary static getDerivedStateFromError`,
2569
+ );
2570
+ return {error};
2571
+ }
2572
+ render() {
2573
+ const {children, id, fallbackID} = this.props;
2574
+ const {error} = this.state;
2575
+ if (error) {
2576
+ Scheduler.unstable_yieldValue(`${id} render error`);
2577
+ return <Component id={fallbackID} />;
2578
+ }
2579
+ Scheduler.unstable_yieldValue(`${id} render success`);
2580
+ return children || null;
2581
+ }
2582
+ }
2583
+
2584
+ class Component extends React.Component {
2585
+ render() {
2586
+ const {id} = this.props;
2587
+ Scheduler.unstable_yieldValue('Component render ' + id);
2588
+ return id;
2589
+ }
2590
+ }
2591
+
2592
+ class LocalBrokenCallbackRef extends React.Component {
2593
+ _ref = ref => {
2594
+ Scheduler.unstable_yieldValue('LocalBrokenCallbackRef ref ' + !!ref);
2595
+ if (ref === null) {
2596
+ throw Error('Expected');
2597
+ }
2598
+ };
2599
+
2600
+ render() {
2601
+ Scheduler.unstable_yieldValue('LocalBrokenCallbackRef render');
2602
+ return <div ref={this._ref}>ref</div>;
2603
+ }
2604
+ }
2605
+
2606
+ const container = document.createElement('div');
2607
+
2608
+ ReactDOM.render(
2609
+ <LocalErrorBoundary id="OuterBoundary" fallbackID="OuterFallback">
2610
+ <Component id="sibling" />
2611
+ <LocalErrorBoundary id="InnerBoundary" fallbackID="InnerFallback">
2612
+ <LocalBrokenCallbackRef />
2613
+ </LocalErrorBoundary>
2614
+ </LocalErrorBoundary>,
2615
+ container,
2616
+ );
2617
+
2618
+ expect(container.firstChild.textContent).toBe('sibling');
2619
+ expect(container.lastChild.textContent).toBe('ref');
2620
+ expect(Scheduler).toHaveYielded([
2621
+ 'OuterBoundary render success',
2622
+ 'Component render sibling',
2623
+ 'InnerBoundary render success',
2624
+ 'LocalBrokenCallbackRef render',
2625
+ 'LocalBrokenCallbackRef ref true',
2626
+ ]);
2627
+
2628
+ ReactDOM.render(
2629
+ <LocalErrorBoundary id="OuterBoundary" fallbackID="OuterFallback">
2630
+ <Component id="sibling" />
2631
+ </LocalErrorBoundary>,
2632
+ container,
2633
+ );
2634
+
2635
+ // React should skip over the unmounting boundary and find the nearest still-mounted boundary.
2636
+ expect(container.firstChild.textContent).toBe('OuterFallback');
2637
+ expect(container.lastChild.textContent).toBe('OuterFallback');
2638
+ expect(Scheduler).toHaveYielded([
2639
+ 'OuterBoundary render success',
2640
+ 'Component render sibling',
2641
+ 'LocalBrokenCallbackRef ref false',
2642
+ 'ErrorBoundary static getDerivedStateFromError',
2643
+ 'OuterBoundary render error',
2644
+ 'Component render OuterFallback',
2645
+ ]);
2646
+ });
2647
});
packages/react-reconciler/src/ReactFiberCommitWork.new.js
+69
-22
@@ -162,7 +162,11 @@ const callComponentWillUnmountWithTimer = function(current, instance) {
162
};
163
164
// Capture errors so they don't interrupt unmounting.
165
-function safelyCallComponentWillUnmount(current, instance) {
165
+function safelyCallComponentWillUnmount(
166
+ current: Fiber,
167
+ instance: any,
168
+ nearestMountedAncestor: Fiber,
169
+) {
170
if (__DEV__) {
171
invokeGuardedCallback(
172
null,
@@ -173,18 +177,18 @@ function safelyCallComponentWillUnmount(current, instance) {
177
);
178
if (hasCaughtError()) {
179
const unmountError = clearCaughtError();
176
- captureCommitPhaseError(current, current.return, unmountError);
180
+ captureCommitPhaseError(current, nearestMountedAncestor, unmountError);
181
}
182
} else {
183
try {
184
callComponentWillUnmountWithTimer(current, instance);
185
} catch (unmountError) {
182
- captureCommitPhaseError(current, current.return, unmountError);
186
+ captureCommitPhaseError(current, nearestMountedAncestor, unmountError);
187
}
188
}
189
}
190
187
-function safelyDetachRef(current: Fiber) {
191
+function safelyDetachRef(current: Fiber, nearestMountedAncestor: Fiber) {
192
const ref = current.ref;
193
if (ref !== null) {
194
if (typeof ref === 'function') {
@@ -192,13 +196,13 @@ function safelyDetachRef(current: Fiber) {
196
invokeGuardedCallback(null, ref, null, null);
197
if (hasCaughtError()) {
198
const refError = clearCaughtError();
195
- captureCommitPhaseError(current, current.return, refError);
199
+ captureCommitPhaseError(current, nearestMountedAncestor, refError);
200
}
201
} else {
202
try {
203
ref(null);
204
} catch (refError) {
201
- captureCommitPhaseError(current, current.return, refError);
205
+ captureCommitPhaseError(current, nearestMountedAncestor, refError);
206
}
207
}
208
} else {
@@ -974,6 +978,7 @@ function commitDetachRef(current: Fiber) {
978
function commitUnmount(
979
finishedRoot: FiberRoot,
980
current: Fiber,
981
+ nearestMountedAncestor: Fiber,
982
renderPriorityLevel: ReactPriorityLevel,
983
): void {
984
onCommitUnmount(current);
@@ -1001,10 +1006,10 @@ function commitUnmount(
1006
current.mode & ProfileMode
1007
) {
1008
startLayoutEffectTimer();
1004
- safelyCallDestroy(current, current.return, destroy);
1009
+ safelyCallDestroy(current, nearestMountedAncestor, destroy);
1010
recordLayoutEffectDuration(current);
1011
} else {
1007
- safelyCallDestroy(current, current.return, destroy);
1012
+ safelyCallDestroy(current, nearestMountedAncestor, destroy);
1013
}
1014
}
1015
}
@@ -1015,15 +1020,19 @@ function commitUnmount(
1020
return;
1021
}
1022
case ClassComponent: {
1018
- safelyDetachRef(current);
1023
+ safelyDetachRef(current, nearestMountedAncestor);
1024
const instance = current.stateNode;
1025
if (typeof instance.componentWillUnmount === 'function') {
1021
- safelyCallComponentWillUnmount(current, instance);
1026
+ safelyCallComponentWillUnmount(
1027
+ current,
1028
+ instance,
1029
+ nearestMountedAncestor,
1030
+ );
1031
}
1032
return;
1033
}
1034
case HostComponent: {
1026
- safelyDetachRef(current);
1035
+ safelyDetachRef(current, nearestMountedAncestor);
1036
return;
1037
}
1038
case HostPortal: {
@@ -1031,7 +1040,12 @@ function commitUnmount(
1040
// We are also not using this parent because
1041
// the portal will get pushed immediately.
1042
if (supportsMutation) {
1034
- unmountHostComponents(finishedRoot, current, renderPriorityLevel);
1043
+ unmountHostComponents(
1044
+ finishedRoot,
1045
+ current,
1046
+ nearestMountedAncestor,
1047
+ renderPriorityLevel,
1048
+ );
1049
} else if (supportsPersistence) {
1050
emptyPortalContainer(current);
1051
}
@@ -1061,7 +1075,7 @@ function commitUnmount(
1075
}
1076
case ScopeComponent: {
1077
if (enableScopeAPI) {
1064
- safelyDetachRef(current);
1078
+ safelyDetachRef(current, nearestMountedAncestor);
1079
}
1080
return;
1081
}
@@ -1071,6 +1085,7 @@ function commitUnmount(
1085
function commitNestedUnmounts(
1086
finishedRoot: FiberRoot,
1087
root: Fiber,
1088
+ nearestMountedAncestor: Fiber,
1089
renderPriorityLevel: ReactPriorityLevel,
1090
): void {
1091
// While we're inside a removed host node we don't want to call
@@ -1080,7 +1095,12 @@ function commitNestedUnmounts(
1095
// we do an inner loop while we're still inside the host node.
1096
let node: Fiber = root;
1097
while (true) {
1083
- commitUnmount(finishedRoot, node, renderPriorityLevel);
1098
+ commitUnmount(
1099
+ finishedRoot,
1100
+ node,
1101
+ nearestMountedAncestor,
1102
+ renderPriorityLevel,
1103
+ );
1104
// Visit children because they may contain more composite or host nodes.
1105
// Skip portals because commitUnmount() currently visits them recursively.
1106
if (
@@ -1361,9 +1381,10 @@ function insertOrAppendPlacementNode(
1381
}
1382
1383
function unmountHostComponents(
1364
- finishedRoot,
1365
- current,
1366
- renderPriorityLevel,
1384
+ finishedRoot: FiberRoot,
1385
+ current: Fiber,
1386
+ nearestMountedAncestor: Fiber,
1387
+ renderPriorityLevel: ReactPriorityLevel,
1388
): void {
1389
// We only have the top Fiber that was deleted but we need to recurse down its
1390
// children to find all the terminal nodes.
@@ -1412,7 +1433,12 @@ function unmountHostComponents(
1433
}
1434
1435
if (node.tag === HostComponent || node.tag === HostText) {
1415
- commitNestedUnmounts(finishedRoot, node, renderPriorityLevel);
1436
+ commitNestedUnmounts(
1437
+ finishedRoot,
1438
+ node,
1439
+ nearestMountedAncestor,
1440
+ renderPriorityLevel,
1441
+ );
1442
// After all the children have unmounted, it is now safe to remove the
1443
// node from the tree.
1444
if (currentParentIsContainer) {
@@ -1429,7 +1455,12 @@ function unmountHostComponents(
1455
// Don't visit children because we already visited them.
1456
} else if (enableFundamentalAPI && node.tag === FundamentalComponent) {
1457
const fundamentalNode = node.stateNode.instance;
1432
- commitNestedUnmounts(finishedRoot, node, renderPriorityLevel);
1458
+ commitNestedUnmounts(
1459
+ finishedRoot,
1460
+ node,
1461
+ nearestMountedAncestor,
1462
+ renderPriorityLevel,
1463
+ );
1464
// After all the children have unmounted, it is now safe to remove the
1465
// node from the tree.
1466
if (currentParentIsContainer) {
@@ -1481,7 +1512,12 @@ function unmountHostComponents(
1512
continue;
1513
}
1514
} else {
1484
- commitUnmount(finishedRoot, node, renderPriorityLevel);
1515
+ commitUnmount(
1516
+ finishedRoot,
1517
+ node,
1518
+ nearestMountedAncestor,
1519
+ renderPriorityLevel,
1520
+ );
1521
// Visit children because we may find more host components below.
1522
if (node.child !== null) {
1523
node.child.return = node;
@@ -1511,15 +1547,26 @@ function unmountHostComponents(
1547
function commitDeletion(
1548
finishedRoot: FiberRoot,
1549
current: Fiber,
1550
+ nearestMountedAncestor: Fiber,
1551
renderPriorityLevel: ReactPriorityLevel,
1552
): void {
1553
if (supportsMutation) {
1554
// Recursively delete all host nodes from the parent.
1555
// Detach refs and call componentWillUnmount() on the whole subtree.
1519
- unmountHostComponents(finishedRoot, current, renderPriorityLevel);
1556
+ unmountHostComponents(
1557
+ finishedRoot,
1558
+ current,
1559
+ nearestMountedAncestor,
1560
+ renderPriorityLevel,
1561
+ );
1562
} else {
1563
// Detach refs and call componentWillUnmount() on the whole subtree.
1522
- commitNestedUnmounts(finishedRoot, current, renderPriorityLevel);
1564
+ commitNestedUnmounts(
1565
+ finishedRoot,
1566
+ current,
1567
+ nearestMountedAncestor,
1568
+ renderPriorityLevel,
1569
+ );
1570
}
1571
const alternate = current.alternate;
1572
detachFiberMutation(current);
packages/react-reconciler/src/ReactFiberCommitWork.old.js
+76
-25
@@ -153,7 +153,11 @@ const callComponentWillUnmountWithTimer = function(current, instance) {
153
};
154
155
// Capture errors so they don't interrupt unmounting.
156
-function safelyCallComponentWillUnmount(current, instance) {
156
+function safelyCallComponentWillUnmount(
157
+ current: Fiber,
158
+ instance: any,
159
+ nearestMountedAncestor: Fiber | null,
160
+) {
161
if (__DEV__) {
162
invokeGuardedCallback(
163
null,
@@ -164,18 +168,18 @@ function safelyCallComponentWillUnmount(current, instance) {
168
);
169
if (hasCaughtError()) {
170
const unmountError = clearCaughtError();
167
- captureCommitPhaseError(current, unmountError);
171
+ captureCommitPhaseError(current, nearestMountedAncestor, unmountError);
172
}
173
} else {
174
try {
175
callComponentWillUnmountWithTimer(current, instance);
176
} catch (unmountError) {
173
- captureCommitPhaseError(current, unmountError);
177
+ captureCommitPhaseError(current, nearestMountedAncestor, unmountError);
178
}
179
}
180
}
181
178
-function safelyDetachRef(current: Fiber) {
182
+function safelyDetachRef(current: Fiber, nearestMountedAncestor: Fiber | null) {
183
const ref = current.ref;
184
if (ref !== null) {
185
if (typeof ref === 'function') {
@@ -183,13 +187,13 @@ function safelyDetachRef(current: Fiber) {
187
invokeGuardedCallback(null, ref, null, null);
188
if (hasCaughtError()) {
189
const refError = clearCaughtError();
186
- captureCommitPhaseError(current, refError);
190
+ captureCommitPhaseError(current, nearestMountedAncestor, refError);
191
}
192
} else {
193
try {
194
ref(null);
195
} catch (refError) {
192
- captureCommitPhaseError(current, refError);
196
+ captureCommitPhaseError(current, nearestMountedAncestor, refError);
197
}
198
}
199
} else {
@@ -198,18 +202,22 @@ function safelyDetachRef(current: Fiber) {
202
}
203
}
204
201
-function safelyCallDestroy(current, destroy) {
205
+function safelyCallDestroy(
206
+ current: Fiber,
207
+ nearestMountedAncestor: Fiber | null,
208
+ destroy: () => void,
209
+) {
210
if (__DEV__) {
211
invokeGuardedCallback(null, destroy, null);
212
if (hasCaughtError()) {
213
const error = clearCaughtError();
206
- captureCommitPhaseError(current, error);
214
+ captureCommitPhaseError(current, nearestMountedAncestor, error);
215
}
216
} else {
217
try {
218
destroy();
219
} catch (error) {
212
- captureCommitPhaseError(current, error);
220
+ captureCommitPhaseError(current, nearestMountedAncestor, error);
221
}
222
}
223
}
@@ -866,6 +874,7 @@ function commitDetachRef(current: Fiber) {
874
function commitUnmount(
875
finishedRoot: FiberRoot,
876
current: Fiber,
877
+ nearestMountedAncestor: Fiber | null,
878
renderPriorityLevel: ReactPriorityLevel,
879
): void {
880
onCommitUnmount(current);
@@ -895,10 +904,10 @@ function commitUnmount(
904
current.mode & ProfileMode
905
) {
906
startLayoutEffectTimer();
898
- safelyCallDestroy(current, destroy);
907
+ safelyCallDestroy(current, nearestMountedAncestor, destroy);
908
recordLayoutEffectDuration(current);
909
} else {
901
- safelyCallDestroy(current, destroy);
910
+ safelyCallDestroy(current, nearestMountedAncestor, destroy);
911
}
912
}
913
}
@@ -909,15 +918,19 @@ function commitUnmount(
918
return;
919
}
920
case ClassComponent: {
912
- safelyDetachRef(current);
921
+ safelyDetachRef(current, nearestMountedAncestor);
922
const instance = current.stateNode;
923
if (typeof instance.componentWillUnmount === 'function') {
915
- safelyCallComponentWillUnmount(current, instance);
924
+ safelyCallComponentWillUnmount(
925
+ current,
926
+ instance,
927
+ nearestMountedAncestor,
928
+ );
929
}
930
return;
931
}
932
case HostComponent: {
920
- safelyDetachRef(current);
933
+ safelyDetachRef(current, nearestMountedAncestor);
934
return;
935
}
936
case HostPortal: {
@@ -925,7 +938,12 @@ function commitUnmount(
938
// We are also not using this parent because
939
// the portal will get pushed immediately.
940
if (supportsMutation) {
928
- unmountHostComponents(finishedRoot, current, renderPriorityLevel);
941
+ unmountHostComponents(
942
+ finishedRoot,
943
+ current,
944
+ nearestMountedAncestor,
945
+ renderPriorityLevel,
946
+ );
947
} else if (supportsPersistence) {
948
emptyPortalContainer(current);
949
}
@@ -955,7 +973,7 @@ function commitUnmount(
973
}
974
case ScopeComponent: {
975
if (enableScopeAPI) {
958
- safelyDetachRef(current);
976
+ safelyDetachRef(current, nearestMountedAncestor);
977
}
978
return;
979
}
@@ -965,6 +983,7 @@ function commitUnmount(
983
function commitNestedUnmounts(
984
finishedRoot: FiberRoot,
985
root: Fiber,
986
+ nearestMountedAncestor: Fiber | null,
987
renderPriorityLevel: ReactPriorityLevel,
988
): void {
989
// While we're inside a removed host node we don't want to call
@@ -974,7 +993,12 @@ function commitNestedUnmounts(
993
// we do an inner loop while we're still inside the host node.
994
let node: Fiber = root;
995
while (true) {
977
- commitUnmount(finishedRoot, node, renderPriorityLevel);
996
+ commitUnmount(
997
+ finishedRoot,
998
+ node,
999
+ nearestMountedAncestor,
1000
+ renderPriorityLevel,
1001
+ );
1002
// Visit children because they may contain more composite or host nodes.
1003
// Skip portals because commitUnmount() currently visits them recursively.
1004
if (
@@ -1263,9 +1287,10 @@ function insertOrAppendPlacementNode(
1287
}
1288
1289
function unmountHostComponents(
1266
- finishedRoot,
1267
- current,
1268
- renderPriorityLevel,
1290
+ finishedRoot: FiberRoot,
1291
+ current: Fiber,
1292
+ nearestMountedAncestor: Fiber | null,
1293
+ renderPriorityLevel: ReactPriorityLevel,
1294
): void {
1295
// We only have the top Fiber that was deleted but we need to recurse down its
1296
// children to find all the terminal nodes.
@@ -1314,7 +1339,12 @@ function unmountHostComponents(
1339
}
1340
1341
if (node.tag === HostComponent || node.tag === HostText) {
1317
- commitNestedUnmounts(finishedRoot, node, renderPriorityLevel);
1342
+ commitNestedUnmounts(
1343
+ finishedRoot,
1344
+ node,
1345
+ nearestMountedAncestor,
1346
+ renderPriorityLevel,
1347
+ );
1348
// After all the children have unmounted, it is now safe to remove the
1349
// node from the tree.
1350
if (currentParentIsContainer) {
@@ -1331,7 +1361,12 @@ function unmountHostComponents(
1361
// Don't visit children because we already visited them.
1362
} else if (enableFundamentalAPI && node.tag === FundamentalComponent) {
1363
const fundamentalNode = node.stateNode.instance;
1334
- commitNestedUnmounts(finishedRoot, node, renderPriorityLevel);
1364
+ commitNestedUnmounts(
1365
+ finishedRoot,
1366
+ node,
1367
+ nearestMountedAncestor,
1368
+ renderPriorityLevel,
1369
+ );
1370
// After all the children have unmounted, it is now safe to remove the
1371
// node from the tree.
1372
if (currentParentIsContainer) {
@@ -1383,7 +1418,12 @@ function unmountHostComponents(
1418
continue;
1419
}
1420
} else {
1386
- commitUnmount(finishedRoot, node, renderPriorityLevel);
1421
+ commitUnmount(
1422
+ finishedRoot,
1423
+ node,
1424
+ nearestMountedAncestor,
1425
+ renderPriorityLevel,
1426
+ );
1427
// Visit children because we may find more host components below.
1428
if (node.child !== null) {
1429
node.child.return = node;
@@ -1413,15 +1453,26 @@ function unmountHostComponents(
1453
function commitDeletion(
1454
finishedRoot: FiberRoot,
1455
current: Fiber,
1456
+ nearestMountedAncestor: Fiber | null,
1457
renderPriorityLevel: ReactPriorityLevel,
1458
): void {
1459
if (supportsMutation) {
1460
// Recursively delete all host nodes from the parent.
1461
// Detach refs and call componentWillUnmount() on the whole subtree.
1421
- unmountHostComponents(finishedRoot, current, renderPriorityLevel);
1462
+ unmountHostComponents(
1463
+ finishedRoot,
1464
+ current,
1465
+ nearestMountedAncestor,
1466
+ renderPriorityLevel,
1467
+ );
1468
} else {
1469
// Detach refs and call componentWillUnmount() on the whole subtree.
1424
- commitNestedUnmounts(finishedRoot, current, renderPriorityLevel);
1470
+ commitNestedUnmounts(
1471
+ finishedRoot,
1472
+ current,
1473
+ nearestMountedAncestor,
1474
+ renderPriorityLevel,
1475
+ );
1476
}
1477
const alternate = current.alternate;
1478
detachFiberMutation(current);
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+25
-6
@@ -30,6 +30,7 @@ import {
30
enableDebugTracing,
31
enableSchedulingProfiler,
32
enableScopeAPI,
33
+ skipUnmountedBoundaries,
34
} from 'shared/ReactFeatureFlags';
35
import ReactSharedInternals from 'shared/ReactSharedInternals';
36
import invariant from 'shared/invariant';
@@ -2462,13 +2463,18 @@ function commitBeforeMutationEffectsDeletions(deletions: Array<Fiber>) {
2463
function commitMutationEffects(
2464
firstChild: Fiber,
2465
root: FiberRoot,
2465
- renderPriorityLevel,
2466
+ renderPriorityLevel: ReactPriorityLevel,
2467
) {
2468
let fiber = firstChild;
2469
while (fiber !== null) {
2470
const deletions = fiber.deletions;
2471
if (deletions !== null) {
2471
- commitMutationEffectsDeletions(deletions, root, renderPriorityLevel);
2472
+ commitMutationEffectsDeletions(
2473
+ deletions,
2474
+ fiber,
2475
+ root,
2476
+ renderPriorityLevel,
2477
+ );
2478
}
2479
2480
if (fiber.child !== null) {
@@ -2577,6 +2583,7 @@ function commitMutationEffectsImpl(
2583
2584
function commitMutationEffectsDeletions(
2585
deletions: Array<Fiber>,
2586
+ nearestMountedAncestor: Fiber,
2587
root: FiberRoot,
2588
renderPriorityLevel,
2589
) {
@@ -2589,17 +2596,23 @@ function commitMutationEffectsDeletions(
2596
null,
2597
root,
2598
childToDelete,
2599
+ nearestMountedAncestor,
2600
renderPriorityLevel,
2601
);
2602
if (hasCaughtError()) {
2603
const error = clearCaughtError();
2596
- captureCommitPhaseError(childToDelete, childToDelete.return, error);
2604
+ captureCommitPhaseError(childToDelete, nearestMountedAncestor, error);
2605
}
2606
} else {
2607
try {
2600
- commitDeletion(root, childToDelete, renderPriorityLevel);
2608
+ commitDeletion(
2609
+ root,
2610
+ childToDelete,
2611
+ nearestMountedAncestor,
2612
+ renderPriorityLevel,
2613
+ );
2614
} catch (error) {
2602
- captureCommitPhaseError(childToDelete, childToDelete.return, error);
2615
+ captureCommitPhaseError(childToDelete, nearestMountedAncestor, error);
2616
}
2617
}
2618
}
@@ -2938,7 +2951,13 @@ export function captureCommitPhaseError(
2951
return;
2952
}
2953
2941
- let fiber = nearestMountedAncestor;
2954
+ let fiber = null;
2955
+ if (skipUnmountedBoundaries) {
2956
+ fiber = nearestMountedAncestor;
2957
+ } else {
2958
+ fiber = sourceFiber.return;
2959
+ }
2960
+
2961
while (fiber !== null) {
2962
if (fiber.tag === HostRoot) {
2963
captureCommitPhaseErrorOnRoot(fiber, sourceFiber, error);
packages/react-reconciler/src/ReactFiberWorkLoop.old.js
+33
-14
@@ -30,6 +30,7 @@ import {
30
enableDebugTracing,
31
enableSchedulingProfiler,
32
enableScopeAPI,
33
+ skipUnmountedBoundaries,
34
} from 'shared/ReactFeatureFlags';
35
import ReactSharedInternals from 'shared/ReactSharedInternals';
36
import invariant from 'shared/invariant';
@@ -2084,7 +2085,7 @@ function commitRootImpl(root, renderPriorityLevel) {
2085
if (hasCaughtError()) {
2086
invariant(nextEffect !== null, 'Should be working on an effect.');
2087
const error = clearCaughtError();
2087
- captureCommitPhaseError(nextEffect, error);
2088
+ captureCommitPhaseError(nextEffect, nextEffect.return, error);
2089
nextEffect = nextEffect.nextEffect;
2090
}
2091
} else {
@@ -2092,7 +2093,7 @@ function commitRootImpl(root, renderPriorityLevel) {
2093
commitBeforeMutationEffects();
2094
} catch (error) {
2095
invariant(nextEffect !== null, 'Should be working on an effect.');
2095
- captureCommitPhaseError(nextEffect, error);
2096
+ captureCommitPhaseError(nextEffect, nextEffect.return, error);
2097
nextEffect = nextEffect.nextEffect;
2098
}
2099
}
@@ -2121,7 +2122,7 @@ function commitRootImpl(root, renderPriorityLevel) {
2122
if (hasCaughtError()) {
2123
invariant(nextEffect !== null, 'Should be working on an effect.');
2124
const error = clearCaughtError();
2124
- captureCommitPhaseError(nextEffect, error);
2125
+ captureCommitPhaseError(nextEffect, nextEffect.return, error);
2126
nextEffect = nextEffect.nextEffect;
2127
}
2128
} else {
@@ -2129,7 +2130,7 @@ function commitRootImpl(root, renderPriorityLevel) {
2130
commitMutationEffects(root, renderPriorityLevel);
2131
} catch (error) {
2132
invariant(nextEffect !== null, 'Should be working on an effect.');
2132
- captureCommitPhaseError(nextEffect, error);
2133
+ captureCommitPhaseError(nextEffect, nextEffect.return, error);
2134
nextEffect = nextEffect.nextEffect;
2135
}
2136
}
@@ -2156,7 +2157,7 @@ function commitRootImpl(root, renderPriorityLevel) {
2157
if (hasCaughtError()) {
2158
invariant(nextEffect !== null, 'Should be working on an effect.');
2159
const error = clearCaughtError();
2159
- captureCommitPhaseError(nextEffect, error);
2160
+ captureCommitPhaseError(nextEffect, nextEffect.return, error);
2161
nextEffect = nextEffect.nextEffect;
2162
}
2163
} else {
@@ -2164,7 +2165,7 @@ function commitRootImpl(root, renderPriorityLevel) {
2165
commitLayoutEffects(root, lanes);
2166
} catch (error) {
2167
invariant(nextEffect !== null, 'Should be working on an effect.');
2167
- captureCommitPhaseError(nextEffect, error);
2168
+ captureCommitPhaseError(nextEffect, nextEffect.return, error);
2169
nextEffect = nextEffect.nextEffect;
2170
}
2171
}
@@ -2365,7 +2366,10 @@ function commitBeforeMutationEffects() {
2366
}
2367
}
2368
2368
-function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
2369
+function commitMutationEffects(
2370
+ root: FiberRoot,
2371
+ renderPriorityLevel: ReactPriorityLevel,
2372
+) {
2373
// TODO: Should probably move the bulk of this function to commitWork.
2374
while (nextEffect !== null) {
2375
setCurrentDebugFiberInDEV(nextEffect);
@@ -2436,7 +2440,12 @@ function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
2440
break;
2441
}
2442
case Deletion: {
2439
- commitDeletion(root, nextEffect, renderPriorityLevel);
2443
+ commitDeletion(
2444
+ root,
2445
+ nextEffect,
2446
+ nextEffect.return,
2447
+ renderPriorityLevel,
2448
+ );
2449
break;
2450
}
2451
}
@@ -2647,7 +2656,7 @@ function flushPassiveEffectsImpl() {
2656
if (hasCaughtError()) {
2657
invariant(fiber !== null, 'Should be working on an effect.');
2658
const error = clearCaughtError();
2650
- captureCommitPhaseError(fiber, error);
2659
+ captureCommitPhaseError(fiber, fiber.return, error);
2660
}
2661
resetCurrentDebugFiberInDEV();
2662
} else {
@@ -2668,7 +2677,7 @@ function flushPassiveEffectsImpl() {
2677
}
2678
} catch (error) {
2679
invariant(fiber !== null, 'Should be working on an effect.');
2671
- captureCommitPhaseError(fiber, error);
2680
+ captureCommitPhaseError(fiber, fiber.return, error);
2681
}
2682
}
2683
}
@@ -2695,7 +2704,7 @@ function flushPassiveEffectsImpl() {
2704
if (hasCaughtError()) {
2705
invariant(fiber !== null, 'Should be working on an effect.');
2706
const error = clearCaughtError();
2698
- captureCommitPhaseError(fiber, error);
2707
+ captureCommitPhaseError(fiber, fiber.return, error);
2708
}
2709
resetCurrentDebugFiberInDEV();
2710
} else {
@@ -2717,7 +2726,7 @@ function flushPassiveEffectsImpl() {
2726
}
2727
} catch (error) {
2728
invariant(fiber !== null, 'Should be working on an effect.');
2720
- captureCommitPhaseError(fiber, error);
2729
+ captureCommitPhaseError(fiber, fiber.return, error);
2730
}
2731
}
2732
}
@@ -2816,7 +2825,11 @@ function captureCommitPhaseErrorOnRoot(
2825
}
2826
}
2827
2819
-export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
2828
+export function captureCommitPhaseError(
2829
+ sourceFiber: Fiber,
2830
+ nearestMountedAncestor: Fiber | null,
2831
+ error: mixed,
2832
+) {
2833
if (sourceFiber.tag === HostRoot) {
2834
// Error was thrown at the root. There is no parent, so the root
2835
// itself should capture it.
@@ -2824,7 +2837,13 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
2837
return;
2838
}
2839
2827
- let fiber = sourceFiber.return;
2840
+ let fiber = null;
2841
+ if (skipUnmountedBoundaries) {
2842
+ fiber = nearestMountedAncestor;
2843
+ } else {
2844
+ fiber = sourceFiber.return;
2845
+ }
2846
+
2847
while (fiber !== null) {
2848
if (fiber.tag === HostRoot) {
2849
captureCommitPhaseErrorOnRoot(fiber, sourceFiber, error);
packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js
+79
@@ -2797,6 +2797,85 @@ describe('ReactHooksWithNoopRenderer', () => {
2797
'Mount normal [current: 1]',
2798
]);
2799
});
2800
+
2801
+ // @gate skipUnmountedBoundaries
2802
+ it('catches errors thrown in useLayoutEffect', () => {
2803
+ class ErrorBoundary extends React.Component {
2804
+ state = {error: null};
2805
+ static getDerivedStateFromError(error) {
2806
+ Scheduler.unstable_yieldValue(
2807
+ `ErrorBoundary static getDerivedStateFromError`,
2808
+ );
2809
+ return {error};
2810
+ }
2811
+ render() {
2812
+ const {children, id, fallbackID} = this.props;
2813
+ const {error} = this.state;
2814
+ if (error) {
2815
+ Scheduler.unstable_yieldValue(`${id} render error`);
2816
+ return <Component id={fallbackID} />;
2817
+ }
2818
+ Scheduler.unstable_yieldValue(`${id} render success`);
2819
+ return children || null;
2820
+ }
2821
+ }
2822
+
2823
+ function Component({id}) {
2824
+ Scheduler.unstable_yieldValue('Component render ' + id);
2825
+ return <span prop={id} />;
2826
+ }
2827
+
2828
+ function BrokenLayoutEffectDestroy() {
2829
+ useLayoutEffect(() => {
2830
+ return () => {
2831
+ Scheduler.unstable_yieldValue(
2832
+ 'BrokenLayoutEffectDestroy useLayoutEffect destroy',
2833
+ );
2834
+ throw Error('Expected');
2835
+ };
2836
+ }, []);
2837
+
2838
+ Scheduler.unstable_yieldValue('BrokenLayoutEffectDestroy render');
2839
+ return <span prop="broken" />;
2840
+ }
2841
+
2842
+ ReactNoop.render(
2843
+ <ErrorBoundary id="OuterBoundary" fallbackID="OuterFallback">
2844
+ <Component id="sibling" />
2845
+ <ErrorBoundary id="InnerBoundary" fallbackID="InnerFallback">
2846
+ <BrokenLayoutEffectDestroy />
2847
+ </ErrorBoundary>
2848
+ </ErrorBoundary>,
2849
+ );
2850
+
2851
+ expect(Scheduler).toFlushAndYield([
2852
+ 'OuterBoundary render success',
2853
+ 'Component render sibling',
2854
+ 'InnerBoundary render success',
2855
+ 'BrokenLayoutEffectDestroy render',
2856
+ ]);
2857
+ expect(ReactNoop.getChildren()).toEqual([
2858
+ span('sibling'),
2859
+ span('broken'),
2860
+ ]);
2861
+
2862
+ ReactNoop.render(
2863
+ <ErrorBoundary id="OuterBoundary" fallbackID="OuterFallback">
2864
+ <Component id="sibling" />
2865
+ </ErrorBoundary>,
2866
+ );
2867
+
2868
+ // React should skip over the unmounting boundary and find the nearest still-mounted boundary.
2869
+ expect(Scheduler).toFlushAndYield([
2870
+ 'OuterBoundary render success',
2871
+ 'Component render sibling',
2872
+ 'BrokenLayoutEffectDestroy useLayoutEffect destroy',
2873
+ 'ErrorBoundary static getDerivedStateFromError',
2874
+ 'OuterBoundary render error',
2875
+ 'Component render OuterFallback',
2876
+ ]);
2877
+ expect(ReactNoop.getChildren()).toEqual([span('OuterFallback')]);
2878
+ });
2879
});
2880
2881
describe('useCallback', () => {
packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js
+12
-6
@@ -961,6 +961,7 @@ describe('ReactIncrementalErrorHandling', () => {
961
expect(Scheduler).toFlushAndYield(['Foo']);
962
});
963
964
+ // @gate skipUnmountedBoundaries
965
it('should not attempt to recover an unmounting error boundary', () => {
966
class Parent extends React.Component {
967
componentWillUnmount() {
@@ -992,12 +993,17 @@ describe('ReactIncrementalErrorHandling', () => {
993
994
ReactNoop.render(<Parent />);
995
expect(Scheduler).toFlushWithoutYielding();
995
- ReactNoop.render(null);
996
- expect(Scheduler).toFlushAndYield([
997
- // Parent unmounts before the error is thrown.
998
- 'Parent componentWillUnmount',
999
- 'ThrowsOnUnmount componentWillUnmount',
1000
- ]);
996
+
997
+ // Because the error boundary is also unmounting,
998
+ // an error in ThrowsOnUnmount should be rethrown.
999
+ expect(() => {
1000
+ ReactNoop.render(null);
1001
+ expect(Scheduler).toFlushAndYield([
1002
+ 'Parent componentWillUnmount',
1003
+ 'ThrowsOnUnmount componentWillUnmount',
1004
+ ]);
1005
+ }).toThrow('unmount error');
1006
+
1007
ReactNoop.render(<Parent />);
1008
});
1009
packages/shared/ReactFeatureFlags.js
+6
@@ -92,6 +92,12 @@ export const enableComponentStackLocations = true;
92
93
export const enableNewReconciler = false;
94
95
+// Errors that are thrown while unmounting (or after in the case of passive effects)
96
+// should bypass any error boundaries that are also unmounting (or have unmounted)
97
+// and be handled by the nearest still-mounted boundary.
98
+// If there are no still-mounted boundaries, the errors should be rethrown.
99
+export const skipUnmountedBoundaries = false;
100
+
101
// --------------------------
102
// Future APIs to be deprecated
103
// --------------------------
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -43,6 +43,7 @@ export const warnAboutSpreadingKeyToJSX = false;
43
export const enableComponentStackLocations = false;
44
export const enableLegacyFBSupport = false;
45
export const enableFilterEmptyStringAttributesDOM = false;
46
+export const skipUnmountedBoundaries = false;
47
48
export const enableNewReconciler = false;
49
export const deferRenderPhaseUpdateToNextBatch = true;
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -42,6 +42,7 @@ export const warnAboutSpreadingKeyToJSX = false;
42
export const enableComponentStackLocations = false;
43
export const enableLegacyFBSupport = false;
44
export const enableFilterEmptyStringAttributesDOM = false;
45
+export const skipUnmountedBoundaries = false;
46
47
export const enableNewReconciler = false;
48
export const deferRenderPhaseUpdateToNextBatch = true;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -42,6 +42,7 @@ export const warnAboutSpreadingKeyToJSX = false;
42
export const enableComponentStackLocations = true;
43
export const enableLegacyFBSupport = false;
44
export const enableFilterEmptyStringAttributesDOM = false;
45
+export const skipUnmountedBoundaries = false;
46
47
export const enableNewReconciler = false;
48
export const deferRenderPhaseUpdateToNextBatch = true;
packages/shared/forks/ReactFeatureFlags.test-renderer.native.js
+1
@@ -42,6 +42,7 @@ export const warnAboutSpreadingKeyToJSX = false;
42
export const enableComponentStackLocations = false;
43
export const enableLegacyFBSupport = false;
44
export const enableFilterEmptyStringAttributesDOM = false;
45
+export const skipUnmountedBoundaries = false;
46
47
export const enableNewReconciler = false;
48
export const deferRenderPhaseUpdateToNextBatch = true;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -42,6 +42,7 @@ export const warnAboutSpreadingKeyToJSX = false;
42
export const enableComponentStackLocations = true;
43
export const enableLegacyFBSupport = false;
44
export const enableFilterEmptyStringAttributesDOM = false;
45
+export const skipUnmountedBoundaries = false;
46
47
export const enableNewReconciler = false;
48
export const deferRenderPhaseUpdateToNextBatch = true;
packages/shared/forks/ReactFeatureFlags.testing.js
+1
@@ -42,6 +42,7 @@ export const warnAboutSpreadingKeyToJSX = false;
42
export const enableComponentStackLocations = true;
43
export const enableLegacyFBSupport = false;
44
export const enableFilterEmptyStringAttributesDOM = false;
45
+export const skipUnmountedBoundaries = false;
46
47
export const enableNewReconciler = false;
48
export const deferRenderPhaseUpdateToNextBatch = true;
packages/shared/forks/ReactFeatureFlags.testing.www.js
+1
@@ -42,6 +42,7 @@ export const warnAboutSpreadingKeyToJSX = false;
42
export const enableComponentStackLocations = true;
43
export const enableLegacyFBSupport = !__EXPERIMENTAL__;
44
export const enableFilterEmptyStringAttributesDOM = false;
45
+export const skipUnmountedBoundaries = __EXPERIMENTAL__;
46
47
export const enableNewReconciler = false;
48
export const deferRenderPhaseUpdateToNextBatch = true;
packages/shared/forks/ReactFeatureFlags.www-dynamic.js
+1
@@ -18,6 +18,7 @@ export const disableInputAttributeSyncing = __VARIANT__;
18
export const enableFilterEmptyStringAttributesDOM = __VARIANT__;
19
export const enableLegacyFBSupport = __VARIANT__;
20
export const decoupleUpdatePriorityFromScheduler = __VARIANT__;
21
+export const skipUnmountedBoundaries = __VARIANT__;
22
23
// Enable this flag to help with concurrent mode debugging.
24
// It logs information to the console about React scheduling, rendering, and commit phases.
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -26,6 +26,7 @@ export const {
26
deferRenderPhaseUpdateToNextBatch,
27
decoupleUpdatePriorityFromScheduler,
28
enableDebugTracing,
29
+ skipUnmountedBoundaries,
30
} = dynamicFeatureFlags;
31
32
// On WWW, __EXPERIMENTAL__ is used for a new modern build.