Skip to content

fix(app-router): reuse force-stale full prefetches#2473

Open
james-elicx wants to merge 15 commits into
mainfrom
codex/fix-segment-cache-force-stale-stacked-28478866791
Open

fix(app-router): reuse force-stale full prefetches#2473
james-elicx wants to merge 15 commits into
mainfrom
codex/fix-segment-cache-force-stale-stacked-28478866791

Conversation

@james-elicx

@james-elicx james-elicx commented Jul 1, 2026

Copy link
Copy Markdown
Member

Summary

  • reuse full-prefetch visited-cache entries for pending force-stale navigations
  • expire that reuse once the matching prefetch settles so stale visited data does not shadow fresh responses
  • keep navigation-time mounted-slot matching strict while allowing the force-stale prefetch handoff
  • scope normalized _rsc visited-response fallback lookups to compatible mounted-slot variants so one slot variant cannot evict another
  • fix the generated create-next-app dev-server 500 by keeping dev RSC client-reference validation IDs normalized and rendering the default global-error shell through the non-client renderer on the SSR side
  • add focused cache, Link navigation, and RSC client-reference coverage for these paths

Next.js parity

Targets the App Router Segment Cache behavior covered by:

  • test/e2e/app-dir/segment-cache/force-stale/force-stale.test.ts

This is stacked on #2440 because the force-stale row depends on visible App Router prefetches starting before browser idle.

Validation

  • vp check packages/vinext/src/server/app-visited-response-cache.ts packages/vinext/src/server/app-browser-entry.ts tests/app-visited-response-cache.test.ts
  • vp test run tests/app-visited-response-cache.test.ts — 11 passed
  • vp test run tests/app-router-rsc-plugin.test.ts tests/app-visited-response-cache.test.ts tests/link-navigation.test.ts tests/app-ssr-light-runtime.test.ts — 84 passed
  • fresh create-next-app replay with packed local vinext on port 3099 — HTTP 200 after Vite startup
  • REPO="/Users/jamesanderson/.codex/worktrees/segment-cache-force-stale-28478866791/vinext" NEXTJS_DIR="/Users/jamesanderson/Developer/vinext/.nextjs-ref" ./scripts/run-targeted-nextjs-e2e.sh test/e2e/app-dir/segment-cache/force-stale/force-stale.test.ts — 2/2 passed
  • CI create-next-app (ubuntu-latest) / create-next-app (windows-latest) — passed on d3bd8b44d301c662b323a7ca2cf38742636f853a; rerunning on latest head

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Jul 1, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2473
npm i https://pkg.pr.new/vinext@2473

commit: 5d08a09

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review — force-stale full prefetch reuse

I traced the visited-response cache changes across app-visited-response-cache.ts, app-browser-entry.ts, and link.tsx. Overall the prefetch-reuse plumbing is coherent and the cache-key normalization/_rsc fallback lookup is correct (separator \0 + null-context handling matches createAppPayloadCacheKey). Targeted tests pass and vp check is clean on the touched files. A few things worth a second look before merge:

1. mountedSlotsMatch change silently loosens navigation-time reuse (not just prefetch)

app-browser-entry.ts:702-703

mountedSlotsMatch:
  match.entry.elements !== undefined || match.entry.mountedSlotsHeader === mountedSlotsHeader,

This facts object feeds classifyVisitedResponseCacheCandidate, which runs on the navigation path (app-browser-entry.ts:1749), not only during prefetch. Previously an entry whose mountedSlotsHeader did not match the current navigation's mounted slots was evicted (mountedSlotsMismatch). Now, any entry that carries decoded elements is treated as a slot match regardless of the incoming mountedSlotsHeader.

Entries only get elements when they were captured with null mounted slots (app-browser-entry.ts:2169-2189, gated on getMountedSlotIdsHeader(...) === null). So the new behavior is: a null-slot cached tree can now be reused to satisfy a navigation that expects a non-null mountedSlotsHeader, where before it was evicted. That is a real behavioral change on the soft-nav path and it is not mentioned in the PR summary (which only describes prefetch reuse/expiry) and has no dedicated navigation-path test. Please confirm this is intended and matches Next.js segment-cache handoff, and add a navigation test asserting the reuse-vs-evict decision for the elements-present / slot-mismatch case so this doesn't silently regress.

2. hasVisitedResponseCacheEntryForPrefetch mutates despite the has* predicate name

app-visited-response-cache.ts:130-155

The function is named like a pure predicate but has three side effects: it deletes stale entries (fine), and on a hit it does delete + set to LRU-promote the entry. A has* name that mutates shared module state is a maintenance trap — a future caller will reasonably assume it is read-only. Consider renaming to something intent-revealing (e.g. claimVisitedResponseForPrefetch / reserveVisitedResponseForPrefetch) or splitting the freshness probe from the promotion. Compare with hasPrefetchCacheEntryForNavigation (navigation.ts:464), which only deletes stale entries and never re-promotes — the asymmetry is easy to trip over.

3. parseVisitedResponseCacheKey duplicates wire-format knowledge

app-visited-response-cache.ts:82-94 hard-codes the \0 separator and null-context semantics that are owned by app-elements-wire.ts (APP_INTERCEPTION_SEPARATOR, appendInterceptionContext, parsePathWithInterception). This is currently correct, but it's a second copy of the cache-key format that will drift if the wire encoding ever changes (e.g. a different separator or an escaped context). Prefer decoding through an AppElementsWire helper so there is a single source of truth, or at minimum add a comment cross-referencing appendInterceptionContext so the coupling is explicit.

Minor / confirm-only

  • When hasVisitedResponseCacheEntryForPrefetch returns true in link.tsx:532-537, the code returns before prefetched.add(cacheKey), so the intersection observer will re-run this check on every future intersection for the same anchor. That's harmless (it's a Map lookup + freshness check) but means the visited-cache lookup is not memoized per anchor — fine to leave, just noting it.
  • The prefetch freshness branch (isVisitedResponseCacheEntryFresh, lines 61-63) deliberately uses static PREFETCH_CACHE_TTL from createdAt and ignores the entry's own expiresAt/dynamic stale. That is the intended force-stale semantic and the test covers the boundary; just calling it out since it diverges from every other navigation kind which keys off expiresAt.

Nothing here is a hard blocker, but item #1 is the one I'd want an explicit answer on before merge given it changes soft-navigation cache reuse beyond the stated scope of the PR.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2473. Summary of what I found:

Behavioral concern (main item to confirm before merge):

  1. mountedSlotsMatch loosening affects navigation, not just prefetchapp-browser-entry.ts:702-703 feeds classifyVisitedResponseCacheCandidate on the soft-nav path. Entries carrying decoded elements (only created under null mounted slots) can now be reused to satisfy navigations expecting a non-null mountedSlotsHeader, where they were previously evicted. Not mentioned in the PR summary and has no navigation-path test.

Maintainability:
2. hasVisitedResponseCacheEntryForPrefetch mutates despite the has* name — it deletes stale entries and LRU-promotes on hit. Asymmetric with hasPrefetchCacheEntryForNavigation (which never re-promotes). Suggested a claim*/reserve* rename or splitting probe from promotion.
3. parseVisitedResponseCacheKey duplicates wire-format knowledge — hard-codes the \0 separator owned by app-elements-wire.ts; will drift if the encoding changes.

Confirm-only notes: the per-anchor lookup re-runs on every intersection (harmless), and the prefetch freshness branch intentionally uses static PREFETCH_CACHE_TTL instead of expiresAt.

The correctness plumbing (cache-key normalization, _rsc fallback lookup, tests, vp check) is sound. No hard blocker, but item #1 warrants an explicit answer given it changes reuse beyond the PR's stated scope.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Re-review — force-stale full prefetch reuse

The three commits on this PR resolve the concerns from my previous review. Confirming the current state and noting a couple of smaller items.

Previously-flagged items — now resolved

  1. mountedSlotsMatch loosening is now scoped to prefetch only. ✅ The navigation path (readVisitedResponseCacheCandidate, app-browser-entry.ts:703) now calls isVisitedResponseCacheEntryCompatibleForNavigation, which keeps strict mountedSlotsHeader === mountedSlotsHeader equality. The loosened elements !== undefined || … check lives only in isVisitedResponseCacheEntryCompatibleForPrefetch, consumed exclusively by the prefetch claim path. The new unit test (app-visited-response-cache.test.ts, "keeps navigation mounted-slot matching strict for entries carrying decoded elements") asserts exactly this split (…ForNavigation(entry, "slot:modal") === false, …ForPrefetch(...) === true). This was the one item I wanted an explicit answer on, and it's now correct.

  2. has* predicate no longer mutates. ✅ Renamed to claimVisitedResponseCacheEntryForPrefetch (app-visited-response-cache.ts:131), which is intent-revealing about the delete-stale + LRU-promote side effects.

  3. Wire-format duplication removed.findVisitedResponseCacheEntry decodes keys through AppElementsWire.decodeCacheKey (new parseAppPayloadCacheKey, app-elements-wire.ts:337), so the \0 separator / null-context semantics have a single source of truth. The _rsc normalization reuses stripRscCacheBustingSearchParam, so it stays consistent with the encoder.

Correctness — looks sound

  • _rsc normalized fallback is safe. The O(n) scan is bounded (MAX_VISITED_RESPONSE_CACHE_SIZE = 50) and only runs on an exact-key miss. It re-checks interceptionContext, and mounted-slot compatibility is enforced separately by each caller. Visited entries are only stored from navigation-mode responses (storeVisitedResponseSnapshot, never shell mode), so there's no cross-render-mode collision risk from dropping the _rsc hash.
  • deleteVisitedResponse fallback parity. It now deletes via the same findVisitedResponseCacheEntry used at read time, so the no-commit eviction at app-browser-entry.ts:1868 targets the same entry the candidate was read from. Consistent.
  • Expiry mechanism. "Expire once the prefetch settles" is implemented as static PREFETCH_CACHE_TTL-from-createdAt freshness in isVisitedResponseCacheEntryFresh (prefetch branch); once past TTL the claim deletes the entry and returns false, so a fresh prefetch is issued. Covered by "expires visited response cache reuse … after the static stale time".

Minor / confirm-only

  • resolveFullAppRoutePrefetch(href) default change. When the route trie is unavailable or unmatched, prefetchShellFirst now defaults to false (was unconditionally true). For the intended client path this is correct (only shell-first when canPrefetchLoadingShell), and it matches the reworked tests (/blog/:slug shell-first vs /products/:id direct). Just flagging that the fallback for an unmatched route changed from shell-first to direct-full — intended, but worth a mental note since it also alters behavior for routes not present in __VINEXT_LINK_PREFETCH_ROUTES__.
  • Prefetch claim isn't memoized on the anchor. On a claim hit, link.tsx:539-544 returns before prefetched.add(cacheKey), so the intersection observer re-runs the (cheap, bounded) lookup on every future intersection for that anchor. Harmless, same as noted before.

Validation

  • tsc --noEmit on the vinext package: clean.
  • tests/app-visited-response-cache.test.ts: 10/10 pass.

No blockers. The scope concern from the prior review is resolved and covered by a dedicated navigation-vs-prefetch test. LGTM.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2473. Summary of what I found:

The three commits resolved all concerns from the earlier review:

  1. mountedSlotsMatch loosening scoped to prefetch only ✅ — Navigation now uses isVisitedResponseCacheEntryCompatibleForNavigation (strict slot equality); the loosened elements !== undefined || … check lives only in isVisitedResponseCacheEntryCompatibleForPrefetch. This was the one item that "warranted an explicit answer before merge," and it's now correct and covered by a dedicated navigation-vs-prefetch unit test.

  2. has* predicate no longer mutates ✅ — Renamed to claimVisitedResponseCacheEntryForPrefetch, making the delete-stale + LRU-promote side effects explicit.

  3. Wire-format duplication removed ✅ — findVisitedResponseCacheEntry decodes keys through the new AppElementsWire.decodeCacheKey, giving the cache-key format a single source of truth.

Correctness verified:

  • The _rsc normalized fallback is safe — bounded scan (max 50 entries), re-checks interception context, and visited entries are only stored from navigation-mode responses so there's no cross-render-mode collision.
  • deleteVisitedResponse uses the same lookup as read time, keeping no-commit eviction consistent.
  • The TTL-based expiry mechanism matches the stated force-stale semantics.

Minor confirm-only notes: the resolveFullAppRoutePrefetch(href) default for unmatched routes changed from shell-first to direct-full (intended), and the prefetch claim isn't memoized per anchor (harmless).

Validation: typecheck clean, 10/10 cache unit tests pass. No blockers — LGTM.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review — force-stale full prefetch reuse

I re-traced the visited-response-cache changes independently (app-visited-response-cache.ts, app-browser-entry.ts, link.tsx, app-elements-wire.ts) and confirm the three concerns from the earlier reviews are genuinely resolved:

  • Navigation slot matching stays strict. readVisitedResponseCacheCandidate (app-browser-entry.ts:703) uses isVisitedResponseCacheEntryCompatibleForNavigation (exact mountedSlotsHeader equality); the loosened elements !== undefined || … check lives only in isVisitedResponseCacheEntryCompatibleForPrefetch, consumed by the prefetch claim path. Covered by a dedicated navigation-vs-prefetch unit test.
  • claimVisitedResponseCacheEntryForPrefetch is now correctly named for its delete-stale + LRU-promote side effects (app-visited-response-cache.ts:131).
  • Wire-format single source of truth. findVisitedResponseCacheEntry decodes keys via AppElementsWire.decodeCacheKey/parseAppPayloadCacheKey instead of re-hard-coding the \0 separator.

Correctness — confirmed sound

  • _rsc normalized fallback is safe. The scan is bounded (MAX_VISITED_RESPONSE_CACHE_SIZE = 50), only runs on an exact-key miss, re-checks interceptionContext, and mounted-slot compatibility is enforced per-caller. The stripped _rsc hash covers headers that don't alter navigation-mode payload content, and visited entries are only stored from navigation-mode responses, so there's no cross-render-mode collision.
  • deleteVisitedResponse (app-browser-entry.ts:1868) re-runs findVisitedResponseCacheEntry with the same key inputs, so no-commit eviction targets the same entry the candidate was read from — consistent even across the renderNavigationPayload await.
  • Prefetch freshness intentionally keys off static PREFETCH_CACHE_TTL-from-createdAt (the "expire once the prefetch settles" force-stale semantic), diverging from the expiresAt-based navigation kinds. Deliberate and test-covered.

Targeted tests pass (tests/app-visited-response-cache.test.ts + tests/app-router-rsc-plugin.test.ts: 18/18) and vp check is clean on the touched files.

Scope — the one thing I'd want confirmed before merge

The PR summary describes only force-stale prefetch reuse (commits 70167db, 88bd954, cc86cb8, all confined to the visited-response cache). But the head also carries 04c2073 "fix(app-router): keep dev RSC client references valid", which is an orthogonal fix:

  • new createRscReferenceValidationAliasPlugin + dev client-reference warmup in rsc-client-reference-loaders.ts
  • splits default-global-error.tsx into a "use client" wrapper + a plain default-global-error-render.tsx (SSR entry now imports the non-client render directly)
  • adds vinext/shims/default-global-error to the optimizeDeps exclude list

This is 313/208 of the diff and shares no code path with the visited-response cache. It has its own unit test and typechecks clean, but:

  1. It's not mentioned in the PR summary or the "Next.js parity" section, and its commit has no body explaining the motivation or the dev failure it fixes.
  2. Because auto-changesets are generated from commit subjects, this fix(app-router): commit will produce a changelog entry under this PR that readers won't be able to trace back to the stated scope.

Not a correctness blocker — but please either (a) split 04c2073 into its own PR, or (b) update the PR summary to describe the dev RSC client-reference fix (what dev scenario broke, why the global-error shim split is the fix) so the change is reviewable and the changelog entry is honest. Given branch protection and the stacked-PR guidance in AGENTS.md, splitting is preferable if the two fixes are truly independent.

Minor / confirm-only

  • resolveFullAppRoutePrefetch(href) default change. For routes not present in __VINEXT_LINK_PREFETCH_ROUTES__ (or when the trie is unavailable), prefetchShellFirst now defaults to false (was unconditionally true). Intended per the reworked link-navigation tests, just flagging that it also alters behavior for unmatched routes.
  • warmupRscDevClientReferences swallows all errors (.catch(() => {})) and the transform proceeds regardless. Best-effort warmup is reasonable, but a silent failure here would leave the original dev symptom unfixed with no signal. Consider a debug-level log on failure.
  • Prefetch claim isn't memoized on the anchor — on a claim hit link.tsx returns before prefetched.add(cacheKey), so the observer re-runs the (cheap, bounded) lookup on every future intersection. Harmless.

Core force-stale logic LGTM. The scope/commit-hygiene item is the only thing I'd resolve before merge.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2473. Summary of what I found:

Force-stale prefetch reuse (the stated PR scope) — correct. The three concerns from the earlier review rounds are genuinely resolved, and I independently re-verified:

  • Navigation slot matching stays strict (isVisitedResponseCacheEntryCompatibleForNavigation); the loosened elements-present check is prefetch-only.
  • claimVisitedResponseCacheEntryForPrefetch is honestly named for its mutations.
  • _rsc normalized fallback is bounded (max 50), re-checks interception context, and can't collide across render modes since visited entries are navigation-mode-only.
  • deleteVisitedResponse at the no-commit path targets the same entry read even across the render await.
  • Targeted tests 18/18 pass; vp check clean.

Main item to confirm before merge — scope creep. The head carries a fourth, orthogonal commit 04c2073 "fix(app-router): keep dev RSC client references valid" (new createRscReferenceValidationAliasPlugin, dev warmup, and the default-global-error shim split) that shares no code path with the visited-response cache. It's 313/208 of the diff, isn't in the PR summary or parity section, and has no commit body. Since auto-changesets come from commit subjects, it'll produce a changelog entry untraceable to the stated scope. Recommended: split it into its own PR (per AGENTS.md stacked-PR guidance), or document the dev failure it fixes in the summary.

Minor/confirm-only: the resolveFullAppRoutePrefetch unmatched-route default flipped shell-first→direct-full (intended); warmupRscDevClientReferences swallows errors silently (suggest a debug log); prefetch claim isn't memoized per anchor (harmless).

No correctness blocker — the only thing I'd resolve before merge is the commit-hygiene/scope item.

github run

…fetch' into codex/fix-segment-cache-force-stale-stacked-28478866791

# Conflicts:
#	packages/vinext/src/index.ts
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review — force-stale full prefetch reuse (independent re-trace)

I re-traced the visited-response-cache changes end to end (app-visited-response-cache.ts, app-elements-wire.ts, app-browser-entry.ts, link.tsx) and independently confirm the three concerns from the earlier rounds are genuinely resolved, plus verified the correctness of the new plumbing.

Resolved / verified sound

  • Navigation slot matching stays strict. readVisitedResponseCacheCandidate (app-browser-entry.ts:693-706) feeds mountedSlotsMatch via isVisitedResponseCacheEntryCompatibleForNavigation (exact mountedSlotsHeader === mountedSlotsHeader). The loosened entry.elements !== undefined || … predicate lives only in isVisitedResponseCacheEntryCompatibleForPrefetch (app-visited-response-cache.ts:124-129), consumed exclusively by claimVisitedResponseCacheEntryForPrefetch. The dedicated unit test asserts the split (…ForNavigation(entry,"slot:modal") === false, …ForPrefetch === true). This was the one scope-creep item that warranted an explicit answer, and it's correct.
  • claim* is honestly named for its mutations. claimVisitedResponseCacheEntryForPrefetch (app-visited-response-cache.ts:131-155) deletes-stale + LRU-promotes, and the name now signals the side effects.
  • Wire-format single source of truth. findVisitedResponseCacheEntry decodes keys via AppElementsWire.decodeCacheKeyparseAppPayloadCacheKey (app-elements-wire.ts:337-347), reusing parsePathWithInterception. No more hard-coded \0 in the cache module.

Correctness — confirmed

  • _rsc normalized fallback is safe. normalizeVisitedResponseCacheLookupUrl strips only the _rsc cache-busting param (stripRscCacheBustingSearchParam), which hashes request headers (prefetch/segment/state-tree/next-url/interception/mounted-slots/render-mode — app-rsc-cache-busting.ts:180-190). The fallback scan re-checks interceptionContext per entry, mounted-slot compatibility is enforced by each caller, and visited entries are stored only from navigation-mode responses (storeVisitedResponseSnapshot), so dropping the _rsc hash can't collide across render modes or the other variance vectors. The scan is bounded by MAX_VISITED_RESPONSE_CACHE_SIZE = 50 and runs only after an exact-key miss.
  • No-commit eviction targets the read entry. deleteVisitedResponse (app-browser-entry.ts:736-738) re-runs findVisitedResponseCacheEntry, so the no-commit path at :1868 deletes the same entry the candidate was read from, even across the render await.
  • Force-stale expiry semantics. Prefetch freshness keys off static PREFETCH_CACHE_TTL-from-createdAt (isVisitedResponseCacheEntryFresh, prefetch branch) rather than expiresAt; once past TTL the claim deletes and returns false, re-issuing a fresh prefetch. Test-covered at the boundary (now + PREFETCH_CACHE_TTL true / +1 false).

Targeted tests pass locally: tests/app-visited-response-cache.test.ts 10/10 and tests/app-router-rsc-plugin.test.ts 8/8.

The one thing still worth resolving before merge — scope

The PR summary describes only force-stale prefetch reuse, but the head still carries the orthogonal 04c2073 "fix(app-router): keep dev RSC client references valid" (createRscReferenceValidationAliasPlugin was added in #2440's base, and this branch adds warmupRscDevClientReferences + the default-global-error.tsxdefault-global-error-render.tsx "use client" split + the optimizeDeps exclude). This shares no code path with the visited-response cache, has no commit body, and isn't in the summary or parity section. Because auto-changesets derive from commit subjects, it will emit a fix(app-router): changelog entry under this PR that readers can't trace to the stated scope. Per AGENTS.md stacked-PR guidance, either split it out or extend the summary to describe the dev failure it fixes.

Minor / confirm-only (non-blocking)

  • warmupRscDevClientReferences swallows all errors (.catch(() => {}), rsc-client-reference-loaders.ts:86) and the transform proceeds regardless. Reasonable as best-effort, but a silent failure would leave the original dev symptom unfixed with no signal — consider a debug-level log on failure.
  • resolveFullAppRoutePrefetch(href) default flipped shell-first → direct-full for routes absent from __VINEXT_LINK_PREFETCH_ROUTES__ / when the trie is unavailable (prefetchShellFirst now false unless canPrefetchLoadingShell === true). Intended per the reworked link-navigation tests, just noting it also changes unmatched-route behavior.
  • Prefetch claim isn't memoized on the anchor — on a claim hit link.tsx:552-557 returns before prefetched.add(cacheKey), so the observer re-runs the (cheap, bounded) lookup on each future intersection. Harmless.

Core force-stale logic LGTM. No correctness blocker; the commit-hygiene/scope item is the only thing I'd resolve before merge.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2473. Summary of my independent findings:

Force-stale prefetch reuse (the stated PR scope) — correct. I re-traced the full flow and independently verified all three previously-flagged concerns are genuinely resolved:

  • Navigation slot matching stays strict (isVisitedResponseCacheEntryCompatibleForNavigation, exact equality); the loosened elements-present check is prefetch-only and covered by a dedicated navigation-vs-prefetch unit test.
  • claimVisitedResponseCacheEntryForPrefetch is honestly named for its delete-stale + LRU-promote mutations.
  • Wire-format duplication removed — cache-key decode goes through AppElementsWire.decodeCacheKey.

Correctness confirmed: the _rsc normalized fallback is safe (bounded to 50 entries, re-checks interception context, navigation-mode-only entries so no cross-render-mode collision), no-commit eviction targets the same entry read, and the static-TTL force-stale expiry matches the stated semantics. Targeted tests pass locally (10/10 + 8/8).

Main item to confirm before merge — scope creep. The head still carries the orthogonal 04c2073 "fix(app-router): keep dev RSC client references valid" commit (dev warmup + default-global-error shim split), which shares no code path with the visited-response cache, has no commit body, and isn't in the PR summary. Auto-changesets will emit a fix(app-router): entry untraceable to the stated scope. Recommend splitting or documenting per AGENTS.md.

Minor/confirm-only: silent error-swallow in warmupRscDevClientReferences (suggest a debug log); resolveFullAppRoutePrefetch default flipped shell-first→direct-full for unmatched routes (intended); prefetch claim not memoized per anchor (harmless).

No correctness blocker — the commit-hygiene/scope item is the only thing worth resolving before merge.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review — force-stale full prefetch reuse (independent re-trace)

I re-traced the visited-response-cache changes end to end and confirm the three concerns from the prior rounds are genuinely resolved (strict navigation slot matching via isVisitedResponseCacheEntryCompatibleForNavigation, honestly-named claimVisitedResponseCacheEntryForPrefetch, and wire-format decode routed through AppElementsWire.decodeCacheKey). vp check is clean and 77/77 targeted tests pass locally.

I did find one new behavioral issue that the earlier reviews did not surface, plus confirm the scope item.

1. Normalized _rsc fallback can cross-evict a valid mounted-slot variant on navigation

app-visited-response-cache.ts:82-105findVisitedResponseCacheEntry falls back to a normalized scan that strips the _rsc cache-busting param. But _rsc hashes VINEXT_MOUNTED_SLOTS_HEADER (app-rsc-cache-busting.ts:34-44), so two visited entries for the same path + interception context that differ only by mounted slots normalize to the same URL. The fallback returns the first one in Map iteration order, and mounted-slot compatibility is only checked by the caller after the lookup.

On the navigation path this is not harmless:

  • readVisitedResponseCacheCandidate (app-browser-entry.ts:694-709) returns that fallback entry with mountedSlotsMatch: false (strict equality — correct).
  • applyVisitedResponseCacheCandidateDecision (app-browser-entry.ts:727-733) then evicts candidate.cacheKey on a slot mismatch.

So a navigation for mounted-slot variant B (same path/context) can now delete the cached entry for variant A, whenever B's own exact key misses. Before this PR, navigation used an exact-key visitedResponseCache.get(...) keyed on the _rsc-inclusive URL, so variant B simply missed and left variant A's entry intact for a later matching navigation to reuse. The normalized fallback turns that clean miss into a destructive cross-variant eviction.

The prefetch claim path is fine — claimVisitedResponseCacheEntryForPrefetch returns false without deleting on a compatibility miss (app-visited-response-cache.ts:149-151), so only the navigation eviction path regresses.

This is an edge case (requires ≥2 coexisting mounted-slot variants for one path/context — parallel routes / intercepting modals), and there's no test covering multiple normalized-collision entries. Recommend one of:

  • have the normalized fallback prefer a mounted-slot-compatible entry (pass the wanted mountedSlotsHeader into findVisitedResponseCacheEntry and skip mismatches during the scan), or
  • scope the eviction so a normalized-fallback slot-mismatch does not delete the entry it happened to land on.

Either way, please add a navigation test with two same-path entries differing only by mounted slots, asserting that reusing/evicting one doesn't clobber the other.

2. default-global-error client/render split — parity confirmed

default-global-error-render.tsx (non-"use client") still carries onClick={handleBackClick} and window.history usage (lines 155-162, 190). Rendered through the SSR entry (app-ssr-entry.ts:55 now imports default-global-error-render), the onClick is dropped in plain SSR — same non-interactive fallback shell you'd get SSR-rendering the client component's HTML, so no parity regression. The "use client" wrapper (default-global-error.tsx) keeps the client boundary for the RSC/hydration path. This looks correct.

3. Scope — the orthogonal dev RSC fix is now documented in the summary

The head still carries 04c2073 "fix(app-router): keep dev RSC client references valid" (dev warmup + default-global-error split + optimizeDeps exclude), which shares no code path with the visited-response cache. The PR summary now describes it, which addresses the changelog-traceability concern from earlier rounds. Still worth confirming whether splitting per AGENTS.md stacked-PR guidance is preferable, but it's no longer an undocumented change.

Minor / confirm-only

  • warmupRscDevClientReferences swallows all errors (.catch(() => {}), rsc-client-reference-loaders.ts) and proceeds. Reasonable as best-effort, but a silent failure would leave the original dev symptom unfixed with no signal — consider a debug-level log.
  • resolveFullAppRoutePrefetch(href) default flipped shell-first → direct-full for routes absent from __VINEXT_LINK_PREFETCH_ROUTES__. Intended per the reworked link-navigation tests, just noting the unmatched-route behavior change.
  • Prefetch claim isn't memoized on the anchor — the (cheap, bounded) lookup re-runs on each intersection. Harmless.

Core force-stale prefetch logic LGTM. Item #1 is the one I'd want addressed (or explicitly accepted as an acceptable edge case) before merge, since it changes navigation-time cache eviction beyond the stated prefetch scope.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2473. Summary of my independent findings:

Verified resolved (from prior rounds): strict navigation slot matching, honestly-named claimVisitedResponseCacheEntryForPrefetch, wire-format decode via AppElementsWire.decodeCacheKey. vp check clean, 77/77 targeted tests pass locally.

New issue not surfaced in earlier reviews (item #1): The normalized _rsc fallback in findVisitedResponseCacheEntry strips a param that hashes VINEXT_MOUNTED_SLOTS_HEADER, so two same-path/context entries differing only by mounted slots normalize to the same URL. On the navigation path, applyVisitedResponseCacheCandidateDecision evicts on a slot mismatch — so a navigation for mounted-slot variant B can now destructively delete the cached entry for variant A. Before this PR the exact-key lookup would simply miss and leave A intact. The prefetch claim path is fine (returns false without deleting). Edge case (needs coexisting parallel-route/intercept variants), no test covers it. Recommended a slot-aware fallback or scoping the eviction, plus a regression test.

Confirmed sound: the default-global-error client/render split preserves SSR fallback parity (dropped onClick matches prior behavior).

Scope: the orthogonal 04c2073 dev RSC fix is now described in the PR summary, addressing the earlier changelog-traceability concern.

Minor/confirm-only: silent error-swallow in warmupRscDevClientReferences, resolveFullAppRoutePrefetch unmatched-route default flip (intended), non-memoized prefetch claim (harmless).

Core force-stale logic LGTM; item #1 is the one worth addressing or explicitly accepting before merge.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review — force-stale full prefetch reuse (item #1 follow-up)

I re-traced the visited-response-cache changes end to end and independently verified the correctness item #1 from my previous review round — the normalized _rsc cross-eviction — is now genuinely fixed by the new commit 9c11137 "fix(app-router): scope visited response fallback by slots".

Item #1 (cross-variant eviction) — resolved ✅

findVisitedResponseCacheEntry now takes a VisitedResponseCacheLookupOptions bag (mountedSlotsHeader + isEntryCompatible) and applies the compatibility predicate inside the normalized fallback scan (app-visited-response-cache.ts:110-116). Both navigation callers thread isVisitedResponseCacheEntryCompatibleForNavigation through:

  • readVisitedResponseCacheCandidate (app-browser-entry.ts:682-685)
  • deleteVisitedResponse (app-browser-entry.ts:739-748), which backs the no-commit eviction at :1878

So the destructive scenario I flagged is closed: a navigation for mounted-slot variant B whose exact key misses can no longer land the normalized fallback on variant A and evict it — variant A is now skipped as slot-incompatible, the fallback returns null, and A's entry is left intact for a later matching navigation. The read path and the no-commit delete path use the identical key inputs (rscUrl, requestInterceptionContext, mountedSlotsHeader), so they target the same entry across the render await.

The new regression test (tests/app-visited-response-cache.test.ts, "keeps normalized _rsc lookup scoped to compatible mounted-slot variants") covers exactly this: two same-path entries (slot:modal:/ and slot:drawer:/) that normalize to the same URL, asserting a lookup for slot:drawer:/ returns the drawer entry and a delete for a third slot:sidebar:/ variant returns false and clobbers neither existing entry. 11/11 pass; vp check clean on both touched files.

Prefetch claim path — still safe

claimVisitedResponseCacheEntryForPrefetch now also runs the fallback scan through isVisitedResponseCacheEntryCompatibleForPrefetch, so the scan prefers a slot-compatible entry rather than the first Map-iteration hit. It still returns false without deleting on an incompatibility miss (:169-171), so it never destructively evicts a foreign slot variant. The elements-present loosening only permits reuse of a null-slot tree for prefetch, never a cross-slot delete.

Minor / confirm-only (unchanged, non-blocking)

  • The redundant isVisitedResponseCacheEntryCompatibleForPrefetch re-check at app-visited-response-cache.ts:169 is now only meaningful for the exact-key hit (the scan filter already covers fallback matches). Harmless.
  • warmupRscDevClientReferences still swallows all errors (.catch(() => {})); consider a debug-level log so a silent warmup failure doesn't quietly re-surface the original dev symptom.
  • Prefetch claim isn't memoized on the anchor — the cheap, bounded lookup re-runs on each intersection. Harmless.

All previously-flagged concerns (strict navigation slot matching, honestly-named claim*, wire-format single source of truth via AppElementsWire.decodeCacheKey, and now the normalized-fallback cross-eviction) are resolved and test-covered. No correctness blocker remains. LGTM.

@ask-bonk

ask-bonk Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2473.

Summary

The head advanced to commit 9c11137 "fix(app-router): scope visited response fallback by slots", which directly addresses the one open correctness item from the prior review round (item #1 — normalized _rsc cross-variant eviction).

Verified resolved:

  • findVisitedResponseCacheEntry now takes a lookup-options bag and applies the mounted-slot compatibility predicate inside the normalized fallback scan (app-visited-response-cache.ts:110-116).
  • Both navigation callers (readVisitedResponseCacheCandidate and deleteVisitedResponse) thread isVisitedResponseCacheEntryCompatibleForNavigation, so a navigation for slot variant B can no longer land the fallback on and evict variant A. Read and no-commit-delete paths use identical key inputs.
  • New regression test covers the exact collision scenario (two same-path entries differing only by mounted slots, plus a third variant delete that clobbers neither). 11/11 pass, vp check clean.
  • Prefetch claim path stays safe — returns false without deleting on an incompatible match.

No correctness blocker remains. The only leftover notes are non-blocking (silent warmup error-swallow, a now-redundant re-check line, non-memoized prefetch claim). LGTM.

github run

@james-elicx james-elicx marked this pull request as ready for review July 1, 2026 11:42
@james-elicx

Copy link
Copy Markdown
Member Author

Correction to the previous truncated mapping comment.

Deploy-suite run 28478866791 / report job 84413308650 triage: this PR appears to cover the App Router force-stale navigation/prefetch row from test/e2e/app-dir/segment-cache/force-stale/force-stale.test.ts.

Exact row:

  • force stale > during a navigation, don't request segments that have a pending "full" prefetch already in progress

Since this PR is already James-owned, I am skipping duplicate implementation work for this row.

Base automatically changed from codex/fix-optimistic-routing-prefetch to main July 1, 2026 16:45
…-7139

# Conflicts:
#	packages/vinext/src/plugins/rsc-reference-validation-normalizer.ts
#	packages/vinext/src/shims/link.tsx
#	tests/link-navigation.test.ts
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 5d08a09 against base 239595e using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 0 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 125.6 KB 126.2 KB ⚫ +0.5%
Client entry size (gzip) vinext 119.8 KB 120.3 KB ⚫ +0.4%
Dev server cold start vinext 2.52 s 2.48 s 🟢 -1.6%
Production build time vinext 2.94 s 2.95 s ⚫ +0.3%
RSC entry closure size (gzip) vinext 96.8 KB 96.9 KB ⚫ +0.1%
Server bundle size (gzip) vinext 163.2 KB 163.4 KB ⚫ +0.2%

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