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

Aberta
#129 0 comentários 0 reações 0 responsáveis Ver no GitHub

Mantenedores costumam responder em até 1 dia

Ninguém assumiu esta issue ainda.

Avaliação

Dificuldade
4/5
Tempo estimado
3-5 dias
Facilidade para iniciantes
18/100
Tipo de issue
Bug
Clareza
Razoavelmente clara
Status de atividade
Ativa
Stack de tecnologia
react, typescript, vite
Domínio
frontend

Direção de pesquisa

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.

Escrita pelo modelo de indexação a partir do texto da issue.

Descrição

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

Linguagem predominante
TypeScript
Estrelas
537
Forks
22
Merge médio
2h 19min
PRs com merge (30d)
24

Preparar o ambiente

Este projeto não oferece contêiner de desenvolvimento, Dockerfile nem guia de contribuição, então a configuração fica por sua conta: comece pelo README e veja nosso guia da primeira contribuição para os passos gerais.

Primeiros passos

  1. Leia a issue inteira e depois o guia de contribuição do projeto.
  2. Comente na issue dizendo que vai assumir — evita que duas pessoas façam o mesmo trabalho.
  3. Faça um fork do repositório e trabalhe em uma branch.
  4. Abra um pull request que referencie o número da issue.

Mais de TanStack/redact

Todas as issues de TanStack/redact

Issues semelhantes

Mais issues de TypeScript

Receba novas issues na sua caixa de entrada

Um resumo curto de issues do GitHub para quem está começando.