diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index f6bee806f3ac2..ca0745edc230c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -2958,4 +2958,44 @@ describe('ReactDOMServerPartialHydration', () => { expect(ref.current).toBe(span); expect(ref.current.innerHTML).toBe('Hidden child'); }); + + function itHydratesWithoutMismatch(msg, App) { + it(msg + ' without mismatch', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const finalHTML = ReactDOMServer.renderToString(); + container.innerHTML = finalHTML; + + ReactDOM.hydrateRoot(container, ); + Scheduler.unstable_flushAll(); + }); + } + + itHydratesWithoutMismatch('can hydrate empty string ', function App() { + return ( +
+
Test
+ {'' &&
Test
} +
Test
+
+ ); + }); + + itHydratesWithoutMismatch('can hydrate empty string simple', function App() { + return ''; + }); + itHydratesWithoutMismatch('can hydrate empty string simple', function App() { + return ( + <> + {''} + {'sup'} + + ); + }); + itHydratesWithoutMismatch( + 'can hydrate empty string without mismatch simple 2', + function App() { + return {'' && false}; + }, + ); }); diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 64fd789b7a1e8..8a605b678f005 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -692,14 +692,33 @@ export function canHydrateTextInstance( instance: HydratableInstance, text: string, ): null | TextInstance { - if (text === '' || instance.nodeType !== TEXT_NODE) { - // Empty strings are not parsed by HTML so there won't be a correct match here. + if ( + (instance.textContent !== '' && text === '') || + instance.nodeType !== TEXT_NODE + ) { return null; } // This has now been refined to a text node. return ((instance: any): TextInstance); } +export function insertMissingEmptyTextNode( + instance: null | HydratableInstance, + parent: null | HydratableInstance, +): null | HydratableInstance { + const parentNode = instance ? instance.parentNode : parent; + if (parentNode) { + const textNode = document.createTextNode(''); + if (instance) { + parentNode.insertBefore(textNode, instance); + } else { + parentNode.appendChild(textNode); + } + return (textNode: TextInstance); + } + return null; +} + export function canHydrateSuspenseInstance( instance: HydratableInstance, ): null | SuspenseInstance { diff --git a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js index 1392cf8a26083..409cac25cfcfb 100644 --- a/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js +++ b/packages/react-reconciler/src/ReactFiberHostConfigWithNoHydration.js @@ -23,6 +23,7 @@ export type SuspenseInstance = mixed; export const supportsHydration = false; export const canHydrateInstance = shim; export const canHydrateTextInstance = shim; +export const insertMissingEmptyTextNode = shim; export const canHydrateSuspenseInstance = shim; export const isSuspenseInstancePending = shim; export const isSuspenseInstanceFallback = shim; diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js index 614c6c9d946ca..106fe5c5010ae 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.new.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.new.js @@ -24,6 +24,7 @@ import { HostComponent, HostText, HostRoot, + HostPortal, SuspenseComponent, } from './ReactWorkTags'; import {ChildDeletion, Placement, Hydrating} from './ReactFiberFlags'; @@ -61,6 +62,7 @@ import { didNotFindHydratableInstance, didNotFindHydratableTextInstance, didNotFindHydratableSuspenseInstance, + insertMissingEmptyTextNode, } from './ReactFiberHostConfig'; import { enableClientRenderFallbackOnHydrationMismatch, @@ -327,6 +329,36 @@ function tryHydrate(fiber, nextInstance) { } } +function tryHydrateEmptyTextNode( + fiber: Fiber, + nextInstance: null | HydratableInstance, + parentFiber: null | Fiber, +) { + if ( + nextInstance && + canHydrateTextInstance(nextInstance, fiber.pendingProps) + ) { + return nextInstance; + } else { + if (!parentFiber) { + return null; + } + switch (parentFiber.tag) { + case HostRoot: + case HostPortal: + return insertMissingEmptyTextNode( + nextInstance, + parentFiber.stateNode.containerInfo, + ); + case HostComponent: + return insertMissingEmptyTextNode(nextInstance, parentFiber.stateNode); + default: + // Recurse upwards to find parent host node for text node + return tryHydrateEmptyTextNode(fiber, nextInstance, parentFiber.return); + } + } +} + function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) { if ( enableClientRenderFallbackOnHydrationMismatch && @@ -342,6 +374,13 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { if (!isHydrating) { return; } + if (fiber.tag === HostText && fiber.pendingProps === '') { + nextHydratableInstance = tryHydrateEmptyTextNode( + fiber, + nextHydratableInstance, + hydrationParentFiber, + ); + } let nextInstance = nextHydratableInstance; if (!nextInstance) { throwOnHydrationMismatchIfConcurrentMode(fiber); diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js index b7bca8217d979..68a6ed146309b 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.old.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.old.js @@ -24,6 +24,7 @@ import { HostComponent, HostText, HostRoot, + HostPortal, SuspenseComponent, } from './ReactWorkTags'; import {ChildDeletion, Placement, Hydrating} from './ReactFiberFlags'; @@ -61,6 +62,7 @@ import { didNotFindHydratableInstance, didNotFindHydratableTextInstance, didNotFindHydratableSuspenseInstance, + insertMissingEmptyTextNode, } from './ReactFiberHostConfig'; import { enableClientRenderFallbackOnHydrationMismatch, @@ -327,6 +329,36 @@ function tryHydrate(fiber, nextInstance) { } } +function tryHydrateEmptyTextNode( + fiber: Fiber, + nextInstance: null | HydratableInstance, + parentFiber: null | Fiber, +) { + if ( + nextInstance && + canHydrateTextInstance(nextInstance, fiber.pendingProps) + ) { + return nextInstance; + } else { + if (!parentFiber) { + return null; + } + switch (parentFiber.tag) { + case HostRoot: + case HostPortal: + return insertMissingEmptyTextNode( + nextInstance, + parentFiber.stateNode.containerInfo, + ); + case HostComponent: + return insertMissingEmptyTextNode(nextInstance, parentFiber.stateNode); + default: + // Recurse upwards to find parent host node for text node + return tryHydrateEmptyTextNode(fiber, nextInstance, parentFiber.return); + } + } +} + function throwOnHydrationMismatchIfConcurrentMode(fiber: Fiber) { if ( enableClientRenderFallbackOnHydrationMismatch && @@ -342,6 +374,13 @@ function tryToClaimNextHydratableInstance(fiber: Fiber): void { if (!isHydrating) { return; } + if (fiber.tag === HostText && fiber.pendingProps === '') { + nextHydratableInstance = tryHydrateEmptyTextNode( + fiber, + nextHydratableInstance, + hydrationParentFiber, + ); + } let nextInstance = nextHydratableInstance; if (!nextInstance) { throwOnHydrationMismatchIfConcurrentMode(fiber); diff --git a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js index 6535d8d3fdec3..676664549d1f7 100644 --- a/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js @@ -134,6 +134,8 @@ export const cloneHiddenTextInstance = $$$hostConfig.cloneHiddenTextInstance; // ------------------- export const canHydrateInstance = $$$hostConfig.canHydrateInstance; export const canHydrateTextInstance = $$$hostConfig.canHydrateTextInstance; +export const insertMissingEmptyTextNode = + $$$hostConfig.insertMissingEmptyTextNode; export const canHydrateSuspenseInstance = $$$hostConfig.canHydrateSuspenseInstance; export const isSuspenseInstancePending =