Suspense content is never revealed in a tab that has not been foregrounded
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 4/5
- Tiempo estimado
- 3-5 días
- Aptitud para principiantes
- 48/100
- Tipo de issue
- Error
- Claridad
- Bastante claro
- Estado de actividad
- Activo
- Stack tecnológico
- javascript, next.js, react
- Área
- frontend, performance, web-dev
Línea de trabajo
Start in react-dom/cjs/react-dom-server.edge.production.js at the $RV/$RC scheduling code and inspect how requestAnimationFrame controls streamed Suspense reveals. Run the isolated happy-dom probe, then build the three-file Next app and verify the hidden-tab behavior with the requested DOM and hydration observations. Done means the streamed content is revealed without foregrounding the tab.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
react-dom 19.2.8 · react 19.2.8 · next 16.3.3 (App Router) ·
Chrome stable, macOS · Node 22
Summary
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", a
target="_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 a
MessageChannel, 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.
Minimal reproduction
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>
}
Steps
next build && next start.- Open
/startin Chrome. - Cmd-click (macOS) or middle-click "Recipes". The new tab opens in the
background; stay on/start. - Wait 30 seconds, then switch to the new tab.
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.
Where it comes from
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 the
setTimeout 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.
Isolated probe
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"
Suggested direction
Either gate the rAF path on document.visibilityState, or always arm a
setTimeout alongside the requestAnimationFrame and let whichever fires first
win (the reveal is already idempotent — $RB.length is reset by $RV). A
visibilitychange listener that drains $RB would also close it.
What is not being claimed
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.
- Lenguaje dominante
- JavaScript
- Estrellas
- 251k
- Forks
- 51.4k
- Merge medio
- 2 d 1 h
- PR fusionados (30 d)
- 51
Guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de react/react
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 70/100
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 74/100
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 75/100
Todos los issues de react/react
Issues similares
-
bug
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
avniproject/avni-client#2135 ·
-
enhancement
Dificultad 2/5 1-3 horas Aptitud para principiantes 70/100
babalae/bettergi-scripts-list#3674 ·
-
A-Release-Notes C-Editing D-Modest S-Ready-For-Implementation
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
bevyengine/bevy-website#2595 ·
-
ecosystem wording
Dificultad 1/5 Menos de una hora Aptitud para principiantes 90/100
matrix-org/matrix.org#3649 ·
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 88/100
vadimdemedes/ink#1029 ·