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

Abierto
#37,666 1 comentario 0 reacciones 0 asignados Ver en GitHub

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

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
  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.

Lenguaje dominante
JavaScript
Estrellas
251k
Forks
51.4k
Merge medio
2 d 1 h
PR fusionados (30 d)
51

Guía de contribución

Abrir la guía de contribución

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de react/react

Todos los issues de react/react

Issues similares

Más issues de JavaScript

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.