From bdbd0d1efc46a8c88bb112cf8300d5ed2ec99242 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Sun, 26 Jul 2026 19:45:08 +1000
Subject: [PATCH 01/17] 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.
---
.../internal/app-route-prefetch-policy.ts | 163 ++++++++++++++++++
packages/vinext/src/shims/link.tsx | 143 +--------------
2 files changed, 171 insertions(+), 135 deletions(-)
create mode 100644 packages/vinext/src/shims/internal/app-route-prefetch-policy.ts
diff --git a/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts b/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts
new file mode 100644
index 000000000..5a69f889e
--- /dev/null
+++ b/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts
@@ -0,0 +1,163 @@
+/**
+ * App Router prefetch policy resolution, shared by `` (`link.tsx`) and
+ * `router.prefetch()` (`navigation.ts`).
+ *
+ * Decides, from the client prefetch route manifest
+ * (`__VINEXT_LINK_PREFETCH_ROUTES__`), whether a prefetched RSC payload can be
+ * cached for navigation reuse or must stay a learning-only / loading-shell
+ * prefetch. Lives in `shims/internal/` because `link.tsx` is a `"use client"`
+ * React module while `navigation.ts` must stay importable without React —
+ * mirrors the layering of `internal/app-route-detection.ts`.
+ */
+import type { VinextLinkPrefetchRoute } from "../../client/vinext-next-data.js";
+import { createRouteTrieCache, matchRouteWithTrie } from "../../routing/route-matching.js";
+import { stripBasePath } from "../../utils/base-path.js";
+
+declare global {
+ // Window is an ambient interface from lib.dom; interface merging is required
+ // for this global browser hook.
+ // oxlint-disable-next-line typescript-eslint/consistent-type-definitions
+ interface Window {
+ __VINEXT_LINK_PREFETCH_ROUTES__?: VinextLinkPrefetchRoute[];
+ }
+}
+
+/** basePath from next.config.js, injected by the plugin at build time */
+const __basePath: string = process.env.__NEXT_ROUTER_BASEPATH ?? "";
+
+const linkPrefetchRouteTrieCache = createRouteTrieCache();
+
+/**
+ * How an App Router prefetch for a given href should behave: whether to issue
+ * it at all, whether the response is reusable by a later navigation, and which
+ * cache TTL family applies.
+ */
+export type AppRoutePrefetchPolicy = {
+ cacheForNavigation: boolean;
+ fallbackTtl: "dynamic" | "static";
+ minimumTtlMs: number | undefined;
+ prefetchShellFirst: boolean;
+ shouldPrefetch: boolean;
+};
+
+function toSameOriginRouteHref(href: string): string | null {
+ if (typeof window === "undefined") return null;
+
+ let url: URL;
+ try {
+ url = new URL(href, window.location.href);
+ } catch {
+ return null;
+ }
+
+ if (url.origin !== window.location.origin) return null;
+
+ return `${stripBasePath(url.pathname, __basePath)}${url.search}`;
+}
+
+function resolveMatchedAutoAppRoutePrefetch(
+ route: VinextLinkPrefetchRoute,
+): AppRoutePrefetchPolicy {
+ const hasLoadingShell = route.canPrefetchLoadingShell;
+ const shouldCacheForNavigation =
+ !hasLoadingShell && route.requiresDynamicNavigationRequest !== true;
+ return {
+ // Vinext does not yet have Next.js's per-segment runtime-prefetch hints.
+ // Routes with loading boundaries prefetch a shell first so navigation can
+ // commit loading.js immediately. Dynamic routes without loading-shell
+ // fallbacks can be cached for navigation unless their active parallel
+ // branches must be derived from the click-time target tree.
+ cacheForNavigation: shouldCacheForNavigation,
+ fallbackTtl: "static",
+ minimumTtlMs: route.isDynamic ? 0 : undefined,
+ prefetchShellFirst: !route.isDynamic,
+ shouldPrefetch: true,
+ };
+}
+
+export function canAutoPrefetchFullAppRoute(href: string): boolean {
+ if (typeof window === "undefined") return false;
+
+ const routes = window.__VINEXT_LINK_PREFETCH_ROUTES__;
+ if (!routes) return false;
+
+ const routeHref = toSameOriginRouteHref(href);
+ if (routeHref === null) return false;
+
+ const match = matchRouteWithTrie(routeHref, routes, linkPrefetchRouteTrieCache);
+ if (!match) return false;
+
+ return resolveAutoAppRoutePrefetch(href).cacheForNavigation;
+}
+
+export function resolveAutoAppRoutePrefetch(href: string): AppRoutePrefetchPolicy {
+ if (typeof window === "undefined") {
+ return {
+ cacheForNavigation: false,
+ fallbackTtl: "static",
+ minimumTtlMs: undefined,
+ prefetchShellFirst: false,
+ shouldPrefetch: false,
+ };
+ }
+
+ const routes = window.__VINEXT_LINK_PREFETCH_ROUTES__;
+ if (!routes) {
+ return {
+ cacheForNavigation: false,
+ fallbackTtl: "static",
+ minimumTtlMs: undefined,
+ prefetchShellFirst: false,
+ shouldPrefetch: false,
+ };
+ }
+
+ const routeHref = toSameOriginRouteHref(href);
+ if (routeHref === null) {
+ return {
+ cacheForNavigation: false,
+ fallbackTtl: "static",
+ minimumTtlMs: undefined,
+ prefetchShellFirst: false,
+ shouldPrefetch: false,
+ };
+ }
+
+ const match = matchRouteWithTrie(routeHref, routes, linkPrefetchRouteTrieCache);
+ if (!match) {
+ return {
+ cacheForNavigation: false,
+ fallbackTtl: "static",
+ minimumTtlMs: undefined,
+ prefetchShellFirst: false,
+ shouldPrefetch: false,
+ };
+ }
+
+ const prefetch = resolveMatchedAutoAppRoutePrefetch(match.route);
+ const url = new URL(routeHref, "http://vinext.local");
+ if (url.search !== "") {
+ return {
+ ...prefetch,
+ cacheForNavigation: false,
+ prefetchShellFirst: true,
+ };
+ }
+
+ return prefetch;
+}
+
+export function resolveFullAppRoutePrefetch(): AppRoutePrefetchPolicy & {
+ cacheForNavigation: true;
+ fallbackTtl: "static";
+ minimumTtlMs: undefined;
+ shouldPrefetch: true;
+} {
+ return {
+ cacheForNavigation: true,
+ fallbackTtl: "static",
+ minimumTtlMs: undefined,
+ prefetchShellFirst: true,
+ shouldPrefetch: true,
+ };
+}
diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx
index 41af7b826..a2a93d671 100644
--- a/packages/vinext/src/shims/link.tsx
+++ b/packages/vinext/src/shims/link.tsx
@@ -47,8 +47,6 @@ import {
navigatePagesRouterLinkWithFallback,
resolvePagesRouterQueryOnlyHref,
} from "../client/pages-router-link-navigation.js";
-import { createRouteTrieCache, matchRouteWithTrie } from "../routing/route-matching.js";
-import { stripBasePath } from "../utils/base-path.js";
import { isBotUserAgent } from "../utils/html-limited-bots.js";
import {
getPagesMiddlewareDataHref,
@@ -57,6 +55,11 @@ import {
} from "./internal/pages-data-target.js";
import { interpolateDynamicRouteHref, resolveDynamicRouteHref } from "./internal/interpolate-as.js";
import { markAppRouteDetectedOnPrefetch } from "./internal/app-route-detection.js";
+import {
+ canAutoPrefetchFullAppRoute,
+ resolveAutoAppRoutePrefetch,
+ resolveFullAppRoutePrefetch,
+} from "./internal/app-route-prefetch-policy.js";
import { RouterContext } from "./internal/router-context.js";
import { getCurrentBrowserLocale } from "./client-locale.js";
import {
@@ -196,7 +199,6 @@ const __basePath: string = process.env.__NEXT_ROUTER_BASEPATH ?? "";
/** trailingSlash from next.config.js, injected by the plugin at build time */
const __trailingSlash: boolean = process.env.__VINEXT_TRAILING_SLASH === "true";
const __prefetchInlining: boolean = process.env.__VINEXT_PREFETCH_INLINING === "true";
-const linkPrefetchRouteTrieCache = createRouteTrieCache();
function resolveHref(href: LinkProps["href"]): string {
if (typeof href === "string") return href;
@@ -332,143 +334,14 @@ export function resolveLinkPrefetchMode(
return "auto";
}
-function toSameOriginRouteHref(href: string): string | null {
- if (typeof window === "undefined") return null;
-
- let url: URL;
- try {
- url = new URL(href, window.location.href);
- } catch {
- return null;
- }
-
- if (url.origin !== window.location.origin) return null;
-
- return `${stripBasePath(url.pathname, __basePath)}${url.search}`;
-}
+// Re-exported so the public test surface of this shim is unchanged after the
+// policy helpers moved to internal/app-route-prefetch-policy.ts.
+export { canAutoPrefetchFullAppRoute, resolveAutoAppRoutePrefetch };
function getLinkPrefetchRouterMode(): LinkPrefetchRouterMode {
return hasAppNavigationRuntime() ? "app" : "pages";
}
-function resolveMatchedAutoAppRoutePrefetch(route: VinextLinkPrefetchRoute): {
- cacheForNavigation: boolean;
- fallbackTtl: "dynamic" | "static";
- minimumTtlMs: number | undefined;
- prefetchShellFirst: boolean;
- shouldPrefetch: boolean;
-} {
- const hasLoadingShell = route.canPrefetchLoadingShell;
- const shouldCacheForNavigation =
- !hasLoadingShell && route.requiresDynamicNavigationRequest !== true;
- return {
- // Vinext does not yet have Next.js's per-segment runtime-prefetch hints.
- // Routes with loading boundaries prefetch a shell first so navigation can
- // commit loading.js immediately. Dynamic routes without loading-shell
- // fallbacks can be cached for navigation unless their active parallel
- // branches must be derived from the click-time target tree.
- cacheForNavigation: shouldCacheForNavigation,
- fallbackTtl: "static",
- minimumTtlMs: route.isDynamic ? 0 : undefined,
- prefetchShellFirst: !route.isDynamic,
- shouldPrefetch: true,
- };
-}
-
-export function canAutoPrefetchFullAppRoute(href: string): boolean {
- if (typeof window === "undefined") return false;
-
- const routes = window.__VINEXT_LINK_PREFETCH_ROUTES__;
- if (!routes) return false;
-
- const routeHref = toSameOriginRouteHref(href);
- if (routeHref === null) return false;
-
- const match = matchRouteWithTrie(routeHref, routes, linkPrefetchRouteTrieCache);
- if (!match) return false;
-
- return resolveAutoAppRoutePrefetch(href).cacheForNavigation;
-}
-
-export function resolveAutoAppRoutePrefetch(href: string): {
- cacheForNavigation: boolean;
- fallbackTtl: "dynamic" | "static";
- minimumTtlMs: number | undefined;
- prefetchShellFirst: boolean;
- shouldPrefetch: boolean;
-} {
- if (typeof window === "undefined") {
- return {
- cacheForNavigation: false,
- fallbackTtl: "static",
- minimumTtlMs: undefined,
- prefetchShellFirst: false,
- shouldPrefetch: false,
- };
- }
-
- const routes = window.__VINEXT_LINK_PREFETCH_ROUTES__;
- if (!routes) {
- return {
- cacheForNavigation: false,
- fallbackTtl: "static",
- minimumTtlMs: undefined,
- prefetchShellFirst: false,
- shouldPrefetch: false,
- };
- }
-
- const routeHref = toSameOriginRouteHref(href);
- if (routeHref === null) {
- return {
- cacheForNavigation: false,
- fallbackTtl: "static",
- minimumTtlMs: undefined,
- prefetchShellFirst: false,
- shouldPrefetch: false,
- };
- }
-
- const match = matchRouteWithTrie(routeHref, routes, linkPrefetchRouteTrieCache);
- if (!match) {
- return {
- cacheForNavigation: false,
- fallbackTtl: "static",
- minimumTtlMs: undefined,
- prefetchShellFirst: false,
- shouldPrefetch: false,
- };
- }
-
- const prefetch = resolveMatchedAutoAppRoutePrefetch(match.route);
- const url = new URL(routeHref, "http://vinext.local");
- if (url.search !== "") {
- return {
- ...prefetch,
- cacheForNavigation: false,
- prefetchShellFirst: true,
- };
- }
-
- return prefetch;
-}
-
-function resolveFullAppRoutePrefetch(): {
- cacheForNavigation: true;
- fallbackTtl: "static";
- minimumTtlMs: undefined;
- prefetchShellFirst: boolean;
- shouldPrefetch: true;
-} {
- return {
- cacheForNavigation: true,
- fallbackTtl: "static",
- minimumTtlMs: undefined,
- prefetchShellFirst: true,
- shouldPrefetch: true,
- };
-}
-
// ---------------------------------------------------------------------------
// Prefetching infrastructure
// ---------------------------------------------------------------------------
From 255dd13963c8c65d282d0d78e55b2ffef7e199f0 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Sun, 26 Jul 2026 19:49:14 +1000
Subject: [PATCH 02/17] 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 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
, 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
---
packages/vinext/src/client/window-next.ts | 2 +-
packages/vinext/src/shims/navigation.ts | 66 +++++++++-
tests/prefetch-cache.test.ts | 139 +++++++++++++++++++++-
3 files changed, 199 insertions(+), 8 deletions(-)
diff --git a/packages/vinext/src/client/window-next.ts b/packages/vinext/src/client/window-next.ts
index 1b193eed3..47a7ca219 100644
--- a/packages/vinext/src/client/window-next.ts
+++ b/packages/vinext/src/client/window-next.ts
@@ -50,7 +50,7 @@ type AppRouterPublicInstance = {
back: () => void;
forward: () => void;
refresh: () => void;
- prefetch: (href: string, options?: { onInvalidate?: () => void }) => void;
+ prefetch: (href: string, options?: { kind?: "auto" | "full"; onInvalidate?: () => void }) => void;
experimental_gesturePush?: (href: string, options?: { scroll?: boolean }) => void;
/** Default placeholder, matches Next.js. */
bfcacheId?: string;
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index 3676d3fa8..b6f55d973 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -39,11 +39,17 @@ import {
} from "../server/app-rsc-cache-busting.js";
import { hasPendingAppRouterPageRedirect } from "../server/app-browser-mpa-navigation.js";
import {
+ NEXT_ROUTER_PREFETCH_HEADER,
+ NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
VINEXT_DYNAMIC_STALE_TIME_HEADER,
VINEXT_MOUNTED_SLOTS_HEADER,
VINEXT_PARAMS_HEADER,
VINEXT_RENDERED_PATH_AND_SEARCH_HEADER,
} from "../server/headers.js";
+import {
+ resolveAutoAppRoutePrefetch,
+ resolveFullAppRoutePrefetch,
+} from "./internal/app-route-prefetch-policy.js";
import { toBrowserNavigationHref, toSameOriginAppPath, withBasePath } from "./url-utils.js";
import { navigationPlanner } from "../server/navigation-planner.js";
import { stripBasePath } from "../utils/base-path.js";
@@ -226,6 +232,8 @@ const isServer = typeof window === "undefined";
/** basePath from next.config.js, injected by the plugin at build time */
export const __basePath: string = process.env.__NEXT_ROUTER_BASEPATH ?? "";
+/** prefetch inlining (Segment Cache wire mode), injected by the plugin at build time */
+const __prefetchInlining: boolean = process.env.__VINEXT_PREFETCH_INLINING === "true";
// ---------------------------------------------------------------------------
// RSC prefetch cache utilities (shared between link.tsx and browser entry)
@@ -2302,20 +2310,47 @@ const _appRouter: AppRouterInstance = {
// Prefetch the RSC payload for the target route and store in cache.
// We must add to prefetchedUrls manually for deduplication.
// prefetchRscResponse only manages the cache Map, not the URL set.
+ //
+ // Resolve the same prefetch policy as so the cached payload is
+ // reusable by a later navigation (issue #2707). Next.js parity:
+ // router.prefetch() defaults to PrefetchKind.AUTO and accepts
+ // kind: "full"; anything else falls back to auto like Next's `default:`
+ // branch (app-router-instance.ts). When the auto policy declines the
+ // route (no manifest match, loading shell, search params), fall back to
+ // the previous learning-only fetch: an explicit programmatic prefetch
+ // must still fetch, and loading-shell routes keep feeding the
+ // optimistic-route-template learner.
const fullHref = toBrowserNavigationHref(prefetchHref, window.location.href, __basePath);
+ const kind = options?.kind === "full" ? "full" : "auto";
+ const policy =
+ kind === "full" ? resolveFullAppRoutePrefetch() : resolveAutoAppRoutePrefetch(prefetchHref);
+ const reusable = policy.shouldPrefetch && policy.cacheForNavigation;
const interceptionContext = getPrefetchInterceptionContext(fullHref);
const mountedSlotsHeader = getMountedSlotsHeader();
const headers = createAppPrefetchRequestHeaders({
fetchPriority: "low",
interceptionContext,
+ prefetchKind: reusable ? kind : undefined,
});
+ if (reusable && kind === "auto") {
+ headers.set(NEXT_ROUTER_PREFETCH_HEADER, "1");
+ headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, __prefetchInlining ? "/__PAGE__" : "1");
+ }
if (mountedSlotsHeader) {
headers.set(VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader);
}
const rscUrl = await createRscRequestUrl(fullHref, headers);
const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext);
const prefetched = getPrefetchedUrls();
- if (prefetched.has(cacheKey)) {
+ if (reusable) {
+ // A previous learning-only prefetch for the same URL must not satisfy
+ // the freshness gate below; only a navigation-reusable entry counts.
+ discardLearningOnlyPrefetchCacheEntry(rscUrl, interceptionContext);
+ if (hasPrefetchCacheEntryForNavigation(rscUrl, interceptionContext, mountedSlotsHeader)) {
+ attachPrefetchInvalidationCallback(cacheKey, options?.onInvalidate);
+ return;
+ }
+ } else if (prefetched.has(cacheKey)) {
attachPrefetchInvalidationCallback(cacheKey, options?.onInvalidate);
return;
}
@@ -2334,11 +2369,30 @@ const _appRouter: AppRouterInstance = {
interceptionContext,
mountedSlotsHeader,
options,
- {
- cacheForNavigation: false,
- optimisticRouteShell: true,
- prefetchKind: "navigation",
- },
+ reusable
+ ? {
+ cacheForNavigation: true,
+ fallbackTtlMs:
+ policy.fallbackTtl === "dynamic"
+ ? DYNAMIC_NAVIGATION_CACHE_TTL
+ : PREFETCH_CACHE_TTL,
+ minimumTtlMs: policy.minimumTtlMs,
+ optimisticRouteShell: false,
+ prefetchKind: "navigation",
+ prepareSnapshot: async (snapshot) => {
+ const preparePrefetchResponse =
+ getNavigationRuntime()?.functions.preparePrefetchResponse;
+ if (!preparePrefetchResponse) {
+ throw new Error("App Router prefetch preparation is unavailable");
+ }
+ return (await preparePrefetchResponse(restoreRscResponse(snapshot))) as AppElements;
+ },
+ }
+ : {
+ cacheForNavigation: false,
+ optimisticRouteShell: true,
+ prefetchKind: "navigation",
+ },
);
})().catch((error) => {
console.error("[vinext] RSC prefetch setup error:", error);
diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts
index 979b35a7e..1ecacc80b 100644
--- a/tests/prefetch-cache.test.ts
+++ b/tests/prefetch-cache.test.ts
@@ -123,6 +123,10 @@ function createDeferredResponse(): {
return { promise, resolve };
}
+function toRscUrlString(input: RequestInfo | URL): string {
+ return typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+}
+
async function waitForPrefetchSetup(isReady: () => boolean = () => true): Promise {
const deadline = Date.now() + 1_000;
@@ -444,7 +448,7 @@ describe("prefetch cache eviction", () => {
expect(release).toHaveBeenCalledTimes(1);
});
- it("settles router.prefetch as a learning-only protocol response without visible navigation", async () => {
+ it("falls back to learning-only when no prefetch route manifest matches", async () => {
let resolveResponse!: (response: Response) => void;
const fetchPromise = new Promise((resolve) => {
resolveResponse = resolve;
@@ -503,6 +507,139 @@ describe("prefetch cache eviction", () => {
expect(navigate).not.toHaveBeenCalled();
});
+ it("caches router.prefetch for navigation reuse on static-eligible routes (#2707)", async () => {
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
+ ];
+ let fetchedUrl: string | undefined;
+ let fetchedHeaders: Headers | undefined;
+ const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ fetchedUrl = toRscUrlString(input);
+ fetchedHeaders = init?.headers as Headers;
+ return new Response("flight", { headers: { "content-type": "text/x-component" } });
+ });
+ (globalThis as any).fetch = fetch;
+
+ appRouterInstance.prefetch("/dashboard");
+ await waitForPrefetchSetup(() => fetch.mock.calls.length > 0);
+
+ if (fetchedUrl === undefined) {
+ throw new Error("Expected router.prefetch to fetch an RSC URL");
+ }
+ expect(fetchedHeaders?.get("Next-Router-Prefetch")).toBe("1");
+ expect(fetchedHeaders?.get("Next-Router-Segment-Prefetch")).toBe("1");
+
+ const cacheKey = AppElementsWire.encodeCacheKey(fetchedUrl, null);
+ await waitForPrefetchSetup(() => getPrefetchCache().get(cacheKey)?.outcome === "cache-seeded");
+ const entry = getPrefetchCache().get(cacheKey);
+ expect(entry?.cacheForNavigation).toBe(true);
+ expect(entry?.optimisticRouteShell).toBe(false);
+ expect(entry?.prefetchKind).toBe("navigation");
+
+ // A second programmatic prefetch while the entry is fresh must not issue
+ // another request.
+ appRouterInstance.prefetch("/dashboard");
+ await waitForPrefetchSetup();
+ expect(fetch).toHaveBeenCalledTimes(1);
+
+ const consumed = consumePrefetchResponse(fetchedUrl, null, null);
+ expect(consumed).not.toBeNull();
+ if (consumed === null) return;
+ await expect(restoreRscResponse(consumed).text()).resolves.toBe("flight");
+ });
+
+ it("shares an in-flight router.prefetch with navigation instead of refetching (#2707)", async () => {
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
+ ];
+ const deferred = createDeferredResponse();
+ let fetchedUrl: string | undefined;
+ const fetch = vi.fn((input: RequestInfo | URL) => {
+ fetchedUrl = toRscUrlString(input);
+ return deferred.promise;
+ });
+ (globalThis as any).fetch = fetch;
+
+ appRouterInstance.prefetch("/dashboard");
+ await waitForPrefetchSetup(() => fetch.mock.calls.length > 0);
+ if (fetchedUrl === undefined) {
+ throw new Error("Expected router.prefetch to fetch an RSC URL");
+ }
+
+ let settled = false;
+ const consumedPromise = consumePrefetchResponseForNavigation(fetchedUrl, null, null).then(
+ (snapshot) => {
+ settled = true;
+ return snapshot;
+ },
+ );
+ await Promise.resolve();
+ expect(settled).toBe(false);
+
+ deferred.resolve(new Response("flight", { headers: { "content-type": "text/x-component" } }));
+ const consumed = await consumedPromise;
+ expect(consumed).not.toBeNull();
+ if (consumed === null) return;
+ await expect(restoreRscResponse(consumed).text()).resolves.toBe("flight");
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('caches kind: "full" router.prefetch for navigation on loading-shell routes (#2707)', async () => {
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: true, patternParts: ["reports"], isDynamic: false },
+ ];
+ let fetchedUrl: string | undefined;
+ let fetchedHeaders: Headers | undefined;
+ const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
+ fetchedUrl = toRscUrlString(input);
+ fetchedHeaders = init?.headers as Headers;
+ return new Response("flight", { headers: { "content-type": "text/x-component" } });
+ });
+ (globalThis as any).fetch = fetch;
+
+ appRouterInstance.prefetch("/reports", { kind: "full" });
+ await waitForPrefetchSetup(() => fetch.mock.calls.length > 0);
+ if (fetchedUrl === undefined) {
+ throw new Error("Expected router.prefetch to fetch an RSC URL");
+ }
+ // A full prefetch requests the complete payload: the wire protocol
+ // suppresses Next-Router-Prefetch (matches Link's prefetch={true}).
+ expect(fetchedHeaders?.get("Next-Router-Prefetch")).toBeNull();
+ expect(fetchedHeaders?.get("Next-Router-Segment-Prefetch")).toBeNull();
+
+ const cacheKey = AppElementsWire.encodeCacheKey(fetchedUrl, null);
+ await waitForPrefetchSetup(() => getPrefetchCache().get(cacheKey)?.outcome === "cache-seeded");
+ expect(getPrefetchCache().get(cacheKey)?.cacheForNavigation).toBe(true);
+
+ const consumed = consumePrefetchResponse(fetchedUrl, null, null);
+ expect(consumed).not.toBeNull();
+ });
+
+ it("keeps default-kind router.prefetch learning-only on loading-shell routes (#2707)", async () => {
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: true, patternParts: ["reports"], isDynamic: false },
+ ];
+ let fetchedUrl: string | undefined;
+ const fetch = vi.fn(async (input: RequestInfo | URL) => {
+ fetchedUrl = toRscUrlString(input);
+ return new Response("flight", { headers: { "content-type": "text/x-component" } });
+ });
+ (globalThis as any).fetch = fetch;
+
+ appRouterInstance.prefetch("/reports");
+ await waitForPrefetchSetup(() => fetch.mock.calls.length > 0);
+ if (fetchedUrl === undefined) {
+ throw new Error("Expected router.prefetch to fetch an RSC URL");
+ }
+
+ const cacheKey = AppElementsWire.encodeCacheKey(fetchedUrl, null);
+ await waitForPrefetchSetup(() => getPrefetchCache().get(cacheKey)?.outcome === "cache-seeded");
+ const entry = getPrefetchCache().get(cacheKey);
+ expect(entry?.cacheForNavigation).toBe(false);
+ expect(entry?.optimisticRouteShell).toBe(true);
+ expect(consumePrefetchResponse(fetchedUrl, null, null)).toBeNull();
+ });
+
it("limits low-priority router.prefetch requests until queued responses are snapshotted", async () => {
const deferredResponses: Array<{
resolve: (response: Response) => void;
From a587ba57b0be36b742d2182eb048b6919268a8f8 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Sun, 26 Jul 2026 19:54:46 +1000
Subject: [PATCH 03/17] 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.
---
.../app-router/router-prefetch-reuse.spec.ts | 73 +++++++++++++++++++
1 file changed, 73 insertions(+)
create mode 100644 tests/e2e/app-router/router-prefetch-reuse.spec.ts
diff --git a/tests/e2e/app-router/router-prefetch-reuse.spec.ts b/tests/e2e/app-router/router-prefetch-reuse.spec.ts
new file mode 100644
index 000000000..b7d2a122a
--- /dev/null
+++ b/tests/e2e/app-router/router-prefetch-reuse.spec.ts
@@ -0,0 +1,73 @@
+/**
+ * router.prefetch() navigation reuse (issue #2707).
+ *
+ * A programmatic router.prefetch(x) followed by a navigation to x must reuse
+ * the prefetched RSC payload instead of issuing a second RSC request, matching
+ * Next.js's PrefetchKind.AUTO semantics. The /film/[imdbId] fixture has no
+ * loading.tsx, so the auto prefetch policy marks it reusable, and /top
+ * intercepts it via @modal/(..)film so the test also proves the interception
+ * context survives prefetch reuse.
+ */
+import { test, expect, type Request } from "@playwright/test";
+import { waitForAppRouterHydration } from "../helpers";
+
+const BASE = process.env.VINEXT_E2E_BASE_URL ?? "http://localhost:4174";
+const FILM_HREF = "/film/tt0068646-the-godfather-1972";
+
+type RouterWindow = Window & {
+ next?: { router?: { prefetch(href: string): void } };
+};
+
+function isFilmRscRequest(request: Request): boolean {
+ const url = new URL(request.url());
+ return (
+ url.pathname.startsWith("/film/") &&
+ url.searchParams.has("_rsc") &&
+ request.headers()["rsc"] === "1"
+ );
+}
+
+test.describe("router.prefetch navigation reuse", () => {
+ test("click after router.prefetch reuses the prefetched payload", async ({ page }) => {
+ const filmRscRequests: string[] = [];
+ page.on("request", (request) => {
+ if (isFilmRscRequest(request)) filmRscRequests.push(request.url());
+ });
+
+ await page.goto(`${BASE}/top`);
+ await waitForAppRouterHydration(page);
+
+ await page.evaluate((href) => {
+ const router = (window as RouterWindow).next?.router;
+ if (router === undefined) throw new Error("Missing app router instance");
+ router.prefetch(href);
+ }, FILM_HREF);
+ await expect.poll(() => filmRscRequests.length).toBe(1);
+
+ await page.click("#godfather-film-link");
+
+ // Interception must stay intact: the modal slot renders the film panel.
+ await expect(page.locator('[data-testid="film-panel"]')).toBeVisible();
+ await expect(page).toHaveURL(`${BASE}${FILM_HREF}`);
+
+ // The navigation consumed the prefetched payload; no second RSC request.
+ expect(filmRscRequests.length).toBe(1);
+ });
+
+ test("click without prefetch issues exactly one navigation request", async ({ page }) => {
+ // Guards the request counter above: proves a plain click is observed as a
+ // /film/* RSC request, so the reuse test's "still 1" assertion is meaningful.
+ const filmRscRequests: string[] = [];
+ page.on("request", (request) => {
+ if (isFilmRscRequest(request)) filmRscRequests.push(request.url());
+ });
+
+ await page.goto(`${BASE}/top`);
+ await waitForAppRouterHydration(page);
+
+ await page.click("#godfather-film-link");
+
+ await expect(page.locator('[data-testid="film-panel"]')).toBeVisible();
+ await expect.poll(() => filmRscRequests.length).toBe(1);
+ });
+});
From a78870c3eabf141280d5ea5894f26b29c94440e5 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Sun, 26 Jul 2026 20:16:53 +1000
Subject: [PATCH 04/17] 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).
---
packages/vinext/src/shims/navigation.ts | 59 +++++++++++++++++++++----
tests/prefetch-cache.test.ts | 40 +++++++++++++++++
2 files changed, 91 insertions(+), 8 deletions(-)
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index b6f55d973..10d312f8a 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -46,10 +46,6 @@ import {
VINEXT_PARAMS_HEADER,
VINEXT_RENDERED_PATH_AND_SEARCH_HEADER,
} from "../server/headers.js";
-import {
- resolveAutoAppRoutePrefetch,
- resolveFullAppRoutePrefetch,
-} from "./internal/app-route-prefetch-policy.js";
import { toBrowserNavigationHref, toSameOriginAppPath, withBasePath } from "./url-utils.js";
import { navigationPlanner } from "../server/navigation-planner.js";
import { stripBasePath } from "../utils/base-path.js";
@@ -89,6 +85,7 @@ import {
} from "./internal/app-prefetch-fetch-queue.js";
const HAS_PAGES_ROUTER = process.env.__VINEXT_HAS_PAGES_ROUTER !== "false";
+const HAS_CLIENT_REWRITES = process.env.__VINEXT_HAS_CLIENT_REWRITES !== "false";
type HybridClientRouteOwnerModule = typeof import("./internal/hybrid-client-route-owner.js");
let hybridClientRouteOwnerModule: HybridClientRouteOwnerModule | null = null;
let hybridClientRouteOwnerModulePromise: Promise | null = null;
@@ -552,7 +549,11 @@ export function hasPrefetchCacheEntryForNavigation(
rscUrl: string,
interceptionContext: string | null = null,
mountedSlotsHeader: string | null = null,
- options: { additionalRscUrls?: readonly string[]; notifyInvalidation?: boolean } = {},
+ options: {
+ additionalRscUrls?: readonly string[];
+ notifyInvalidation?: boolean;
+ onInvalidate?: () => void;
+ } = {},
): boolean {
const match = findPrefetchCacheEntryForNavigation(
rscUrl,
@@ -562,12 +563,25 @@ export function hasPrefetchCacheEntryForNavigation(
);
if (match === null) return false;
+ // Register onInvalidate against the matched entry, not the caller's exact
+ // cache key — the match may be a normalized `_rsc` variant or an alias, so
+ // an exact-key lookup after this call could silently miss it.
+ const attachOnInvalidate = (): void => {
+ if (options.onInvalidate === undefined) return;
+ addPrefetchInvalidationCallback(match.entry, options.onInvalidate);
+ if (match.entry.outcome === "cache-seeded") {
+ schedulePrefetchInvalidation(match.cacheKey, match.entry);
+ }
+ };
+
if (match.entry.pending !== undefined) {
touchPrefetchCacheEntry(getPrefetchCache(), match.cacheKey, match.entry);
+ attachOnInvalidate();
return true;
}
if (resolvePrefetchCacheEntryExpiresAt(match.entry) > Date.now()) {
touchPrefetchCacheEntry(getPrefetchCache(), match.cacheKey, match.entry);
+ attachOnInvalidate();
return true;
}
@@ -2302,6 +2316,12 @@ const _appRouter: AppRouterInstance = {
// an unusable cache entry. The matching `push`/`replace` call will
// hard-navigate via `window.location`, so a no-op here is correct —
// the document prefetch the link shim emits on hover still runs.
+ // Load the rewrite-aware module first (mirrors Link, which always
+ // resolves ownership and rewrites through the full module) so the
+ // prefetch policy below sees the rewritten destination route.
+ if (HAS_PAGES_ROUTER || HAS_CLIENT_REWRITES) {
+ await preloadHybridClientRouteOwner();
+ }
const hybridOwner = resolveHybridClientRouteOwner(prefetchHref);
if (hybridOwner === "pages" || hybridOwner === "document") {
return;
@@ -2321,9 +2341,21 @@ const _appRouter: AppRouterInstance = {
// must still fetch, and loading-shell routes keep feeding the
// optimistic-route-template learner.
const fullHref = toBrowserNavigationHref(prefetchHref, window.location.href, __basePath);
+ // A configured rewrite can map this href onto a different App route;
+ // the policy must describe the destination the request will actually
+ // resolve to, not the source pattern (mirrors Link's prefetchPolicyHref).
+ const rewrittenPrefetchHref = HAS_CLIENT_REWRITES
+ ? resolveLoadedHybridClientRewriteHref(fullHref, __basePath)
+ : null;
const kind = options?.kind === "full" ? "full" : "auto";
+ // Dynamic import keeps the policy module and its route-trie
+ // dependencies off the startup path of every next/navigation consumer.
+ const { resolveAutoAppRoutePrefetch, resolveFullAppRoutePrefetch } =
+ await import("./internal/app-route-prefetch-policy.js");
const policy =
- kind === "full" ? resolveFullAppRoutePrefetch() : resolveAutoAppRoutePrefetch(prefetchHref);
+ kind === "full"
+ ? resolveFullAppRoutePrefetch()
+ : resolveAutoAppRoutePrefetch(rewrittenPrefetchHref ?? prefetchHref);
const reusable = policy.shouldPrefetch && policy.cacheForNavigation;
const interceptionContext = getPrefetchInterceptionContext(fullHref);
const mountedSlotsHeader = getMountedSlotsHeader();
@@ -2340,14 +2372,25 @@ const _appRouter: AppRouterInstance = {
headers.set(VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader);
}
const rscUrl = await createRscRequestUrl(fullHref, headers);
+ const additionalRscUrls =
+ rewrittenPrefetchHref !== null && rewrittenPrefetchHref !== fullHref
+ ? [await createRscRequestUrl(rewrittenPrefetchHref, headers)]
+ : [];
const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext);
const prefetched = getPrefetchedUrls();
if (reusable) {
// A previous learning-only prefetch for the same URL must not satisfy
// the freshness gate below; only a navigation-reusable entry counts.
+ // The gate attaches onInvalidate to whichever entry it matches — the
+ // match may live under a normalized `_rsc` variant or rendered-path
+ // alias, not this call's exact cache key.
discardLearningOnlyPrefetchCacheEntry(rscUrl, interceptionContext);
- if (hasPrefetchCacheEntryForNavigation(rscUrl, interceptionContext, mountedSlotsHeader)) {
- attachPrefetchInvalidationCallback(cacheKey, options?.onInvalidate);
+ if (
+ hasPrefetchCacheEntryForNavigation(rscUrl, interceptionContext, mountedSlotsHeader, {
+ additionalRscUrls,
+ onInvalidate: options?.onInvalidate,
+ })
+ ) {
return;
}
} else if (prefetched.has(cacheKey)) {
diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts
index 1ecacc80b..2aa560ba7 100644
--- a/tests/prefetch-cache.test.ts
+++ b/tests/prefetch-cache.test.ts
@@ -584,6 +584,46 @@ describe("prefetch cache eviction", () => {
expect(fetch).toHaveBeenCalledTimes(1);
});
+ it("attaches onInvalidate when reuse matches an entry under a different _rsc variant (#2707)", async () => {
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
+ ];
+ // Seed a reusable entry the way a would, under a
+ // different `_rsc` cache-busting variant than router.prefetch computes.
+ const aliasRscUrl = "/dashboard?_rsc=linkvariant";
+ prefetchRscResponse(
+ aliasRscUrl,
+ Promise.resolve(new Response("flight", { headers: { "content-type": "text/x-component" } })),
+ null,
+ null,
+ undefined,
+ { cacheForNavigation: true },
+ );
+ await waitForPrefetchSetup(
+ () => getPrefetchCache().get(aliasRscUrl)?.outcome === "cache-seeded",
+ );
+
+ const fetch = vi.fn();
+ const onInvalidate = vi.fn();
+ (globalThis as any).fetch = fetch;
+
+ appRouterInstance.prefetch("/dashboard", { onInvalidate });
+ // The prefetch closure awaits module imports before it reaches the
+ // freshness gate; wait for the observable registration instead of a
+ // fixed microtask count.
+ await waitForPrefetchSetup(
+ () => (getPrefetchCache().get(aliasRscUrl)?.onInvalidateCallbacks?.size ?? 0) > 0,
+ );
+
+ // The alias entry satisfied the freshness gate: no new request, and the
+ // callback must be registered on the matched entry, not the absent exact
+ // cache key.
+ expect(fetch).not.toHaveBeenCalled();
+
+ invalidatePrefetchCache();
+ expect(onInvalidate).toHaveBeenCalledTimes(1);
+ });
+
it('caches kind: "full" router.prefetch for navigation on loading-shell routes (#2707)', async () => {
(globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
{ canPrefetchLoadingShell: true, patternParts: ["reports"], isDynamic: false },
From d92877083f4f357a8292a68a1d7deb3fecfb29a4 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Mon, 27 Jul 2026 16:42:07 +1000
Subject: [PATCH 05/17] 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.
---
packages/vinext/src/shims/navigation.ts | 112 ++++++++++++++++++++----
tests/prefetch-cache.test.ts | 63 +++++++++++++
2 files changed, 156 insertions(+), 19 deletions(-)
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index 10d312f8a..6416255c7 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -720,6 +720,18 @@ function clearPrefetchInvalidation(entry: PrefetchCacheEntry): void {
}
}
+function runInvalidationCallback(onInvalidate: () => void): void {
+ try {
+ onInvalidate();
+ } catch (error) {
+ if (typeof reportError === "function") {
+ reportError(error);
+ } else {
+ console.error(error);
+ }
+ }
+}
+
function notifyPrefetchInvalidated(entry: PrefetchCacheEntry): void {
clearPrefetchInvalidation(entry);
const callbacks = entry.onInvalidateCallbacks;
@@ -727,15 +739,43 @@ function notifyPrefetchInvalidated(entry: PrefetchCacheEntry): void {
if (callbacks === undefined) return;
for (const onInvalidate of callbacks) {
- try {
- onInvalidate();
- } catch (error) {
- if (typeof reportError === "function") {
- reportError(error);
- } else {
- console.error(error);
- }
- }
+ runInvalidationCallback(onInvalidate);
+ }
+}
+
+/**
+ * `onInvalidate` callbacks whose prefetch cache entry has already been handed
+ * to a navigation. A prefetch entry is single-consumption — navigation deletes
+ * it as it takes ownership of the payload — but Next.js keeps the callback on
+ * the prefetch task across cache reads and fires it exactly once when the data
+ * goes stale or the client cache is invalidated
+ * (`packages/next/src/client/components/segment-cache*`). Dropping it at
+ * consumption time would silently break `router.prefetch(href, { onInvalidate })`
+ * followed by `router.push(href)`.
+ */
+type RetainedPrefetchInvalidation = {
+ callbacks: Set<() => void>;
+ timer: ReturnType;
+};
+const retainedPrefetchInvalidations = new Set();
+
+function retainPrefetchInvalidationAfterConsume(entry: PrefetchCacheEntry): void {
+ const callbacks = entry.onInvalidateCallbacks;
+ if (callbacks === undefined || callbacks.size === 0) return;
+
+ const delay = Math.max(0, resolvePrefetchCacheEntryExpiresAt(entry) - Date.now());
+ const retained: RetainedPrefetchInvalidation = {
+ callbacks,
+ timer: setTimeout(() => fireRetainedPrefetchInvalidation(retained), delay),
+ };
+ retainedPrefetchInvalidations.add(retained);
+}
+
+function fireRetainedPrefetchInvalidation(retained: RetainedPrefetchInvalidation): void {
+ if (!retainedPrefetchInvalidations.delete(retained)) return;
+ clearTimeout(retained.timer);
+ for (const onInvalidate of retained.callbacks) {
+ runInvalidationCallback(onInvalidate);
}
}
@@ -837,6 +877,12 @@ export function invalidatePrefetchCache(): void {
deletePrefetchCacheEntry(cache, prefetched, cacheKey, entry, true);
}
prefetched.clear();
+ // Each callback removes its own record before running, which Set iteration
+ // tolerates; a record retained by a callback is fired too, which is the
+ // correct outcome for a full cache invalidation.
+ for (const retained of retainedPrefetchInvalidations) {
+ fireRetainedPrefetchInvalidation(retained);
+ }
if (!isServer) {
getNavigationRuntime()?.functions.pingVisibleLinks?.();
}
@@ -1241,9 +1287,14 @@ function consumeMatchedPrefetchResponse(
return null;
}
if (resolvePrefetchCacheEntryExpiresAt(entry) <= Date.now()) {
- deletePrefetchCacheEntry(cache, getPrefetchedUrls(), cacheKey, entry, false);
+ // The entry aged out before navigation reached it — that *is* the
+ // invalidation `onInvalidate` subscribers are waiting for.
+ deletePrefetchCacheEntry(cache, getPrefetchedUrls(), cacheKey, entry, true);
return null;
}
+ // Navigation takes ownership of the payload; the entry is deleted, but the
+ // invalidation subscription outlives it (see retainedPrefetchInvalidations).
+ retainPrefetchInvalidationAfterConsume(entry);
deletePrefetchCacheEntry(cache, getPrefetchedUrls(), cacheKey, entry, false);
const snapshot = entry.snapshot;
// Only synthesize `expiresAt` onto the returned snapshot when the entry (or
@@ -2024,6 +2075,25 @@ function hardNavigateTo(fullHref: string, mode: "push" | "replace"): void {
}
}
+/**
+ * Bumped whenever a navigation starts. `router.prefetch()` captures it before
+ * its `await`s so a navigation that wins the setup race can cancel the late
+ * prefetch instead of letting it issue a second request for the same route.
+ * Mirrors `linkPrefetchNavigationEpoch` in link.tsx.
+ */
+let appNavigationEpoch = 0;
+
+/**
+ * Signal that a navigation is starting: cancel in-flight prefetch setup and
+ * reset any link still showing a `useLinkStatus()` pending state that did not
+ * initiate this navigation (e.g. a programmatic router.push or form submit).
+ * A click registers itself first, so the hook keeps that link pending.
+ */
+function notifyAppNavigationStart(): void {
+ appNavigationEpoch += 1;
+ getNavigationRuntime()?.functions.notifyLinkNavigationStart?.();
+}
+
/**
* Navigate to a URL, handling external URLs, hash-only changes, and RSC navigation.
*/
@@ -2034,10 +2104,7 @@ export async function navigateClientSide(
programmaticTransition = false,
visibleCommitMode: NavigationRuntimeVisibleCommitMode = "transition",
): Promise {
- // Reset any link still showing a `useLinkStatus()` pending state that did not
- // initiate this navigation (e.g. a programmatic router.push or form submit).
- // A click registers itself first, so the hook keeps that link pending.
- getNavigationRuntime()?.functions.notifyLinkNavigationStart?.();
+ notifyAppNavigationStart();
// Normalize same-origin absolute URLs to local paths for SPA navigation
let normalizedHref = href;
@@ -2227,7 +2294,7 @@ const _appRouter: AppRouterInstance = {
// An imperative navigation supersedes any -owned pending state.
// Clear it before entering the navigation transition so React does not
// defer the idle update behind the suspended destination render.
- getNavigationRuntime()?.functions.notifyLinkNavigationStart?.();
+ notifyAppNavigationStart();
const releaseNavigation = trackScheduledAppRouterNavigation();
try {
React.startTransition(() => {
@@ -2242,7 +2309,7 @@ const _appRouter: AppRouterInstance = {
replace(href: string, options?: { scroll?: boolean }): void {
assertSafeNavigationUrl(href);
if (isServer) return;
- getNavigationRuntime()?.functions.notifyLinkNavigationStart?.();
+ notifyAppNavigationStart();
const releaseNavigation = trackScheduledAppRouterNavigation();
try {
React.startTransition(() => {
@@ -2299,6 +2366,7 @@ const _appRouter: AppRouterInstance = {
} catch {
throw new Error(`Cannot prefetch '${href}' because it cannot be converted to a URL.`);
}
+ const navigationEpoch = appNavigationEpoch;
void (async () => {
// Normalize same-origin absolute URLs to local paths; no-op for external
// origins so we don't pollute the prefetch cache with a same-path .rsc on
@@ -2376,6 +2444,12 @@ const _appRouter: AppRouterInstance = {
rewrittenPrefetchHref !== null && rewrittenPrefetchHref !== fullHref
? [await createRscRequestUrl(rewrittenPrefetchHref, headers)]
: [];
+ // A navigation can start in the same task as this call and win the race
+ // above (hybrid-route module load, policy import, RSC URL generation).
+ // Nothing was registered in the cache during that window, so navigation
+ // already began its own request; starting a second one here would break
+ // the one-request-per-route invariant. Mirrors Link's equivalent guard.
+ if (navigationEpoch !== appNavigationEpoch) return;
const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext);
const prefetched = getPrefetchedUrls();
if (reusable) {
@@ -2651,7 +2725,7 @@ if (!isServer) {
// not initiate, so clear any sticky `useLinkStatus()` pending state. Runs
// for both routers; the App Router's own popstate handler (in
// app-browser-entry.ts) drives scroll restoration and RSC fetching.
- getNavigationRuntime()?.functions.notifyLinkNavigationStart?.();
+ notifyAppNavigationStart();
});
window.addEventListener("popstate", (event) => {
@@ -2675,7 +2749,7 @@ if (!isServer) {
if (state.suppressUrlNotifyCount === 0) {
// A raw history.pushState (shallow routing) starts a navigation that did
// not go through navigateClientSide; clear any sticky pending link.
- getNavigationRuntime()?.functions.notifyLinkNavigationStart?.();
+ notifyAppNavigationStart();
commitClientNavigationState();
}
};
@@ -2692,7 +2766,7 @@ if (!isServer) {
url,
);
if (state.suppressUrlNotifyCount === 0) {
- getNavigationRuntime()?.functions.notifyLinkNavigationStart?.();
+ notifyAppNavigationStart();
commitClientNavigationState();
}
};
diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts
index 2aa560ba7..467d6665d 100644
--- a/tests/prefetch-cache.test.ts
+++ b/tests/prefetch-cache.test.ts
@@ -137,6 +137,13 @@ async function waitForPrefetchSetup(isReady: () => boolean = () => true): Promis
} while (Date.now() < deadline);
}
+/** Drain enough macrotasks for a prefetch setup closure to run to completion. */
+async function settlePrefetchSetup(): Promise {
+ for (let i = 0; i < 10; i++) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+}
+
describe("prefetch cache eviction", () => {
it("router.prefetch does not fetch for a bot user agent", async () => {
const fetch = vi.fn();
@@ -624,6 +631,62 @@ describe("prefetch cache eviction", () => {
expect(onInvalidate).toHaveBeenCalledTimes(1);
});
+ it("cancels router.prefetch setup when a navigation starts in the same task (#2707)", async () => {
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
+ ];
+ const fetch = vi.fn(
+ async () => new Response("flight", { headers: { "content-type": "text/x-component" } }),
+ );
+ (globalThis as any).fetch = fetch;
+
+ // No polling between the two calls: navigation starts while the prefetch
+ // closure is still awaiting its module imports and RSC URL, i.e. before it
+ // has registered anything navigation could share.
+ appRouterInstance.prefetch("/dashboard");
+ appRouterInstance.push("/dashboard");
+
+ await settlePrefetchSetup();
+
+ // Navigation owns the request for this route now; the late prefetch must
+ // not start a second one.
+ expect(fetch).not.toHaveBeenCalled();
+ expect(getPrefetchCache().size).toBe(0);
+ });
+
+ it("keeps onInvalidate alive after navigation consumes the prefetch (#2707)", async () => {
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
+ ];
+ let fetchedUrl: string | undefined;
+ const fetch = vi.fn(async (input: RequestInfo | URL) => {
+ fetchedUrl = toRscUrlString(input);
+ return new Response("flight", { headers: { "content-type": "text/x-component" } });
+ });
+ (globalThis as any).fetch = fetch;
+
+ const onInvalidate = vi.fn();
+ appRouterInstance.prefetch("/dashboard", { onInvalidate });
+ await waitForPrefetchSetup(() => fetch.mock.calls.length > 0);
+ if (fetchedUrl === undefined) {
+ throw new Error("Expected router.prefetch to fetch an RSC URL");
+ }
+ const cacheKey = AppElementsWire.encodeCacheKey(fetchedUrl, null);
+ await waitForPrefetchSetup(() => getPrefetchCache().get(cacheKey)?.outcome === "cache-seeded");
+
+ // Navigation takes the payload; the cache entry is gone but the
+ // subscription must outlive it.
+ expect(consumePrefetchResponse(fetchedUrl, null, null)).not.toBeNull();
+ expect(getPrefetchCache().get(cacheKey)).toBeUndefined();
+ expect(onInvalidate).not.toHaveBeenCalled();
+
+ invalidatePrefetchCache();
+ expect(onInvalidate).toHaveBeenCalledTimes(1);
+ // Fires exactly once, matching Next.js's prefetch-task contract.
+ invalidatePrefetchCache();
+ expect(onInvalidate).toHaveBeenCalledTimes(1);
+ });
+
it('caches kind: "full" router.prefetch for navigation on loading-shell routes (#2707)', async () => {
(globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
{ canPrefetchLoadingShell: true, patternParts: ["reports"], isDynamic: false },
From 4282cfb6624cf359e372d14c8a48ca1abbd5f79a Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Mon, 27 Jul 2026 17:06:05 +1000
Subject: [PATCH 06/17] refactor(navigation): de-duplicate app prefetch policy
and snapshot prep
No behavior change.
`` 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.
---
.../internal/app-route-prefetch-policy.ts | 115 +++++-------------
packages/vinext/src/shims/link.tsx | 12 +-
packages/vinext/src/shims/navigation.ts | 25 ++--
3 files changed, 51 insertions(+), 101 deletions(-)
diff --git a/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts b/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts
index 5a69f889e..0f4c44c34 100644
--- a/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts
+++ b/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts
@@ -55,104 +55,53 @@ function toSameOriginRouteHref(href: string): string | null {
return `${stripBasePath(url.pathname, __basePath)}${url.search}`;
}
-function resolveMatchedAutoAppRoutePrefetch(
- route: VinextLinkPrefetchRoute,
-): AppRoutePrefetchPolicy {
- const hasLoadingShell = route.canPrefetchLoadingShell;
- const shouldCacheForNavigation =
- !hasLoadingShell && route.requiresDynamicNavigationRequest !== true;
- return {
- // Vinext does not yet have Next.js's per-segment runtime-prefetch hints.
- // Routes with loading boundaries prefetch a shell first so navigation can
- // commit loading.js immediately. Dynamic routes without loading-shell
- // fallbacks can be cached for navigation unless their active parallel
- // branches must be derived from the click-time target tree.
- cacheForNavigation: shouldCacheForNavigation,
- fallbackTtl: "static",
- minimumTtlMs: route.isDynamic ? 0 : undefined,
- prefetchShellFirst: !route.isDynamic,
- shouldPrefetch: true,
- };
-}
+/** Href the manifest does not cover: no request, nothing reusable. */
+const NO_APP_ROUTE_PREFETCH: AppRoutePrefetchPolicy = {
+ cacheForNavigation: false,
+ fallbackTtl: "static",
+ minimumTtlMs: undefined,
+ prefetchShellFirst: false,
+ shouldPrefetch: false,
+};
export function canAutoPrefetchFullAppRoute(href: string): boolean {
- if (typeof window === "undefined") return false;
-
- const routes = window.__VINEXT_LINK_PREFETCH_ROUTES__;
- if (!routes) return false;
-
- const routeHref = toSameOriginRouteHref(href);
- if (routeHref === null) return false;
-
- const match = matchRouteWithTrie(routeHref, routes, linkPrefetchRouteTrieCache);
- if (!match) return false;
-
return resolveAutoAppRoutePrefetch(href).cacheForNavigation;
}
export function resolveAutoAppRoutePrefetch(href: string): AppRoutePrefetchPolicy {
- if (typeof window === "undefined") {
- return {
- cacheForNavigation: false,
- fallbackTtl: "static",
- minimumTtlMs: undefined,
- prefetchShellFirst: false,
- shouldPrefetch: false,
- };
- }
+ if (typeof window === "undefined") return NO_APP_ROUTE_PREFETCH;
const routes = window.__VINEXT_LINK_PREFETCH_ROUTES__;
- if (!routes) {
- return {
- cacheForNavigation: false,
- fallbackTtl: "static",
- minimumTtlMs: undefined,
- prefetchShellFirst: false,
- shouldPrefetch: false,
- };
- }
+ if (!routes) return NO_APP_ROUTE_PREFETCH;
const routeHref = toSameOriginRouteHref(href);
- if (routeHref === null) {
- return {
- cacheForNavigation: false,
- fallbackTtl: "static",
- minimumTtlMs: undefined,
- prefetchShellFirst: false,
- shouldPrefetch: false,
- };
- }
+ if (routeHref === null) return NO_APP_ROUTE_PREFETCH;
const match = matchRouteWithTrie(routeHref, routes, linkPrefetchRouteTrieCache);
- if (!match) {
- return {
- cacheForNavigation: false,
- fallbackTtl: "static",
- minimumTtlMs: undefined,
- prefetchShellFirst: false,
- shouldPrefetch: false,
- };
- }
+ if (!match) return NO_APP_ROUTE_PREFETCH;
- const prefetch = resolveMatchedAutoAppRoutePrefetch(match.route);
- const url = new URL(routeHref, "http://vinext.local");
- if (url.search !== "") {
- return {
- ...prefetch,
- cacheForNavigation: false,
- prefetchShellFirst: true,
- };
- }
-
- return prefetch;
+ const route = match.route;
+ // A search-param href renders query-specific output, so its payload can only
+ // ever be a shell — never reusable by a navigation to the same route.
+ const hasSearchParams = new URL(routeHref, "http://vinext.local").search !== "";
+ return {
+ // Vinext does not yet have Next.js's per-segment runtime-prefetch hints.
+ // Routes with loading boundaries prefetch a shell first so navigation can
+ // commit loading.js immediately. Dynamic routes without loading-shell
+ // fallbacks can be cached for navigation unless their active parallel
+ // branches must be derived from the click-time target tree.
+ cacheForNavigation:
+ !hasSearchParams &&
+ !route.canPrefetchLoadingShell &&
+ route.requiresDynamicNavigationRequest !== true,
+ fallbackTtl: "static",
+ minimumTtlMs: route.isDynamic ? 0 : undefined,
+ prefetchShellFirst: hasSearchParams || !route.isDynamic,
+ shouldPrefetch: true,
+ };
}
-export function resolveFullAppRoutePrefetch(): AppRoutePrefetchPolicy & {
- cacheForNavigation: true;
- fallbackTtl: "static";
- minimumTtlMs: undefined;
- shouldPrefetch: true;
-} {
+export function resolveFullAppRoutePrefetch(): AppRoutePrefetchPolicy {
return {
cacheForNavigation: true,
fallbackTtl: "static",
diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx
index a2a93d671..54503afd4 100644
--- a/packages/vinext/src/shims/link.tsx
+++ b/packages/vinext/src/shims/link.tsx
@@ -449,6 +449,7 @@ function prefetchUrl(
hasPrefetchCacheEntryForNavigation,
peekPrefetchResponseForNavigation,
prefetchRscResponse,
+ prepareNavigationPrefetchSnapshot,
DYNAMIC_NAVIGATION_CACHE_TTL,
restoreRscResponse,
PREFETCH_CACHE_TTL,
@@ -764,16 +765,7 @@ function prefetchUrl(
optimisticRouteShell: isOptimisticRouteShellPrefetch,
prefetchKind: isOptimisticRouteShellPrefetch ? "loading-shell" : "navigation",
prepareSnapshot: autoPrefetch.cacheForNavigation
- ? async (snapshot) => {
- const preparePrefetchResponse =
- getNavigationRuntime()?.functions.preparePrefetchResponse;
- if (!preparePrefetchResponse) {
- throw new Error("App Router prefetch preparation is unavailable");
- }
- return (await preparePrefetchResponse(
- restoreRscResponse(snapshot),
- )) as ReturnType;
- }
+ ? prepareNavigationPrefetchSnapshot
: undefined,
searchAgnosticShell: isAutomaticSearchParamShell && !hasSearchAgnosticShell,
},
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index 6416255c7..b3be369ab 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -1082,6 +1082,22 @@ export function restoreRscResponse(cached: CachedRscResponse, copy = true): Resp
});
}
+/**
+ * `prefetchRscResponse`'s `prepareSnapshot` for navigation-reusable entries:
+ * decode the cached payload through the App Router runtime so a later
+ * navigation can commit it without re-parsing. Shared by `` and
+ * `router.prefetch()`.
+ */
+export async function prepareNavigationPrefetchSnapshot(
+ snapshot: CachedRscResponse,
+): Promise {
+ const preparePrefetchResponse = getNavigationRuntime()?.functions.preparePrefetchResponse;
+ if (!preparePrefetchResponse) {
+ throw new Error("App Router prefetch preparation is unavailable");
+ }
+ return (await preparePrefetchResponse(restoreRscResponse(snapshot))) as AppElements;
+}
+
/**
* Prefetch an RSC response and snapshot it for later consumption.
* Stores the in-flight promise so immediate clicks can await it instead
@@ -2496,14 +2512,7 @@ const _appRouter: AppRouterInstance = {
minimumTtlMs: policy.minimumTtlMs,
optimisticRouteShell: false,
prefetchKind: "navigation",
- prepareSnapshot: async (snapshot) => {
- const preparePrefetchResponse =
- getNavigationRuntime()?.functions.preparePrefetchResponse;
- if (!preparePrefetchResponse) {
- throw new Error("App Router prefetch preparation is unavailable");
- }
- return (await preparePrefetchResponse(restoreRscResponse(snapshot))) as AppElements;
- },
+ prepareSnapshot: prepareNavigationPrefetchSnapshot,
}
: {
cacheForNavigation: false,
From 0027309a8eea26bab573417f7084c4574b5858a1 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Mon, 27 Jul 2026 17:19:22 +1000
Subject: [PATCH 07/17] 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 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.
---
packages/vinext/src/shims/navigation.ts | 46 +++++++++++++++------
tests/prefetch-cache.test.ts | 53 +++++++++++++++++++++++++
2 files changed, 87 insertions(+), 12 deletions(-)
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index b3be369ab..4f7356a64 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -713,6 +713,21 @@ function evictPrefetchCacheIfNeeded(): void {
}
}
+/**
+ * Prefetch-cache generation counter. `router.prefetch()` captures it before its
+ * `await`s and re-checks after, so setup belonging to a superseded generation
+ * cannot register an entry. It advances on two events, both of which mean "any
+ * prefetch still being set up is now based on stale state":
+ * - a navigation starts (`notifyAppNavigationStart`), which will fetch the
+ * route itself — a late prefetch would duplicate that request
+ * - the prefetch cache is invalidated (`invalidatePrefetchCache`, reached via
+ * `router.refresh()`), which would otherwise be undone for that one route by
+ * a closure that started before the refresh
+ * Mirrors `linkPrefetchNavigationEpoch` in link.tsx, which currently advances
+ * only on the first of those.
+ */
+let appNavigationEpoch = 0;
+
function clearPrefetchInvalidation(entry: PrefetchCacheEntry): void {
if (entry.invalidationTimer !== undefined) {
clearTimeout(entry.invalidationTimer);
@@ -816,17 +831,27 @@ export function discardLearningOnlyPrefetchCacheEntry(
const normalizedTarget = normalizeRscCacheLookupUrl(rscUrl);
if (normalizedTarget === null) return false;
- let discarded = false;
+ // Collect before deleting: notifying runs subscriber callbacks synchronously,
+ // and a callback that seeds a new prefetch would otherwise be appended to the
+ // Map this loop is still iterating.
+ const superseded: Array<[string, PrefetchCacheEntry]> = [];
for (const [cacheKey, entry] of cache) {
if (entry.cacheForNavigation !== false || entry.prefetchKind !== "navigation") continue;
const source = parsePrefetchCacheKey(cacheKey);
if (source.interceptionContext !== interceptionContext) continue;
if (normalizeRscCacheLookupUrl(source.rscUrl) !== normalizedTarget) continue;
- deletePrefetchCacheEntry(cache, prefetched, cacheKey, entry, false);
- discarded = true;
+ superseded.push([cacheKey, entry]);
+ }
+
+ // A superseded prefetch is dirty in Next.js terms — its payload is being
+ // replaced by a navigation-reusable one — so `onInvalidate` subscribers are
+ // notified rather than silently dropped. Both callers (`router.prefetch()`
+ // and ``) reach this on the learning-only -> reusable upgrade.
+ for (const [cacheKey, entry] of superseded) {
+ deletePrefetchCacheEntry(cache, prefetched, cacheKey, entry, true);
}
- return discarded;
+ return superseded.length > 0;
}
function invalidatePrefetchCacheEntry(cacheKey: string): void {
@@ -871,6 +896,11 @@ function attachPrefetchInvalidationCallback(
}
export function invalidatePrefetchCache(): void {
+ // Void prefetch setup that is still in flight. Without this, a closure that
+ // started before `router.refresh()` resumes afterwards and repopulates a
+ // navigation-reusable entry built from the pre-refresh cache generation,
+ // undoing the invalidation for that route.
+ appNavigationEpoch += 1;
const cache = getPrefetchCache();
const prefetched = getPrefetchedUrls();
for (const [cacheKey, entry] of cache) {
@@ -2091,14 +2121,6 @@ function hardNavigateTo(fullHref: string, mode: "push" | "replace"): void {
}
}
-/**
- * Bumped whenever a navigation starts. `router.prefetch()` captures it before
- * its `await`s so a navigation that wins the setup race can cancel the late
- * prefetch instead of letting it issue a second request for the same route.
- * Mirrors `linkPrefetchNavigationEpoch` in link.tsx.
- */
-let appNavigationEpoch = 0;
-
/**
* Signal that a navigation is starting: cancel in-flight prefetch setup and
* reset any link still showing a `useLinkStatus()` pending state that did not
diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts
index 467d6665d..f5f46179e 100644
--- a/tests/prefetch-cache.test.ts
+++ b/tests/prefetch-cache.test.ts
@@ -654,6 +654,59 @@ describe("prefetch cache eviction", () => {
expect(getPrefetchCache().size).toBe(0);
});
+ it("notifies onInvalidate when a learning-only prefetch is superseded (#2707)", async () => {
+ // A loading-shell route: the default `kind` resolves to learning-only, and
+ // a later `kind: "full"` upgrades the same URL to a reusable entry.
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: true, patternParts: ["reports"], isDynamic: false },
+ ];
+ let fetchedUrl: string | undefined;
+ const fetch = vi.fn(async (input: RequestInfo | URL) => {
+ fetchedUrl = toRscUrlString(input);
+ return new Response("flight", { headers: { "content-type": "text/x-component" } });
+ });
+ (globalThis as any).fetch = fetch;
+
+ const onInvalidate = vi.fn();
+ appRouterInstance.prefetch("/reports", { onInvalidate });
+ await waitForPrefetchSetup(() => fetch.mock.calls.length > 0);
+ if (fetchedUrl === undefined) {
+ throw new Error("Expected router.prefetch to fetch an RSC URL");
+ }
+ const learningKey = AppElementsWire.encodeCacheKey(fetchedUrl, null);
+ await waitForPrefetchSetup(
+ () => getPrefetchCache().get(learningKey)?.outcome === "cache-seeded",
+ );
+ expect(getPrefetchCache().get(learningKey)?.cacheForNavigation).toBe(false);
+
+ // The upgrade discards the learning-only entry. Its subscriber must be told
+ // the payload is gone rather than have the callback silently dropped.
+ appRouterInstance.prefetch("/reports", { kind: "full" });
+ await waitForPrefetchSetup(() => onInvalidate.mock.calls.length > 0);
+ expect(onInvalidate).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not repopulate the prefetch cache across an invalidation (#2707)", async () => {
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
+ ];
+ const fetch = vi.fn(
+ async () => new Response("flight", { headers: { "content-type": "text/x-component" } }),
+ );
+ (globalThis as any).fetch = fetch;
+
+ // router.refresh() reaches invalidatePrefetchCache() while this closure is
+ // still awaiting its policy import, i.e. before it registers anything.
+ appRouterInstance.prefetch("/dashboard");
+ invalidatePrefetchCache();
+
+ await settlePrefetchSetup();
+
+ // The entry would have been built from the pre-refresh cache generation.
+ expect(fetch).not.toHaveBeenCalled();
+ expect(getPrefetchCache().size).toBe(0);
+ });
+
it("keeps onInvalidate alive after navigation consumes the prefetch (#2707)", async () => {
(globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
{ canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
From 60575ba102d17dc19d592061977e01b20659a4c0 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Mon, 27 Jul 2026 18:01:39 +1000
Subject: [PATCH 08/17] 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.
---
packages/vinext/src/shims/link.tsx | 10 +---
packages/vinext/src/shims/navigation.ts | 66 ++++++++++++++-----------
2 files changed, 38 insertions(+), 38 deletions(-)
diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx
index 54503afd4..7b58d6ac3 100644
--- a/packages/vinext/src/shims/link.tsx
+++ b/packages/vinext/src/shims/link.tsx
@@ -480,15 +480,7 @@ function prefetchUrl(
const autoPrefetch =
mode === "auto"
? resolveAutoAppRoutePrefetch(prefetchPolicyHref)
- : mode === "full-after-shell"
- ? {
- cacheForNavigation: true,
- fallbackTtl: "static" as const,
- minimumTtlMs: undefined,
- prefetchShellFirst: true,
- shouldPrefetch: true,
- }
- : resolveFullAppRoutePrefetch();
+ : resolveFullAppRoutePrefetch();
if (!autoPrefetch.shouldPrefetch) return;
const interceptionContext = getPrefetchInterceptionContext(fullHref);
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index 4f7356a64..c297620c9 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -563,25 +563,16 @@ export function hasPrefetchCacheEntryForNavigation(
);
if (match === null) return false;
- // Register onInvalidate against the matched entry, not the caller's exact
- // cache key — the match may be a normalized `_rsc` variant or an alias, so
- // an exact-key lookup after this call could silently miss it.
- const attachOnInvalidate = (): void => {
- if (options.onInvalidate === undefined) return;
- addPrefetchInvalidationCallback(match.entry, options.onInvalidate);
- if (match.entry.outcome === "cache-seeded") {
- schedulePrefetchInvalidation(match.cacheKey, match.entry);
- }
- };
-
- if (match.entry.pending !== undefined) {
- touchPrefetchCacheEntry(getPrefetchCache(), match.cacheKey, match.entry);
- attachOnInvalidate();
- return true;
- }
- if (resolvePrefetchCacheEntryExpiresAt(match.entry) > Date.now()) {
+ // In flight, or settled and still fresh: either way the entry is reusable.
+ if (
+ match.entry.pending !== undefined ||
+ resolvePrefetchCacheEntryExpiresAt(match.entry) > Date.now()
+ ) {
touchPrefetchCacheEntry(getPrefetchCache(), match.cacheKey, match.entry);
- attachOnInvalidate();
+ // Register onInvalidate against the matched entry, not the caller's exact
+ // cache key — the match may be a normalized `_rsc` variant or an alias, so
+ // an exact-key lookup after this call could silently miss it.
+ attachPrefetchInvalidationToEntry(match.cacheKey, match.entry, options.onInvalidate);
return true;
}
@@ -882,19 +873,33 @@ function addPrefetchInvalidationCallback(
entry.onInvalidateCallbacks.add(onInvalidate);
}
-function attachPrefetchInvalidationCallback(
+/**
+ * Attach `onInvalidate` to an entry the caller already holds. A settled entry
+ * needs its invalidation timer started here — nothing else will schedule one
+ * once `prefetchRscResponse` has finished with it.
+ */
+function attachPrefetchInvalidationToEntry(
cacheKey: string,
+ entry: PrefetchCacheEntry,
onInvalidate: (() => void) | undefined,
): void {
if (onInvalidate === undefined) return;
- const entry = getPrefetchCache().get(cacheKey);
- if (!entry) return;
addPrefetchInvalidationCallback(entry, onInvalidate);
if (entry.outcome === "cache-seeded") {
schedulePrefetchInvalidation(cacheKey, entry);
}
}
+function attachPrefetchInvalidationCallback(
+ cacheKey: string,
+ onInvalidate: (() => void) | undefined,
+): void {
+ if (onInvalidate === undefined) return;
+ const entry = getPrefetchCache().get(cacheKey);
+ if (!entry) return;
+ attachPrefetchInvalidationToEntry(cacheKey, entry, onInvalidate);
+}
+
export function invalidatePrefetchCache(): void {
// Void prefetch setup that is still in flight. Without this, a closure that
// started before `router.refresh()` resumes afterwards and repopulates a
@@ -2468,20 +2473,23 @@ const _appRouter: AppRouterInstance = {
const headers = createAppPrefetchRequestHeaders({
fetchPriority: "low",
interceptionContext,
+ // `|| null` keeps the helper's "set unless null/undefined" rule from
+ // emitting an empty slot header where this call site skipped it.
+ mountedSlotsHeader: mountedSlotsHeader || null,
prefetchKind: reusable ? kind : undefined,
});
if (reusable && kind === "auto") {
headers.set(NEXT_ROUTER_PREFETCH_HEADER, "1");
headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, __prefetchInlining ? "/__PAGE__" : "1");
}
- if (mountedSlotsHeader) {
- headers.set(VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader);
- }
- const rscUrl = await createRscRequestUrl(fullHref, headers);
- const additionalRscUrls =
- rewrittenPrefetchHref !== null && rewrittenPrefetchHref !== fullHref
- ? [await createRscRequestUrl(rewrittenPrefetchHref, headers)]
- : [];
+ // Both derive from the same headers and neither feeds the other, so the
+ // rewrite variant is generated alongside rather than after.
+ const [rscUrl, ...additionalRscUrls] = await Promise.all([
+ createRscRequestUrl(fullHref, headers),
+ ...(rewrittenPrefetchHref !== null && rewrittenPrefetchHref !== fullHref
+ ? [createRscRequestUrl(rewrittenPrefetchHref, headers)]
+ : []),
+ ]);
// A navigation can start in the same task as this call and win the race
// above (hybrid-route module load, policy import, RSC URL generation).
// Nothing was registered in the cache during that window, so navigation
From 4c970e02a2c3968a3313fd9052093f782c491c7d Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Mon, 27 Jul 2026 18:18:36 +1000
Subject: [PATCH 09/17] fix(navigation): promote a queued prefetch when
navigation consumes it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.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).
---
.../internal/app-prefetch-fetch-queue.ts | 41 ++++++++++-
packages/vinext/src/shims/navigation.ts | 11 +++
tests/prefetch-cache.test.ts | 73 ++++++++++++++++---
3 files changed, 110 insertions(+), 15 deletions(-)
diff --git a/packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts b/packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts
index f220e8f90..805560aa6 100644
--- a/packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts
+++ b/packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts
@@ -4,6 +4,8 @@ const APP_PREFETCH_FETCH_SLOT_RELEASE_KEY = Symbol.for("vinext.appPrefetchFetchS
const MAX_DEFAULT_APP_PREFETCH_REQUESTS = 4;
const defaultAppPrefetchQueue: Array<() => void> = [];
+/** Lets a consumer find the queued runner behind a promise it already holds. */
+const queuedAppPrefetchRunners = new WeakMap, () => void>();
let activeDefaultAppPrefetchRequests = 0;
let defaultAppPrefetchDrainScheduled = false;
@@ -48,8 +50,9 @@ export function scheduleAppPrefetchFetch(
return fetcher();
}
- return new Promise((resolve, reject) => {
- defaultAppPrefetchQueue.push(() => {
+ let runner!: () => void;
+ const promise = new Promise((resolve, reject) => {
+ runner = () => {
let didRelease = false;
const release = () => {
if (didRelease) return;
@@ -75,7 +78,37 @@ export function scheduleAppPrefetchFetch(
release();
reject(error);
}
- });
- scheduleDefaultAppPrefetchDrain();
+ };
});
+
+ defaultAppPrefetchQueue.push(runner);
+ queuedAppPrefetchRunners.set(promise, runner);
+ scheduleDefaultAppPrefetchDrain();
+ return promise;
+}
+
+/**
+ * Start a still-queued prefetch request immediately.
+ *
+ * A navigation that reuses an in-flight prefetch awaits that prefetch's
+ * promise. When the request is only queued, the navigation would otherwise wait
+ * for unrelated prefetch response bodies to finish before its own request even
+ * starts — indefinitely if one of those streams stalls. A promoted request is
+ * no longer a prefetch, it is the navigation, so it bypasses the concurrency
+ * cap instead of waiting for a slot.
+ *
+ * No-op when the request has already started or was never queued.
+ */
+export function promoteAppPrefetchFetch(promise: Promise | undefined): void {
+ if (promise === undefined) return;
+ const runner = queuedAppPrefetchRunners.get(promise);
+ if (runner === undefined) return;
+
+ const index = defaultAppPrefetchQueue.indexOf(runner);
+ if (index === -1) return;
+ defaultAppPrefetchQueue.splice(index, 1);
+ queuedAppPrefetchRunners.delete(promise);
+
+ activeDefaultAppPrefetchRequests += 1;
+ runner();
}
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index c297620c9..ac4593b77 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -80,6 +80,7 @@ import {
type NavigationContext,
} from "./navigation-context-state.js";
import {
+ promoteAppPrefetchFetch,
releaseAppPrefetchFetchSlot,
scheduleAppPrefetchFetch,
} from "./internal/app-prefetch-fetch-queue.js";
@@ -302,6 +303,8 @@ export type PrefetchCacheEntry = {
outcome: "pending" | "cache-seeded";
snapshot?: CachedRscResponse;
cacheKeys?: Set;
+ /** The queue-scheduled request, so a consuming navigation can promote it. */
+ fetchPromise?: Promise;
pending?: Promise;
preparedElements?: AppElements;
prefetchKind?: PrefetchCacheKind;
@@ -1178,6 +1181,7 @@ export function prefetchRscResponse(
timestamp: now,
};
addPrefetchInvalidationCallback(entry, options?.onInvalidate);
+ entry.fetchPromise = fetchPromise;
entry.pending = fetchPromise
.then(async (response) => {
@@ -1217,6 +1221,8 @@ export function prefetchRscResponse(
.finally(() => {
if (cache.get(cacheKey) !== entry) return;
entry.pending = undefined;
+ // Nothing left to promote, and holding it would pin the settled Response.
+ entry.fetchPromise = undefined;
if (entry.snapshot) {
entry.outcome = "cache-seeded";
schedulePrefetchInvalidation(cacheKey, entry);
@@ -1396,6 +1402,11 @@ export async function consumePrefetchResponseForNavigation(
const { cacheKey, entry } = match;
if (entry.pending !== undefined) {
+ // This navigation is about to wait on the prefetch's request. If that
+ // request is still queued behind the low-priority concurrency cap, waiting
+ // would block the navigation on unrelated prefetch response bodies, so
+ // start it now instead. No-op once the request is already in flight.
+ promoteAppPrefetchFetch(entry.fetchPromise);
await entry.pending.catch(() => {});
if (cache.get(cacheKey) !== entry) return null;
}
diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts
index f5f46179e..6e8638236 100644
--- a/tests/prefetch-cache.test.ts
+++ b/tests/prefetch-cache.test.ts
@@ -538,10 +538,6 @@ describe("prefetch cache eviction", () => {
const cacheKey = AppElementsWire.encodeCacheKey(fetchedUrl, null);
await waitForPrefetchSetup(() => getPrefetchCache().get(cacheKey)?.outcome === "cache-seeded");
- const entry = getPrefetchCache().get(cacheKey);
- expect(entry?.cacheForNavigation).toBe(true);
- expect(entry?.optimisticRouteShell).toBe(false);
- expect(entry?.prefetchKind).toBe("navigation");
// A second programmatic prefetch while the entry is fresh must not issue
// another request.
@@ -677,7 +673,6 @@ describe("prefetch cache eviction", () => {
await waitForPrefetchSetup(
() => getPrefetchCache().get(learningKey)?.outcome === "cache-seeded",
);
- expect(getPrefetchCache().get(learningKey)?.cacheForNavigation).toBe(false);
// The upgrade discards the learning-only entry. Its subscriber must be told
// the payload is gone rather than have the callback silently dropped.
@@ -765,10 +760,8 @@ describe("prefetch cache eviction", () => {
const cacheKey = AppElementsWire.encodeCacheKey(fetchedUrl, null);
await waitForPrefetchSetup(() => getPrefetchCache().get(cacheKey)?.outcome === "cache-seeded");
- expect(getPrefetchCache().get(cacheKey)?.cacheForNavigation).toBe(true);
- const consumed = consumePrefetchResponse(fetchedUrl, null, null);
- expect(consumed).not.toBeNull();
+ expect(consumePrefetchResponse(fetchedUrl, null, null)).not.toBeNull();
});
it("keeps default-kind router.prefetch learning-only on loading-shell routes (#2707)", async () => {
@@ -790,12 +783,70 @@ describe("prefetch cache eviction", () => {
const cacheKey = AppElementsWire.encodeCacheKey(fetchedUrl, null);
await waitForPrefetchSetup(() => getPrefetchCache().get(cacheKey)?.outcome === "cache-seeded");
- const entry = getPrefetchCache().get(cacheKey);
- expect(entry?.cacheForNavigation).toBe(false);
- expect(entry?.optimisticRouteShell).toBe(true);
expect(consumePrefetchResponse(fetchedUrl, null, null)).toBeNull();
});
+ it("promotes a queued prefetch when navigation consumes it (#2722)", async () => {
+ // Every route is navigation-reusable, so the queued 5th prefetch is one a
+ // navigation will actually try to await.
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = Array.from(
+ { length: 5 },
+ (_unused, index) => ({
+ canPrefetchLoadingShell: false,
+ patternParts: [`dashboard-${index}`],
+ isDynamic: false,
+ }),
+ );
+ const requestedUrls: string[] = [];
+ const deferredResponses: Array<(response: Response) => void> = [];
+ const fetch = vi.fn((input: RequestInfo | URL) => {
+ requestedUrls.push(toRscUrlString(input));
+ return new Promise((resolve) => {
+ deferredResponses.push(resolve);
+ });
+ });
+ (globalThis as any).fetch = fetch;
+
+ for (let index = 0; index < 5; index++) {
+ appRouterInstance.prefetch(`/dashboard-${index}`);
+ }
+
+ // Four slots are occupied and none of their bodies have been read, so the
+ // fifth request has not been issued — but all five entries are registered.
+ await waitForPrefetchSetup(
+ () =>
+ fetch.mock.calls.length === 4 &&
+ [...getPrefetchCache().keys()].some((key) => key.includes("dashboard-4")),
+ );
+ expect(fetch).toHaveBeenCalledTimes(4);
+
+ const queuedCacheKey = [...getPrefetchCache().keys()].find((key) =>
+ key.includes("dashboard-4"),
+ );
+ if (queuedCacheKey === undefined) {
+ throw new Error("Expected the queued prefetch to hold a cache entry");
+ }
+ const queuedRscUrl = queuedCacheKey.split("\0")[0];
+
+ // Navigating to the queued route must start its request rather than wait
+ // for the four in-flight response bodies.
+ const consumed = consumePrefetchResponseForNavigation(queuedRscUrl, null, null);
+ await waitForPrefetchSetup(() => fetch.mock.calls.length === 5);
+ expect(fetch).toHaveBeenCalledTimes(5);
+ expect(requestedUrls[4]).toBe(queuedRscUrl);
+
+ // The occupying prefetches are still unresolved at this point.
+ deferredResponses[4](
+ new Response("flight", { headers: { "content-type": "text/x-component" } }),
+ );
+ const snapshot = await consumed;
+ expect(snapshot).not.toBeNull();
+
+ for (const resolve of deferredResponses.slice(0, 4)) {
+ resolve(new Response("flight", { headers: { "content-type": "text/x-component" } }));
+ }
+ });
+
it("limits low-priority router.prefetch requests until queued responses are snapshotted", async () => {
const deferredResponses: Array<{
resolve: (response: Response) => void;
From 00ec0275839e3ad3f09ae0e53ed28582e427a7f2 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Mon, 27 Jul 2026 18:32:26 +1000
Subject: [PATCH 10/17] test(navigation): cover router.prefetch then
router.push at the browser boundary
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The issue reports router.prefetch(x) followed by router.push(x). The
existing e2e covered router.prefetch(x) followed by a 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.
---
.../app-router/router-prefetch-reuse.spec.ts | 60 ++++++++++++++++++-
tests/prefetch-cache.test.ts | 23 -------
2 files changed, 59 insertions(+), 24 deletions(-)
diff --git a/tests/e2e/app-router/router-prefetch-reuse.spec.ts b/tests/e2e/app-router/router-prefetch-reuse.spec.ts
index b7d2a122a..96d358446 100644
--- a/tests/e2e/app-router/router-prefetch-reuse.spec.ts
+++ b/tests/e2e/app-router/router-prefetch-reuse.spec.ts
@@ -15,7 +15,7 @@ const BASE = process.env.VINEXT_E2E_BASE_URL ?? "http://localhost:4174";
const FILM_HREF = "/film/tt0068646-the-godfather-1972";
type RouterWindow = Window & {
- next?: { router?: { prefetch(href: string): void } };
+ next?: { router?: { prefetch(href: string): void; push(href: string): void } };
};
function isFilmRscRequest(request: Request): boolean {
@@ -54,6 +54,64 @@ test.describe("router.prefetch navigation reuse", () => {
expect(filmRscRequests.length).toBe(1);
});
+ test("router.push after router.prefetch reuses the prefetched payload", async ({ page }) => {
+ // The literal sequence from the issue: both calls go through the public
+ // router, with no involved.
+ const filmRscRequests: string[] = [];
+ page.on("request", (request) => {
+ if (isFilmRscRequest(request)) filmRscRequests.push(request.url());
+ });
+
+ await page.goto(`${BASE}/top`);
+ await waitForAppRouterHydration(page);
+
+ await page.evaluate((href) => {
+ const router = (window as RouterWindow).next?.router;
+ if (router === undefined) throw new Error("Missing app router instance");
+ router.prefetch(href);
+ }, FILM_HREF);
+ await expect.poll(() => filmRscRequests.length).toBe(1);
+
+ await page.evaluate((href) => {
+ const router = (window as RouterWindow).next?.router;
+ if (router === undefined) throw new Error("Missing app router instance");
+ void router.push(href);
+ }, FILM_HREF);
+
+ await expect(page.locator('[data-testid="film-panel"]')).toBeVisible();
+ await expect(page).toHaveURL(`${BASE}${FILM_HREF}`);
+ expect(filmRscRequests.length).toBe(1);
+ });
+
+ test("router.prefetch and router.push in the same task issue one request", async ({ page }) => {
+ // The race the epoch guard exists for: push() starts before prefetch()
+ // setup has registered anything, so there is nothing to share yet.
+ //
+ // Asserts the contract — the destination renders and the route is fetched
+ // once — rather than the mechanism. Cancelling the late prefetch and
+ // having navigation adopt an already-started prefetch are both valid ways
+ // to satisfy it, and this test accepts either.
+ const filmRscRequests: string[] = [];
+ page.on("request", (request) => {
+ if (isFilmRscRequest(request)) filmRscRequests.push(request.url());
+ });
+
+ await page.goto(`${BASE}/top`);
+ await waitForAppRouterHydration(page);
+
+ await page.evaluate((href) => {
+ const router = (window as RouterWindow).next?.router;
+ if (router === undefined) throw new Error("Missing app router instance");
+ // No await between them: same task, no polling for the prefetch request.
+ router.prefetch(href);
+ void router.push(href);
+ }, FILM_HREF);
+
+ await expect(page.locator('[data-testid="film-panel"]')).toBeVisible();
+ await expect(page).toHaveURL(`${BASE}${FILM_HREF}`);
+ expect(filmRscRequests.length).toBe(1);
+ });
+
test("click without prefetch issues exactly one navigation request", async ({ page }) => {
// Guards the request counter above: proves a plain click is observed as a
// /film/* RSC request, so the reuse test's "still 1" assertion is meaningful.
diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts
index 6e8638236..31f78ba1c 100644
--- a/tests/prefetch-cache.test.ts
+++ b/tests/prefetch-cache.test.ts
@@ -627,29 +627,6 @@ describe("prefetch cache eviction", () => {
expect(onInvalidate).toHaveBeenCalledTimes(1);
});
- it("cancels router.prefetch setup when a navigation starts in the same task (#2707)", async () => {
- (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
- { canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
- ];
- const fetch = vi.fn(
- async () => new Response("flight", { headers: { "content-type": "text/x-component" } }),
- );
- (globalThis as any).fetch = fetch;
-
- // No polling between the two calls: navigation starts while the prefetch
- // closure is still awaiting its module imports and RSC URL, i.e. before it
- // has registered anything navigation could share.
- appRouterInstance.prefetch("/dashboard");
- appRouterInstance.push("/dashboard");
-
- await settlePrefetchSetup();
-
- // Navigation owns the request for this route now; the late prefetch must
- // not start a second one.
- expect(fetch).not.toHaveBeenCalled();
- expect(getPrefetchCache().size).toBe(0);
- });
-
it("notifies onInvalidate when a learning-only prefetch is superseded (#2707)", async () => {
// A loading-shell route: the default `kind` resolves to learning-only, and
// a later `kind: "full"` upgrades the same URL to a reusable entry.
From bd9d07d9518c16091166de3df7842d3ef42aaf79 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Mon, 27 Jul 2026 19:09:31 +1000
Subject: [PATCH 11/17] fix(navigation): scope prefetch-setup cancellation to
the destination
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
packages/vinext/src/shims/navigation.ts | 158 ++++++++++++++++--------
tests/prefetch-cache.test.ts | 46 +++++++
2 files changed, 152 insertions(+), 52 deletions(-)
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index ac4593b77..f44f21db9 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -708,19 +708,60 @@ function evictPrefetchCacheIfNeeded(): void {
}
/**
- * Prefetch-cache generation counter. `router.prefetch()` captures it before its
- * `await`s and re-checks after, so setup belonging to a superseded generation
- * cannot register an entry. It advances on two events, both of which mean "any
- * prefetch still being set up is now based on stale state":
- * - a navigation starts (`notifyAppNavigationStart`), which will fetch the
- * route itself — a late prefetch would duplicate that request
- * - the prefetch cache is invalidated (`invalidatePrefetchCache`, reached via
- * `router.refresh()`), which would otherwise be undone for that one route by
- * a closure that started before the refresh
- * Mirrors `linkPrefetchNavigationEpoch` in link.tsx, which currently advances
- * only on the first of those.
+ * `router.prefetch()` calls whose asynchronous setup is still running. Each one
+ * registers a token before its first `await` and re-checks `cancelled` after,
+ * so superseded setup cannot register a cache entry or start a request.
+ *
+ * Cancellation is sticky and scoped to the destination:
+ * - a navigation to the same href (`notifyAppNavigationStart`) will fetch that
+ * route itself, so a late prefetch would duplicate the request. Navigations
+ * elsewhere leave the prefetch alone — nothing else is going to fetch it,
+ * and dropping it would make an explicit prefetch timing-dependent.
+ * - invalidating the whole cache (`invalidatePrefetchCache`, reached via
+ * `router.refresh()`) cancels every pending setup, which would otherwise
+ * repopulate one route from the pre-refresh generation.
+ *
+ * Sticky matters: a navigation to `/a` followed by one to `/b` must leave a
+ * pending `/a` prefetch cancelled, which comparing against a "current
+ * destination" value would not.
+ *
+ * `linkPrefetchNavigationEpoch` in link.tsx still uses a global counter for the
+ * navigation case; unifying the two is tracked separately.
*/
-let appNavigationEpoch = 0;
+type PendingPrefetchSetup = { readonly destination: string; cancelled: boolean };
+const pendingPrefetchSetups = new Set();
+
+function beginPrefetchSetup(destination: string): PendingPrefetchSetup {
+ const setup: PendingPrefetchSetup = { destination, cancelled: false };
+ pendingPrefetchSetups.add(setup);
+ return setup;
+}
+
+/** Passing `null` cancels every pending setup regardless of destination. */
+function cancelPendingPrefetchSetups(destination: string | null): void {
+ for (const setup of pendingPrefetchSetups) {
+ if (destination === null || setup.destination === destination) {
+ setup.cancelled = true;
+ }
+ }
+}
+
+/**
+ * Normalize a navigation or prefetch target to the browser href both sides
+ * compare on. Returns null when the target is not same-origin — no same-origin
+ * prefetch can be a duplicate of it — and on the server, where
+ * `navigateClientSide` can still be reached but there is nothing to cancel.
+ */
+function toAppPrefetchDestination(href: string): string | null {
+ if (isServer) return null;
+ let localHref = href;
+ if (isExternalUrl(href)) {
+ const localPath = toSameOriginAppPath(href, __basePath);
+ if (localPath == null) return null;
+ localHref = localPath;
+ }
+ return toBrowserNavigationHref(localHref, window.location.href, __basePath);
+}
function clearPrefetchInvalidation(entry: PrefetchCacheEntry): void {
if (entry.invalidationTimer !== undefined) {
@@ -904,11 +945,11 @@ function attachPrefetchInvalidationCallback(
}
export function invalidatePrefetchCache(): void {
- // Void prefetch setup that is still in flight. Without this, a closure that
- // started before `router.refresh()` resumes afterwards and repopulates a
- // navigation-reusable entry built from the pre-refresh cache generation,
- // undoing the invalidation for that route.
- appNavigationEpoch += 1;
+ // Void prefetch setup that is still in flight, whatever its destination.
+ // Without this, a closure that started before `router.refresh()` resumes
+ // afterwards and repopulates a navigation-reusable entry built from the
+ // pre-refresh cache generation, undoing the invalidation for that route.
+ cancelPendingPrefetchSetups(null);
const cache = getPrefetchCache();
const prefetched = getPrefetchedUrls();
for (const [cacheKey, entry] of cache) {
@@ -1401,6 +1442,11 @@ export async function consumePrefetchResponseForNavigation(
if (!match) return null;
const { cacheKey, entry } = match;
+ // Checked before touching the request queue: a navigation superseded while
+ // the caller prepared this lookup must not promote its destination past the
+ // concurrency cap, where it would compete with the current navigation.
+ if (options?.shouldConsume?.() === false) return null;
+
if (entry.pending !== undefined) {
// This navigation is about to wait on the prefetch's request. If that
// request is still queued behind the low-priority concurrency cap, waiting
@@ -1409,10 +1455,10 @@ export async function consumePrefetchResponseForNavigation(
promoteAppPrefetchFetch(entry.fetchPromise);
await entry.pending.catch(() => {});
if (cache.get(cacheKey) !== entry) return null;
+ // Re-checked for a navigation superseded while the request was in flight.
+ if (options?.shouldConsume?.() === false) return null;
}
- if (options?.shouldConsume?.() === false) return null;
-
return consumeMatchedPrefetchResponse(cacheKey, entry, mountedSlotsHeader);
}
@@ -2138,13 +2184,16 @@ function hardNavigateTo(fullHref: string, mode: "push" | "replace"): void {
}
/**
- * Signal that a navigation is starting: cancel in-flight prefetch setup and
- * reset any link still showing a `useLinkStatus()` pending state that did not
- * initiate this navigation (e.g. a programmatic router.push or form submit).
- * A click registers itself first, so the hook keeps that link pending.
+ * Signal that a navigation to `href` is starting: cancel in-flight prefetch
+ * setup for that same destination, and reset any link still showing a
+ * `useLinkStatus()` pending state that did not initiate this navigation (e.g. a
+ * programmatic router.push or form submit). A click registers itself
+ * first, so the hook keeps that link pending.
*/
-function notifyAppNavigationStart(): void {
- appNavigationEpoch += 1;
+function notifyAppNavigationStart(href: string): void {
+ const destination = toAppPrefetchDestination(href);
+ // A destination on another origin cannot duplicate a same-origin prefetch.
+ if (destination !== null) cancelPendingPrefetchSetups(destination);
getNavigationRuntime()?.functions.notifyLinkNavigationStart?.();
}
@@ -2158,7 +2207,7 @@ export async function navigateClientSide(
programmaticTransition = false,
visibleCommitMode: NavigationRuntimeVisibleCommitMode = "transition",
): Promise {
- notifyAppNavigationStart();
+ notifyAppNavigationStart(href);
// Normalize same-origin absolute URLs to local paths for SPA navigation
let normalizedHref = href;
@@ -2348,7 +2397,7 @@ const _appRouter: AppRouterInstance = {
// An imperative navigation supersedes any -owned pending state.
// Clear it before entering the navigation transition so React does not
// defer the idle update behind the suspended destination render.
- notifyAppNavigationStart();
+ notifyAppNavigationStart(href);
const releaseNavigation = trackScheduledAppRouterNavigation();
try {
React.startTransition(() => {
@@ -2363,7 +2412,7 @@ const _appRouter: AppRouterInstance = {
replace(href: string, options?: { scroll?: boolean }): void {
assertSafeNavigationUrl(href);
if (isServer) return;
- notifyAppNavigationStart();
+ notifyAppNavigationStart(href);
const releaseNavigation = trackScheduledAppRouterNavigation();
try {
React.startTransition(() => {
@@ -2420,18 +2469,18 @@ const _appRouter: AppRouterInstance = {
} catch {
throw new Error(`Cannot prefetch '${href}' because it cannot be converted to a URL.`);
}
- const navigationEpoch = appNavigationEpoch;
+ // Normalize same-origin absolute URLs to local paths; bail for external
+ // origins so we don't pollute the prefetch cache with a same-path .rsc on
+ // the current origin. Mirrors Link's prefetchUrl and navigateClientSide.
+ const prefetchHref = isExternalUrl(href) ? toSameOriginAppPath(href, __basePath) : href;
+ if (prefetchHref == null) return;
+ // Resolved here rather than inside the closure so relative hrefs resolve
+ // against the URL at call time, and so the destination is registered before
+ // a navigation in this same task can start.
+ const fullHref = toAppPrefetchDestination(prefetchHref);
+ if (fullHref === null) return;
+ const setup = beginPrefetchSetup(fullHref);
void (async () => {
- // Normalize same-origin absolute URLs to local paths; no-op for external
- // origins so we don't pollute the prefetch cache with a same-path .rsc on
- // the current origin. Mirrors Link's prefetchUrl and navigateClientSide.
- let prefetchHref = href;
- if (isExternalUrl(href)) {
- const localPath = toSameOriginAppPath(href, __basePath);
- if (localPath == null) return;
- prefetchHref = localPath;
- }
-
// Hybrid ownership: when a Pages route owns the URL, the App Router
// cannot serve it (Pages produces HTML documents / `_next/data` JSON,
// not RSC streams). Prefetching an RSC URL would either 404 or warm
@@ -2462,7 +2511,7 @@ const _appRouter: AppRouterInstance = {
// the previous learning-only fetch: an explicit programmatic prefetch
// must still fetch, and loading-shell routes keep feeding the
// optimistic-route-template learner.
- const fullHref = toBrowserNavigationHref(prefetchHref, window.location.href, __basePath);
+ //
// A configured rewrite can map this href onto a different App route;
// the policy must describe the destination the request will actually
// resolve to, not the source pattern (mirrors Link's prefetchPolicyHref).
@@ -2501,12 +2550,13 @@ const _appRouter: AppRouterInstance = {
? [createRscRequestUrl(rewrittenPrefetchHref, headers)]
: []),
]);
- // A navigation can start in the same task as this call and win the race
- // above (hybrid-route module load, policy import, RSC URL generation).
- // Nothing was registered in the cache during that window, so navigation
- // already began its own request; starting a second one here would break
- // the one-request-per-route invariant. Mirrors Link's equivalent guard.
- if (navigationEpoch !== appNavigationEpoch) return;
+ // A navigation to this same href can start in the same task as this call
+ // and win the race above (hybrid-route module load, policy import, RSC
+ // URL generation). Nothing was registered in the cache during that
+ // window, so navigation already began its own request; starting a second
+ // one here would break the one-request-per-route invariant. Mirrors
+ // Link's equivalent guard.
+ if (setup.cancelled) return;
const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext);
const prefetched = getPrefetchedUrls();
if (reusable) {
@@ -2561,9 +2611,13 @@ const _appRouter: AppRouterInstance = {
prefetchKind: "navigation",
},
);
- })().catch((error) => {
- console.error("[vinext] RSC prefetch setup error:", error);
- });
+ })()
+ .catch((error: unknown) => {
+ console.error("[vinext] RSC prefetch setup error:", error);
+ })
+ .finally(() => {
+ pendingPrefetchSetups.delete(setup);
+ });
},
};
@@ -2775,7 +2829,7 @@ if (!isServer) {
// not initiate, so clear any sticky `useLinkStatus()` pending state. Runs
// for both routers; the App Router's own popstate handler (in
// app-browser-entry.ts) drives scroll restoration and RSC fetching.
- notifyAppNavigationStart();
+ notifyAppNavigationStart(window.location.href);
});
window.addEventListener("popstate", (event) => {
@@ -2799,7 +2853,7 @@ if (!isServer) {
if (state.suppressUrlNotifyCount === 0) {
// A raw history.pushState (shallow routing) starts a navigation that did
// not go through navigateClientSide; clear any sticky pending link.
- notifyAppNavigationStart();
+ notifyAppNavigationStart(window.location.href);
commitClientNavigationState();
}
};
@@ -2816,7 +2870,7 @@ if (!isServer) {
url,
);
if (state.suppressUrlNotifyCount === 0) {
- notifyAppNavigationStart();
+ notifyAppNavigationStart(window.location.href);
commitClientNavigationState();
}
};
diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts
index 31f78ba1c..6577f6b45 100644
--- a/tests/prefetch-cache.test.ts
+++ b/tests/prefetch-cache.test.ts
@@ -679,6 +679,52 @@ describe("prefetch cache eviction", () => {
expect(getPrefetchCache().size).toBe(0);
});
+ // A navigation cancels prefetch setup only for the route it is about to
+ // fetch itself. These two cases differ only in where the navigation goes, so
+ // together they pin the scoping: a global "any navigation cancels everything"
+ // rule passes the first and fails the second.
+ it("cancels prefetch setup superseded by a navigation to the same route (#2707)", async () => {
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
+ ];
+ const fetch = vi.fn(
+ async () => new Response("flight", { headers: { "content-type": "text/x-component" } }),
+ );
+ (globalThis as any).fetch = fetch;
+
+ appRouterInstance.prefetch("/dashboard");
+ appRouterInstance.push("/dashboard");
+
+ await settlePrefetchSetup();
+
+ // The navigation fetches /dashboard itself; a late prefetch would make it
+ // two requests for one route.
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("leaves prefetch setup alone when the navigation goes elsewhere (#2707)", async () => {
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
+ { canPrefetchLoadingShell: false, patternParts: ["settings"], isDynamic: false },
+ ];
+ let fetchedUrl: string | undefined;
+ const fetch = vi.fn(async (input: RequestInfo | URL) => {
+ fetchedUrl = toRscUrlString(input);
+ return new Response("flight", { headers: { "content-type": "text/x-component" } });
+ });
+ (globalThis as any).fetch = fetch;
+
+ appRouterInstance.prefetch("/dashboard");
+ appRouterInstance.push("/settings");
+
+ await settlePrefetchSetup();
+
+ // Nothing else is going to fetch /dashboard, so dropping it here would make
+ // an explicit prefetch depend on unrelated navigation timing.
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(fetchedUrl).toContain("/dashboard");
+ });
+
it("keeps onInvalidate alive after navigation consumes the prefetch (#2707)", async () => {
(globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
{ canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
From 030585dabb0e881887bed2f08e5ae6ae53305ff6 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Mon, 27 Jul 2026 19:21:28 +1000
Subject: [PATCH 12/17] fix(navigation): do not cancel prefetch setup on
shallow history updates
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
packages/vinext/src/shims/navigation.ts | 30 ++++++++++++-------
.../app-router/router-prefetch-reuse.spec.ts | 28 +++++++++++++++++
2 files changed, 48 insertions(+), 10 deletions(-)
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index f44f21db9..8913e454e 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -2184,17 +2184,26 @@ function hardNavigateTo(fullHref: string, mode: "push" | "replace"): void {
}
/**
- * Signal that a navigation to `href` is starting: cancel in-flight prefetch
- * setup for that same destination, and reset any link still showing a
- * `useLinkStatus()` pending state that did not initiate this navigation (e.g. a
- * programmatic router.push or form submit). A click registers itself
- * first, so the hook keeps that link pending.
+ * Reset any link still showing a `useLinkStatus()` pending state that did not
+ * initiate the navigation now starting (e.g. a programmatic router.push, a form
+ * submit, or a raw history update). A click registers itself first, so
+ * the hook keeps that link pending.
+ */
+function resetStaleLinkStatus(): void {
+ getNavigationRuntime()?.functions.notifyLinkNavigationStart?.();
+}
+
+/**
+ * Signal that a navigation to `href` is starting, for the callers that will
+ * fetch the destination. Separate from `resetStaleLinkStatus()` because a raw
+ * `history.pushState` also supersedes a pending link but issues no request —
+ * cancelling a prefetch there would drop it with nothing to take its place.
*/
function notifyAppNavigationStart(href: string): void {
const destination = toAppPrefetchDestination(href);
// A destination on another origin cannot duplicate a same-origin prefetch.
if (destination !== null) cancelPendingPrefetchSetups(destination);
- getNavigationRuntime()?.functions.notifyLinkNavigationStart?.();
+ resetStaleLinkStatus();
}
/**
@@ -2851,9 +2860,10 @@ if (!isServer) {
url,
);
if (state.suppressUrlNotifyCount === 0) {
- // A raw history.pushState (shallow routing) starts a navigation that did
- // not go through navigateClientSide; clear any sticky pending link.
- notifyAppNavigationStart(window.location.href);
+ // A raw history.pushState (shallow routing) supersedes a pending link,
+ // but changes browser state only — it issues no RSC request, so it must
+ // not cancel prefetch setup for the URL it moves to.
+ resetStaleLinkStatus();
commitClientNavigationState();
}
};
@@ -2870,7 +2880,7 @@ if (!isServer) {
url,
);
if (state.suppressUrlNotifyCount === 0) {
- notifyAppNavigationStart(window.location.href);
+ resetStaleLinkStatus();
commitClientNavigationState();
}
};
diff --git a/tests/e2e/app-router/router-prefetch-reuse.spec.ts b/tests/e2e/app-router/router-prefetch-reuse.spec.ts
index 96d358446..b4bd27857 100644
--- a/tests/e2e/app-router/router-prefetch-reuse.spec.ts
+++ b/tests/e2e/app-router/router-prefetch-reuse.spec.ts
@@ -112,6 +112,34 @@ test.describe("router.prefetch navigation reuse", () => {
expect(filmRscRequests.length).toBe(1);
});
+ test("a shallow history update does not cancel a pending prefetch", async ({ page }) => {
+ // Only a navigation that fetches the destination itself makes a pending
+ // prefetch for it redundant. A raw history.pushState moves the URL without
+ // requesting anything, so it must leave the prefetch running.
+ //
+ // The unit suite cannot cover this: it stubs window.history, so the shim's
+ // pushState patch is never the function under test there.
+ const filmRscRequests: string[] = [];
+ page.on("request", (request) => {
+ if (isFilmRscRequest(request)) filmRscRequests.push(request.url());
+ });
+
+ await page.goto(`${BASE}/top`);
+ await waitForAppRouterHydration(page);
+
+ await page.evaluate((href) => {
+ const router = (window as RouterWindow).next?.router;
+ if (router === undefined) throw new Error("Missing app router instance");
+ router.prefetch(href);
+ // Same task, so this lands while prefetch setup is still in flight, and
+ // targets the very href being prefetched — the case that would be
+ // cancelled if shallow routing counted as a navigation.
+ window.history.pushState(null, "", href);
+ }, FILM_HREF);
+
+ await expect.poll(() => filmRscRequests.length).toBe(1);
+ });
+
test("click without prefetch issues exactly one navigation request", async ({ page }) => {
// Guards the request counter above: proves a plain click is observed as a
// /film/* RSC request, so the reuse test's "still 1" assertion is meaningful.
From 171001112f1b0f524d4275cd5ce75d22a6fed4a0 Mon Sep 17 00:00:00 2001
From: James
Date: Mon, 27 Jul 2026 10:34:37 +0100
Subject: [PATCH 13/17] 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.
---
.../internal/app-prefetch-fetch-queue.ts | 55 +++++++++--
packages/vinext/src/shims/link.tsx | 12 ++-
packages/vinext/src/shims/navigation.ts | 22 +++--
tests/prefetch-cache.test.ts | 95 +++++++++++++++++++
4 files changed, 166 insertions(+), 18 deletions(-)
diff --git a/packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts b/packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts
index 805560aa6..6ede6e9bf 100644
--- a/packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts
+++ b/packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts
@@ -4,8 +4,12 @@ const APP_PREFETCH_FETCH_SLOT_RELEASE_KEY = Symbol.for("vinext.appPrefetchFetchS
const MAX_DEFAULT_APP_PREFETCH_REQUESTS = 4;
const defaultAppPrefetchQueue: Array<() => void> = [];
-/** Lets a consumer find the queued runner behind a promise it already holds. */
-const queuedAppPrefetchRunners = new WeakMap, () => void>();
+type AppPrefetchFetchControl = {
+ cancel: () => void;
+ runner?: () => void;
+};
+/** Lets a consumer promote or cancel the request behind a promise it already holds. */
+const appPrefetchFetchControls = new WeakMap, AppPrefetchFetchControl>();
let activeDefaultAppPrefetchRequests = 0;
let defaultAppPrefetchDrainScheduled = false;
@@ -43,16 +47,28 @@ export function releaseAppPrefetchFetchSlot(response: Response): void {
* releaseAppPrefetchFetchSlot() when it drops the response without consuming it.
*/
export function scheduleAppPrefetchFetch(
- fetcher: () => Promise,
+ fetcher: (signal: AbortSignal) => Promise,
priority: "low" | "high",
): Promise {
+ const controller = new AbortController();
if (priority === "high") {
- return fetcher();
+ const promise = fetcher(controller.signal);
+ const control = { cancel: () => controller.abort() };
+ appPrefetchFetchControls.set(promise, control);
+ void promise.then(
+ () => appPrefetchFetchControls.delete(promise),
+ () => appPrefetchFetchControls.delete(promise),
+ );
+ return promise;
}
let runner!: () => void;
+ let rejectPromise!: (reason?: unknown) => void;
+ let started = false;
const promise = new Promise((resolve, reject) => {
+ rejectPromise = reject;
runner = () => {
+ started = true;
let didRelease = false;
const release = () => {
if (didRelease) return;
@@ -62,19 +78,22 @@ export function scheduleAppPrefetchFetch(
};
try {
- fetcher().then(
+ fetcher(controller.signal).then(
(response) => {
+ appPrefetchFetchControls.delete(promise);
(response as Response & Record void) | undefined>)[
APP_PREFETCH_FETCH_SLOT_RELEASE_KEY
] = release;
resolve(response);
},
(error: unknown) => {
+ appPrefetchFetchControls.delete(promise);
release();
reject(error);
},
);
} catch (error) {
+ appPrefetchFetchControls.delete(promise);
release();
reject(error);
}
@@ -82,11 +101,31 @@ export function scheduleAppPrefetchFetch(
});
defaultAppPrefetchQueue.push(runner);
- queuedAppPrefetchRunners.set(promise, runner);
+ appPrefetchFetchControls.set(promise, {
+ runner,
+ cancel: () => {
+ if (started) {
+ controller.abort();
+ return;
+ }
+ const index = defaultAppPrefetchQueue.indexOf(runner);
+ if (index === -1) return;
+ defaultAppPrefetchQueue.splice(index, 1);
+ appPrefetchFetchControls.delete(promise);
+ controller.abort();
+ rejectPromise(controller.signal.reason);
+ },
+ });
scheduleDefaultAppPrefetchDrain();
return promise;
}
+/** Cancel a queued or in-flight prefetch request. No-op once it has settled. */
+export function cancelAppPrefetchFetch(promise: Promise | undefined): void {
+ if (promise === undefined) return;
+ appPrefetchFetchControls.get(promise)?.cancel();
+}
+
/**
* Start a still-queued prefetch request immediately.
*
@@ -101,13 +140,13 @@ export function scheduleAppPrefetchFetch(
*/
export function promoteAppPrefetchFetch(promise: Promise | undefined): void {
if (promise === undefined) return;
- const runner = queuedAppPrefetchRunners.get(promise);
+ const control = appPrefetchFetchControls.get(promise);
+ const runner = control?.runner;
if (runner === undefined) return;
const index = defaultAppPrefetchQueue.indexOf(runner);
if (index === -1) return;
defaultAppPrefetchQueue.splice(index, 1);
- queuedAppPrefetchRunners.delete(promise);
activeDefaultAppPrefetchRequests += 1;
runner();
diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx
index 7b58d6ac3..de37c5b34 100644
--- a/packages/vinext/src/shims/link.tsx
+++ b/packages/vinext/src/shims/link.tsx
@@ -538,11 +538,12 @@ function prefetchUrl(
}
const fetchFullRscPayload = () =>
scheduleAppPrefetchFetch(
- () =>
+ (signal) =>
fetch(rscUrl, {
headers,
credentials: "include",
priority,
+ signal,
// @ts-expect-error — purpose is a valid fetch option in some browsers
purpose: "prefetch",
}),
@@ -568,11 +569,12 @@ function prefetchUrl(
prefetchRscResponse(
shellRscUrl,
scheduleAppPrefetchFetch(
- () =>
+ (signal) =>
fetch(shellRscUrl, {
headers: shellHeaders,
credentials: "include",
priority,
+ signal,
// @ts-expect-error — purpose is a valid fetch option in some browsers
purpose: "prefetch",
}),
@@ -684,11 +686,12 @@ function prefetchUrl(
prefetchRscResponse(
shellRscUrl,
scheduleAppPrefetchFetch(
- () =>
+ (signal) =>
fetch(shellRscUrl, {
headers: shellHeaders,
credentials: "include",
priority,
+ signal,
// @ts-expect-error — purpose is a valid fetch option in some browsers
purpose: "prefetch",
}),
@@ -719,11 +722,12 @@ function prefetchUrl(
}
}
return scheduleAppPrefetchFetch(
- () =>
+ (signal) =>
fetch(rscUrl, {
headers,
credentials: "include",
priority,
+ signal,
// @ts-expect-error — purpose is a valid fetch option in some browsers
purpose: "prefetch",
}),
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index 8913e454e..9ae40246c 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -80,6 +80,7 @@ import {
type NavigationContext,
} from "./navigation-context-state.js";
import {
+ cancelAppPrefetchFetch,
promoteAppPrefetchFetch,
releaseAppPrefetchFetchSlot,
scheduleAppPrefetchFetch,
@@ -760,7 +761,13 @@ function toAppPrefetchDestination(href: string): string | null {
if (localPath == null) return null;
localHref = localPath;
}
- return toBrowserNavigationHref(localHref, window.location.href, __basePath);
+ const browserHref = toBrowserNavigationHref(localHref, window.location.href, __basePath);
+ try {
+ const url = new URL(browserHref, window.location.href);
+ return `${url.pathname}${url.search}`;
+ } catch {
+ return browserHref.split("#", 1)[0];
+ }
}
function clearPrefetchInvalidation(entry: PrefetchCacheEntry): void {
@@ -884,6 +891,7 @@ export function discardLearningOnlyPrefetchCacheEntry(
// notified rather than silently dropped. Both callers (`router.prefetch()`
// and ``) reach this on the learning-only -> reusable upgrade.
for (const [cacheKey, entry] of superseded) {
+ cancelAppPrefetchFetch(entry.fetchPromise);
deletePrefetchCacheEntry(cache, prefetched, cacheKey, entry, true);
}
return superseded.length > 0;
@@ -2496,10 +2504,11 @@ const _appRouter: AppRouterInstance = {
// an unusable cache entry. The matching `push`/`replace` call will
// hard-navigate via `window.location`, so a no-op here is correct —
// the document prefetch the link shim emits on hover still runs.
- // Load the rewrite-aware module first (mirrors Link, which always
- // resolves ownership and rewrites through the full module) so the
- // prefetch policy below sees the rewritten destination route.
- if (HAS_PAGES_ROUTER || HAS_CLIENT_REWRITES) {
+ // Load the rewrite-aware module when client rewrites can affect the
+ // destination policy. Without rewrites, the synchronous direct resolver
+ // below already has enough manifest data to distinguish App and Pages
+ // ownership, avoiding a feature-specific chunk on the prefetch path.
+ if (HAS_CLIENT_REWRITES) {
await preloadHybridClientRouteOwner();
}
const hybridOwner = resolveHybridClientRouteOwner(prefetchHref);
@@ -2591,11 +2600,12 @@ const _appRouter: AppRouterInstance = {
prefetchRscResponse(
rscUrl,
scheduleAppPrefetchFetch(
- () =>
+ (signal) =>
fetch(rscUrl, {
headers,
credentials: "include",
priority: "low" as RequestInit["priority"],
+ signal,
}),
"low",
),
diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts
index 6577f6b45..a942a145d 100644
--- a/tests/prefetch-cache.test.ts
+++ b/tests/prefetch-cache.test.ts
@@ -658,6 +658,36 @@ describe("prefetch cache eviction", () => {
expect(onInvalidate).toHaveBeenCalledTimes(1);
});
+ it("aborts an in-flight learning-only request when a full prefetch supersedes it (#2707)", async () => {
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: true, patternParts: ["reports"], isDynamic: false },
+ ];
+ let firstSignal: AbortSignal | undefined;
+ const fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
+ if (fetch.mock.calls.length === 1) {
+ firstSignal = init?.signal ?? undefined;
+ return new Promise((_resolve, reject) => {
+ firstSignal?.addEventListener("abort", () => reject(firstSignal?.reason), { once: true });
+ });
+ }
+ return Promise.resolve(
+ new Response("flight", { headers: { "content-type": "text/x-component" } }),
+ );
+ });
+ (globalThis as any).fetch = fetch;
+
+ appRouterInstance.prefetch("/reports");
+ await waitForPrefetchSetup(() => fetch.mock.calls.length === 1);
+
+ appRouterInstance.prefetch("/reports", { kind: "full" });
+ await waitForPrefetchSetup(() => fetch.mock.calls.length === 2);
+
+ expect(firstSignal?.aborted).toBe(true);
+ await waitForPrefetchSetup(() =>
+ [...getPrefetchCache().values()].some((entry) => entry.outcome === "cache-seeded"),
+ );
+ });
+
it("does not repopulate the prefetch cache across an invalidation (#2707)", async () => {
(globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
{ canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
@@ -702,6 +732,25 @@ describe("prefetch cache eviction", () => {
expect(fetch).not.toHaveBeenCalled();
});
+ it("cancels prefetch setup when only the navigation hash differs (#2707)", async () => {
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
+ ];
+ const fetch = vi.fn(
+ async () => new Response("flight", { headers: { "content-type": "text/x-component" } }),
+ );
+ (globalThis as any).fetch = fetch;
+
+ appRouterInstance.prefetch("/dashboard");
+ appRouterInstance.push("/dashboard#details");
+
+ await settlePrefetchSetup();
+
+ // Hash fragments never reach an RSC request, so these are the same data
+ // destination and the late prefetch would duplicate navigation's fetch.
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
it("leaves prefetch setup alone when the navigation goes elsewhere (#2707)", async () => {
(globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
{ canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },
@@ -870,6 +919,52 @@ describe("prefetch cache eviction", () => {
}
});
+ it("removes a superseded learning-only request from the prefetch queue (#2707)", async () => {
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ ...Array.from({ length: 4 }, (_unused, index) => ({
+ canPrefetchLoadingShell: false,
+ patternParts: [`occupy-${index}`],
+ isDynamic: false,
+ })),
+ { canPrefetchLoadingShell: true, patternParts: ["reports"], isDynamic: false },
+ ];
+ const requestedPrefetchHeaders: Array = [];
+ const deferredResponses: Array<(response: Response) => void> = [];
+ const fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
+ requestedPrefetchHeaders.push((init?.headers as Headers).get("Next-Router-Prefetch"));
+ return new Promise((resolve) => deferredResponses.push(resolve));
+ });
+ (globalThis as any).fetch = fetch;
+
+ for (let index = 0; index < 4; index++) {
+ appRouterInstance.prefetch(`/occupy-${index}`);
+ }
+ await waitForPrefetchSetup(() => fetch.mock.calls.length === 4);
+
+ // The learning-only request is queued behind the four occupied slots.
+ appRouterInstance.prefetch("/reports");
+ await waitForPrefetchSetup(() =>
+ [...getPrefetchCache().keys()].some((key) => key.includes("reports")),
+ );
+ expect(fetch).toHaveBeenCalledTimes(4);
+
+ // Upgrading to a full prefetch must remove that queued runner rather than
+ // leave it ahead of the replacement request.
+ appRouterInstance.prefetch("/reports", { kind: "full" });
+ await settlePrefetchSetup();
+ expect(fetch).toHaveBeenCalledTimes(4);
+
+ deferredResponses[0](
+ new Response("flight", { headers: { "content-type": "text/x-component" } }),
+ );
+ await waitForPrefetchSetup(() => fetch.mock.calls.length === 5);
+ expect(requestedPrefetchHeaders[4]).toBeNull();
+
+ for (const resolve of deferredResponses.slice(1)) {
+ resolve(new Response("flight", { headers: { "content-type": "text/x-component" } }));
+ }
+ });
+
it("limits low-priority router.prefetch requests until queued responses are snapshotted", async () => {
const deferredResponses: Array<{
resolve: (response: Response) => void;
From 98abfa53157bcc12179b660435c71fb88a75a3d2 Mon Sep 17 00:00:00 2001
From: James
Date: Mon, 27 Jul 2026 10:38:56 +0100
Subject: [PATCH 14/17] 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.
---
packages/vinext/src/shims/navigation.ts | 28 ++++++++++++-------
.../app-router/router-prefetch-reuse.spec.ts | 6 +++-
2 files changed, 23 insertions(+), 11 deletions(-)
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index 9ae40246c..4d4039374 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -2496,6 +2496,19 @@ const _appRouter: AppRouterInstance = {
// a navigation in this same task can start.
const fullHref = toAppPrefetchDestination(prefetchHref);
if (fullHref === null) return;
+ // Next captures nextUrl and the router tree synchronously when
+ // router.prefetch() is called. Capture the equivalent request context here,
+ // before the policy/ownership imports yield: a same-task shallow URL update
+ // must not make this prefetch look as though it originated from the target
+ // route, and an intervening route change must not change its interception
+ // or mounted-slot key.
+ const interceptionContext = getPrefetchInterceptionContext(fullHref);
+ const mountedSlotsHeader = getMountedSlotsHeader();
+ const headers = createAppPrefetchRequestHeaders({
+ fetchPriority: "low",
+ interceptionContext,
+ mountedSlotsHeader: mountedSlotsHeader || null,
+ });
const setup = beginPrefetchSetup(fullHref);
void (async () => {
// Hybrid ownership: when a Pages route owns the URL, the App Router
@@ -2546,16 +2559,11 @@ const _appRouter: AppRouterInstance = {
? resolveFullAppRoutePrefetch()
: resolveAutoAppRoutePrefetch(rewrittenPrefetchHref ?? prefetchHref);
const reusable = policy.shouldPrefetch && policy.cacheForNavigation;
- const interceptionContext = getPrefetchInterceptionContext(fullHref);
- const mountedSlotsHeader = getMountedSlotsHeader();
- const headers = createAppPrefetchRequestHeaders({
- fetchPriority: "low",
- interceptionContext,
- // `|| null` keeps the helper's "set unless null/undefined" rule from
- // emitting an empty slot header where this call site skipped it.
- mountedSlotsHeader: mountedSlotsHeader || null,
- prefetchKind: reusable ? kind : undefined,
- });
+ // The call-time header snapshot defaults to AUTO/learning semantics.
+ // A full reusable prefetch is the one policy that suppresses this header.
+ if (reusable && kind === "full") {
+ headers.delete(NEXT_ROUTER_PREFETCH_HEADER);
+ }
if (reusable && kind === "auto") {
headers.set(NEXT_ROUTER_PREFETCH_HEADER, "1");
headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, __prefetchInlining ? "/__PAGE__" : "1");
diff --git a/tests/e2e/app-router/router-prefetch-reuse.spec.ts b/tests/e2e/app-router/router-prefetch-reuse.spec.ts
index b4bd27857..17dc8c84f 100644
--- a/tests/e2e/app-router/router-prefetch-reuse.spec.ts
+++ b/tests/e2e/app-router/router-prefetch-reuse.spec.ts
@@ -133,7 +133,11 @@ test.describe("router.prefetch navigation reuse", () => {
router.prefetch(href);
// Same task, so this lands while prefetch setup is still in flight, and
// targets the very href being prefetched — the case that would be
- // cancelled if shallow routing counted as a navigation.
+ // cancelled if shallow routing counted as a navigation. The prefetch must
+ // also retain /top's call-time interception context rather than reading the
+ // target URL after asynchronous policy setup; in production the visible
+ // Link already owns that /top-context entry, so a context drift would make
+ // this call issue a second request.
window.history.pushState(null, "", href);
}, FILM_HREF);
From 16438054098342c2ab2d61bede9e10b528829289 Mon Sep 17 00:00:00 2001
From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com>
Date: Mon, 27 Jul 2026 19:41:14 +1000
Subject: [PATCH 15/17] fix(navigation): keep prefetch setup across
same-document hash changes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
packages/vinext/src/shims/navigation.ts | 31 +++++++++++++++++--
.../app-router/router-prefetch-reuse.spec.ts | 28 +++++++++++++++++
tests/prefetch-cache.test.ts | 2 +-
3 files changed, 57 insertions(+), 4 deletions(-)
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index 9ae40246c..28b290f3e 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -46,7 +46,12 @@ import {
VINEXT_PARAMS_HEADER,
VINEXT_RENDERED_PATH_AND_SEARCH_HEADER,
} from "../server/headers.js";
-import { toBrowserNavigationHref, toSameOriginAppPath, withBasePath } from "./url-utils.js";
+import {
+ isHashOnlyBrowserUrlChange,
+ toBrowserNavigationHref,
+ toSameOriginAppPath,
+ withBasePath,
+} from "./url-utils.js";
import { navigationPlanner } from "../server/navigation-planner.js";
import { stripBasePath } from "../utils/base-path.js";
import { isBotUserAgent } from "../utils/html-limited-bots.js";
@@ -2209,7 +2214,27 @@ function resetStaleLinkStatus(): void {
*/
function notifyAppNavigationStart(href: string): void {
const destination = toAppPrefetchDestination(href);
- // A destination on another origin cannot duplicate a same-origin prefetch.
+ // A destination on another origin cannot duplicate a same-origin prefetch,
+ // and a same-document hash change scrolls to its target without an RSC
+ // fetch, so neither supersedes a pending prefetch. The hash is stripped from
+ // the destination above precisely because it does not select a resource —
+ // which makes checking for the same-document case here load-bearing.
+ if (destination !== null && !isHashOnlyBrowserUrlChange(href, window.location.href, __basePath)) {
+ cancelPendingPrefetchSetups(destination);
+ }
+ resetStaleLinkStatus();
+}
+
+/**
+ * popstate variant. The browser has already applied the history entry by the
+ * time this runs, so the pre-navigation URL that `isHashOnlyBrowserUrlChange`
+ * needs is gone. Back/forward across a route boundary does drive an RSC fetch
+ * (`app-browser-entry.ts`'s popstate handler), so cancelling by destination is
+ * right; a hash-only entry over-cancels, which costs one re-prefetch and can
+ * never cause a duplicate request.
+ */
+function notifyAppPopstateNavigationStart(): void {
+ const destination = toAppPrefetchDestination(window.location.href);
if (destination !== null) cancelPendingPrefetchSetups(destination);
resetStaleLinkStatus();
}
@@ -2848,7 +2873,7 @@ if (!isServer) {
// not initiate, so clear any sticky `useLinkStatus()` pending state. Runs
// for both routers; the App Router's own popstate handler (in
// app-browser-entry.ts) drives scroll restoration and RSC fetching.
- notifyAppNavigationStart(window.location.href);
+ notifyAppPopstateNavigationStart();
});
window.addEventListener("popstate", (event) => {
diff --git a/tests/e2e/app-router/router-prefetch-reuse.spec.ts b/tests/e2e/app-router/router-prefetch-reuse.spec.ts
index b4bd27857..9cd93f050 100644
--- a/tests/e2e/app-router/router-prefetch-reuse.spec.ts
+++ b/tests/e2e/app-router/router-prefetch-reuse.spec.ts
@@ -140,6 +140,34 @@ test.describe("router.prefetch navigation reuse", () => {
await expect.poll(() => filmRscRequests.length).toBe(1);
});
+ test("a same-document hash change does not cancel a pending prefetch", async ({ page }) => {
+ // The fragment is stripped when comparing a navigation against pending
+ // prefetch destinations, because it never reaches the RSC request. That
+ // makes a hash-only navigation compare equal to the route it scrolls
+ // within — but it issues no request, so it must not cancel anything.
+ //
+ // Like the shallow-history case above, this cannot live in the unit suite:
+ // driving a hash navigation there needs a DOM the harness does not have.
+ await page.goto(`${BASE}${FILM_HREF}`);
+ await waitForAppRouterHydration(page);
+
+ const filmRscRequests: string[] = [];
+ page.on("request", (request) => {
+ if (isFilmRscRequest(request)) filmRscRequests.push(request.url());
+ });
+
+ await page.evaluate((href) => {
+ const router = (window as RouterWindow).next?.router;
+ if (router === undefined) throw new Error("Missing app router instance");
+ router.prefetch(href);
+ // Already on href, so this only scrolls — it is not the navigation that
+ // would make the prefetch above redundant.
+ void router.push(`${href}#cast`);
+ }, FILM_HREF);
+
+ await expect.poll(() => filmRscRequests.length).toBe(1);
+ });
+
test("click without prefetch issues exactly one navigation request", async ({ page }) => {
// Guards the request counter above: proves a plain click is observed as a
// /film/* RSC request, so the reuse test's "still 1" assertion is meaningful.
diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts
index a942a145d..14f928880 100644
--- a/tests/prefetch-cache.test.ts
+++ b/tests/prefetch-cache.test.ts
@@ -931,7 +931,7 @@ describe("prefetch cache eviction", () => {
const requestedPrefetchHeaders: Array = [];
const deferredResponses: Array<(response: Response) => void> = [];
const fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
- requestedPrefetchHeaders.push((init?.headers as Headers).get("Next-Router-Prefetch"));
+ requestedPrefetchHeaders.push(new Headers(init?.headers).get("Next-Router-Prefetch"));
return new Promise((resolve) => deferredResponses.push(resolve));
});
(globalThis as any).fetch = fetch;
From 07302b553b92336b4f25ae3173af4495445e0be3 Mon Sep 17 00:00:00 2001
From: James
Date: Mon, 27 Jul 2026 10:41:37 +0100
Subject: [PATCH 16/17] test(navigation): keep queued prefetch assertions
type-safe
---
tests/prefetch-cache.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts
index a942a145d..14f928880 100644
--- a/tests/prefetch-cache.test.ts
+++ b/tests/prefetch-cache.test.ts
@@ -931,7 +931,7 @@ describe("prefetch cache eviction", () => {
const requestedPrefetchHeaders: Array = [];
const deferredResponses: Array<(response: Response) => void> = [];
const fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
- requestedPrefetchHeaders.push((init?.headers as Headers).get("Next-Router-Prefetch"));
+ requestedPrefetchHeaders.push(new Headers(init?.headers).get("Next-Router-Prefetch"));
return new Promise((resolve) => deferredResponses.push(resolve));
});
(globalThis as any).fetch = fetch;
From ae70329dd2b0adc7067136adf1749a9370552c0a Mon Sep 17 00:00:00 2001
From: James
Date: Mon, 27 Jul 2026 11:10:26 +0100
Subject: [PATCH 17/17] 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.
---
.../internal/app-prefetch-fetch-queue.ts | 11 ++-
packages/vinext/src/shims/navigation.ts | 4 +-
tests/prefetch-cache.test.ts | 71 +++++++++++++++++++
3 files changed, 82 insertions(+), 4 deletions(-)
diff --git a/packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts b/packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts
index 6ede6e9bf..95a48a8cb 100644
--- a/packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts
+++ b/packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts
@@ -56,7 +56,14 @@ export function scheduleAppPrefetchFetch(
const control = { cancel: () => controller.abort() };
appPrefetchFetchControls.set(promise, control);
void promise.then(
- () => appPrefetchFetchControls.delete(promise),
+ (response) => {
+ // Keep cancellation live while the response body streams. The
+ // consumer releases this after snapshotting (or when dropping a
+ // non-success response), matching the low-priority lifecycle below.
+ (response as Response & Record void) | undefined>)[
+ APP_PREFETCH_FETCH_SLOT_RELEASE_KEY
+ ] = () => appPrefetchFetchControls.delete(promise);
+ },
() => appPrefetchFetchControls.delete(promise),
);
return promise;
@@ -73,6 +80,7 @@ export function scheduleAppPrefetchFetch(
const release = () => {
if (didRelease) return;
didRelease = true;
+ appPrefetchFetchControls.delete(promise);
activeDefaultAppPrefetchRequests -= 1;
drainDefaultAppPrefetchQueue();
};
@@ -80,7 +88,6 @@ export function scheduleAppPrefetchFetch(
try {
fetcher(controller.signal).then(
(response) => {
- appPrefetchFetchControls.delete(promise);
(response as Response & Record void) | undefined>)[
APP_PREFETCH_FETCH_SLOT_RELEASE_KEY
] = release;
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index 86bbfdf14..2279bce2a 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -2549,7 +2549,7 @@ const _appRouter: AppRouterInstance = {
if (HAS_CLIENT_REWRITES) {
await preloadHybridClientRouteOwner();
}
- const hybridOwner = resolveHybridClientRouteOwner(prefetchHref);
+ const hybridOwner = resolveHybridClientRouteOwner(fullHref);
if (hybridOwner === "pages" || hybridOwner === "document") {
return;
}
@@ -2582,7 +2582,7 @@ const _appRouter: AppRouterInstance = {
const policy =
kind === "full"
? resolveFullAppRoutePrefetch()
- : resolveAutoAppRoutePrefetch(rewrittenPrefetchHref ?? prefetchHref);
+ : resolveAutoAppRoutePrefetch(rewrittenPrefetchHref ?? fullHref);
const reusable = policy.shouldPrefetch && policy.cacheForNavigation;
// The call-time header snapshot defaults to AUTO/learning semantics.
// A full reusable prefetch is the one policy that suppresses this header.
diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts
index 14f928880..47c392931 100644
--- a/tests/prefetch-cache.test.ts
+++ b/tests/prefetch-cache.test.ts
@@ -688,6 +688,77 @@ describe("prefetch cache eviction", () => {
);
});
+ it("keeps an abort control while a superseded response body is streaming (#2707)", async () => {
+ (globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: true, patternParts: ["reports"], isDynamic: false },
+ ];
+ let firstSignal: AbortSignal | undefined;
+ const fetch = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
+ if (fetch.mock.calls.length === 1) {
+ firstSignal = init?.signal ?? undefined;
+ const body = new ReadableStream({
+ start(controller) {
+ firstSignal?.addEventListener("abort", () => controller.error(firstSignal?.reason), {
+ once: true,
+ });
+ },
+ });
+ // Headers resolve immediately while arrayBuffer() remains pending.
+ return Promise.resolve(
+ new Response(body, { headers: { "content-type": "text/x-component" } }),
+ );
+ }
+ return Promise.resolve(
+ new Response("flight", { headers: { "content-type": "text/x-component" } }),
+ );
+ });
+ (globalThis as any).fetch = fetch;
+
+ appRouterInstance.prefetch("/reports");
+ await waitForPrefetchSetup(() => fetch.mock.calls.length === 1);
+ // Allow fetch()'s resolved Response to enter snapshotRscResponse(), where
+ // its body remains in flight and continues to own a queue slot.
+ await Promise.resolve();
+ await Promise.resolve();
+
+ appRouterInstance.prefetch("/reports", { kind: "full" });
+ await waitForPrefetchSetup(() => fetch.mock.calls.length === 2);
+
+ expect(firstSignal?.aborted).toBe(true);
+ await waitForPrefetchSetup(() =>
+ [...getPrefetchCache().values()].some((entry) => entry.outcome === "cache-seeded"),
+ );
+ });
+
+ it("resolves relative router.prefetch policy from the call-time URL (#2707)", async () => {
+ const window = (globalThis as any).window;
+ window.location.pathname = "/docs/current";
+ window.location.href = "http://localhost/docs/current";
+ window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
+ { canPrefetchLoadingShell: false, patternParts: ["docs", "next"], isDynamic: false },
+ ];
+ let fetchedUrl: string | undefined;
+ const fetch = vi.fn(async (input: RequestInfo | URL) => {
+ fetchedUrl = toRscUrlString(input);
+ return new Response("flight", { headers: { "content-type": "text/x-component" } });
+ });
+ (globalThis as any).fetch = fetch;
+
+ appRouterInstance.prefetch("next");
+ // Policy and ownership resolution run after dynamic imports. Moving the
+ // browser URL in the same task must not make the relative href resolve from
+ // this later location.
+ window.location.pathname = "/elsewhere";
+ window.location.href = "http://localhost/elsewhere";
+
+ await waitForPrefetchSetup(() => fetch.mock.calls.length === 1);
+ if (fetchedUrl === undefined) throw new Error("Expected the relative prefetch to fetch");
+ expect(new URL(fetchedUrl, "http://localhost").pathname).toBe("/docs/next");
+ const cacheKey = AppElementsWire.encodeCacheKey(fetchedUrl, null);
+ await waitForPrefetchSetup(() => getPrefetchCache().get(cacheKey)?.outcome === "cache-seeded");
+ expect(consumePrefetchResponse(fetchedUrl, null, null)).not.toBeNull();
+ });
+
it("does not repopulate the prefetch cache across an invalidation (#2707)", async () => {
(globalThis as any).window.__VINEXT_LINK_PREFETCH_ROUTES__ = [
{ canPrefetchLoadingShell: false, patternParts: ["dashboard"], isDynamic: false },