Skip to content

Commit bf2d7ac

Browse files
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/window-next.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ type AppRouterPublicInstance = {
5050
back: () => void;
5151
forward: () => void;
5252
refresh: () => void;
53-
prefetch: (href: string, options?: { onInvalidate?: () => void }) => void;
53+
prefetch: (href: string, options?: { kind?: "auto" | "full"; onInvalidate?: () => void }) => void;
5454
experimental_gesturePush?: (href: string, options?: { scroll?: boolean }) => void;
5555
/** Default placeholder, matches Next.js. */
5656
bfcacheId?: string;

packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts

Lines changed: 86 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ const APP_PREFETCH_FETCH_SLOT_RELEASE_KEY = Symbol.for("vinext.appPrefetchFetchS
44

55
const MAX_DEFAULT_APP_PREFETCH_REQUESTS = 4;
66
const defaultAppPrefetchQueue: Array<() => void> = [];
7+
type AppPrefetchFetchControl = {
8+
cancel: () => void;
9+
runner?: () => void;
10+
};
11+
/** Lets a consumer promote or cancel the request behind a promise it already holds. */
12+
const appPrefetchFetchControls = new WeakMap<Promise<Response>, AppPrefetchFetchControl>();
713
let activeDefaultAppPrefetchRequests = 0;
814
let defaultAppPrefetchDrainScheduled = false;
915

@@ -41,41 +47,114 @@ export function releaseAppPrefetchFetchSlot(response: Response): void {
4147
* releaseAppPrefetchFetchSlot() when it drops the response without consuming it.
4248
*/
4349
export function scheduleAppPrefetchFetch(
44-
fetcher: () => Promise<Response>,
50+
fetcher: (signal: AbortSignal) => Promise<Response>,
4551
priority: "low" | "high",
4652
): Promise<Response> {
53+
const controller = new AbortController();
4754
if (priority === "high") {
48-
return fetcher();
55+
const promise = fetcher(controller.signal);
56+
const control = { cancel: () => controller.abort() };
57+
appPrefetchFetchControls.set(promise, control);
58+
void promise.then(
59+
(response) => {
60+
// Keep cancellation live while the response body streams. The
61+
// consumer releases this after snapshotting (or when dropping a
62+
// non-success response), matching the low-priority lifecycle below.
63+
(response as Response & Record<symbol, (() => void) | undefined>)[
64+
APP_PREFETCH_FETCH_SLOT_RELEASE_KEY
65+
] = () => appPrefetchFetchControls.delete(promise);
66+
},
67+
() => appPrefetchFetchControls.delete(promise),
68+
);
69+
return promise;
4970
}
5071

51-
return new Promise<Response>((resolve, reject) => {
52-
defaultAppPrefetchQueue.push(() => {
72+
let runner!: () => void;
73+
let rejectPromise!: (reason?: unknown) => void;
74+
let started = false;
75+
const promise = new Promise<Response>((resolve, reject) => {
76+
rejectPromise = reject;
77+
runner = () => {
78+
started = true;
5379
let didRelease = false;
5480
const release = () => {
5581
if (didRelease) return;
5682
didRelease = true;
83+
appPrefetchFetchControls.delete(promise);
5784
activeDefaultAppPrefetchRequests -= 1;
5885
drainDefaultAppPrefetchQueue();
5986
};
6087

6188
try {
62-
fetcher().then(
89+
fetcher(controller.signal).then(
6390
(response) => {
6491
(response as Response & Record<symbol, (() => void) | undefined>)[
6592
APP_PREFETCH_FETCH_SLOT_RELEASE_KEY
6693
] = release;
6794
resolve(response);
6895
},
6996
(error: unknown) => {
97+
appPrefetchFetchControls.delete(promise);
7098
release();
7199
reject(error);
72100
},
73101
);
74102
} catch (error) {
103+
appPrefetchFetchControls.delete(promise);
75104
release();
76105
reject(error);
77106
}
78-
});
79-
scheduleDefaultAppPrefetchDrain();
107+
};
80108
});
109+
110+
defaultAppPrefetchQueue.push(runner);
111+
appPrefetchFetchControls.set(promise, {
112+
runner,
113+
cancel: () => {
114+
if (started) {
115+
controller.abort();
116+
return;
117+
}
118+
const index = defaultAppPrefetchQueue.indexOf(runner);
119+
if (index === -1) return;
120+
defaultAppPrefetchQueue.splice(index, 1);
121+
appPrefetchFetchControls.delete(promise);
122+
controller.abort();
123+
rejectPromise(controller.signal.reason);
124+
},
125+
});
126+
scheduleDefaultAppPrefetchDrain();
127+
return promise;
128+
}
129+
130+
/** Cancel a queued or in-flight prefetch request. No-op once it has settled. */
131+
export function cancelAppPrefetchFetch(promise: Promise<Response> | undefined): void {
132+
if (promise === undefined) return;
133+
appPrefetchFetchControls.get(promise)?.cancel();
134+
}
135+
136+
/**
137+
* Start a still-queued prefetch request immediately.
138+
*
139+
* A navigation that reuses an in-flight prefetch awaits that prefetch's
140+
* promise. When the request is only queued, the navigation would otherwise wait
141+
* for unrelated prefetch response bodies to finish before its own request even
142+
* starts — indefinitely if one of those streams stalls. A promoted request is
143+
* no longer a prefetch, it is the navigation, so it bypasses the concurrency
144+
* cap instead of waiting for a slot.
145+
*
146+
* No-op when the request has already started or was never queued.
147+
*/
148+
export function promoteAppPrefetchFetch(promise: Promise<Response> | undefined): void {
149+
if (promise === undefined) return;
150+
const control = appPrefetchFetchControls.get(promise);
151+
const runner = control?.runner;
152+
if (runner === undefined) return;
153+
154+
const index = defaultAppPrefetchQueue.indexOf(runner);
155+
if (index === -1) return;
156+
defaultAppPrefetchQueue.splice(index, 1);
157+
158+
activeDefaultAppPrefetchRequests += 1;
159+
runner();
81160
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* App Router prefetch policy resolution, shared by `<Link>` (`link.tsx`) and
3+
* `router.prefetch()` (`navigation.ts`).
4+
*
5+
* Decides, from the client prefetch route manifest
6+
* (`__VINEXT_LINK_PREFETCH_ROUTES__`), whether a prefetched RSC payload can be
7+
* cached for navigation reuse or must stay a learning-only / loading-shell
8+
* prefetch. Lives in `shims/internal/` because `link.tsx` is a `"use client"`
9+
* React module while `navigation.ts` must stay importable without React —
10+
* mirrors the layering of `internal/app-route-detection.ts`.
11+
*/
12+
import type { VinextLinkPrefetchRoute } from "../../client/vinext-next-data.js";
13+
import { createRouteTrieCache, matchRouteWithTrie } from "../../routing/route-matching.js";
14+
import { stripBasePath } from "../../utils/base-path.js";
15+
16+
declare global {
17+
// Window is an ambient interface from lib.dom; interface merging is required
18+
// for this global browser hook.
19+
// oxlint-disable-next-line typescript-eslint/consistent-type-definitions
20+
interface Window {
21+
__VINEXT_LINK_PREFETCH_ROUTES__?: VinextLinkPrefetchRoute[];
22+
}
23+
}
24+
25+
/** basePath from next.config.js, injected by the plugin at build time */
26+
const __basePath: string = process.env.__NEXT_ROUTER_BASEPATH ?? "";
27+
28+
const linkPrefetchRouteTrieCache = createRouteTrieCache<VinextLinkPrefetchRoute>();
29+
30+
/**
31+
* How an App Router prefetch for a given href should behave: whether to issue
32+
* it at all, whether the response is reusable by a later navigation, and which
33+
* cache TTL family applies.
34+
*/
35+
export type AppRoutePrefetchPolicy = {
36+
cacheForNavigation: boolean;
37+
fallbackTtl: "dynamic" | "static";
38+
minimumTtlMs: number | undefined;
39+
prefetchShellFirst: boolean;
40+
shouldPrefetch: boolean;
41+
};
42+
43+
function toSameOriginRouteHref(href: string): string | null {
44+
if (typeof window === "undefined") return null;
45+
46+
let url: URL;
47+
try {
48+
url = new URL(href, window.location.href);
49+
} catch {
50+
return null;
51+
}
52+
53+
if (url.origin !== window.location.origin) return null;
54+
55+
return `${stripBasePath(url.pathname, __basePath)}${url.search}`;
56+
}
57+
58+
/** Href the manifest does not cover: no request, nothing reusable. */
59+
const NO_APP_ROUTE_PREFETCH: AppRoutePrefetchPolicy = {
60+
cacheForNavigation: false,
61+
fallbackTtl: "static",
62+
minimumTtlMs: undefined,
63+
prefetchShellFirst: false,
64+
shouldPrefetch: false,
65+
};
66+
67+
export function canAutoPrefetchFullAppRoute(href: string): boolean {
68+
return resolveAutoAppRoutePrefetch(href).cacheForNavigation;
69+
}
70+
71+
export function resolveAutoAppRoutePrefetch(href: string): AppRoutePrefetchPolicy {
72+
if (typeof window === "undefined") return NO_APP_ROUTE_PREFETCH;
73+
74+
const routes = window.__VINEXT_LINK_PREFETCH_ROUTES__;
75+
if (!routes) return NO_APP_ROUTE_PREFETCH;
76+
77+
const routeHref = toSameOriginRouteHref(href);
78+
if (routeHref === null) return NO_APP_ROUTE_PREFETCH;
79+
80+
const match = matchRouteWithTrie(routeHref, routes, linkPrefetchRouteTrieCache);
81+
if (!match) return NO_APP_ROUTE_PREFETCH;
82+
83+
const route = match.route;
84+
// A search-param href renders query-specific output, so its payload can only
85+
// ever be a shell — never reusable by a navigation to the same route.
86+
const hasSearchParams = new URL(routeHref, "http://vinext.local").search !== "";
87+
return {
88+
// Vinext does not yet have Next.js's per-segment runtime-prefetch hints.
89+
// Routes with loading boundaries prefetch a shell first so navigation can
90+
// commit loading.js immediately. Dynamic routes without loading-shell
91+
// fallbacks can be cached for navigation unless their active parallel
92+
// branches must be derived from the click-time target tree.
93+
cacheForNavigation:
94+
!hasSearchParams &&
95+
!route.canPrefetchLoadingShell &&
96+
route.requiresDynamicNavigationRequest !== true,
97+
fallbackTtl: "static",
98+
minimumTtlMs: route.isDynamic ? 0 : undefined,
99+
prefetchShellFirst: hasSearchParams || !route.isDynamic,
100+
shouldPrefetch: true,
101+
};
102+
}
103+
104+
export function resolveFullAppRoutePrefetch(): AppRoutePrefetchPolicy {
105+
return {
106+
cacheForNavigation: true,
107+
fallbackTtl: "static",
108+
minimumTtlMs: undefined,
109+
prefetchShellFirst: true,
110+
shouldPrefetch: true,
111+
};
112+
}

0 commit comments

Comments
 (0)