facebook / react · Issue No. 37551
When the children of <head> suspend during hydration and React later replays the head fiber (replaySuspendedUnitOfWork), claimHydratableSingleton runs a second time and overwrites previousHydratableOnEnteringScopedSingleton with the cursor that is already inside <head>. When head pops, the cursor is "restored" to that in-head node instead of body.firstChild, the first element under <body> finds no matching hydratable node, and React reports Hydration failed because the server rendered HTML didn't match the client (#418, args[]=HTML in production) and client-renders the whole tree — although server and client markup are identical.
replayBeginWork re-syncs the hydration cursor for HostComponent only (added in #35494 for #35210). The comment there says other fiber types "aren't reliant on the cursor positioning", but head is a singleton scope (isSingletonScope) that saves/restores the cursor on enter/exit, so it is.
React version: react-dom@19.3.0-canary-6c0e1047-20260908 (latest canary at the time of writing) and react-dom@19.2.8. Also hit in production with Next.js 16.3.4 (bundled 19.3.0-canary-cbb046ab-20260731): a 'use client' module export used as a prop inside <head> becomes a client reference, so the <script> element under <head> is a blocked lazy until that chunk loads; whether the chunk has loaded before hydration reaches <head> is a race, so the error is intermittent and page-dependent.
mkdir repro && cd repro && npm init -y && npm i react@canary react-dom@canary jsdomrepro.jsnode repro.js head → hydration error (the bug)node repro.js body (same thenable under a <div>) → fine on canary; node repro.js head sync (hydrateRoot outside startTransition) → fine on canary, because a sync render unwinds instead of replayingLink to code example: self-contained script (jsdom is the only dependency besides React; same environment as React's own tests):
'use strict'
const where = process.argv[2] || 'head'
const lane = process.argv[3] || 'transition'
const React = require('react')
const { renderToString } = require('react-dom/server')
const h = React.createElement
const SCRIPT = h('script', { dangerouslySetInnerHTML: { __html: '/* inline init script */' } })
const LEAF = h('p', null, 'hello')
const App = ({ headChild, bodyChild }) =>
h('html', { lang: 'en' }, h('head', null, headChild), h('body', null, h('div', { className: 'app' }, bodyChild), h('footer', null, 'footer')))
// Server: everything resolved.
const html = renderToString(h(App, { headChild: SCRIPT, bodyChild: LEAF }))
// Client environment.
const { JSDOM } = require('jsdom')
const dom = new JSDOM(html)
global.window = dom.window
global.document = dom.window.document
for (const k of ['Node', 'Element', 'HTMLElement', 'Text', 'Comment', 'DocumentFragment', 'Event', 'CustomEvent', 'MutationObserver', 'HTMLIFrameElement']) {
if (!(k in global)) global[k] = dom.window[k]
}
const ssrDiv = document.querySelector('.app')
// A thenable with no `status` yet that settles in a microtask once `then` is called. This is the shape an RSC
// client-reference / lazy chunk presents to the reconciler (its `status` becomes "fulfilled" when the chunk loads),
// so React takes the replaySuspendedUnitOfWork path instead of unwinding.
const lateThenable = (value) => ({ then(resolve) { queueMicrotask(() => resolve(value)) } })
const { hydrateRoot } = require('react-dom/client')
const props = where === 'head' ? { headChild: lateThenable(SCRIPT), bodyChild: LEAF } : { headChild: SCRIPT, bodyChild: lateThenable(LEAF) }
const errors = []
const hydrate = () => hydrateRoot(document, h(App, props), { onRecoverableError: (e) => errors.push(String(e.message).split('\n')[0]) })
if (lane === 'transition') React.startTransition(hydrate) // what Next.js does
else hydrate()
setTimeout(() => {
console.log(JSON.stringify({ react: require('react-dom/package.json').version, where, lane, recoverableErrors: errors, bodyDivReused: document.querySelector('.app') === ssrDiv }, null, 2))
process.exit(errors.length ? 1 : 0)
}, 300)
head (transition) |
body (transition) |
head sync |
|
|---|---|---|---|
| 19.3.0-canary-6c0e1047-20260908 | error, <div class="app"> re-created |
ok | ok |
| 19.2.8 | error | error (expected: #35494 is not in 19.2) | error |
node repro.js head on the canary:
{
"react": "19.3.0-canary-6c0e1047-20260908",
"where": "head",
"lane": "transition",
"recoverableErrors": [
"Hydration failed because the server rendered HTML didn't match the client. As a result this tree will be regenerated on the client. This can happen if a SSR-ed Client Component used:"
],
"bodyDivReused": false
}
With NODE_ENV=production: Minified React error #418; visit https://react.dev/errors/418?args[]=HTML&args[]=.
Trace from the dev build with console.log at the relevant points:
renderRootConcurrent
claimSingleton html cursor=<div.app> prevSaved=null
claimSingleton head cursor=<div.app> prevSaved=null ← saves body.firstChild, cursor := head.firstChild (<script>)
← reconciling head's children throws (thenable pending)
readyToContinue status=fulfilled unit=head
REPLAY tag=27 type=head cursor=<script> isHydrating=true ← replayBeginWork → default branch → beginWork(head) again
claimSingleton head cursor=<script> prevSaved=<div.app> ← overwrites the saved cursor with <script> (already inside head)
popSingleton head restoreTo=<script> ← cursor "restored" to <script>
claimSingleton body cursor=<script>
MISMATCH fiber=div cursor=<script> parent=body ← body's first element looks for a match among head's children
No hydration error: server and client render the same markup; the child of <head> merely arrives late.
packages/react-reconciler/src/ReactFiberWorkLoop.js — replayBeginWork: case HostComponent calls popHydrationStateOnInterruptedWork(unitOfWork) with the comment "Other fiber types hydrate differently and aren't reliant on the cursor positioning so this function is only for HostComponent". HostSingleton falls into default, which does unwindInterruptedWork + resetWorkInProgress + beginWork, i.e. claimHydratableSingleton runs again.packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js — getFirstHydratableChildWithinSingleton: for isSingletonScope(type) (head) it unconditionally does previousHydratableOnEnteringScopedSingleton = currentHydratableInstance, so a re-entry saves the in-scope cursor.unwindInterruptedWork for HostSingleton only pops the host context; nothing restores previousHydratableOnEnteringScopedSingleton.A native Promise child happens to mask the bug: on resume its status is still "pending" from React's point of view, so React unwinds to the root and re-hydrates from scratch. A thenable whose status is "fulfilled" by the time React resumes (Flight chunks, or the bare thenable above) takes the replay path.
replayBeginWork, treat HostSingleton like HostComponent when the replayed fiber is hydrationParentFiber: pop to the next host parent and restore nextHydratableInstance (and, for a singleton scope, previousHydratableOnEnteringScopedSingleton) before re-running beginWork.hydrationParentFiber.Happy to turn this into a PR with a regression test if that helps.
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.