useId values depend on render order, so a lazy component loaded only on the server makes hydration discard server HTML (0.3.1)

Offen
#155 0 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen

Maintainer antworten meist innerhalb von 1 Tag

Dieses Issue hat noch niemand übernommen.

Bewertung

Schwierigkeit
4/5
Geschätzter Aufwand
3-5 Tage
Anfängerfreundlichkeit
54/100
Issue-Typ
Bug
Klarheit
Klar beschrieben
Aktivitätsstatus
Aktiv
Tech-Stack
react, typescript
Bereich
frontend

Rechercherichtung

Start by reading the ID counters in server/dispatcher.ts and dom/dispatcher.ts, then trace how the server and browser assign IDs when a lazy component suspends only in the browser. Reproduce the issue with the provided field.jsx, app.jsx, server.jsx, and client.jsx example by calling render() twice before hydration. Done means the second server render hydrates without replacing the section or either label.

Vom Indexierungsmodell aus dem Issue-Text verfasst.

Beschreibung

Summary

Redact's useId values depend on render order, so they change when a lazy component is already loaded on the server but not yet in the browser. The ID mismatch makes Redact silently discard the server-rendered section and render it again on the client. React keeps all of its server HTML in the same app.

This is the normal case on a running server: the lazy module loads on the first request and renders inline from then on, while each new visitor's browser still has to fetch it.

Reproduction

field.jsx

import { useId } from 'react'

export default function LazyField() {
  const id = useId()
  return <label id={id}>lazy</label>
}

app.jsx

import { Suspense, lazy, useId } from 'react'

const LazyField = lazy(() => import('./field.jsx'))

function Field() {
  const id = useId()
  return <label id={id}>eager</label>
}

export function App() {
  return (
    <main>
      <Suspense fallback={<p>loading section</p>}>
        <section>
          <Suspense fallback={<p>loading field</p>}>
            <LazyField />
          </Suspense>
          <Field />
        </section>
      </Suspense>
    </main>
  )
}

server.jsx

import { renderToReadableStream } from 'react-dom/server'
import { App } from './app.jsx'

export async function render() {
  const stream = await renderToReadableStream(<App />)
  await stream.allReady
  return new Response(stream).text()
}

client.jsx

import { hydrateRoot } from 'react-dom/client'
import { App } from './app.jsx'

hydrateRoot(document.getElementById('root'), <App />)

Build the server and client with and without the redact() plugin. Call render() twice and serve the second result, as a server would after its first request. Then load the client bundle.

Runtime Server HTML After hydration Server nodes Errors reported
React 19.3.1 _R_1_, _R_2_ _R_1_, _R_2_ All kept None
Redact 0.3.1 :R0, :R1 :r1, :r0 section and both labels replaced None

I checked node identity with a snapshot taken in an inline script before the client bundle loads. The result was the same across repeated runs.

The first render() doesn't show the problem. There the lazy component suspends on the server too, so the numbers happen to line up.

Cause

From reading the source, both sides use a counter in render order: frame.idCounter++ in server/dispatcher.ts and root.ic++ in dom/dispatcher.ts. In this case:

  1. The warm server renders LazyField first, so it gets :R0 and Field gets :R1.
  2. In the browser, LazyField suspends and its boundary is skipped, so Field takes :R0.
  3. :R0 doesn't match the server's :R1, so hydration of the outer boundary fails and it recovers by rendering on the client.

React avoids this by deriving IDs from tree position. Redact already has that logic on the client for native React servers (identifierStrategy: 'react'), so using tree-position IDs on Redact's own server may be one way to fix it. I haven't tested that.

Real-world impact

In a TanStack Start app with code-split components, the top section of a page was rebuilt about 150 ms after load for signed-in visitors, which throws away the work the server already did. An experimental patch that corrected the mismatched attribute in place stopped the rebuild, which points to the ID mismatch as the trigger.

Because nothing is reported, this is easy to miss. No error is logged in a production build unless the app has passed its own onRecoverableError, and I didn't test that path.

Environment
  • @tanstack/redact 0.3.1, default redact() preset
  • React 19.3.1 (control)
  • Vite 7.3.7, @vitejs/plugin-react 5.2.0
  • Chromium 156 (Playwright), Node 26
Vorherrschende Sprache
TypeScript
Sterne
537
Forks
22
Ø Merge
10 Std. 27 Min.
Gemergte PRs (30 T.)
40

Entwicklungsumgebung

Dieses Projekt bietet weder Dev-Container noch Dockerfile noch Beitragsleitfaden – die Einrichtung liegt bei Ihnen. Beginnen Sie mit der README; die allgemeinen Schritte stehen in unserem Leitfaden für den ersten Beitrag.

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 TanStack/redact

Alle Issues in TanStack/redact

Ähnliche Issues

Weitere Issues zu TypeScript

Neue Issues direkt in Ihr Postfach

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