diff --git a/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js index 0c4fc869764e7..61ea4414c68b4 100644 --- a/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js +++ b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js @@ -2380,4 +2380,95 @@ describe('InspectedElement', () => { `); }); }); + + describe('error boundary', () => { + it('can toggle error', async () => { + class ErrorBoundary extends React.Component { + 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( + + + , + 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 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 + inspectedElement = await inspect(1); + expect(inspectedElement.canToggleError).toBe(true); + expect(inspectedElement.isErrored).toBe(false); + expect(inspectedElement.targetErrorBoundaryID).toBe( + targetErrorBoundaryID, + ); + + // now force error state on + await toggleError(true); + + // we are in error state now, won't show up + expect(store.getElementIDAtIndex(1)).toBe(null); + + // Inpsect 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 with ability to toggle again + inspectedElement = await inspect(1); + expect(inspectedElement.canToggleError).toBe(true); + expect(inspectedElement.isErrored).toBe(false); + expect(inspectedElement.targetErrorBoundaryID).toBe( + targetErrorBoundaryID, + ); + }); + }); }); diff --git a/packages/react-devtools-shared/src/__tests__/inspectedElementSerializer.js b/packages/react-devtools-shared/src/__tests__/inspectedElementSerializer.js index db75d9e150880..d3e87c7110a0c 100644 --- a/packages/react-devtools-shared/src/__tests__/inspectedElementSerializer.js +++ b/packages/react-devtools-shared/src/__tests__/inspectedElementSerializer.js @@ -14,6 +14,7 @@ export function test(maybeInspectedElement) { hasOwnProperty('canEditFunctionProps') && hasOwnProperty('canEditHooks') && hasOwnProperty('canToggleSuspense') && + hasOwnProperty('canToggleError') && hasOwnProperty('canViewSource') ); } diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 7470070455c04..4dc727a59e2f8 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -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, diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 091d755009b42..7b612ff346171 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -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, diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index b7b8eb605615a..2911855f57bd9 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -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, ): 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, diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index 342931c250eab..d8e0939f1150d 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -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, diff --git a/packages/react-devtools-shared/src/backendAPI.js b/packages/react-devtools-shared/src/backendAPI.js index a4b870ea8f9b1..4a79147ec23ef 100644 --- a/packages/react-devtools-shared/src/backendAPI.js +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -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, diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index 5b34a2321253c..5bf8a5f127c9c 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -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], diff --git a/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js b/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js index 0c7fe00b42aac..fffd0aec4cbc6 100644 --- a/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js +++ b/packages/react-devtools-shared/src/devtools/views/ButtonIcon.js @@ -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 diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js index e7cda475052ec..ea976767c02f1 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js @@ -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) { + {canToggleError && ( + + + + )} {canToggleSuspense && ( + An error was thrown. + + ); + } + + const {children} = this.props; + return ( +
+ {children} +
+ ); + } +} + +function Component({label}) { + return
{label}
; +} + +export default function ErrorBoundaries() { + return ( + +

Nested error boundaries demo

+ + + + + + + + + +
+ ); +} diff --git a/packages/react-devtools-shell/src/app/index.js b/packages/react-devtools-shell/src/app/index.js index 5764130075aec..d00ffde4a5ded 100644 --- a/packages/react-devtools-shell/src/app/index.js +++ b/packages/react-devtools-shell/src/app/index.js @@ -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); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 6f1f7953b257a..30fdc51656363 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -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. diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 8bac08c0ff6f1..090fa0b195715 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -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. diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index e789b58b61c11..ff68770f09b06 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -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; diff --git a/packages/react-reconciler/src/ReactFiberReconciler.new.js b/packages/react-reconciler/src/ReactFiberReconciler.new.js index c1e9778160a7c..c53fabbb8e4ae 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.new.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.new.js @@ -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, diff --git a/packages/react-reconciler/src/ReactFiberReconciler.old.js b/packages/react-reconciler/src/ReactFiberReconciler.old.js index 47ced00130310..73d412bfcd1ae 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.old.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.old.js @@ -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,