Hacktoberfest 2026: los issues que los mantenedores marcaron para octubre, abiertos y aptos para principiantes. Explorar issues de Hacktoberfest

Bug: hydration restart storm — a root that suspends during hydration re-renders thousands of times when `useDeferredValue(value, initialValue)` is mounted

Abierto
#37,682 2 comentarios 1 reacción 0 asignados Ver en GitHub

Los mantenedores suelen responder en 1 día

Nadie ha tomado este issue todavía.

Evaluación

Dificultad
5/5
Tiempo estimado
Más de una semana
Aptitud para principiantes
35/100
Tipo de issue
Error
Claridad
Bastante claro
Estado de actividad
Activo
Stack tecnológico
javascript, react

Línea de trabajo

Reproduce the inline JSX case with the provided esbuild command and inspect the hydration path through mountDeferredValueImpl, renderDidSuspendDelayIfPossible, markRootSuspended, markSpawnedDeferredLane, and markRootFinished. Compare it with the no-initialValue, Suspense, and createRoot variants described in the issue. Done means root hydration waits for the lazy ping without repeated empty deferred-lane commits, while hydration still completes.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

Status: Unconfirmed

React version: 19.3.0 (latest). Also reproduced on 19.2.8 and on 19.3.0-canary-cbb046ab-20260731 (the copy vendored in Next.js 16.3.4). The relevant functions (markSpawnedDeferredLane, markRootSuspended, markRootFinished, mountDeferredValueImpl) are unchanged on main as of 2026-09-24.

Steps To Reproduce

  1. Bundle the code below: esbuild repro.jsx --bundle --jsx=automatic --define:process.env.NODE_ENV='"production"' --outfile=bundle.js.
  2. Open the HTML below, then read window.__counter.app after about 1.8 s.

Link to code example: inline, with no dependencies other than React.

import { useDeferredValue, lazy, startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'

const counter = (window.__counter = { app: 0 })
function Real() { return <i>real</i> }
// A lazy element type whose module arrives after hydration starts (like a Flight client reference)
const Lazy = lazy(() => new Promise((r) => setTimeout(() => r({ default: Real }), 1000)))

function App() {
  counter.app++
  const v = useDeferredValue('value', 'value') // same shape as Next's layout-router during hydration
  return <div><span>{v}</span><Lazy /></div>
}

startTransition(() => hydrateRoot(document.getElementById('root'), <App />))
<div id="root"><div><span>value</span><i>real</i></div></div>
<script src="bundle.js"></script>

The current behavior

Suppose a hydration render suspends outside any Suspense boundary, and the tree contains a useDeferredValue with an initialValue. React then re-renders the root in a tight loop until the thenable resolves. In a 1-second window this means ~80,000–150,000 renders in Chromium and ~7,000–24,000 in WebKit, where 2 are expected.

No error is reported, and hydration completes normally when the thenable resolves. The cost is CPU time spent spinning while the page is still loading.

This affects every Next.js App Router page on a cold load. app-router.js and layout-router.js call useDeferredValue(x, resolvedPrefetchRsc) during hydration. The Flight client references for the root/app layouts are lazy element types whose chunks usually arrive after hydration has started. We measured 13k–17k root restarts per cold page load on a real app at 150 ms RTT (details below).

Headless Playwright 1.62.1 (Chromium 1234 / WebKit 2336), macOS, 3 loads each:

variant Chromium App renders WebKit App renders
useDeferredValue(v, initial), startTransition(hydrateRoot) 91,479 – 97,461 (19.3.0); 92,938 – 102,677 (19.2.8); 78,678 – 150,835 (canary) 9,559 – 9,667 (19.3.0); 7,009 – 8,204 (19.2.8); 7,226 – 8,110 (canary)
same, hydrateRoot without startTransition 139,120 – 142,916 (19.3.0); 125,311 – 141,163 (19.2.8); 104,954 – 136,051 (canary) 14,767 – 22,008 (19.3.0); 11,996 – 12,965 (19.2.8); 11,648 – 13,990 (canary)
useDeferredValue(v) (no initial value) 2 2
<Lazy /> wrapped in <Suspense> (server HTML with <!--$-->…<!--/$-->) 2 2
no hydration: createRoot + startTransition(() => root.render(<App />)), same tree 4 4

The last three rows are identical on 19.3.0, 19.2.8 and the canary. In every variant the real content is rendered once the lazy resolves (Real renders once), and there are no recoverable errors.

The 19.3.0 rows were measured in a later window, interleaved with 19.2.8. In that same window 19.2.8 gave 91,733–95,850 (Chromium) and 9,746–11,611 (WebKit) for the first row, and 131,964–142,750 / 20,619–24,015 for the second. Absolute counts vary with machine load, so compare within a window. The code above, run verbatim against 19.3.0, gives 95,682 – 103,096 renders in Chromium and 7,655 – 8,030 in WebKit.

What we think happens

We instrumented a production build (the Next.js-vendored canary). We counted performWorkOnRoot, prepareFreshStack, commits by lane, and each call site of markRootSuspended / markSpawnedDeferredLane. Each iteration goes like this:

  1. The hydration render (TransitionHydrationLane, 128) mounts useDeferredValue(value, initialValue). mountDeferredValueImpl calls requestDeferredLane(), which gives D, a TransitionDeferredLane (262144 → 524288 → 1048576 → 2097152, cycling). It then calls markSkippedUpdateLanes(D).
  2. The render suspends on the lazy (SuspendedOnImmediate → SuspendedAndReadyToContinue → unwind to the root; there is no boundary). renderDidSuspendDelayIfPossible() finds non-idle workInProgressRootSkippedLanes, which contain only D, the lane this same render just spawned. It calls markRootSuspended(root, 128, D), following the comment "Mark the current render as suspended so that we switch to working on the updates that were skipped". finishConcurrentRender (RootSuspendedWithDelay) calls markRootSuspended(root, 128, D) a second time. Both calls end in markSpawnedDeferredLane(root, D, 128), which makes D pending.
  3. markSpawnedDeferredLane entangles D with entangledLanes & UpdateLanes. UpdateLanes does not include the hydration lanes, so D is not entangled with 128. For an ordinary transition update the suspended lane is entangled and re-rendered together with D. The same tree rendered with createRoot in a transition finishes in 4 renders (see the table), so the loop is specific to hydration.
  4. D renders on its own. The root is still dehydrated, and no fiber in the current tree carries D, so this is a bailout. D commits an empty tree: finishedWork.child === null on every one of these commits.
  5. markRootFinished runs root.suspendedLanes = NoLanes ("Let's try everything again"). Lane 128 is pending again, ensureRootIsScheduled picks it, and the cycle repeats from step 1 with a fresh deferred lane.

Counters from one cold load of a real Next.js page, with the Flight chunk held for 1.5 s:

  • 44,952 root hydration entries.
  • 44,966 commits, of which 44,951 were empty commits on the four TransitionDeferredLanes (evenly split).
  • 89,905 prepareFreshStack calls: 44,952 for lane 128 and 44,951 for D.
  • 89,904 markSpawnedDeferredLane calls.

A separate load split the markRootSuspended calls by call site. It had 17,794 root entries, 17,793 calls from renderDidSuspendDelayIfPossible (skipped lanes = D every time) and 17,795 from finishConcurrentRender. There were only 2 markSpawnedDeferredLane calls from markRootFinished.

Causal check. In the same build, we suppressed only the markSpawnedDeferredLane call from markRootSuspended when the suspended lanes include 128, controlled by a runtime flag, with loads interleaved. Root hydration entries dropped from 13,023–16,793 to 4 per load. Hydration still completed at the same point, about 10 ms after the last client chunk arrived, in both arms.

Impact (real app, Next.js 16.3.4, cold cache, Chromium, emulated 150 ms RTT / 9 Mbps, n=5 per arm)
default spawn suppressed in hydration
root hydration restarts 15,612 (13,023–16,793) 4
main-thread task time (CDP TaskDuration) 940 ms (870–1104) 310 ms (242–1438)
script time (ScriptDuration) 610 ms (602–649) 78 ms (66–234)
hydration commit after last client chunk 10 ms 9 ms

With 4× CPU throttling we saw 764 vs 4 restarts, and 771 vs 440 ms script time. With a warm HTTP cache (chunks already cached) the storm does not happen: 1 root entry.

The expected behavior

A hydration render that suspends at the root should wait for the ping, as it does without initialValue or inside a Suspense boundary. A deferred lane spawned by a render that never committed should not cause the suspended hydration lane to be retried immediately.

Possible directions, which you know better than we do:

  • In renderDidSuspendDelayIfPossible, don't treat the render's own spawned deferred lane as a "skipped update that might unblock this render".
  • Entangle the spawned lane with hydration lanes as well.
  • Don't clear suspendedLanes for a commit that completed no work.
Lenguaje dominante
JavaScript
Estrellas
251k
Forks
51.4k
Merge medio
1 d 20 h
PR fusionados (30 d)
39

Preparar el entorno

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.