facebook / react · Issue No. 37666
react-dom 19.2.8 · react 19.2.8 · next 16.3.3 (App Router) ·
Chrome stable, macOS · Node 22
Server-rendered Suspense content streamed after the shell is revealed by the
client runtime React inlines into the document. Every step of that reveal is
scheduled through requestAnimationFrame. Chrome does not run animation frame
callbacks in a tab that is not visible, so a document opened directly into a
background tab — middle-click, cmd-click, "Open link in new tab", atarget="_blank" link, a session restore — stays on its Suspense fallback
indefinitely. It reveals only once the tab is looked at.
Hydration itself is unaffected, because React's scheduler uses aMessageChannel, which does run in a hidden tab. So the page is interactive and
holding the fallback at the same time: no error, no warning, no network
activity.
Three files. No dependencies beyond next and react.
./app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
./app/page.tsx
import { Suspense } from 'react'
export const dynamic = 'force-dynamic'
async function RecipeList() {
// Any await that outlives the shell flush. A fetch behaves identically.
await new Promise((resolve) => setTimeout(resolve, 500))
return (
<ul id="recipes">
<li>Lentil soup — 45 min</li>
<li>Sourdough focaccia — 3 h</li>
<li>Lemon tart — 90 min</li>
</ul>
)
}
export default function Page() {
return (
<main>
<h1>Recipes</h1>
<Suspense fallback={<p id="loading">Loading recipes…</p>}>
<RecipeList />
</Suspense>
</main>
)
}
./app/start/page.tsx — just somewhere to open the link from.
import Link from 'next/link'
export default function Start() {
return <Link href="/">Recipes</Link>
}
next build && next start./start in Chrome./start.Expected: the recipe list is there — the data resolved 500 ms after the
request, long before the tab was looked at.
Actual: the tab shows "Loading recipes…" at the moment it is switched to,
and swaps to the list a frame later — the reveal happens because the tab was
looked at.
Verification status, and the one thing left to do before this is filed.
The runtime analysis and the isolated probe below are reproduced and pinned.
The browser walk-through above has not been run against this synthetic
app yet — it is what the runtime predicts, written out. Build and run the
three files once, and capture the two observations that make the report
airtight: with DevTools attached to the hidden tab, the streamed content is
already in the document as<div hidden id="S:0">…</div>while the boundary
still shows its fallback; and aconsole.logfrom a client component in the
same tab shows hydration has already run. Replace this block with those two
captures.
In react-dom-server's inlined completion runtime the whole reveal chain is
behind requestAnimationFrame — $RC schedules $RV, and $RV schedules each
boundary's retry:
// react-dom/cjs/react-dom-server.edge.production.js:2559
$RB = []
$RV = function (a) {
$RT = performance.now()
/* … splice the streamed content in … */
g._reactRetry && requestAnimationFrame(g._reactRetry)
}
$RC = function (a, b) {
if ((b = document.getElementById(b)))
(a = document.getElementById(a))
? (a.previousSibling.data = '$~',
$RB.push(a, b),
2 === $RB.length &&
('number' !== typeof $RT
? requestAnimationFrame($RV.bind(null, $RB))
: setTimeout($RV.bind(null, $RB), /* … */)))
: b.parentNode.removeChild(b)
}
The setTimeout branch — the one that would survive a hidden tab — is reachable
only when $RT is a number, and $RT is assigned in exactly two places: inside$RV (which only ever runs from a rAF callback) and inside a one-line
bootstrap that is itself a rAF callback:
// react-dom/cjs/react-dom-server.edge.production.js:2408
'requestAnimationFrame(function(){$RT=performance.now()});'
So in a document that has never painted, $RT is undefined on the first
completion, the requestAnimationFrame branch is taken, and nothing in the
chain can run until the tab becomes visible. The timing heuristic thesetTimeout branch implements (hold a reveal briefly so several boundaries land
together) reads as a paint-scheduling concern, but its scheduling primitive
makes it a visibility requirement.
The DOM half reproduces without a browser. This extracts the runtime React
ships and drives it once with a requestAnimationFrame that never fires (a
hidden tab) and once with one that does:
import { readFileSync } from 'node:fs'
import { Window } from 'happy-dom'
const src = readFileSync('./node_modules/react-dom/cjs/react-dom-server.edge.production.js', 'utf8')
const runtime = src.match(/'(\$RB=\[\];\$RV=function[\s\S]*?)'\n/)[1].replace(/\\n/g, '\n')
function run({ rafFires }) {
const { document } = new Window()
document.body.innerHTML =
'<div id="root"><!--$?--><template id="B:0"></template>FALLBACK<!--/$--></div>' +
'<div hidden id="S:0">CONTENT</div>'
const queue = []
const $RC = new Function(
'document', 'requestAnimationFrame', 'performance', `${runtime}\nreturn $RC`,
)(document, (cb) => queue.push(cb), { now: () => 1000 })
$RC('B:0', 'S:0')
if (rafFires) while (queue.length) queue.shift()()
return document.getElementById('root').textContent
}
console.log('rAF never fires:', JSON.stringify(run({ rafFires: false })))
console.log('rAF fires: ', JSON.stringify(run({ rafFires: true })))
Output (react-dom 19.2.8, happy-dom 20.11.6):
rAF never fires: "FALLBACK"
rAF fires: "CONTENT"
Either gate the rAF path on document.visibilityState, or always arm asetTimeout alongside the requestAnimationFrame and let whichever fires first
win (the reveal is already idempotent — $RB.length is reset by $RV). Avisibilitychange listener that drains $RB would also close it.
No timing numbers from anything but the probe above. The workaround on our side
is simply not putting a Suspense boundary above content the first view depends
on; that is a local decision and is not part of this report.
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.