Commit bf2d7ac
fix(navigation): reuse router.prefetch payloads during navigation (#2709)
* refactor(link): extract app prefetch policy helpers to internal module
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.
* fix(navigation): reuse router.prefetch payloads during navigation
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 #2707
* test(e2e): cover router.prefetch reuse across an intercepted navigation
Covers issue #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.
* fix(navigation): resolve rewrites and alias matches in router.prefetch 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).
* fix(navigation): keep router.prefetch lifecycle correct across navigation
Two lifecycle gaps in the router.prefetch navigation-reuse path:
`router.prefetch()` awaits hybrid-route loading, the lazily imported
prefetch policy module, and RSC URL generation before it registers
anything in the prefetch cache. A navigation starting in the same task
finds nothing to share, issues its own request, and the late prefetch
then starts a second one. Track a navigation epoch alongside the
existing link-navigation-start signal and bail out of prefetch setup
when a navigation won the race, mirroring link.tsx's guard.
Consuming a prefetched response deleted the entry with notify=false,
which discarded `onInvalidate` without ever calling it -- so
`prefetch(href, { onInvalidate })` followed by `push(href)` silently
lost the subscription. Retain the callbacks past consumption so they
still fire once when the original stale window elapses or the prefetch
cache is invalidated, matching Next.js's prefetch-task contract. An
entry that aged out before navigation reached it now notifies instead
of dropping the callback.
* refactor(navigation): de-duplicate app prefetch policy and snapshot prep
No behavior change.
`<Link>` and `router.prefetch()` carried byte-identical `prepareSnapshot`
closures; extract `prepareNavigationPrefetchSnapshot` and share it.
`resolveAutoAppRoutePrefetch` repeated the same five-field "no prefetch"
literal at four early returns; collapse them to a single
NO_APP_ROUTE_PREFETCH constant. Every call site reads the resolved policy
without mutating or spreading it, so one shared object is safe.
Fold `resolveMatchedAutoAppRoutePrefetch` and its post-hoc search-param
override into one object literal: `prefetchShellFirst` becomes
`hasSearchParams || !isDynamic`, matching the previous unconditional
`true` on the search branch and `!isDynamic` otherwise.
`canAutoPrefetchFullAppRoute` re-ran the window / manifest / href /
trie-match checks before delegating; each of those failures already
resolves to `cacheForNavigation: false`, so drop them.
The policy module stays behind a dynamic import inside the prefetch
closure, keeping its route-trie dependencies off the next/navigation
startup path.
* fix(navigation): notify superseded prefetches and void setup on invalidation
Two more lifecycle gaps in the router.prefetch reuse path.
Upgrading a learning-only prefetch to a navigation-reusable one
(`router.prefetch(href)` then `router.prefetch(href, { kind: "full" })`,
or the equivalent <Link> transition) discarded the old entry with
notifications suppressed, so `onInvalidate` callbacks registered by the
first call could never run. A superseded prefetch is dirty in Next.js
terms, so notify instead of dropping. Fixed in the shared discard helper
rather than at the router call site: link.tsx reaches the same path on
the same upgrade. Entries are now collected before deletion so a
subscriber that seeds a new prefetch cannot be visited by the loop that
is still iterating the cache.
The prefetch setup epoch advanced only on navigation start, but
`router.refresh()` clears the caches via `clearNavigationCaches` ->
`invalidatePrefetchCache` without starting a navigation. A closure still
awaiting its policy import would resume afterwards and repopulate a
navigation-reusable entry derived from the pre-refresh generation,
undoing the refresh for that route. Advance the epoch on invalidation
too, and move it next to the cache it guards.
* refactor(navigation): reuse existing prefetch helpers and drop duplicated branches
No behavior change.
router.prefetch generated the RSC URL and its rewrite variant with two
sequential awaits over the same headers; run them together.
It also set the mounted-slots header by hand when
createAppPrefetchRequestHeaders already accepts `mountedSlotsHeader`.
The option sets the header for any non-null value while the call site
skipped empty strings, so pass `|| null` to keep an empty slot header
off the wire.
link.tsx built a `full-after-shell` policy literal byte-identical to
resolveFullAppRoutePrefetch(), which the policy extraction had already
made shared and which the file already imports; both arms of the ternary
now resolve the same way, so the branch collapses.
hasPrefetchCacheEntryForNavigation had two byte-identical if bodies and
a local closure that existed only to avoid duplicating their shared
tail. Merge the conditions, and hoist the callback attachment into
attachPrefetchInvalidationToEntry so the key-taking
attachPrefetchInvalidationCallback delegates to it instead of repeating
the "seeded entries need their invalidation timer scheduled" invariant.
* fix(navigation): promote a queued prefetch when navigation consumes it
Low-priority prefetches share a 4-slot queue whose runners do not start
until a previous response body is read. A navigation that reuses an
in-flight prefetch awaits that prefetch's promise, so when the request is
still queued the navigation waits on unrelated response bodies before its
own request starts — indefinitely if one of those streams stalls.
Promote on consume: `consumePrefetchResponseForNavigation` now starts a
queued request instead of waiting for a slot. A promoted request is no
longer a prefetch, it is the navigation, so it bypasses the concurrency
cap. Reaching the queued runner needs a handle the queue did not expose,
so it keeps a WeakMap from the returned promise to its runner, and the
cache entry records the schedulable promise (`entry.pending` is the
derived chain, not the promise the queue knows).
Pre-existing via <Link>: link.tsx passes cacheForNavigation through and
prefetchRscResponse defaults it to true, so low-priority viewport
prefetches already produced reusable entries that navigation awaited.
Fixed in the shared queue rather than the router path so both benefit.
Also drops prefetch cache-entry field assertions that restated the
implementation where the same test already asserted the behavior those
fields produce (consume returning a payload, or null).
* test(navigation): cover router.prefetch then router.push at the browser boundary
The issue reports router.prefetch(x) followed by router.push(x). The
existing e2e covered router.prefetch(x) followed by a <Link> click, which
proves cache interoperability but not the reported API sequence — a
regression confined to _appRouter.push or its navigation-start handling
would pass it.
Adds the literal reproduction, plus the same-task variant where push()
starts before prefetch setup has registered anything. Both assert the
contract (destination renders, route fetched once) rather than the
mechanism, so either cancelling the late prefetch or having navigation
adopt an already-started one satisfies them.
Removes the unit test that claimed to cover the same-task path. It
asserted fetch was never called after prefetch() + push(), but push()
issues no request in the unit environment, so it only ever proved the
prefetch did not fire — never the one-request contract. The browser test
replaces it and checks the actual invariant.
* fix(navigation): scope prefetch-setup cancellation to the destination
router.prefetch() guarded its asynchronous setup with a global generation
counter that any navigation advanced. A navigation elsewhere therefore
abandoned an unrelated programmatic prefetch: nothing was going to fetch that
route, so the prefetch became timing-dependent and a later navigation to it had
to fetch again.
Replace the counter with a registry of pending setup tokens keyed by
destination. A navigation cancels only the setups for the href it is about to
fetch itself; invalidatePrefetchCache() still cancels every pending setup,
which is the one case where all in-flight setup really is stale.
Cancellation stays sticky per token, so a navigation to /a followed by one to
/b leaves a pending /a prefetch cancelled — the property a "current
destination" comparison would lose.
Also check the caller's shouldConsume predicate before promoting a queued
prefetch past the concurrency cap, so a navigation superseded while its cache
lookup was being prepared cannot start a low-priority request that competes
with the current navigation.
* fix(navigation): do not cancel prefetch setup on shallow history updates
The patched history.pushState/replaceState reused the navigation-start hook to
clear a stale useLinkStatus() pending state. Once that hook began cancelling
prefetch setup by destination, those two call sites started dropping a pending
prefetch for the URL they moved to — but a raw history update only changes
browser state and issues no RSC request, so nothing takes the prefetch's place.
Split the hook: resetStaleLinkStatus() does the link-status half, and
notifyAppNavigationStart() adds destination cancellation for the callers that
really do fetch. The history patches take the former; popstate keeps the
latter, since the App Router's popstate handler drives an RSC fetch.
Covered by an e2e case rather than a unit test — the unit suite stubs
window.history, so the shim's patch is never the function under test there.
* fix(navigation): cancel superseded prefetch requests
Normalize pending setup keys without URL fragments so same-resource navigations cancel late prefetch setup. Give queued and active prefetch requests abort controls, allowing learning-only entries superseded by a full prefetch to stop instead of downloading a duplicate payload. Avoid loading the rewrite-aware ownership chunk when client rewrites are disabled.
* fix(navigation): capture prefetch context at call time
Match Next.js by snapshotting interception, mounted-slot, nextUrl, and router-tree request context before asynchronous prefetch setup. This prevents same-task history changes from re-keying an explicit prefetch as though it originated from the destination route.
* fix(navigation): keep prefetch setup across same-document hash changes
Stripping the fragment from pending-prefetch destinations made hash-only
navigations compare equal to the route they scroll within, so pushing
/film/x#cast while already on /film/x cancelled a pending /film/x prefetch.
That navigation issues no RSC request — the planner classifies it as a
same-document scroll — so nothing replaced the prefetch it dropped.
Skip cancellation for same-document hash changes. The fragment is still
stripped when comparing destinations, which is what makes a real navigation to
/film/x#cast supersede a pending /film/x prefetch; the two rules are
complementary, not competing.
popstate gets its own entry point: the browser has already applied the history
entry by then, so the pre-navigation URL that isHashOnlyBrowserUrlChange needs
is gone. It keeps cancelling by destination, which over-cancels a hash-only
back/forward at the cost of one re-prefetch and never a duplicate request.
Also fixes the eslint(no-unsafe-optional-chaining) failure in the queue test
that turned CI's `vp run check` red.
* test(navigation): keep queued prefetch assertions type-safe
* fix(navigation): preserve prefetch identity through streaming
Resolve relative prefetch ownership and policy from the call-time URL, matching the already captured request target. Keep abort controls alive until response bodies are consumed so superseded streaming prefetches release bandwidth and queue slots.
---------
Co-authored-by: James <james@eli.cx>1 parent 3d1ab6b commit bf2d7ac
7 files changed
Lines changed: 1339 additions & 235 deletions
File tree
- packages/vinext/src
- client
- shims
- internal
- tests
- e2e/app-router
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
50 | 50 | | |
51 | 51 | | |
52 | 52 | | |
53 | | - | |
| 53 | + | |
54 | 54 | | |
55 | 55 | | |
56 | 56 | | |
| |||
Lines changed: 86 additions & 7 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
7 | 13 | | |
8 | 14 | | |
9 | 15 | | |
| |||
41 | 47 | | |
42 | 48 | | |
43 | 49 | | |
44 | | - | |
| 50 | + | |
45 | 51 | | |
46 | 52 | | |
| 53 | + | |
47 | 54 | | |
48 | | - | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
49 | 70 | | |
50 | 71 | | |
51 | | - | |
52 | | - | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
53 | 79 | | |
54 | 80 | | |
55 | 81 | | |
56 | 82 | | |
| 83 | + | |
57 | 84 | | |
58 | 85 | | |
59 | 86 | | |
60 | 87 | | |
61 | 88 | | |
62 | | - | |
| 89 | + | |
63 | 90 | | |
64 | 91 | | |
65 | 92 | | |
66 | 93 | | |
67 | 94 | | |
68 | 95 | | |
69 | 96 | | |
| 97 | + | |
70 | 98 | | |
71 | 99 | | |
72 | 100 | | |
73 | 101 | | |
74 | 102 | | |
| 103 | + | |
75 | 104 | | |
76 | 105 | | |
77 | 106 | | |
78 | | - | |
79 | | - | |
| 107 | + | |
80 | 108 | | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
81 | 160 | | |
Lines changed: 112 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
0 commit comments