Skip to content

fix(navigation): reuse router.prefetch payloads during navigation#2709

Draft
NathanDrake2406 wants to merge 4 commits into
cloudflare:mainfrom
NathanDrake2406:fix/router-prefetch-navigation-reuse
Draft

fix(navigation): reuse router.prefetch payloads during navigation#2709
NathanDrake2406 wants to merge 4 commits into
cloudflare:mainfrom
NathanDrake2406:fix/router-prefetch-navigation-reuse

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Overview

Goal Make router.prefetch() results reusable by a subsequent navigation
Core change _appRouter.prefetch resolves the same prefetch policy as <Link> instead of always storing a learning-only entry
Key boundary Policy helpers moved to React-free shims/internal/app-route-prefetch-policy.ts so navigation.ts (no React) and link.tsx ("use client") share one decision
Expected impact router.prefetch(x) + router.push(x) / Link click issues one RSC request instead of two; in-flight programmatic prefetches are shared with navigation

Closes #2707.

Why

Every prefetched payload must either be safe to commit on navigation or be marked learning-only. router.prefetch() hardcoded the learning-only shape ({ cacheForNavigation: false, optimisticRouteShell: true }), so all navigation-reuse paths (findPrefetchCacheEntryForNavigation, consumeMatchedPrefetchResponse, peekPrefetchResponseForNavigation) skipped its entries by design, and every navigation refetched. Next.js reuses router.prefetch() results: it defaults to PrefetchKind.AUTO and caches the full payload for kind: "full" (app-router-instance.ts).

vinext already encodes exactly that reuse decision for <Link> in resolveAutoAppRoutePrefetch / resolveFullAppRoutePrefetch, keyed off the client prefetch route manifest. The right owner of the decision is that shared policy, not a second hardcoded literal in the router, so this PR moves the helpers to an internal module and has router.prefetch consult them.

What changed

Scenario Before After
router.prefetch(x) then navigate to x (route without loading shell / search params) Two RSC requests; prefetch entry never consumed One request; navigation consumes the prefetched snapshot (navigation TTLs, ahead-of-navigation snapshot preparation)
Navigation while router.prefetch(x) is still in flight Second request raced the first Navigation awaits the pending prefetch via the existing consumePrefetchResponseForNavigation path
router.prefetch(x, { kind: "full" }) Option ignored (learning-only) Full-prefetch policy: reusable entry, Next-Router-Prefetch suppressed on the wire (matches <Link prefetch={true}>)
Route with a loading shell / search params, default kind Learning-only Unchanged: learning-only fallback preserves optimistic-route-template learning and matches Next.js AUTO semantics for dynamic routes
No prefetch route manifest match Learning-only fetch Unchanged: an explicit programmatic prefetch still fetches
Interception routes Learning-only Reuse works unchanged through getPrefetchInterceptionContext, which already keys prefetch and navigation identically (covered by the new e2e on the /top/film/[imdbId] modal fixture)
<Link> prefetching, form.tsx prefetching Untouched; link.tsx re-exports the moved helpers so its public test surface is unchanged
Maintainer review path
  1. packages/vinext/src/shims/internal/app-route-prefetch-policy.ts: verbatim extraction from link.tsx (first commit is mechanical only).
  2. packages/vinext/src/shims/navigation.ts (_appRouter.prefetch): the behavioural decision — kind resolution, policy lookup, reusable vs learning branch, freshness gate (discardLearningOnlyPrefetchCacheEntry + hasPrefetchCacheEntryForNavigation), and the reusable prefetchRscResponse behaviour object mirroring Link's.
  3. tests/prefetch-cache.test.ts: unit proof for reuse, in-flight sharing, kind: "full", and the preserved learning-only fallback.
  4. tests/e2e/app-router/router-prefetch-reuse.spec.ts: end-to-end request counting across an intercepted navigation.
Validation
  • New unit tests: reusable entry shape + segment-prefetch headers + no refetch while fresh; navigation awaits an in-flight programmatic prefetch (single fetch); kind: "full" reusable on a loading-shell route with Next-Router-Prefetch absent; loading-shell route stays learning-only under the default kind.
  • New e2e (app-router, dev): router.prefetch + click on the film interception fixture produces exactly one /film/* RSC request and still renders the intercepted panel; a companion no-prefetch test pins the request counter.
  • vp check clean on all touched files.
Commands
vp test run tests/prefetch-cache.test.ts tests/link-navigation.test.ts tests/link.test.ts tests/router-prefetch-invalid-url.test.ts
  4 files, 251 tests passed
PLAYWRIGHT_PROJECT=app-router pnpm test:e2e tests/e2e/app-router/router-prefetch-reuse.spec.ts
  2 passed
PLAYWRIGHT_PROJECT=app-router pnpm test:e2e tests/e2e/app-router/nextjs-compat/prefetch.spec.ts tests/e2e/app-router/interception-dynamic-segment.spec.ts
  14 passed
Risk / compatibility
  • Public API: window.next.router.prefetch gains the documented Next.js kind option; PrefetchOptions.kind stays unknown at the shim boundary and anything other than "full" falls back to auto, matching Next's default: branch.
  • Dev behaviour: programmatic prefetches become reusable in dev too (the manifest is injected in dev; Next.js does not dev-gate router.prefetch). Dev-mode e2e that depend on per-navigation requests are protected by the loading-shell policy and were re-run.
  • Dynamic routes without server stale-time headers keep the same TTL floor as Link full prefetches today (minimumTtlMs: 0 honours server stale-times, PREFETCH_CACHE_TTL fallback; encoded in tests/link-navigation.test.ts stale-time tests).
  • Conflict surface: touches the same link.tsx / prefetchRscResponse region as open PRs fix(app-router): support instant route prefetch shells #2374 and fix(app-router): full prefetch static loading routes #2446. The extraction commit is a verbatim move to keep rebases cheap; this PR depends on neither.
Non-goals
  • Link's fetchLoadingShellForReuse / full-after-shell sequencing, searchAgnosticShell probes, and route-tree gating are not replicated for router.prefetch. Those optimise viewport/hover races that do not apply to a programmatic call; an in-flight router.prefetch entry is already shared with navigation by awaiting entry.pending.
  • No change to Pages Router Router.prefetch or form.tsx prefetching.

References

Reference Why it matters
Issue #2707 Report: router.prefetch + router.push always double-fetches
Next.js app-router-instance.ts (prefetch, lines 392-438) Parity bar: AUTO default, kind: "full", navigation reuse
#2374, #2446 Open PRs touching the same prefetch region (conflict surface only)

link.tsx is a "use client" React module, so its prefetch policy helpers
(resolveAutoAppRoutePrefetch, resolveFullAppRoutePrefetch and their
supporting trie/route-href logic) could not be reached from navigation.ts,
which must stay importable without React. Move them verbatim into
shims/internal/app-route-prefetch-policy.ts (same layering as
internal/app-route-detection.ts) and name the previously inline policy
shape AppRoutePrefetchPolicy.

link.tsx re-exports canAutoPrefetchFullAppRoute and
resolveAutoAppRoutePrefetch, keeping its public test surface unchanged.
No behaviour change.
router.prefetch() stored every response as a learning-only cache entry
(cacheForNavigation: false), so a router.prefetch(x) followed by
router.push(x) always issued a second RSC request; even an in-flight
programmatic prefetch was invisible to navigation. Next.js reuses
router.prefetch() results (PrefetchKind.AUTO by default, kind: "full"
caches the full payload), and <Link> prefetches in vinext already
produce reusable entries through the shared route-policy helpers.

Resolve the same policy in router.prefetch(): kind: "full" maps to the
full-prefetch policy, anything else to the auto policy for the target
route. Reusable prefetches send the same segment-prefetch headers as
<Link>, gate re-issue on a freshness-aware navigation cache probe
(discarding a stale learning-only twin first), and store the payload
with navigation TTLs plus ahead-of-navigation snapshot preparation.

Routes the auto policy declines (no manifest match, loading shell,
search params) keep the previous learning-only fetch, preserving
optimistic-route-template learning and matching Next.js AUTO semantics
for dynamic routes. In-flight sharing needs no extra machinery:
consumePrefetchResponseForNavigation already awaits a pending entry
once the entry is no longer learning-only.

Fixes cloudflare#2707
Covers issue cloudflare#2707 end to end on the /top -> /film/[imdbId] interception
fixture: a programmatic router.prefetch followed by a Link click must
produce exactly one /film RSC request while still rendering the
intercepted film panel. A companion test without the prefetch pins the
request counter so the reuse assertion cannot pass vacuously.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@pkg-pr-new

pkg-pr-new Bot commented Jul 26, 2026

Copy link
Copy Markdown

Open in StackBlitz

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

commit: a78870c

@github-actions

github-actions Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared a78870c against base 05eee9f using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 132.4 KB 132.7 KB ⚫ +0.2%
Client entry size (gzip) vinext 119.7 KB 120.2 KB ⚫ +0.5%
Dev server cold start vinext 2.93 s 2.89 s ⚫ -1.3%
Production build time vinext 3.16 s 3.15 s ⚫ -0.4%
RSC entry closure size (gzip) vinext 103.3 KB 103.3 KB ⚫ -0.0%
Server bundle size (gzip) vinext 177.8 KB 177.8 KB ⚫ +0.0%

View detailed results and traces

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a587ba57b0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/shims/navigation.ts Outdated
Comment on lines +2325 to +2326
const policy =
kind === "full" ? resolveFullAppRoutePrefetch() : resolveAutoAppRoutePrefetch(prefetchHref);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Resolve rewrites before applying the prefetch policy

When a configured rewrite maps an href that also matches an App Router manifest entry to a different App route, this selects policy metadata for the source while the request and navigation resolve the destination. If the destination has a loading boundary or sets requiresDynamicNavigationRequest, the prefetch is incorrectly marked reusable and sent with full-payload headers instead of remaining a loading-shell/learning-only request. The Link path already avoids this by calling resolveHybridClientRewriteHref before resolveAutoAppRoutePrefetch; apply the same resolution here.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a78870c: the prefetch closure now loads the rewrite-aware hybrid module first and resolves the policy (and the freshness-gate lookup, via additionalRscUrls) against the rewritten destination, mirroring Link's prefetchPolicyHref.

Comment thread packages/vinext/src/shims/navigation.ts Outdated
Comment on lines +49 to +52
import {
resolveAutoAppRoutePrefetch,
resolveFullAppRoutePrefetch,
} from "./internal/app-route-prefetch-policy.js";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Lazy-load the route prefetch policy

This static import makes every next/navigation consumer load and evaluate the prefetch policy and its route-trie dependencies, even when an application only uses hooks such as usePathname() and never calls router.prefetch(). Since this is a feature-specific path in a shared App Router module, load it inside the existing asynchronous prefetch closure instead so it stays off the common startup path.

AGENTS.md reference: AGENTS.md:L479-L482

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a78870c: the policy module is now imported dynamically inside the async prefetch closure, keeping the route-trie dependencies off the next/navigation startup path.

Comment on lines +2335 to +2337
if (reusable && kind === "auto") {
headers.set(NEXT_ROUTER_PREFETCH_HEADER, "1");
headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, __prefetchInlining ? "/__PAGE__" : "1");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fetch the route tree before the inlined page segment

With experimental.prefetchInlining enabled, an AUTO prefetch for an eligible static route goes directly to Next-Router-Segment-Prefetch: /__PAGE__. The server computes the route tree and its inlining hints only for a preceding /_tree request, and the Link implementation deliberately gates the page request behind that stage. Programmatic prefetches therefore bypass the configured inlining protocol and lose its segment-selection benefit; use the same route-tree-first sequence here.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Declining this one: in vinext the server's only special segment-prefetch handling is /_tree (isRouteTreePrefetchRequest in server/app-route-tree-prefetch.ts); a Next-Router-Segment-Prefetch: /__PAGE__ request returns the same unified route payload with or without a preceding tree request, and that unified payload is what gets cached for navigation. Link's tree-first stage exists for request-timing parity so duplicate visible links observe the full payload as pending (see the comment above gateViaRouteTree in link.tsx) — a viewport-race concern that doesn't apply to a single programmatic call. Listed as a deliberate non-goal in the PR description.

Comment thread packages/vinext/src/shims/navigation.ts Outdated
Comment on lines +2349 to +2351
if (hasPrefetchCacheEntryForNavigation(rscUrl, interceptionContext, mountedSlotsHeader)) {
attachPrefetchInvalidationCallback(cacheKey, options?.onInvalidate);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Attach invalidation callbacks to the matched cache entry

When the freshness check succeeds through a normalized URL or rendered-path alias rather than the exact new key—for example, a prior <Link prefetch={true}> cached the same route under a different _rsc header variant—hasPrefetchCacheEntryForNavigation returns true, but attachPrefetchInvalidationCallback(cacheKey, ...) looks up only the absent exact key. The call then returns without registering onInvalidate, so the documented callback never fires when the reused entry expires; propagate the matched entry/key from the lookup and attach to that instead.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in a78870c: hasPrefetchCacheEntryForNavigation now accepts onInvalidate and attaches it to whichever entry it matched (exact, normalized _rsc variant, or alias). Regression test: "attaches onInvalidate when reuse matches an entry under a different _rsc variant" in tests/prefetch-cache.test.ts.

…h reuse

Address review findings on the router.prefetch reuse path:

- A configured client rewrite can map the prefetched href onto a
  different App route. The policy previously described the source
  pattern, so a destination with a loading boundary or a
  requires-dynamic flag was incorrectly marked reusable. Load the
  rewrite-aware hybrid module first and resolve the policy (and the
  freshness-gate lookup) against the rewritten destination, mirroring
  Link's prefetchPolicyHref.

- When the freshness gate matched a reusable entry under a normalized
  _rsc variant or rendered-path alias, onInvalidate was attached via an
  exact-key lookup that silently missed, so the documented callback
  never fired on expiry. hasPrefetchCacheEntryForNavigation now attaches
  the callback to whichever entry it matched.

- Import the prefetch policy module dynamically inside the prefetch
  closure so its route-trie dependencies stay off the startup path of
  every next/navigation consumer (AGENTS.md performance guidance).
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: a78870c3ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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.

App Router: router.prefetch() responses are never reused by navigation (learning-only cache entry) — every push after prefetch re-fetches

1 participant