From c17a9ce5acbe9b0ff6a067113b5d8363f0c7ca35 Mon Sep 17 00:00:00 2001 From: Josh Story Date: Fri, 20 May 2022 16:34:18 -0700 Subject: [PATCH] extend onRecoverableError API to support errorInfo errorInfo has been used in Error Boundaries wiht componentDidCatch for a while now. To date this metadata only contained a componentStack. onRecoverableError only receives an error (type mixed) argument and thus providing additional error metadata was not possible without mutating user created mixed objects. This change modifies rootConcurrentErrors rootRecoverableErrors, and hydrationErrors so all expect CapturedValue types. additionally a new factory function allows the creation of CapturedValues from a value plus a hash and stack. In general, client derived CapturedValues will be created using the original function which derives a componentStack from a fiber and server originated CapturedValues will be created using with a passed in hash and optional componentStack. --- .../src/__tests__/ReactDOMFizzServer-test.js | 191 +++++++++++------- .../src/client/ReactDOMHostConfig.js | 21 +- .../src/ReactCapturedValue.js | 17 +- .../src/ReactFiberBeginWork.new.js | 63 +++--- .../src/ReactFiberBeginWork.old.js | 63 +++--- .../src/ReactFiberHydrationContext.new.js | 5 +- .../src/ReactFiberHydrationContext.old.js | 5 +- .../src/ReactFiberThrow.new.js | 10 +- .../src/ReactFiberThrow.old.js | 10 +- .../src/ReactFiberWorkLoop.new.js | 29 ++- .../src/ReactFiberWorkLoop.old.js | 29 ++- .../src/ReactInternalTypes.js | 5 +- 12 files changed, 280 insertions(+), 168 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 66ae2c25e6901..bba82732a7c9b 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -90,46 +90,28 @@ describe('ReactDOMFizzServer', () => { }); function expectErrors(errorsArr, toBeDevArr, toBeProdArr) { - const mappedErrows = errorsArr.map(error => { - if (error.componentStack) { - return [ - error.message, - error.hash, - normalizeCodeLocInfo(error.componentStack), - ]; - } else if (error.hash) { - return [error.message, error.hash]; + const mappedErrows = errorsArr.map(({error, errorInfo}) => { + const stack = errorInfo && errorInfo.componentStack; + const errorHash = errorInfo && errorInfo.errorHash; + if (stack) { + return [error.message, errorHash, normalizeCodeLocInfo(stack)]; + } else if (errorHash) { + return [error.message, errorHash]; } return error.message; }); if (__DEV__) { - expect(mappedErrows).toEqual( - toBeDevArr, - // .map(([errorMessage, errorHash, errorComponentStack]) => { - // if (typeof error === 'string' || error instanceof String) { - // return error; - // } - // let str = JSON.stringify(error).replace(/\\n/g, '\n'); - // // this gets stripped away by normalizeCodeLocInfo... - // // Kind of hacky but lets strip it away here too just so they match... - // // easier than fixing the regex to account for this edge case - // if (str.endsWith('at **)"}')) { - // str = str.replace(/at \*\*\)\"}$/, 'at **)'); - // } - // return str; - // }), - ); + expect(mappedErrows).toEqual(toBeDevArr); } else { expect(mappedErrows).toEqual(toBeProdArr); } } - // @TODO we will use this in a followup change once we start exposing componentStacks from server errors - // function componentStack(components) { - // return components - // .map(component => `\n in ${component} (at **)`) - // .join(''); - // } + function componentStack(components) { + return components + .map(component => `\n in ${component} (at **)`) + .join(''); + } async function act(callback) { await callback(); @@ -471,8 +453,8 @@ describe('ReactDOMFizzServer', () => { bootstrapped = true; // Attempt to hydrate the content. ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - errors.push(error); + onRecoverableError(error, errorInfo) { + errors.push({error, errorInfo}); }, }); }; @@ -483,8 +465,8 @@ describe('ReactDOMFizzServer', () => { loggedErrors.push(x); return 'Hash of (' + x.message + ')'; } - // const expectedHash = onError(theError); - // loggedErrors.length = 0; + const expectedHash = onError(theError); + loggedErrors.length = 0; await act(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream( @@ -519,9 +501,18 @@ describe('ReactDOMFizzServer', () => { expect(Scheduler).toFlushAndYield([]); expectErrors( errors, - [theError.message], [ - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + [ + theError.message, + expectedHash, + componentStack(['Lazy', 'Suspense', 'div', 'App']), + ], + ], + [ + [ + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + expectedHash, + ], ], ); @@ -577,8 +568,8 @@ describe('ReactDOMFizzServer', () => { loggedErrors.push(x); return 'hash of (' + x.message + ')'; } - // const expectedHash = onError(theError); - // loggedErrors.length = 0; + const expectedHash = onError(theError); + loggedErrors.length = 0; function App({isClient}) { return ( @@ -605,8 +596,8 @@ describe('ReactDOMFizzServer', () => { const errors = []; // Attempt to hydrate the content. ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - errors.push(error); + onRecoverableError(error, errorInfo) { + errors.push({error, errorInfo}); }, }); Scheduler.unstable_flushAll(); @@ -630,9 +621,18 @@ describe('ReactDOMFizzServer', () => { expectErrors( errors, - [theError.message], [ - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + [ + theError.message, + expectedHash, + componentStack(['Suspense', 'div', 'App']), + ], + ], + [ + [ + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + expectedHash, + ], ], ); @@ -675,8 +675,8 @@ describe('ReactDOMFizzServer', () => { loggedErrors.push(x); return 'hash(' + x.message + ')'; } - // const expectedHash = onError(theError); - // loggedErrors.length = 0; + const expectedHash = onError(theError); + loggedErrors.length = 0; await act(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream( @@ -693,8 +693,8 @@ describe('ReactDOMFizzServer', () => { const errors = []; // Attempt to hydrate the content. ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - errors.push(error); + onRecoverableError(error, errorInfo) { + errors.push({error, errorInfo}); }, }); Scheduler.unstable_flushAll(); @@ -703,9 +703,18 @@ describe('ReactDOMFizzServer', () => { expectErrors( errors, - [theError.message], [ - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + [ + theError.message, + expectedHash, + componentStack(['Erroring', 'Suspense', 'div', 'App']), + ], + ], + [ + [ + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + expectedHash, + ], ], ); }); @@ -735,8 +744,8 @@ describe('ReactDOMFizzServer', () => { loggedErrors.push(x); return 'hash(' + x.message + ')'; } - // const expectedHash = onError(theError); - // loggedErrors.length = 0; + const expectedHash = onError(theError); + loggedErrors.length = 0; await act(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream( @@ -753,8 +762,8 @@ describe('ReactDOMFizzServer', () => { const errors = []; // Attempt to hydrate the content. ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - errors.push(error); + onRecoverableError(error, errorInfo) { + errors.push({error, errorInfo}); }, }); Scheduler.unstable_flushAll(); @@ -773,9 +782,18 @@ describe('ReactDOMFizzServer', () => { expectErrors( errors, - [theError.message], [ - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + [ + theError.message, + expectedHash, + componentStack(['Lazy', 'Suspense', 'div', 'App']), + ], + ], + [ + [ + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + expectedHash, + ], ], ); @@ -1053,9 +1071,10 @@ describe('ReactDOMFizzServer', () => { } const loggedErrors = []; + const expectedHash = 'Hash for Abort'; function onError(error) { loggedErrors.push(error); - return `Hash of (${error.message})`; + return expectedHash; } let controls; @@ -1069,8 +1088,8 @@ describe('ReactDOMFizzServer', () => { const errors = []; // Attempt to hydrate the content. ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - errors.push(error); + onRecoverableError(error, errorInfo) { + errors.push({error, errorInfo}); }, }); Scheduler.unstable_flushAll(); @@ -1087,9 +1106,12 @@ describe('ReactDOMFizzServer', () => { expect(Scheduler).toFlushAndYield([]); expectErrors( errors, - ['This Suspense boundary was aborted by the server'], + [['This Suspense boundary was aborted by the server', expectedHash]], [ - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + [ + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + expectedHash, + ], ], ); expect(getVisibleChildren(container)).toEqual(
Loading...
); @@ -1755,8 +1777,8 @@ describe('ReactDOMFizzServer', () => { loggedErrors.push(x); return `hash of (${x.message})`; } - // const expectedHash = onError(theError); - // loggedErrors.length = 0; + const expectedHash = onError(theError); + loggedErrors.length = 0; let controls; await act(async () => { @@ -1775,8 +1797,8 @@ describe('ReactDOMFizzServer', () => { const errors = []; // Attempt to hydrate the content. ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - errors.push(error); + onRecoverableError(error, errorInfo) { + errors.push({error, errorInfo}); }, }); Scheduler.unstable_flushAll(); @@ -1809,9 +1831,25 @@ describe('ReactDOMFizzServer', () => { expect(Scheduler).toFlushAndYield([]); expectErrors( errors, - [theError.message], [ - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + [ + theError.message, + expectedHash, + componentStack([ + 'AsyncText', + 'h1', + 'Suspense', + 'div', + 'Suspense', + 'App', + ]), + ], + ], + [ + [ + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + expectedHash, + ], ], ); @@ -3142,8 +3180,8 @@ describe('ReactDOMFizzServer', () => { loggedErrors.push(x); return x.message.replace('bad message', 'bad hash'); } - // const expectedHash = onError(theError); - // loggedErrors.length = 0; + const expectedHash = onError(theError); + loggedErrors.length = 0; await act(async () => { const {pipe} = ReactDOMFizzServer.renderToPipeableStream(, { @@ -3156,8 +3194,8 @@ describe('ReactDOMFizzServer', () => { const errors = []; ReactDOMClient.hydrateRoot(container, , { - onRecoverableError(error) { - errors.push(error); + onRecoverableError(error, errorInfo) { + errors.push({error, errorInfo}); }, }); expect(Scheduler).toFlushAndYield([]); @@ -3165,9 +3203,18 @@ describe('ReactDOMFizzServer', () => { // If escaping were not done we would get a message that says "bad hash" expectErrors( errors, - [theError.message], [ - 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + [ + theError.message, + expectedHash, + componentStack(['Erroring', 'Suspense', 'div', 'App']), + ], + ], + [ + [ + 'The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.', + expectedHash, + ], ], ); }); diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index fb7d4e66e91f3..58dab0f150889 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -731,27 +731,20 @@ export function isSuspenseInstanceFallback(instance: SuspenseInstance) { } export function getSuspenseInstanceFallbackErrorDetails( instance: SuspenseInstance, -) { +): {message?: string, stack?: string, hash?: string} { const nextSibling = instance.nextSibling; - let errorMessage /*, errorComponentStack, errorHash*/; if ( nextSibling && nextSibling.nodeType === ELEMENT_NODE && nextSibling.nodeName.toLowerCase() === 'template' ) { - const msg = ((nextSibling: any): HTMLTemplateElement).dataset.msg; - if (msg !== null) errorMessage = msg; - - // @TODO read and return hash and componentStack once we know how we are goign to - // expose this extra errorInfo to onRecoverableError - - // const hash = ((nextSibling: any): HTMLTemplateElement).dataset.hash; - // if (hash !== null) errorHash = hash; - - // const stack = ((nextSibling: any): HTMLTemplateElement).dataset.stack; - // if (stack !== null) errorComponentStack = stack; + return { + message: ((nextSibling: any): HTMLTemplateElement).dataset.msg, + stack: ((nextSibling: any): HTMLTemplateElement).dataset.stack, + hash: ((nextSibling: any): HTMLTemplateElement).dataset.hash, + }; } - return {errorMessage /*, errorComponentStack, errorHash*/}; + return {}; } export function registerSuspenseInstanceRetry( diff --git a/packages/react-reconciler/src/ReactCapturedValue.js b/packages/react-reconciler/src/ReactCapturedValue.js index 8b87eb9a6c1b3..82425f09e2556 100644 --- a/packages/react-reconciler/src/ReactCapturedValue.js +++ b/packages/react-reconciler/src/ReactCapturedValue.js @@ -15,9 +15,10 @@ export type CapturedValue = {| value: T, source: Fiber | null, stack: string | null, + hash: string | null, |}; -export function createCapturedValue( +export function createCapturedValueAtFiber( value: T, source: Fiber, ): CapturedValue { @@ -27,5 +28,19 @@ export function createCapturedValue( value, source, stack: getStackByFiberInDevAndProd(source), + hash: null, + }; +} + +export function createCapturedValue( + value: T, + hash?: string, + stack?: string, +): CapturedValue { + return { + value, + source: null, + stack: stack != null ? stack : null, + hash: hash != null ? hash : null, }; } diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index f1f5913b01433..5c9579599384e 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -236,7 +236,11 @@ import { } from './ReactFiberWorkLoop.new'; import {setWorkInProgressVersion} from './ReactMutableSource.new'; import {pushCacheProvider, CacheContext} from './ReactFiberCacheComponent.new'; -import {createCapturedValue} from './ReactCapturedValue'; +import { + createCapturedValue, + createCapturedValueAtFiber, + type CapturedValue, +} from './ReactCapturedValue'; import {createClassErrorUpdate} from './ReactFiberThrow.new'; import is from 'shared/objectIs'; import { @@ -1073,7 +1077,7 @@ function updateClassComponent( // Schedule the error boundary to re-render using updated state const update = createClassErrorUpdate( workInProgress, - createCapturedValue(error, workInProgress), + createCapturedValueAtFiber(error, workInProgress), lane, ); enqueueCapturedUpdate(workInProgress, update); @@ -1321,10 +1325,13 @@ function updateHostRoot(current, workInProgress, renderLanes) { if (workInProgress.flags & ForceClientRender) { // Something errored during a previous attempt to hydrate the shell, so we // forced a client render. - const recoverableError = new Error( - 'There was an error while hydrating. Because the error happened outside ' + - 'of a Suspense boundary, the entire root will switch to ' + - 'client rendering.', + const recoverableError = createCapturedValueAtFiber( + new Error( + 'There was an error while hydrating. Because the error happened outside ' + + 'of a Suspense boundary, the entire root will switch to ' + + 'client rendering.', + ), + workInProgress, ); return mountHostRootWithoutHydrating( current, @@ -1334,9 +1341,12 @@ function updateHostRoot(current, workInProgress, renderLanes) { recoverableError, ); } else if (nextChildren !== prevChildren) { - const recoverableError = new Error( - 'This root received an early update, before anything was able ' + - 'hydrate. Switched the entire root to client rendering.', + const recoverableError = createCapturedValueAtFiber( + new Error( + 'This root received an early update, before anything was able ' + + 'hydrate. Switched the entire root to client rendering.', + ), + workInProgress, ); return mountHostRootWithoutHydrating( current, @@ -1399,7 +1409,7 @@ function mountHostRootWithoutHydrating( workInProgress: Fiber, nextChildren: ReactNodeList, renderLanes: Lanes, - recoverableError: Error, + recoverableError: CapturedValue, ) { // Revert to client rendering. resetHydrationState(); @@ -2428,7 +2438,7 @@ function retrySuspenseComponentWithoutHydrating( current: Fiber, workInProgress: Fiber, renderLanes: Lanes, - recoverableError: Error | null, + recoverableError: CapturedValue | null, ) { // Falling back to client rendering. Because this has performance // implications, it's considered a recoverable error, even though the user @@ -2573,22 +2583,23 @@ function updateDehydratedSuspenseComponent( // This boundary is in a permanent fallback state. In this case, we'll never // get an update and we'll never be able to hydrate the final content. Let's just try the // client side render instead. - const {errorMessage} = getSuspenseInstanceFallbackErrorDetails( + const {message, hash, stack} = getSuspenseInstanceFallbackErrorDetails( suspenseInstance, ); - const error = errorMessage + const error = message ? // eslint-disable-next-line react-internal/prod-error-codes - new Error(errorMessage) + new Error(message) : new Error( 'The server could not finish this Suspense boundary, likely ' + 'due to an error during server rendering. Switched to ' + 'client rendering.', ); + const capturedValue = createCapturedValue(error, hash, stack); return retrySuspenseComponentWithoutHydrating( current, workInProgress, renderLanes, - error, + capturedValue, ); } @@ -2643,10 +2654,7 @@ function updateDehydratedSuspenseComponent( // skip hydration. // Delay having to do this as long as the suspense timeout allows us. renderDidSuspendDelayIfPossible(); - return retrySuspenseComponentWithoutHydrating( - current, - workInProgress, - renderLanes, + const capturedValue = createCapturedValue( new Error( 'This Suspense boundary received an update before it finished ' + 'hydrating. This caused the boundary to switch to client rendering. ' + @@ -2654,6 +2662,12 @@ function updateDehydratedSuspenseComponent( 'in startTransition.', ), ); + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes, + capturedValue, + ); } else if (isSuspenseInstancePending(suspenseInstance)) { // This component is still pending more data from the server, so we can't hydrate its // content. We treat it as if this component suspended itself. It might seem as if @@ -2700,15 +2714,18 @@ function updateDehydratedSuspenseComponent( if (workInProgress.flags & ForceClientRender) { // Something errored during hydration. Try again without hydrating. workInProgress.flags &= ~ForceClientRender; - return retrySuspenseComponentWithoutHydrating( - current, - workInProgress, - renderLanes, + const capturedValue = createCapturedValue( new Error( 'There was an error while hydrating this Suspense boundary. ' + 'Switched to client rendering.', ), ); + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes, + capturedValue, + ); } else if ((workInProgress.memoizedState: null | SuspenseState) !== null) { // Something suspended and we should still be in dehydrated mode. // Leave the existing child in place. diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 2303227185d2d..d1de936825229 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -236,7 +236,11 @@ import { } from './ReactFiberWorkLoop.old'; import {setWorkInProgressVersion} from './ReactMutableSource.old'; import {pushCacheProvider, CacheContext} from './ReactFiberCacheComponent.old'; -import {createCapturedValue} from './ReactCapturedValue'; +import { + createCapturedValue, + createCapturedValueAtFiber, + type CapturedValue, +} from './ReactCapturedValue'; import {createClassErrorUpdate} from './ReactFiberThrow.old'; import is from 'shared/objectIs'; import { @@ -1073,7 +1077,7 @@ function updateClassComponent( // Schedule the error boundary to re-render using updated state const update = createClassErrorUpdate( workInProgress, - createCapturedValue(error, workInProgress), + createCapturedValueAtFiber(error, workInProgress), lane, ); enqueueCapturedUpdate(workInProgress, update); @@ -1321,10 +1325,13 @@ function updateHostRoot(current, workInProgress, renderLanes) { if (workInProgress.flags & ForceClientRender) { // Something errored during a previous attempt to hydrate the shell, so we // forced a client render. - const recoverableError = new Error( - 'There was an error while hydrating. Because the error happened outside ' + - 'of a Suspense boundary, the entire root will switch to ' + - 'client rendering.', + const recoverableError = createCapturedValueAtFiber( + new Error( + 'There was an error while hydrating. Because the error happened outside ' + + 'of a Suspense boundary, the entire root will switch to ' + + 'client rendering.', + ), + workInProgress, ); return mountHostRootWithoutHydrating( current, @@ -1334,9 +1341,12 @@ function updateHostRoot(current, workInProgress, renderLanes) { recoverableError, ); } else if (nextChildren !== prevChildren) { - const recoverableError = new Error( - 'This root received an early update, before anything was able ' + - 'hydrate. Switched the entire root to client rendering.', + const recoverableError = createCapturedValueAtFiber( + new Error( + 'This root received an early update, before anything was able ' + + 'hydrate. Switched the entire root to client rendering.', + ), + workInProgress, ); return mountHostRootWithoutHydrating( current, @@ -1399,7 +1409,7 @@ function mountHostRootWithoutHydrating( workInProgress: Fiber, nextChildren: ReactNodeList, renderLanes: Lanes, - recoverableError: Error, + recoverableError: CapturedValue, ) { // Revert to client rendering. resetHydrationState(); @@ -2428,7 +2438,7 @@ function retrySuspenseComponentWithoutHydrating( current: Fiber, workInProgress: Fiber, renderLanes: Lanes, - recoverableError: Error | null, + recoverableError: CapturedValue | null, ) { // Falling back to client rendering. Because this has performance // implications, it's considered a recoverable error, even though the user @@ -2573,22 +2583,23 @@ function updateDehydratedSuspenseComponent( // This boundary is in a permanent fallback state. In this case, we'll never // get an update and we'll never be able to hydrate the final content. Let's just try the // client side render instead. - const {errorMessage} = getSuspenseInstanceFallbackErrorDetails( + const {message, hash, stack} = getSuspenseInstanceFallbackErrorDetails( suspenseInstance, ); - const error = errorMessage + const error = message ? // eslint-disable-next-line react-internal/prod-error-codes - new Error(errorMessage) + new Error(message) : new Error( 'The server could not finish this Suspense boundary, likely ' + 'due to an error during server rendering. Switched to ' + 'client rendering.', ); + const capturedValue = createCapturedValue(error, hash, stack); return retrySuspenseComponentWithoutHydrating( current, workInProgress, renderLanes, - error, + capturedValue, ); } @@ -2643,10 +2654,7 @@ function updateDehydratedSuspenseComponent( // skip hydration. // Delay having to do this as long as the suspense timeout allows us. renderDidSuspendDelayIfPossible(); - return retrySuspenseComponentWithoutHydrating( - current, - workInProgress, - renderLanes, + const capturedValue = createCapturedValue( new Error( 'This Suspense boundary received an update before it finished ' + 'hydrating. This caused the boundary to switch to client rendering. ' + @@ -2654,6 +2662,12 @@ function updateDehydratedSuspenseComponent( 'in startTransition.', ), ); + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes, + capturedValue, + ); } else if (isSuspenseInstancePending(suspenseInstance)) { // This component is still pending more data from the server, so we can't hydrate its // content. We treat it as if this component suspended itself. It might seem as if @@ -2700,15 +2714,18 @@ function updateDehydratedSuspenseComponent( if (workInProgress.flags & ForceClientRender) { // Something errored during hydration. Try again without hydrating. workInProgress.flags &= ~ForceClientRender; - return retrySuspenseComponentWithoutHydrating( - current, - workInProgress, - renderLanes, + const capturedValue = createCapturedValue( new Error( 'There was an error while hydrating this Suspense boundary. ' + 'Switched to client rendering.', ), ); + return retrySuspenseComponentWithoutHydrating( + current, + workInProgress, + renderLanes, + capturedValue, + ); } else if ((workInProgress.memoizedState: null | SuspenseState) !== null) { // Something suspended and we should still be in dehydrated mode. // Leave the existing child in place. diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index 636a467475df4..fc18efab37169 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -19,6 +19,7 @@ import type { } from './ReactFiberHostConfig'; import type {SuspenseState} from './ReactFiberSuspenseComponent.new'; import type {TreeContext} from './ReactFiberTreeContext.new'; +import type {CapturedValue} from './ReactCapturedValue'; import { HostComponent, @@ -86,7 +87,7 @@ let isHydrating: boolean = false; let didSuspendOrErrorDEV: boolean = false; // Hydration errors that were thrown inside this boundary -let hydrationErrors: Array | null = null; +let hydrationErrors: Array> | null = null; function warnIfHydrating() { if (__DEV__) { @@ -680,7 +681,7 @@ function getIsHydrating(): boolean { return isHydrating; } -export function queueHydrationError(error: mixed): void { +export function queueHydrationError(error: CapturedValue): void { if (hydrationErrors === null) { hydrationErrors = [error]; } else { diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index 3befb348c05ab..099b02fbcecc3 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -19,6 +19,7 @@ import type { } from './ReactFiberHostConfig'; import type {SuspenseState} from './ReactFiberSuspenseComponent.old'; import type {TreeContext} from './ReactFiberTreeContext.old'; +import type {CapturedValue} from './ReactCapturedValue'; import { HostComponent, @@ -86,7 +87,7 @@ let isHydrating: boolean = false; let didSuspendOrErrorDEV: boolean = false; // Hydration errors that were thrown inside this boundary -let hydrationErrors: Array | null = null; +let hydrationErrors: Array> | null = null; function warnIfHydrating() { if (__DEV__) { @@ -680,7 +681,7 @@ function getIsHydrating(): boolean { return isHydrating; } -export function queueHydrationError(error: mixed): void { +export function queueHydrationError(error: CapturedValue): void { if (hydrationErrors === null) { hydrationErrors = [error]; } else { diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index 9a3d1b619235d..47e2673128c15 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -41,7 +41,7 @@ import { enableLazyContextPropagation, enableUpdaterTracking, } from 'shared/ReactFeatureFlags'; -import {createCapturedValue} from './ReactCapturedValue'; +import {createCapturedValueAtFiber} from './ReactCapturedValue'; import { enqueueCapturedUpdate, createUpdate, @@ -517,7 +517,7 @@ function throwException( // Even though the user may not be affected by this error, we should // still log it so it can be fixed. - queueHydrationError(value); + queueHydrationError(createCapturedValueAtFiber(value, sourceFiber)); return; } } else { @@ -525,12 +525,12 @@ function throwException( } } + value = createCapturedValueAtFiber(value, sourceFiber); + renderDidError(value); + // 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(value); - - value = createCapturedValue(value, sourceFiber); let workInProgress = returnFiber; do { switch (workInProgress.tag) { diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index 66f91a69d343e..a14ab09eb0e08 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -41,7 +41,7 @@ import { enableLazyContextPropagation, enableUpdaterTracking, } from 'shared/ReactFeatureFlags'; -import {createCapturedValue} from './ReactCapturedValue'; +import {createCapturedValueAtFiber} from './ReactCapturedValue'; import { enqueueCapturedUpdate, createUpdate, @@ -517,7 +517,7 @@ function throwException( // Even though the user may not be affected by this error, we should // still log it so it can be fixed. - queueHydrationError(value); + queueHydrationError(createCapturedValueAtFiber(value, sourceFiber)); return; } } else { @@ -525,12 +525,12 @@ function throwException( } } + value = createCapturedValueAtFiber(value, sourceFiber); + renderDidError(value); + // 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(value); - - value = createCapturedValue(value, sourceFiber); let workInProgress = returnFiber; do { switch (workInProgress.tag) { diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js index 6e438aaa7bb60..8b6fff00626a3 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.new.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.new.js @@ -189,7 +189,10 @@ import { ContextOnlyDispatcher, getIsUpdatingOpaqueValueInRenderPhaseInDEV, } from './ReactFiberHooks.new'; -import {createCapturedValue} from './ReactCapturedValue'; +import { + createCapturedValueAtFiber, + type CapturedValue, +} from './ReactCapturedValue'; import { push as pushToStack, pop as popFromStack, @@ -312,10 +315,14 @@ let workInProgressRootRenderPhaseUpdatedLanes: Lanes = NoLanes; // Lanes that were pinged (in an interleaved event) during this render. let workInProgressRootPingedLanes: Lanes = NoLanes; // Errors that are thrown during the render phase. -let workInProgressRootConcurrentErrors: Array | null = null; +let workInProgressRootConcurrentErrors: Array< + CapturedValue, +> | null = null; // These are errors that we recovered from without surfacing them to the UI. // We will log them once the tree commits. -let workInProgressRootRecoverableErrors: Array | null = null; +let workInProgressRootRecoverableErrors: Array< + CapturedValue, +> | 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. @@ -1065,7 +1072,7 @@ function recoverFromConcurrentError(root, errorRetryLanes) { return exitStatus; } -export function queueRecoverableErrors(errors: Array) { +export function queueRecoverableErrors(errors: Array>) { if (workInProgressRootRecoverableErrors === null) { workInProgressRootRecoverableErrors = errors; } else { @@ -1696,7 +1703,7 @@ export function renderDidSuspendDelayIfPossible(): void { } } -export function renderDidError(error: mixed) { +export function renderDidError(error: CapturedValue) { if (workInProgressRootExitStatus !== RootSuspendedWithDelay) { workInProgressRootExitStatus = RootErrored; } @@ -2017,7 +2024,7 @@ function completeUnitOfWork(unitOfWork: Fiber): void { function commitRoot( root: FiberRoot, - recoverableErrors: null | Array, + recoverableErrors: null | Array>, transitions: Array | null, ) { // TODO: This no longer makes any sense. We already wrap the mutation and @@ -2044,7 +2051,7 @@ function commitRoot( function commitRootImpl( root: FiberRoot, - recoverableErrors: null | Array, + recoverableErrors: null | Array>, transitions: Array | null, renderPriorityLevel: EventPriority, ) { @@ -2335,7 +2342,9 @@ function commitRootImpl( const onRecoverableError = root.onRecoverableError; for (let i = 0; i < recoverableErrors.length; i++) { const recoverableError = recoverableErrors[i]; - onRecoverableError(recoverableError); + const componentStack = recoverableError.stack; + const errorHash = recoverableError.hash; + onRecoverableError(recoverableError.value, {componentStack, errorHash}); } } @@ -2615,7 +2624,7 @@ function captureCommitPhaseErrorOnRoot( sourceFiber: Fiber, error: mixed, ) { - const errorInfo = createCapturedValue(error, sourceFiber); + const errorInfo = createCapturedValueAtFiber(error, sourceFiber); const update = createRootErrorUpdate(rootFiber, errorInfo, (SyncLane: Lane)); enqueueUpdate(rootFiber, update, (SyncLane: Lane)); const eventTime = requestEventTime(); @@ -2661,7 +2670,7 @@ export function captureCommitPhaseError( (typeof instance.componentDidCatch === 'function' && !isAlreadyFailedLegacyErrorBoundary(instance)) ) { - const errorInfo = createCapturedValue(error, sourceFiber); + const errorInfo = createCapturedValueAtFiber(error, sourceFiber); const update = createClassErrorUpdate( fiber, errorInfo, diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 3e02a4fe37697..3f930d8b41b83 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -189,7 +189,10 @@ import { ContextOnlyDispatcher, getIsUpdatingOpaqueValueInRenderPhaseInDEV, } from './ReactFiberHooks.old'; -import {createCapturedValue} from './ReactCapturedValue'; +import { + createCapturedValueAtFiber, + type CapturedValue, +} from './ReactCapturedValue'; import { push as pushToStack, pop as popFromStack, @@ -312,10 +315,14 @@ let workInProgressRootRenderPhaseUpdatedLanes: Lanes = NoLanes; // Lanes that were pinged (in an interleaved event) during this render. let workInProgressRootPingedLanes: Lanes = NoLanes; // Errors that are thrown during the render phase. -let workInProgressRootConcurrentErrors: Array | null = null; +let workInProgressRootConcurrentErrors: Array< + CapturedValue, +> | null = null; // These are errors that we recovered from without surfacing them to the UI. // We will log them once the tree commits. -let workInProgressRootRecoverableErrors: Array | null = null; +let workInProgressRootRecoverableErrors: Array< + CapturedValue, +> | 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. @@ -1065,7 +1072,7 @@ function recoverFromConcurrentError(root, errorRetryLanes) { return exitStatus; } -export function queueRecoverableErrors(errors: Array) { +export function queueRecoverableErrors(errors: Array>) { if (workInProgressRootRecoverableErrors === null) { workInProgressRootRecoverableErrors = errors; } else { @@ -1696,7 +1703,7 @@ export function renderDidSuspendDelayIfPossible(): void { } } -export function renderDidError(error: mixed) { +export function renderDidError(error: CapturedValue) { if (workInProgressRootExitStatus !== RootSuspendedWithDelay) { workInProgressRootExitStatus = RootErrored; } @@ -2017,7 +2024,7 @@ function completeUnitOfWork(unitOfWork: Fiber): void { function commitRoot( root: FiberRoot, - recoverableErrors: null | Array, + recoverableErrors: null | Array>, transitions: Array | null, ) { // TODO: This no longer makes any sense. We already wrap the mutation and @@ -2044,7 +2051,7 @@ function commitRoot( function commitRootImpl( root: FiberRoot, - recoverableErrors: null | Array, + recoverableErrors: null | Array>, transitions: Array | null, renderPriorityLevel: EventPriority, ) { @@ -2335,7 +2342,9 @@ function commitRootImpl( const onRecoverableError = root.onRecoverableError; for (let i = 0; i < recoverableErrors.length; i++) { const recoverableError = recoverableErrors[i]; - onRecoverableError(recoverableError); + const componentStack = recoverableError.stack; + const errorHash = recoverableError.hash; + onRecoverableError(recoverableError.value, {componentStack, errorHash}); } } @@ -2615,7 +2624,7 @@ function captureCommitPhaseErrorOnRoot( sourceFiber: Fiber, error: mixed, ) { - const errorInfo = createCapturedValue(error, sourceFiber); + const errorInfo = createCapturedValueAtFiber(error, sourceFiber); const update = createRootErrorUpdate(rootFiber, errorInfo, (SyncLane: Lane)); enqueueUpdate(rootFiber, update, (SyncLane: Lane)); const eventTime = requestEventTime(); @@ -2661,7 +2670,7 @@ export function captureCommitPhaseError( (typeof instance.componentDidCatch === 'function' && !isAlreadyFailedLegacyErrorBoundary(instance)) ) { - const errorInfo = createCapturedValue(error, sourceFiber); + const errorInfo = createCapturedValueAtFiber(error, sourceFiber); const update = createClassErrorUpdate( fiber, errorInfo, diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 319bbc1c337dd..8e552aaf4c046 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -247,7 +247,10 @@ type BaseFiberRootProperties = {| // a reference to. identifierPrefix: string, - onRecoverableError: (error: mixed) => void, + onRecoverableError: ( + error: mixed, + errorInfo: {errorHash?: ?string, componentStack?: ?string}, + ) => void, |}; // The following attributes are only used by DevTools and are only present in DEV builds.