Suspense content is never revealed in a tab that has not been foregrounded
Dieses Issue hat noch niemand übernommen.
Bewertung
- Schwierigkeit
- 4/5
- Geschätzter Aufwand
- 3-5 Tage
- Anfängerfreundlichkeit
- 48/100
- Issue-Typ
- Bug
- Klarheit
- Größtenteils klar
- Aktivitätsstatus
- Aktiv
- Tech-Stack
- javascript, next.js, react
- Bereich
- frontend, performance, web-dev
Rechercherichtung
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.
Vom Indexierungsmodell aus dem Issue-Text verfasst.
Beschreibung
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.
- Vorherrschende Sprache
- JavaScript
- Sterne
- 251k
- Forks
- 51.4k
- Ø Merge
- 2 T. 4 Std.
- Gemergte PRs (30 T.)
- 50
Beitragsleitfaden
Erste Schritte
- Lesen Sie das ganze Issue und danach den Beitragsleitfaden des Projekts.
- Schreiben Sie ins Issue, dass Sie es übernehmen — das erspart doppelte Arbeit.
- Forken Sie das Repository und arbeiten Sie in einem Branch.
- Öffnen Sie einen Pull Request, der die Issue-Nummer nennt.
Mehr aus react/react
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 78/100
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 76/100
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 70/100
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 72/100
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 74/100
Ähnliche Issues
-
awaiting triage bug Causes friction Hop Gui P1 P2 Transforms
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 75/100
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 75/100
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 75/100
-
Improve Title Support Offen
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 70/100
georgestephanis/p2026#40 ·
-
Enatega Customer and Rider app: Add-ons price is not visible to customer after order is placed. Offen
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 75/100
Margaret-Petersen/food-delivery-app-clone-react-native#1981 ·