From a0537160771bafae90c6fd3154eeead2f2c903e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Tue, 26 Mar 2024 21:51:37 -0700 Subject: [PATCH] Make onUncaughtError and onCaughtError Configurable (#28641) Stacked on #28627. This makes error logging configurable using these `createRoot`/`hydrateRoot` options: ``` onUncaughtError(error: mixed, errorInfo: {componentStack?: ?string}) => void onCaughtError(error: mixed, errorInfo: {componentStack?: ?string, errorBoundary?: ?React.Component}) => void onRecoverableError(error: mixed, errorInfo: {digest?: ?string, componentStack?: ?string}) => void ``` We already have the `onRecoverableError` option since before. Overriding these can be used to implement custom error dialogs (with access to the `componentStack`). It can also be used to silence caught errors when testing an error boundary or if you prefer not getting logs for caught errors that you've already handled in an error boundary. I currently expose the error boundary instance but I think we should probably remove that since it doesn't make sense for non-class error boundaries and isn't very useful anyway. It's also unclear what it should do when an error is rethrown from one boundary to another. Since these are public APIs now we can implement the ReactFiberErrorDialog forks using these options at the roots of the builds. So I unforked those files and instead passed a custom option for the native and www builds. To do this I had to fork the ReactDOMLegacy file into ReactDOMRootFB which is a duplication but that will go away as soon as the FB fork is the only legacy root. --- packages/react-dom/index.classic.fb.js | 5 +- packages/react-dom/index.modern.fb.js | 4 +- .../src/__tests__/ReactDOMRoot-test.js | 2 + .../react-dom/src/client/ReactDOMLegacy.js | 8 +- packages/react-dom/src/client/ReactDOMRoot.js | 61 ++- .../react-dom/src/client/ReactDOMRootFB.js | 418 ++++++++++++++++++ .../client/__mocks__/ReactFiberErrorDialog.js | 12 + .../react-native-renderer/src/ReactFabric.js | 64 ++- .../src/ReactNativeRenderer.js | 64 ++- .../src/createReactNoop.js | 6 + .../src/ReactFiberBeginWork.js | 17 +- .../src/ReactFiberErrorDialog.js | 22 - .../src/ReactFiberErrorLogger.js | 207 +++++---- .../src/ReactFiberReconciler.js | 41 +- .../react-reconciler/src/ReactFiberRoot.js | 22 +- .../react-reconciler/src/ReactFiberThrow.js | 46 +- .../src/ReactFiberWorkLoop.js | 25 +- .../src/ReactInternalTypes.js | 13 +- .../ReactConfigurableErrorLogging-test.js | 229 ++++++++++ .../ReactFiberHostContext-test.internal.js | 4 + .../src/forks/ReactFiberErrorDialog.native.js | 37 -- .../src/forks/ReactFiberErrorDialog.www.js | 37 -- .../src/ReactTestRenderer.js | 14 +- .../ReactTestRenderer-test.internal.js | 2 + scripts/rollup/forks.js | 30 -- 25 files changed, 1115 insertions(+), 275 deletions(-) create mode 100644 packages/react-dom/src/client/ReactDOMRootFB.js create mode 100644 packages/react-dom/src/client/__mocks__/ReactFiberErrorDialog.js delete mode 100644 packages/react-reconciler/src/ReactFiberErrorDialog.js create mode 100644 packages/react-reconciler/src/__tests__/ReactConfigurableErrorLogging-test.js delete mode 100644 packages/react-reconciler/src/forks/ReactFiberErrorDialog.native.js delete mode 100644 packages/react-reconciler/src/forks/ReactFiberErrorDialog.www.js diff --git a/packages/react-dom/index.classic.fb.js b/packages/react-dom/index.classic.fb.js index b5e974f2b5..33133eafbf 100644 --- a/packages/react-dom/index.classic.fb.js +++ b/packages/react-dom/index.classic.fb.js @@ -20,11 +20,8 @@ Object.assign((Internals: any), { export { createPortal, - createRoot, - hydrateRoot, findDOMNode, flushSync, - render, unmountComponentAtNode, unstable_batchedUpdates, unstable_createEventHandle, @@ -41,4 +38,6 @@ export { version, } from './src/client/ReactDOM'; +export {createRoot, hydrateRoot, render} from './src/client/ReactDOMRootFB'; + export {Internals as __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED}; diff --git a/packages/react-dom/index.modern.fb.js b/packages/react-dom/index.modern.fb.js index 531f4d429b..0bff591fac 100644 --- a/packages/react-dom/index.modern.fb.js +++ b/packages/react-dom/index.modern.fb.js @@ -10,8 +10,6 @@ export {default as __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED} from './src/ReactDOMSharedInternals'; export { createPortal, - createRoot, - hydrateRoot, flushSync, unstable_batchedUpdates, unstable_createEventHandle, @@ -26,3 +24,5 @@ export { preinitModule, version, } from './src/client/ReactDOM'; + +export {createRoot, hydrateRoot} from './src/client/ReactDOMRootFB'; diff --git a/packages/react-dom/src/__tests__/ReactDOMRoot-test.js b/packages/react-dom/src/__tests__/ReactDOMRoot-test.js index c1f1f3d5bc..cdfb267006 100644 --- a/packages/react-dom/src/__tests__/ReactDOMRoot-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMRoot-test.js @@ -47,6 +47,7 @@ describe('ReactDOMRoot', () => { expect(container.textContent).toEqual('Hi'); }); + // @gate !classic || !__DEV__ it('warns if you import createRoot from react-dom', async () => { expect(() => ReactDOM.createRoot(container)).toErrorDev( 'You are importing createRoot from "react-dom" which is not supported. ' + @@ -57,6 +58,7 @@ describe('ReactDOMRoot', () => { ); }); + // @gate !classic || !__DEV__ it('warns if you import hydrateRoot from react-dom', async () => { expect(() => ReactDOM.hydrateRoot(container, null)).toErrorDev( 'You are importing hydrateRoot from "react-dom" which is not supported. ' + diff --git a/packages/react-dom/src/client/ReactDOMLegacy.js b/packages/react-dom/src/client/ReactDOMLegacy.js index 50cb6fbf94..cd651488eb 100644 --- a/packages/react-dom/src/client/ReactDOMLegacy.js +++ b/packages/react-dom/src/client/ReactDOMLegacy.js @@ -39,6 +39,8 @@ import { getPublicRootInstance, findHostInstance, findHostInstanceWithWarning, + defaultOnUncaughtError, + defaultOnCaughtError, } from 'react-reconciler/src/ReactFiberReconciler'; import {LegacyRoot} from 'react-reconciler/src/ReactRootTags'; import getComponentNameFromType from 'shared/getComponentNameFromType'; @@ -124,6 +126,8 @@ function legacyCreateRootFromDOMContainer( false, // isStrictMode false, // concurrentUpdatesByDefaultOverride, '', // identifierPrefix + defaultOnUncaughtError, + defaultOnCaughtError, noopOnRecoverableError, // TODO(luna) Support hydration later null, @@ -158,7 +162,9 @@ function legacyCreateRootFromDOMContainer( false, // isStrictMode false, // concurrentUpdatesByDefaultOverride, '', // identifierPrefix - noopOnRecoverableError, // onRecoverableError + defaultOnUncaughtError, + defaultOnCaughtError, + noopOnRecoverableError, null, // transitionCallbacks ); container._reactRootContainer = root; diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index 119152e875..21b08b784e 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -32,7 +32,21 @@ export type CreateRootOptions = { unstable_concurrentUpdatesByDefault?: boolean, unstable_transitionCallbacks?: TransitionTracingCallbacks, identifierPrefix?: string, - onRecoverableError?: (error: mixed) => void, + onUncaughtError?: ( + error: mixed, + errorInfo: {+componentStack?: ?string}, + ) => void, + onCaughtError?: ( + error: mixed, + errorInfo: { + +componentStack?: ?string, + +errorBoundary?: ?React$Component, + }, + ) => void, + onRecoverableError?: ( + error: mixed, + errorInfo: {+digest?: ?string, +componentStack?: ?string}, + ) => void, }; export type HydrateRootOptions = { @@ -44,7 +58,21 @@ export type HydrateRootOptions = { unstable_concurrentUpdatesByDefault?: boolean, unstable_transitionCallbacks?: TransitionTracingCallbacks, identifierPrefix?: string, - onRecoverableError?: (error: mixed) => void, + onUncaughtError?: ( + error: mixed, + errorInfo: {+componentStack?: ?string}, + ) => void, + onCaughtError?: ( + error: mixed, + errorInfo: { + +componentStack?: ?string, + +errorBoundary?: ?React$Component, + }, + ) => void, + onRecoverableError?: ( + error: mixed, + errorInfo: {+digest?: ?string, +componentStack?: ?string}, + ) => void, formState?: ReactFormState | null, }; @@ -67,15 +95,12 @@ import { updateContainer, flushSync, isAlreadyRendering, + defaultOnUncaughtError, + defaultOnCaughtError, + defaultOnRecoverableError, } from 'react-reconciler/src/ReactFiberReconciler'; import {ConcurrentRoot} from 'react-reconciler/src/ReactRootTags'; -import reportGlobalError from 'shared/reportGlobalError'; - -function defaultOnRecoverableError(error: mixed, errorInfo: any) { - reportGlobalError(error); -} - // $FlowFixMe[missing-this-annot] function ReactDOMRoot(internalRoot: FiberRoot) { this._internalRoot = internalRoot; @@ -156,6 +181,8 @@ export function createRoot( let isStrictMode = false; let concurrentUpdatesByDefaultOverride = false; let identifierPrefix = ''; + let onUncaughtError = defaultOnUncaughtError; + let onCaughtError = defaultOnCaughtError; let onRecoverableError = defaultOnRecoverableError; let transitionCallbacks = null; @@ -193,6 +220,12 @@ export function createRoot( if (options.identifierPrefix !== undefined) { identifierPrefix = options.identifierPrefix; } + if (options.onUncaughtError !== undefined) { + onUncaughtError = options.onUncaughtError; + } + if (options.onCaughtError !== undefined) { + onCaughtError = options.onCaughtError; + } if (options.onRecoverableError !== undefined) { onRecoverableError = options.onRecoverableError; } @@ -208,6 +241,8 @@ export function createRoot( isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, + onUncaughtError, + onCaughtError, onRecoverableError, transitionCallbacks, ); @@ -262,6 +297,8 @@ export function hydrateRoot( let isStrictMode = false; let concurrentUpdatesByDefaultOverride = false; let identifierPrefix = ''; + let onUncaughtError = defaultOnUncaughtError; + let onCaughtError = defaultOnCaughtError; let onRecoverableError = defaultOnRecoverableError; let transitionCallbacks = null; let formState = null; @@ -278,6 +315,12 @@ export function hydrateRoot( if (options.identifierPrefix !== undefined) { identifierPrefix = options.identifierPrefix; } + if (options.onUncaughtError !== undefined) { + onUncaughtError = options.onUncaughtError; + } + if (options.onCaughtError !== undefined) { + onCaughtError = options.onCaughtError; + } if (options.onRecoverableError !== undefined) { onRecoverableError = options.onRecoverableError; } @@ -300,6 +343,8 @@ export function hydrateRoot( isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, + onUncaughtError, + onCaughtError, onRecoverableError, transitionCallbacks, formState, diff --git a/packages/react-dom/src/client/ReactDOMRootFB.js b/packages/react-dom/src/client/ReactDOMRootFB.js new file mode 100644 index 0000000000..4f2f0094de --- /dev/null +++ b/packages/react-dom/src/client/ReactDOMRootFB.js @@ -0,0 +1,418 @@ +/** + * Copyright (c) Meta Platforms, Inc. and 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 type {ReactNodeList} from 'shared/ReactTypes'; + +import type { + RootType, + CreateRootOptions, + HydrateRootOptions, +} from './ReactDOMRoot'; + +import type {FiberRoot} from 'react-reconciler/src/ReactInternalTypes'; + +import type { + Container, + PublicInstance, +} from 'react-dom-bindings/src/client/ReactFiberConfigDOM'; + +import { + createRoot as createRootImpl, + hydrateRoot as hydrateRootImpl, +} from './ReactDOMRoot'; + +import {disableLegacyMode} from 'shared/ReactFeatureFlags'; +import {clearContainer} from 'react-dom-bindings/src/client/ReactFiberConfigDOM'; +import { + getInstanceFromNode, + isContainerMarkedAsRoot, + markContainerAsRoot, +} from 'react-dom-bindings/src/client/ReactDOMComponentTree'; +import {listenToAllSupportedEvents} from 'react-dom-bindings/src/events/DOMPluginEventSystem'; +import {isValidContainerLegacy} from './ReactDOMRoot'; +import { + DOCUMENT_NODE, + COMMENT_NODE, +} from 'react-dom-bindings/src/client/HTMLNodeType'; + +import { + createContainer, + createHydrationContainer, + findHostInstanceWithNoPortals, + updateContainer, + flushSync, + getPublicRootInstance, + defaultOnUncaughtError, + defaultOnCaughtError, +} from 'react-reconciler/src/ReactFiberReconciler'; +import {LegacyRoot} from 'react-reconciler/src/ReactRootTags'; +import {has as hasInstance} from 'shared/ReactInstanceMap'; + +import assign from 'shared/assign'; + +// Provided by www +const ReactFiberErrorDialogWWW = require('ReactFiberErrorDialog'); + +if (typeof ReactFiberErrorDialogWWW.showErrorDialog !== 'function') { + throw new Error( + 'Expected ReactFiberErrorDialog.showErrorDialog to be a function.', + ); +} + +function wwwOnUncaughtError( + error: mixed, + errorInfo: {+componentStack?: ?string}, +): void { + const componentStack = + errorInfo.componentStack != null ? errorInfo.componentStack : ''; + const logError = ReactFiberErrorDialogWWW.showErrorDialog({ + errorBoundary: null, + error, + componentStack, + }); + + // Allow injected showErrorDialog() to prevent default console.error logging. + // This enables renderers like ReactNative to better manage redbox behavior. + if (logError === false) { + return; + } + + defaultOnUncaughtError(error, errorInfo); +} + +function wwwOnCaughtError( + error: mixed, + errorInfo: { + +componentStack?: ?string, + +errorBoundary?: ?React$Component, + }, +): void { + const errorBoundary = errorInfo.errorBoundary; + const componentStack = + errorInfo.componentStack != null ? errorInfo.componentStack : ''; + const logError = ReactFiberErrorDialogWWW.showErrorDialog({ + errorBoundary, + error, + componentStack, + }); + + // Allow injected showErrorDialog() to prevent default console.error logging. + // This enables renderers like ReactNative to better manage redbox behavior. + if (logError === false) { + return; + } + + defaultOnCaughtError(error, errorInfo); +} + +export function createRoot( + container: Element | Document | DocumentFragment, + options?: CreateRootOptions, +): RootType { + return createRootImpl( + container, + assign( + ({ + onUncaughtError: wwwOnUncaughtError, + onCaughtError: wwwOnCaughtError, + }: any), + options, + ), + ); +} + +export function hydrateRoot( + container: Document | Element, + initialChildren: ReactNodeList, + options?: HydrateRootOptions, +): RootType { + return hydrateRootImpl( + container, + initialChildren, + assign( + ({ + onUncaughtError: wwwOnUncaughtError, + onCaughtError: wwwOnCaughtError, + }: any), + options, + ), + ); +} + +let topLevelUpdateWarnings; + +if (__DEV__) { + topLevelUpdateWarnings = (container: Container) => { + if (container._reactRootContainer && container.nodeType !== COMMENT_NODE) { + const hostInstance = findHostInstanceWithNoPortals( + container._reactRootContainer.current, + ); + if (hostInstance) { + if (hostInstance.parentNode !== container) { + console.error( + 'It looks like the React-rendered content of this ' + + 'container was removed without using React. This is not ' + + 'supported and will cause errors. Instead, call ' + + 'ReactDOM.unmountComponentAtNode to empty a container.', + ); + } + } + } + + const isRootRenderedBySomeReact = !!container._reactRootContainer; + const rootEl = getReactRootElementInContainer(container); + const hasNonRootReactChild = !!(rootEl && getInstanceFromNode(rootEl)); + + if (hasNonRootReactChild && !isRootRenderedBySomeReact) { + console.error( + 'Replacing React-rendered children with a new root ' + + 'component. If you intended to update the children of this node, ' + + 'you should instead have the existing children update their state ' + + 'and render the new components instead of calling ReactDOM.render.', + ); + } + }; +} + +function getReactRootElementInContainer(container: any) { + if (!container) { + return null; + } + + if (container.nodeType === DOCUMENT_NODE) { + return container.documentElement; + } else { + return container.firstChild; + } +} + +function noopOnRecoverableError() { + // This isn't reachable because onRecoverableError isn't called in the + // legacy API. +} + +function legacyCreateRootFromDOMContainer( + container: Container, + initialChildren: ReactNodeList, + parentComponent: ?React$Component, + callback: ?Function, + isHydrationContainer: boolean, +): FiberRoot { + if (isHydrationContainer) { + if (typeof callback === 'function') { + const originalCallback = callback; + callback = function () { + const instance = getPublicRootInstance(root); + originalCallback.call(instance); + }; + } + + const root: FiberRoot = createHydrationContainer( + initialChildren, + callback, + container, + LegacyRoot, + null, // hydrationCallbacks + false, // isStrictMode + false, // concurrentUpdatesByDefaultOverride, + '', // identifierPrefix + wwwOnUncaughtError, + wwwOnCaughtError, + noopOnRecoverableError, + // TODO(luna) Support hydration later + null, + null, + ); + container._reactRootContainer = root; + markContainerAsRoot(root.current, container); + + const rootContainerElement = + container.nodeType === COMMENT_NODE ? container.parentNode : container; + // $FlowFixMe[incompatible-call] + listenToAllSupportedEvents(rootContainerElement); + + flushSync(); + return root; + } else { + // First clear any existing content. + clearContainer(container); + + if (typeof callback === 'function') { + const originalCallback = callback; + callback = function () { + const instance = getPublicRootInstance(root); + originalCallback.call(instance); + }; + } + + const root = createContainer( + container, + LegacyRoot, + null, // hydrationCallbacks + false, // isStrictMode + false, // concurrentUpdatesByDefaultOverride, + '', // identifierPrefix + wwwOnUncaughtError, + wwwOnCaughtError, + noopOnRecoverableError, + null, // transitionCallbacks + ); + container._reactRootContainer = root; + markContainerAsRoot(root.current, container); + + const rootContainerElement = + container.nodeType === COMMENT_NODE ? container.parentNode : container; + // $FlowFixMe[incompatible-call] + listenToAllSupportedEvents(rootContainerElement); + + // Initial mount should not be batched. + flushSync(() => { + updateContainer(initialChildren, root, parentComponent, callback); + }); + + return root; + } +} + +function warnOnInvalidCallback(callback: mixed): void { + if (__DEV__) { + if (callback !== null && typeof callback !== 'function') { + console.error( + 'Expected the last optional `callback` argument to be a ' + + 'function. Instead received: %s.', + callback, + ); + } + } +} + +function legacyRenderSubtreeIntoContainer( + parentComponent: ?React$Component, + children: ReactNodeList, + container: Container, + forceHydrate: boolean, + callback: ?Function, +): React$Component | PublicInstance | null { + if (__DEV__) { + topLevelUpdateWarnings(container); + warnOnInvalidCallback(callback === undefined ? null : callback); + } + + const maybeRoot = container._reactRootContainer; + let root: FiberRoot; + if (!maybeRoot) { + // Initial mount + root = legacyCreateRootFromDOMContainer( + container, + children, + parentComponent, + callback, + forceHydrate, + ); + } else { + root = maybeRoot; + if (typeof callback === 'function') { + const originalCallback = callback; + callback = function () { + const instance = getPublicRootInstance(root); + originalCallback.call(instance); + }; + } + // Update + updateContainer(children, root, parentComponent, callback); + } + return getPublicRootInstance(root); +} + +export function render( + element: React$Element, + container: Container, + callback: ?Function, +): React$Component | PublicInstance | null { + if (disableLegacyMode) { + if (__DEV__) { + console.error( + 'ReactDOM.render was removed in React 19. Use createRoot instead.', + ); + } + throw new Error('ReactDOM: Unsupported Legacy Mode API.'); + } + if (__DEV__) { + console.error( + 'ReactDOM.render has not been supported since React 18. Use createRoot ' + + 'instead. Until you switch to the new API, your app will behave as ' + + "if it's running React 17. Learn " + + 'more: https://react.dev/link/switch-to-createroot', + ); + } + + if (!isValidContainerLegacy(container)) { + throw new Error('Target container is not a DOM element.'); + } + + if (__DEV__) { + const isModernRoot = + isContainerMarkedAsRoot(container) && + container._reactRootContainer === undefined; + if (isModernRoot) { + console.error( + 'You are calling ReactDOM.render() on a container that was previously ' + + 'passed to ReactDOMClient.createRoot(). This is not supported. ' + + 'Did you mean to call root.render(element)?', + ); + } + } + return legacyRenderSubtreeIntoContainer( + null, + element, + container, + false, + callback, + ); +} + +export function unstable_renderSubtreeIntoContainer( + parentComponent: React$Component, + element: React$Element, + containerNode: Container, + callback: ?Function, +): React$Component | PublicInstance | null { + if (disableLegacyMode) { + if (__DEV__) { + console.error( + 'ReactDOM.unstable_renderSubtreeIntoContainer() was removed in React 19. Consider using a portal instead.', + ); + } + throw new Error('ReactDOM: Unsupported Legacy Mode API.'); + } + if (__DEV__) { + console.error( + 'ReactDOM.unstable_renderSubtreeIntoContainer() has not been supported ' + + 'since React 18. Consider using a portal instead. Until you switch to ' + + "the createRoot API, your app will behave as if it's running React " + + '17. Learn more: https://react.dev/link/switch-to-createroot', + ); + } + + if (!isValidContainerLegacy(containerNode)) { + throw new Error('Target container is not a DOM element.'); + } + + if (parentComponent == null || !hasInstance(parentComponent)) { + throw new Error('parentComponent must be a valid React Component'); + } + + return legacyRenderSubtreeIntoContainer( + parentComponent, + element, + containerNode, + false, + callback, + ); +} diff --git a/packages/react-dom/src/client/__mocks__/ReactFiberErrorDialog.js b/packages/react-dom/src/client/__mocks__/ReactFiberErrorDialog.js new file mode 100644 index 0000000000..94b0e9ab67 --- /dev/null +++ b/packages/react-dom/src/client/__mocks__/ReactFiberErrorDialog.js @@ -0,0 +1,12 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export function showErrorDialog(): boolean { + return true; +} diff --git a/packages/react-native-renderer/src/ReactFabric.js b/packages/react-native-renderer/src/ReactFabric.js index 3c2eff2dbc..6cdb179093 100644 --- a/packages/react-native-renderer/src/ReactFabric.js +++ b/packages/react-native-renderer/src/ReactFabric.js @@ -20,6 +20,9 @@ import { updateContainer, injectIntoDevTools, getPublicRootInstance, + defaultOnUncaughtError, + defaultOnCaughtError, + defaultOnRecoverableError, } from 'react-reconciler/src/ReactFiberReconciler'; import {createPortal as createPortalImpl} from 'react-reconciler/src/ReactPortal'; @@ -43,11 +46,58 @@ import { } from './ReactNativePublicCompat'; import {getPublicInstanceFromInternalInstanceHandle} from './ReactFiberConfigFabric'; -// $FlowFixMe[missing-local-annot] -function onRecoverableError(error) { - // TODO: Expose onRecoverableError option to userspace - // eslint-disable-next-line react-internal/no-production-logging, react-internal/warning-args - console.error(error); +// Module provided by RN: +import {ReactFiberErrorDialog} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'; + +if (typeof ReactFiberErrorDialog.showErrorDialog !== 'function') { + throw new Error( + 'Expected ReactFiberErrorDialog.showErrorDialog to be a function.', + ); +} + +function nativeOnUncaughtError( + error: mixed, + errorInfo: {+componentStack?: ?string}, +): void { + const componentStack = + errorInfo.componentStack != null ? errorInfo.componentStack : ''; + const logError = ReactFiberErrorDialog.showErrorDialog({ + errorBoundary: null, + error, + componentStack, + }); + + // Allow injected showErrorDialog() to prevent default console.error logging. + // This enables renderers like ReactNative to better manage redbox behavior. + if (logError === false) { + return; + } + + defaultOnUncaughtError(error, errorInfo); +} +function nativeOnCaughtError( + error: mixed, + errorInfo: { + +componentStack?: ?string, + +errorBoundary?: ?React$Component, + }, +): void { + const errorBoundary = errorInfo.errorBoundary; + const componentStack = + errorInfo.componentStack != null ? errorInfo.componentStack : ''; + const logError = ReactFiberErrorDialog.showErrorDialog({ + errorBoundary, + error, + componentStack, + }); + + // Allow injected showErrorDialog() to prevent default console.error logging. + // This enables renderers like ReactNative to better manage redbox behavior. + if (logError === false) { + return; + } + + defaultOnCaughtError(error, errorInfo); } function render( @@ -68,7 +118,9 @@ function render( false, null, '', - onRecoverableError, + nativeOnUncaughtError, + nativeOnCaughtError, + defaultOnRecoverableError, null, ); roots.set(containerTag, root); diff --git a/packages/react-native-renderer/src/ReactNativeRenderer.js b/packages/react-native-renderer/src/ReactNativeRenderer.js index d8eb76bc23..5409eb3df4 100644 --- a/packages/react-native-renderer/src/ReactNativeRenderer.js +++ b/packages/react-native-renderer/src/ReactNativeRenderer.js @@ -20,6 +20,9 @@ import { updateContainer, injectIntoDevTools, getPublicRootInstance, + defaultOnUncaughtError, + defaultOnCaughtError, + defaultOnRecoverableError, } from 'react-reconciler/src/ReactFiberReconciler'; // TODO: direct imports like some-package/src/* are bad. Fix me. import {getStackByFiberInDevAndProd} from 'react-reconciler/src/ReactFiberComponentStack'; @@ -47,11 +50,58 @@ import { isChildPublicInstance, } from './ReactNativePublicCompat'; -// $FlowFixMe[missing-local-annot] -function onRecoverableError(error) { - // TODO: Expose onRecoverableError option to userspace - // eslint-disable-next-line react-internal/no-production-logging, react-internal/warning-args - console.error(error); +// Module provided by RN: +import {ReactFiberErrorDialog} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'; + +if (typeof ReactFiberErrorDialog.showErrorDialog !== 'function') { + throw new Error( + 'Expected ReactFiberErrorDialog.showErrorDialog to be a function.', + ); +} + +function nativeOnUncaughtError( + error: mixed, + errorInfo: {+componentStack?: ?string}, +): void { + const componentStack = + errorInfo.componentStack != null ? errorInfo.componentStack : ''; + const logError = ReactFiberErrorDialog.showErrorDialog({ + errorBoundary: null, + error, + componentStack, + }); + + // Allow injected showErrorDialog() to prevent default console.error logging. + // This enables renderers like ReactNative to better manage redbox behavior. + if (logError === false) { + return; + } + + defaultOnUncaughtError(error, errorInfo); +} +function nativeOnCaughtError( + error: mixed, + errorInfo: { + +componentStack?: ?string, + +errorBoundary?: ?React$Component, + }, +): void { + const errorBoundary = errorInfo.errorBoundary; + const componentStack = + errorInfo.componentStack != null ? errorInfo.componentStack : ''; + const logError = ReactFiberErrorDialog.showErrorDialog({ + errorBoundary, + error, + componentStack, + }); + + // Allow injected showErrorDialog() to prevent default console.error logging. + // This enables renderers like ReactNative to better manage redbox behavior. + if (logError === false) { + return; + } + + defaultOnCaughtError(error, errorInfo); } function render( @@ -71,7 +121,9 @@ function render( false, null, '', - onRecoverableError, + nativeOnUncaughtError, + nativeOnCaughtError, + defaultOnRecoverableError, null, ); roots.set(containerTag, root); diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 0470672f4b..af4612f00d 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -974,6 +974,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { null, false, '', + NoopRenderer.defaultOnUncaughtError, + NoopRenderer.defaultOnCaughtError, onRecoverableError, null, ); @@ -996,6 +998,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { null, false, '', + NoopRenderer.defaultOnUncaughtError, + NoopRenderer.defaultOnCaughtError, onRecoverableError, options && options.unstable_transitionCallbacks ? options.unstable_transitionCallbacks @@ -1028,6 +1032,8 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { null, false, '', + NoopRenderer.defaultOnUncaughtError, + NoopRenderer.defaultOnCaughtError, onRecoverableError, null, ); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 28e04f7c4b..c53e01d4ec 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -266,7 +266,10 @@ import { createCapturedValueAtFiber, type CapturedValue, } from './ReactCapturedValue'; -import {createClassErrorUpdate} from './ReactFiberThrow'; +import { + createClassErrorUpdate, + initializeClassErrorUpdate, +} from './ReactFiberThrow'; import is from 'shared/objectIs'; import { getForksAtLevel, @@ -1179,10 +1182,18 @@ function updateClassComponent( const lane = pickArbitraryLane(renderLanes); workInProgress.lanes = mergeLanes(workInProgress.lanes, lane); // Schedule the error boundary to re-render using updated state - const update = createClassErrorUpdate( + const root: FiberRoot | null = getWorkInProgressRoot(); + if (root === null) { + throw new Error( + 'Expected a work-in-progress root. This is a bug in React. Please file an issue.', + ); + } + const update = createClassErrorUpdate(lane); + initializeClassErrorUpdate( + update, + root, workInProgress, createCapturedValueAtFiber(error, workInProgress), - lane, ); enqueueCapturedUpdate(workInProgress, update); break; diff --git a/packages/react-reconciler/src/ReactFiberErrorDialog.js b/packages/react-reconciler/src/ReactFiberErrorDialog.js deleted file mode 100644 index 7baf2f9651..0000000000 --- a/packages/react-reconciler/src/ReactFiberErrorDialog.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and 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 type {Fiber} from './ReactInternalTypes'; -import type {CapturedValue} from './ReactCapturedValue'; - -// This module is forked in different environments. -// By default, return `true` to log errors to the console. -// Forks can return `false` if this isn't desirable. - -export function showErrorDialog( - boundary: Fiber, - errorInfo: CapturedValue, -): boolean { - return true; -} diff --git a/packages/react-reconciler/src/ReactFiberErrorLogger.js b/packages/react-reconciler/src/ReactFiberErrorLogger.js index d2eae0479d..775ca9b20a 100644 --- a/packages/react-reconciler/src/ReactFiberErrorLogger.js +++ b/packages/react-reconciler/src/ReactFiberErrorLogger.js @@ -7,99 +7,152 @@ * @flow */ -import type {Fiber} from './ReactInternalTypes'; +import type {Fiber, FiberRoot} from './ReactInternalTypes'; import type {CapturedValue} from './ReactCapturedValue'; -import {showErrorDialog} from './ReactFiberErrorDialog'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; -import {HostRoot} from 'react-reconciler/src/ReactWorkTags'; + +import {ClassComponent} from './ReactWorkTags'; import reportGlobalError from 'shared/reportGlobalError'; import ReactSharedInternals from 'shared/ReactSharedInternals'; const {ReactCurrentActQueue} = ReactSharedInternals; -export function logCapturedError( - boundary: Fiber, +// Side-channel since I'm not sure we want to make this part of the public API +let componentName: null | string = null; +let errorBoundaryName: null | string = null; + +export function defaultOnUncaughtError( + error: mixed, + errorInfo: {+componentStack?: ?string}, +): void { + // Overriding this can silence these warnings e.g. for tests. + // See https://github.com/facebook/react/pull/13384 + + // For uncaught root errors we report them as uncaught to the browser's + // onerror callback. This won't have component stacks and the error addendum. + // So we add those into a separate console.warn. + reportGlobalError(error); + if (__DEV__) { + const componentStack = + errorInfo.componentStack != null ? errorInfo.componentStack : ''; + + const componentNameMessage = componentName + ? `An error occurred in the <${componentName}> component:` + : 'An error occurred in one of your React components:'; + + console['warn']( + '%s\n%s\n\n%s', + componentNameMessage, + componentStack || '', + 'Consider adding an error boundary to your tree to customize error handling behavior.\n' + + 'Visit https://react.dev/link/error-boundaries to learn more about error boundaries.', + ); + } +} + +export function defaultOnCaughtError( + error: mixed, + errorInfo: { + +componentStack?: ?string, + +errorBoundary?: ?React$Component, + }, +): void { + // Overriding this can silence these warnings e.g. for tests. + // See https://github.com/facebook/react/pull/13384 + + // Caught by error boundary + if (__DEV__) { + const componentStack = + errorInfo.componentStack != null ? errorInfo.componentStack : ''; + + const componentNameMessage = componentName + ? `The above error occurred in the <${componentName}> component:` + : 'The above error occurred in one of your React components:'; + + // In development, we provide our own message which includes the component stack + // in addition to the error. + // Don't transform to our wrapper + console['error']( + '%o\n\n%s\n%s\n\n%s', + error, + componentNameMessage, + componentStack, + `React will try to recreate this component tree from scratch ` + + `using the error boundary you provided, ${ + errorBoundaryName || 'Anonymous' + }.`, + ); + } else { + // In production, we print the error directly. + // This will include the message, the JS stack, and anything the browser wants to show. + // We pass the error object instead of custom message so that the browser displays the error natively. + console['error'](error); // Don't transform to our wrapper + } +} + +export function defaultOnRecoverableError( + error: mixed, + errorInfo: {+digest?: ?string, +componentStack?: ?string}, +) { + reportGlobalError(error); +} + +export function logUncaughtError( + root: FiberRoot, errorInfo: CapturedValue, ): void { try { - const logError = showErrorDialog(boundary, errorInfo); - - // Allow injected showErrorDialog() to prevent default console.error logging. - // This enables renderers like ReactNative to better manage redbox behavior. - if (logError === false) { + if (__DEV__) { + componentName = errorInfo.source + ? getComponentNameFromFiber(errorInfo.source) + : null; + errorBoundaryName = null; + } + const error = (errorInfo.value: any); + if (__DEV__ && ReactCurrentActQueue.current !== null) { + // For uncaught errors inside act, we track them on the act and then + // rethrow them into the test. + ReactCurrentActQueue.thrownErrors.push(error); return; } + const onUncaughtError = root.onUncaughtError; + onUncaughtError(error, { + componentStack: errorInfo.stack, + }); + } catch (e) { + // This method must not throw, or React internal state will get messed up. + // If console.error is overridden, or logCapturedError() shows a dialog that throws, + // we want to report this error outside of the normal stack as a last resort. + // https://github.com/facebook/react/issues/13188 + setTimeout(() => { + throw e; + }); + } +} +export function logCaughtError( + root: FiberRoot, + boundary: Fiber, + errorInfo: CapturedValue, +): void { + try { + if (__DEV__) { + componentName = errorInfo.source + ? getComponentNameFromFiber(errorInfo.source) + : null; + errorBoundaryName = getComponentNameFromFiber(boundary); + } const error = (errorInfo.value: any); - - if (boundary.tag === HostRoot) { - if (__DEV__ && ReactCurrentActQueue.current !== null) { - // For uncaught errors inside act, we track them on the act and then - // rethrow them into the test. - ReactCurrentActQueue.thrownErrors.push(error); - return; - } - // For uncaught root errors we report them as uncaught to the browser's - // onerror callback. This won't have component stacks and the error addendum. - // So we add those into a separate console.warn. - reportGlobalError(error); - if (__DEV__) { - const source = errorInfo.source; - const stack = errorInfo.stack; - const componentStack = stack !== null ? stack : ''; - // TODO: There's no longer a way to silence these warnings e.g. for tests. - // See https://github.com/facebook/react/pull/13384 - - const componentName = source ? getComponentNameFromFiber(source) : null; - const componentNameMessage = componentName - ? `An error occurred in the <${componentName}> component:` - : 'An error occurred in one of your React components:'; - - console['warn']( - '%s\n%s\n\n%s', - componentNameMessage, - componentStack, - 'Consider adding an error boundary to your tree to customize error handling behavior.\n' + - 'Visit https://react.dev/link/error-boundaries to learn more about error boundaries.', - ); - } - } else { - // Caught by error boundary - if (__DEV__) { - const source = errorInfo.source; - const stack = errorInfo.stack; - const componentStack = stack !== null ? stack : ''; - // TODO: There's no longer a way to silence these warnings e.g. for tests. - // See https://github.com/facebook/react/pull/13384 - - const componentName = source ? getComponentNameFromFiber(source) : null; - const componentNameMessage = componentName - ? `The above error occurred in the <${componentName}> component:` - : 'The above error occurred in one of your React components:'; - - const errorBoundaryName = - getComponentNameFromFiber(boundary) || 'Anonymous'; - - // In development, we provide our own message which includes the component stack - // in addition to the error. - // Don't transform to our wrapper - console['error']( - '%o\n\n%s\n%s\n\n%s', - error, - componentNameMessage, - componentStack, - `React will try to recreate this component tree from scratch ` + - `using the error boundary you provided, ${errorBoundaryName}.`, - ); - } else { - // In production, we print the error directly. - // This will include the message, the JS stack, and anything the browser wants to show. - // We pass the error object instead of custom message so that the browser displays the error natively. - console['error'](error); // Don't transform to our wrapper - } - } + const onCaughtError = root.onCaughtError; + onCaughtError(error, { + componentStack: errorInfo.stack, + errorBoundary: + boundary.tag === ClassComponent + ? boundary.stateNode // This should always be the case as long as we only have class boundaries + : null, + }); } catch (e) { // This method must not throw, or React internal state will get messed up. // If console.error is overridden, or logCapturedError() shows a dialog that throws, diff --git a/packages/react-reconciler/src/ReactFiberReconciler.js b/packages/react-reconciler/src/ReactFiberReconciler.js index 8bee380ce0..6cd30c78ae 100644 --- a/packages/react-reconciler/src/ReactFiberReconciler.js +++ b/packages/react-reconciler/src/ReactFiberReconciler.js @@ -111,6 +111,11 @@ export { observeVisibleRects, } from './ReactTestSelectors'; export {startHostTransition} from './ReactFiberHooks'; +export { + defaultOnUncaughtError, + defaultOnCaughtError, + defaultOnRecoverableError, +} from './ReactFiberErrorLogger'; type OpaqueRoot = FiberRoot; @@ -249,7 +254,21 @@ export function createContainer( isStrictMode: boolean, concurrentUpdatesByDefaultOverride: null | boolean, identifierPrefix: string, - onRecoverableError: (error: mixed) => void, + onUncaughtError: ( + error: mixed, + errorInfo: {+componentStack?: ?string}, + ) => void, + onCaughtError: ( + error: mixed, + errorInfo: { + +componentStack?: ?string, + +errorBoundary?: ?React$Component, + }, + ) => void, + onRecoverableError: ( + error: mixed, + errorInfo: {+digest?: ?string, +componentStack?: ?string}, + ) => void, transitionCallbacks: null | TransitionTracingCallbacks, ): OpaqueRoot { const hydrate = false; @@ -263,6 +282,8 @@ export function createContainer( isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, + onUncaughtError, + onCaughtError, onRecoverableError, transitionCallbacks, null, @@ -279,7 +300,21 @@ export function createHydrationContainer( isStrictMode: boolean, concurrentUpdatesByDefaultOverride: null | boolean, identifierPrefix: string, - onRecoverableError: (error: mixed) => void, + onUncaughtError: ( + error: mixed, + errorInfo: {+componentStack?: ?string}, + ) => void, + onCaughtError: ( + error: mixed, + errorInfo: { + +componentStack?: ?string, + +errorBoundary?: ?React$Component, + }, + ) => void, + onRecoverableError: ( + error: mixed, + errorInfo: {+digest?: ?string, +componentStack?: ?string}, + ) => void, transitionCallbacks: null | TransitionTracingCallbacks, formState: ReactFormState | null, ): OpaqueRoot { @@ -293,6 +328,8 @@ export function createHydrationContainer( isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, + onUncaughtError, + onCaughtError, onRecoverableError, transitionCallbacks, formState, diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 210561595a..1db2e6bda4 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -51,6 +51,8 @@ function FiberRootNode( tag, hydrate: any, identifierPrefix: any, + onUncaughtError: any, + onCaughtError: any, onRecoverableError: any, formState: ReactFormState | null, ) { @@ -83,6 +85,8 @@ function FiberRootNode( this.hiddenUpdates = createLaneMap(null); this.identifierPrefix = identifierPrefix; + this.onUncaughtError = onUncaughtError; + this.onCaughtError = onCaughtError; this.onRecoverableError = onRecoverableError; if (enableCache) { @@ -143,7 +147,21 @@ export function createFiberRoot( // them through the root constructor. Perhaps we should put them all into a // single type, like a DynamicHostConfig that is defined by the renderer. identifierPrefix: string, - onRecoverableError: null | ((error: mixed) => void), + onUncaughtError: ( + error: mixed, + errorInfo: {+componentStack?: ?string}, + ) => void, + onCaughtError: ( + error: mixed, + errorInfo: { + +componentStack?: ?string, + +errorBoundary?: ?React$Component, + }, + ) => void, + onRecoverableError: ( + error: mixed, + errorInfo: {+digest?: ?string, +componentStack?: ?string}, + ) => void, transitionCallbacks: null | TransitionTracingCallbacks, formState: ReactFormState | null, ): FiberRoot { @@ -153,6 +171,8 @@ export function createFiberRoot( tag, hydrate, identifierPrefix, + onUncaughtError, + onCaughtError, onRecoverableError, formState, ): any); diff --git a/packages/react-reconciler/src/ReactFiberThrow.js b/packages/react-reconciler/src/ReactFiberThrow.js index e2759eb0ce..ce18234fd3 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.js +++ b/packages/react-reconciler/src/ReactFiberThrow.js @@ -66,7 +66,7 @@ import { renderDidSuspend, } from './ReactFiberWorkLoop'; import {propagateParentContextChangesToDeferredTree} from './ReactFiberNewContext'; -import {logCapturedError} from './ReactFiberErrorLogger'; +import {logUncaughtError, logCaughtError} from './ReactFiberErrorLogger'; import {logComponentSuspended} from './DebugTracing'; import {isDevToolsPresent} from './ReactFiberDevToolsHook'; import { @@ -85,7 +85,7 @@ import {noopSuspenseyCommitThenable} from './ReactFiberThenable'; import {REACT_POSTPONE_TYPE} from 'shared/ReactSymbols'; function createRootErrorUpdate( - fiber: Fiber, + root: FiberRoot, errorInfo: CapturedValue, lane: Lane, ): Update { @@ -96,18 +96,23 @@ function createRootErrorUpdate( // being called "element". update.payload = {element: null}; update.callback = () => { - logCapturedError(fiber, errorInfo); + logUncaughtError(root, errorInfo); }; return update; } -function createClassErrorUpdate( - fiber: Fiber, - errorInfo: CapturedValue, - lane: Lane, -): Update { +function createClassErrorUpdate(lane: Lane): Update { const update = createUpdate(lane); update.tag = CaptureUpdate; + return update; +} + +function initializeClassErrorUpdate( + update: Update, + root: FiberRoot, + fiber: Fiber, + errorInfo: CapturedValue, +): void { const getDerivedStateFromError = fiber.type.getDerivedStateFromError; if (typeof getDerivedStateFromError === 'function') { const error = errorInfo.value; @@ -118,7 +123,7 @@ function createClassErrorUpdate( if (__DEV__) { markFailedErrorBoundaryForHotReloading(fiber); } - logCapturedError(fiber, errorInfo); + logCaughtError(root, fiber, errorInfo); }; } @@ -129,7 +134,7 @@ function createClassErrorUpdate( if (__DEV__) { markFailedErrorBoundaryForHotReloading(fiber); } - logCapturedError(fiber, errorInfo); + logCaughtError(root, fiber, errorInfo); if (typeof getDerivedStateFromError !== 'function') { // To preserve the preexisting retry behavior of error boundaries, // we keep track of which ones already failed during this batch. @@ -159,7 +164,6 @@ function createClassErrorUpdate( } }; } - return update; } function resetSuspendedComponent(sourceFiber: Fiber, rootRenderLanes: Lanes) { @@ -561,7 +565,11 @@ function throwException( workInProgress.flags |= ShouldCapture; const lane = pickArbitraryLane(rootRenderLanes); workInProgress.lanes = mergeLanes(workInProgress.lanes, lane); - const update = createRootErrorUpdate(workInProgress, errorInfo, lane); + const update = createRootErrorUpdate( + workInProgress.stateNode, + errorInfo, + lane, + ); enqueueCapturedUpdate(workInProgress, update); return false; } @@ -581,11 +589,8 @@ function throwException( const lane = pickArbitraryLane(rootRenderLanes); workInProgress.lanes = mergeLanes(workInProgress.lanes, lane); // Schedule the error boundary to re-render using updated state - const update = createClassErrorUpdate( - workInProgress, - errorInfo, - lane, - ); + const update = createClassErrorUpdate(lane); + initializeClassErrorUpdate(update, root, workInProgress, errorInfo); enqueueCapturedUpdate(workInProgress, update); return false; } @@ -600,4 +605,9 @@ function throwException( return false; } -export {throwException, createRootErrorUpdate, createClassErrorUpdate}; +export { + throwException, + createRootErrorUpdate, + createClassErrorUpdate, + initializeClassErrorUpdate, +}; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 6e01d5e1c4..81b8128a79 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -177,6 +177,7 @@ import { throwException, createRootErrorUpdate, createClassErrorUpdate, + initializeClassErrorUpdate, } from './ReactFiberThrow'; import { commitBeforeMutationEffects, @@ -277,7 +278,7 @@ import { } from './ReactFiberRootScheduler'; import {getMaskedContext, getUnmaskedContext} from './ReactFiberContext'; import {peekEntangledActionLane} from './ReactFiberAsyncAction'; -import {logCapturedError} from './ReactFiberErrorLogger'; +import {logUncaughtError} from './ReactFiberErrorLogger'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; @@ -1731,8 +1732,8 @@ function handleThrow(root: FiberRoot, thrownValue: any): void { if (erroredWork === null) { // This is a fatal error workInProgressRootExitStatus = RootFatalErrored; - logCapturedError( - root.current, + logUncaughtError( + root, createCapturedValueAtFiber(thrownValue, root.current), ); return; @@ -2552,10 +2553,7 @@ function panicOnRootError(root: FiberRoot, error: mixed) { // caught by an error boundary. This is a fatal error, or panic condition, // because we've run out of ways to recover. workInProgressRootExitStatus = RootFatalErrored; - logCapturedError( - root.current, - createCapturedValueAtFiber(error, root.current), - ); + logUncaughtError(root, createCapturedValueAtFiber(error, root.current)); // Set `workInProgress` to null. This represents advancing to the next // sibling, or the parent if there are no siblings. But since the root // has no siblings nor a parent, we set it to null. Usually this is @@ -3356,7 +3354,11 @@ function captureCommitPhaseErrorOnRoot( error: mixed, ) { const errorInfo = createCapturedValueAtFiber(error, sourceFiber); - const update = createRootErrorUpdate(rootFiber, errorInfo, (SyncLane: Lane)); + const update = createRootErrorUpdate( + rootFiber.stateNode, + errorInfo, + (SyncLane: Lane), + ); const root = enqueueUpdate(rootFiber, update, (SyncLane: Lane)); if (root !== null) { markRootUpdated(root, SyncLane); @@ -3393,13 +3395,10 @@ export function captureCommitPhaseError( !isAlreadyFailedLegacyErrorBoundary(instance)) ) { const errorInfo = createCapturedValueAtFiber(error, sourceFiber); - const update = createClassErrorUpdate( - fiber, - errorInfo, - (SyncLane: Lane), - ); + const update = createClassErrorUpdate((SyncLane: Lane)); const root = enqueueUpdate(fiber, update, (SyncLane: Lane)); if (root !== null) { + initializeClassErrorUpdate(update, root, fiber, errorInfo); markRootUpdated(root, SyncLane); ensureRootIsScheduled(root); } diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 398a5720ab..10b65fb392 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -260,9 +260,20 @@ type BaseFiberRootProperties = { // a reference to. identifierPrefix: string, + onUncaughtError: ( + error: mixed, + errorInfo: {+componentStack?: ?string}, + ) => void, + onCaughtError: ( + error: mixed, + errorInfo: { + +componentStack?: ?string, + +errorBoundary?: ?React$Component, + }, + ) => void, onRecoverableError: ( error: mixed, - errorInfo: {digest?: ?string, componentStack?: ?string}, + errorInfo: {+digest?: ?string, +componentStack?: ?string}, ) => void, formState: ReactFormState | null, diff --git a/packages/react-reconciler/src/__tests__/ReactConfigurableErrorLogging-test.js b/packages/react-reconciler/src/__tests__/ReactConfigurableErrorLogging-test.js new file mode 100644 index 0000000000..4cdde5ce64 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactConfigurableErrorLogging-test.js @@ -0,0 +1,229 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactDOMClient; +let Scheduler; +let container; +let act; + +async function fakeAct(cb) { + // We don't use act/waitForThrow here because we want to observe how errors are reported for real. + await cb(); + Scheduler.unstable_flushAll(); +} + +describe('ReactConfigurableErrorLogging', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactDOMClient = require('react-dom/client'); + Scheduler = require('scheduler'); + container = document.createElement('div'); + if (__DEV__) { + act = React.act; + } + }); + + it('should log errors that occur during the begin phase', async () => { + class ErrorThrowingComponent extends React.Component { + constructor(props) { + super(props); + throw new Error('constructor error'); + } + render() { + return
; + } + } + const uncaughtErrors = []; + const caughtErrors = []; + const root = ReactDOMClient.createRoot(container, { + onUncaughtError(error, errorInfo) { + uncaughtErrors.push(error, errorInfo); + }, + onCaughtError(error, errorInfo) { + caughtErrors.push(error, errorInfo); + }, + }); + await fakeAct(() => { + root.render( +
+ + + +
, + ); + }); + + expect(uncaughtErrors).toEqual([ + expect.objectContaining({ + message: 'constructor error', + }), + expect.objectContaining({ + componentStack: expect.stringMatching( + new RegExp( + '\\s+(in|at) ErrorThrowingComponent (.*)\n' + + '\\s+(in|at) span(.*)\n' + + '\\s+(in|at) div(.*)', + ), + ), + }), + ]); + expect(caughtErrors).toEqual([]); + }); + + it('should log errors that occur during the commit phase', async () => { + class ErrorThrowingComponent extends React.Component { + componentDidMount() { + throw new Error('componentDidMount error'); + } + render() { + return
; + } + } + const uncaughtErrors = []; + const caughtErrors = []; + const root = ReactDOMClient.createRoot(container, { + onUncaughtError(error, errorInfo) { + uncaughtErrors.push(error, errorInfo); + }, + onCaughtError(error, errorInfo) { + caughtErrors.push(error, errorInfo); + }, + }); + await fakeAct(() => { + root.render( +
+ + + +
, + ); + }); + + expect(uncaughtErrors).toEqual([ + expect.objectContaining({ + message: 'componentDidMount error', + }), + expect.objectContaining({ + componentStack: expect.stringMatching( + new RegExp( + '\\s+(in|at) ErrorThrowingComponent (.*)\n' + + '\\s+(in|at) span(.*)\n' + + '\\s+(in|at) div(.*)', + ), + ), + }), + ]); + expect(caughtErrors).toEqual([]); + }); + + it('should ignore errors thrown in log method to prevent cycle', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + render() { + return this.state.error ? null : this.props.children; + } + } + class ErrorThrowingComponent extends React.Component { + render() { + throw new Error('render error'); + } + } + + const uncaughtErrors = []; + const caughtErrors = []; + const root = ReactDOMClient.createRoot(container, { + onUncaughtError(error, errorInfo) { + uncaughtErrors.push(error, errorInfo); + }, + onCaughtError(error, errorInfo) { + caughtErrors.push(error, errorInfo); + throw new Error('onCaughtError error'); + }, + }); + + const ref = React.createRef(); + + await fakeAct(() => { + root.render( +
+ + + + + +
, + ); + }); + + expect(uncaughtErrors).toEqual([]); + expect(caughtErrors).toEqual([ + expect.objectContaining({ + message: 'render error', + }), + expect.objectContaining({ + componentStack: expect.stringMatching( + new RegExp( + '\\s+(in|at) ErrorThrowingComponent (.*)\n' + + '\\s+(in|at) span(.*)\n' + + '\\s+(in|at) ErrorBoundary(.*)\n' + + '\\s+(in|at) div(.*)', + ), + ), + errorBoundary: ref.current, + }), + ]); + + // The error thrown in caughtError should be rethrown with a clean stack + expect(() => { + jest.runAllTimers(); + }).toThrow('onCaughtError error'); + }); + + it('does not log errors when inside real act', async () => { + function ErrorThrowingComponent() { + throw new Error('render error'); + } + const uncaughtErrors = []; + const caughtErrors = []; + const root = ReactDOMClient.createRoot(container, { + onUncaughtError(error, errorInfo) { + uncaughtErrors.push(error, errorInfo); + }, + onCaughtError(error, errorInfo) { + caughtErrors.push(error, errorInfo); + }, + }); + + if (__DEV__) { + global.IS_REACT_ACT_ENVIRONMENT = true; + + await expect(async () => { + await act(() => { + root.render( +
+ + + +
, + ); + }); + }).rejects.toThrow('render error'); + } + + expect(uncaughtErrors).toEqual([]); + expect(caughtErrors).toEqual([]); + }); +}); diff --git a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js index 0711fb3adb..0a38182ecb 100644 --- a/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactFiberHostContext-test.internal.js @@ -93,7 +93,11 @@ describe('ReactFiberHostContext', () => { ConcurrentRoot, null, false, + null, '', + () => {}, + () => {}, + () => {}, null, ); act(() => { diff --git a/packages/react-reconciler/src/forks/ReactFiberErrorDialog.native.js b/packages/react-reconciler/src/forks/ReactFiberErrorDialog.native.js deleted file mode 100644 index 9883128cc2..0000000000 --- a/packages/react-reconciler/src/forks/ReactFiberErrorDialog.native.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and 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 type {Fiber} from '../ReactFiber'; -import type {CapturedValue} from '../ReactCapturedValue'; - -import {ClassComponent} from '../ReactWorkTags'; - -// Module provided by RN: -import {ReactFiberErrorDialog as RNImpl} from 'react-native/Libraries/ReactPrivate/ReactNativePrivateInterface'; - -if (typeof RNImpl.showErrorDialog !== 'function') { - throw new Error( - 'Expected ReactFiberErrorDialog.showErrorDialog to be a function.', - ); -} - -export function showErrorDialog( - boundary: Fiber, - errorInfo: CapturedValue, -): boolean { - const capturedError = { - componentStack: errorInfo.stack !== null ? errorInfo.stack : '', - error: errorInfo.value, - errorBoundary: - boundary !== null && boundary.tag === ClassComponent - ? boundary.stateNode - : null, - }; - return RNImpl.showErrorDialog(capturedError); -} diff --git a/packages/react-reconciler/src/forks/ReactFiberErrorDialog.www.js b/packages/react-reconciler/src/forks/ReactFiberErrorDialog.www.js deleted file mode 100644 index 0f7eaef803..0000000000 --- a/packages/react-reconciler/src/forks/ReactFiberErrorDialog.www.js +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and 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 type {Fiber} from '../ReactFiber'; -import type {CapturedValue} from '../ReactCapturedValue'; - -import {ClassComponent} from '../ReactWorkTags'; - -// Provided by www -const ReactFiberErrorDialogWWW = require('ReactFiberErrorDialog'); - -if (typeof ReactFiberErrorDialogWWW.showErrorDialog !== 'function') { - throw new Error( - 'Expected ReactFiberErrorDialog.showErrorDialog to be a function.', - ); -} - -export function showErrorDialog( - boundary: Fiber, - errorInfo: CapturedValue, -): boolean { - const capturedError = { - componentStack: errorInfo.stack !== null ? errorInfo.stack : '', - error: errorInfo.value, - errorBoundary: - boundary !== null && boundary.tag === ClassComponent - ? boundary.stateNode - : null, - }; - return ReactFiberErrorDialogWWW.showErrorDialog(capturedError); -} diff --git a/packages/react-test-renderer/src/ReactTestRenderer.js b/packages/react-test-renderer/src/ReactTestRenderer.js index 56e37850da..7368eada24 100644 --- a/packages/react-test-renderer/src/ReactTestRenderer.js +++ b/packages/react-test-renderer/src/ReactTestRenderer.js @@ -23,6 +23,9 @@ import { flushSync, injectIntoDevTools, batchedUpdates, + defaultOnUncaughtError, + defaultOnCaughtError, + defaultOnRecoverableError, } from 'react-reconciler/src/ReactFiberReconciler'; import {findCurrentFiberUsingSlowPath} from 'react-reconciler/src/ReactFiberTreeReflection'; import { @@ -454,13 +457,6 @@ function propsMatch(props: Object, filter: Object): boolean { return true; } -// $FlowFixMe[missing-local-annot] -function onRecoverableError(error) { - // TODO: Expose onRecoverableError option to userspace - // eslint-disable-next-line react-internal/no-production-logging, react-internal/warning-args - console.error(error); -} - function create( element: React$Element, options: TestRendererOptions, @@ -522,7 +518,9 @@ function create( isStrictMode, concurrentUpdatesByDefault, '', - onRecoverableError, + defaultOnUncaughtError, + defaultOnCaughtError, + defaultOnRecoverableError, null, ); diff --git a/packages/react-test-renderer/src/__tests__/ReactTestRenderer-test.internal.js b/packages/react-test-renderer/src/__tests__/ReactTestRenderer-test.internal.js index ec6cf15914..4cd173d06f 100644 --- a/packages/react-test-renderer/src/__tests__/ReactTestRenderer-test.internal.js +++ b/packages/react-test-renderer/src/__tests__/ReactTestRenderer-test.internal.js @@ -98,6 +98,8 @@ describe('ReactTestRenderer', () => { null, expect.anything(), expect.anything(), + expect.anything(), + expect.anything(), null, ); } diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index eef64866e1..f1ed750c22 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -232,36 +232,6 @@ const forks = Object.freeze({ } }, - // Different dialogs for caught errors. - './packages/react-reconciler/src/ReactFiberErrorDialog.js': ( - bundleType, - entry - ) => { - switch (bundleType) { - case FB_WWW_DEV: - case FB_WWW_PROD: - case FB_WWW_PROFILING: - // Use the www fork which shows an error dialog. - return './packages/react-reconciler/src/forks/ReactFiberErrorDialog.www.js'; - case RN_OSS_DEV: - case RN_OSS_PROD: - case RN_OSS_PROFILING: - case RN_FB_DEV: - case RN_FB_PROD: - case RN_FB_PROFILING: - switch (entry) { - case 'react-native-renderer': - case 'react-native-renderer/fabric': - // Use the RN fork which plays well with redbox. - return './packages/react-reconciler/src/forks/ReactFiberErrorDialog.native.js'; - default: - return null; - } - default: - return null; - } - }, - './packages/react-reconciler/src/ReactFiberConfig.js': ( bundleType, entry,