useSyncExternalStore: a subscriber under a suspended boundary renders with rewound props and the new snapshot (0.2.4)

Đang mở
#129 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

@tannerlinsley đang làm issue này rồi.

Từ ngày 2/11/2026.

  • #133 của @tannerlinsley — đang mở

Đánh giá

Độ khó
4/5
Thời gian dự kiến
3-5 ngày
Mức phù hợp với người mới
18/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ệ
react, typescript, vite
Lĩnh vực
frontend

Hướng nghiên cứu

Start in packages/redact/src/dom/reconcile.ts, in renderUpdates (around lines 337-371), where pending fibers are sorted and a RenderSuspenseCapture rewinds the commit. Run the reproduction in the issue, with and without ?control, to confirm the Item render sees id: "a" after items.a is cleared. Done when the repro shows B loaded instead of the TypeError, and Redact's own test suite passes with the change.

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

Mô tả

🤖 Issue drafted with Claude Code. Reproduced and verified before posting.

Version: @tanstack/redact 0.2.4 (also 0.2.3). Baseline: react/react-dom 19.3.1. Chromium, production build with vite 8.3.5, @vitejs/plugin-react 6.2.1 and the redact() plugin.

A store update switches a component to a new prop, and the new render suspends. Redact then renders the same component with its old props and the new store snapshot. In the repro below, that render reads a store that the update has just emptied, and the app crashes. React shows the new content once the data resolves.

This looks like a variant of #87, which was fixed in 0.2.2: here the child keeps its position and type and only its prop changes.

Reproduction

Vite app with react() + redact(), then call go() from the console:

import { Suspense, use, useSyncExternalStore } from "react"
import { createRoot } from "react-dom/client"

const store = (value) => {
  const listeners = new Set()
  return {
    get: () => value,
    set: (next) => { value = next },
    emit: () => listeners.forEach((l) => l()),
    subscribe: (l) => (listeners.add(l), () => listeners.delete(l)),
  }
}
const page = store("a")
const items = { a: store({ label: "A" }), b: store({ label: "B" }) }
let slow
const load = () => (slow ??= new Promise((resolve) => setTimeout(() => resolve("loaded"), 300)))
const suspend = !new URLSearchParams(location.search).has("control")

function Item({ id }) {
  const item = useSyncExternalStore(items[id].subscribe, items[id].get)
  return <p>{item.label} {id === "b" && suspend ? use(load()) : null}</p>
}
function Page() {
  const id = useSyncExternalStore(page.subscribe, page.get)
  return <Item id={id} />
}
createRoot(document.getElementById("root")).render(<Suspense fallback="loading"><Page /></Suspense>)

// Switch to "b" and drop "a", then notify (like a router updating its stores).
window.go = () => { page.set("b"); items.a.set(undefined); page.emit(); items.a.emit() }

Result 2 s after go(), 10 runs each:

React 19.3.1 Redact 0.2.4
As written 10/10 B loaded 10/10 TypeError: Cannot read properties of undefined (reading 'label'), empty root
?control (nothing suspends) 10/10 B 10/10 B

The result is the same with the two notifications in the other order, and with the update wrapped in startTransition.

Real-world impact

With TanStack Router 1.170.46, this crashes client navigation from /account to /account/profile when the child route's component suspends during render: Cannot read properties of undefined (reading 'routeId') in MatchImpl, caught by the route's error boundary. I first hit it in a TanStack Start app on 0.2.3. A router-only repro (a layout route with an <Outlet />, and a child route whose component calls use(promise)) still crashes on 0.2.4. The same routes without the layout do not crash.

Likely cause

Tracing renderUpdates (reconcile.ts#L337-L371) shows this sequence:

  1. Both Page and Item are pending and sorted by depth.
  2. Page re-renders and its child Item (now id: "b") suspends. The RenderSuspenseCapture thrown out of Page's render rewinds the commit, which restores Item's props to id: "a", then the boundary is rescheduled.
  3. The loop continues to Item, which is still pending. It renders with id: "a" while items.a is already undefined.

React never renders a component with props its parent no longer gives it: the boundary's retry renders the subtree top-down.

A local patch fixes both this repro and the router repro on 0.2.4. It skips pending fibers that sit under a boundary that suspended earlier in the same batch, and lets the boundary retry render them:

const suspended = new Set<Fiber>()
for (const fiber of pending) {
    if (!fiber.g || fiber.u || fiber.pd)
        continue
    let ancestor = fiber.p
    while (ancestor && !suspended.has(ancestor))
        ancestor = ancestor.p
    if (ancestor)
        continue
    // ...
        if (error instanceof RenderSuspenseCapture) {
            rewindCommit(checkpoint)
            error.b.b.u = true
            suspended.add(error.b)
            scheduleUpdate(error.b)
            continue
        }

I have only checked it against these repros, not against Redact's test suite. A related case, where the boundary retry does not reach a pending subscriber behind an unchanged memo, is filed separately as #130.

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.