From b0e1074c22440dde08d700c51d0e83e87b5368f8 Mon Sep 17 00:00:00 2001 From: Varun Date: Tue, 21 Apr 2026 12:56:50 +0530 Subject: [PATCH 1/2] Fix false hydration mismatch warning when client renders portal that was null on server Portals render into a separate container that is never server-rendered. Previously, when a component returned null on the server but a portal on the client, React would incorrectly try to hydrate the portal's children against the parent container's server nodes, producing a false 'Hydration failed' error. Fix: save and reset hydration state when entering a portal (prepareToHydrateHostPortal), then restore it when the portal completes (popHydrationStateAfterPortal). This ensures portal children are inserted fresh rather than incorrectly matched against server HTML. Fixes #12615 --- .../__tests__/ReactDOMHydrationDiff-test.js | 44 ++++++++++++++++++ .../src/ReactFiberBeginWork.js | 2 + .../src/ReactFiberCompleteWork.js | 2 + .../src/ReactFiberHydrationContext.js | 46 +++++++++++++++++++ 4 files changed, 94 insertions(+) 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..d395043ccf7b 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 @@ -954,4 +998,6 @@ export { prepareToHydrateHostActivityInstance, prepareToHydrateHostSuspenseInstance, popHydrationState, + prepareToHydrateHostPortal, + popHydrationStateAfterPortal, }; From 5579b4a246e64a8ab29b1e60877cb24f0a0b816d Mon Sep 17 00:00:00 2001 From: Varun Date: Tue, 21 Apr 2026 13:25:58 +0530 Subject: [PATCH 2/2] Fix duplicate export causing build failure prepareToHydrateHostPortal and popHydrationStateAfterPortal were already exported via `export function`, so they must not also appear in the trailing `export {}` block. --- packages/react-reconciler/src/ReactFiberHydrationContext.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberHydrationContext.js b/packages/react-reconciler/src/ReactFiberHydrationContext.js index d395043ccf7b..1d5330136d72 100644 --- a/packages/react-reconciler/src/ReactFiberHydrationContext.js +++ b/packages/react-reconciler/src/ReactFiberHydrationContext.js @@ -998,6 +998,4 @@ export { prepareToHydrateHostActivityInstance, prepareToHydrateHostSuspenseInstance, popHydrationState, - prepareToHydrateHostPortal, - popHydrationStateAfterPortal, };