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 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 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); + 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,