Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
78 changes: 78 additions & 0 deletions packages/vinext/src/shims/internal/app-prefetch-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Registry of prefetch calls whose asynchronous setup is still running. Both
* `router.prefetch()` (navigation.ts) and `<Link>`'s `prefetchUrl` (link.tsx)
* register a token before their first `await` and re-check `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` in
* navigation.ts) 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 the
* 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.
*
* This lives in shims/internal/ rather than navigation.ts because link.tsx
* must register synchronously but only loads navigation.ts lazily — a static
* import of the registry must not pull the navigation runtime onto Link's
* startup path. Keep this module free of React and route-trie dependencies.
*/
import { isExternalUrl } from "../../utils/external-url.js";
import { toBrowserNavigationHref, toSameOriginAppPath } from "../url-utils.js";

/** basePath from next.config.js, injected by the plugin at build time */
const __basePath: string = process.env.__NEXT_ROUTER_BASEPATH ?? "";

const isServer = typeof window === "undefined";

export type PendingPrefetchSetup = { readonly destination: string; cancelled: boolean };
const pendingPrefetchSetups = new Set<PendingPrefetchSetup>();

export 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. */
export function cancelPendingPrefetchSetups(destination: string | null): void {
for (const setup of pendingPrefetchSetups) {
if (destination === null || setup.destination === destination) {
setup.cancelled = true;
}
}
}

/** Unregister a token once its setup closure has settled, cancelled or not. */
export function finishPrefetchSetup(setup: PendingPrefetchSetup): void {
pendingPrefetchSetups.delete(setup);
}

/**
* 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.
*/
export 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];
}
}
82 changes: 65 additions & 17 deletions packages/vinext/src/shims/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ import {
} from "./internal/pages-data-target.js";
import { interpolateDynamicRouteHref, resolveDynamicRouteHref } from "./internal/interpolate-as.js";
import { markAppRouteDetectedOnPrefetch } from "./internal/app-route-detection.js";
import {
beginPrefetchSetup,
finishPrefetchSetup,
toAppPrefetchDestination,
type PendingPrefetchSetup,
} from "./internal/app-prefetch-setup.js";
import {
canAutoPrefetchFullAppRoute,
resolveAutoAppRoutePrefetch,
Expand Down Expand Up @@ -175,23 +181,19 @@ export function useLinkStatus(): LinkStatusContextValue {
return useContext(LinkStatusContext);
}

let linkPrefetchNavigationEpoch = 0;

function notifyLinkNavigationStartAndCancelPrefetchSetup(): void {
linkPrefetchNavigationEpoch += 1;
notifyLinkNavigationStart();
}

// Register the link-status reset hook on the navigation runtime as soon as this
// module evaluates on the client. `navigateClientSide` calls it at the start of
// every App Router navigation (including router.push and shallow routing), so a
// stale link's pending state is cleared even when no <Link> initiated the
// navigation. The registry itself lives in internal/link-status-registry.ts so
// it can be unit-tested without rendering a <Link>.
//
// Prefetch-setup cancellation does NOT ride this hook: `prefetchUrl` registers
// in the shared registry (internal/app-prefetch-setup.ts), which navigation.ts
// cancels by destination on navigation start and wholesale on cache
// invalidation — the same policy `router.prefetch()` gets.
if (typeof window !== "undefined") {
registerNavigationRuntimeFunctions({
notifyLinkNavigationStart: notifyLinkNavigationStartAndCancelPrefetchSetup,
});
registerNavigationRuntimeFunctions({ notifyLinkNavigationStart });
}

/** basePath from next.config.js, injected by the plugin at build time */
Expand Down Expand Up @@ -366,7 +368,6 @@ function prefetchUrl(
locale?: string | false,
): void {
if (typeof window === "undefined") return;
const navigationEpoch = linkPrefetchNavigationEpoch;

const prefetchHref = getLinkPrefetchHref({
href,
Expand Down Expand Up @@ -412,10 +413,28 @@ function prefetchUrl(
}

const runPrefetch = () => {
let setup: PendingPrefetchSetup | null = null;
// Setup work that keeps running after the outer closure returns (the
// fire-and-forget loading shell, the staged shell-first payload). The
// token must stay registered until these can no longer write to the
// prefetch cache, or a cancellation during their awaits finds no token
// and their guards never trip.
const stagedSetupWork: Promise<unknown>[] = [];
void (async () => {
if (hasAppNavigationRuntime()) {
if (isBotUserAgent(window.navigator?.userAgent ?? "")) return;

// Registered before the first await, so a navigation to this
// destination or a cache invalidation in the same task cancels the
// setup below instead of racing it. The destination is derived from
// the app-relative `prefetchHref`, matching what navigations pass to
// `toAppPrefetchDestination` — `fullHref` already carries basePath and
// would get it prefixed a second time, producing a key no navigation
// ever matches. `prefetchHref` is same-origin local (getLinkPrefetchHref
// returned non-null) and this runs on the client, so the destination
// always resolves; the fallback is unreachable.
const activeSetup = beginPrefetchSetup(toAppPrefetchDestination(prefetchHref) ?? fullHref);
setup = activeSetup;
const [
navigation,
{ AppElementsWire },
Expand All @@ -437,7 +456,10 @@ function prefetchUrl(
// A pointer-intent prefetch and its click navigation can start in the
// same event turn. If navigation won the module-loading race, do not
// begin a second request after it consumes an equivalent cached route.
if (navigationEpoch !== linkPrefetchNavigationEpoch) return;
// Cancellation also arrives from invalidatePrefetchCache(): a
// router.refresh() while these modules load voids the whole cache
// generation, and resuming would repopulate one route from it.
if (activeSetup.cancelled) return;
const {
getPrefetchInterceptionContext,
getPrefetchCache,
Expand Down Expand Up @@ -526,6 +548,10 @@ function prefetchUrl(
rewrittenPrefetchHref && rewrittenPrefetchHref !== fullHref
? [await createRscRequestUrl(rewrittenPrefetchHref, headers)]
: [];
// Re-checked after the RSC-URL awaits above: a cancellation that
// arrived since the module-loading check must not reach the cache
// writes below. Mirrors router.prefetch()'s guard in navigation.ts.
if (activeSetup.cancelled) return;
const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext);
const prefetched = getPrefetchedUrls();
if (autoPrefetch.cacheForNavigation) {
Expand Down Expand Up @@ -561,6 +587,11 @@ function prefetchUrl(
shellHeaders.set(VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader);
}
const shellRscUrl = await createRscRequestUrl(fullHref, shellHeaders);
// This helper outlives the outer closure (it is launched without
// being awaited), so a cancellation that lands during the await
// above must stop it here before it registers a shell entry in an
// invalidated cache generation.
if (activeSetup.cancelled) return;
const shellCacheKey = AppElementsWire.encodeCacheKey(shellRscUrl, interceptionContext);
const shellCache = getPrefetchCache();
let shellEntry = shellCache.get(shellCacheKey);
Expand Down Expand Up @@ -681,7 +712,11 @@ function prefetchUrl(
);
const shellCache = getPrefetchCache();
let shellEntry = shellCache.get(shellCacheKey);
if (shellEntry === undefined) {
// Guard only the side write: this closure's return value feeds
// the navigation entry registered before any cancellation, so
// it must still resolve; only a new route-tree entry must not
// be added to an invalidated cache generation.
if (shellEntry === undefined && !activeSetup.cancelled) {
getPrefetchedUrls().add(shellCacheKey);
prefetchRscResponse(
shellRscUrl,
Expand Down Expand Up @@ -735,6 +770,11 @@ function prefetchUrl(
);
})()
: fetchFullRscPayload();
if (autoPrefetch.cacheForNavigation && (gateViaRouteTree || gateViaLoadingShell)) {
// The staged closure writes shell/route-tree entries between its
// awaits; hold the token until it settles.
stagedSetupWork.push(fetchPromise.catch(() => {}));
}
if (
!__prefetchInlining &&
mode === "full" &&
Expand All @@ -743,7 +783,7 @@ function prefetchUrl(
mountedSlotsHeader === null &&
!gateViaExplicitSearchShell
) {
void fetchLoadingShellForReuse();
stagedSetupWork.push(fetchLoadingShellForReuse().catch(() => {}));
}
prefetchRscResponse(
rscUrl,
Expand Down Expand Up @@ -803,9 +843,17 @@ function prefetchUrl(
document.head.appendChild(link);
}
}
})().catch((error) => {
console.error("[vinext] RSC prefetch setup error:", error);
});
})()
.catch((error) => {
console.error("[vinext] RSC prefetch setup error:", error);
})
.finally(() => {
if (setup === null) return;
const registered = setup;
// Not until the outer closure ends: staged helpers keep writing after
// it, and an unregistered token can no longer be cancelled.
void Promise.allSettled(stagedSetupWork).then(() => finishPrefetchSetup(registered));
});
};

if (priority === "high" || hasAppNavigationRuntime()) {
Expand Down
70 changes: 7 additions & 63 deletions packages/vinext/src/shims/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ import { assertSafeNavigationUrl } from "./url-safety.js";
import { markPprFallbackShellDynamicBoundary } from "./ppr-fallback-shell.js";
import type { AppRscRenderMode } from "../server/app-rsc-render-mode.js";
import { AppRouterContext, type AppRouterInstance } from "./internal/app-router-context.js";
import {
beginPrefetchSetup,
cancelPendingPrefetchSetups,
finishPrefetchSetup,
toAppPrefetchDestination,
} from "./internal/app-prefetch-setup.js";
import { getPagesNavigationContext as _getPagesNavigationContext } from "./internal/pages-router-accessor.js";
import {
resolveDirectHybridClientRouteOwner,
Expand Down Expand Up @@ -768,68 +774,6 @@ 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<PendingPrefetchSetup>();

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);
Expand Down Expand Up @@ -2763,7 +2707,7 @@ const _appRouter: AppRouterInstance = {
console.error("[vinext] RSC prefetch setup error:", error);
})
.finally(() => {
pendingPrefetchSetups.delete(setup);
finishPrefetchSetup(setup);
});
},
};
Expand Down
Loading
Loading