Hydration runs all Suspense boundaries in one long task, so main-thread freezes are much longer than React's (0.3.1)
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
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)
- 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
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- 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.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của TanStack/redact
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 84/100
TanStack/redact#142 · 2 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 85/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 55/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 54/100
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của TanStack/redact
Issue tương tự
-
cloudflare-cache-binding: `unstorage://` keys are rejected by the Cache API, cache never storesĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
Maintainer thường phản hồi trong vòng 3 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 66/100
Maintainer thường phản hồi trong vòng 2 ngày
-
reproduced
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 74/100
anomalyco/opencode#62052 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 68/100
strapi/design-system#2084 ·
Maintainer thường phản hồi trong vòng 3 ngày
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 88/100
Maintainer thường phản hồi trong vòng 1 ngày