diff --git a/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js b/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js index 835ffaac79dc..4110cea4acd9 100644 --- a/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMHydrationDiff-test.js @@ -10,6 +10,7 @@ 'use strict'; let React; +let ReactDOM; let ReactDOMClient; let ReactDOMServer; let act; @@ -28,6 +29,7 @@ describe('ReactDOMServerHydration', () => { beforeEach(() => { jest.resetModules(); React = require('react'); + ReactDOM = require('react-dom'); ReactDOMClient = require('react-dom/client'); ReactDOMServer = require('react-dom/server'); act = React.act; @@ -1644,4 +1646,46 @@ describe('ReactDOMServerHydration', () => { `); }); }); + + describe('portal', () => { + // @gate __DEV__ + it('does not produce a false mismatch warning when a component renders null on server but a portal on client', () => { + const portalContainer = document.createElement('div'); + document.body.appendChild(portalContainer); + try { + function HoverMenu({isClient}) { + if (!isClient) return null; + return ReactDOM.createPortal(
Hello World
, portalContainer); + } + function Mismatch({isClient}) { + return ( + + Some Text + + + ); + } + expect(testMismatch(Mismatch)).toEqual([]); + } finally { + document.body.removeChild(portalContainer); + } + }); + + // @gate __DEV__ + it('does not produce a false mismatch warning when the portal target is the hydration root', () => { + function HoverMenu({isClient}) { + if (!isClient) return null; + return ReactDOM.createPortal(
Hello World
, container); + } + function Mismatch({isClient}) { + return ( + + Some Text + + + ); + } + expect(testMismatch(Mismatch)).toEqual([]); + }); + }); }); diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index 4f41a70e56eb..7b2134780047 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -250,6 +250,7 @@ import { claimNextHydratableSuspenseInstance, warnIfHydrating, queueHydrationError, + prepareToHydrateHostPortal, } from './ReactFiberHydrationContext'; import { constructClassInstance, @@ -3638,6 +3639,7 @@ function updatePortalComponent( renderLanes: Lanes, ) { pushHostContainer(workInProgress, workInProgress.stateNode.containerInfo); + prepareToHydrateHostPortal(workInProgress); const nextChildren = workInProgress.pendingProps; if (current === null) { // Portals are special because we don't append the children during mount diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 6fc4297e1b33..f11354e57b38 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -162,6 +162,7 @@ import { getIsHydrating, upgradeHydrationErrorsToRecoverable, emitPendingHydrationWarnings, + popHydrationStateAfterPortal, } from './ReactFiberHydrationContext'; import { renderHasNotSuspendedYet, @@ -1661,6 +1662,7 @@ function completeWork( return null; } case HostPortal: + popHydrationStateAfterPortal(workInProgress); popHostContainer(workInProgress); updateHostContainer(current, workInProgress); if (current === null) { diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index 0c758202b52f..1d5330136d72 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -93,6 +93,17 @@ let hydrationErrors: Array> | null = null; let rootOrSingletonContext = false; +// Stack to save hydration state when traversing into a portal. Portals render +// into a separate container that was not server-rendered, so hydration must be +// suspended for the portal's subtree and resumed afterward. +type PortalHydrationState = { + hydrationParentFiber: null | Fiber, + nextHydratableInstance: null | HydratableInstance, + isHydrating: boolean, + rootOrSingletonContext: boolean, +}; +const hydrationPortalStateStack: Array = []; + // Builds a common ancestor tree from the root down for collecting diffs. function buildHydrationDiffNode( fiber: Fiber, @@ -824,6 +835,38 @@ function warnIfUnhydratedTailNodes(fiber: Fiber) { } } +export function prepareToHydrateHostPortal(fiber: Fiber): void { + if (!supportsHydration) { + return; + } + // Save the current hydration state so we can restore it after the portal. + hydrationPortalStateStack.push({ + hydrationParentFiber, + nextHydratableInstance, + isHydrating, + rootOrSingletonContext, + }); + // Portals render into a separate container that is never server-rendered. + // Disable hydration for the portal's children so they are inserted fresh. + hydrationParentFiber = null; + nextHydratableInstance = null; + isHydrating = false; + rootOrSingletonContext = false; +} + +export function popHydrationStateAfterPortal(fiber: Fiber): void { + if (!supportsHydration) { + return; + } + const savedState = hydrationPortalStateStack.pop(); + if (savedState !== undefined) { + hydrationParentFiber = savedState.hydrationParentFiber; + nextHydratableInstance = savedState.nextHydratableInstance; + isHydrating = savedState.isHydrating; + rootOrSingletonContext = savedState.rootOrSingletonContext; + } +} + function resetHydrationState(): void { if (!supportsHydration) { return; @@ -833,6 +876,7 @@ function resetHydrationState(): void { nextHydratableInstance = null; isHydrating = false; didSuspendOrErrorDEV = false; + hydrationPortalStateStack.length = 0; } // Restore the hydration cursor when unwinding a HostComponent that already