Skip to content

Fix parallel slot persistence and cache variants#811

Merged
james-elicx merged 7 commits intocloudflare:mainfrom
NathanDrake2406:fix/parallel-slot-persistence-soft-nav
Apr 10, 2026
Merged

Fix parallel slot persistence and cache variants#811
james-elicx merged 7 commits intocloudflare:mainfrom
NathanDrake2406:fix/parallel-slot-persistence-soft-nav

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Apr 10, 2026

Fixes bugs in slot components introduced in #750 that #752 caught

This tightens App Router slot behavior in three places:

  • preserve default.tsx fallback behavior correctly on soft navigation by distinguishing already-mounted slots from first entry into a layout
  • make prefetch, visited-response, and ISR RSC caches vary by mounted-slot state so stale slot payloads are not replayed across incompatible trees
  • keep the generated App Router entry and snapshots aligned with the new mounted-slot normalization and cache threading

Verification:

  • vp test run tests/app-page-cache.test.ts tests/prefetch-cache.test.ts tests/app-browser-entry.test.ts tests/app-page-route-wiring.test.ts
  • vp test run tests/app-router.test.ts -t "generated code|ISR cache read fires|generated handler exports async function handler"
  • vp test run tests/entry-templates.test.ts -u
  • vp check

Risks:

  • App Router RSC cache keys now vary on mounted-slot context for slot-sensitive responses, so cache behavior changed in a targeted way around parallel routes and intercepted routes.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 10, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@811

commit: 3ef5218

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review April 10, 2026 09:49
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Fix parallel slot persistence and cache variants

The overall approach is sound — tracking mounted-slot state on the client, sending it to the server via X-Vinext-Mounted-Slots, and using it to vary cache keys and suppress default.tsx re-emission on soft nav. The three-pronged fix (slot persistence, cache keying, prefetch/visited-response invalidation) is coherent and well-tested.

Main observations:

  1. Normalization divergence between the generated entry's __normalizeMountedSlotsHeader (splits on " ") and the typed module's normalizeMountedSlotsHeader in app-elements.ts (splits on /\s+/). They should match.
  2. Prefetch dedup key doesn't incorporate slot state (link.tsx:132), so a URL prefetched with one set of mounted slots won't be re-prefetched if slots change. consumePrefetchResponse catches the mismatch at consumption time, but the prefetch slot is wasted.
  3. useLayoutEffect runs the full body (re-assigning module-level refs) on every treeState.elements change where only setMountedSlotsHeader needs element-level tracking. Minor perf nit — could split into two effects.
  4. Stale background regen skips HTML write for RSC requests (app-page-cache.ts:174), which is correct but means an RSC-triggered regen won't refresh the HTML cache variant. Worth a comment noting the intentional asymmetry.

The slot persistence logic in app-page-route-wiring.tsx:460-471 reads well and handles the key cases correctly. Stale mountedSlotIds from the client are safe because they would reference slots not present in route.slots, so the for loop would never encounter them.

Test coverage for the new behavior is solid — the route-wiring tests cover mounted/not-mounted/hard-nav, the cache tests verify slot-keyed reads and stale regen, and the prefetch tests verify slot-context matching/rejection.

None of the issues above are blocking.

const normalized = Array.from(
new Set(
raw
.split(" ")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated entry splits on " " (single space) while the typed module normalizeMountedSlotsHeader in app-elements.ts:28 splits on /\s+/ (any whitespace). With consecutive spaces, .split(" ") produces empty strings that .filter(Boolean) cleans up, so the end result is the same for well-formed input. But the implementations should match to avoid subtle divergence.

Suggested change
.split(" ")
.split(/\s+/)

setMountedSlotsHeader(null);
};
}, [dispatchTreeState]);
}, [dispatchTreeState, treeState.elements]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding treeState.elements to the dependency array means this layout effect re-runs on every navigation (every time elements change). The cleanup tears down dispatchBrowserRouterAction and browserRouterStateRef, then re-assigns them — all unnecessary churn. Only setMountedSlotsHeader needs to track element changes.

Consider splitting into two effects: one stable effect for the dispatch/ref setup (deps: [dispatchTreeState]) and a second for setMountedSlotsHeader(getMountedSlotIdsHeader(stateRef.current.elements)) (deps: [treeState.elements]).

]);
];

if (!options.isRscRequest) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When an RSC request triggers stale revalidation, only the RSC cache key (with the requesting client's mounted-slot variant) is regenerated — the HTML cache key is skipped.

This is correct for the immediate request, but it means an RSC-triggered regen won't refresh the HTML cache. If the HTML cache is also stale for the same pathname, it will require its own separate request to trigger regeneration. Might be worth a brief comment noting this is an intentional asymmetry.

entry.snapshot = await snapshotRscResponse(response);
entry.snapshot = {
...(await snapshotRscResponse(response)),
mountedSlotsHeader,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The snapshot from snapshotRscResponse(response) already reads X-Vinext-Mounted-Slots from the response headers (line 365). The spread then overwrites mountedSlotsHeader with the caller-provided value. This is correct (the caller knows the request context better), but since both values should agree, the double-read is slightly surprising. A brief comment explaining why the caller's value takes precedence would help readability.

!overrideOrPageComponent &&
defaultComponent &&
options.isRscRequest &&
options.mountedSlotIds?.has(slotId)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core slot persistence logic reads well. One thought: if a slot has both a page and a default export, the overrideOrPageComponent will be truthy and this branch is skipped — correct. And if mountedSlotIds is null (initial page load, no header sent), mountedSlotIds?.has(slotId) is undefined (falsy) and the branch is also skipped — also correct.

Looks good.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 10, 2026

Review posted successfully. Here's a summary of the findings:

Overall: The PR is well-structured and addresses the parallel slot persistence bugs coherently across three layers (slot rendering, cache keying, and client-side cache invalidation). Test coverage is solid.

Key inline comments posted:

  1. app-rsc-entry.ts:550 — The generated entry's __normalizeMountedSlotsHeader splits on " " (single space) while the typed module in app-elements.ts splits on /\s+/ (regex). Suggested aligning them with a code suggestion.

  2. app-browser-entry.ts:425 — The useLayoutEffect dependency array now includes treeState.elements, causing the full effect body (including module-level ref teardown/re-setup) to run on every navigation. Suggested splitting into two effects.

  3. app-page-cache.ts:174 — RSC-triggered stale background regeneration skips the HTML cache write. This is correct but means HTML cache won't be refreshed by RSC requests — suggested adding a comment.

  4. navigation.ts:424prefetchRscResponse overwrites the mountedSlotsHeader from snapshotRscResponse with the caller-provided value. Correct but slightly surprising — suggested a clarifying comment.

  5. app-page-route-wiring.tsx:468 — Confirmed the core slot persistence logic handles edge cases correctly (both page + default, null mountedSlotIds, stale client state).

None of the issues are blocking.

github run

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Fix parallel slot persistence and cache variants

The approach is solid and well-structured. The three-pronged fix (slot persistence via mountedSlotIds, cache key fanning by slot state, and prefetch/visited-response invalidation) is coherent and the test coverage is thorough.

I have a few observations — one concrete divergence (normalization regex in the generated entry vs the typed module), one structural awareness note (background regen forwarding the original request), and a few smaller nits.

Summary of findings

  1. Generated entry normalization regex diverges from typed module — The __normalizeMountedSlotsHeader in the generated entry splits on /[\\t\\r\\n ]+/ (tab, CR, LF, space) while the typed module in app-elements.ts uses /\s+/ (all Unicode whitespace). For practical purposes these agree on well-formed input, but they diverge on vertical tab, form feed, NBSP, etc. Easy to align. (See inline.)

  2. Background regen passes original request to buildPageElements — In the stale regen closure, the original request is forwarded, so regen renders with the requesting client's X-Vinext-Mounted-Slots header. This means the RSC variant written is client-specific rather than canonical. This is the correct behavior for refreshing the requesting client's cache variant, but worth noting the regen is slot-specific by design.

  3. Prefetch dedup key doesn't include slot stateprefetchUrl in link.tsx deduplicates on rscUrl alone. If mounted slots change between two prefetch calls for the same URL, the second is suppressed. Caught safely at consumption time (consumePrefetchResponse rejects on mismatch), so correctness is preserved but the prefetch is wasted. Not blocking.

  4. useLayoutEffect separation is correct — Already split into two effects: one stable for dispatch/ref (deps: [dispatchTreeState]), one reactive for slot tracking (deps: [treeState.elements]). Good structure.

  5. Test coverage is strong — Route-wiring tests cover mounted/unmounted/hard-nav. Cache tests verify slot-keyed reads, stale regen writing only RSC for RSC requests, and the prefetch tests verify slot-context match/reject. Good coverage of the key behavioral boundaries.

None of the findings are blocking.

const normalized = Array.from(
new Set(
raw
.split(/[\\t\\r\\n ]+/)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This regex character class /[\\t\\r\\n ]+/ in the generated code resolves to tab, CR, LF, and space — which is correct for well-formed input. However, the typed module normalizeMountedSlotsHeader in app-elements.ts:28 uses /\s+/ which also covers vertical tab, form feed, NBSP, and other Unicode whitespace.

Aligning them would prevent any exotic-whitespace divergence:

Suggested change
.split(/[\\t\\r\\n ]+/)
.split(/\\s+/)

This generates .split(/\s+/) in the output, matching the typed module exactly.

Comment on lines +174 to +177
if (!options.isRscRequest) {
// HTML remains canonical across slot-state variants; only RSC cache
// entries fan out by mounted-slot header.
writes.push(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The asymmetry here is correct but non-obvious. When an RSC request triggers stale regen, only the RSC slot-variant cache key is refreshed. The HTML cache (if also stale) requires a separate HTML request to trigger its own regen. Consider a brief comment:

Suggested change
if (!options.isRscRequest) {
// HTML remains canonical across slot-state variants; only RSC cache
// entries fan out by mounted-slot header.
writes.push(
if (!options.isRscRequest) {
// HTML cache is slot-state-independent (canonical), so only refresh it
// during HTML-triggered regens. RSC-triggered regens only update the
// requesting client's RSC slot variant; a stale HTML cache entry will
// be regenerated independently by the next full-page HTML request.

const [streamForClient, streamForCache] = response.body.tee();
const htmlKey = options.isrHtmlKey(options.cleanPathname);
const rscKey = options.isrRscKey(options.cleanPathname);
const rscKey = options.isrRscKey(options.cleanPathname, null);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

finalizeAppPageHtmlCacheResponse correctly writes the RSC cache entry with mountedSlotsHeader: null — the HTML path produces a canonical (slot-independent) RSC payload. Consistent with the overall design.


schedule(() => {
if (typeof window.__VINEXT_RSC_NAVIGATE__ === "function") {
const mountedSlotsHeader = getMountedSlotsHeader();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mounted-slot header is captured here for the prefetch, but the dedup check at line 132 (prefetched.has(rscUrl)) uses rscUrl alone as the key. If slots change between two prefetch calls for the same URL, the second prefetch is suppressed and the first (now stale-slot) prefetch will be rejected at consumption time.

Correctness is preserved since consumePrefetchResponse catches the mismatch, but the prefetch network work is wasted. If you want to address this later, the simplest fix is to incorporate the slot header into the dedup key.

if (
!overrideOrPageComponent &&
defaultComponent &&
options.isRscRequest &&
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core slot persistence logic is clean. Worth noting the three cases:

  1. isRscRequest && mountedSlotIds?.has(slotId) → slot already mounted, skip key so browser retains prior content
  2. isRscRequest && !mountedSlotIds?.has(slotId) → first entry into layout, render default.tsx
  3. !isRscRequest (hard nav / initial load) → always render default.tsx

Implicit safety: stale mountedSlotIds referencing slots not in the current route's route.slots are harmless — the for loop only iterates actual route slots, so unknown IDs in the Set are never reached.

}, [dispatchTreeState]);

useLayoutEffect(() => {
setMountedSlotsHeader(getMountedSlotIdsHeader(stateRef.current.elements));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: this reactive effect correctly tracks element changes independently from the stable dispatch/ref setup effect above. Splitting into two effects avoids unnecessary teardown/re-setup of dispatchBrowserRouterAction and browserRouterStateRef on every navigation.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 10, 2026

Review posted successfully. Here's a summary of what I submitted:

PR #811 Review: Fix parallel slot persistence and cache variants

The review was posted as a batch review with 6 inline comments across the key changed files. The overall assessment is positive — the three-pronged approach (slot persistence, cache key fanning, prefetch/visited-response invalidation) is coherent and well-tested.

Key findings:

  1. Generated entry regex divergence (app-rsc-entry.ts:550) — The normalization function uses /[\t\r\n ]+/ while the typed module uses /\s+/. Suggested aligning to /\s+/ to prevent exotic-whitespace divergence.

  2. Stale regen HTML cache asymmetry (app-page-cache.ts:174) — RSC-triggered stale regen only refreshes the RSC variant, not the HTML cache. Correct behavior, but suggested a clarifying comment.

  3. Prefetch dedup key missing slot state (link.tsx:139) — Dedup uses rscUrl alone, so slot changes between prefetches waste the earlier fetch. Caught safely at consumption time. Not blocking.

  4. Slot persistence logic (app-page-route-wiring.tsx:467) — Clean logic covering all three cases (mounted RSC, unmounted RSC, hard nav). Stale slot IDs from client are implicitly safe.

  5. useLayoutEffect split (app-browser-entry.ts:427) — Correctly separated into stable and reactive effects.

  6. HTML finalize uses null for slot header (app-page-cache.ts:230) — Confirmed correct: HTML path produces canonical (slot-independent) RSC cache entries.

None of the findings are blocking.

github run

Align generated entry's __normalizeMountedSlotsHeader regex with the
typed module in app-elements.ts (both now use /\s+/). Clarify the
HTML vs RSC cache asymmetry comment in stale regen path.
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Fix parallel slot persistence and cache variants

The design is coherent — tracking mounted-slot state on the client, propagating it via X-Vinext-Mounted-Slots, and using it to vary cache keys and suppress default.tsx re-emission during soft nav. The three layers (slot persistence, cache key fanning, prefetch/visited-response invalidation) fit together well and the test coverage is thorough across all three.

I have one substantive concern (background regen re-rendering with the original request, which bakes user-specific slot context into cached content) and a few smaller observations.

Substantive issue

Background regen inherits the requesting client's slot context. The renderFreshPageForCache closure in the generated entry passes the original request object to buildPageElements (line 2186). This means the background regen respects the triggering client's X-Vinext-Mounted-Slots header — the regen will continue (skip) slots that the triggering client already had mounted. The resulting RSC payload is then cached under the slot-variant key, so other clients with the same mounted-slot combination get the right thing. But if a different client triggers a stale read for the same URL with a different slot combination, they will get a cache MISS and fall through to a fresh render — the stale regen from the first client doesn't help them.

This is probably fine as an MVP (slot-variant caching is inherently per-combination), but it does mean the regen comment at line 2167 ("Use an empty headers context for background regeneration — not the original user request") is misleading — it describes the headers context correctly, but the slot context still comes from the original user request. Worth a comment clarifying the intentional design.

Smaller observations

  1. normalizeMountedSlotsHeader in app-elements.ts:29 has a redundant .map(trim) after .split(/\s+/)split(/\s+/) never produces tokens with leading/trailing whitespace; the only values that .trim() would affect are empty strings, which .filter(Boolean) already handles. Not a bug, just dead code.

  2. Prefetch dedup key doesn't incorporate slot statelink.tsx:132 deduplicates on rscUrl alone. If slots change between two prefetch calls for the same URL, the second is suppressed. The mismatch is caught at consumption time (consumePrefetchResponse rejects when slot headers differ), so correctness is preserved, but the first prefetch is wasted. Minor — the comment at line 128 could note this limitation.

  3. The getVisitedResponse slot-mismatch path deletes the cache entry (app-browser-entry.ts:230) rather than leaving it for a future consumer with matching slots. Since visited-response entries are URL-keyed (not slot-keyed), this is probably the simplest correct behavior — but it does mean a rapid back-forward between two slot contexts will always miss the visited cache.

Overall the PR looks good. None of the above are blocking.

const normalized = Array.from(
new Set(
raw
.split(/\\s+/)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous reviews flagged the regex divergence between this generated code and the typed module. This .split(/\\s+/) in the template string produces .split(/\s+/) in the generated output, which correctly matches app-elements.ts:28. Just confirming this is already aligned — the earlier review suggestion to change from " " to /\\s+/ appears to have been addressed (or was never an issue in the committed code).

new Set(
header
.split(/\s+/)
.map((slotId) => slotId.trim())
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: .map((slotId) => slotId.trim()) is a no-op here. split(/\s+/) produces tokens that never have leading/trailing whitespace — the only edge case is empty strings from leading/trailing whitespace in the input, which .filter(Boolean) already handles.

Suggested change
.map((slotId) => slotId.trim())
.split(/\s+/)
.filter(Boolean),

undefined,
new URLSearchParams(),
isRscRequest,
request,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original request is passed here, so the background regen renders with the triggering client's X-Vinext-Mounted-Slots header. The result is cached under the slot-variant key, which is correct for that specific variant — but the existing comment block (lines 2167-2170) describes avoiding user-specific context only in terms of headers/cookies, not slot state. Worth a one-liner noting that slot context is intentionally inherited:

Suggested change
request,
isRscRequest,
request, // Slot context (X-Vinext-Mounted-Slots) is intentionally
// inherited from the triggering request — the regen result
// is cached under the slot-variant key.

const cached = visitedResponseCache.get(rscUrl);
if (!cached) {
return null;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The slot-mismatch path unconditionally deletes the visited-response entry. Since the visited cache is keyed by URL (not URL+slots), this means rapid navigation between two slot contexts for the same URL will always miss. This is the simplest correct behavior given the single-key structure, but if visited-response cache misses become a perf concern for parallel-route-heavy apps, the cache could be changed to store multiple slot variants per URL.

Not blocking — just noting the tradeoff.

.then(async (response) => {
if (response.ok) {
entry.snapshot = await snapshotRscResponse(response);
entry.snapshot = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: explicitly overriding the snapshot's mountedSlotsHeader with the caller-provided value ensures prefetch compatibility is defined by the slot context at fetch time, not by whatever header the response happens to carry. The comment on lines 424-426 explains this well.

// already mounted on the client. Absent key means the browser retains prior
// slot content rather than replacing it. When the slot is not yet mounted
// (first entry into this layout), include the key so default.tsx renders.
if (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The slot persistence logic is clean and correct. The three cases are well-handled:

  1. RSC + mounted → continue (browser retains prior content)
  2. RSC + not mounted → render default.tsx (first entry)
  3. Non-RSC (hard nav) → always render default.tsx

Stale mountedSlotIds referencing slots not in route.slots are harmless since the loop only iterates actual route slots.

),
options.isrSet(
options.isrRscKey(options.cleanPathname),
options.isrRscKey(options.cleanPathname, options.mountedSlotsHeader),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor note: the renderFreshPageForCache closure (in the generated entry) uses isRscRequest and request from the outer scope, so the regen produces a slot-variant-specific RSC payload. The cache key at line 167 correctly uses options.mountedSlotsHeader to store it under the right variant. This means different clients with different slot combinations get independent cache entries — cache fan-out is proportional to the number of distinct slot combinations observed, which could be worth monitoring in production.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 10, 2026

Review posted successfully. Here's a summary of what I submitted:

Overall assessment: The PR is well-designed and none of the findings are blocking.

Key findings:

  1. Background regen inherits the triggering client's slot context — The renderFreshPageForCache closure passes the original request to buildPageElements, so the regen produces a slot-variant-specific RSC payload. This is correct (cached under the right variant key), but the existing comment about avoiding user-specific context is misleading — it covers headers/cookies but not slot state. Suggested adding a clarifying comment.

  2. Redundant .map(trim) in normalizeMountedSlotsHeader — After .split(/\s+/), tokens never have leading/trailing whitespace. The .trim() call is dead code. Suggested removing it.

  3. Prefetch dedup key doesn't incorporate slot statelink.tsx:132 deduplicates on rscUrl alone, so a slot-mismatched prefetch is wasted (though caught safely at consumption time).

  4. Visited-response cache evicts on slot mismatch rather than storing multiple variants — simplest correct behavior but means rapid back-forward between slot contexts always misses.

  5. Slot persistence logic is clean and correct — The three cases (RSC+mounted, RSC+not-mounted, hard nav) are well-handled, and stale slot IDs are harmless.

  6. Cache fan-out is proportional to distinct slot combinations — worth monitoring in production for parallel-route-heavy apps.

github run

split(/\s+/) never produces tokens with leading/trailing whitespace,
so .map(trim) was a no-op in both normalizeMountedSlotsHeader and its
generated counterpart. Also add a comment clarifying that background
regen intentionally inherits the triggering request's slot context.
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review for issues

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Fix parallel slot persistence and cache variants

The design is sound and well-layered: client-side slot state tracking via X-Vinext-Mounted-Slots, server-side slot-aware rendering in buildAppPageElements, and cache key fanning by slot variant. The three previous bot reviews have covered the broad strokes well. I want to focus on a few issues that were either missed or under-discussed.

Key findings

  1. Stale regen dedup key is pathname-only, but cache write key includes slot state. The scheduleBackgroundRegeneration callback receives options.cleanPathname as its dedup key (app-page-cache.ts:163). This means if two concurrent RSC requests for the same pathname but different slot combinations both get STALE, only one regen runs. The second slot variant's cache entry stays stale until a future request triggers its own regen cycle. Probably acceptable in practice — a composite dedup key would increase concurrent regen work — but worth a comment.

  2. consumePrefetchResponse permanently destroys the cache entry on slot mismatch (navigation.ts:464-469). The entry is deleted before the mismatch check, so the prefetch work is fully wasted — not just unused for this navigation, but unavailable for a future navigation that does match. Prior reviews noted this is "caught safely" but didn't flag the permanent deletion.

  3. Prefetch dedup key doesn't include slot state (link.tsx:132). If mounted slots change between two prefetches of the same URL (e.g., modal open/close), the second prefetch is suppressed and the first will be rejected at consumption time. For apps with frequent slot transitions, prefetches could be persistently wasted. A lightweight fix: include the slot header in the dedup key (rscUrl + '\0' + (mountedSlotsHeader ?? '')). Fine to defer — not a correctness issue.

  4. buildPageElements parameter list is at 7 positional args. This is generated code so ergonomics matter less, but it's getting close to the threshold where an options bag would be clearer. Not blocking.

Test coverage assessment

Test coverage is strong across all three layers:

  • app-page-route-wiring.test.ts covers mounted/unmounted/hard-nav slot persistence
  • app-page-cache.test.ts covers slot-keyed RSC reads, stale regen RSC-only writes, and HTML+canonical-RSC writes
  • prefetch-cache.test.ts covers slot match/reject in consumePrefetchResponse
  • app-browser-entry.test.ts covers getMountedSlotIds and getMountedSlotIdsHeader helpers

The normalization functions between the generated entry and app-elements.ts are correctly aligned — both produce /\s+/ in the runtime output.

None of the findings are blocking. The PR is ready to merge once CI is green.

@@ -153,27 +162,38 @@ export async function readAppPageCacheResponse(
// the stale payload and will fall through to a fresh render.
options.scheduleBackgroundRegeneration(options.cleanPathname, async () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dedup key here is options.cleanPathname alone, but the cache write at line 167 uses options.isrRscKey(options.cleanPathname, options.mountedSlotsHeader) which includes slot state. This means two concurrent RSC requests for the same URL with different slot combinations will have only one regen run (the loser's variant stays stale until its next STALE read).

This is probably the right tradeoff (avoids concurrent regen storms for slot-heavy pages), but a brief comment would help:

Suggested change
options.scheduleBackgroundRegeneration(options.cleanPathname, async () => {
options.scheduleBackgroundRegeneration(options.cleanPathname, async () => {
// Dedup key is pathname-only: if multiple slot variants are stale
// concurrently, only one regen runs. Other variants refresh on
// their next STALE read.

getPrefetchedUrls().delete(rscUrl);

if (entry.snapshot) {
if ((entry.snapshot.mountedSlotsHeader ?? null) !== mountedSlotsHeader) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The entry was already deleted at line 464 (unconditionally, before any checks). So a slot mismatch permanently destroys the prefetch — it's not available for a future navigation that does match the original slot context.

If intentional (simplest correct behavior), a brief comment would clarify the design:

Suggested change
if ((entry.snapshot.mountedSlotsHeader ?? null) !== mountedSlotsHeader) {
if ((entry.snapshot.mountedSlotsHeader ?? null) !== mountedSlotsHeader) {
// Entry was already removed — slot mismatch means the prefetch was
// done with stale slot context and cannot be safely reused.
return null;
}

// already mounted on the client. Absent key means the browser retains prior
// slot content rather than replacing it. When the slot is not yet mounted
// (first entry into this layout), include the key so default.tsx renders.
if (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guard conditions are exactly right, covering three cases: RSC+mounted (skip), RSC+not-mounted (render default), non-RSC (always render default). There's an implicit fourth case worth noting: when a slot has both a page and a default, overrideOrPageComponent is truthy from the page match, so this branch is never entered — the page always renders regardless of mounted state. Correct Next.js behavior.

stripBasePath(url.pathname, __basePath) ===
stripBasePath(window.location.pathname, __basePath);
const cachedRoute = getVisitedResponse(rscUrl, navigationKind);
const elementsAtNavStart = getBrowserRouterState().elements;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: capturing elementsAtNavStart before any async work ensures the mounted-slot header reflects the state at navigation start, not a potentially-mutated state from a concurrent navigation that committed during the fetch. This is the right approach for consistency between the header sent to the server and the compatibility check against prefetched responses.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 10, 2026

Review posted successfully on PR #811. Here's a summary of my findings:

Overall assessment: The PR is well-designed and ready to merge. The three-pronged approach (slot persistence, cache key fanning, prefetch/visited-response invalidation) is coherent, and test coverage is thorough across all layers.

Key findings (none blocking):

  1. Stale regen dedup key mismatchscheduleBackgroundRegeneration deduplicates by pathname alone, but the cache write key includes slot state. Two concurrent STALE reads for the same URL with different slot combinations will only trigger one regen. The other variant stays stale until its next read.

  2. consumePrefetchResponse permanently destroys entries on slot mismatch — The cache entry is deleted unconditionally before the slot check, so mismatched prefetches are gone forever, not just skipped for this navigation. Prior reviews noted this was "caught safely" but didn't flag the permanent deletion.

  3. Prefetch dedup key doesn't include slot state — Frequent slot transitions (modal open/close) can cause prefetches to be persistently wasted since the dedup set prevents re-prefetching.

  4. Normalization functions are correctly aligned — The generated entry's \\s+ in the template literal correctly produces /\s+/ in runtime output, matching app-elements.ts.

github run

Add comments explaining that pathname-only regen dedup is intentional
(avoids concurrent regen storms across slot variants) and that
consumePrefetchResponse unconditionally removes the entry before the
slot-mismatch check (simplest correct behavior, prefetch is wasted on
mismatch).
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review for issues

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Fix parallel slot persistence and cache variants

The design is coherent and well-implemented. The three-layer approach works correctly together:

  1. Server-side slot persistence (app-page-route-wiring.tsx) — Distinguishes mounted vs first-entry slots using the X-Vinext-Mounted-Slots header. The continue to omit already-mounted default-only slots from the RSC payload is the right mechanism for letting the browser retain prior content.

  2. Cache key fanning (app-page-cache.ts, app-rsc-entry.ts) — RSC cache keys now incorporate slot state via FNV-1a hash, while HTML cache remains canonical (slot-independent). The asymmetry between RSC and HTML regen paths is correct and well-commented.

  3. Client-side cache invalidation (app-browser-entry.ts, navigation.ts, link.tsx) — Both visited-response and prefetch caches check slot compatibility before serving entries. The elementsAtNavStart capture pattern in the browser entry is correct for consistency between the header sent to the server and the compatibility check.

Test coverage is solid across all three layers. The normalization functions between the generated entry and app-elements.ts are correctly aligned.

None of the findings below are blocking.

Additional note: storePrefetchResponse slot-awareness gap

storePrefetchResponse (navigation.ts:340) is a legacy backward-compat function that doesn't accept a mountedSlotsHeader parameter. Entries it creates will have mountedSlotsHeader set to whatever the response's X-Vinext-Mounted-Slots header happens to be (via snapshotRscResponse), rather than the caller-controlled value that prefetchRscResponse uses. This is fine today since it's only called from tests, but if anyone calls it from production code in the future, consumePrefetchResponse will reject entries on mismatch. Consider adding a JSDoc warning that it's slot-unaware, or adding the parameter for parity.

options.mountedSlotIds?.has(slotId)
) {
continue;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The slot persistence logic is clean and handles all four cases correctly:

  1. RSC + only default.tsx + mounted → continue (browser retains prior content)
  2. RSC + only default.tsx + not mounted → render default (first entry into layout)
  3. RSC + has page → always renders page (overrideOrPageComponent is truthy, branch skipped)
  4. Non-RSC (hard nav) → always render default.tsx

Stale mountedSlotIds from the client referencing slots not in route.slots are harmless since the loop only iterates actual route slots. Null mountedSlotIds (initial page load, no header) correctly falls through via optional chaining.

getPrefetchedUrls().delete(rscUrl);

if (entry.snapshot) {
if ((entry.snapshot.mountedSlotsHeader ?? null) !== mountedSlotsHeader) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unconditional deletion (line 464) before this slot check means a mismatched prefetch is permanently destroyed. If the user opens a modal (changing mounted slots), then immediately closes it (reverting slots) and navigates to the prefetched URL, the entry is gone even though the original slot context would have matched.

This is the simplest correct behavior. getPrefetchedUrls().delete(rscUrl) at line 465 clears the dedup guard, so a subsequent <Link> hover will re-prefetch — the waste is limited to one round-trip per slot change per URL.

Not blocking — just documenting the tradeoff for future readers.

// the stale payload and will fall through to a fresh render.
// Dedup key is pathname-only: if multiple slot variants are stale
// concurrently, only one regen runs. Other variants refresh on
// their next STALE read.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pathname-only dedup key means if two concurrent RSC requests for the same URL with different slot combinations both get STALE, only one regen runs. The second variant stays stale until its next read. The comment here correctly documents this — confirming the tradeoff (fewer concurrent regens vs. delayed freshness for non-primary variants) is reasonable.

}, [dispatchTreeState]);

useLayoutEffect(() => {
setMountedSlotsHeader(getMountedSlotIdsHeader(stateRef.current.elements));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: splitting into two effects is the right call. The first effect (deps: [dispatchTreeState]) handles stable module-level ref setup/teardown. This reactive effect only runs when treeState.elements changes, avoiding unnecessary teardown/re-setup of dispatchBrowserRouterAction and browserRouterStateRef on every navigation.

stripBasePath(window.location.pathname, __basePath);
const cachedRoute = getVisitedResponse(rscUrl, navigationKind);
const elementsAtNavStart = getBrowserRouterState().elements;
const mountedSlotsHeader = getMountedSlotIdsHeader(elementsAtNavStart);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Capturing elementsAtNavStart before any async work is the right approach. This ensures the mounted-slot header reflects the state at navigation start, not a potentially-mutated state from a concurrent navigation that committed during the fetch. The same captured value is used for both the fetch header and the prefetch compatibility check — consistent.

]);
];

if (!options.isRscRequest) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The asymmetry between RSC-triggered and HTML-triggered regens is correct: HTML cache is canonical (slot-independent), so an RSC-triggered regen should only refresh the requesting client's RSC slot variant. The comment explains this well. One thing worth noting: the renderFreshPageForCache closure in the generated entry inherits the triggering request's slot context (via the original request object), so the regen does produce a slot-variant-specific RSC payload — correctly cached under the matching variant key.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 10, 2026

The review was posted successfully. Here's a summary of what I submitted:

PR #811 Review: Fix parallel slot persistence and cache variants

The review was posted as a batch review with 6 inline comments across the key changed files. The overall assessment is positive — the three-pronged approach (slot persistence, cache key fanning, prefetch/visited-response invalidation) is coherent, well-tested, and correctly implemented.

Key inline comments posted:

  1. app-page-route-wiring.tsx:471 — Confirmed the slot persistence logic covers all four cases correctly (RSC+mounted, RSC+not-mounted, RSC+has-page, hard-nav). Stale client slot IDs are safely ignored.

  2. navigation.ts:468 — Documented the tradeoff of unconditional prefetch entry deletion before slot check. Waste is bounded to one round-trip per slot change per URL since the dedup guard is also cleared.

  3. app-page-cache.ts:165 — Confirmed the pathname-only dedup key for background regen is a reasonable tradeoff (fewer concurrent regens vs. delayed freshness for non-primary variants).

  4. app-browser-entry.ts:427 — Confirmed the two-effect split is correct, avoiding unnecessary ref teardown/re-setup on navigation.

  5. app-browser-entry.ts:754 — Confirmed elementsAtNavStart capture before async work ensures consistency between the header sent to the server and the prefetch compatibility check.

  6. app-page-cache.ts:177 — Confirmed the RSC/HTML regen asymmetry is correct and well-documented.

Top-level note: Flagged that storePrefetchResponse (legacy function, test-only usage) doesn't accept a slot header parameter, creating a latent API inconsistency if anyone calls it from production code in the future.

None of the findings are blocking.

github run

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@james-elicx Not sure why it isn't approved here. Comments are all obsevations

Add JSDoc note that storePrefetchResponse is slot-unaware: the
snapshot's mountedSlotsHeader comes from the response headers rather
than the caller, so consumePrefetchResponse may reject the entry on
slot context mismatch.
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk re-review pr for issues? any blockers?

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Fix parallel slot persistence and cache variants

This PR correctly addresses three interrelated bugs in parallel slot handling introduced in #750:

  1. Slot persistence on soft navigation — The core logic in app-page-route-wiring.tsx:464-471 correctly distinguishes between already-mounted slots (omit from RSC payload so browser retains prior content) and first-entry slots (include default.tsx). The four cases (RSC+mounted, RSC+not-mounted, RSC+has-page, hard-nav) are all handled correctly, and stale mountedSlotIds from the client are harmless since the loop only iterates actual route slots.

  2. Cache key fanning by slot state — RSC cache keys now incorporate the mounted-slot header via FNV-1a hash, while HTML cache remains canonical (slot-independent). The finalizeAppPageHtmlCacheResponse correctly passes null for the slot header. The asymmetry between RSC-triggered and HTML-triggered stale regens is correct and well-commented.

  3. Client-side cache invalidation — Both visited-response and prefetch caches check slot compatibility before serving entries. The elementsAtNavStart capture in app-browser-entry.ts:753 ensures consistency between the header sent to the server and the prefetch compatibility check.

The normalization functions between the generated entry (\\s+ in template literal producing /\s+/ at runtime) and the typed module (/\s+/ directly) are correctly aligned.

Test coverage is strong across all three layers. The two-effect split in BrowserRoot is the right approach.

Non-blocking observations (already noted by prior bot reviews, documenting agreement):

  • Prefetch dedup key doesn't include slot state — waste is bounded and caught at consumption.
  • Visited-response cache evicts on slot mismatch rather than storing variants per URL — simplest correct behavior.
  • Background regen dedup key is pathname-only — acceptable tradeoff vs concurrent regen storms.

Clean, well-structured PR. LGTM.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 10, 2026

Review posted successfully. Here's a summary:

Approved PR #811 — The three-pronged fix for parallel slot persistence is correct and well-tested:

  1. Slot persistence (app-page-route-wiring.tsx) correctly omits already-mounted default-only slots from RSC payloads on soft navigation, while rendering them on first entry and hard navigation.

  2. Cache key fanning (app-page-cache.ts, app-rsc-entry.ts) correctly varies RSC cache keys by mounted-slot state via FNV-1a hash, while keeping HTML cache canonical (slot-independent). The stale regen asymmetry (RSC-only for RSC-triggered regens) is correct.

  3. Client-side invalidation (app-browser-entry.ts, navigation.ts, link.tsx) correctly checks slot compatibility for both visited-response and prefetch caches, with proper state capture at navigation start.

Test coverage is solid across all layers. The normalization functions are correctly aligned between generated and typed code. No blocking issues found.

github run

@james-elicx james-elicx merged commit ca0d542 into cloudflare:main Apr 10, 2026
23 checks passed
@NathanDrake2406 NathanDrake2406 deleted the fix/parallel-slot-persistence-soft-nav branch April 10, 2026 14:30
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.

2 participants