Skip to content

fix(app-router): reuse visited responses for full prefetch - #2858

Open
james-elicx wants to merge 7 commits into
mainfrom
codex/fix-segment-cache-force-stale-current
Open

fix(app-router): reuse visited responses for full prefetch#2858
james-elicx wants to merge 7 commits into
mainfrom
codex/fix-segment-cache-force-stale-current

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

  • let explicit Full prefetches reuse compatible recently visited App Router responses before issuing a network request
  • keep BFCache traversal and ordinary-navigation lifetimes independent from the Full-prefetch static deadline
  • share the behavior across Link and router.prefetch, including one-shot onInvalidate expiry and cache-clear notifications
  • scan canonical _rsc aliases and select the newest claimable response without deleting older history entries

Next.js targeted E2E

  • source: test/e2e/app-dir/segment-cache/force-stale/force-stale.test.ts
  • final exact head: 2 passed / 0 failed

This maps both failures from Actions run 31290819291 in the force-stale suite: pending Full-prefetch reuse during navigation and BFCache reuse during a Full prefetch.

Additional validation

  • focused browser, visited-response, Link, router-prefetch, runtime, and planner tests: 417 passed / 0 failed
  • vp check: clean
  • independent exact-head review: no findings

The targeted Next.js E2E is the primary acceptance check.

@james-elicx

Copy link
Copy Markdown
Member Author

Mapping note for the segment-cache backlog from Actions run 31290819291: this PR fixes both failures in test/e2e/app-dir/segment-cache/force-stale/force-stale.test.ts (pending Full-prefetch reuse during navigation and BFCache reuse during a Full prefetch). The targeted Next.js wrapper passes 2/2 at exact head 3b35846ac3a11ee62353b50d09e3f252080e390b.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@pkg-pr-new

pkg-pr-new Bot commented Aug 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: ea12c66

@github-actions

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

@james-elicx
james-elicx force-pushed the codex/fix-segment-cache-force-stale-current branch from 3b35846 to 8f2d014 Compare August 10, 2026 08:36
@james-elicx

Copy link
Copy Markdown
Member Author

Updated exact-head verification after the CI-only Knip cleanup: the targeted Next.js force-stale wrapper still passes 2/2 at 8f2d01455a90e468de377cefc5b956789b3ee31f; focused tests pass 410/410, Knip is clean, and vp check is clean.

@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: fix(app-router): reuse visited responses for full prefetch

Reviewed the exact head. The core logic in claimVisitedResponseCacheEntryForFullPrefetch is sound: it scans canonical _rsc aliases, selects the newest claimable (non-stale, mounted-slot-compatible) entry, promotes its expiresAt with Math.max so a longer dynamic deadline is never shortened, and re-inserts it as MRU without deleting older history entries. The router.prefetch/Link wiring, one-shot retainPrefetchInvalidationUntil expiry, and integration with invalidatePrefetchCache (cache-clear notifications) all look correct, and the targeted tests pass locally.

One notable finding below.

Dead code: unused options plumbing on findVisitedResponseCacheEntry

packages/vinext/src/server/app-visited-response-cache.ts

The PR adds a VisitedResponseCacheLookupOptions type and threads an options parameter through findVisitedResponseCacheEntry (the isEntryCompatible / mountedSlotsHeader compatibility gates on both the exact-key and alias-scan branches):

type VisitedResponseCacheLookupOptions = {
  isEntryCompatible?: (
    entry: VisitedResponseCacheEntry,
    mountedSlotsHeader: string | null,
  ) => boolean;
  mountedSlotsHeader?: string | null;
};

But nothing ever passes these options. All four call sites of findVisitedResponseCacheEntry (two in source — deleteVisitedResponseCacheEntry and app-browser-entry.ts:707 — and two in tests/app-visited-response-cache.test.ts) call it with exactly three positional args. The new full-prefetch path (claimVisitedResponseCacheEntryForFullPrefetch) does not delegate to findVisitedResponseCacheEntry; it implements its own scan loop (because it needs newest-wins selection plus in-place mutation) and calls isVisitedResponseCacheEntryCompatibleForFullPrefetch directly.

So the VisitedResponseCacheLookupOptions type (lines 11-17) and the added compatibility branches inside findVisitedResponseCacheEntry (lines 103-108 and 119-125) are unreachable. Suggest removing them to keep the module lean, or, if the intent was for claimVisitedResponseCacheEntryForFullPrefetch to reuse findVisitedResponseCacheEntry, wire it up and drop the duplicate scan. Either way there is currently untested, unused branching in a hot lookup helper.

Question (non-blocking): claiming for full-after-shell / unstable_dynamicOnHover

In link.tsx, the claim fires for mode !== "auto", which includes full-after-shell (the unstable_dynamicOnHover intent mode), not just full. router.prefetch gates the same behavior on kind === "full" only. Reusing an already-committed visited response for a dynamic-on-hover prefetch could serve staler dynamic content than a fresh shell+full fetch would. Is the full-after-shell inclusion deliberate and matched to Next.js's Segment Cache behavior? A short comment or a test asserting the full-after-shell path would remove the ambiguity. (Could not verify against .nextjs-ref — the local clone is not present in this environment.)

Verified

  • vitest run on the three touched test files: 168 passed.
  • tsc --noEmit on packages/vinext: clean.
  • retainPrefetchInvalidationUntil correctly participates in invalidatePrefetchCache's retained-invalidation sweep, and the Math.max(0, expiresAt - Date.now()) timer fires the callback exactly once (covered by the two new prefetch-cache tests).
  • Existing findVisitedResponseCacheEntry callers are unaffected because options.mountedSlotsHeader === undefined short-circuits the new branches (though that only matters because the branches are otherwise dead).

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2858.

Summary

I reviewed PR #2858 at exact head and posted a review. The core full-prefetch reuse logic is sound and well-tested (168 tests pass locally, tsc clean). One notable finding:

Dead code (main finding): The PR adds a VisitedResponseCacheLookupOptions type and threads an options parameter with isEntryCompatible/mountedSlotsHeader compatibility gates through findVisitedResponseCacheEntry, but no caller ever passes these options. All four call sites (2 source, 2 test) use the 3-arg form, and the new claimVisitedResponseCacheEntryForFullPrefetch implements its own scan loop rather than delegating to findVisitedResponseCacheEntry. The added type and both compatibility branches are unreachable — suggested removing them or wiring up the delegation.

Non-blocking question: Link's claim fires for mode !== "auto" (includes full-after-shell/unstable_dynamicOnHover), while router.prefetch gates on kind === "full" only. Reusing a committed visited response for dynamic-on-hover could serve staler dynamic content — asked whether the full-after-shell inclusion is deliberate and matches Next.js Segment Cache behavior (couldn't verify against a Next.js reference, which isn't present in this environment).

Verified good: retainPrefetchInvalidationUntil correctly participates in invalidatePrefetchCache's sweep and fires exactly once at the static deadline; existing callers are behaviorally unaffected; Math.max deadline promotion never shortens a longer dynamic lifetime.

github run

@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

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

0 improved · 1 regressed · 5 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 135.2 KB 135.6 KB ⚫ +0.3%
Client entry size (gzip) vinext 122.7 KB 123.0 KB ⚫ +0.3%
Dev server cold start vinext 2.31 s 2.35 s 🔴 +1.8%
Production build time vinext 2.41 s 2.42 s ⚫ +0.2%
RSC entry closure size (gzip) vinext 114.2 KB 114.2 KB ⚫ +0.0%
Server bundle size (gzip) vinext 193.0 KB 193.1 KB ⚫ +0.0%

View detailed results and traces

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

@james-elicx
james-elicx force-pushed the codex/fix-segment-cache-force-stale-current branch from 8f2d014 to b4a69a3 Compare August 10, 2026 08:42
@james-elicx

Copy link
Copy Markdown
Member Author

Updated exact head b4a69a3 with the exact-deadline fix and removal of dead lookup-options plumbing.

Current validation:

  • targeted vinext client-cache exact-deadline regression: 1/1 passed
  • targeted Next.js test/e2e/app-dir/segment-cache/force-stale/force-stale.test.ts: 2/2 passed
  • focused Vitest suites: 410/410 passed
  • vp check: passed
  • knip: passed

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

1 similar comment
@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: fix(app-router): reuse visited responses for full prefetch — exact head b4a69a3d1

Re-reviewed at the current head. The two items from the prior /bigbonk review are resolved:

  • Dead lookup-options plumbing removed. findVisitedResponseCacheEntry is back to three positional args, and VisitedResponseCacheLookupOptions / the isEntryCompatible compatibility branches are gone. Confirmed no dangling references (grep for VisitedResponseCacheLookupOptions / isEntryCompatible across src and tests returns nothing). The module is lean again.
  • Exact-deadline fix. The claim window now excludes the boundary via now >= entry.createdAt + staleTimeMs (app-visited-response-cache.ts:164). The preserves an old BFCache response for back/forward traversal test pins this: at now === createdAt + staleTimeMs the entry is not claimable and stays available for traversal restore.

Verified good

  • Newest-wins alias scan (app-visited-response-cache.ts:154-167): scans every canonical _rsc alias, selects the newest claimable entry, and re-inserts as MRU without deleting older/expired history entries. Math.max deadline promotion never shortens a longer dynamic lifetime. Covered by the alias-scan and dynamic-deadline tests.
  • retainPrefetchInvalidationUntil (navigation.ts:896-909) participates in invalidatePrefetchCache's retained-invalidation sweep (navigation.ts:1062) and fires the caller's onInvalidate exactly once at the static deadline via fireRetainedPrefetchInvalidation (self-deleting, clearTimeout).
  • Claim-then-return without prefetched.add in both Link (link.tsx:652-657) and router.prefetch (navigation.ts:2779-2785): re-claiming is idempotent (re-promotes expiresAt, re-inserts MRU), and if the claimed entry later ages out the path correctly falls through to a real fetch. Good design, consistent across both callers.
  • Client-only seam. The capability is wired through the single browser runtime seam (app-browser-entry.ts:2391); no prod-server / worker-entry parity concern since this is browser navigation runtime only.
  • Focused suites pass locally: app-visited-response-cache 19/19, link-navigation + prefetch-cache 149/149. vp check on all five changed files is clean.

Non-blocking (carried over): full-after-shell claim gate

The mode/kind gate is intentionally asymmetric and, on inspection, correct — not a bug:

  • router.prefetch only ever resolves kind to "full" or "auto" (navigation.ts:2721), so kind === "full" is the complete gate there.
  • Link's mode !== "auto" (link.tsx:645) additionally admits "full-after-shell", which is only reachable via unstable_dynamicOnHover (link.tsx:1316-1320). On hover, Link already calls promotePrefetchEntriesForNavigation (link.tsx:1322) — the intent is explicit navigation, so reusing a recently-visited response within the bounded stale window is aligned, not a staleness regression. The PREFETCH_CACHE_TTL-from-creation bound prevents serving anything genuinely stale.

That said, the clarity gap the prior review raised still stands: there is no test exercising the full-after-shell claim path and no inline comment explaining why the gate is mode !== "auto" rather than mode === "full". A one-line comment at link.tsx:645 (or a small full-after-shell claim test mirroring the existing prefetch: true test) would remove the ambiguity for future readers. Non-blocking.

Overall: the core reuse logic is sound and well-tested, the prior findings are addressed, and I found no new correctness issues.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2858.

Summary

Re-reviewed exact head b4a69a3d1. Both prior /bigbonk findings are resolved, and I found no new correctness issues.

Prior findings — resolved:

  • Dead lookup-options plumbing removed. findVisitedResponseCacheEntry is back to 3 positional args; VisitedResponseCacheLookupOptions and the unreachable isEntryCompatible branches are gone (grep across src/tests confirms zero references).
  • Exact-deadline fix. Claim window now excludes the boundary via now >= entry.createdAt + staleTimeMs (app-visited-response-cache.ts:164), pinned by the traversal-restore test — at exactly the deadline the entry is not claimed and stays available for BFCache traversal.

Verified good:

  • Newest-wins alias scan with Math.max deadline promotion never shortens a longer dynamic lifetime; older/expired history entries are preserved.
  • retainPrefetchInvalidationUntil participates in the invalidatePrefetchCache sweep and fires onInvalidate exactly once at the static deadline.
  • Claim-then-return without prefetched.add is idempotent in both Link and router.prefetch, and correctly falls through to a real fetch if the entry later ages out.
  • Client-only runtime seam — no prod-server/worker-entry parity concern.
  • Focused suites pass locally (19/19 + 149/149); vp check clean on all changed files.

Non-blocking (carried over): The full-after-shell claim gate in Link (mode !== "auto") vs router.prefetch (kind === "full") asymmetry is actually correctrouter.prefetch only resolves full/auto, and Link's full-after-shell (dynamic-on-hover) already signals navigation intent, so bounded reuse is aligned. But the clarity gap remains: no test exercises the full-after-shell claim path and no comment explains the gate choice. A one-liner or small test would remove the ambiguity.

github run

@james-elicx
james-elicx force-pushed the codex/fix-segment-cache-force-stale-current branch from b4a69a3 to ba3e51e Compare August 10, 2026 08:47
@james-elicx

Copy link
Copy Markdown
Member Author

Updated exact head ba3e51e to address the final Bonk clarity note for the full-after-shell claim path.

Current exact-head validation:

  • targeted Next.js test/e2e/app-dir/segment-cache/force-stale/force-stale.test.ts: 2/2 passed
  • vp check packages/vinext/src/shims/link.tsx: passed
  • prior functional exact-deadline Playwright regression: 1/1 passed

All CI checks on the preceding functional head were green; fresh CI is now running for this comment-only follow-up.

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow failed. Check the logs for details.

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 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

1 similar comment
@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: fix(app-router): reuse visited responses for full prefetch — exact head ba3e51e

Re-reviewed at the current head. The two prior /bigbonk findings (dead lookup-options plumbing, exact-deadline boundary) are still resolved, and the full-after-shell comment was added at link.tsx:644-645. The newest-wins alias scan, Math.max deadline promotion, and history-preservation all remain sound and well-tested.

However, I found one new correctness bug in the router.prefetch claim path.

Bug: repeated router.prefetch(href, { kind: "full", onInvalidate }) fires the callback more than once

packages/vinext/src/shims/navigation.ts:2772-2786 + retainPrefetchInvalidationUntil (navigation.ts:896-909)

When a Full prefetch claims a visited response, the callback is preserved via retainPrefetchInvalidationUntil, which unconditionally allocates a brand-new RetainedPrefetchInvalidation record (with its own Set and timer) and adds it to retainedPrefetchInvalidations on every call:

function retainPrefetchInvalidationUntil(onInvalidate, expiresAt): void {
  if (onInvalidate === undefined) return;
  const retained = { callbacks: new Set([onInvalidate]), timer: setTimeout(...) };
  retainedPrefetchInvalidations.add(retained); // new record every time
}

There is no dedup keyed on the route/callback. So if the same route is Full-prefetched more than once with the same onInvalidate (common — hover re-prefetch, React re-renders, or repeated intent), each claim registers a separate retained record holding the same callback. On invalidatePrefetchCache() (navigation.ts:1062-1064), every record fires, so the caller's onInvalidate runs once per claim.

This diverges from the PR's own "one-shot onInvalidate expiry" goal and from the non-claimed path. The normal prefetch path dedups through a per-entry Set (addPrefetchInvalidationCallback, navigation.ts:1009-1018) so the same callback added N times still fires exactly once. The pre-existing retainPrefetchInvalidationAfterConsume (navigation.ts:911) is safe for the same reason — it hands over an already-deduplicated per-entry Set. The new claim path has no cache entry to attach to, so it bypasses that dedup entirely.

Repro (fails on this head):

appRouterInstance.prefetch("/reports", { kind: "full", onInvalidate });
await waitForPrefetchSetup(() => claim.mock.calls.length > 0);
appRouterInstance.prefetch("/reports", { kind: "full", onInvalidate });
await waitForPrefetchSetup(() => claim.mock.calls.length > 1);

invalidatePrefetchCache();
expect(onInvalidate).toHaveBeenCalledTimes(1); // ← got 2

I ran this locally against ba3e51e and it fails with expected 1 times, but got 2 times. The existing reuses a visited response for kind: "full" router.prefetch without fetching test only prefetches once, so it doesn't catch this.

Suggested fix: dedup retained claim callbacks per route (e.g. key retainedPrefetchInvalidations by cache key + callback, reusing/refreshing an existing record's timer instead of allocating a new one), mirroring the per-entry Set dedup used by addPrefetchInvalidationCallback. Add a regression test that prefetches the same kind: "full" route twice with the same onInvalidate and asserts a single fire.

Verified good

  • Dead code stays removed: findVisitedResponseCacheEntry is 3-arg; no VisitedResponseCacheLookupOptions/isEntryCompatible references.
  • Newest-wins scan (app-visited-response-cache.ts:154-167): compatibility filter runs before the createdAt comparison, so an incompatible newer alias never blocks an older compatible one; older/expired history entries are preserved (MRU re-insert only).
  • Exact-deadline exclusion (app-visited-response-cache.ts:164) is consistent with the navigation freshness check (expiresAt > now): an entry claimed at the last claimable instant becomes non-fresh for navigation exactly at the deadline.
  • Math.max promotion never shortens a longer dynamic deadline (covered by test).
  • Link claim path doesn't touch onInvalidate (Link has no such option), so it's unaffected by the bug above; the mode !== "auto" gate now has an explanatory comment.
  • Focused suites pass locally: app-visited-response-cache 19/19, link-navigation + prefetch-cache 149/149.

The core reuse logic is correct; the blocker is the repeated-claim callback multi-fire in retainPrefetchInvalidationUntil.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2858.

Summary

Re-reviewed at exact head ba3e51e. Both prior /bigbonk findings remain resolved (dead lookup-options plumbing gone, exact-deadline boundary fixed) and the full-after-shell comment was added. But I found one new correctness bug:

Bug — repeated router.prefetch fires onInvalidate more than once. The new retainPrefetchInvalidationUntil (navigation.ts:896-909) unconditionally allocates a fresh RetainedPrefetchInvalidation record (own Set + timer) on every claim, with no dedup. So calling router.prefetch(href, { kind: "full", onInvalidate }) twice for the same route (hover re-prefetch, React re-renders) registers two records; invalidatePrefetchCache() fires both, invoking the callback twice.

This diverges from:

  • the PR's own stated "one-shot onInvalidate expiry" goal,
  • the non-claimed prefetch path, which dedups via a per-entry Set (addPrefetchInvalidationCallback) and fires exactly once,
  • retainPrefetchInvalidationAfterConsume, which is safe because it hands over an already-deduplicated per-entry Set.

I reproduced it locally against ba3e51e — a double-prefetch test fails with expected 1 times, but got 2 times. The existing test only prefetches once so it misses this. Suggested fix: dedup retained claim callbacks per route (key by cache key + callback, refresh the existing timer) and add a repeated-claim regression test. (Temp repro test was removed; working tree is clean.)

Verified good: newest-wins scan filters compatibility before the createdAt comparison, exact-deadline exclusion is consistent with navigation freshness, Math.max never shortens a longer dynamic deadline, Link's claim path doesn't touch onInvalidate, and focused suites pass (19/19 + 149/149).

github run

@james-elicx
james-elicx force-pushed the codex/fix-segment-cache-force-stale-current branch from ba3e51e to d2d44ef Compare August 10, 2026 09:01
@james-elicx

Copy link
Copy Markdown
Member Author

Addressed the exact-head Bonk blocker at d2d44ef.

The repeated full router.prefetch claim now reuses one retained invalidation record per cache key and deduplicates callbacks with the same Set semantics as a normal prefetch cache entry. Added a regression that failed 2 calls before the fix and now fires onInvalidate exactly once.

Current validation:

  • focused repeated-claim regression: 1/1 passed
  • app-browser-entry + app-visited-response-cache + link-navigation + prefetch-cache: 402/402 passed
  • targeted Next.js test/e2e/app-dir/segment-cache/force-stale/force-stale.test.ts: 2/2 passed
  • vp check on all changed files: passed
  • knip: passed

@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: fix(app-router): reuse visited responses for full prefetch — exact head d2d44ef

Re-reviewed at the current head. The prior /bigbonk blocker (repeated router.prefetch(kind: "full") firing onInvalidate more than once) is resolved, and I found no new correctness issues.

Prior blocker — resolved

retainPrefetchInvalidationUntil (navigation.ts:907-931) now dedups per route via a retainedClaimPrefetchInvalidations Map keyed by cacheKey. A repeated claim for the same key hits the existing branch, adds the callback into the shared Set (idempotent), and refreshes the timer only when a later deadline is proposed (expiresAt > existing.expiresAt). fireRetainedPrefetchInvalidation (navigation.ts:945-957) deletes the record from both the Set and the Map atomically before running callbacks, so no stale Map reference can survive a fire. The new regression (prefetch-cache.test.ts) prefetches the same kind: "full" route twice and asserts onInvalidate fires exactly once — it fails on the pre-fix head and passes here.

The dedup key (cacheKey = encodeCacheKey(rscUrl, interceptionContext)) is the same request-identity key the rest of the prefetch cache uses (prefetched.has(cacheKey), attachPrefetchInvalidationCallback). For a reusable full prefetch the header snapshot is normalized (navigation.ts:2761-2763 deletes NEXT_ROUTER_PREFETCH_HEADER), so repeated identical calls compute the same RSC cache-busting hash → identical rscUrl/cacheKey → correct dedup. Consistent with the existing per-entry Set dedup in addPrefetchInvalidationCallback.

Verified good

  • No double-fire across normal + claim paths. The claim path (navigation.ts:2800) is only reached when hasPrefetchCacheEntryForNavigation returns false, so a route with a live PrefetchCacheEntry attaches onInvalidate to that entry instead — the two retention mechanisms never both hold the same callback for the same route.
  • invalidatePrefetchCache sweep (navigation.ts:1090-1092) iterates retainedPrefetchInvalidations; each fire self-removes and cleans the claim Map. Second invalidatePrefetchCache() is a no-op (test covers idempotency).
  • Newest-wins alias scan (app-visited-response-cache.ts:154-167): compatibility filter runs before the createdAt comparison; Math.max deadline promotion never shortens a longer dynamic lifetime; older/expired history entries are preserved (MRU re-insert only). Covered by the promotion and dynamic-deadline tests.
  • Exact-deadline exclusion (app-visited-response-cache.ts:164, now >= createdAt + staleTimeMs) keeps an at-deadline entry available for BFCache traversal.
  • Link claim path (link.tsx:646-659): full-after-shell inclusion now has an explanatory comment; it doesn't touch onInvalidate (Link has no such option), doesn't add to prefetched, is idempotent on re-hover, and falls through to a real fetch if the entry ages out.
  • Client-only runtime seam (app-browser-entry.ts:2391, navigation-runtime.ts): typed, optional, validated via isOptionalRuntimeFunction; no prod-server/worker-entry parity concern.

Checks run locally at this head

  • app-visited-response-cache 19/19, prefetch-cache 73/73, link-navigation 76/76, app-browser-entry 234/234 — all pass.
  • vp check on all 5 changed source files: format, lint, and types clean.

No blocking issues. LGTM.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2858.

Summary

Re-reviewed at exact head d2d44ef. The prior /bigbonk blocker is resolved and I found no new correctness issues.

Prior blocker — resolved: The repeated-claim onInvalidate multi-fire is fixed. retainPrefetchInvalidationUntil now dedups per route via a retainedClaimPrefetchInvalidations Map keyed by cacheKey; repeated claims merge into a shared Set and only refresh the timer on a later deadline. fireRetainedPrefetchInvalidation cleans both the Set and Map atomically before running callbacks. The new prefetch-cache.test.ts regression prefetches the same kind: "full" route twice and asserts a single fire.

Verified good:

  • No double-fire across the normal-prefetch and claim paths — the claim path is only reachable when no live PrefetchCacheEntry exists.
  • invalidatePrefetchCache sweep is correct and idempotent on a second call.
  • Newest-wins alias scan filters compatibility before the createdAt comparison; Math.max never shortens a longer dynamic deadline; history preserved.
  • Exact-deadline exclusion keeps at-deadline entries available for BFCache traversal.
  • Link's full-after-shell claim path is now commented, idempotent, and ages out cleanly.
  • Client-only runtime seam — no prod/worker parity concern.

Checks at this head: focused suites 402/402 pass (app-visited-response-cache 19, prefetch-cache 73, link-navigation 76, app-browser-entry 234); vp check clean on all 5 changed files.

No blocking issues.

github run

@james-elicx
james-elicx marked this pull request as ready for review August 10, 2026 09:05
…he-force-stale-current

# Conflicts:
#	packages/vinext/src/shims/navigation.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: fix(app-router): reuse visited responses for full prefetch — exact head 14b9156

Re-reviewed at the current head. All three prior /bigbonk findings remain resolved (dead lookup-options plumbing removed, exact-deadline boundary excluded, and the repeated-claim multi-fire dedup via retainedClaimPrefetchInvalidations). But the dedup fix from the last round only covers claim-then-claim; I found a new correctness bug in the sibling case: claim-then-real-fetch.

Bug: a claim followed by a real prefetch entry for the same route fires onInvalidate twice

packages/vinext/src/shims/navigation.ts:2817-2854

The dedup added last round keys retained claim callbacks by cacheKey in retainedClaimPrefetchInvalidations, so two claims for the same route share one record. But a claim and a PrefetchCacheEntry for the same route are stored in two independent structuresretainedClaimPrefetchInvalidations (the claim record) and the entry's own onInvalidateCallbacks Set — and invalidatePrefetchCache() sweeps both:

// navigation.ts:1115-1124
for (const [cacheKey, entry] of cache) {
  deletePrefetchCacheEntry(cache, prefetched, cacheKey, entry, true); // fires entry.onInvalidateCallbacks
}
...
for (const retained of retainedPrefetchInvalidations) {
  fireRetainedPrefetchInvalidation(retained); // fires the retained claim record
}

So if the same onInvalidate ends up in both places for one route, it runs twice.

Reachable sequence (I reproduced it locally against this head):

  1. router.prefetch("/reports", { kind: "full", onInvalidate }) — no live PrefetchCacheEntry, claim succeedsonInvalidate retained in retainedClaimPrefetchInvalidations["/reports…"].
  2. Later router.prefetch("/reports", { kind: "full", onInvalidate }) — the visited response has since been consumed/aged, so the claim misses (claimVisitedResponseForFullPrefetch returns null), the code falls through to prefetchRscResponse, which creates a PrefetchCacheEntry and attaches the same onInvalidate to entry.onInvalidateCallbacks.
  3. invalidatePrefetchCache() → the entry sweep fires the callback once, then the retained-claim sweep fires it again.

Temp repro (fails on this head with expected 1 times, but got 2 times):

let claimCalls = 0;
const claim = vi.fn(() => { claimCalls += 1; return claimCalls === 1 ? Date.now() + 60_000 : null; });
// runtime.functions.claimVisitedResponseForFullPrefetch = claim
appRouterInstance.prefetch("/reports", { kind: "full", onInvalidate });
await waitForPrefetchSetup(() => claim.mock.calls.length > 0);
appRouterInstance.prefetch("/reports", { kind: "full", onInvalidate }); // claim misses → real fetch entry
await waitForPrefetchSetup(() => fetch.mock.calls.length > 0 || claimCalls > 1);
invalidatePrefetchCache();
expect(onInvalidate).toHaveBeenCalledTimes(1); // ← got 2

This still diverges from the PR's stated "one-shot onInvalidate expiry" goal. The existing reuses a visited response for repeated kind:"full" router.prefetch test only exercises claim-then-claim (both claims succeed), so it doesn't catch the claim-then-fetch handoff.

Why the last round's fix is insufficient: the retainedClaimPrefetchInvalidations Map dedups within the claim path, but the claim record and the prefetch entry are separate sinks for the same callback and there is no cross-structure dedup or handoff between them. retainPrefetchInvalidationAfterConsume is safe only because it moves the entry's existing Set into the retained record (single sink); the claim path adds a second sink.

Suggested fix (pick one):

  • When a real prefetch entry is created for a route that has a live retained-claim record (same cacheKey), hand the claim's callbacks over to the entry (or drop the claim record), so a single sink owns the callback — mirroring the retainPrefetchInvalidationAfterConsume handoff.
  • Or, before attachPrefetchInvalidationToEntry/addPrefetchInvalidationCallback in the reusable-fallback path, check retainedClaimPrefetchInvalidations for the same cacheKey and skip re-attaching a callback already retained there.

Add a regression that claims once, then forces a claim miss so a real prefetch entry is created for the same route with the same onInvalidate, and asserts a single fire.

Verified good (unchanged from prior rounds)

  • Newest-wins alias scan (app-visited-response-cache.ts:154-167): compatibility filter runs before the createdAt comparison; Math.max deadline promotion never shortens a longer dynamic lifetime; older/expired history entries preserved (MRU re-insert only).
  • Exact-deadline exclusion (app-visited-response-cache.ts:164) keeps an at-deadline entry available for BFCache traversal.
  • Repeated-claim dedup (navigation.ts:939-963) — two claims for the same key share one record and only refresh the timer on a later deadline; fireRetainedPrefetchInvalidation cleans both the Set and Map atomically.
  • Link claim path (link.tsx:646-661): full-after-shell inclusion is commented, doesn't touch onInvalidate, is idempotent on re-hover, and falls through to a real fetch if the entry ages out.
  • Client-only runtime seam (app-browser-entry.ts:2391, navigation-runtime.ts:57): typed, optional, validated — no prod-server/worker-entry parity concern.
  • Focused suites pass at this head: app-visited-response-cache 19, prefetch-cache 75, link-navigation 77 (171 total); vp check clean on all 5 changed files.

The core reuse logic is sound; the blocker is the claim-then-real-fetch callback double-fire on invalidatePrefetchCache().

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2858.

Summary

Re-reviewed at exact head 14b9156. All three prior /bigbonk findings remain resolved (dead lookup-options plumbing removed, exact-deadline boundary excluded, repeated-claim dedup via retainedClaimPrefetchInvalidations). But I found one new correctness bug — a sibling case the last round's dedup doesn't cover.

Bug — claim-then-real-fetch double-fires onInvalidate. The dedup added last round keys retained claim callbacks by cacheKey, so two claims share one record. But a claim record and a PrefetchCacheEntry are two independent sinks for the same callback, and invalidatePrefetchCache() sweeps both. Reachable sequence:

  1. router.prefetch("/reports", { kind: "full", onInvalidate }) — claim succeeds → callback retained in retainedClaimPrefetchInvalidations.
  2. Later router.prefetch("/reports", { kind: "full", onInvalidate }) — visited response aged out, claim misses (returns null), falls through to a real prefetchRscResponse which attaches the same callback to entry.onInvalidateCallbacks.
  3. invalidatePrefetchCache() fires the entry callback once, then the retained-claim record again → onInvalidate runs twice.

I reproduced it locally against this head (temp test failed expected 1 times, but got 2 times; removed after — working tree clean). The existing repeated-full test only covers claim-then-claim (both succeed), so it misses the claim-then-fetch handoff. This still diverges from the PR's stated "one-shot onInvalidate expiry" goal. Suggested fix: hand the claim's callbacks over to the entry when a real prefetch entry is created for a route with a live retained-claim record (single sink), mirroring retainPrefetchInvalidationAfterConsume, plus a regression test.

Verified good: newest-wins scan filters compatibility before the createdAt compare, Math.max never shortens a longer dynamic deadline, exact-deadline exclusion preserves BFCache traversal, repeated-claim dedup is atomic, Link's full-after-shell path is commented and idempotent, and the runtime seam is client-only (no parity concern). Focused suites pass (171 total); vp check clean.

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: fix(app-router): reuse visited responses for full prefetch — exact head 8be232b

Re-reviewed at the current head, which contains a substantial rework (8be232b "preserve full prefetch freshness") that changes the whole callback-retention design. The prior /bigbonk blocker (claim-then-real-fetch onInvalidate double-fire) is resolved — structurally, not with a patch. No new correctness issues found.

Prior blocker — resolved by design change

The previous rounds retained claim callbacks in a side structure (retainedClaimPrefetchInvalidations / retainPrefetchInvalidationUntil), which created a second sink for the same callback and required per-key dedup that still didn't cover the claim-then-real-fetch handoff. That whole mechanism is gone (grep confirms zero references to retainedClaimPrefetchInvalidations / retainPrefetchInvalidationUntil in src and tests).

The claim now seeds a real PrefetchCacheEntry. The runtime seam (app-browser-entry.ts:2401-2413) calls seedPrefetchResponseSnapshot(rscUrl, createVisitedResponseFullPrefetchSnapshot(claim), …, PREFETCH_CACHE_TTL) on a successful claim, and router.prefetch then attaches onInvalidate to that entry via attachPrefetchInvalidationCallback(cacheKey, …) (navigation.ts:2804). This makes the entry the single sink for the callback, so every downstream path stays exactly-once:

  • Claim-then-claim (same route): the second router.prefetch no longer even re-claims — hasPrefetchCacheEntryForNavigation finds the fresh seeded entry and attaches onInvalidate to it (navigation.ts:669), then returns. The updated test asserts claimVisitedResponseForFullPrefetch is called once (not twice) and onInvalidate fires once. Correct.
  • Claim-then-real-fetch (seeded entry aged out): prefetchRscResponse deletes the pre-existing seeded entry with notify: false (navigation.ts:1369-1371) before creating the fetch entry, so no double-fire; the freshness-gate delete at navigation.ts:673-679 fires the stale entry's callback exactly once for its own genuine expiry (a distinct invalidation event), which is the correct Next.js semantic.
  • discardLearningOnlyPrefetchCacheEntry does not eat the seeded entry: it only targets cacheForNavigation !== false && prefetchKind === "navigation" (navigation.ts:994); the seeded entry leaves prefetchKind undefined, so it survives.

New in this head — verified good

  • 30s stale-window floor. claimVisitedResponseCacheEntryForFullPrefetch now routes its stale time through resolveFullPrefetchStaleTimeMs = Math.max(staleTimeMs, 30_000) (navigation.ts:291-293), matching Next.js's getStaleTimeMs (Math.max(staleTimeSeconds, 30) * 1000). Covered by the new it.each([0, 10_000, 29_999]) "floors … static window" test. Good parity improvement over the prior ?? PREFETCH_CACHE_TTL.
  • BFCache traversal boundary. The claim no longer mutates the visited entry in place — the earlier exact-deadline exclusion (>=) was a workaround for that mutation and is now back to inclusive (now > createdAt + staleTimeMs, app-visited-response-cache.ts:179). This is safe because the visited entry's own expiresAt is left untouched (expect(entry.expiresAt).toBe(now) in the claim tests); traversal reads the untouched entry regardless of the claim boundary. The claimed deadline lives only on the separately-seeded prefetch snapshot.
  • Prepared-elements handoff. createVisitedResponseFullPrefetchSnapshot copies preparedElements only when the visited entry has decoded elements, and does not mutate entry.response (expect(entry.response.preparedElements).toBeUndefined()). Snapshot-only entries still gate on mountedSlotsHeader equality.
  • Newest-wins alias scan unchanged and still sound: compatibility filter runs before the createdAt compare; Math.max deadline promotion never shortens a longer dynamic deadline; older/expired history preserved (MRU re-insert only).
  • Link claim path (link.tsx:646-661) shares the same runtime seam, so it inherits the seeding behavior; Link has no onInvalidate, the full-after-shell inclusion is commented, and the path is idempotent on re-hover.
  • Client-only runtime seam — no prod-server / worker-entry parity concern.

Checks run locally at this head

  • app-visited-response-cache 23/23, prefetch-cache 75/75, link-navigation 77/77 — all pass.
  • vp check on all 5 changed source files: format, lint, and types clean.

No blocking issues. The rework is a genuine simplification — collapsing two callback sinks into one — and it closes the claim-then-real-fetch gap at the design level rather than adding another dedup layer. LGTM.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2858.

Summary

Re-reviewed at exact head 8be232b, which contains a substantial rework ("preserve full prefetch freshness") landed after the last /bigbonk round. The prior blocker (claim-then-real-fetch onInvalidate double-fire) is resolved — structurally, not with another dedup patch. No new correctness issues found.

Key finding — the fix is a design change, not a bandaid:

  • The previous rounds' retained side-structure (retainedClaimPrefetchInvalidations / retainPrefetchInvalidationUntil) is entirely removed (grep confirms zero references). That mechanism created a second sink for the callback and never fully covered the claim-then-fetch handoff.
  • The claim now seeds a real PrefetchCacheEntry via seedPrefetchResponseSnapshot, and onInvalidate attaches to that single entry. This collapses two sinks into one, so every path (claim-then-claim, claim-then-real-fetch, static-deadline expiry) fires exactly once by construction:
    • Repeat claim: second prefetch reuses the seeded entry via the freshness gate — claim is now called once, not twice.
    • Claim-then-real-fetch: prefetchRscResponse deletes the seeded entry with notify: false, so no double-fire.
    • The seeded entry survives discardLearningOnlyPrefetchCacheEntry (its prefetchKind is undefined).

Also verified new-in-this-head:

  • 30s stale-window floor via resolveFullPrefetchStaleTimeMs = Math.max(staleTimeMs, 30_000) — matches Next.js getStaleTimeMs, covered by the new it.each test.
  • BFCache traversal stays safe: the boundary reverting to inclusive (now > …) is fine now because the claim no longer mutates the visited entry in place (entry.expiresAt is left untouched); the earlier exact-deadline exclusion was a workaround for the old mutation design.
  • Prepared-elements handoff, newest-wins alias scan, and the Link/router client-only seam all remain sound.

Checks at this head: app-visited-response-cache 23/23, prefetch-cache 75/75, link-navigation 77/77 pass; vp check clean on all 5 changed files.

LGTM — no blocking issues.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@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: fix(app-router): reuse visited responses for full prefetch — exact head d57fade

Independent review at the current head (d57fade, one test-only commit past 8be232b's freshness rework). All prior /bigbonk findings remain resolved and I found no new correctness issues. LGTM.

Design is sound — single-sink callback ownership

The core of this fix is that a claim seeds a real PrefetchCacheEntry rather than parking the callback in a side structure. This collapses the two-sink problem that produced the earlier double-fire bugs:

  • claimVisitedResponseForFullPrefetch (app-browser-entry.ts:2392-2412) claims a compatible visited response and immediately seedPrefetchResponseSnapshot(...) publishes it into the prefetch cache under encodeCacheKey(rscUrl, interceptionContext).
  • router.prefetch then attaches onInvalidate to that same entry via attachPrefetchInvalidationCallback(cacheKey, …) (navigation.ts:2804) — the cache keys line up exactly (both derive from the same rscUrl/interceptionContext), so there is a single owner for the callback.
  • Repeat prefetch: the seeded entry is cacheForNavigation: true, so the second router.prefetch short-circuits at hasPrefetchCacheEntryForNavigation (navigation.ts:2786) and re-attaches to the existing entry rather than re-claiming — the test asserts claim is called once.
  • Claim-then-real-fetch: prefetchRscResponse deletes any pre-existing seeded entry with notify: false (navigation.ts:1369-1371) before creating the fetch entry, so the seed's callbacks are cleared (onInvalidateCallbacks = undefined) without firing — no double-fire.

Verified good

  • Timer wiring for the seeded entry. seedPrefetchResponseSnapshot calls schedulePrefetchInvalidation while onInvalidateCallbacks is still undefined, so it no-ops (no orphan timer). The timer is correctly scheduled later inside attachPrefetchInvalidationToEntry because the entry's outcome === "cache-seeded" (navigation.ts:1053-1055). Confirmed by the "invalidates … once at its static deadline" test.
  • Static-window expiry uses the original navigation time, not Date.now(). createVisitedResponseFullPrefetchSnapshot stamps expiresAt: claim.expiresAt (= entry.createdAt + staleTimeMs); seedPrefetchResponseSnapshotresolveCachedRscResponseExpiresAt returns that verbatim via the isCacheExpiresAt short-circuit (navigation.ts:484-486). So the seeded deadline is measured from the BFCache entry's creation, exactly as intended.
  • 30s static floor via resolveFullPrefetchStaleTimeMs = Math.max(staleTimeMs, 30_000) matches Next's getStaleTimeMs (Math.max(staleTimeSeconds, 30) * 1000); the shared helper also now backs the two resolvePrefetchedRscResponseExpiresAt floors (navigation.ts:518,530), removing duplicated Math.max literals. Covered by the it.each([0, 10_000, 29_999]) floor test.
  • Visited entry is left untouched. The claim no longer mutates entry.expiresAt — every claim test asserts entry.expiresAt is unchanged. Because of that, the BFCache traversal boundary can safely revert to inclusive (now > createdAt + staleTimeMs, app-visited-response-cache.ts:179); traversal reads the untouched entry regardless of the claim window. The preserves an old BFCache response for back/forward traversal test pins this.
  • Newest-wins alias scan (app-visited-response-cache.ts:169-181): the compatibility filter runs before the createdAt comparison, so an incompatible newer alias never blocks an older compatible one; older/expired history entries are preserved (MRU re-insert only, no deletes). Covered by the alias-scan test.
  • Prepared-elements handoff copies preparedElements only when the visited entry has decoded elements and never mutates entry.response (entry.response.preparedElements stays undefined in the test). Consumption path is the existing one (navigation.ts:1574-1578, app-browser-entry.ts:2010).
  • Link claim path (link.tsx:646-661): full-after-shell inclusion is commented and only reachable via unstable_dynamicOnHover; Link has no onInvalidate, so it can't hit the callback machinery; it doesn't add to prefetched (the seed does) and is idempotent on re-hover because the seeded entry satisfies the freshness gate at link.tsx:621.
  • Client-only runtime seam (navigation-runtime.ts:57, validated via isOptionalRuntimeFunction; app-browser-entry.ts:2392) — no prod-server / worker-entry parity concern.

Non-blocking observations

  • Claim-at-exact-deadline seeds an immediately-dead entry. When now === createdAt + staleTimeMs, the claim succeeds and seeds an entry with expiresAt === now; hasPrefetchCacheEntryForNavigation's strict > Date.now() check then rejects it on the very next read and deletes it. Harmless (worst case: one wasted seed + a real fetch on the following navigation), and the advanceTime(..., + 1) e2e adjustments show the boundary is exercised deliberately. Not worth changing.
  • full-after-shell still has no unit test. The Link claim path is exercised for prefetch: true (full) and correctly not for auto, but there's no test asserting the mode === "full-after-shell" branch claims. The inline comment covers the intent, so this is purely a coverage gap, not a correctness one.

Checks run locally at this head

  • app-visited-response-cache 23/23, prefetch-cache 75/75, link-navigation 77/77, app-browser-entry 234/234 — all pass.
  • vp check on all 5 changed source files: format, lint, and types clean.

No blocking issues.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2858.

Summary

Reviewed at exact head d57fade. The design is sound and I found no blocking issues — LGTM.

Key finding — the fix is structurally correct. The rework (8be232b) resolved all earlier double-fire bugs by making a claim seed a real PrefetchCacheEntry instead of parking the callback in a side structure. onInvalidate now has a single owner (the seeded entry), and every path stays exactly-once by construction:

  • Repeat claim → second prefetch short-circuits via the freshness gate and re-attaches (claim called once).
  • Claim-then-real-fetch → prefetchRscResponse deletes the seeded entry with notify: false, clearing callbacks without firing.

Verified good:

  • Seeded-entry timer wiring is correct (no orphan timer at seed; scheduled later because outcome === "cache-seeded").
  • Static expiry uses the original navigation time (entry.createdAt + staleTimeMs), preserved verbatim through resolveCachedRscResponseExpiresAt's expiresAt short-circuit.
  • 30s floor matches Next's getStaleTimeMs; shared helper de-dups the two existing Math.max floors.
  • Visited entry is never mutated, so the inclusive BFCache traversal boundary is safe.
  • Newest-wins alias scan filters compatibility before the createdAt compare; history preserved.
  • Client-only seam — no prod/worker parity concern.

Non-blocking: (1) a claim made at the exact deadline seeds an immediately-dead entry (harmless), and (2) the full-after-shell Link claim branch still has no dedicated unit test (coverage gap only).

Local checks: app-visited-response-cache 23/23, prefetch-cache 75/75, link-navigation 77/77, app-browser-entry 234/234 pass; vp check clean on all 5 changed files.

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: fix(app-router): reuse visited responses for full prefetch — exact head 83ec07d

Re-reviewed at the current head (one test-only commit — 83ec07d "measure stale time from latest visit" — past the last /bigbonk round at d57fade). All prior findings remain resolved and I found no new correctness issues. LGTM.

The new commit is a legitimate test correction

83ec07d only touches the first advanceTime in client-cache.spec.ts (270_0015 * 60 * 1_000 + 1). This is correct: storeVisitedResponseSnapshot (app-browser-entry.ts:776-787) deletes and re-creates the visited entry with createdAt: now on every visit, and the claim measures its static window from match.entry.createdAt + staleTimeMs (app-visited-response-cache.ts:189). Because the test performs an intervening navigation at +30s that refreshes createdAt, the deadline must be measured from that latest visit, not the original — matching Next's navigatedAt semantics. The +1 correctly crosses the boundary given the claim window is inclusive at the deadline (now > createdAt + staleTimeMs, app-visited-response-cache.ts:179).

Single-sink callback ownership holds — verified end to end

The core fix (a claim seeds a real PrefetchCacheEntry rather than parking the callback in a side structure) remains the reason every onInvalidate path is exactly-once:

  • Seed timer wiring: seedPrefetchResponseSnapshot calls schedulePrefetchInvalidation while onInvalidateCallbacks is still undefined (line 1021 early-returns → no orphan timer). router.prefetch then attaches via attachPrefetchInvalidationCallback (navigation.ts:2804), and because entry.outcome === "cache-seeded" the timer is scheduled there (attachPrefetchInvalidationToEntry, line 1053-1055).
  • Repeat claim (same route): repeated kind:"full" prefetches delete NEXT_ROUTER_PREFETCH_HEADER (line 2755) → deterministic rscUrl/cacheKey, so the second call short-circuits at hasPrefetchCacheEntryForNavigation (line 2786) and re-attaches to the seeded entry — claim runs once (pinned by the prefetch-cache.test.ts repeated-claim test).
  • Claim-then-real-fetch: a stale seeded entry is deleted with notify:false by prefetchRscResponse (line 1370) before the fetch entry is created — no residual callback. If the seeded entry ages out first, hasPrefetchCacheEntryForNavigation deletes it with notify:true (line 673) which clears its timer and nulls callbacks (deletePrefetchCacheEntrynotifyPrefetchInvalidated), so a following real fetch's own onInvalidate cannot double-fire. Both sinks can never simultaneously hold the same callback for one route.

Verified good (unchanged and re-confirmed at this head)

  • Newest-wins alias scan (app-visited-response-cache.ts:169-182): compatibility filter runs before the createdAt comparison; older/expired history entries are preserved (MRU re-insert only, no deletes).
  • Math.max/deadline independence: the claim returns createdAt + staleTimeMs and never mutates entry.expiresAt (every claim test asserts entry.expiresAt unchanged), so a longer dynamic navigation deadline is never shortened and BFCache traversal reads the untouched entry.
  • 30s static floor via resolveFullPrefetchStaleTimeMs = Math.max(staleTimeMs, 30_000) matches Next's getStaleTimeMs; the shared helper also backs the two resolvePrefetchedRscResponseExpiresAt floors. Covered by the it.each([0, 10_000, 29_999]) test.
  • Slot-compatibility is re-validated at consumption, not just at claim. The claim gate admits a decoded entry regardless of mountedSlotsHeader (isVisitedResponseCacheEntryCompatibleForFullPrefetch), but the seeded entry is re-checked at consumption via isPrefetchCacheEntryCompatibleWithMountedSlots (navigation.ts:1550) against both the pinned request-time slots and the snapshot's server-declared slots. A navigation whose slots match neither rejects the seed (line 1553) and falls through to a fresh fetch — so a tree decoded under a different slot context is never rendered incorrectly.
  • Client-only runtime seam (navigation-runtime.ts:57, validated via isOptionalRuntimeFunction; app-browser-entry.ts:2392) — no prod-server / worker-entry parity concern.
  • Link full-after-shell claim (link.tsx:646-661) is commented, only reachable via unstable_dynamicOnHover, has no onInvalidate, doesn't add to prefetched (the seed does), and is idempotent on re-hover.

Non-blocking (carried over)

  • Claim-at-exact-deadline seeds an immediately-dead entry (expiresAt === now), rejected on the next read by the strict > Date.now() freshness check. Harmless (one wasted seed + a real fetch on the following navigation).
  • full-after-shell still has no dedicated unit test. The inline comment covers intent, so this is a coverage gap, not correctness.

Checks run locally at this head

  • app-visited-response-cache 23/23, prefetch-cache 75/75, link-navigation 77/77 — all pass.

No blocking issues.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2858.

Summary

Reviewed at exact head 83ec07d (one test-only commit past the last /bigbonk round). Found no blocking issues — LGTM.

The new commit (83ec07d) is a legitimate test correction. It fixes the first advanceTime in client-cache.spec.ts to measure the static window from the latest visit. This is correct: storeVisitedResponseSnapshot deletes and re-creates the visited entry with a fresh createdAt on every navigation, and the claim measures its window from entry.createdAt + staleTimeMs — so an intervening navigation at +30s legitimately resets the deadline (matching Next's navigatedAt).

Single-sink callback ownership verified end-to-end. The claim seeds a real PrefetchCacheEntry (not a side structure), so onInvalidate has exactly one owner. I traced all three exactly-once paths:

  • Seed timer wires correctly (no orphan timer at seed; scheduled at attach because outcome === "cache-seeded").
  • Repeat claim short-circuits at the freshness gate — claim runs once.
  • Claim-then-real-fetch clears the stale seed (notify:false on supersede, or notify:true + timer-clear on age-out) so no double-fire.

Extra verification beyond prior rounds: confirmed slot-compatibility is re-validated at consumption (isPrefetchCacheEntryCompatibleWithMountedSlots), not just at claim time — a decoded tree from a different slot context is rejected and falls through to a fresh fetch rather than rendering incorrectly.

Non-blocking (carried over): claim-at-exact-deadline seeds an immediately-dead entry (harmless); full-after-shell still lacks a dedicated unit test (coverage gap only).

Local checks: app-visited-response-cache 23/23, prefetch-cache 75/75, link-navigation 77/77 pass.

github run

@james-elicx
james-elicx marked this pull request as draft August 10, 2026 19:51
@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: fix(app-router): reuse visited responses for full prefetch — exact head 5f258b4

Re-reviewed at the current head (one test-only commit, 5f258b4 "cover visited full-prefetch promotion", past the last /bigbonk round at 83ec07d). All prior findings remain resolved and I found no new correctness issues. LGTM.

The new commit is a legitimate coverage + comment improvement

5f258b4 refines the link.tsx:646-649 comment (now explaining that unstable_dynamicOnHover starts as an automatic shell and upgrades to Full on hover, which is why the upgraded task may claim a visited response) and adds a link-navigation.test.ts case: reuses a visited response when an automatic prefetch is promoted to Full on hover. The test is well-constructed:

  • Viewport intersection first performs only the automatic loading-shell request and asserts claimVisitedResponseForFullPrefetch is not called.
  • Hover upgrades the task to Full (instance.mode = "full-after-shell") and asserts exactly one claim + one fetch, that the seeded entry's snapshot.expiresAt === now + 30_000 (the 30s static floor from the latest visit), and that visitedEntry.expiresAt stays at now — pinning the invariant that Full-prefetch ownership never mutates the visited entry's ordinary-navigation deadline. This closes the full-after-shell coverage gap called out in the prior round.

Single-sink callback ownership verified end to end

The core fix — a claim seeds a real PrefetchCacheEntry (app-browser-entry.ts:2392seedPrefetchResponseSnapshot) rather than parking the callback in a side structure — remains the reason every onInvalidate path is exactly-once. I re-traced all three paths at this head:

  • Seed timer wiring: seedPrefetchResponseSnapshot calls schedulePrefetchInvalidation while onInvalidateCallbacks is still undefined, so it no-ops (navigation.ts:1021, no orphan timer). router.prefetch then attaches via attachPrefetchInvalidationCallback (navigation.ts:2804), and because entry.outcome === "cache-seeded" the timer is scheduled exactly once there (attachPrefetchInvalidationToEntry, navigation.ts:1053-1055), with clearPrefetchInvalidation guarding against a double timer.
  • Repeat claim: the seeded entry is cacheForNavigation: true, so the second router.prefetch short-circuits at hasPrefetchCacheEntryForNavigation (navigation.ts:2786) and re-attaches to the existing entry — claim runs once.
  • Claim-then-real-fetch: seedPrefetchResponseSnapshot deletes any stale existing entry with notify: false (navigation.ts:1102) and prefetchRscResponse does the same before creating a fetch entry, so no double-fire.

Verified good (re-confirmed at this head)

  • Cache-key alignment: the seed publishes under encodeCacheKey(rscUrl, interceptionContext) (the caller's rscUrl), and router.prefetch attaches the callback to the identical cacheKey (navigation.ts:2776), even when the claim matched an older _rsc alias — a single owner.
  • expiresAt verbatim: the snapshot carries expiresAt: claim.expiresAt (= entry.createdAt + staleTimeMs); resolveCachedRscResponseExpiresAt short-circuits on isCacheExpiresAt (navigation.ts:484-485), so the seeded deadline is measured from the visit time, not the seed time — matching Next's navigatedAt.
  • 30s static floor via resolveFullPrefetchStaleTimeMs = Math.max(staleTimeMs, MIN_PREFETCH_STALE_TIME_MS) matches Next's getStaleTimeMs; the shared helper also de-dups the two resolvePrefetchedRscResponseExpiresAt floors.
  • Newest-wins alias scan (app-visited-response-cache.ts:169-182): compatibility filter runs before the createdAt comparison; the claim never mutates match.entry (only MRU re-insert), so a longer dynamic navigation deadline is never shortened and BFCache traversal reads the untouched entry — the inclusive boundary (now > createdAt + staleTimeMs) is safe here.
  • Slot compatibility re-validated at consumption (isPrefetchCacheEntryCompatibleWithMountedSlots, navigation.ts:1550), so a decoded tree from a different slot context is rejected and falls through to a fresh fetch.
  • Link mode !== "auto" gate is not over-broad. Although the union includes "disabled", a prefetch={false} App Router link cannot reach the claim: viewport is gated by canLinkPrefetch (prefetch !== false) and hover by canLinkIntentPrefetch (App Router requires prefetch !== false; the Pages Router allowance is moot because the claim lives inside the hasAppNavigationRuntime() block). So the gate is effectively only "full" / "full-after-shell", matching the comment.
  • Client-only runtime seam (navigation-runtime.ts:57, validated via isOptionalRuntimeFunction) — no prod-server / worker-entry parity concern.

Non-blocking (carried over)

  • Claim-at-exact-deadline seeds an immediately-dead entry (expiresAt === now), rejected on the next read by the strict > Date.now() freshness check. Harmless (one wasted seed + a real fetch on the following navigation).

Checks run locally at this head

  • app-visited-response-cache 23/23, prefetch-cache 75/75, link-navigation 78/78, app-browser-entry 234/234 — all pass.
  • vp check on all 5 changed source files: format, lint, and types clean.

No blocking issues.

@ask-bonk

ask-bonk Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2858.

Summary

Reviewed at exact head 5f258b4 (one test-only commit past the last /bigbonk round at 83ec07d). All prior findings remain resolved and I found no new correctness issues — LGTM.

The new commit (5f258b4) is a legitimate coverage + comment improvement. It refines the link.tsx comment explaining the unstable_dynamicOnHover shell→Full upgrade, and adds a link-navigation.test.ts case that closes the full-after-shell coverage gap flagged in prior rounds: it asserts viewport does not claim, hover claims exactly once, the seeded entry expires at now + 30_000 (30s floor), and the visited entry's own deadline stays untouched.

Verified this round:

  • Single-sink callback ownership holds across all three onInvalidate paths (seed timer no-op → single schedule at attach; repeat claim short-circuits; claim-then-real-fetch deletes with notify: false).
  • Cache-key alignment between the seed and the callback attach, even when the claim matched an older _rsc alias.
  • expiresAt is carried verbatim from the visit time via the isCacheExpiresAt short-circuit.
  • New verification: the Link mode !== "auto" gate is not over-broad despite including "disabled" in the union — canLinkPrefetch/canLinkIntentPrefetch prevent a prefetch={false} App Router link from ever reaching the claim, so the gate is effectively "full"/"full-after-shell" as the comment states.
  • Slot compatibility re-validated at consumption; newest-wins scan never mutates the visited entry; client-only seam (no parity concern).

Non-blocking (carried over): claim-at-exact-deadline seeds a harmless immediately-dead entry.

Local checks: app-visited-response-cache 23/23, prefetch-cache 75/75, link-navigation 78/78, app-browser-entry 234/234 pass; vp check clean on all 5 changed source files.

github run

@james-elicx
james-elicx marked this pull request as ready for review August 10, 2026 21:54
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