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/internal/app-prefetch-fetch-queue.ts b/packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts index f220e8f90..95a48a8cb 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,12 @@ const APP_PREFETCH_FETCH_SLOT_RELEASE_KEY = Symbol.for("vinext.appPrefetchFetchS const MAX_DEFAULT_APP_PREFETCH_REQUESTS = 4; const defaultAppPrefetchQueue: Array<() => 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; @@ -41,25 +47,46 @@ 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( + (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; } - return new Promise((resolve, reject) => { - defaultAppPrefetchQueue.push(() => { + 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; didRelease = true; + appPrefetchFetchControls.delete(promise); activeDefaultAppPrefetchRequests -= 1; drainDefaultAppPrefetchQueue(); }; try { - fetcher().then( + fetcher(controller.signal).then( (response) => { (response as Response & Record void) | undefined>)[ APP_PREFETCH_FETCH_SLOT_RELEASE_KEY @@ -67,15 +94,67 @@ export function scheduleAppPrefetchFetch( resolve(response); }, (error: unknown) => { + appPrefetchFetchControls.delete(promise); release(); reject(error); }, ); } catch (error) { + appPrefetchFetchControls.delete(promise); release(); reject(error); } - }); - scheduleDefaultAppPrefetchDrain(); + }; }); + + defaultAppPrefetchQueue.push(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. + * + * 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 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); + + activeDefaultAppPrefetchRequests += 1; + runner(); } 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..0f4c44c34 --- /dev/null +++ b/packages/vinext/src/shims/internal/app-route-prefetch-policy.ts @@ -0,0 +1,112 @@ +/** + * 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}`; +} + +/** 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 { + return resolveAutoAppRoutePrefetch(href).cacheForNavigation; +} + +export function resolveAutoAppRoutePrefetch(href: string): AppRoutePrefetchPolicy { + if (typeof window === "undefined") return NO_APP_ROUTE_PREFETCH; + + const routes = window.__VINEXT_LINK_PREFETCH_ROUTES__; + if (!routes) return NO_APP_ROUTE_PREFETCH; + + const routeHref = toSameOriginRouteHref(href); + if (routeHref === null) return NO_APP_ROUTE_PREFETCH; + + const match = matchRouteWithTrie(routeHref, routes, linkPrefetchRouteTrieCache); + if (!match) return NO_APP_ROUTE_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 { + 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..de37c5b34 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 // --------------------------------------------------------------------------- @@ -576,6 +449,7 @@ function prefetchUrl( hasPrefetchCacheEntryForNavigation, peekPrefetchResponseForNavigation, prefetchRscResponse, + prepareNavigationPrefetchSnapshot, DYNAMIC_NAVIGATION_CACHE_TTL, restoreRscResponse, PREFETCH_CACHE_TTL, @@ -606,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); @@ -672,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", }), @@ -702,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", }), @@ -818,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", }), @@ -853,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", }), @@ -891,16 +761,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 3676d3fa8..2279bce2a 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -39,12 +39,19 @@ 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 { 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"; @@ -78,11 +85,14 @@ import { type NavigationContext, } from "./navigation-context-state.js"; import { + cancelAppPrefetchFetch, + promoteAppPrefetchFetch, releaseAppPrefetchFetchSlot, scheduleAppPrefetchFetch, } 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; @@ -226,6 +236,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) @@ -297,6 +309,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; @@ -544,7 +558,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, @@ -554,12 +572,16 @@ export function hasPrefetchCacheEntryForNavigation( ); if (match === null) return false; - if (match.entry.pending !== undefined) { - touchPrefetchCacheEntry(getPrefetchCache(), match.cacheKey, match.entry); - 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); + // 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; } @@ -691,6 +713,68 @@ function evictPrefetchCacheIfNeeded(): void { } } +/** + * `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. + */ +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; + } + 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 { if (entry.invalidationTimer !== undefined) { clearTimeout(entry.invalidationTimer); @@ -698,6 +782,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; @@ -705,15 +801,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); } } @@ -754,17 +878,28 @@ 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) { + cancelAppPrefetchFetch(entry.fetchPromise); + deletePrefetchCacheEntry(cache, prefetched, cacheKey, entry, true); } - return discarded; + return superseded.length > 0; } function invalidatePrefetchCacheEntry(cacheKey: string): void { @@ -795,26 +930,51 @@ 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, 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) { 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?.(); } @@ -1014,6 +1174,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 @@ -1059,6 +1235,7 @@ export function prefetchRscResponse( timestamp: now, }; addPrefetchInvalidationCallback(entry, options?.onInvalidate); + entry.fetchPromise = fetchPromise; entry.pending = fetchPromise .then(async (response) => { @@ -1098,6 +1275,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); @@ -1219,9 +1398,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 @@ -1271,13 +1455,23 @@ 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 + // 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; + // 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); } @@ -2002,6 +2196,49 @@ function hardNavigateTo(fullHref: string, mode: "push" | "replace"): void { } } +/** + * 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, + // 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(); +} + /** * Navigate to a URL, handling external URLs, hash-only changes, and RSC navigation. */ @@ -2012,10 +2249,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(href); // Normalize same-origin absolute URLs to local paths for SPA navigation let normalizedHref = href; @@ -2205,7 +2439,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(href); const releaseNavigation = trackScheduledAppRouterNavigation(); try { React.startTransition(() => { @@ -2220,7 +2454,7 @@ const _appRouter: AppRouterInstance = { replace(href: string, options?: { scroll?: boolean }): void { assertSafeNavigationUrl(href); if (isServer) return; - getNavigationRuntime()?.functions.notifyLinkNavigationStart?.(); + notifyAppNavigationStart(href); const releaseNavigation = trackScheduledAppRouterNavigation(); try { React.startTransition(() => { @@ -2277,24 +2511,45 @@ const _appRouter: AppRouterInstance = { } catch { throw new Error(`Cannot prefetch '${href}' because it cannot be converted to a URL.`); } + // 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; + // 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 () => { - // 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 // 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. - const hybridOwner = resolveHybridClientRouteOwner(prefetchHref); + // 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(fullHref); if (hybridOwner === "pages" || hybridOwner === "document") { return; } @@ -2302,20 +2557,75 @@ 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. - const fullHref = toBrowserNavigationHref(prefetchHref, window.location.href, __basePath); - const interceptionContext = getPrefetchInterceptionContext(fullHref); - const mountedSlotsHeader = getMountedSlotsHeader(); - const headers = createAppPrefetchRequestHeaders({ - fetchPriority: "low", - interceptionContext, - }); - if (mountedSlotsHeader) { - headers.set(VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader); + // + // 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. + // + // 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(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. + 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"); } - const rscUrl = await createRscRequestUrl(fullHref, 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 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 (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. + // 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, { + additionalRscUrls, + onInvalidate: options?.onInvalidate, + }) + ) { + return; + } + } else if (prefetched.has(cacheKey)) { attachPrefetchInvalidationCallback(cacheKey, options?.onInvalidate); return; } @@ -2323,26 +2633,43 @@ const _appRouter: AppRouterInstance = { prefetchRscResponse( rscUrl, scheduleAppPrefetchFetch( - () => + (signal) => fetch(rscUrl, { headers, credentials: "include", priority: "low" as RequestInit["priority"], + signal, }), "low", ), 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: prepareNavigationPrefetchSnapshot, + } + : { + cacheForNavigation: false, + optimisticRouteShell: true, + 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); + }); }, }; @@ -2554,7 +2881,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?.(); + notifyAppPopstateNavigationStart(); }); window.addEventListener("popstate", (event) => { @@ -2576,9 +2903,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. - getNavigationRuntime()?.functions.notifyLinkNavigationStart?.(); + // 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(); } }; @@ -2595,7 +2923,7 @@ if (!isServer) { url, ); if (state.suppressUrlNotifyCount === 0) { - getNavigationRuntime()?.functions.notifyLinkNavigationStart?.(); + resetStaleLinkStatus(); commitClientNavigationState(); } }; 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..94e997993 --- /dev/null +++ b/tests/e2e/app-router/router-prefetch-reuse.spec.ts @@ -0,0 +1,191 @@ +/** + * 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; push(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("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("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. 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); + + 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. + 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); + }); +}); diff --git a/tests/prefetch-cache.test.ts b/tests/prefetch-cache.test.ts index 979b35a7e..47c392931 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; @@ -133,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(); @@ -444,7 +455,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 +514,528 @@ 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"); + + // 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("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("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", + ); + + // 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("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("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 }, + ]; + 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); + }); + + // 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("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 }, + { 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 }, + ]; + 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 }, + ]; + 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(consumePrefetchResponse(fetchedUrl, null, null)).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"); + 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("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(new Headers(init?.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;