Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Devtools: add feature to trigger an error boundary #21583

Merged
merged 7 commits into from
Jun 3, 2021
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
);
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a nicely written test 👍🏼

});
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
135 changes: 133 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,
baopham marked this conversation as resolved.
Show resolved Hide resolved
targetErrorBoundaryID,

canToggleSuspense:
supportsTogglingSuspense &&
// If it's showing the real content, we can always flip fallback.
Expand Down Expand Up @@ -3747,7 +3821,63 @@ 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) {
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);
}
}
baopham marked this conversation as resolved.
Show resolved Hide resolved

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