Skip to content

Devtools: add feature to trigger an error boundary #21583

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

Merged
merged 7 commits into from
Jun 3, 2021
Merged
Changes from all 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
@@ -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
@@ -14,6 +14,7 @@ export function test(maybeInspectedElement) {
hasOwnProperty('canEditFunctionProps') &&
hasOwnProperty('canEditHooks') &&
hasOwnProperty('canToggleSuspense') &&
hasOwnProperty('canToggleError') &&
hasOwnProperty('canViewSource')
);
}
16 changes: 16 additions & 0 deletions packages/react-devtools-shared/src/backend/agent.js
Original file line number Diff line number Diff line change
@@ -122,6 +122,12 @@ type OverrideValueAtPathParams = {|
value: any,
|};

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

type OverrideSuspenseParams = {|
id: number,
rendererID: number,
@@ -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);
@@ -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,
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
@@ -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,

@@ -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');
};
@@ -1089,6 +1097,7 @@ export function attach(
handlePostCommitFiberRoot,
inspectElement,
logElementToConsole,
overrideError,
overrideSuspense,
overrideValueAtPath,
renamePath,
144 changes: 142 additions & 2 deletions packages/react-devtools-shared/src/backend/renderer.js
Original file line number Diff line number Diff line change
@@ -120,6 +120,7 @@ type ReactPriorityLevelsType = {|
|};

type ReactTypeOfSideEffectType = {|
DidCapture: number,
NoFlags: number,
PerformedWork: number,
Placement: number,
@@ -147,6 +148,7 @@ export function getInternalReactConstants(
ReactTypeOfWork: WorkTagMap,
|} {
const ReactTypeOfSideEffect: ReactTypeOfSideEffectType = {
DidCapture: 0b10000000,
NoFlags: 0b00,
PerformedWork: 0b01,
Placement: 0b10,
@@ -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,
@@ -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';
@@ -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}"`);
@@ -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();
}
@@ -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) {
@@ -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,

@@ -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.
@@ -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;
@@ -4042,6 +4181,7 @@ export function attach(
logElementToConsole,
prepareViewAttributeSource,
prepareViewElementSource,
overrideError,
overrideSuspense,
overrideValueAtPath,
renamePath,
8 changes: 8 additions & 0 deletions packages/react-devtools-shared/src/backend/types.js
Original file line number Diff line number Diff line change
@@ -142,6 +142,8 @@ export type ReactRenderer = {
ComponentTree?: any,
// Present for React DOM v12 (possibly earlier) through v15.
Mount?: any,
// Only injected by React v17.0.3+ in DEV mode
setErrorHandler?: ?(shouldError: (fiber: Object) => ?boolean) => void,
...
};

@@ -224,6 +226,11 @@ export type InspectedElement = {|
canEditFunctionPropsDeletePaths: boolean,
canEditFunctionPropsRenamePaths: boolean,

// Is this Error, and can its value be overridden now?
canToggleError: boolean,
isErrored: boolean,
targetErrorBoundaryID: ?number,

// Is this Suspense, and can its value be overridden now?
canToggleSuspense: boolean,

@@ -332,6 +339,7 @@ export type RendererInterface = {
inspectedPaths: Object,
) => InspectedElementPayload,
logElementToConsole: (id: number) => void,
overrideError: (id: number, forceError: boolean) => void,
overrideSuspense: (id: number, forceFallback: boolean) => void,
overrideValueAtPath: (
type: Type,
6 changes: 6 additions & 0 deletions packages/react-devtools-shared/src/backendAPI.js
Original file line number Diff line number Diff line change
@@ -190,6 +190,9 @@ export function convertInspectedElementBackendToFrontend(
canEditHooks,
canEditHooksAndDeletePaths,
canEditHooksAndRenamePaths,
canToggleError,
isErrored,
targetErrorBoundaryID,
canToggleSuspense,
canViewSource,
hasLegacyContext,
@@ -216,6 +219,9 @@ export function convertInspectedElementBackendToFrontend(
canEditHooks,
canEditHooksAndDeletePaths,
canEditHooksAndRenamePaths,
canToggleError,
isErrored,
targetErrorBoundaryID,
canToggleSuspense,
canViewSource,
hasLegacyContext,
6 changes: 6 additions & 0 deletions packages/react-devtools-shared/src/bridge.js
Original file line number Diff line number Diff line change
@@ -115,6 +115,11 @@ type OverrideValueAtPath = {|
value: any,
|};

type OverrideError = {|
...ElementAndRendererID,
forceError: boolean,
|};

type OverrideSuspense = {|
...ElementAndRendererID,
forceFallback: boolean,
@@ -201,6 +206,7 @@ type FrontendEvents = {|
highlightNativeElement: [HighlightElementInDOM],
inspectElement: [InspectElementParams],
logElementToConsole: [ElementAndRendererID],
overrideError: [OverrideError],
overrideSuspense: [OverrideSuspense],
overrideValueAtPath: [OverrideValueAtPath],
profilingData: [ProfilingDataBackend],
Original file line number Diff line number Diff line change
@@ -32,6 +32,7 @@ export type IconType =
| 'save'
| 'search'
| 'settings'
| 'error'
| 'suspend'
| 'undo'
| 'up'
@@ -109,6 +110,9 @@ export default function ButtonIcon({className = '', type}: Props) {
case 'settings':
pathData = PATH_SETTINGS;
break;
case 'error':
pathData = PATH_ERROR;
break;
case 'suspend':
pathData = PATH_SUSPEND;
break;
@@ -187,7 +191,7 @@ const PATH_LOG_DATA = `
`;

const PATH_MORE = `
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
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
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
`;

@@ -223,6 +227,9 @@ const PATH_SETTINGS = `
3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z
`;

const PATH_ERROR =
'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';

const PATH_SUSPEND = `
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
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
Original file line number Diff line number Diff line change
@@ -92,15 +92,47 @@ export default function InspectedElementWrapper(_: Props) {
(canViewElementSourceFunction === null ||
canViewElementSourceFunction(inspectedElement));

const isErrored = inspectedElement != null && inspectedElement.isErrored;
const targetErrorBoundaryID =
inspectedElement != null ? inspectedElement.targetErrorBoundaryID : null;

const isSuspended =
element !== null &&
element.type === ElementTypeSuspense &&
inspectedElement != null &&
inspectedElement.state != null;

const canToggleError =
inspectedElement != null && inspectedElement.canToggleError;

const canToggleSuspense =
inspectedElement != null && inspectedElement.canToggleSuspense;

const toggleErrored = useCallback(() => {
if (inspectedElement == null || targetErrorBoundaryID == null) {
return;
}

const rendererID = store.getRendererIDForElement(targetErrorBoundaryID);
if (rendererID !== null) {
if (targetErrorBoundaryID !== inspectedElement.id) {
// Update tree selection so that if we cause a component to error,
// the nearest error boundary will become the newly selected thing.
dispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: targetErrorBoundaryID,
});
}

// Toggle error.
bridge.send('overrideError', {
id: targetErrorBoundaryID,
rendererID,
forceError: !isErrored,
});
}
}, [bridge, dispatch, isErrored, targetErrorBoundaryID]);

// TODO (suspense toggle) Would be nice to eventually use a two setState pattern here as well.
const toggleSuspended = useCallback(() => {
let nearestSuspenseElement = null;
@@ -177,6 +209,19 @@ export default function InspectedElementWrapper(_: Props) {
</div>
</div>

{canToggleError && (
<Toggle
className={styles.IconButton}
isChecked={isErrored}
onChange={toggleErrored}
title={
isErrored
? 'Clear the forced error'
: 'Force the selected component into an errored state'
}>
<ButtonIcon type="error" />
</Toggle>
)}
{canToggleSuspense && (
<Toggle
className={styles.IconButton}
Original file line number Diff line number Diff line change
@@ -76,6 +76,11 @@ export type InspectedElement = {|
canEditFunctionPropsDeletePaths: boolean,
canEditFunctionPropsRenamePaths: boolean,

// Is this Error, and can its value be overridden now?
isErrored: boolean,
canToggleError: boolean,
targetErrorBoundaryID: ?number,

// Is this Suspense, and can its value be overridden now?
canToggleSuspense: boolean,

71 changes: 71 additions & 0 deletions packages/react-devtools-shell/src/app/ErrorBoundaries/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/

import * as React from 'react';
import {Fragment} from 'react';

class ErrorBoundary extends React.Component {
state = {hasError: false};

static getDerivedStateFromError(error) {
return {hasError: true};
}

render() {
const {hasError} = this.state;
if (hasError) {
return (
<div
style={{
color: 'red',
border: '1px solid red',
borderRadius: '0.25rem',
margin: '0.5rem',
padding: '0.5rem',
}}>
An error was thrown.
</div>
);
}

const {children} = this.props;
return (
<div
style={{
border: '1px solid gray',
borderRadius: '0.25rem',
margin: '0.5rem',
padding: '0.5rem',
}}>
{children}
</div>
);
}
}

function Component({label}) {
return <div>{label}</div>;
}

export default function ErrorBoundaries() {
return (
<Fragment>
<h1>Nested error boundaries demo</h1>
<ErrorBoundary>
<Component label="Outer component" />
<ErrorBoundary>
<Component label="Inner component" />
</ErrorBoundary>
</ErrorBoundary>
<ErrorBoundary>
<Component label="Neighbour component" />
</ErrorBoundary>
</Fragment>
);
}
2 changes: 2 additions & 0 deletions packages/react-devtools-shell/src/app/index.js
Original file line number Diff line number Diff line change
@@ -17,6 +17,7 @@ import InspectableElements from './InspectableElements';
import ReactNativeWeb from './ReactNativeWeb';
import ToDoList from './ToDoList';
import Toggle from './Toggle';
import ErrorBoundaries from './ErrorBoundaries';
import SuspenseTree from './SuspenseTree';
import {ignoreErrors, ignoreWarnings} from './console';

@@ -54,6 +55,7 @@ function mountTestApp() {
mountHelper(InlineWarnings);
mountHelper(ReactNativeWeb);
mountHelper(Toggle);
mountHelper(ErrorBoundaries);
mountHelper(SuspenseTree);
mountHelper(DeeplyNestedComponents);
mountHelper(Iframe);
39 changes: 38 additions & 1 deletion packages/react-reconciler/src/ReactFiberBeginWork.new.js
Original file line number Diff line number Diff line change
@@ -70,6 +70,7 @@ import {
ChildDeletion,
ForceUpdateForLegacySuspense,
StaticMask,
ShouldCapture,
} from './ReactFiberFlags';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import {
@@ -111,6 +112,7 @@ import {
processUpdateQueue,
cloneUpdateQueue,
initializeUpdateQueue,
enqueueCapturedUpdate,
} from './ReactUpdateQueue.new';
import {
NoLane,
@@ -125,6 +127,7 @@ import {
removeLanes,
mergeLanes,
getBumpedLaneForHydration,
pickArbitraryLane,
} from './ReactFiberLane.new';
import {
ConcurrentMode,
@@ -141,7 +144,7 @@ import {
isPrimaryRenderer,
} from './ReactFiberHostConfig';
import type {SuspenseInstance} from './ReactFiberHostConfig';
import {shouldSuspend} from './ReactFiberReconciler';
import {shouldError, shouldSuspend} from './ReactFiberReconciler';
import {pushHostContext, pushHostContainer} from './ReactFiberHostContext.new';
import {
suspenseStackCursor,
@@ -219,6 +222,8 @@ import {
restoreSpawnedCachePool,
getOffscreenDeferredCachePool,
} from './ReactFiberCacheComponent.new';
import {createCapturedValue} from './ReactCapturedValue';
import {createClassErrorUpdate} from './ReactFiberThrow.new';
import is from 'shared/objectIs';

import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev';
@@ -947,6 +952,38 @@ function updateClassComponent(
renderLanes: Lanes,
) {
if (__DEV__) {
// This is used by DevTools to force a boundary to error.
switch (shouldError(workInProgress)) {
case false: {
const instance = workInProgress.stateNode;
const ctor = workInProgress.type;
// TODO This way of resetting the error boundary state is a hack.
// Is there a better way to do this?
const tempInstance = new ctor(
workInProgress.memoizedProps,
instance.context,
);
const state = tempInstance.state;
instance.updater.enqueueSetState(instance, state, null);
break;
}
case true: {
workInProgress.flags |= DidCapture;
workInProgress.flags |= ShouldCapture;
const error = new Error('Simulated error coming from DevTools');
const lane = pickArbitraryLane(renderLanes);
workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
// Schedule the error boundary to re-render using updated state
const update = createClassErrorUpdate(
workInProgress,
createCapturedValue(error, workInProgress),
lane,
);
enqueueCapturedUpdate(workInProgress, update);
break;
}
}

if (workInProgress.type !== workInProgress.elementType) {
// Lazy component props can't be validated in createElement
// because they're only guaranteed to be resolved here.
39 changes: 38 additions & 1 deletion packages/react-reconciler/src/ReactFiberBeginWork.old.js
Original file line number Diff line number Diff line change
@@ -70,6 +70,7 @@ import {
ChildDeletion,
ForceUpdateForLegacySuspense,
StaticMask,
ShouldCapture,
} from './ReactFiberFlags';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import {
@@ -111,6 +112,7 @@ import {
processUpdateQueue,
cloneUpdateQueue,
initializeUpdateQueue,
enqueueCapturedUpdate,
} from './ReactUpdateQueue.old';
import {
NoLane,
@@ -125,6 +127,7 @@ import {
removeLanes,
mergeLanes,
getBumpedLaneForHydration,
pickArbitraryLane,
} from './ReactFiberLane.old';
import {
ConcurrentMode,
@@ -141,7 +144,7 @@ import {
isPrimaryRenderer,
} from './ReactFiberHostConfig';
import type {SuspenseInstance} from './ReactFiberHostConfig';
import {shouldSuspend} from './ReactFiberReconciler';
import {shouldError, shouldSuspend} from './ReactFiberReconciler';
import {pushHostContext, pushHostContainer} from './ReactFiberHostContext.old';
import {
suspenseStackCursor,
@@ -219,6 +222,8 @@ import {
restoreSpawnedCachePool,
getOffscreenDeferredCachePool,
} from './ReactFiberCacheComponent.old';
import {createCapturedValue} from './ReactCapturedValue';
import {createClassErrorUpdate} from './ReactFiberThrow.old';
import is from 'shared/objectIs';

import {disableLogs, reenableLogs} from 'shared/ConsolePatchingDev';
@@ -947,6 +952,38 @@ function updateClassComponent(
renderLanes: Lanes,
) {
if (__DEV__) {
// This is used by DevTools to force a boundary to error.
switch (shouldError(workInProgress)) {
case false: {
const instance = workInProgress.stateNode;
const ctor = workInProgress.type;
// TODO This way of resetting the error boundary state is a hack.
// Is there a better way to do this?
const tempInstance = new ctor(
workInProgress.memoizedProps,
instance.context,
);
const state = tempInstance.state;
instance.updater.enqueueSetState(instance, state, null);
break;
}
case true: {
workInProgress.flags |= DidCapture;
workInProgress.flags |= ShouldCapture;
const error = new Error('Simulated error coming from DevTools');
const lane = pickArbitraryLane(renderLanes);
workInProgress.lanes = mergeLanes(workInProgress.lanes, lane);
// Schedule the error boundary to re-render using updated state
const update = createClassErrorUpdate(
workInProgress,
createCapturedValue(error, workInProgress),
lane,
);
enqueueCapturedUpdate(workInProgress, update);
break;
}
}

if (workInProgress.type !== workInProgress.elementType) {
// Lazy component props can't be validated in createElement
// because they're only guaranteed to be resolved here.
5 changes: 5 additions & 0 deletions packages/react-reconciler/src/ReactFiberReconciler.js
Original file line number Diff line number Diff line change
@@ -35,6 +35,7 @@ import {
findHostInstance as findHostInstance_old,
findHostInstanceWithWarning as findHostInstanceWithWarning_old,
findHostInstanceWithNoPortals as findHostInstanceWithNoPortals_old,
shouldError as shouldError_old,
shouldSuspend as shouldSuspend_old,
injectIntoDevTools as injectIntoDevTools_old,
act as act_old,
@@ -75,6 +76,7 @@ import {
findHostInstance as findHostInstance_new,
findHostInstanceWithWarning as findHostInstanceWithWarning_new,
findHostInstanceWithNoPortals as findHostInstanceWithNoPortals_new,
shouldError as shouldError_new,
shouldSuspend as shouldSuspend_new,
injectIntoDevTools as injectIntoDevTools_new,
act as act_new,
@@ -155,6 +157,9 @@ export const findHostInstanceWithWarning = enableNewReconciler
export const findHostInstanceWithNoPortals = enableNewReconciler
? findHostInstanceWithNoPortals_new
: findHostInstanceWithNoPortals_old;
export const shouldError = enableNewReconciler
? shouldError_new
: shouldError_old;
export const shouldSuspend = enableNewReconciler
? shouldSuspend_new
: shouldSuspend_old;
12 changes: 12 additions & 0 deletions packages/react-reconciler/src/ReactFiberReconciler.new.js
Original file line number Diff line number Diff line change
@@ -463,6 +463,12 @@ export function findHostInstanceWithNoPortals(
return hostFiber.stateNode;
}

let shouldErrorImpl = fiber => null;

export function shouldError(fiber: Fiber): ?boolean {
return shouldErrorImpl(fiber);
}

let shouldSuspendImpl = fiber => false;

export function shouldSuspend(fiber: Fiber): boolean {
@@ -476,6 +482,7 @@ let overrideProps = null;
let overridePropsDeletePath = null;
let overridePropsRenamePath = null;
let scheduleUpdate = null;
let setErrorHandler = null;
let setSuspenseHandler = null;

if (__DEV__) {
@@ -690,6 +697,10 @@ if (__DEV__) {
scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
};

setErrorHandler = (newShouldErrorImpl: Fiber => ?boolean) => {
shouldErrorImpl = newShouldErrorImpl;
};

setSuspenseHandler = (newShouldSuspendImpl: Fiber => boolean) => {
shouldSuspendImpl = newShouldSuspendImpl;
};
@@ -728,6 +739,7 @@ export function injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean {
overrideProps,
overridePropsDeletePath,
overridePropsRenamePath,
setErrorHandler,
setSuspenseHandler,
scheduleUpdate,
currentDispatcherRef: ReactCurrentDispatcher,
12 changes: 12 additions & 0 deletions packages/react-reconciler/src/ReactFiberReconciler.old.js
Original file line number Diff line number Diff line change
@@ -463,6 +463,12 @@ export function findHostInstanceWithNoPortals(
return hostFiber.stateNode;
}

let shouldErrorImpl = fiber => null;

export function shouldError(fiber: Fiber): ?boolean {
return shouldErrorImpl(fiber);
}

let shouldSuspendImpl = fiber => false;

export function shouldSuspend(fiber: Fiber): boolean {
@@ -476,6 +482,7 @@ let overrideProps = null;
let overridePropsDeletePath = null;
let overridePropsRenamePath = null;
let scheduleUpdate = null;
let setErrorHandler = null;
let setSuspenseHandler = null;

if (__DEV__) {
@@ -690,6 +697,10 @@ if (__DEV__) {
scheduleUpdateOnFiber(fiber, SyncLane, NoTimestamp);
};

setErrorHandler = (newShouldErrorImpl: Fiber => ?boolean) => {
shouldErrorImpl = newShouldErrorImpl;
};

setSuspenseHandler = (newShouldSuspendImpl: Fiber => boolean) => {
shouldSuspendImpl = newShouldSuspendImpl;
};
@@ -728,6 +739,7 @@ export function injectIntoDevTools(devToolsConfig: DevToolsConfig): boolean {
overrideProps,
overridePropsDeletePath,
overridePropsRenamePath,
setErrorHandler,
setSuspenseHandler,
scheduleUpdate,
currentDispatcherRef: ReactCurrentDispatcher,