Skip to content

Commit

Permalink
Devtools: add feature to trigger an error boundary (#21583)
Browse files Browse the repository at this point in the history
Co-authored-by: Brian Vaughn <[email protected]>
  • Loading branch information
baopham and Brian Vaughn authored Jun 3, 2021
1 parent 2418f24 commit 8b42015
Show file tree
Hide file tree
Showing 18 changed files with 515 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2380,4 +2380,95 @@ describe('InspectedElement', () => {
`);
});
});

describe('error boundary', () => {
it('can toggle error', async () => {
class ErrorBoundary extends React.Component<any> {
state = {hasError: false};
static getDerivedStateFromError(error) {
return {hasError: true};
}
render() {
const {hasError} = this.state;
return hasError ? 'has-error' : this.props.children;
}
}
const Example = () => 'example';

await utils.actAsync(() =>
ReactDOM.render(
<ErrorBoundary>
<Example />
</ErrorBoundary>,
document.createElement('div'),
),
);

const targetErrorBoundaryID = ((store.getElementIDAtIndex(
0,
): any): number);
const inspect = index => {
// HACK: Recreate TestRenderer instance so we can inspect different
// elements
testRendererInstance = TestRenderer.create(null, {
unstable_isConcurrent: true,
});
return inspectElementAtIndex(index);
};
const toggleError = async forceError => {
await withErrorsOrWarningsIgnored(['ErrorBoundary'], async () => {
await utils.actAsync(() => {
bridge.send('overrideError', {
id: targetErrorBoundaryID,
rendererID: store.getRendererIDForElement(targetErrorBoundaryID),
forceError,
});
});
});

TestUtilsAct(() => {
jest.runOnlyPendingTimers();
});
};

// Inspect <ErrorBoundary /> and see that we cannot toggle error state
// on error boundary itself
let inspectedElement = await inspect(0);
expect(inspectedElement.canToggleError).toBe(false);
expect(inspectedElement.targetErrorBoundaryID).toBe(null);

// Inspect <Example />
inspectedElement = await inspect(1);
expect(inspectedElement.canToggleError).toBe(true);
expect(inspectedElement.isErrored).toBe(false);
expect(inspectedElement.targetErrorBoundaryID).toBe(
targetErrorBoundaryID,
);

// now force error state on <Example />
await toggleError(true);

// we are in error state now, <Example /> won't show up
expect(store.getElementIDAtIndex(1)).toBe(null);

// Inpsect <ErrorBoundary /> to toggle off the error state
inspectedElement = await inspect(0);
expect(inspectedElement.canToggleError).toBe(true);
expect(inspectedElement.isErrored).toBe(true);
// its error boundary ID is itself because it's caught the error
expect(inspectedElement.targetErrorBoundaryID).toBe(
targetErrorBoundaryID,
);

await toggleError(false);

// We can now inspect <Example /> with ability to toggle again
inspectedElement = await inspect(1);
expect(inspectedElement.canToggleError).toBe(true);
expect(inspectedElement.isErrored).toBe(false);
expect(inspectedElement.targetErrorBoundaryID).toBe(
targetErrorBoundaryID,
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function test(maybeInspectedElement) {
hasOwnProperty('canEditFunctionProps') &&
hasOwnProperty('canEditHooks') &&
hasOwnProperty('canToggleSuspense') &&
hasOwnProperty('canToggleError') &&
hasOwnProperty('canViewSource')
);
}
Expand Down
16 changes: 16 additions & 0 deletions packages/react-devtools-shared/src/backend/agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ type OverrideValueAtPathParams = {|
value: any,
|};

type OverrideErrorParams = {|
id: number,
rendererID: number,
forceError: boolean,
|};

type OverrideSuspenseParams = {|
id: number,
rendererID: number,
Expand Down Expand Up @@ -183,6 +189,7 @@ export default class Agent extends EventEmitter<{|
bridge.addListener('getOwnersList', this.getOwnersList);
bridge.addListener('inspectElement', this.inspectElement);
bridge.addListener('logElementToConsole', this.logElementToConsole);
bridge.addListener('overrideError', this.overrideError);
bridge.addListener('overrideSuspense', this.overrideSuspense);
bridge.addListener('overrideValueAtPath', this.overrideValueAtPath);
bridge.addListener('reloadAndProfile', this.reloadAndProfile);
Expand Down Expand Up @@ -381,6 +388,15 @@ export default class Agent extends EventEmitter<{|
}
};

overrideError = ({id, rendererID, forceError}: OverrideErrorParams) => {
const renderer = this._rendererInterfaces[rendererID];
if (renderer == null) {
console.warn(`Invalid renderer id "${rendererID}" for element "${id}"`);
} else {
renderer.overrideError(id, forceError);
}
};

overrideSuspense = ({
id,
rendererID,
Expand Down
9 changes: 9 additions & 0 deletions packages/react-devtools-shared/src/backend/legacy/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,11 @@ export function attach(
canEditFunctionPropsDeletePaths: false,
canEditFunctionPropsRenamePaths: false,

// Toggle error boundary did not exist in legacy versions
canToggleError: false,
isErrored: false,
targetErrorBoundaryID: null,

// Suspense did not exist in legacy versions
canToggleSuspense: false,

Expand Down Expand Up @@ -1016,6 +1021,9 @@ export function attach(
const handlePostCommitFiberRoot = () => {
throw new Error('handlePostCommitFiberRoot not supported by this renderer');
};
const overrideError = () => {
throw new Error('overrideError not supported by this renderer');
};
const overrideSuspense = () => {
throw new Error('overrideSuspense not supported by this renderer');
};
Expand Down Expand Up @@ -1089,6 +1097,7 @@ export function attach(
handlePostCommitFiberRoot,
inspectElement,
logElementToConsole,
overrideError,
overrideSuspense,
overrideValueAtPath,
renamePath,
Expand Down
144 changes: 142 additions & 2 deletions packages/react-devtools-shared/src/backend/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ type ReactPriorityLevelsType = {|
|};

type ReactTypeOfSideEffectType = {|
DidCapture: number,
NoFlags: number,
PerformedWork: number,
Placement: number,
Expand Down Expand Up @@ -147,6 +148,7 @@ export function getInternalReactConstants(
ReactTypeOfWork: WorkTagMap,
|} {
const ReactTypeOfSideEffect: ReactTypeOfSideEffectType = {
DidCapture: 0b10000000,
NoFlags: 0b00,
PerformedWork: 0b01,
Placement: 0b10,
Expand Down Expand Up @@ -519,7 +521,13 @@ export function attach(
ReactTypeOfWork,
ReactTypeOfSideEffect,
} = getInternalReactConstants(version);
const {Incomplete, NoFlags, PerformedWork, Placement} = ReactTypeOfSideEffect;
const {
DidCapture,
Incomplete,
NoFlags,
PerformedWork,
Placement,
} = ReactTypeOfSideEffect;
const {
CacheComponent,
ClassComponent,
Expand Down Expand Up @@ -557,9 +565,13 @@ export function attach(
overrideProps,
overridePropsDeletePath,
overridePropsRenamePath,
setErrorHandler,
setSuspenseHandler,
scheduleUpdate,
} = renderer;
const supportsTogglingError =
typeof setErrorHandler === 'function' &&
typeof scheduleUpdate === 'function';
const supportsTogglingSuspense =
typeof setSuspenseHandler === 'function' &&
typeof scheduleUpdate === 'function';
Expand Down Expand Up @@ -659,6 +671,13 @@ export function attach(
type: 'error' | 'warn',
args: $ReadOnlyArray<any>,
): void {
if (type === 'error') {
const maybeID = getFiberIDUnsafe(fiber);
// if this is an error simulated by us to trigger error boundary, ignore
if (maybeID != null && forceErrorForFiberIDs.get(maybeID) === true) {
return;
}
}
const message = format(...args);
if (__DEBUG__) {
debug('onErrorOrWarning', fiber, null, `${type}: "${message}"`);
Expand Down Expand Up @@ -1133,6 +1152,13 @@ export function attach(
if (alternate !== null) {
fiberToIDMap.delete(alternate);
}

if (forceErrorForFiberIDs.has(fiberID)) {
forceErrorForFiberIDs.delete(fiberID);
if (forceErrorForFiberIDs.size === 0 && setErrorHandler != null) {
setErrorHandler(shouldErrorFiberAlwaysNull);
}
}
});
untrackFibersSet.clear();
}
Expand Down Expand Up @@ -2909,6 +2935,34 @@ export function attach(
return {instance, style};
}

function isErrorBoundary(fiber: Fiber): boolean {
const {tag, type} = fiber;

switch (tag) {
case ClassComponent:
case IncompleteClassComponent:
const instance = fiber.stateNode;
return (
typeof type.getDerivedStateFromError === 'function' ||
(instance !== null &&
typeof instance.componentDidCatch === 'function')
);
default:
return false;
}
}

function getNearestErrorBoundaryID(fiber: Fiber): number | null {
let parent = fiber.return;
while (parent !== null) {
if (isErrorBoundary(parent)) {
return getFiberIDUnsafe(parent);
}
parent = parent.return;
}
return null;
}

function inspectElementRaw(id: number): InspectedElement | null {
const fiber = findCurrentFiberUsingSlowPathById(id);
if (fiber == null) {
Expand Down Expand Up @@ -3063,6 +3117,21 @@ export function attach(
const errors = fiberIDToErrorsMap.get(id) || new Map();
const warnings = fiberIDToWarningsMap.get(id) || new Map();

const isErrored =
(fiber.flags & DidCapture) !== NoFlags ||
forceErrorForFiberIDs.get(id) === true;

let targetErrorBoundaryID;
if (isErrorBoundary(fiber)) {
// if the current inspected element is an error boundary,
// either that we want to use it to toggle off error state
// or that we allow to force error state on it if it's within another
// error boundary
targetErrorBoundaryID = isErrored ? id : getNearestErrorBoundaryID(fiber);
} else {
targetErrorBoundaryID = getNearestErrorBoundaryID(fiber);
}

return {
id,

Expand All @@ -3080,6 +3149,11 @@ export function attach(
canEditFunctionPropsRenamePaths:
typeof overridePropsRenamePath === 'function',

canToggleError: supportsTogglingError && targetErrorBoundaryID != null,
// Is this error boundary in error state.
isErrored,
targetErrorBoundaryID,

canToggleSuspense:
supportsTogglingSuspense &&
// If it's showing the real content, we can always flip fallback.
Expand Down Expand Up @@ -3747,7 +3821,72 @@ export function attach(
}

// React will switch between these implementations depending on whether
// we have any manually suspended Fibers or not.
// we have any manually suspended/errored-out Fibers or not.
function shouldErrorFiberAlwaysNull() {
return null;
}

// Map of id and its force error status: true (error), false (toggled off),
// null (do nothing)
const forceErrorForFiberIDs = new Map();
function shouldErrorFiberAccordingToMap(fiber) {
if (typeof setErrorHandler !== 'function') {
throw new Error(
'Expected overrideError() to not get called for earlier React versions.',
);
}

const id = getFiberIDUnsafe(fiber);
if (id === null) {
return null;
}

let status = null;
if (forceErrorForFiberIDs.has(id)) {
status = forceErrorForFiberIDs.get(id);
if (status === false) {
// TRICKY overrideError adds entries to this Map,
// so ideally it would be the method that clears them too,
// but that would break the functionality of the feature,
// since DevTools needs to tell React to act differently than it normally would
// (don't just re-render the failed boundary, but reset its errored state too).
// So we can only clear it after telling React to reset the state.
// Technically this is premature and we should schedule it for later,
// since the render could always fail without committing the updated error boundary,
// but since this is a DEV-only feature, the simplicity is worth the trade off.
forceErrorForFiberIDs.delete(id);

if (forceErrorForFiberIDs.size === 0) {
// Last override is gone. Switch React back to fast path.
setErrorHandler(shouldErrorFiberAlwaysNull);
}
}
}
return status;
}

function overrideError(id, forceError) {
if (
typeof setErrorHandler !== 'function' ||
typeof scheduleUpdate !== 'function'
) {
throw new Error(
'Expected overrideError() to not get called for earlier React versions.',
);
}

forceErrorForFiberIDs.set(id, forceError);

if (forceErrorForFiberIDs.size === 1) {
// First override is added. Switch React to slower path.
setErrorHandler(shouldErrorFiberAccordingToMap);
}

const fiber = idToArbitraryFiberMap.get(id);
if (fiber != null) {
scheduleUpdate(fiber);
}
}

function shouldSuspendFiberAlwaysFalse() {
return false;
Expand Down Expand Up @@ -4042,6 +4181,7 @@ export function attach(
logElementToConsole,
prepareViewAttributeSource,
prepareViewElementSource,
overrideError,
overrideSuspense,
overrideValueAtPath,
renamePath,
Expand Down
Loading

0 comments on commit 8b42015

Please sign in to comment.