Skip to content

fix(app-router): keep mounted-slot RSC MISS responses no-store - #2728

Open
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:fix/mounted-slot-rsc-no-store
Open

fix(app-router): keep mounted-slot RSC MISS responses no-store#2728
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:fix/mounted-slot-rsc-no-store

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

What

finalizeAppPageRscCacheResponse derives "should I rewrite the client-facing headers?" from the return value of scheduleAppPageRscCacheWrite. Those are independent decisions, and #2497 made them disagree.

That PR correctly stopped persisting mounted-slot RSC variants — their RSC cache key is slot-blind (isrRscKey(pathname, null, ...)), so writing a slot-specific payload under it would poison the store. But it expressed that as a new early return in scheduleAppPageRscCacheWrite, and the finalizer's if (!didSchedule) return response took the pending-dynamic header rewrite down with it.

Effect

A fresh ISR-eligible RSC MISS carrying X-Vinext-Mounted-Slots keeps the s-maxage=..., stale-while-revalidate it was built with, instead of being finalized to no-store, must-revalidate:

response before after
RSC MISS, no mounted slots no-store, must-revalidate unchanged
RSC MISS, slot:auth:/ s-maxage=60, stale-while-revalidate no-store, must-revalidate

consumeDynamicUsage() runs at app-page-render.ts:817, before the shell has flushed, so a MISS response has not yet proven its render was non-dynamic — that is what the provisional no-store is for. Skipping it for slot variants was not intended by #2497, which only meant to change the persistent write.

Fix

Gate the header rewrite on preserveClientResponseHeaders alone. At the only production call site that is already rscResponsePolicy.cacheState !== "MISS" (app-page-render.ts:913), so this is a no-op for every non-MISS response and correct-by-construction for MISS.

Structural rather than adding a mountedSlotsHeader term to the condition: scheduleAppPageRscCacheWrite already has three early returns, and the next one added would silently reintroduce this. finalizeAppPageHtmlCacheResponse never coupled the two — this brings the RSC path back in line with it.

Restores the client-facing behavior that shipped before #2497 while keeping its cache-write change, so there is no CDN hit-rate tradeoff: those responses were already no-store on the client leg.

Verification

  • New test in tests/app-page-cache.test.ts asserts both halves — the slot variant is finalized to no-store and still never reaches isrSet. Confirmed red without the source change (expected 's-maxage=60, stale-while-revalidate' to be 'no-store, must-revalidate'), green with it.
  • tests/app-page-cache.test.ts (39), tests/app-page-dispatch.test.ts (67), tests/app-router-production-server.test.ts (73) pass.
  • pnpm run check clean.

No changeset added — happy to add one if this warrants a release note.

finalizeAppPageRscCacheResponse derived "should I rewrite the client
headers?" from the return value of scheduleAppPageRscCacheWrite. Those are
independent decisions, and cloudflare#2497 made them disagree: mounted-slot variants
now correctly skip the persistent write (their RSC key is slot-blind), but
the early return took the pending-dynamic finalization with it.

The result is that a fresh ISR-eligible RSC MISS carrying
X-Vinext-Mounted-Slots leaves the origin with its initial
`s-maxage=..., stale-while-revalidate` instead of being rewritten to
`no-store, must-revalidate`. That header is what stops a shared cache from
storing a stream that may still reach cookies()/headers() below a Suspense
boundary after the cache policy was chosen, so a personalized payload can be
stored and replayed for the URL/variant. Apps with named parallel routes send
the header on essentially every client navigation; apps without slots never
enter the path.

Gate the header rewrite on preserveClientResponseHeaders alone, which is
already `cacheState !== "MISS"` at the only production call site. This
restores the client-facing behavior that shipped before cloudflare#2497 while keeping
its cache-write change, and matches finalizeAppPageHtmlCacheResponse, which
never coupled the two. Doing it structurally rather than adding a
mountedSlotsHeader term means the next early return added to
scheduleAppPageRscCacheWrite cannot silently reintroduce this.
@pkg-pr-new

pkg-pr-new Bot commented Jul 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2728
npm i https://pkg.pr.new/create-vinext-app@2728
npm i https://pkg.pr.new/@vinext/types@2728
npm i https://pkg.pr.new/vinext@2728

commit: aab33cc

@github-actions

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared aab33cc against base b779754 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 132.4 KB 132.4 KB ⚫ -0.0%
Client entry size (gzip) vinext 119.8 KB 119.8 KB ⚫ -0.0%
Dev server cold start vinext 2.87 s 2.86 s ⚫ -0.4%
Production build time vinext 2.97 s 3.01 s ⚫ +1.3%
RSC entry closure size (gzip) vinext 105.6 KB 105.6 KB ⚫ +0.0%
Server bundle size (gzip) vinext 179.8 KB 179.8 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

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.

1 participant