@samitouri / QOS-React-2 / commits / 8b4201535c

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,