Hydration runs all Suspense boundaries in one long task, so main-thread freezes are much longer than React's (0.3.1)
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
useIdvalues 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/redact0.3.1, defaultredact()preset- React 19.3.1 (control)
- Synthetic page: Vite 7.3.7,
@vitejs/plugin-react5.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
- Leia a issue inteira e depois o guia de contribuição do projeto.
- Comente na issue dizendo que vai assumir — evita que duas pessoas façam o mesmo trabalho.
- Faça um fork do repositório e trabalhe em uma branch.
- Abra um pull request que referencie o número da issue.
Mais de TanStack/redact
-
Dificuldade 2/5 1-3 horas Facilidade para iniciantes 84/100
TanStack/redact#142 · 4 comentários ·
Mantenedores costumam responder em até 1 dia
-
Dificuldade 2/5 1-3 horas Facilidade para iniciantes 85/100
Mantenedores costumam responder em até 1 dia
-
Dificuldade 2/5 1-3 horas Facilidade para iniciantes 78/100
Mantenedores costumam responder em até 1 dia
-
Dificuldade 3/5 1-2 dias Facilidade para iniciantes 55/100
TanStack/redact#164 · 1 comentário ·
Mantenedores costumam responder em até 1 dia
-
Dificuldade 4/5 3-5 dias Facilidade para iniciantes 49/100
Mantenedores costumam responder em até 1 dia
Todas as issues de TanStack/redact
Issues semelhantes
-
Dificuldade 2/5 1-3 horas Facilidade para iniciantes 75/100
transitmatters/shutdown-tracker#155 ·
Mantenedores costumam responder em até 2 dias
-
Popping out a single panel leaves its always render overlay in the main window, blocking clicksAbertabug triaged
Dificuldade 2/5 1-3 horas Facilidade para iniciantes 76/100
Mantenedores costumam responder em até 1 dia
-
Dificuldade 2/5 1-3 horas Facilidade para iniciantes 65/100
Mantenedores costumam responder em até 1 dia
-
"Claude Code request is missing its initial user message" when the prompt starts with an image pathAberta
Dificuldade 2/5 1-3 horas Facilidade para iniciantes 84/100
earendil-works/pi-claude-code#31 ·
Mantenedores costumam responder em até 8 dias
-
bug pending triage
Dificuldade 1/5 Menos de uma hora Facilidade para iniciantes 92/100
privatenumber/cleye#48 ·