diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 25ec3c590ec..46c1cdb0ce6 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -244,11 +244,13 @@ describe('ReactDOMServerPartialHydration', () => { function App() { return ( - - - - - +
+ + + + + +
); } @@ -264,7 +266,7 @@ describe('ReactDOMServerPartialHydration', () => { ]); expect(container.innerHTML).toBe( - 'Hello
Component
Component
Component
Component
', + '
Hello
Component
Component
Component
Component
', ); suspend = true; @@ -282,31 +284,46 @@ describe('ReactDOMServerPartialHydration', () => { // Unchanged expect(container.innerHTML).toBe( - 'Hello
Component
Component
Component
Component
', + '
Hello
Component
Component
Component
Component
', ); suspend = false; resolve(); await promise; - expect(Scheduler).toFlushAndYield([ - // first pass, mismatches at end - 'Hello', - 'Component', - 'Component', - 'Component', - 'Component', - // second pass as client render - 'Hello', - 'Component', - 'Component', - 'Component', - 'Component', - ]); + function whatWeExpect() { + expect(() => { + expect(Scheduler).toFlushAndYield([ + // first pass, mismatches at end + 'Hello', + 'Component', + 'Component', + 'Component', + 'Component', + // second pass as client render + 'Hello', + 'Component', + 'Component', + 'Component', + 'Component', + ]); + }).toErrorDev( + 'Warning: An error occurred during hydration. expected article got DIV. At: [object HTMLDivElement] Inside: [object HTMLDivElement]', + {withoutStack: true}, + ); + } + + if (__DEV__) { + expect(whatWeExpect).toThrow( + 'Received 5 arguments for a message with 2 placeholders', + ); + } else { + whatWeExpect(); + } // Client rendered - suspense comment nodes removed expect(container.innerHTML).toBe( - 'Hello
Component
Component
Component
Mismatch
', + '
Hello
Component
Component
Component
Mismatch
', ); }); diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 64fd789b7a1..09ad788faf1 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -66,7 +66,11 @@ import { enableCreateEventHandleAPI, enableScopeAPI, } from 'shared/ReactFeatureFlags'; -import {HostComponent, HostText} from 'react-reconciler/src/ReactWorkTags'; +import { + HostComponent, + HostText, + SuspenseComponent, +} from 'react-reconciler/src/ReactWorkTags'; import {listenToAllSupportedEvents} from '../events/DOMPluginEventSystem'; import {DefaultEventPriority} from 'react-reconciler/src/ReactEventPriorities'; @@ -1077,6 +1081,28 @@ export function errorHydratingContainer(parentContainer: Container): void { } } +export function warnOnHydrationMismatch( + fiber: Fiber, + parentFiber: Fiber | null, + hydratableInstance: HydratableInstance | null, +) { + if (__DEV__) { + // eslint-disable-next-line react-internal/warning-args + console.error( + 'An error occurred during hydration. expected %s got %s. At: ', + fiber.type, + hydratableInstance ? hydratableInstance.nodeName : '', + hydratableInstance, + 'Inside: ', + parentFiber + ? parentFiber.tag === SuspenseComponent + ? '' + : parentFiber.stateNode + : null, + ); + } +} + export function getInstanceFromNode(node: HTMLElement): null | Object { return getClosestInstanceFromNode(node) || null; } diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js index 1392cf8a260..eabb6c48d11 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js @@ -55,3 +55,4 @@ export const didNotFindHydratableInstance = shim; export const didNotFindHydratableTextInstance = shim; export const didNotFindHydratableSuspenseInstance = shim; export const errorHydratingContainer = shim; +export const warnOnHydrationMismatch = shim; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index 5e915579a7c..aeddd4b1e64 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -61,6 +61,7 @@ import { didNotFindHydratableInstance, didNotFindHydratableTextInstance, didNotFindHydratableSuspenseInstance, + warnOnHydrationMismatch as hostWarnOnHydrationMismatch, } from './ReactFiberHostConfig'; import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags'; import {OffscreenLane} from './ReactFiberLane.new'; @@ -324,7 +325,15 @@ function tryHydrate(fiber, nextInstance) { } } -function throwOnHydrationMismatchIfConcurrentMode(fiber) { +export function warnOnHydrationMismatch(fiber: Fiber) { + hostWarnOnHydrationMismatch( + fiber, + hydrationParentFiber, + nextHydratableInstance, + ); +} + +function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) { if ((fiber.mode & ConcurrentMode) !== NoMode) { throw new Error( 'An error occurred during hydration. The server HTML was replaced with client content', diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index e7e08d5f3b8..d0197e1c34b 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -61,6 +61,7 @@ import { didNotFindHydratableInstance, didNotFindHydratableTextInstance, didNotFindHydratableSuspenseInstance, + warnOnHydrationMismatch as hostWarnOnHydrationMismatch, } from './ReactFiberHostConfig'; import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags'; import {OffscreenLane} from './ReactFiberLane.old'; @@ -324,7 +325,15 @@ function tryHydrate(fiber, nextInstance) { } } -function throwOnHydrationMismatchIfConcurrentMode(fiber) { +export function warnOnHydrationMismatch(fiber: Fiber) { + hostWarnOnHydrationMismatch( + fiber, + hydrationParentFiber, + nextHydratableInstance, + ); +} + +function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) { if ((fiber.mode & ConcurrentMode) !== NoMode) { throw new Error( 'An error occurred during hydration. The server HTML was replaced with client content', diff --git a/packages/react-reconciler/src/ReactFiberThrow.new.js b/packages/react-reconciler/src/ReactFiberThrow.new.js index cd9931687ba..622be7dcc42 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.new.js +++ b/packages/react-reconciler/src/ReactFiberThrow.new.js @@ -79,7 +79,10 @@ import { mergeLanes, pickArbitraryLane, } from './ReactFiberLane.new'; -import {getIsHydrating} from './ReactFiberHydrationContext.new'; +import { + warnOnHydrationMismatch, + getIsHydrating, +} from './ReactFiberHydrationContext.new'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; @@ -499,6 +502,18 @@ function throwException( // Set a flag to indicate that we should try rendering the normal // children again, not the fallback. suspenseBoundary.flags |= ForceClientRender; + if (__DEV__) { + if ( + value && + value.message && + value.message.startsWith instanceof Function && + (value.message.startsWith: any)( + 'An error occurred during hydration', + ) + ) { + warnOnHydrationMismatch(sourceFiber); + } + } } markSuspenseBoundaryShouldCapture( suspenseBoundary, diff --git a/packages/react-reconciler/src/ReactFiberThrow.old.js b/packages/react-reconciler/src/ReactFiberThrow.old.js index 8f6d18a48de..fc6ceed10bb 100644 --- a/packages/react-reconciler/src/ReactFiberThrow.old.js +++ b/packages/react-reconciler/src/ReactFiberThrow.old.js @@ -79,7 +79,10 @@ import { mergeLanes, pickArbitraryLane, } from './ReactFiberLane.old'; -import {getIsHydrating} from './ReactFiberHydrationContext.old'; +import { + warnOnHydrationMismatch, + getIsHydrating, +} from './ReactFiberHydrationContext.old'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; @@ -499,6 +502,18 @@ function throwException( // Set a flag to indicate that we should try rendering the normal // children again, not the fallback. suspenseBoundary.flags |= ForceClientRender; + if (__DEV__) { + if ( + value && + value.message && + value.message.startsWith instanceof Function && + (value.message.startsWith: any)( + 'An error occurred during hydration', + ) + ) { + warnOnHydrationMismatch(sourceFiber); + } + } } markSuspenseBoundaryShouldCapture( suspenseBoundary, diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 6535d8d3fde..f658bafa785 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -189,3 +189,4 @@ export const didNotFindHydratableTextInstance = export const didNotFindHydratableSuspenseInstance = $$$hostConfig.didNotFindHydratableSuspenseInstance; export const errorHydratingContainer = $$$hostConfig.errorHydratingContainer; +export const warnOnHydrationMismatch = $$$hostConfig.warnOnHydrationMismatch;