Skip to content

Commit 4a56f6e

Browse files
bvaughnjetoneza
authored andcommitted
Enable getDerivedStateFromError (facebook#13746)
* Removed the enableGetDerivedStateFromCatch feature flag (aka permanently enabled the feature) * Forked/copied ReactErrorBoundaries to ReactLegacyErrorBoundaries for testing componentDidCatch * Updated error boundaries tests to apply to getDerivedStateFromCatch * Renamed getDerivedStateFromCatch -> getDerivedStateFromError * Warn if boundary with only componentDidCatch swallows error * Fixed a subtle reconciliation bug with render phase error boundary
1 parent e9b525f commit 4a56f6e

25 files changed

+2639
-242
lines changed

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

Lines changed: 185 additions & 157 deletions
Large diffs are not rendered by default.

packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js

Lines changed: 2130 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1343,6 +1343,9 @@ describe('ReactUpdates', () => {
13431343

13441344
class ErrorBoundary extends React.Component {
13451345
componentDidCatch() {
1346+
// Schedule a no-op state update to avoid triggering a DEV warning in the test.
1347+
this.setState({});
1348+
13461349
this.props.parent.remount();
13471350
}
13481351
render() {

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 71 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ import {
4646
import {captureWillSyncRenderPlaceholder} from './ReactFiberScheduler';
4747
import ReactSharedInternals from 'shared/ReactSharedInternals';
4848
import {
49-
enableGetDerivedStateFromCatch,
5049
enableSuspense,
5150
debugRenderPhaseSideEffects,
5251
debugRenderPhaseSideEffectsForStrictMode,
@@ -156,6 +155,38 @@ export function reconcileChildren(
156155
}
157156
}
158157

158+
function forceUnmountCurrentAndReconcile(
159+
current: Fiber,
160+
workInProgress: Fiber,
161+
nextChildren: any,
162+
renderExpirationTime: ExpirationTime,
163+
) {
164+
// This function is fork of reconcileChildren. It's used in cases where we
165+
// want to reconcile without matching against the existing set. This has the
166+
// effect of all current children being unmounted; even if the type and key
167+
// are the same, the old child is unmounted and a new child is created.
168+
//
169+
// To do this, we're going to go through the reconcile algorithm twice. In
170+
// the first pass, we schedule a deletion for all the current children by
171+
// passing null.
172+
workInProgress.child = reconcileChildFibers(
173+
workInProgress,
174+
current.child,
175+
null,
176+
renderExpirationTime,
177+
);
178+
// In the second pass, we mount the new children. The trick here is that we
179+
// pass null in place of where we usually pass the current child set. This has
180+
// the effect of remounting all children regardless of whether their their
181+
// identity matches.
182+
workInProgress.child = reconcileChildFibers(
183+
workInProgress,
184+
null,
185+
nextChildren,
186+
renderExpirationTime,
187+
);
188+
}
189+
159190
function updateForwardRef(
160191
current: Fiber | null,
161192
workInProgress: Fiber,
@@ -444,8 +475,7 @@ function finishClassComponent(
444475
let nextChildren;
445476
if (
446477
didCaptureError &&
447-
(!enableGetDerivedStateFromCatch ||
448-
typeof Component.getDerivedStateFromCatch !== 'function')
478+
typeof Component.getDerivedStateFromError !== 'function'
449479
) {
450480
// If we captured an error, but getDerivedStateFrom catch is not defined,
451481
// unmount all the children. componentDidCatch will schedule an update to
@@ -477,20 +507,25 @@ function finishClassComponent(
477507
// React DevTools reads this flag.
478508
workInProgress.effectTag |= PerformedWork;
479509
if (current !== null && didCaptureError) {
480-
// If we're recovering from an error, reconcile twice: first to delete
481-
// all the existing children.
482-
reconcileChildren(current, workInProgress, null, renderExpirationTime);
483-
workInProgress.child = null;
484-
// Now we can continue reconciling like normal. This has the effect of
485-
// remounting all children regardless of whether their their
486-
// identity matches.
510+
// If we're recovering from an error, reconcile without reusing any of
511+
// the existing children. Conceptually, the normal children and the children
512+
// that are shown on error are two different sets, so we shouldn't reuse
513+
// normal children even if their identities match.
514+
forceUnmountCurrentAndReconcile(
515+
current,
516+
workInProgress,
517+
nextChildren,
518+
renderExpirationTime,
519+
);
520+
} else {
521+
reconcileChildren(
522+
current,
523+
workInProgress,
524+
nextChildren,
525+
renderExpirationTime,
526+
);
487527
}
488-
reconcileChildren(
489-
current,
490-
workInProgress,
491-
nextChildren,
492-
renderExpirationTime,
493-
);
528+
494529
// Memoize props and state using the values we just used to render.
495530
// TODO: Restructure so we never read values from the instance.
496531
memoizeState(workInProgress, instance.state);
@@ -930,13 +965,6 @@ function updatePlaceholderComponent(
930965
// suspended during the last commit. Switch to the placholder.
931966
workInProgress.updateQueue = null;
932967
nextDidTimeout = true;
933-
// If we're recovering from an error, reconcile twice: first to delete
934-
// all the existing children.
935-
reconcileChildren(current, workInProgress, null, renderExpirationTime);
936-
current.child = null;
937-
// Now we can continue reconciling like normal. This has the effect of
938-
// remounting all children regardless of whether their their
939-
// identity matches.
940968
} else {
941969
nextDidTimeout = !alreadyCaptured;
942970
}
@@ -963,14 +991,28 @@ function updatePlaceholderComponent(
963991
nextChildren = nextDidTimeout ? nextProps.fallback : children;
964992
}
965993

994+
if (current !== null && nextDidTimeout !== workInProgress.memoizedState) {
995+
// We're about to switch from the placeholder children to the normal
996+
// children, or vice versa. These are two different conceptual sets that
997+
// happen to be stored in the same set. Call this special function to
998+
// force the new set not to match with the current set.
999+
// TODO: The proper way to model this is by storing each set separately.
1000+
forceUnmountCurrentAndReconcile(
1001+
current,
1002+
workInProgress,
1003+
nextChildren,
1004+
renderExpirationTime,
1005+
);
1006+
} else {
1007+
reconcileChildren(
1008+
current,
1009+
workInProgress,
1010+
nextChildren,
1011+
renderExpirationTime,
1012+
);
1013+
}
9661014
workInProgress.memoizedProps = nextProps;
9671015
workInProgress.memoizedState = nextDidTimeout;
968-
reconcileChildren(
969-
current,
970-
workInProgress,
971-
nextChildren,
972-
renderExpirationTime,
973-
);
9741016
return workInProgress.child;
9751017
} else {
9761018
return null;

packages/react-reconciler/src/ReactFiberClassComponent.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -466,10 +466,10 @@ function checkClassInstance(workInProgress: Fiber, ctor: any, newProps: any) {
466466
name,
467467
);
468468
const noInstanceGetDerivedStateFromCatch =
469-
typeof instance.getDerivedStateFromCatch !== 'function';
469+
typeof instance.getDerivedStateFromError !== 'function';
470470
warningWithoutStack(
471471
noInstanceGetDerivedStateFromCatch,
472-
'%s: getDerivedStateFromCatch() is defined as an instance method ' +
472+
'%s: getDerivedStateFromError() is defined as an instance method ' +
473473
'and will be ignored. Instead, declare it as a static method.',
474474
name,
475475
);

packages/react-reconciler/src/ReactFiberScheduler.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1464,7 +1464,7 @@ function dispatch(
14641464
const ctor = fiber.type;
14651465
const instance = fiber.stateNode;
14661466
if (
1467-
typeof ctor.getDerivedStateFromCatch === 'function' ||
1467+
typeof ctor.getDerivedStateFromError === 'function' ||
14681468
(typeof instance.componentDidCatch === 'function' &&
14691469
!isAlreadyFailedLegacyErrorBoundary(instance))
14701470
) {

packages/react-reconciler/src/ReactFiberUnwindWork.js

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import type {CapturedValue} from './ReactCapturedValue';
1414
import type {Update} from './ReactUpdateQueue';
1515
import type {Thenable} from './ReactFiberScheduler';
1616

17+
import getComponentName from 'shared/getComponentName';
18+
import warningWithoutStack from 'shared/warningWithoutStack';
1719
import {
1820
IndeterminateComponent,
1921
FunctionalComponent,
@@ -33,11 +35,7 @@ import {
3335
Update as UpdateEffect,
3436
LifecycleEffectMask,
3537
} from 'shared/ReactSideEffectTags';
36-
import {
37-
enableGetDerivedStateFromCatch,
38-
enableSuspense,
39-
enableSchedulerTracing,
40-
} from 'shared/ReactFeatureFlags';
38+
import {enableSuspense, enableSchedulerTracing} from 'shared/ReactFeatureFlags';
4139
import {StrictMode, ConcurrentMode} from './ReactTypeOfMode';
4240

4341
import {createCapturedValue} from './ReactCapturedValue';
@@ -104,28 +102,22 @@ function createClassErrorUpdate(
104102
): Update<mixed> {
105103
const update = createUpdate(expirationTime);
106104
update.tag = CaptureUpdate;
107-
const getDerivedStateFromCatch = fiber.type.getDerivedStateFromCatch;
108-
if (
109-
enableGetDerivedStateFromCatch &&
110-
typeof getDerivedStateFromCatch === 'function'
111-
) {
105+
const getDerivedStateFromError = fiber.type.getDerivedStateFromError;
106+
if (typeof getDerivedStateFromError === 'function') {
112107
const error = errorInfo.value;
113108
update.payload = () => {
114-
return getDerivedStateFromCatch(error);
109+
return getDerivedStateFromError(error);
115110
};
116111
}
117112

118113
const inst = fiber.stateNode;
119114
if (inst !== null && typeof inst.componentDidCatch === 'function') {
120115
update.callback = function callback() {
121-
if (
122-
!enableGetDerivedStateFromCatch ||
123-
getDerivedStateFromCatch !== 'function'
124-
) {
116+
if (typeof getDerivedStateFromError !== 'function') {
125117
// To preserve the preexisting retry behavior of error boundaries,
126118
// we keep track of which ones already failed during this batch.
127119
// This gets reset before we yield back to the browser.
128-
// TODO: Warn in strict mode if getDerivedStateFromCatch is
120+
// TODO: Warn in strict mode if getDerivedStateFromError is
129121
// not defined.
130122
markLegacyErrorBoundaryAsFailed(this);
131123
}
@@ -135,6 +127,19 @@ function createClassErrorUpdate(
135127
this.componentDidCatch(error, {
136128
componentStack: stack !== null ? stack : '',
137129
});
130+
if (__DEV__) {
131+
if (typeof getDerivedStateFromError !== 'function') {
132+
// If componentDidCatch is the only error boundary method defined,
133+
// then it needs to call setState to recover from errors.
134+
// If no state update is scheduled then the boundary will swallow the error.
135+
warningWithoutStack(
136+
fiber.expirationTime === Sync,
137+
'%s: Error boundaries should implement getDerivedStateFromError(). ' +
138+
'In that method, return a state update to display an error message or fallback UI.',
139+
getComponentName(fiber.type) || 'Unknown',
140+
);
141+
}
142+
}
138143
};
139144
}
140145
return update;
@@ -364,8 +369,7 @@ function throwException(
364369
const instance = workInProgress.stateNode;
365370
if (
366371
(workInProgress.effectTag & DidCapture) === NoEffect &&
367-
((typeof ctor.getDerivedStateFromCatch === 'function' &&
368-
enableGetDerivedStateFromCatch) ||
372+
(typeof ctor.getDerivedStateFromError === 'function' ||
369373
(instance !== null &&
370374
typeof instance.componentDidCatch === 'function' &&
371375
!isAlreadyFailedLegacyErrorBoundary(instance)))
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
const jestDiff = require('jest-diff');
2+
3+
describe('ErrorBoundaryReconciliation', () => {
4+
let BrokenRender;
5+
let DidCatchErrorBoundary;
6+
let GetDerivedErrorBoundary;
7+
let React;
8+
let ReactFeatureFlags;
9+
let ReactTestRenderer;
10+
let span;
11+
12+
beforeEach(() => {
13+
jest.resetModules();
14+
15+
ReactFeatureFlags = require('shared/ReactFeatureFlags');
16+
ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
17+
ReactTestRenderer = require('react-test-renderer');
18+
React = require('react');
19+
20+
DidCatchErrorBoundary = class extends React.Component {
21+
state = {error: null};
22+
componentDidCatch(error) {
23+
this.setState({error});
24+
}
25+
render() {
26+
return this.state.error
27+
? React.createElement(this.props.fallbackTagName, {
28+
prop: 'ErrorBoundary',
29+
})
30+
: this.props.children;
31+
}
32+
};
33+
34+
GetDerivedErrorBoundary = class extends React.Component {
35+
state = {error: null};
36+
static getDerivedStateFromError(error) {
37+
return {error};
38+
}
39+
render() {
40+
return this.state.error
41+
? React.createElement(this.props.fallbackTagName, {
42+
prop: 'ErrorBoundary',
43+
})
44+
: this.props.children;
45+
}
46+
};
47+
48+
const InvalidType = undefined;
49+
BrokenRender = ({fail}) =>
50+
fail ? <InvalidType /> : <span prop="BrokenRender" />;
51+
52+
function toHaveRenderedChildren(renderer, children) {
53+
let actual, expected;
54+
try {
55+
actual = renderer.toJSON();
56+
expected = ReactTestRenderer.create(children).toJSON();
57+
expect(actual).toEqual(expected);
58+
} catch (error) {
59+
return {
60+
message: () => jestDiff(expected, actual),
61+
pass: false,
62+
};
63+
}
64+
return {pass: true};
65+
}
66+
expect.extend({toHaveRenderedChildren});
67+
});
68+
69+
[true, false].forEach(isConcurrent => {
70+
function sharedTest(ErrorBoundary, fallbackTagName) {
71+
const renderer = ReactTestRenderer.create(
72+
<ErrorBoundary fallbackTagName={fallbackTagName}>
73+
<BrokenRender fail={false} />
74+
</ErrorBoundary>,
75+
{unstable_isConcurrent: isConcurrent},
76+
);
77+
if (isConcurrent) {
78+
renderer.unstable_flushAll();
79+
}
80+
expect(renderer).toHaveRenderedChildren(<span prop="BrokenRender" />);
81+
82+
expect(() => {
83+
renderer.update(
84+
<ErrorBoundary fallbackTagName={fallbackTagName}>
85+
<BrokenRender fail={true} />
86+
</ErrorBoundary>,
87+
);
88+
if (isConcurrent) {
89+
renderer.unstable_flushAll();
90+
}
91+
}).toWarnDev(isConcurrent ? ['invalid', 'invalid'] : ['invalid']);
92+
expect(renderer).toHaveRenderedChildren(
93+
React.createElement(fallbackTagName, {prop: 'ErrorBoundary'}),
94+
);
95+
}
96+
97+
describe(isConcurrent ? 'concurrent' : 'sync', () => {
98+
it('componentDidCatch can recover by rendering an element of the same type', () =>
99+
sharedTest(DidCatchErrorBoundary, 'span'));
100+
101+
it('componentDidCatch can recover by rendering an element of a different type', () =>
102+
sharedTest(DidCatchErrorBoundary, 'div'));
103+
104+
it('getDerivedStateFromError can recover by rendering an element of the same type', () =>
105+
sharedTest(GetDerivedErrorBoundary, 'span'));
106+
107+
it('getDerivedStateFromError can recover by rendering an element of a different type', () =>
108+
sharedTest(GetDerivedErrorBoundary, 'div'));
109+
});
110+
});
111+
});

packages/react-reconciler/src/__tests__/ReactIncremental-test.internal.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2398,7 +2398,10 @@ describe('ReactIncremental', () => {
23982398
instance.setState({
23992399
throwError: true,
24002400
});
2401-
ReactNoop.flush();
2401+
expect(ReactNoop.flush).toWarnDev(
2402+
'Error boundaries should implement getDerivedStateFromError()',
2403+
{withoutStack: true},
2404+
);
24022405
});
24032406

24042407
it('should not recreate masked context unless inputs have changed', () => {

0 commit comments

Comments
 (0)