Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
bdbd0d1
refactor(link): extract app prefetch policy helpers to internal module
NathanDrake2406 Jul 26, 2026
255dd13
fix(navigation): reuse router.prefetch payloads during navigation
NathanDrake2406 Jul 26, 2026
a587ba5
test(e2e): cover router.prefetch reuse across an intercepted navigation
NathanDrake2406 Jul 26, 2026
a78870c
fix(navigation): resolve rewrites and alias matches in router.prefetc…
NathanDrake2406 Jul 26, 2026
d928770
fix(navigation): keep router.prefetch lifecycle correct across naviga…
NathanDrake2406 Jul 27, 2026
4282cfb
refactor(navigation): de-duplicate app prefetch policy and snapshot prep
NathanDrake2406 Jul 27, 2026
0027309
fix(navigation): notify superseded prefetches and void setup on inval…
NathanDrake2406 Jul 27, 2026
60575ba
refactor(navigation): reuse existing prefetch helpers and drop duplic…
NathanDrake2406 Jul 27, 2026
4c970e0
fix(navigation): promote a queued prefetch when navigation consumes it
NathanDrake2406 Jul 27, 2026
fae4e87
Merge branch 'main' into fix/router-prefetch-navigation-reuse
NathanDrake2406 Jul 27, 2026
00ec027
test(navigation): cover router.prefetch then router.push at the brows…
NathanDrake2406 Jul 27, 2026
bd9d07d
fix(navigation): scope prefetch-setup cancellation to the destination
NathanDrake2406 Jul 27, 2026
030585d
fix(navigation): do not cancel prefetch setup on shallow history updates
NathanDrake2406 Jul 27, 2026
1710011
fix(navigation): cancel superseded prefetch requests
james-elicx Jul 27, 2026
98abfa5
fix(navigation): capture prefetch context at call time
james-elicx Jul 27, 2026
1643805
fix(navigation): keep prefetch setup across same-document hash changes
NathanDrake2406 Jul 27, 2026
07302b5
test(navigation): keep queued prefetch assertions type-safe
james-elicx Jul 27, 2026
fea672b
Merge remote-tracking branch 'origin/fix/router-prefetch-navigation-r…
NathanDrake2406 Jul 27, 2026
727e334
Merge remote-tracking branch 'origin/fix/router-prefetch-navigation-r…
NathanDrake2406 Jul 27, 2026
ae70329
fix(navigation): preserve prefetch identity through streaming
james-elicx Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/vinext/src/client/window-next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
93 changes: 86 additions & 7 deletions packages/vinext/src/shims/internal/app-prefetch-fetch-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Promise<Response>, AppPrefetchFetchControl>();
let activeDefaultAppPrefetchRequests = 0;
let defaultAppPrefetchDrainScheduled = false;

Expand Down Expand Up @@ -41,41 +47,114 @@ export function releaseAppPrefetchFetchSlot(response: Response): void {
* releaseAppPrefetchFetchSlot() when it drops the response without consuming it.
*/
export function scheduleAppPrefetchFetch(
fetcher: () => Promise<Response>,
fetcher: (signal: AbortSignal) => Promise<Response>,
priority: "low" | "high",
): Promise<Response> {
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<symbol, (() => void) | undefined>)[
APP_PREFETCH_FETCH_SLOT_RELEASE_KEY
] = () => appPrefetchFetchControls.delete(promise);
},
() => appPrefetchFetchControls.delete(promise),
);
return promise;
}

return new Promise<Response>((resolve, reject) => {
defaultAppPrefetchQueue.push(() => {
let runner!: () => void;
let rejectPromise!: (reason?: unknown) => void;
let started = false;
const promise = new Promise<Response>((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<symbol, (() => 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);
}
});
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<Response> | 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<Response> | 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();
}
112 changes: 112 additions & 0 deletions packages/vinext/src/shims/internal/app-route-prefetch-policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* App Router prefetch policy resolution, shared by `<Link>` (`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<VinextLinkPrefetchRoute>();

/**
* 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,
};
}
Loading
Loading