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

Đang mở
#156 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

Maintainer thường phản hồi trong vòng 1 ngày

Chưa có ai nhận issue này.

Đánh giá

Độ khó
5/5
Thời gian dự kiến
Hơn một tuần
Mức phù hợp với người mới
35/100
Loại issue
Lỗi
Độ rõ ràng
Khá rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
playwright, react, typescript, vite
Lĩnh vực
frontend, performance

Hướng nghiên cứu

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.

Do mô hình lập chỉ mục viết ra từ nội dung của issue.

Mô tả

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)
Ngôn ngữ chính
TypeScript
Star
537
Fork
22
Merge trung bình
11 giờ 37 phút
Pull request đã merge (30 ngày)
35

Chuẩn bị môi trường

Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.

Bắt đầu từ đâu

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của TanStack/redact

Tất cả issue của TanStack/redact

Issue tương tự

Thêm issue về TypeScript

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.