Skip to content

fix(ppr): collect shell tags at the putShell write barrier — async-recorded tags evict the shell (#676) - #680

Merged
ivogt merged 1 commit into
mainfrom
ppr/shell-tag-write-barrier
Jul 4, 2026
Merged

ivogt merged 1 commit into
mainfrom
ppr/shell-tag-write-barrier

Conversation

@ivogt

@ivogt ivogt commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator

Closes #676.

Problem statement

A cacheTag() recorded after an await in an async server component that renders into a PPR shell was silently dropped from the shell entry — revalidateTag() with that tag would not evict the shell. The same window applied to tags propagated by async cache()/"use cache" reads during capture. Landed as a documented known edge with #678; consumers following the render-callable cacheTag() pattern in async components got un-evictable shells with no signal.

Root cause and fix

The shell's tag snapshot ran at RSC-stream construction time in attemptCapture — before the capture had rendered or quiesced. Synchronous tags survived only because React renders the sync tree during construction. The fix moves the snapshot (and the union with static ppr.tags) to the putShell write barrier inside captureAndStoreShell — after the capture quiesces and the deferred cache writes are awaited — so anything recorded at any point before the write is collected. Moved, not duplicated: shell-capture.ts has exactly one tag snapshot site. The hole invariant is unchanged (masked loaders never execute during capture, so nothing under a renderable loading() boundary can tag the shell); the document cache never shared the edge (it buffers before collecting).

Before vs. after

async function CampaignBanner() {
  const campaign = await getActiveCampaign();   // ← the await
  cacheTag(`campaign:${campaign.id}`);          // recorded after it
  return <aside>{campaign.name}</aside>;
}
Case Before After
Tag recorded after an await in async shell content dropped — revalidateTag cannot evict the shell collected; revalidateTag drops the shell
Tags from an async "use cache" read at capture same window, could be dropped collected (the barrier sits after the deferred-write awaits)
Sync-recorded tag (canonical #648 form) collected unchanged
Static ppr.tags union + dedupe at construction same data, unioned at the barrier
Tag under a renderable loading() hole impossible (masked) unchanged — impossible
Document cache tags unaffected (buffers first) unchanged

Code-change summary

One production file: src/rsc/shell-capture.ts (+44/−29 incl. comments) — the collect/union block deleted from attemptCapture, recreated at the putShell site in captureAndStoreShell (whose reqCtx is the derived capture context), ShellCaptureDescriptor.tags docblock updated. Everything else is tests, fixture, and the design-doc contract update.

Test plan

  • Red-before-green, both new unit cases fail on pre-move source (re-verified by reviewer): async component tag after a macrotask hop; async "use cache" propagation simulated via the exact recordRequestTags(entry.tags, requestCtx) call cache-runtime.ts:384 makes on a read-HIT (substitution over standing up a cached fn — noted in the test).
  • All feat(cache): render-callable cacheTag() — PPR shell/document tags ride the existing instrument (#648) #678 tag tests pass with assertions untouched. Five non-tag captureAndStoreShell stub tests gained the required _requestTags: new Set() field their mock omitted (production always seeds it); their existing tags: ["t1"] assertions passing unchanged is itself the data-preservation proof for the move.
  • e2e dev + production: ppr-tag-eviction gains the async case (async-tagged shell → HIT → revalidateTag → recapture), probe-gated with a unique per-run token like the sync case; existing cases untouched. 4+4 green.
  • Capture-semantics regression gate: full ppr-shell suite 19+19 green in dev and production.
  • Gates: typecheck, test:unit:all (708 in the affected suites), lint, format, check:e2e-bucketing, zero .gen.ts churn.

Notes

  • Strictly more complete collection: a tag recorded between quiesce and the write (previously dropped) is now included — the invariant "everything recorded during capture evicts the shell" is exact.
  • The known-edge paragraph in docs/design/ppr-shell-resume.md is replaced by the write-barrier contract.
  • One ppr-shell dev test flaked once in the executor's first full-suite run and passed on re-runs; the fixture change is provably inert for it (the async tagger renders only behind a search-param probe that test never sends), and the reviewer's fresh 19+19 runs were clean.
  • If build-time capture (PPR: build-time shell capture (prerender × ppr) #649) lands, its putShell path should reuse the same barrier-collect.

@pkg-pr-new

pkg-pr-new Bot commented Jul 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@680

commit: 0d903b7

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PPR capture: tags recorded after an await in async shell content miss the shell tag snapshot

1 participant