From d597ebd2d4f1c319105eccf11b41f67466834d24 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 25 Mar 2024 22:11:59 -0400 Subject: [PATCH] Make onCaughtError and onUncaughtError configurable --- .../react-dom/src/client/ReactDOMLegacy.js | 8 +- packages/react-dom/src/client/ReactDOMRoot.js | 61 ++++- .../react-native-renderer/src/ReactFabric.js | 14 +- .../src/ReactNativeRenderer.js | 14 +- .../src/createReactNoop.js | 6 + .../src/ReactFiberErrorDialog.js | 8 +- .../src/ReactFiberErrorLogger.js | 191 +++++++++------ .../src/ReactFiberReconciler.js | 41 +++- .../react-reconciler/src/ReactFiberRoot.js | 22 +- .../src/ReactInternalTypes.js | 13 +- .../ReactConfigurableErrorLogging-test.js | 229 ++++++++++++++++++ .../ReactFiberHostContext-test.internal.js | 4 + .../src/forks/ReactFiberErrorDialog.native.js | 24 +- .../src/forks/ReactFiberErrorDialog.www.js | 24 +- .../src/ReactTestRenderer.js | 14 +- 15 files changed, 527 insertions(+), 146 deletions(-) create mode 100644 packages/react-reconciler/src/__tests__/ReactConfigurableErrorLogging-test.js diff --git a/packages/react-dom/src/client/ReactDOMLegacy.js b/packages/react-dom/src/client/ReactDOMLegacy.js index ed0c6baae2fae..9f25a78448bd0 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 c9342bb9e8b7e..21ef5ce1c7dc4 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -33,7 +33,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 = { @@ -45,7 +59,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, }; @@ -68,15 +96,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; @@ -157,6 +182,8 @@ export function createRoot( let isStrictMode = false; let concurrentUpdatesByDefaultOverride = false; let identifierPrefix = ''; + let onUncaughtError = defaultOnUncaughtError; + let onCaughtError = defaultOnCaughtError; let onRecoverableError = defaultOnRecoverableError; let transitionCallbacks = null; @@ -194,6 +221,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; } @@ -209,6 +242,8 @@ export function createRoot( isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, + onUncaughtError, + onCaughtError, onRecoverableError, transitionCallbacks, ); @@ -263,6 +298,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; @@ -279,6 +316,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; } @@ -301,6 +344,8 @@ export function hydrateRoot( isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, + onUncaughtError, + onCaughtError, onRecoverableError, transitionCallbacks, formState, diff --git a/packages/react-native-renderer/src/ReactFabric.js b/packages/react-native-renderer/src/ReactFabric.js index 3c2eff2dbc9bb..0917cf4360d00 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,13 +46,6 @@ 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); -} - function render( element: Element, containerTag: number, @@ -68,7 +64,9 @@ function render( false, null, '', - onRecoverableError, + defaultOnUncaughtError, + defaultOnCaughtError, + 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 d8eb76bc23a36..197fe1b91b439 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,13 +50,6 @@ 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); -} - function render( element: Element, containerTag: number, @@ -71,7 +67,9 @@ function render( false, null, '', - onRecoverableError, + defaultOnUncaughtError, + defaultOnCaughtError, + 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 0470672f4b5f3..af4612f00ddee 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/ReactFiberErrorDialog.js b/packages/react-reconciler/src/ReactFiberErrorDialog.js index 83fdde3c3e589..4e415ee6ddb79 100644 --- a/packages/react-reconciler/src/ReactFiberErrorDialog.js +++ b/packages/react-reconciler/src/ReactFiberErrorDialog.js @@ -7,16 +7,14 @@ * @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: null | Fiber, - errorInfo: CapturedValue, + errorBoundary: ?React$Component, + error: mixed, + componentStack: string, ): boolean { return true; } diff --git a/packages/react-reconciler/src/ReactFiberErrorLogger.js b/packages/react-reconciler/src/ReactFiberErrorLogger.js index a6ef5ac01cdc9..0ab443a964ac0 100644 --- a/packages/react-reconciler/src/ReactFiberErrorLogger.js +++ b/packages/react-reconciler/src/ReactFiberErrorLogger.js @@ -13,56 +13,130 @@ import type {CapturedValue} from './ReactCapturedValue'; import {showErrorDialog} from './ReactFiberErrorDialog'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; +import {ClassComponent} from './ReactWorkTags'; + import reportGlobalError from 'shared/reportGlobalError'; import ReactSharedInternals from 'shared/ReactSharedInternals'; const {ReactCurrentActQueue} = ReactSharedInternals; +// 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 { + const componentStack = + errorInfo.componentStack != null ? errorInfo.componentStack : ''; + const logError = showErrorDialog(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; + } + + // 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__) { + // TODO: There's no longer a way to silence these warnings e.g. for tests. + // See https://github.com/facebook/react/pull/13384 + + 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 + + const boundary = errorInfo.errorBoundary; + const componentStack = + errorInfo.componentStack != null ? errorInfo.componentStack : ''; + const logError = showErrorDialog(boundary, 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; + } + + // Caught by error boundary + if (__DEV__) { + 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(null, errorInfo); - - // Allow injected showErrorDialog() to prevent default console.error logging. - // This enables renderers like ReactNative to better manage redbox behavior. - if (logError === false) { - return; + 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; } - // 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.', - ); - } + 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, @@ -80,48 +154,21 @@ export function logCaughtError( 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) { - return; - } - - const error = (errorInfo.value: any); - // 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 + 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 8bee380ce0370..6cd30c78ae901 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 210561595aac4..1db2e6bda4b49 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/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 398a5720abf5f..10b65fb39280f 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 0000000000000..4cdde5ce64636 --- /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 0711fb3adb3d0..0a38182ecbe54 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 index 98e4cd84d56c0..1f5a34f6bc179 100644 --- a/packages/react-reconciler/src/forks/ReactFiberErrorDialog.native.js +++ b/packages/react-reconciler/src/forks/ReactFiberErrorDialog.native.js @@ -7,11 +7,6 @@ * @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'; @@ -22,16 +17,13 @@ if (typeof RNImpl.showErrorDialog !== 'function') { } export function showErrorDialog( - boundary: null | Fiber, - errorInfo: CapturedValue, + errorBoundary: ?React$Component, + error: mixed, + componentStack: string, ): 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); + return RNImpl.showErrorDialog({ + componentStack, + error, + errorBoundary, + }); } diff --git a/packages/react-reconciler/src/forks/ReactFiberErrorDialog.www.js b/packages/react-reconciler/src/forks/ReactFiberErrorDialog.www.js index 17e873fcbdf15..e2e0a4943aa44 100644 --- a/packages/react-reconciler/src/forks/ReactFiberErrorDialog.www.js +++ b/packages/react-reconciler/src/forks/ReactFiberErrorDialog.www.js @@ -7,11 +7,6 @@ * @flow */ -import type {Fiber} from '../ReactFiber'; -import type {CapturedValue} from '../ReactCapturedValue'; - -import {ClassComponent} from '../ReactWorkTags'; - // Provided by www const ReactFiberErrorDialogWWW = require('ReactFiberErrorDialog'); @@ -22,16 +17,13 @@ if (typeof ReactFiberErrorDialogWWW.showErrorDialog !== 'function') { } export function showErrorDialog( - boundary: null | Fiber, - errorInfo: CapturedValue, + errorBoundary: ?React$Component, + error: mixed, + componentStack: string, ): 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); + return ReactFiberErrorDialogWWW.showErrorDialog({ + errorBoundary, + error, + componentStack, + }); } diff --git a/packages/react-test-renderer/src/ReactTestRenderer.js b/packages/react-test-renderer/src/ReactTestRenderer.js index 410f39d055c75..94b6bfc3a2e98 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, @@ -516,7 +512,9 @@ function create( isStrictMode, concurrentUpdatesByDefault, '', - onRecoverableError, + defaultOnUncaughtError, + defaultOnCaughtError, + defaultOnRecoverableError, null, );