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