Skip to content

Commit 94518b0

Browse files
authored
Add stack unwinding phase for handling errors (#12201)
* Add stack unwinding phase for handling errors A rewrite of error handling, with semantics that more closely match stack unwinding. Errors that are thrown during the render phase unwind to the nearest error boundary, like before. But rather than synchronously unmount the children before retrying, we restart the failed subtree within the same render phase. The failed children are still unmounted (as if all their keys changed) but without an extra commit. Commit phase errors are different. They work by scheduling an error on the update queue of the error boundary. When we enter the render phase, the error is popped off the queue. The rest of the algorithm is the same. This approach is designed to work for throwing non-errors, too, though that feature is not implemented yet. * Add experimental getDerivedStateFromCatch lifecycle Fires during the render phase, so you can recover from an error within the same pass. This aligns error boundaries more closely with try-catch semantics. Let's keep this behind a feature flag until a future release. For now, the recommendation is to keep using componentDidCatch. Eventually, the advice will be to use getDerivedStateFromCatch for handling errors and componentDidCatch only for logging. * Reconcile twice to remount failed children, instead of using a boolean * Handle effect immediately after its thrown This way we don't have to store the thrown values on the effect list. * ReactFiberIncompleteWork -> ReactFiberUnwindWork * Remove startTime * Remove TypeOfException We don't need it yet. We'll reconsider once we add another exception type. * Move replay to outer catch block This moves it out of the hot path.
1 parent 6d7c847 commit 94518b0

39 files changed

+1663
-1052
lines changed

packages/react-dom/src/__tests__/ReactDOM-test.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,7 @@ describe('ReactDOM', () => {
380380
it('throws in DEV if jsdom is destroyed by the time setState() is called', () => {
381381
class App extends React.Component {
382382
state = {x: 1};
383+
componentDidUpdate() {}
383384
render() {
384385
return <div />;
385386
}
@@ -395,6 +396,10 @@ describe('ReactDOM', () => {
395396
// This is roughly what happens if the test finished and then
396397
// an asynchronous callback tried to setState() after this.
397398
delete global.document;
399+
400+
// The error we're interested in is thrown by invokeGuardedCallback, which
401+
// in DEV is used 1) to replay a failed begin phase, or 2) when calling
402+
// lifecycle methods. We're triggering the second case here.
398403
const fn = () => instance.setState({x: 2});
399404
if (__DEV__) {
400405
expect(fn).toThrow(

packages/react-dom/src/__tests__/ReactErrorBoundaries-test.js renamed to packages/react-dom/src/__tests__/ReactErrorBoundaries-test.internal.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
let PropTypes;
1313
let React;
1414
let ReactDOM;
15+
let ReactFeatureFlags;
1516

1617
describe('ReactErrorBoundaries', () => {
1718
let log;
@@ -34,7 +35,10 @@ describe('ReactErrorBoundaries', () => {
3435
let Normal;
3536

3637
beforeEach(() => {
38+
jest.resetModules();
3739
PropTypes = require('prop-types');
40+
ReactFeatureFlags = require('shared/ReactFeatureFlags');
41+
ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
3842
ReactDOM = require('react-dom');
3943
React = require('react');
4044

@@ -922,6 +926,10 @@ describe('ReactErrorBoundaries', () => {
922926
'BrokenRender constructor',
923927
'BrokenRender componentWillMount',
924928
'BrokenRender render [!]',
929+
// Render third child, even though an earlier sibling threw.
930+
'Normal constructor',
931+
'Normal componentWillMount',
932+
'Normal render',
925933
// Finish mounting with null children
926934
'ErrorBoundary componentDidMount',
927935
// Handle the error
@@ -1379,6 +1387,10 @@ describe('ReactErrorBoundaries', () => {
13791387
// The initial render was aborted, so
13801388
// Fiber retries from the root.
13811389
'ErrorBoundary componentWillUpdate',
1390+
'ErrorBoundary componentDidUpdate',
1391+
// The second willUnmount error should be captured and logged, too.
1392+
'ErrorBoundary componentDidCatch',
1393+
'ErrorBoundary componentWillUpdate',
13821394
// Render an error now (stack will do it later)
13831395
'ErrorBoundary render error',
13841396
// Attempt to unmount previous child:
@@ -1437,6 +1449,10 @@ describe('ReactErrorBoundaries', () => {
14371449
// The initial render was aborted, so
14381450
// Fiber retries from the root.
14391451
'ErrorBoundary componentWillUpdate',
1452+
'ErrorBoundary componentDidUpdate',
1453+
// The second willUnmount error should be captured and logged, too.
1454+
'ErrorBoundary componentDidCatch',
1455+
'ErrorBoundary componentWillUpdate',
14401456
// Render an error now (stack will do it later)
14411457
'ErrorBoundary render error',
14421458
// Done
@@ -1761,6 +1777,10 @@ describe('ReactErrorBoundaries', () => {
17611777
// Handle the error
17621778
'ErrorBoundary componentDidCatch',
17631779
'ErrorBoundary componentWillUpdate',
1780+
// The willUnmount error should be captured and logged, too.
1781+
'ErrorBoundary componentDidUpdate',
1782+
'ErrorBoundary componentDidCatch',
1783+
'ErrorBoundary componentWillUpdate',
17641784
'ErrorBoundary render error',
17651785
// The update has finished
17661786
'ErrorBoundary componentDidUpdate',
@@ -1847,7 +1867,7 @@ describe('ReactErrorBoundaries', () => {
18471867
expect(log).toEqual(['ErrorBoundary componentWillUnmount']);
18481868
});
18491869

1850-
it('lets different boundaries catch their own first errors', () => {
1870+
it('calls componentDidCatch for each error that is captured', () => {
18511871
function renderUnmountError(error) {
18521872
return <div>Caught an unmounting error: {error.message}.</div>;
18531873
}
@@ -1892,7 +1912,7 @@ describe('ReactErrorBoundaries', () => {
18921912
);
18931913

18941914
expect(container.firstChild.textContent).toBe(
1895-
'Caught an unmounting error: E1.' + 'Caught an updating error: E3.',
1915+
'Caught an unmounting error: E2.' + 'Caught an updating error: E4.',
18961916
);
18971917
expect(log).toEqual([
18981918
// Begin update phase
@@ -1928,6 +1948,8 @@ describe('ReactErrorBoundaries', () => {
19281948
'BrokenComponentDidUpdate componentWillUnmount',
19291949
'BrokenComponentDidUpdate componentWillUnmount',
19301950
'InnerUnmountBoundary componentDidCatch',
1951+
'InnerUnmountBoundary componentDidCatch',
1952+
'InnerUpdateBoundary componentDidCatch',
19311953
'InnerUpdateBoundary componentDidCatch',
19321954
'InnerUnmountBoundary componentWillUpdate',
19331955
'InnerUnmountBoundary render error',

packages/react-noop-renderer/src/ReactNoop.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,6 @@ function* flushUnitsOfWork(n: number): Generator<Array<mixed>, void, void> {
287287
while (!didStop && scheduledCallback !== null) {
288288
let cb = scheduledCallback;
289289
scheduledCallback = null;
290-
yieldedValues = null;
291290
unitsRemaining = n;
292291
cb({
293292
timeRemaining() {
@@ -418,7 +417,8 @@ const ReactNoop = {
418417
},
419418

420419
flushUnitsOfWork(n: number): Array<mixed> {
421-
let values = [];
420+
let values = yieldedValues || [];
421+
yieldedValues = null;
422422
// eslint-disable-next-line no-for-of-loops/no-for-of-loops
423423
for (const value of flushUnitsOfWork(n)) {
424424
values.push(...value);
@@ -456,6 +456,12 @@ const ReactNoop = {
456456
}
457457
},
458458

459+
clearYields() {
460+
const values = yieldedValues;
461+
yieldedValues = null;
462+
return values;
463+
},
464+
459465
hasScheduledCallback() {
460466
return !!scheduledCallback;
461467
},
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Copyright (c) 2013-present, Facebook, Inc.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import type {Fiber} from './ReactFiber';
11+
12+
import {getStackAddendumByWorkInProgressFiber} from 'shared/ReactFiberComponentTreeHook';
13+
14+
export type CapturedValue<T> = {
15+
value: T,
16+
source: Fiber | null,
17+
stack: string | null,
18+
};
19+
20+
export type CapturedError = {
21+
componentName: ?string,
22+
componentStack: string,
23+
error: mixed,
24+
errorBoundary: ?Object,
25+
errorBoundaryFound: boolean,
26+
errorBoundaryName: string | null,
27+
willRetry: boolean,
28+
};
29+
30+
export function createCapturedValue<T>(
31+
value: T,
32+
source: Fiber | null,
33+
): CapturedValue<T> {
34+
// If the value is an error, call this function immediately after it is thrown
35+
// so the stack is accurate.
36+
return {
37+
value,
38+
source,
39+
stack: getStackAddendumByWorkInProgressFiber(source),
40+
};
41+
}

0 commit comments

Comments
 (0)