Devtools: add feature to trigger an error boundary (#21583)
Co-authored-by: Brian Vaughn <bvaughn@fb.com>
Bao Pham committed
Jun 3, 2021 at 23:21 UTC
8b4201535c6068147d61d0ed3f02d21d6dcd6927
18 files changed
+515
-5
packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
+91
@@ -2380,4 +2380,95 @@ describe('InspectedElement', () => {
2380
`);
2381
});
2382
});
2383
+
2384
+ describe('error boundary', () => {
2385
+ it('can toggle error', async () => {
2386
+ class ErrorBoundary extends React.Component<any> {
2387
+ state = {hasError: false};
2388
+ static getDerivedStateFromError(error) {
2389
+ return {hasError: true};
2390
+ }
2391
+ render() {
2392
+ const {hasError} = this.state;
2393
+ return hasError ? 'has-error' : this.props.children;
2394
+ }
2395
+ }
2396
+ const Example = () => 'example';
2397
+
2398
+ await utils.actAsync(() =>
2399
+ ReactDOM.render(
2400
+ <ErrorBoundary>
2401
+ <Example />
2402
+ </ErrorBoundary>,
2403
+ document.createElement('div'),
2404
+ ),
2405
+ );
2406
+
2407
+ const targetErrorBoundaryID = ((store.getElementIDAtIndex(
2408
+ 0,
2409
+ ): any): number);
2410
+ const inspect = index => {
2411
+ // HACK: Recreate TestRenderer instance so we can inspect different
2412
+ // elements
2413
+ testRendererInstance = TestRenderer.create(null, {
2414
+ unstable_isConcurrent: true,
2415
+ });
2416
+ return inspectElementAtIndex(index);
2417
+ };
2418
+ const toggleError = async forceError => {
2419
+ await withErrorsOrWarningsIgnored(['ErrorBoundary'], async () => {
2420
+ await utils.actAsync(() => {
2421
+ bridge.send('overrideError', {
2422
+ id: targetErrorBoundaryID,
2423
+ rendererID: store.getRendererIDForElement(targetErrorBoundaryID),
2424
+ forceError,
2425
+ });
2426
+ });
2427
+ });
2428
+
2429
+ TestUtilsAct(() => {
2430
+ jest.runOnlyPendingTimers();
2431
+ });
2432
+ };
2433
+
2434
+ // Inspect <ErrorBoundary /> and see that we cannot toggle error state
2435
+ // on error boundary itself
2436
+ let inspectedElement = await inspect(0);
2437
+ expect(inspectedElement.canToggleError).toBe(false);
2438
+ expect(inspectedElement.targetErrorBoundaryID).toBe(null);
2439
+
2440
+ // Inspect <Example />
2441
+ inspectedElement = await inspect(1);
2442
+ expect(inspectedElement.canToggleError).toBe(true);
2443
+ expect(inspectedElement.isErrored).toBe(false);
2444
+ expect(inspectedElement.targetErrorBoundaryID).toBe(
2445
+ targetErrorBoundaryID,
2446
+ );
2447
+
2448
+ // now force error state on <Example />
2449
+ await toggleError(true);
2450
+
2451
+ // we are in error state now, <Example /> won't show up
2452
+ expect(store.getElementIDAtIndex(1)).toBe(null);
2453
+
2454
+ // Inpsect <ErrorBoundary /> to toggle off the error state
2455
+ inspectedElement = await inspect(0);
2456
+ expect(inspectedElement.canToggleError).toBe(true);
2457
+ expect(inspectedElement.isErrored).toBe(true);
2458
+ // its error boundary ID is itself because it's caught the error
2459
+ expect(inspectedElement.targetErrorBoundaryID).toBe(
2460
+ targetErrorBoundaryID,
2461
+ );
2462
+
2463
+ await toggleError(false);
2464
+
2465
+ // We can now inspect <Example /> with ability to toggle again
2466
+ inspectedElement = await inspect(1);
2467
+ expect(inspectedElement.canToggleError).toBe(true);
2468
+ expect(inspectedElement.isErrored).toBe(false);
2469
+ expect(inspectedElement.targetErrorBoundaryID).toBe(
2470
+ targetErrorBoundaryID,
2471
+ );
2472
+ });
2473
+ });
2474
});
packages/react-devtools-shared/src/__tests__/inspectedElementSerializer.js
+1
@@ -14,6 +14,7 @@ export function test(maybeInspectedElement) {
14
hasOwnProperty('canEditFunctionProps') &&
15
hasOwnProperty('canEditHooks') &&
16
hasOwnProperty('canToggleSuspense') &&
17
+ hasOwnProperty('canToggleError') &&
18
hasOwnProperty('canViewSource')
19
);
20
}
packages/react-devtools-shared/src/backend/agent.js
+16
@@ -122,6 +122,12 @@ type OverrideValueAtPathParams = {|
122
value: any,
123
|};
124
125
+type OverrideErrorParams = {|
126
+ id: number,
127
+ rendererID: number,
128
+ forceError: boolean,
129
+|};
130
+
131
type OverrideSuspenseParams = {|
132
id: number,
133
rendererID: number,
@@ -183,6 +189,7 @@ export default class Agent extends EventEmitter<{|
189
bridge.addListener('getOwnersList', this.getOwnersList);
190
bridge.addListener('inspectElement', this.inspectElement);
191
bridge.addListener('logElementToConsole', this.logElementToConsole);
192
+ bridge.addListener('overrideError', this.overrideError);
193
bridge.addListener('overrideSuspense', this.overrideSuspense);
194
bridge.addListener('overrideValueAtPath', this.overrideValueAtPath);
195
bridge.addListener('reloadAndProfile', this.reloadAndProfile);
@@ -381,6 +388,15 @@ export default class Agent extends EventEmitter<{|
388
}
389
};
390
391
+ overrideError = ({id, rendererID, forceError}: OverrideErrorParams) => {
392
+ const renderer = this._rendererInterfaces[rendererID];
393
+ if (renderer == null) {
394
+ console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
395
+ } else {
396
+ renderer.overrideError(id, forceError);
397
+ }
398
+ };
399
+
400
overrideSuspense = ({
401
id,
402
rendererID,
packages/react-devtools-shared/src/backend/legacy/renderer.js
+9
@@ -800,6 +800,11 @@ export function attach(
800
canEditFunctionPropsDeletePaths: false,
801
canEditFunctionPropsRenamePaths: false,
802
803
+ // Toggle error boundary did not exist in legacy versions
804
+ canToggleError: false,
805
+ isErrored: false,
806
+ targetErrorBoundaryID: null,
807
+
808
// Suspense did not exist in legacy versions
809
canToggleSuspense: false,
810
@@ -1016,6 +1021,9 @@ export function attach(
1021
const handlePostCommitFiberRoot = () => {
1022
throw new Error('handlePostCommitFiberRoot not supported by this renderer');
1023
};
1024
+ const overrideError = () => {
1025
+ throw new Error('overrideError not supported by this renderer');
1026
+ };
1027
const overrideSuspense = () => {
1028
throw new Error('overrideSuspense not supported by this renderer');
1029
};
@@ -1089,6 +1097,7 @@ export function attach(
1097
handlePostCommitFiberRoot,
1098
inspectElement,
1099
logElementToConsole,
1100
+ overrideError,
1101
overrideSuspense,
1102
overrideValueAtPath,
1103
renamePath,
packages/react-devtools-shared/src/backend/renderer.js
+142
-2
@@ -120,6 +120,7 @@ type ReactPriorityLevelsType = {|
120
|};
121
122
type ReactTypeOfSideEffectType = {|
123
+ DidCapture: number,
124
NoFlags: number,
125
PerformedWork: number,
126
Placement: number,
@@ -147,6 +148,7 @@ export function getInternalReactConstants(
148
ReactTypeOfWork: WorkTagMap,
149
|} {
150
const ReactTypeOfSideEffect: ReactTypeOfSideEffectType = {
151
+ DidCapture: 0b10000000,
152
NoFlags: 0b00,
153
PerformedWork: 0b01,
154
Placement: 0b10,
@@ -519,7 +521,13 @@ export function attach(
521
ReactTypeOfWork,
522
ReactTypeOfSideEffect,
523
} = getInternalReactConstants(version);
522
- const {Incomplete, NoFlags, PerformedWork, Placement} = ReactTypeOfSideEffect;
524
+ const {
525
+ DidCapture,
526
+ Incomplete,
527
+ NoFlags,
528
+ PerformedWork,
529
+ Placement,
530
+ } = ReactTypeOfSideEffect;
531
const {
532
CacheComponent,
533
ClassComponent,
@@ -557,9 +565,13 @@ export function attach(
565
overrideProps,
566
overridePropsDeletePath,
567
overridePropsRenamePath,
568
+ setErrorHandler,
569
setSuspenseHandler,
570
scheduleUpdate,
571
} = renderer;
572
+ const supportsTogglingError =
573
+ typeof setErrorHandler === 'function' &&
574
+ typeof scheduleUpdate === 'function';
575
const supportsTogglingSuspense =
576
typeof setSuspenseHandler === 'function' &&
577
typeof scheduleUpdate === 'function';
@@ -659,6 +671,13 @@ export function attach(
671
type: 'error' | 'warn',
672
args: $ReadOnlyArray<any>,
673
): void {
674
+ if (type === 'error') {
675
+ const maybeID = getFiberIDUnsafe(fiber);
676
+ // if this is an error simulated by us to trigger error boundary, ignore
677
+ if (maybeID != null && forceErrorForFiberIDs.get(maybeID) === true) {
678
+ return;
679
+ }
680
+ }
681
const message = format(...args);
682
if (__DEBUG__) {
683
debug('onErrorOrWarning', fiber, null, `${type}: "${message}"`);
@@ -1133,6 +1152,13 @@ export function attach(
1152
if (alternate !== null) {
1153
fiberToIDMap.delete(alternate);
1154
}
1155
+
1156
+ if (forceErrorForFiberIDs.has(fiberID)) {
1157
+ forceErrorForFiberIDs.delete(fiberID);
1158
+ if (forceErrorForFiberIDs.size === 0 && setErrorHandler != null) {
1159
+ setErrorHandler(shouldErrorFiberAlwaysNull);
1160
+ }
1161
+ }
1162
});
1163
untrackFibersSet.clear();
1164
}
@@ -2909,6 +2935,34 @@ export function attach(
2935
return {instance, style};
2936
}
2937
2938
+ function isErrorBoundary(fiber: Fiber): boolean {
2939
+ const {tag, type} = fiber;
2940
+
2941
+ switch (tag) {
2942
+ case ClassComponent:
2943
+ case IncompleteClassComponent:
2944
+ const instance = fiber.stateNode;
2945
+ return (
2946
+ typeof type.getDerivedStateFromError === 'function' ||
2947
+ (instance !== null &&
2948
+ typeof instance.componentDidCatch === 'function')
2949
+ );
2950
+ default:
2951
+ return false;
2952
+ }
2953
+ }
2954
+
2955
+ function getNearestErrorBoundaryID(fiber: Fiber): number | null {
2956
+ let parent = fiber.return;
2957
+ while (parent !== null) {
2958
+ if (isErrorBoundary(parent)) {
2959
+ return getFiberIDUnsafe(parent);
2960
+ }
2961
+ parent = parent.return;
2962
+ }
2963
+ return null;
2964
+ }
2965
+
2966
function inspectElementRaw(id: number): InspectedElement | null {
2967
const fiber = findCurrentFiberUsingSlowPathById(id);
2968
if (fiber == null) {
@@ -3063,6 +3117,21 @@ export function attach(
3117
const errors = fiberIDToErrorsMap.get(id) || new Map();
3118
const warnings = fiberIDToWarningsMap.get(id) || new Map();
3119
3120
+ const isErrored =
3121
+ (fiber.flags & DidCapture) !== NoFlags ||
3122
+ forceErrorForFiberIDs.get(id) === true;
3123
+
3124
+ let targetErrorBoundaryID;
3125
+ if (isErrorBoundary(fiber)) {
3126
+ // if the current inspected element is an error boundary,
3127
+ // either that we want to use it to toggle off error state
3128
+ // or that we allow to force error state on it if it's within another
3129
+ // error boundary
3130
+ targetErrorBoundaryID = isErrored ? id : getNearestErrorBoundaryID(fiber);
3131
+ } else {
3132
+ targetErrorBoundaryID = getNearestErrorBoundaryID(fiber);
3133
+ }
3134
+
3135
return {
3136
id,
3137
@@ -3080,6 +3149,11 @@ export function attach(
3149
canEditFunctionPropsRenamePaths:
3150
typeof overridePropsRenamePath === 'function',
3151
3152
+ canToggleError: supportsTogglingError && targetErrorBoundaryID != null,
3153
+ // Is this error boundary in error state.
3154
+ isErrored,
3155
+ targetErrorBoundaryID,
3156
+
3157
canToggleSuspense:
3158
supportsTogglingSuspense &&
3159
// If it's showing the real content, we can always flip fallback.
@@ -3747,7 +3821,72 @@ export function attach(
3821
}
3822
3823
// React will switch between these implementations depending on whether
3750
- // we have any manually suspended Fibers or not.
3824
+ // we have any manually suspended/errored-out Fibers or not.
3825
+ function shouldErrorFiberAlwaysNull() {
3826
+ return null;
3827
+ }
3828
+
3829
+ // Map of id and its force error status: true (error), false (toggled off),
3830
+ // null (do nothing)
3831
+ const forceErrorForFiberIDs = new Map();
3832
+ function shouldErrorFiberAccordingToMap(fiber) {
3833
+ if (typeof setErrorHandler !== 'function') {
3834
+ throw new Error(
3835
+ 'Expected overrideError() to not get called for earlier React versions.',
3836
+ );
3837
+ }
3838
+
3839
+ const id = getFiberIDUnsafe(fiber);
3840
+ if (id === null) {
3841
+ return null;
3842
+ }
3843
+
3844
+ let status = null;
3845
+ if (forceErrorForFiberIDs.has(id)) {
3846
+ status = forceErrorForFiberIDs.get(id);
3847
+ if (status === false) {
3848
+ // TRICKY overrideError adds entries to this Map,
3849
+ // so ideally it would be the method that clears them too,
3850
+ // but that would break the functionality of the feature,
3851
+ // since DevTools needs to tell React to act differently than it normally would
3852
+ // (don't just re-render the failed boundary, but reset its errored state too).
3853
+ // So we can only clear it after telling React to reset the state.
3854
+ // Technically this is premature and we should schedule it for later,
3855
+ // since the render could always fail without committing the updated error boundary,
3856
+ // but since this is a DEV-only feature, the simplicity is worth the trade off.
3857
+ forceErrorForFiberIDs.delete(id);
3858
+
3859
+ if (forceErrorForFiberIDs.size === 0) {
3860
+ // Last override is gone. Switch React back to fast path.
3861
+ setErrorHandler(shouldErrorFiberAlwaysNull);
3862
+ }
3863
+ }
3864
+ }
3865
+ return status;
3866
+ }
3867
+
3868
+ function overrideError(id, forceError) {
3869
+ if (
3870
+ typeof setErrorHandler !== 'function' ||
3871
+ typeof scheduleUpdate !== 'function'
3872
+ ) {
3873
+ throw new Error(
3874
+ 'Expected overrideError() to not get called for earlier React versions.',
3875
+ );
3876
+ }
3877
+
3878
+ forceErrorForFiberIDs.set(id, forceError);
3879
+
3880
+ if (forceErrorForFiberIDs.size === 1) {
3881
+ // First override is added. Switch React to slower path.
3882
+ setErrorHandler(shouldErrorFiberAccordingToMap);
3883
+ }
3884
+
3885
+ const fiber = idToArbitraryFiberMap.get(id);
3886
+ if (fiber != null) {
3887
+ scheduleUpdate(fiber);
3888
+ }
3889
+ }
3890
3891
function shouldSuspendFiberAlwaysFalse() {
3892
return false;
@@ -4042,6 +4181,7 @@ export function attach(
4181
logElementToConsole,
4182
prepareViewAttributeSource,
4183
prepareViewElementSource,
4184
+ overrideError,
4185
overrideSuspense,
4186
overrideValueAtPath,
4187
renamePath,
packages/react-devtools-shared/src/backend/types.js
+8
@@ -142,6 +142,8 @@ export type ReactRenderer = {
142
ComponentTree?: any,
143
// Present for React DOM v12 (possibly earlier) through v15.
144
Mount?: any,
145
+ // Only injected by React v17.0.3+ in DEV mode
146
+ setErrorHandler?: ?(shouldError: (fiber: Object) => ?boolean) => void,
147
...
148
};
149
@@ -224,6 +226,11 @@ export type InspectedElement = {|
226
canEditFunctionPropsDeletePaths: boolean,
227
canEditFunctionPropsRenamePaths: boolean,
228
229
+ // Is this Error, and can its value be overridden now?
230
+ canToggleError: boolean,
231
+ isErrored: boolean,
232
+ targetErrorBoundaryID: ?number,
233
+
234
// Is this Suspense, and can its value be overridden now?
235
canToggleSuspense: boolean,
236
@@ -332,6 +339,7 @@ export type RendererInterface = {
339
inspectedPaths: Object,
340
) => InspectedElementPayload,
341
logElementToConsole: (id: number) => void,
342
+ overrideError: (id: number, forceError: boolean) => void,
343
overrideSuspense: (id: number, forceFallback: boolean) => void,
344
overrideValueAtPath: (
345
type: Type,
packages/react-devtools-shared/src/backendAPI.js
+6
@@ -190,6 +190,9 @@ export function convertInspectedElementBackendToFrontend(
190
canEditHooks,
191
canEditHooksAndDeletePaths,
192
canEditHooksAndRenamePaths,
193
+ canToggleError,
194
+ isErrored,
195
+ targetErrorBoundaryID,
196
canToggleSuspense,
197
canViewSource,
198
hasLegacyContext,
@@ -216,6 +219,9 @@ export function convertInspectedElementBackendToFrontend(
219
canEditHooks,
220
canEditHooksAndDeletePaths,
221
canEditHooksAndRenamePaths,
222
+ canToggleError,
223
+ isErrored,
224
+ targetErrorBoundaryID,
225
canToggleSuspense,
226
canViewSource,
227
hasLegacyContext,
packages/react-devtools-shared/src/bridge.js
+6
@@ -115,6 +115,11 @@ type OverrideValueAtPath = {|
115
value: any,
116
|};
117
118
+type OverrideError = {|
119
+ ...ElementAndRendererID,
120
+ forceError: boolean,
121
+|};
122
+
123
type OverrideSuspense = {|
124
...ElementAndRendererID,
125
forceFallback: boolean,
@@ -201,6 +206,7 @@ type FrontendEvents = {|
206
highlightNativeElement: [HighlightElementInDOM],
207
inspectElement: [InspectElementParams],
208
logElementToConsole: [ElementAndRendererID],
209
+ overrideError: [OverrideError],
210
overrideSuspense: [OverrideSuspense],
211
overrideValueAtPath: [OverrideValueAtPath],
212
profilingData: [ProfilingDataBackend],
packages/react-devtools-shared/src/devtools/views/ButtonIcon.js
+8
-1
@@ -32,6 +32,7 @@ export type IconType =
32
| 'save'
33
| 'search'
34
| 'settings'
35
+ | 'error'
36
| 'suspend'
37
| 'undo'
38
| 'up'
@@ -109,6 +110,9 @@ export default function ButtonIcon({className = '', type}: Props) {
110
case 'settings':
111
pathData = PATH_SETTINGS;
112
break;
113
+ case 'error':
114
+ pathData = PATH_ERROR;
115
+ break;
116
case 'suspend':
117
pathData = PATH_SUSPEND;
118
break;
@@ -187,7 +191,7 @@ const PATH_LOG_DATA = `
191
`;
192
193
const PATH_MORE = `
190
- M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9
194
+ M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9
195
2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z
196
`;
197
@@ -223,6 +227,9 @@ const PATH_SETTINGS = `
227
3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z
228
`;
229
230
+const PATH_ERROR =
231
+ 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z';
232
+
233
const PATH_SUSPEND = `
234
M15 1H9v2h6V1zm-4 13h2V8h-2v6zm8.03-6.61l1.42-1.42c-.43-.51-.9-.99-1.41-1.41l-1.42 1.42C16.07 4.74 14.12 4 12 4c-4.97
235
0-9 4.03-9 9s4.02 9 9 9 9-4.03 9-9c0-2.12-.74-4.07-1.97-5.61zM12 20c-3.87 0-7-3.13-7-7s3.13-7 7-7 7 3.13 7 7-3.13 7-7 7z
packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js
+45
@@ -92,15 +92,47 @@ export default function InspectedElementWrapper(_: Props) {
92
(canViewElementSourceFunction === null ||
93
canViewElementSourceFunction(inspectedElement));
94
95
+ const isErrored = inspectedElement != null && inspectedElement.isErrored;
96
+ const targetErrorBoundaryID =
97
+ inspectedElement != null ? inspectedElement.targetErrorBoundaryID : null;
98
+
99
const isSuspended =
100
element !== null &&
101
element.type === ElementTypeSuspense &&
102
inspectedElement != null &&
103
inspectedElement.state != null;
104
105
+ const canToggleError =
106
+ inspectedElement != null && inspectedElement.canToggleError;
107
+
108
const canToggleSuspense =
109
inspectedElement != null && inspectedElement.canToggleSuspense;
110
111
+ const toggleErrored = useCallback(() => {
112
+ if (inspectedElement == null || targetErrorBoundaryID == null) {
113
+ return;
114
+ }
115
+
116
+ const rendererID = store.getRendererIDForElement(targetErrorBoundaryID);
117
+ if (rendererID !== null) {
118
+ if (targetErrorBoundaryID !== inspectedElement.id) {
119
+ // Update tree selection so that if we cause a component to error,
120
+ // the nearest error boundary will become the newly selected thing.
121
+ dispatch({
122
+ type: 'SELECT_ELEMENT_BY_ID',
123
+ payload: targetErrorBoundaryID,
124
+ });
125
+ }
126
+
127
+ // Toggle error.
128
+ bridge.send('overrideError', {
129
+ id: targetErrorBoundaryID,
130
+ rendererID,
131
+ forceError: !isErrored,
132
+ });
133
+ }
134
+ }, [bridge, dispatch, isErrored, targetErrorBoundaryID]);
135
+
136
// TODO (suspense toggle) Would be nice to eventually use a two setState pattern here as well.
137
const toggleSuspended = useCallback(() => {
138
let nearestSuspenseElement = null;
@@ -177,6 +209,19 @@ export default function InspectedElementWrapper(_: Props) {
209
</div>
210
</div>
211
212
+ {canToggleError && (
213
+ <Toggle
214
+ className={styles.IconButton}
215
+ isChecked={isErrored}
216
+ onChange={toggleErrored}
217
+ title={
218
+ isErrored
219
+ ? 'Clear the forced error'
220
+ : 'Force the selected component into an errored state'
221
+ }>
222
+ <ButtonIcon type="error" />
223
+ </Toggle>
224
+ )}
225
{canToggleSuspense && (
226
<Toggle
227
className={styles.IconButton}
packages/react-devtools-shared/src/devtools/views/Components/types.js
+5
@@ -76,6 +76,11 @@ export type InspectedElement = {|
76
canEditFunctionPropsDeletePaths: boolean,
77
canEditFunctionPropsRenamePaths: boolean,
78
79
+ // Is this Error, and can its value be overridden now?
80
+ isErrored: boolean,
81
+ canToggleError: boolean,
82
+ targetErrorBoundaryID: ?number,
83
+
84
// Is this Suspense, and can its value be overridden now?
85
canToggleSuspense: boolean,
86
packages/react-devtools-shell/src/app/ErrorBoundaries/index.js
new
+71
@@ -0,0 +1,71 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
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 * as React from 'react';
11
+import {Fragment} from 'react';
12
+
13
+class ErrorBoundary extends React.Component {
14
+ state = {hasError: false};
15
+
16
+ static getDerivedStateFromError(error) {
17
+ return {hasError: true};
18
+ }
19
+
20
+ render() {
21
+ const {hasError} = this.state;
22
+ if (hasError) {
23
+ return (
24
+ <div
25
+ style={{
26
+ color: 'red',
27
+ border: '1px solid red',
28
+ borderRadius: '0.25rem',
29
+ margin: '0.5rem',
30
+ padding: '0.5rem',
31
+ }}>
32
+ An error was thrown.
33
+ </div>
34
+ );
35
+ }
36
+
37
+ const {children} = this.props;
38
+ return (
39
+ <div
40
+ style={{
41
+ border: '1px solid gray',
42
+ borderRadius: '0.25rem',
43
+ margin: '0.5rem',
44
+ padding: '0.5rem',
45
+ }}>
46
+ {children}
47
+ </div>
48
+ );
49
+ }
50
+}
51
+
52
+function Component({label}) {
53
+ return <div>{label}</div>;
54
+}
55
+
56
+export default function ErrorBoundaries() {
57
+ return (
58
+ <Fragment>
59
+ <h1>Nested error boundaries demo</h1>
60
+ <ErrorBoundary>
61
+ <Component label="Outer component" />
62
+ <ErrorBoundary>
63
+ <Component label="Inner component" />
64
+ </ErrorBoundary>
65
+ </ErrorBoundary>
66
+ <ErrorBoundary>
67
+ <Component label="Neighbour component" />
68
+ </ErrorBoundary>
69
+ </Fragment>
70
+ );
71
+}
packages/react-devtools-shell/src/app/index.js
+2
@@ -17,6 +17,7 @@ import InspectableElements from './InspectableElements';
17
import ReactNativeWeb from './ReactNativeWeb';
18
import ToDoList from './ToDoList';
19
import Toggle from './Toggle';
20
+import ErrorBoundaries from './ErrorBoundaries';
21
import SuspenseTree from './SuspenseTree';
22
import {ignoreErrors, ignoreWarnings} from './console';
23
@@ -54,6 +55,7 @@ function mountTestApp() {
55
mountHelper(InlineWarnings);
56
mountHelper(ReactNativeWeb);
57
mountHelper(Toggle);
58
+ mountHelper(ErrorBoundaries);
59
mountHelper(SuspenseTree);
60
mountHelper(DeeplyNestedComponents);
61
mountHelper(Iframe);
packages/react-reconciler/src/ReactFiberBeginWork.new.js
+38
-1
@@ -70,6 +70,7 @@ import {
70
ChildDeletion,
71
ForceUpdateForLegacySuspense,
72
StaticMask,
73
+ ShouldCapture,
74
} from './ReactFiberFlags';
75
import ReactSharedInternals from 'shared/ReactSharedInternals';
76
import {
@@ -111,6 +112,7 @@ import {
112
processUpdateQueue,
113
cloneUpdateQueue,
114
initializeUpdateQueue,
115
+ enqueueCapturedUpdate,
116
} from './ReactUpdateQueue.new';
117
import {
118
NoLane,
@@ -125,6 +127,7 @@ import {
127
removeLanes,
128
mergeLanes,
129
getBumpedLaneForHydration,
130
+ pickArbitraryLane,
131
} from './ReactFiberLane.new';
132
import {
133
ConcurrentMode,
@@ -141,7 +144,7 @@ import {
144
isPrimaryRenderer,
145
} from './ReactFiberHostConfig';
146
import type {SuspenseInstance} from './ReactFiberHostConfig';
144
-import {shouldSuspend} from './ReactFiberReconciler';
147
+import {shouldError, shouldSuspend} from './ReactFiberReconciler';
148
import {pushHostContext, pushHostContainer} from './ReactFiberHostContext.new';
149
import {
150
suspenseStackCursor,
@@ -219,6 +222,8 @@ import {
222
restoreSpawnedCachePool,
223
getOffscreenDeferredCachePool,
224
} from './ReactFiberCacheComponent.new';
225
+import {createCapturedValue} from './ReactCapturedValue';
226
+import {createClassErrorUpdate} from './ReactFiberThrow.new';
227
import is from 'shared/objectIs';
228
229
import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev';
@@ -947,6 +952,38 @@ function updateClassComponent(
952
renderLanes: Lanes,
953
) {
954
if (__DEV__) {
955
+ // This is used by DevTools to force a boundary to error.
956
+ switch (shouldError(workInProgress)) {
957
+ case false: {
958
+ const instance = workInProgress.stateNode;
959
+ const ctor = workInProgress.type;
960
+ // TODO This way of resetting the error boundary state is a hack.
961
+ // Is there a better way to do this?
962
+ const tempInstance = new ctor(
963
+ workInProgress.memoizedProps,
964
+ instance.context,
965
+ );
966
+ const state = tempInstance.state;
967
+ instance.updater.enqueueSetState(instance, state, null);
968
+ break;
969
+ }
970
+ case true: {
971
+ workInProgress.flags |= DidCapture;
972
+ workInProgress.flags |= ShouldCapture;
973
+ const error = new Error('Simulated error coming from DevTools');
974
+ const lane = pickArbitraryLane(renderLanes);
975
+ workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
976
+ // Schedule the error boundary to re-render using updated state
977
+ const update = createClassErrorUpdate(
978
+ workInProgress,
979
+ createCapturedValue(error, workInProgress),
980
+ lane,
981
+ );
982
+ enqueueCapturedUpdate(workInProgress, update);
983
+ break;
984
+ }
985
+ }
986
+
987
if (workInProgress.type !== workInProgress.elementType) {
988
// Lazy component props can't be validated in createElement
989
// because they're only guaranteed to be resolved here.
packages/react-reconciler/src/ReactFiberBeginWork.old.js
+38
-1
@@ -70,6 +70,7 @@ import {
70
ChildDeletion,
71
ForceUpdateForLegacySuspense,
72
StaticMask,
73
+ ShouldCapture,
74
} from './ReactFiberFlags';
75
import ReactSharedInternals from 'shared/ReactSharedInternals';
76
import {
@@ -111,6 +112,7 @@ import {
112
processUpdateQueue,
113
cloneUpdateQueue,
114
initializeUpdateQueue,
115
+ enqueueCapturedUpdate,
116
} from './ReactUpdateQueue.old';
117
import {
118
NoLane,
@@ -125,6 +127,7 @@ import {
127
removeLanes,
128
mergeLanes,
129
getBumpedLaneForHydration,
130
+ pickArbitraryLane,
131
} from './ReactFiberLane.old';
132
import {
133
ConcurrentMode,
@@ -141,7 +144,7 @@ import {
144
isPrimaryRenderer,
145
} from './ReactFiberHostConfig';
146
import type {SuspenseInstance} from './ReactFiberHostConfig';
144
-import {shouldSuspend} from './ReactFiberReconciler';
147
+import {shouldError, shouldSuspend} from './ReactFiberReconciler';
148
import {pushHostContext, pushHostContainer} from './ReactFiberHostContext.old';
149
import {
150
suspenseStackCursor,
@@ -219,6 +222,8 @@ import {
222
restoreSpawnedCachePool,
223
getOffscreenDeferredCachePool,
224
} from './ReactFiberCacheComponent.old';
225
+import {createCapturedValue} from './ReactCapturedValue';
226
+import {createClassErrorUpdate} from './ReactFiberThrow.old';
227
import is from 'shared/objectIs';
228
229
import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev';
@@ -947,6 +952,38 @@ function updateClassComponent(
952
renderLanes: Lanes,
953
) {
954
if (__DEV__) {
955
+ // This is used by DevTools to force a boundary to error.
956
+ switch (shouldError(workInProgress)) {
957
+ case false: {
958
+ const instance = workInProgress.stateNode;
959
+ const ctor = workInProgress.type;
960
+ // TODO This way of resetting the error boundary state is a hack.
961
+ // Is there a better way to do this?
962
+ const tempInstance = new ctor(
963
+ workInProgress.memoizedProps,
964
+ instance.context,
965
+ );
966
+ const state = tempInstance.state;
967
+ instance.updater.enqueueSetState(instance, state, null);
968
+ break;
969
+ }
970
+ case true: {
971
+ workInProgress.flags |= DidCapture;
972
+ workInProgress.flags |= ShouldCapture;
973
+ const error = new Error('Simulated error coming from DevTools');
974
+ const lane = pickArbitraryLane(renderLanes);
975
+ workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
976
+ // Schedule the error boundary to re-render using updated state
977
+ const update = createClassErrorUpdate(
978
+ workInProgress,
979
+ createCapturedValue(error, workInProgress),
980
+ lane,
981
+ );
982
+ enqueueCapturedUpdate(workInProgress, update);
983
+ break;
984
+ }
985
+ }
986
+
987
if (workInProgress.type !== workInProgress.elementType) {
988
// Lazy component props can't be validated in createElement
989
// because they're only guaranteed to be resolved here.
packages/react-reconciler/src/ReactFiberReconciler.js
+5
@@ -35,6 +35,7 @@ import {
35
findHostInstance as findHostInstance_old,
36
findHostInstanceWithWarning as findHostInstanceWithWarning_old,
37
findHostInstanceWithNoPortals as findHostInstanceWithNoPortals_old,
38
+ shouldError as shouldError_old,
39
shouldSuspend as shouldSuspend_old,
40
injectIntoDevTools as injectIntoDevTools_old,
41
act as act_old,
@@ -75,6 +76,7 @@ import {
76
findHostInstance as findHostInstance_new,
77
findHostInstanceWithWarning as findHostInstanceWithWarning_new,
78
findHostInstanceWithNoPortals as findHostInstanceWithNoPortals_new,
79
+ shouldError as shouldError_new,
80
shouldSuspend as shouldSuspend_new,
81
injectIntoDevTools as injectIntoDevTools_new,
82
act as act_new,
@@ -155,6 +157,9 @@ export const findHostInstanceWithWarning = enableNewReconciler
157
export const findHostInstanceWithNoPortals = enableNewReconciler
158
? findHostInstanceWithNoPortals_new
159
: findHostInstanceWithNoPortals_old;
160
+export const shouldError = enableNewReconciler
161
+ ? shouldError_new
162
+ : shouldError_old;
163
export const shouldSuspend = enableNewReconciler
164
? shouldSuspend_new
165
: shouldSuspend_old;
packages/react-reconciler/src/ReactFiberReconciler.new.js
+12
@@ -463,6 +463,12 @@ export function findHostInstanceWithNoPortals(
463
return hostFiber.stateNode;
464
}
465
466
+let shouldErrorImpl = fiber => null;
467
+
468
+export function shouldError(fiber: Fiber): ?boolean {
469
+ return shouldErrorImpl(fiber);
470
+}
471
+
472
let shouldSuspendImpl = fiber => false;
473
474
export function shouldSuspend(fiber: Fiber): boolean {
@@ -476,6 +482,7 @@ let overrideProps = null;
482
let overridePropsDeletePath = null;
483
let overridePropsRenamePath = null;
484
let scheduleUpdate = null;
485
+let setErrorHandler = null;
486
let setSuspenseHandler = null;
487
488
if (__DEV__) {
@@ -690,6 +697,10 @@ if (__DEV__) {
697
scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
698
};
699
700
+ setErrorHandler = (newShouldErrorImpl: Fiber => ?boolean) => {
701
+ shouldErrorImpl = newShouldErrorImpl;
702
+ };
703
+
704
setSuspenseHandler = (newShouldSuspendImpl: Fiber => boolean) => {
705
shouldSuspendImpl = newShouldSuspendImpl;
706
};
@@ -728,6 +739,7 @@ export function injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean {
739
overrideProps,
740
overridePropsDeletePath,
741
overridePropsRenamePath,
742
+ setErrorHandler,
743
setSuspenseHandler,
744
scheduleUpdate,
745
currentDispatcherRef: ReactCurrentDispatcher,
packages/react-reconciler/src/ReactFiberReconciler.old.js
+12
@@ -463,6 +463,12 @@ export function findHostInstanceWithNoPortals(
463
return hostFiber.stateNode;
464
}
465
466
+let shouldErrorImpl = fiber => null;
467
+
468
+export function shouldError(fiber: Fiber): ?boolean {
469
+ return shouldErrorImpl(fiber);
470
+}
471
+
472
let shouldSuspendImpl = fiber => false;
473
474
export function shouldSuspend(fiber: Fiber): boolean {
@@ -476,6 +482,7 @@ let overrideProps = null;
482
let overridePropsDeletePath = null;
483
let overridePropsRenamePath = null;
484
let scheduleUpdate = null;
485
+let setErrorHandler = null;
486
let setSuspenseHandler = null;
487
488
if (__DEV__) {
@@ -690,6 +697,10 @@ if (__DEV__) {
697
scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
698
};
699
700
+ setErrorHandler = (newShouldErrorImpl: Fiber => ?boolean) => {
701
+ shouldErrorImpl = newShouldErrorImpl;
702
+ };
703
+
704
setSuspenseHandler = (newShouldSuspendImpl: Fiber => boolean) => {
705
shouldSuspendImpl = newShouldSuspendImpl;
706
};
@@ -728,6 +739,7 @@ export function injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean {
739
overrideProps,
740
overridePropsDeletePath,
741
overridePropsRenamePath,
742
+ setErrorHandler,
743
setSuspenseHandler,
744
scheduleUpdate,
745
currentDispatcherRef: ReactCurrentDispatcher,