Store update that suspends behind an unchanged memo is lost: the boundary retry bails out and the UI never updates (0.2.4)

Đang mở
#130 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
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
Lĩnh vực
frontend

Hướng nghiên cứu

Start with the suspense path in packages/redact/src/dom/features/suspense/full.ts, around realHandleSuspended, where RenderSuspenseCapture is thrown without the thenable. Then read renderUpdates and the memo bailout in packages/redact/src/dom/features/memo/full.ts to see why a pending useSyncExternalStore subscriber is skipped on retry. Reproduce with the snippet in the issue, using the ?control flag as the baseline. Done means the store update appears after the promise resolves under memo, matching React 19.3.1.

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 component subscribed with useSyncExternalStore sits behind a memo wrapper with unchanged props. When the store changes and the component suspends, Redact never shows the new content: the screen keeps the old UI after the promise has resolved, with no error. React shows the new content once the promise resolves.

Reproduction

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

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

let user = null
const listeners = new Set()
const subscribe = (l) => (listeners.add(l), () => listeners.delete(l))
const getUser = () => user
let orders
const loadOrders = () => (orders ??= new Promise((resolve) => setTimeout(() => resolve("orders"), 100)))

function Account() {
  const current = useSyncExternalStore(subscribe, getUser)
  return <p>{current ? `${current}: ${use(loadOrders())}` : "logged out"}</p>
}
const Wrapper = memo(() => <Account />)
const control = new URLSearchParams(location.search).has("control")
createRoot(document.getElementById("root")).render(
  <Suspense fallback="loading">{control ? <Account /> : <Wrapper />}</Suspense>,
)

window.go = () => { user = "alice"; listeners.forEach((l) => l()) }

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

React 19.3.1 Redact 0.2.4
As written (behind memo) 10/10 alice: orders 10/10 logged out
?control (no memo) 10/10 alice: orders 10/10 alice: orders

The Redact page still shows logged out 11 s after go(), so the update is lost, not delayed.

Real-world impact

On a TanStack Start storefront running 0.2.3, a successful sign-up (a useActionState action, then queryClient.invalidateQueries() and router.invalidate()) left the sign-up form on screen. The session was created, and a reload showed the account page. With React the account page appears right away.

Likely cause

Tracing the repro:

  1. The store notification schedules Account, which renders on its own in renderUpdates and suspends.
  2. realHandleSuspended throws RenderSuspenseCapture(boundary), which carries the boundary but not the thenable.
  3. renderUpdates rewinds and reschedules the boundary. Account's pending flag was cleared by rerenderFiber before it suspended.
  4. The boundary re-renders its children. The memo sees equal props and bails out (memo/full.ts#L21-L36), so Account is not rendered, nothing suspends, and nothing waits for the promise.

The update is dropped. In React it stays pending on the fiber until it commits, and the boundary retries when the promise resolves.

The React 19.3 audit already fixed the context version of this ("equal-prop memo wrappers do not need to render to reach their consumers. Suspended primary content can retry when its context changes"). External-store subscribers seem to need the same treatment.

Restoring fiber.g = true after the rewind was not enough in my test on 0.2.4, because the boundary's retry still stops at the memo. The fix probably needs the retry to reach pending descendants, like the context propagation path does.

Related: #129, where a pending subscriber under the same suspended boundary renders with rewound props.

Ngôn ngữ chính
TypeScript
Star
537
Fork
22
Merge trung bình
2 giờ 19 phút
Pull request đã merge (30 ngày)
24

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.