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

オープン
#129 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

メンテナーはふだん 1 日以内に返信

まだ誰も着手していません。

評価

難易度
4/5
見積もり時間
3〜5日
初心者へのやさしさ
18/100
issue の種類
バグ
明瞭さ
おおむね明確
活発さ
活発
技術スタック
react, typescript, vite
領域
frontend

調査の方向性

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.

索引モデルが issue の本文から書いたものです。

説明

🤖 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.

主要言語
TypeScript
スター
537
フォーク
22
平均マージ
2時間 19分
マージ済み PR(30日)
24

環境構築

このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

TanStack/redact のほかの issue

TanStack/redact の issue をすべて見る

似ている issue

TypeScript の issue をもっと見る

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。