From 8ceda74993f79629968662259875651b502d6e56 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 3 Apr 2024 11:43:39 -0400 Subject: [PATCH 1/7] Throw special marker exception when we have a hydration mismatch We log it as recoverable in a side-channel. --- .../src/ReactFiberHydrationContext.js | 11 ++++- .../react-reconciler/src/ReactFiberThrow.js | 41 +++++++++++++------ .../src/ReactFiberWorkLoop.js | 5 ++- scripts/error-codes/codes.json | 3 +- 4 files changed, 45 insertions(+), 15 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index e3fe81641ced6..1d2f69852a9db 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -29,6 +29,8 @@ import { } from './ReactWorkTags'; import {favorSafetyOverHydrationPerf} from 'shared/ReactFeatureFlags'; +import {createCapturedValueAtFiber} from './ReactCapturedValue'; + import {createFiberFromDehydratedFragment} from './ReactFiber'; import { shouldSetTextContent, @@ -297,6 +299,11 @@ function tryHydrateSuspense(fiber: Fiber, nextInstance: any) { return false; } +export const HydrationMismatchException: mixed = new Error( + 'Hydration Mismatch Exception: This is not a real error, and should not leak into ' + + "userspace. If you're seeing this, it's likely a bug in React.", +); + function throwOnHydrationMismatch(fiber: Fiber) { let diff = ''; if (__DEV__) { @@ -308,7 +315,7 @@ function throwOnHydrationMismatch(fiber: Fiber) { diff = describeDiff(diffRoot); } } - throw new Error( + const error = new Error( "Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:\n" + '\n' + "- A server/client branch `if (typeof window !== 'undefined')`.\n" + @@ -322,6 +329,8 @@ function throwOnHydrationMismatch(fiber: Fiber) { 'https://react.dev/link/hydration-mismatch' + diff, ); + queueHydrationError(createCapturedValueAtFiber(error, fiber)); + throw HydrationMismatchException; } function claimHydratableSingleton(fiber: Fiber): void { diff --git a/packages/react-reconciler/src/ReactFiberThrow.js b/packages/react-reconciler/src/ReactFiberThrow.js index fbad2f2acfe6c..90ad965d257e8 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.js +++ b/packages/react-reconciler/src/ReactFiberThrow.js @@ -60,6 +60,7 @@ import { } from './ReactFiberSuspenseContext'; import { renderDidError, + queueConcurrentError, renderDidSuspendDelayIfPossible, markLegacyErrorBoundaryAsFailed, isAlreadyFailedLegacyErrorBoundary, @@ -81,6 +82,7 @@ import { getIsHydrating, markDidThrowWhileHydratingDEV, queueHydrationError, + HydrationMismatchException, } from './ReactFiberHydrationContext'; import {ConcurrentRoot} from './ReactRootTags'; import {noopSuspenseyCommitThenable} from './ReactFiberThenable'; @@ -556,15 +558,39 @@ function throwException( // Even though the user may not be affected by this error, we should // still log it so it can be fixed. - queueHydrationError(createCapturedValueAtFiber(value, sourceFiber)); + if (value !== HydrationMismatchException) { + queueHydrationError(createCapturedValueAtFiber(value, sourceFiber)); + } + return false; + } else { + const rootErrorInfo = createCapturedValueAtFiber(value, sourceFiber); + if (value !== HydrationMismatchException) { + // This is a concurrent error that then becomes a hydration error when + // we retry the root. + queueConcurrentError(rootErrorInfo); + } + // Schedule an update at the root to log the error but this shouldn't + // actually happen because we should recover. + const workInProgress: Fiber = (root.current: any).alternate; + workInProgress.flags |= ShouldCapture; + const lane = pickArbitraryLane(rootRenderLanes); + workInProgress.lanes = mergeLanes(workInProgress.lanes, lane); + const update = createRootErrorUpdate( + workInProgress.stateNode, + rootErrorInfo, // This should never actually get logged due to the recovery. + lane, + ); + enqueueCapturedUpdate(workInProgress, update); + renderDidError(); return false; } } else { // Otherwise, fall through to the error path. } - value = createCapturedValueAtFiber(value, sourceFiber); - renderDidError(value); + const errorInfo = createCapturedValueAtFiber(value, sourceFiber); + queueConcurrentError(errorInfo); + renderDidError(); // 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 @@ -580,7 +606,6 @@ function throwException( do { switch (workInProgress.tag) { case HostRoot: { - const errorInfo = value; workInProgress.flags |= ShouldCapture; const lane = pickArbitraryLane(rootRenderLanes); workInProgress.lanes = mergeLanes(workInProgress.lanes, lane); @@ -593,15 +618,7 @@ function throwException( return false; } case ClassComponent: - if (getIsHydrating() && sourceFiber.mode & ConcurrentMode) { - // If we're hydrating and got here, it means that we didn't find a suspense - // boundary above so it's a root error. In this case we shouldn't let the - // error boundary capture it because it'll just try to hydrate the error state. - // Instead we let it bubble to the root and let the recover pass handle it. - break; - } // Capture and retry - const errorInfo = value; const ctor = workInProgress.type; const instance = workInProgress.stateNode; if ( diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 36182d999403b..41d9bcdeaf8bf 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -1933,10 +1933,13 @@ export function renderDidSuspendDelayIfPossible(): void { } } -export function renderDidError(error: CapturedValue) { +export function renderDidError() { if (workInProgressRootExitStatus !== RootSuspendedWithDelay) { workInProgressRootExitStatus = RootErrored; } +} + +export function queueConcurrentError(error: CapturedValue) { if (workInProgressRootConcurrentErrors === null) { workInProgressRootConcurrentErrors = [error]; } else { diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index cc1985051339a..2e7b4f95c09a8 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -503,5 +503,6 @@ "515": "Cannot assign to a temporary client reference from a server module.", "516": "Attempted to call a temporary Client Reference from the server but it is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.", "517": "Symbols cannot be passed to a Server Function without a temporary reference set. Pass a TemporaryReferenceSet to the options.%s", - "518": "Saw multiple hydration diff roots in a pass. This is a bug in React." + "518": "Saw multiple hydration diff roots in a pass. This is a bug in React.", + "519": "Hydration Mismatch Exception: This is not a real error, and should not leak into userspace. If you're seeing this, it's likely a bug in React." } From 2bbf7da6ae52d6a3092f3bdb13292f647601237b Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 3 Apr 2024 13:38:49 -0400 Subject: [PATCH 2/7] Move the appendix for errors causing client rendering to the throw phase This way they're associated with the error that was thrown. This means that if there's more than one, there's multiple appendix for each one. However, since we're now unwinding early this doesn't actually happen in practice. --- .../src/ReactCapturedValue.js | 2 +- .../src/ReactFiberBeginWork.js | 43 +++---------------- .../react-reconciler/src/ReactFiberThrow.js | 25 +++++++++-- 3 files changed, 28 insertions(+), 42 deletions(-) diff --git a/packages/react-reconciler/src/ReactCapturedValue.js b/packages/react-reconciler/src/ReactCapturedValue.js index 2b34651653bde..6ac8e235ccc04 100644 --- a/packages/react-reconciler/src/ReactCapturedValue.js +++ b/packages/react-reconciler/src/ReactCapturedValue.js @@ -13,7 +13,7 @@ import {getStackByFiberInDevAndProd} from './ReactFiberComponentStack'; const CapturedStacks: WeakMap = new WeakMap(); -export type CapturedValue = { +export type CapturedValue<+T> = { +value: T, source: Fiber | null, stack: string | null, diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 8c7ba485856f2..a3b90d41de424 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -269,7 +269,6 @@ import {pushCacheProvider, CacheContext} from './ReactFiberCacheComponent'; import { createCapturedValueFromError, createCapturedValueAtFiber, - type CapturedValue, } from './ReactCapturedValue'; import { createClassErrorUpdate, @@ -1500,21 +1499,12 @@ function updateHostRoot( if (workInProgress.flags & ForceClientRender) { // Something errored during a previous attempt to hydrate the shell, so we - // forced a client render. - 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, - ); + // forced a client render. We should have a recoverable error already scheduled. return mountHostRootWithoutHydrating( current, workInProgress, nextChildren, renderLanes, - recoverableError, ); } else if (nextChildren !== prevChildren) { const recoverableError = createCapturedValueAtFiber( @@ -1524,12 +1514,12 @@ function updateHostRoot( ), workInProgress, ); + queueHydrationError(recoverableError); return mountHostRootWithoutHydrating( current, workInProgress, nextChildren, renderLanes, - recoverableError, ); } else { // The outermost shell has not hydrated yet. Start hydrating. @@ -1572,13 +1562,10 @@ function mountHostRootWithoutHydrating( workInProgress: Fiber, nextChildren: ReactNodeList, renderLanes: Lanes, - recoverableError: CapturedValue, ) { // Revert to client rendering. resetHydrationState(); - queueHydrationError(recoverableError); - workInProgress.flags |= ForceClientRender; reconcileChildren(current, workInProgress, nextChildren, renderLanes); @@ -2553,18 +2540,10 @@ function retrySuspenseComponentWithoutHydrating( current: Fiber, workInProgress: Fiber, renderLanes: Lanes, - recoverableError: CapturedValue | null, ) { // Falling back to client rendering. Because this has performance // implications, it's considered a recoverable error, even though the user // likely won't observe anything wrong with the UI. - // - // The error is passed in as an argument to enforce that every caller provide - // a custom message, or explicitly opt out (currently the only path that opts - // out is legacy mode; every concurrent path provides an error). - if (recoverableError !== null) { - queueHydrationError(recoverableError); - } // This will add the old fiber to the deletion list reconcileChildFibers(workInProgress, current.child, null, renderLanes); @@ -2688,10 +2667,9 @@ function updateDehydratedSuspenseComponent( ({digest} = getSuspenseInstanceFallbackErrorDetails(suspenseInstance)); } - let capturedValue = null; // TODO: Figure out a better signal than encoding a magic digest value. if (!enablePostpone || digest !== 'POSTPONE') { - let error; + let error: Error; if (__DEV__ && message) { // eslint-disable-next-line react-internal/prod-error-codes error = new Error(message); @@ -2705,16 +2683,16 @@ function updateDehydratedSuspenseComponent( // Replace the stack with the server stack error.stack = (__DEV__ && stack) || ''; (error: any).digest = digest; - capturedValue = createCapturedValueFromError( + const capturedValue = createCapturedValueFromError( error, componentStack === undefined ? null : componentStack, ); + queueHydrationError(capturedValue); } return retrySuspenseComponentWithoutHydrating( current, workInProgress, renderLanes, - capturedValue, ); } @@ -2795,7 +2773,6 @@ function updateDehydratedSuspenseComponent( current, workInProgress, renderLanes, - null, ); } else if (isSuspenseInstancePending(suspenseInstance)) { // This component is still pending more data from the server, so we can't hydrate its @@ -2842,21 +2819,13 @@ function updateDehydratedSuspenseComponent( if (workInProgress.flags & ForceClientRender) { // Something errored during hydration. Try again without hydrating. + // The error should've already been logged in throwException. pushPrimaryTreeSuspenseHandler(workInProgress); - workInProgress.flags &= ~ForceClientRender; - const capturedValue = createCapturedValueFromError( - new Error( - 'There was an error while hydrating this Suspense boundary. ' + - 'Switched to client rendering.', - ), - null, - ); return retrySuspenseComponentWithoutHydrating( current, workInProgress, renderLanes, - capturedValue, ); } else if ((workInProgress.memoizedState: null | SuspenseState) !== null) { // Something suspended and we should still be in dehydrated mode. diff --git a/packages/react-reconciler/src/ReactFiberThrow.js b/packages/react-reconciler/src/ReactFiberThrow.js index 90ad965d257e8..fde7c33954517 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.js +++ b/packages/react-reconciler/src/ReactFiberThrow.js @@ -561,17 +561,34 @@ function throwException( if (value !== HydrationMismatchException) { queueHydrationError(createCapturedValueAtFiber(value, sourceFiber)); } + const appendix = createCapturedValueAtFiber( + new Error( + 'There was an error while hydrating this Suspense boundary. ' + + 'Switched to client rendering.', + ), + suspenseBoundary, + ); + queueHydrationError(appendix); return false; } else { const rootErrorInfo = createCapturedValueAtFiber(value, sourceFiber); if (value !== HydrationMismatchException) { - // This is a concurrent error that then becomes a hydration error when - // we retry the root. - queueConcurrentError(rootErrorInfo); + queueHydrationError(rootErrorInfo); } + const workInProgress: Fiber = (root.current: any).alternate; + + const appendix = 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, + ); + queueHydrationError(appendix); + // Schedule an update at the root to log the error but this shouldn't // actually happen because we should recover. - const workInProgress: Fiber = (root.current: any).alternate; workInProgress.flags |= ShouldCapture; const lane = pickArbitraryLane(rootRenderLanes); workInProgress.lanes = mergeLanes(workInProgress.lanes, lane); From 1b2989369d16e5f4862422935f1ca7c3e25b29bd Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 3 Apr 2024 13:48:00 -0400 Subject: [PATCH 3/7] Use a wrapper error with a "cause" instead of an extra appendix error This makes it a little easier to group and display as a single error in UIs. The error is that it caused client rendering - the cause is the other error. We also exclude the appendix when it's a hydration mismatch since that has its own description of what happened and no other cause. --- .../react-reconciler/src/ReactFiberThrow.js | 39 ++++++++----------- scripts/error-codes/codes.json | 4 +- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberThrow.js b/packages/react-reconciler/src/ReactFiberThrow.js index fde7c33954517..c67ad951fc92f 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.js +++ b/packages/react-reconciler/src/ReactFiberThrow.js @@ -559,39 +559,34 @@ function throwException( // Even though the user may not be affected by this error, we should // still log it so it can be fixed. if (value !== HydrationMismatchException) { - queueHydrationError(createCapturedValueAtFiber(value, sourceFiber)); + const wrapperError = new Error( + 'There was an error while hydrating but React was able to recover by ' + + 'instead client rendering from the nearest Suspense boundary.', + {cause: value}, + ); + queueHydrationError( + createCapturedValueAtFiber(wrapperError, sourceFiber), + ); } - const appendix = createCapturedValueAtFiber( - new Error( - 'There was an error while hydrating this Suspense boundary. ' + - 'Switched to client rendering.', - ), - suspenseBoundary, - ); - queueHydrationError(appendix); return false; } else { - const rootErrorInfo = createCapturedValueAtFiber(value, sourceFiber); if (value !== HydrationMismatchException) { - queueHydrationError(rootErrorInfo); + const wrapperError = new Error( + 'There was an error while hydrating but React was able to recover by ' + + 'instead client rendering the entire root.', + {cause: value}, + ); + queueHydrationError( + createCapturedValueAtFiber(wrapperError, sourceFiber), + ); } const workInProgress: Fiber = (root.current: any).alternate; - - const appendix = 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, - ); - queueHydrationError(appendix); - // Schedule an update at the root to log the error but this shouldn't // actually happen because we should recover. workInProgress.flags |= ShouldCapture; const lane = pickArbitraryLane(rootRenderLanes); workInProgress.lanes = mergeLanes(workInProgress.lanes, lane); + const rootErrorInfo = createCapturedValueAtFiber(value, sourceFiber); const update = createRootErrorUpdate( workInProgress.stateNode, rootErrorInfo, // This should never actually get logged due to the recovery. diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index 2e7b4f95c09a8..d218b97a44878 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -407,8 +407,8 @@ "419": "The server could not finish this Suspense boundary, likely due to an error during server rendering. Switched to client rendering.", "420": "ServerContext: %s already defined", "421": "This Suspense boundary received an update before it finished hydrating. This caused the boundary to switch to client rendering. The usual way to fix this is to wrap the original update in startTransition.", - "422": "There was an error while hydrating this Suspense boundary. Switched to client rendering.", - "423": "There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.", + "422": "There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.", + "423": "There was an error while hydrating but React was able to recover by instead client rendering the entire root.", "424": "This root received an early update, before anything was able hydrate. Switched the entire root to client rendering.", "425": "Text content does not match server-rendered HTML.", "426": "A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading indicator. To fix, updates that suspend should be wrapped with startTransition.", From 6c52d4d78a051bcbd04ec9922ad0825fc3ebe103 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 3 Apr 2024 14:25:12 -0400 Subject: [PATCH 4/7] Preserve additional arguments passed to Error constructor in prod --- .../__snapshots__/transform-error-messages.js.snap | 7 +++++++ scripts/error-codes/__tests__/transform-error-messages.js | 8 ++++++++ scripts/error-codes/transform-error-messages.js | 5 ++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/scripts/error-codes/__tests__/__snapshots__/transform-error-messages.js.snap b/scripts/error-codes/__tests__/__snapshots__/transform-error-messages.js.snap index 878b92da0aee3..a531f008bebab 100644 --- a/scripts/error-codes/__tests__/__snapshots__/transform-error-messages.js.snap +++ b/scripts/error-codes/__tests__/__snapshots__/transform-error-messages.js.snap @@ -61,6 +61,13 @@ exports[`error transform should support error constructors with concatenated mes Error(_formatProdErrorMessage(7, foo, bar));" `; +exports[`error transform should support extra arguments to error constructor 1`] = ` +"import _formatProdErrorMessage from "shared/formatProdErrorMessage"; +Error(_formatProdErrorMessage(7, foo, bar), { + cause: error +});" +`; + exports[`error transform should support interpolating arguments with concatenation 1`] = ` "import _formatProdErrorMessage from "shared/formatProdErrorMessage"; Error(_formatProdErrorMessage(7, foo, bar));" diff --git a/scripts/error-codes/__tests__/transform-error-messages.js b/scripts/error-codes/__tests__/transform-error-messages.js index 1586e7dfbf8ed..f822e3d492e2c 100644 --- a/scripts/error-codes/__tests__/transform-error-messages.js +++ b/scripts/error-codes/__tests__/transform-error-messages.js @@ -154,6 +154,14 @@ let val = (a, // eslint-disable-next-line react-internal/prod-error-codes (b, new Error('foo'))); +`) + ).toMatchSnapshot(); + }); + + it('should support extra arguments to error constructor', () => { + expect( + transform(` +new Error(\`Expected \${foo} target to \` + \`be an array; got \${bar}\`, {cause: error}); `) ).toMatchSnapshot(); }); diff --git a/scripts/error-codes/transform-error-messages.js b/scripts/error-codes/transform-error-messages.js index 96eff7ed5fca2..234224b89b1c1 100644 --- a/scripts/error-codes/transform-error-messages.js +++ b/scripts/error-codes/transform-error-messages.js @@ -122,7 +122,10 @@ module.exports = function (babel) { // Outputs: // Error(formatProdErrorMessage(ERR_CODE, adj, noun)); - const newErrorCall = t.callExpression(t.identifier('Error'), [prodMessage]); + const newErrorCall = t.callExpression(t.identifier('Error'), [ + prodMessage, + ...node.arguments.slice(1), + ]); newErrorCall[SEEN_SYMBOL] = true; path.replaceWith(newErrorCall); } From dc9bd13b51ee25f40d38ef893743b30c6b9a3135 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 3 Apr 2024 15:36:00 -0400 Subject: [PATCH 5/7] Update tests --- .../src/__tests__/ReactDOMFizzServer-test.js | 169 +++++++++------- .../ReactDOMFizzShellHydration-test.js | 13 +- ...actDOMFizzSuppressHydrationWarning-test.js | 81 +++++--- .../__tests__/ReactDOMHydrationDiff-test.js | 79 +++----- ...DOMServerPartialHydration-test.internal.js | 188 ++++++++++++------ .../ReactDOMSingletonComponents-test.js | 1 - .../src/__tests__/ReactRenderDocument-test.js | 34 ++-- 7 files changed, 340 insertions(+), 225 deletions(-) diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js index 6064ad9312e87..2c61eea519921 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzServer-test.js @@ -2417,17 +2417,17 @@ describe('ReactDOMFizzServer', () => { ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log( - 'Log recoverable error: ' + normalizeError(error.message), - ); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); // The first paint switches to client rendering due to mismatch await waitForPaint([ 'client', - "Log recoverable error: Hydration failed because the server rendered HTML didn't match the client.", - 'Log recoverable error: There was an error while hydrating.', + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); expect(getVisibleChildren(container)).toEqual(
client
); }); @@ -2489,9 +2489,7 @@ describe('ReactDOMFizzServer', () => { ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log( - 'Log recoverable error: ' + normalizeError(error.message), - ); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); }, }); @@ -2499,8 +2497,7 @@ describe('ReactDOMFizzServer', () => { // The first paint switches to client rendering due to mismatch await waitForPaint([ 'client', - "Log recoverable error: Hydration failed because the server rendered HTML didn't match the client.", - 'Log recoverable error: There was an error while hydrating.', + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); expect(getVisibleChildren(container)).toEqual(
client
); }); @@ -2561,7 +2558,10 @@ describe('ReactDOMFizzServer', () => { isClient = true; ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); @@ -2569,8 +2569,8 @@ describe('ReactDOMFizzServer', () => { // to client rendering. await waitForAll([ 'Yay!', - 'Hydration error', - 'There was an error while hydrating.', + 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering the entire root.', + 'Cause: Hydration error', ]); expect(getVisibleChildren(container)).toEqual(Yay!); @@ -2736,7 +2736,10 @@ describe('ReactDOMFizzServer', () => { isClient = true; ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); @@ -2744,8 +2747,8 @@ describe('ReactDOMFizzServer', () => { // to client rendering. await waitForAll([ 'Yay!', - 'Hydration error', - 'There was an error while hydrating this Suspense boundary.', + 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.', + 'Cause: Hydration error', ]); expect(getVisibleChildren(container)).toEqual(
@@ -2884,7 +2887,10 @@ describe('ReactDOMFizzServer', () => { isClient = true; ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log('[c!] ' + error.message); + Scheduler.log('onRecoverableError: ' + error.message); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); // This should not report any errors yet. @@ -2908,7 +2914,7 @@ describe('ReactDOMFizzServer', () => { }); await waitForAll([ 'Yay!', - '[c!] The server could not finish this Suspense boundary, ' + + 'onRecoverableError: The server could not finish this Suspense boundary, ' + 'likely due to an error during server rendering. ' + 'Switched to client rendering.', ]); @@ -2969,7 +2975,10 @@ describe('ReactDOMFizzServer', () => { isClient = true; const root = ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log('[c!] ' + error.message); + Scheduler.log('onRecoverableError: ' + error.message); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); // This should not report any errors yet. @@ -3002,7 +3011,7 @@ describe('ReactDOMFizzServer', () => { }); await waitForAll([ 'Yay! (red)', - '[c!] The server could not finish this Suspense boundary, ' + + 'onRecoverableError: The server could not finish this Suspense boundary, ' + 'likely due to an error during server rendering. ' + 'Switched to client rendering.', 'Yay! (blue)', @@ -3072,7 +3081,10 @@ describe('ReactDOMFizzServer', () => { , { onRecoverableError(error) { - Scheduler.log('[c!] ' + error.message); + Scheduler.log('onRecoverableError: ' + error.message); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }, ); @@ -3097,7 +3109,7 @@ describe('ReactDOMFizzServer', () => { await waitForAll([]); jest.runAllTimers(); assertLog([ - '[c!] The server could not finish this Suspense boundary, ' + + 'onRecoverableError: The server could not finish this Suspense boundary, ' + 'likely due to an error during server rendering. ' + 'Switched to client rendering.', ]); @@ -3191,15 +3203,18 @@ describe('ReactDOMFizzServer', () => { isClient = true; ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); // An error logged but instead of surfacing it to the UI, we switched // to client rendering. await waitForAll([ - 'Hydration error', - 'There was an error while hydrating this Suspense boundary.', + 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.', + 'Cause: Hydration error', ]); expect(getVisibleChildren(container)).toEqual(
@@ -3259,9 +3274,10 @@ describe('ReactDOMFizzServer', () => { const root = ReactDOMClient.createRoot(container, { onRecoverableError(error) { - Scheduler.log( - 'Logged a recoverable error: ' + normalizeError(error.message), - ); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); React.startTransition(() => { @@ -3281,7 +3297,7 @@ describe('ReactDOMFizzServer', () => { 'B', // Log the error - 'Logged a recoverable error: Oops!', + 'onRecoverableError: Oops!', ]); // UI looks normal @@ -3337,9 +3353,10 @@ describe('ReactDOMFizzServer', () => { isClient = true; ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log( - 'Logged recoverable error: ' + normalizeError(error.message), - ); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); @@ -3347,13 +3364,11 @@ describe('ReactDOMFizzServer', () => { 'A', 'B', - 'Logged recoverable error: Hydration error', - 'Logged recoverable error: There was an error while hydrating this ' + - 'Suspense boundary.', + 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.', + 'Cause: Hydration error', - 'Logged recoverable error: Hydration error', - 'Logged recoverable error: There was an error while hydrating this ' + - 'Suspense boundary.', + 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.', + 'Cause: Hydration error', ]); }); @@ -4399,9 +4414,10 @@ describe('ReactDOMFizzServer', () => { const [ClientApp, clientResolve] = makeApp(); ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log( - 'Logged recoverable error: ' + normalizeError(error.message), - ); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([]); @@ -4478,9 +4494,10 @@ describe('ReactDOMFizzServer', () => { const [ClientApp, clientResolve] = makeApp(); ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log( - 'Logged recoverable error: ' + normalizeError(error.message), - ); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([]); @@ -4496,8 +4513,7 @@ describe('ReactDOMFizzServer', () => { // client-side rendering. await clientResolve(); await waitForAll([ - "Logged recoverable error: Hydration failed because the server rendered HTML didn't match the client.", - 'Logged recoverable error: There was an error while hydrating this Suspense boundary.', + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); expect(getVisibleChildren(container)).toEqual(
@@ -4545,14 +4561,14 @@ describe('ReactDOMFizzServer', () => { ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log( - 'Logged recoverable error: ' + normalizeError(error.message), - ); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([ - "Logged recoverable error: Hydration failed because the server rendered HTML didn't match the client.", - 'Logged recoverable error: There was an error while hydrating this Suspense boundary.', + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); expect(getVisibleChildren(container)).toEqual( @@ -4620,14 +4636,15 @@ describe('ReactDOMFizzServer', () => { ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log( - 'Logged recoverable error: ' + normalizeError(error.message), - ); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([ - 'Logged recoverable error: uh oh', - 'Logged recoverable error: There was an error while hydrating this Suspense boundary.', + 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.', + 'Cause: uh oh', ]); expect(getVisibleChildren(container)).toEqual( @@ -4709,9 +4726,10 @@ describe('ReactDOMFizzServer', () => { ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log( - 'Logged recoverable error: ' + normalizeError(error.message), - ); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([ @@ -4719,8 +4737,8 @@ describe('ReactDOMFizzServer', () => { // onRecoverableError because the UI recovered without surfacing the // error to the user. - 'Logged recoverable error: first error', - 'Logged recoverable error: There was an error while hydrating this Suspense boundary.', + 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.', + 'Cause: first error', ]); expect(mockError.mock.calls).toEqual([]); mockError.mockClear(); @@ -4828,9 +4846,10 @@ describe('ReactDOMFizzServer', () => { ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log( - 'Logged recoverable error: ' + normalizeError(error.message), - ); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll(['suspending']); @@ -4846,8 +4865,8 @@ describe('ReactDOMFizzServer', () => { await unsuspend(); await waitForAll([ 'throwing: first error', - 'Logged recoverable error: first error', - 'Logged recoverable error: There was an error while hydrating this Suspense boundary.', + 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.', + 'Cause: first error', ]); expect(getVisibleChildren(container)).toEqual(
@@ -4954,16 +4973,17 @@ describe('ReactDOMFizzServer', () => { ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log( - 'Logged recoverable error: ' + normalizeError(error.message), - ); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([ 'throwing: first error', 'suspending', - 'Logged recoverable error: first error', - 'Logged recoverable error: There was an error while hydrating this Suspense boundary.', + 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering from the nearest Suspense boundary.', + 'Cause: first error', ]); expect(mockError.mock.calls).toEqual([]); mockError.mockClear(); @@ -5368,7 +5388,10 @@ describe('ReactDOMFizzServer', () => { const errors = []; ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - errors.push(error.message); + errors.push('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([]); @@ -6336,7 +6359,7 @@ describe('ReactDOMFizzServer', () => { }, }); await waitForAll([]); - expect(errors.length).toEqual(2); + expect(errors.length).toEqual(1); expect(getVisibleChildren(container)).toEqual(); }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js index 756d5d455c9d2..f4df63ca7a652 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzShellHydration-test.js @@ -397,6 +397,9 @@ describe('ReactDOMFizzShellHydration', () => { }, onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + error.message); + if (error.cause) { + Scheduler.log('Cause: ' + error.cause.message); + } }, }); }); @@ -462,6 +465,9 @@ describe('ReactDOMFizzShellHydration', () => { }, onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + error.message); + if (error.cause) { + Scheduler.log('Cause: ' + error.cause.message); + } }, }); }); @@ -529,13 +535,16 @@ describe('ReactDOMFizzShellHydration', () => { }, onRecoverableError(error) { Scheduler.log('onRecoverableError: ' + error.message); + if (error.cause) { + Scheduler.log('Cause: ' + error.cause.message); + } }, }); }); assertLog([ - 'onRecoverableError: plain error', - 'onRecoverableError: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.', + 'onRecoverableError: There was an error while hydrating but React was able to recover by instead client rendering the entire root.', + 'Cause: plain error', ]); expect(container.textContent).toBe('Hello world'); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js b/packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js index 7a584d1797d92..7f893234c6135 100644 --- a/packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMFizzSuppressHydrationWarning-test.js @@ -165,7 +165,10 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { // Don't miss a hydration error. There should be none. - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([]); @@ -205,7 +208,10 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([]); @@ -246,12 +252,14 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([ - "Hydration failed because the server rendered HTML didn't match the client.", - 'There was an error while hydrating.', + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); expect(getVisibleChildren(container)).toEqual(
@@ -283,7 +291,10 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); const root = ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([]); @@ -327,12 +338,14 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([ - "Hydration failed because the server rendered HTML didn't match the client.", - 'There was an error while hydrating.', + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); expect(getVisibleChildren(container)).toEqual(
@@ -367,12 +380,14 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([ - "Hydration failed because the server rendered HTML didn't match the client.", - 'There was an error while hydrating.', + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); expect(getVisibleChildren(container)).toEqual(
@@ -410,12 +425,14 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([ - "Hydration failed because the server rendered HTML didn't match the client.", - 'There was an error while hydrating.', + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); expect(getVisibleChildren(container)).toEqual(
@@ -451,12 +468,14 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([ - "Hydration failed because the server rendered HTML didn't match the client.", - 'There was an error while hydrating.', + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); expect(getVisibleChildren(container)).toEqual(
@@ -496,7 +515,10 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([]); @@ -533,7 +555,10 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([]); @@ -566,12 +591,14 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([ - "Hydration failed because the server rendered HTML didn't match the client.", - 'There was an error while hydrating.', + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); expect(getVisibleChildren(container)).toEqual(
@@ -604,12 +631,14 @@ describe('ReactDOMFizzServerHydrationWarning', () => { ); ReactDOMClient.hydrateRoot(container, , { onRecoverableError(error) { - Scheduler.log(normalizeError(error.message)); + Scheduler.log('onRecoverableError: ' + normalizeError(error.message)); + if (error.cause) { + Scheduler.log('Cause: ' + normalizeError(error.cause.message)); + } }, }); await waitForAll([ - "Hydration failed because the server rendered HTML didn't match the client.", - 'There was an error while hydrating.', + "onRecoverableError: Hydration failed because the server rendered HTML didn't match the client.", ]); expect(getVisibleChildren(container)).toEqual(
diff --git a/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js b/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js index 07cbcbf9bed34..147a84207947c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js @@ -55,6 +55,15 @@ describe('ReactDOMServerHydration', () => { function formatMessage(args) { const [format, ...rest] = args; if (format instanceof Error) { + if (format.cause instanceof Error) { + return ( + 'Caught [' + + format.message + + ']\n Cause [' + + format.cause.message + + ']' + ); + } return 'Caught [' + format.message + ']'; } rest[rest.length - 1] = normalizeCodeLocInfo(rest[rest.length - 1]); @@ -88,28 +97,27 @@ describe('ReactDOMServerHydration', () => { } if (gate(flags => flags.favorSafetyOverHydrationPerf)) { expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` - [ - "Caught [Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used: - - - A server/client branch \`if (typeof window !== 'undefined')\`. - - Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called. - - Date formatting in a user's locale which doesn't match the server. - - External changing data without sending a snapshot of it along with the HTML. - - Invalid HTML tag nesting. - - It can also happen if the client has a browser extension installed which messes with the HTML before React loaded. - - https://react.dev/link/hydration-mismatch - - -
-
- + client - - server - ]", - "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", - ] - `); + [ + "Caught [Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used: + + - A server/client branch \`if (typeof window !== 'undefined')\`. + - Variable input such as \`Date.now()\` or \`Math.random()\` which changes each time it's called. + - Date formatting in a user's locale which doesn't match the server. + - External changing data without sending a snapshot of it along with the HTML. + - Invalid HTML tag nesting. + + It can also happen if the client has a browser extension installed which messes with the HTML before React loaded. + + https://react.dev/link/hydration-mismatch + + +
+
+ + client + - server + ]", + ] + `); } else { expect(testMismatch(Mismatch)).toMatchInlineSnapshot(` [ @@ -170,7 +178,6 @@ describe('ReactDOMServerHydration', () => { + This markup contains an nbsp entity:   client text - This markup contains an nbsp entity:   server text ]", - "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] `); } else { @@ -477,7 +484,6 @@ describe('ReactDOMServerHydration', () => {
+
]", - "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] `); }); @@ -513,7 +519,6 @@ describe('ReactDOMServerHydration', () => { -
... ]", - "Caught [There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.]", ] `); }); @@ -550,7 +555,6 @@ describe('ReactDOMServerHydration', () => { -