Hacktoberfest 2026: die Issues, die Maintainer für den Oktober markiert haben – offen und einsteigerfreundlich. Hacktoberfest-Issues durchsuchen

Suspense content is never revealed in a tab that has not been foregrounded

Offen
#37,666 4 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen

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

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
  1. next build && next start.
  2. Open /start in Chrome.
  3. Cmd-click (macOS) or middle-click "Recipes". The new tab opens in the
    background; stay on /start.
  4. 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 a console.log from 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

Beitragsleitfaden öffnen

Erste Schritte

  1. Lesen Sie das ganze Issue und danach den Beitragsleitfaden des Projekts.
  2. Schreiben Sie ins Issue, dass Sie es übernehmen — das erspart doppelte Arbeit.
  3. Forken Sie das Repository und arbeiten Sie in einem Branch.
  4. Öffnen Sie einen Pull Request, der die Issue-Nummer nennt.

Mehr aus react/react

Alle Issues in react/react

Ähnliche Issues

Weitere Issues zu JavaScript

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.