diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index b1f80ccc2be85..4ec8e3a4e6f2e 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -452,6 +452,6 @@ export function detachDeletedInstance(node: Instance): void { // noop } -export function logHydrationError(config, error) { +export function logRecoverableError(config, error) { // noop } diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index bcf0aaa461628..c2b474c1b1a40 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -1898,7 +1898,7 @@ describe('ReactDOMFizzServer', () => { // falls back to client rendering. isClient = true; ReactDOM.hydrateRoot(container, , { - onHydrationError(error) { + onRecoverableError(error) { Scheduler.unstable_yieldValue(error.message); }, }); @@ -1982,7 +1982,7 @@ describe('ReactDOMFizzServer', () => { // Hydrate the tree. Child will throw during render. isClient = true; ReactDOM.hydrateRoot(container, , { - onHydrationError(error) { + onRecoverableError(error) { // TODO: We logged a hydration error, but the same error ends up // being thrown during the fallback to client rendering, too. Maybe // we should only log if the client render succeeds. @@ -2063,7 +2063,7 @@ describe('ReactDOMFizzServer', () => { // falls back to client rendering. isClient = true; ReactDOM.hydrateRoot(container, , { - onHydrationError(error) { + onRecoverableError(error) { Scheduler.unstable_yieldValue(error.message); }, }); @@ -2100,4 +2100,64 @@ describe('ReactDOMFizzServer', () => { expect(span3Ref.current).toBe(span3); }, ); + + it('logs regular (non-hydration) errors when the UI recovers', async () => { + let shouldThrow = true; + + function A() { + if (shouldThrow) { + Scheduler.unstable_yieldValue('Oops!'); + throw new Error('Oops!'); + } + Scheduler.unstable_yieldValue('A'); + return 'A'; + } + + function B() { + Scheduler.unstable_yieldValue('B'); + return 'B'; + } + + function App() { + return ( + <> + + + + ); + } + + const root = ReactDOM.createRoot(container, { + onRecoverableError(error) { + Scheduler.unstable_yieldValue( + 'Logged a recoverable error: ' + error.message, + ); + }, + }); + React.startTransition(() => { + root.render(); + }); + + // Partially render A, but yield before the render has finished + expect(Scheduler).toFlushAndYieldThrough(['Oops!', 'Oops!']); + + // React will try rendering again synchronously. During the retry, A will + // not throw. This simulates a concurrent data race that is fixed by + // blocking the main thread. + shouldThrow = false; + expect(Scheduler).toFlushAndYield([ + // Finish initial render attempt + 'B', + + // Render again, synchronously + 'A', + 'B', + + // Log the error + 'Logged a recoverable error: Oops!', + ]); + + // UI looks normal + expect(container.textContent).toEqual('AB'); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 1a98b8e8f877e..a5ca716bdb96d 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -209,7 +209,7 @@ describe('ReactDOMServerPartialHydration', () => { // hydrating anyway. suspend = true; ReactDOM.hydrateRoot(container, , { - onHydrationError(error) { + onRecoverableError(error) { Scheduler.unstable_yieldValue(error.message); }, }); @@ -299,7 +299,7 @@ describe('ReactDOMServerPartialHydration', () => { client = true; ReactDOM.hydrateRoot(container, , { - onHydrationError(error) { + onRecoverableError(error) { Scheduler.unstable_yieldValue(error.message); }, }); @@ -3052,13 +3052,27 @@ describe('ReactDOMServerPartialHydration', () => { expect(() => { act(() => { - ReactDOM.hydrateRoot(container, ); + ReactDOM.hydrateRoot(container, , { + onRecoverableError(error) { + Scheduler.unstable_yieldValue( + 'Log recoverable error: ' + error.message, + ); + }, + }); }); }).toErrorDev( 'Warning: An error occurred during hydration. ' + 'The server HTML was replaced with client content in
.', {withoutStack: true}, ); + expect(Scheduler).toHaveYielded([ + 'Log recoverable error: An error occurred during hydration. The server ' + + 'HTML was replaced with client content', + // TODO: There were multiple mismatches in a single container. Should + // we attempt to de-dupe them? + 'Log recoverable error: An error occurred during hydration. The server ' + + 'HTML was replaced with client content', + ]); // We show fallback state when mismatch happens at root expect(container.innerHTML).toEqual( diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index cd2fad2fdb873..fdd8b38e312b7 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -379,15 +379,15 @@ export function getCurrentEventPriority(): * { return getEventPriority(currentEvent.type); } -export function logHydrationError( +export function logRecoverableError( config: ErrorLoggingConfig, error: mixed, ): void { - const onHydrationError = config; - if (onHydrationError !== null) { + const onRecoverableError = config; + if (onRecoverableError !== null) { // Schedule a callback to invoke the user-provided logging function. scheduleCallback(IdlePriority, () => { - onHydrationError(error); + onRecoverableError(error); }); } else { // Default behavior is to rethrow the error in a separate task. This will @@ -1094,6 +1094,8 @@ export function didNotFindHydratableSuspenseInstance( export function errorHydratingContainer(parentContainer: Container): void { if (__DEV__) { + // TODO: This gets logged by onRecoverableError, too, so we should be + // able to remove it. console.error( 'An error occurred during hydration. The server HTML was replaced with client content in <%s>.', parentContainer.nodeName.toLowerCase(), diff --git a/packages/react-dom/src/client/ReactDOMRoot.js b/packages/react-dom/src/client/ReactDOMRoot.js index caf1f78c4801c..fe6b6ee31f773 100644 --- a/packages/react-dom/src/client/ReactDOMRoot.js +++ b/packages/react-dom/src/client/ReactDOMRoot.js @@ -24,6 +24,7 @@ export type CreateRootOptions = { unstable_strictMode?: boolean, unstable_concurrentUpdatesByDefault?: boolean, identifierPrefix?: string, + onRecoverableError?: (error: mixed) => void, ... }; @@ -36,7 +37,7 @@ export type HydrateRootOptions = { unstable_strictMode?: boolean, unstable_concurrentUpdatesByDefault?: boolean, identifierPrefix?: string, - onHydrationError?: (error: mixed) => void, + onRecoverableError?: (error: mixed) => void, ... }; @@ -144,6 +145,7 @@ export function createRoot( let isStrictMode = false; let concurrentUpdatesByDefaultOverride = false; let identifierPrefix = ''; + let onRecoverableError = null; if (options !== null && options !== undefined) { if (__DEV__) { if ((options: any).hydrate) { @@ -164,6 +166,9 @@ export function createRoot( if (options.identifierPrefix !== undefined) { identifierPrefix = options.identifierPrefix; } + if (options.onRecoverableError !== undefined) { + onRecoverableError = options.onRecoverableError; + } } const root = createContainer( @@ -174,7 +179,7 @@ export function createRoot( isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, - null, + onRecoverableError, ); markContainerAsRoot(root.current, container); @@ -215,7 +220,7 @@ export function hydrateRoot( let isStrictMode = false; let concurrentUpdatesByDefaultOverride = false; let identifierPrefix = ''; - let onHydrationError = null; + let onRecoverableError = null; if (options !== null && options !== undefined) { if (options.unstable_strictMode === true) { isStrictMode = true; @@ -229,8 +234,8 @@ export function hydrateRoot( if (options.identifierPrefix !== undefined) { identifierPrefix = options.identifierPrefix; } - if (options.onHydrationError !== undefined) { - onHydrationError = options.onHydrationError; + if (options.onRecoverableError !== undefined) { + onRecoverableError = options.onRecoverableError; } } @@ -242,7 +247,7 @@ export function hydrateRoot( isStrictMode, concurrentUpdatesByDefaultOverride, identifierPrefix, - onHydrationError, + onRecoverableError, ); markContainerAsRoot(root.current, container); // This can't be a comment node since hydration doesn't work on comment nodes anyway. diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index 3d2f890387678..e720c2e12534e 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -528,7 +528,7 @@ export function detachDeletedInstance(node: Instance): void { // noop } -export function logHydrationError( +export function logRecoverableError( config: ErrorLoggingConfig, error: mixed, ): void { diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index bc7c859c4c858..27df360718aee 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -516,7 +516,7 @@ export function detachDeletedInstance(node: Instance): void { // noop } -export function logHydrationError( +export function logRecoverableError( config: ErrorLoggingConfig, error: mixed, ): void { diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index c93b5eb6e91dd..411757c0436be 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -467,7 +467,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { detachDeletedInstance() {}, - logHydrationError() { + logRecoverableError() { // no-op }, }; diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index 60903d236e0d5..c2b2a9a2fa5e7 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -37,7 +37,7 @@ import { import { supportsPersistence, getOffscreenContainerProps, - logHydrationError, + logRecoverableError, } from './ReactFiberHostConfig'; import {shouldCaptureSuspense} from './ReactFiberSuspenseComponent.new'; import {NoMode, ConcurrentMode, DebugTracingMode} from './ReactTypeOfMode'; @@ -515,7 +515,7 @@ function throwException( // probably want to log any error that is recovered from without // triggering an error boundary — or maybe even those, too. Need to // figure out the right API. - logHydrationError(root.errorLoggingConfig, value); + logRecoverableError(root.errorLoggingConfig, value); return; } } else { @@ -526,7 +526,7 @@ function throwException( // We didn't find a boundary that could handle this type of exception. Start // over and traverse parent path again, this time treating the exception // as an error. - renderDidError(); + renderDidError(value); value = createCapturedValue(value, sourceFiber); let workInProgress = returnFiber; diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index 6b7f4bf6055b4..3ae5df1f93414 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -37,7 +37,7 @@ import { import { supportsPersistence, getOffscreenContainerProps, - logHydrationError, + logRecoverableError, } from './ReactFiberHostConfig'; import {shouldCaptureSuspense} from './ReactFiberSuspenseComponent.old'; import {NoMode, ConcurrentMode, DebugTracingMode} from './ReactTypeOfMode'; @@ -515,7 +515,7 @@ function throwException( // probably want to log any error that is recovered from without // triggering an error boundary — or maybe even those, too. Need to // figure out the right API. - logHydrationError(root.errorLoggingConfig, value); + logRecoverableError(root.errorLoggingConfig, value); return; } } else { @@ -526,7 +526,7 @@ function throwException( // We didn't find a boundary that could handle this type of exception. Start // over and traverse parent path again, this time treating the exception // as an error. - renderDidError(); + renderDidError(value); value = createCapturedValue(value, sourceFiber); let workInProgress = returnFiber; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index b4b333547c194..f05174cf34f9c 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -76,6 +76,7 @@ import { supportsMicrotasks, errorHydratingContainer, scheduleMicrotask, + logRecoverableError, } from './ReactFiberHostConfig'; import { @@ -296,6 +297,7 @@ let workInProgressRootInterleavedUpdatedLanes: Lanes = NoLanes; let workInProgressRootRenderPhaseUpdatedLanes: Lanes = NoLanes; // Lanes that were pinged (in an interleaved event) during this render. let workInProgressRootPingedLanes: Lanes = NoLanes; +let workInProgressRootConcurrentErrors: Array | null = null; // The most recent time we committed a fallback. This lets us ensure a train // model where we don't commit new loading states in too quick succession. @@ -896,18 +898,40 @@ function recoverFromConcurrentError(root, errorRetryLanes) { let exitStatus; + let recoverableErrors = workInProgressRootConcurrentErrors; const MAX_ERROR_RETRY_ATTEMPTS = 50; for (let i = 0; i < MAX_ERROR_RETRY_ATTEMPTS; i++) { exitStatus = renderRootSync(root, errorRetryLanes); - if ( - exitStatus === RootErrored && - workInProgressRootRenderPhaseUpdatedLanes !== NoLanes - ) { - // There was a render phase update during this render. Some internal React - // implementation details may use this as a trick to schedule another - // render pass. To protect against an inifinite loop, eventually - // we'll give up. - continue; + if (exitStatus !== RootErrored) { + // Successfully finished rendering + if (recoverableErrors !== null) { + // Although we recovered the UI without surfacing an error, we should + // still log the errors so they can be fixed. + for (let j = 0; j < recoverableErrors.length; j++) { + const recoverableError = recoverableErrors[j]; + logRecoverableError(root.errorLoggingConfig, recoverableError); + } + } + } else { + // The UI failed to recover. + if (workInProgressRootRenderPhaseUpdatedLanes !== NoLanes) { + // There was a render phase update during this render. Some internal React + // implementation details may use this as a trick to schedule another + // render pass. To protect against an inifinite loop, eventually + // we'll give up. + // + // Add the newly thrown errors to the list of recoverable errors. If we + // eventually recover, we'll log them. Otherwise, we'll surface the + // error to the UI. + if (workInProgressRootConcurrentErrors !== null) { + if (recoverableErrors === null) { + recoverableErrors = workInProgressRootConcurrentErrors; + } else { + recoverableErrors.concat(workInProgressRootConcurrentErrors); + } + } + continue; + } } break; } @@ -1336,6 +1360,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) { workInProgressRootInterleavedUpdatedLanes = NoLanes; workInProgressRootRenderPhaseUpdatedLanes = NoLanes; workInProgressRootPingedLanes = NoLanes; + workInProgressRootConcurrentErrors = null; enqueueInterleavedUpdates(); @@ -1490,10 +1515,15 @@ export function renderDidSuspendDelayIfPossible(): void { } } -export function renderDidError() { +export function renderDidError(error: mixed) { if (workInProgressRootExitStatus !== RootSuspendedWithDelay) { workInProgressRootExitStatus = RootErrored; } + if (workInProgressRootConcurrentErrors === null) { + workInProgressRootConcurrentErrors = [error]; + } else { + workInProgressRootConcurrentErrors.push(error); + } } // Called during render to determine if anything has suspended. diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index d8bb61af50c84..7bc8edc872f96 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -76,6 +76,7 @@ import { supportsMicrotasks, errorHydratingContainer, scheduleMicrotask, + logRecoverableError, } from './ReactFiberHostConfig'; import { @@ -296,6 +297,7 @@ let workInProgressRootInterleavedUpdatedLanes: Lanes = NoLanes; let workInProgressRootRenderPhaseUpdatedLanes: Lanes = NoLanes; // Lanes that were pinged (in an interleaved event) during this render. let workInProgressRootPingedLanes: Lanes = NoLanes; +let workInProgressRootConcurrentErrors: Array | null = null; // The most recent time we committed a fallback. This lets us ensure a train // model where we don't commit new loading states in too quick succession. @@ -896,18 +898,40 @@ function recoverFromConcurrentError(root, errorRetryLanes) { let exitStatus; + let recoverableErrors = workInProgressRootConcurrentErrors; const MAX_ERROR_RETRY_ATTEMPTS = 50; for (let i = 0; i < MAX_ERROR_RETRY_ATTEMPTS; i++) { exitStatus = renderRootSync(root, errorRetryLanes); - if ( - exitStatus === RootErrored && - workInProgressRootRenderPhaseUpdatedLanes !== NoLanes - ) { - // There was a render phase update during this render. Some internal React - // implementation details may use this as a trick to schedule another - // render pass. To protect against an inifinite loop, eventually - // we'll give up. - continue; + if (exitStatus !== RootErrored) { + // Successfully finished rendering + if (recoverableErrors !== null) { + // Although we recovered the UI without surfacing an error, we should + // still log the errors so they can be fixed. + for (let j = 0; j < recoverableErrors.length; j++) { + const recoverableError = recoverableErrors[j]; + logRecoverableError(root.errorLoggingConfig, recoverableError); + } + } + } else { + // The UI failed to recover. + if (workInProgressRootRenderPhaseUpdatedLanes !== NoLanes) { + // There was a render phase update during this render. Some internal React + // implementation details may use this as a trick to schedule another + // render pass. To protect against an inifinite loop, eventually + // we'll give up. + // + // Add the newly thrown errors to the list of recoverable errors. If we + // eventually recover, we'll log them. Otherwise, we'll surface the + // error to the UI. + if (workInProgressRootConcurrentErrors !== null) { + if (recoverableErrors === null) { + recoverableErrors = workInProgressRootConcurrentErrors; + } else { + recoverableErrors.concat(workInProgressRootConcurrentErrors); + } + } + continue; + } } break; } @@ -1336,6 +1360,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) { workInProgressRootInterleavedUpdatedLanes = NoLanes; workInProgressRootRenderPhaseUpdatedLanes = NoLanes; workInProgressRootPingedLanes = NoLanes; + workInProgressRootConcurrentErrors = null; enqueueInterleavedUpdates(); @@ -1490,10 +1515,15 @@ export function renderDidSuspendDelayIfPossible(): void { } } -export function renderDidError() { +export function renderDidError(error: mixed) { if (workInProgressRootExitStatus !== RootSuspendedWithDelay) { workInProgressRootExitStatus = RootErrored; } + if (workInProgressRootConcurrentErrors === null) { + workInProgressRootConcurrentErrors = [error]; + } else { + workInProgressRootConcurrentErrors.push(error); + } } // Called during render to determine if anything has suspended. diff --git a/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js index 4c13ef7c35dfa..16ebb4657d28d 100644 --- a/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js +++ b/packages/react-reconciler/src/__tests__/useMutableSourceHydration-test.js @@ -205,6 +205,9 @@ describe('useMutableSourceHydration', () => { act(() => { ReactDOM.hydrateRoot(container, , { mutableSources: [mutableSource], + onRecoverableError(error) { + Scheduler.unstable_yieldValue('Log error: ' + error.message); + }, }); source.value = 'two'; @@ -254,11 +257,17 @@ describe('useMutableSourceHydration', () => { React.startTransition(() => { ReactDOM.hydrateRoot(container, , { mutableSources: [mutableSource], + onRecoverableError(error) { + Scheduler.unstable_yieldValue('Log error: ' + error.message); + }, }); }); } else { ReactDOM.hydrateRoot(container, , { mutableSources: [mutableSource], + onRecoverableError(error) { + Scheduler.unstable_yieldValue('Log error: ' + error.message); + }, }); } expect(Scheduler).toFlushAndYieldThrough(['a:one']); @@ -269,7 +278,17 @@ describe('useMutableSourceHydration', () => { 'The server HTML was replaced with client content in
.', {withoutStack: true}, ); - expect(Scheduler).toHaveYielded(['a:two', 'b:two']); + expect(Scheduler).toHaveYielded([ + 'a:two', + 'b:two', + // TODO: Before onRecoverableError, this error was never surfaced to the + // user. The request to file an bug report no longer makes sense. + // However, the experimental useMutableSource API is slated for + // removal, anyway. + 'Log error: Cannot read from mutable source during the current ' + + 'render without tearing. This may be a bug in React. Please file ' + + 'an issue.', + ]); expect(source.listenerCount).toBe(2); }); @@ -328,11 +347,17 @@ describe('useMutableSourceHydration', () => { React.startTransition(() => { ReactDOM.hydrateRoot(container, fragment, { mutableSources: [mutableSource], + onRecoverableError(error) { + Scheduler.unstable_yieldValue('Log error: ' + error.message); + }, }); }); } else { ReactDOM.hydrateRoot(container, fragment, { mutableSources: [mutableSource], + onRecoverableError(error) { + Scheduler.unstable_yieldValue('Log error: ' + error.message); + }, }); } expect(Scheduler).toFlushAndYieldThrough(['0:a:one']); @@ -343,7 +368,17 @@ describe('useMutableSourceHydration', () => { 'The server HTML was replaced with client content in
.', {withoutStack: true}, ); - expect(Scheduler).toHaveYielded(['0:a:one', '1:b:two']); + expect(Scheduler).toHaveYielded([ + '0:a:one', + '1:b:two', + // TODO: Before onRecoverableError, this error was never surfaced to the + // user. The request to file an bug report no longer makes sense. + // However, the experimental useMutableSource API is slated for + // removal, anyway. + 'Log error: Cannot read from mutable source during the current ' + + 'render without tearing. This may be a bug in React. Please file ' + + 'an issue.', + ]); }); // @gate !enableSyncDefaultUpdates diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 38cee5f94e11c..8e67bf5517e45 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -69,7 +69,7 @@ export const prepareScopeUpdate = $$$hostConfig.preparePortalMount; export const getInstanceFromScope = $$$hostConfig.getInstanceFromScope; export const getCurrentEventPriority = $$$hostConfig.getCurrentEventPriority; export const detachDeletedInstance = $$$hostConfig.detachDeletedInstance; -export const logHydrationError = $$$hostConfig.logHydrationError; +export const logRecoverableError = $$$hostConfig.logRecoverableError; // ------------------- // Microtasks diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index 5279fda0b43f6..266b2c06e58a1 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -317,7 +317,7 @@ export function detachDeletedInstance(node: Instance): void { // noop } -export function logHydrationError( +export function logRecoverableError( config: ErrorLoggingConfig, error: mixed, ): void {