Hydration runs all Suspense boundaries in one long task, so main-thread freezes are much longer than React's (0.3.1)

Aberta
#156 0 comentários 0 reações 0 responsáveis Ver no GitHub

Mantenedores costumam responder em até 1 dia

Ninguém assumiu esta issue ainda.

Avaliação

Dificuldade
5/5
Tempo estimado
Mais de uma semana
Facilidade para iniciantes
35/100
Tipo de issue
Bug
Clareza
Razoavelmente clara
Status de atividade
Ativa
Stack de tecnologia
playwright, react, typescript, vite
Domínio
frontend, performance

Direção de pesquisa

Start with packages/redact/src/dom/features/suspense-hydration/full.ts and trace flushPending → flushRoot → renderSuspense → hydrateSuspenseBoundary; compare how React schedules hydration across Suspense boundaries. The issue includes a reproduction, measurements, and an experimental patch, but flags unresolved useId ordering in #155, event replay, and budget choices. Done means reducing long main-thread tasks while preserving hydration correctness and validating the relevant tests and interactions.

Escrita pelo modelo de indexação a partir do texto da issue.

Descrição

Summary

Redact hydrates every Suspense boundary on a page in one uninterrupted task, while React splits the work up. On a synthetic page with 20 boundaries, the longest main-thread task is about 54 ms on React and about 464 ms on Redact. A real TanStack Start app showed the same pattern, with freezes 1.4–2.6× longer than React's.

A small experimental patch that defers boundaries to the next task after a time budget brings the synthetic case down to 101–144 ms. It's included below in case it helps, since I can't open PRs here.

Reproduction

app.jsx

import { Suspense, useState } from 'react'

const SECTIONS = 20
const CARDS = 40

function Card({ section, index }) {
  const [liked, setLiked] = useState(false)
  let hash = 0
  for (let i = 0; i < 60000; i++) hash = (hash * 31 + i + index) | 0
  return (
    <article data-hash={hash}>
      <h3>Card {section}-{index}</h3>
      <button onClick={() => setLiked(!liked)}>{liked ? 'Liked' : 'Like'}</button>
    </article>
  )
}

function Section({ section }) {
  return (
    <section>
      {Array.from({ length: CARDS }, (_, index) => (
        <Card key={index} section={section} index={index} />
      ))}
    </section>
  )
}

export function App() {
  return (
    <main>
      {Array.from({ length: SECTIONS }, (_, section) => (
        <Suspense key={section} fallback={<p>loading</p>}>
          <Section section={section} />
        </Suspense>
      ))}
    </main>
  )
}

Render it on the server with renderToReadableStream, awaiting allReady. Hydrate it with hydrateRoot, build with and without the redact() plugin, and read longtask entries from a PerformanceObserver added in an inline script before the client bundle.

Runtime Longest task Blocking time (sum over 50 ms) Long tasks
React 19.3.1 54–55 ms 4–5 ms 1
Redact 0.3.1 464–473 ms 414–423 ms 1
Redact with the patch below 101–144 ms 92–135 ms 3

These are production builds in Chromium with 4× CPU slowdown, as medians of 7 loads in each of two passes. The page stays correct: a button clicked after load works in all three. The patched build is main plus the patch below and my fix for #153.

In a real app

This is a private TanStack Start app with the React Compiler, measured on mocked backends in Chromium at a phone-sized viewport. Figures are medians of 5 cold loads, comparing React with Redact 0.3.1.

Page and conditions Longest task (ms) Blocking time (ms) Ready to use (ms)
Landing page, slow 4G, 4× CPU 132 → 240 87 → 228 3057 → 2794
Search results, slow 4G, 4× CPU 140 → 295 179 → 456 3527 → 3285
Landing page, fast 4G, 2× CPU 64 → 115 14 → 65 663 → 616
Search results, fast 4G, 2× CPU 83 → 219 59 → 369 721 → 725
  • Smaller bundles help: pages are ready sooner in most cases, thanks to the 45–47 KB gzip saved per route. Longest task and blocking time got worse on every page in both rounds I ran.
  • Scheduling looks like the main cause: CPU profiles showed total JavaScript time only 8–22% higher. On search results at slow 4G, one load's long tasks were 158, 85, 80, 78 and 71 ms on React, and 341 and 242 ms on Redact.
  • Where the time goes: the largest Redact task is flushPending → flushRoot → renderSuspense → hydrateSuspenseBoundary, which hydrates the route's content and runs its layout effects together.

I measured page load, not interactions, so the effect on INP is an inference.

This differs from #75, which found Redact's hydration blocking less than React's on two sites. So this may depend on the page. On those sites, React's longest task was already 377–500 ms.

Experimental patch

Redact already rolls back a suspended boundary and lets its siblings continue. This reuses that path to defer a boundary once hydration has run for 25 ms:

--- a/packages/redact/src/dom/features/suspense-hydration/full.ts
+++ b/packages/redact/src/dom/features/suspense-hydration/full.ts
@@ -84,6 +84,23 @@ export function recoverBoundaryHydration(...)
+const HYDRATION_BUDGET_MS = 25;
+let sliceStart = 0;
+function nextTask(): Promise<void> {
+    return new Promise(resolve => {
+        const channel = new MessageChannel();
+        channel.port1.onmessage = () => resolve();
+        channel.port2.postMessage(0);
+    });
+}
+function hydrationBudgetSpent(): boolean {
+    const now = performance.now();
+    if (!sliceStart) {
+        sliceStart = now;
+        nextTask().then(() => { sliceStart = 0; });
+    }
+    return now - sliceStart > HYDRATION_BUDGET_MS;
+}
 export function hydrateSuspenseBoundary(fiber: Fiber, props: any, boundary: [
@@ -105,6 +122,13 @@ export function hydrateSuspenseBoundary(...)
     if (!pendingBoundary) {
+        if (hydrationBudgetSpent()) {
+            fiber.b.w = true;
+            retryHydration(fiber, nextTask());
+            advanceCursorPast(fiber.p!, endMark);
+            fiber.m = props;
+            return;
+        }
         // Real DOM is inline between startMark and endMark. Hydrate into it.

In the real app, in A-B-B-A order, it reduced blocking time by 13–27%. The longest task fell 7–19%, leaving it 1.5–1.8× React's. Part of what's left is a single task that starts the scripts and hydrates the page shell, which this patch can't split.

Please treat this as evidence that the work can be split, not as a finished fix:

  • Testing: only Redact's hydration and Suspense test files were run (240 passed), not the full CI or size checks. The app's mocked e2e suite showed no new failures.
  • Interaction with #155: deferring a boundary changes render order, and useId values currently follow render order. I'd expect this to cause ID mismatches until #155 is fixed.
  • Unknowns: I haven't checked event replay on deferred boundaries beyond the click above, or chosen the 25 ms budget carefully.
Environment
  • @tanstack/redact 0.3.1, default redact() preset
  • React 19.3.1 (control)
  • Synthetic page: Vite 7.3.7, @vitejs/plugin-react 5.2.0, Node 26
  • Chromium 156 (Playwright)
Linguagem predominante
TypeScript
Estrelas
537
Forks
22
Merge médio
10h 27min
PRs com merge (30d)
40

Preparar o ambiente

Este projeto não oferece contêiner de desenvolvimento, Dockerfile nem guia de contribuição, então a configuração fica por sua conta: comece pelo README e veja nosso guia da primeira contribuição para os passos gerais.

Primeiros passos

  1. Leia a issue inteira e depois o guia de contribuição do projeto.
  2. Comente na issue dizendo que vai assumir — evita que duas pessoas façam o mesmo trabalho.
  3. Faça um fork do repositório e trabalhe em uma branch.
  4. Abra um pull request que referencie o número da issue.

Mais de TanStack/redact

Todas as issues de TanStack/redact

Issues semelhantes

Mais issues de TypeScript

Receba novas issues na sua caixa de entrada

Um resumo curto de issues do GitHub para quem está começando.