Skip to content

Commit 8b42015

Browse files
baophamBrian Vaughn
andauthored
Devtools: add feature to trigger an error boundary (#21583)
Co-authored-by: Brian Vaughn <bvaughn@fb.com>
1 parent 2418f24 commit 8b42015

File tree

18 files changed

+515
-5
lines changed

18 files changed

+515
-5
lines changed

packages/react-devtools-shared/src/__tests__/inspectedElement-test.js

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2380,4 +2380,95 @@ describe('InspectedElement', () => {
23802380
`);
23812381
});
23822382
});
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+
});
23832474
});

packages/react-devtools-shared/src/__tests__/inspectedElementSerializer.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export function test(maybeInspectedElement) {
1414
hasOwnProperty('canEditFunctionProps') &&
1515
hasOwnProperty('canEditHooks') &&
1616
hasOwnProperty('canToggleSuspense') &&
17+
hasOwnProperty('canToggleError') &&
1718
hasOwnProperty('canViewSource')
1819
);
1920
}

packages/react-devtools-shared/src/backend/agent.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ type OverrideValueAtPathParams = {|
122122
value: any,
123123
|};
124124

125+
type OverrideErrorParams = {|
126+
id: number,
127+
rendererID: number,
128+
forceError: boolean,
129+
|};
130+
125131
type OverrideSuspenseParams = {|
126132
id: number,
127133
rendererID: number,
@@ -183,6 +189,7 @@ export default class Agent extends EventEmitter<{|
183189
bridge.addListener('getOwnersList', this.getOwnersList);
184190
bridge.addListener('inspectElement', this.inspectElement);
185191
bridge.addListener('logElementToConsole', this.logElementToConsole);
192+
bridge.addListener('overrideError', this.overrideError);
186193
bridge.addListener('overrideSuspense', this.overrideSuspense);
187194
bridge.addListener('overrideValueAtPath', this.overrideValueAtPath);
188195
bridge.addListener('reloadAndProfile', this.reloadAndProfile);
@@ -381,6 +388,15 @@ export default class Agent extends EventEmitter<{|
381388
}
382389
};
383390

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+
384400
overrideSuspense = ({
385401
id,
386402
rendererID,

packages/react-devtools-shared/src/backend/legacy/renderer.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,11 @@ export function attach(
800800
canEditFunctionPropsDeletePaths: false,
801801
canEditFunctionPropsRenamePaths: false,
802802

803+
// Toggle error boundary did not exist in legacy versions
804+
canToggleError: false,
805+
isErrored: false,
806+
targetErrorBoundaryID: null,
807+
803808
// Suspense did not exist in legacy versions
804809
canToggleSuspense: false,
805810

@@ -1016,6 +1021,9 @@ export function attach(
10161021
const handlePostCommitFiberRoot = () => {
10171022
throw new Error('handlePostCommitFiberRoot not supported by this renderer');
10181023
};
1024+
const overrideError = () => {
1025+
throw new Error('overrideError not supported by this renderer');
1026+
};
10191027
const overrideSuspense = () => {
10201028
throw new Error('overrideSuspense not supported by this renderer');
10211029
};
@@ -1089,6 +1097,7 @@ export function attach(
10891097
handlePostCommitFiberRoot,
10901098
inspectElement,
10911099
logElementToConsole,
1100+
overrideError,
10921101
overrideSuspense,
10931102
overrideValueAtPath,
10941103
renamePath,

packages/react-devtools-shared/src/backend/renderer.js

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ type ReactPriorityLevelsType = {|
120120
|};
121121

122122
type ReactTypeOfSideEffectType = {|
123+
DidCapture: number,
123124
NoFlags: number,
124125
PerformedWork: number,
125126
Placement: number,
@@ -147,6 +148,7 @@ export function getInternalReactConstants(
147148
ReactTypeOfWork: WorkTagMap,
148149
|} {
149150
const ReactTypeOfSideEffect: ReactTypeOfSideEffectType = {
151+
DidCapture: 0b10000000,
150152
NoFlags: 0b00,
151153
PerformedWork: 0b01,
152154
Placement: 0b10,
@@ -519,7 +521,13 @@ export function attach(
519521
ReactTypeOfWork,
520522
ReactTypeOfSideEffect,
521523
} = getInternalReactConstants(version);
522-
const {Incomplete, NoFlags, PerformedWork, Placement} = ReactTypeOfSideEffect;
524+
const {
525+
DidCapture,
526+
Incomplete,
527+
NoFlags,
528+
PerformedWork,
529+
Placement,
530+
} = ReactTypeOfSideEffect;
523531
const {
524532
CacheComponent,
525533
ClassComponent,
@@ -557,9 +565,13 @@ export function attach(
557565
overrideProps,
558566
overridePropsDeletePath,
559567
overridePropsRenamePath,
568+
setErrorHandler,
560569
setSuspenseHandler,
561570
scheduleUpdate,
562571
} = renderer;
572+
const supportsTogglingError =
573+
typeof setErrorHandler === 'function' &&
574+
typeof scheduleUpdate === 'function';
563575
const supportsTogglingSuspense =
564576
typeof setSuspenseHandler === 'function' &&
565577
typeof scheduleUpdate === 'function';
@@ -659,6 +671,13 @@ export function attach(
659671
type: 'error' | 'warn',
660672
args: $ReadOnlyArray<any>,
661673
): 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+
}
662681
const message = format(...args);
663682
if (__DEBUG__) {
664683
debug('onErrorOrWarning', fiber, null, `${type}: "${message}"`);
@@ -1133,6 +1152,13 @@ export function attach(
11331152
if (alternate !== null) {
11341153
fiberToIDMap.delete(alternate);
11351154
}
1155+
1156+
if (forceErrorForFiberIDs.has(fiberID)) {
1157+
forceErrorForFiberIDs.delete(fiberID);
1158+
if (forceErrorForFiberIDs.size === 0 && setErrorHandler != null) {
1159+
setErrorHandler(shouldErrorFiberAlwaysNull);
1160+
}
1161+
}
11361162
});
11371163
untrackFibersSet.clear();
11381164
}
@@ -2909,6 +2935,34 @@ export function attach(
29092935
return {instance, style};
29102936
}
29112937

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+
29122966
function inspectElementRaw(id: number): InspectedElement | null {
29132967
const fiber = findCurrentFiberUsingSlowPathById(id);
29142968
if (fiber == null) {
@@ -3063,6 +3117,21 @@ export function attach(
30633117
const errors = fiberIDToErrorsMap.get(id) || new Map();
30643118
const warnings = fiberIDToWarningsMap.get(id) || new Map();
30653119

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+
30663135
return {
30673136
id,
30683137

@@ -3080,6 +3149,11 @@ export function attach(
30803149
canEditFunctionPropsRenamePaths:
30813150
typeof overridePropsRenamePath === 'function',
30823151

3152+
canToggleError: supportsTogglingError && targetErrorBoundaryID != null,
3153+
// Is this error boundary in error state.
3154+
isErrored,
3155+
targetErrorBoundaryID,
3156+
30833157
canToggleSuspense:
30843158
supportsTogglingSuspense &&
30853159
// If it's showing the real content, we can always flip fallback.
@@ -3747,7 +3821,72 @@ export function attach(
37473821
}
37483822

37493823
// 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+
}
37513890

37523891
function shouldSuspendFiberAlwaysFalse() {
37533892
return false;
@@ -4042,6 +4181,7 @@ export function attach(
40424181
logElementToConsole,
40434182
prepareViewAttributeSource,
40444183
prepareViewElementSource,
4184+
overrideError,
40454185
overrideSuspense,
40464186
overrideValueAtPath,
40474187
renamePath,

0 commit comments

Comments
 (0)