@samitouri / QOS-React / commits / bcca5a6ca7

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.