diff --git a/packages/vinext/src/shims/internal/app-prefetch-rsc-request.ts b/packages/vinext/src/shims/internal/app-prefetch-rsc-request.ts
new file mode 100644
index 000000000..f0291b5d6
--- /dev/null
+++ b/packages/vinext/src/shims/internal/app-prefetch-rsc-request.ts
@@ -0,0 +1,69 @@
+import {
+ canonicalizeLoadingShellRscRequestHeaders,
+ canonicalizePrewarmableRscRequestHeaders,
+ createCanonicalRscRequestUrl,
+ createRscRequestUrl,
+} from "../../server/app-rsc-cache-busting.js";
+
+export type ResolveAppPrefetchRscRequestOptions = {
+ fullHref: string;
+ headers: Headers;
+ interceptionContext: string | null;
+ mountedSlotsHeader: string | null;
+ prefetchInlining: boolean;
+ requiresRouteTreePrefetch: boolean;
+ rewrittenPrefetchHref: string | null;
+};
+
+export type ResolvedAppPrefetchRscRequest = {
+ additionalRscUrls: string[];
+ rscUrl: string;
+ usesCanonicalPrewarmedRequest: boolean;
+};
+
+/**
+ * Resolve the RSC request identity shared by `` and `router.prefetch()`.
+ * Only full routes and ordinary loading shells without request-specific
+ * context can reuse deploy-prewarmed CDN entries. Contextual requests retain
+ * their varying headers and deterministic `_rsc` digest.
+ */
+export async function resolveAppPrefetchRscRequest({
+ fullHref,
+ headers,
+ interceptionContext,
+ mountedSlotsHeader,
+ prefetchInlining,
+ requiresRouteTreePrefetch,
+ rewrittenPrefetchHref,
+}: ResolveAppPrefetchRscRequestOptions): Promise {
+ const canUseCanonicalSharedRequest =
+ process.env.__VINEXT_CANONICAL_RSC_REQUESTS === "1" &&
+ interceptionContext === null &&
+ mountedSlotsHeader === null &&
+ (rewrittenPrefetchHref === null || rewrittenPrefetchHref === fullHref) &&
+ !requiresRouteTreePrefetch &&
+ !prefetchInlining;
+ const usesCanonicalLoadingShell =
+ canUseCanonicalSharedRequest && canonicalizeLoadingShellRscRequestHeaders(headers);
+ const usesCanonicalFullRoute =
+ canUseCanonicalSharedRequest &&
+ !usesCanonicalLoadingShell &&
+ canonicalizePrewarmableRscRequestHeaders(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([
+ usesCanonicalFullRoute
+ ? createCanonicalRscRequestUrl(fullHref)
+ : createRscRequestUrl(fullHref, headers),
+ ...(rewrittenPrefetchHref !== null && rewrittenPrefetchHref !== fullHref
+ ? [createRscRequestUrl(rewrittenPrefetchHref, headers)]
+ : []),
+ ]);
+
+ return {
+ additionalRscUrls,
+ rscUrl,
+ usesCanonicalPrewarmedRequest: usesCanonicalLoadingShell || usesCanonicalFullRoute,
+ };
+}
diff --git a/packages/vinext/src/shims/link.tsx b/packages/vinext/src/shims/link.tsx
index 3abe496cd..a6921132d 100644
--- a/packages/vinext/src/shims/link.tsx
+++ b/packages/vinext/src/shims/link.tsx
@@ -426,6 +426,7 @@ function prefetchUrl(
navigation,
{ AppElementsWire },
rscCacheBusting,
+ { resolveAppPrefetchRscRequest },
{
APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL,
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
@@ -436,6 +437,7 @@ function prefetchUrl(
loadNavigationModule(),
import("../server/app-elements.js"),
import("../server/app-rsc-cache-busting.js"),
+ import("./internal/app-prefetch-rsc-request.js"),
import("../server/app-rsc-render-mode.js"),
import("../server/headers.js"),
HAS_PAGES_ROUTER || HAS_CLIENT_REWRITES ? loadHybridClientRouteOwnerModule() : null,
@@ -460,12 +462,7 @@ function prefetchUrl(
DYNAMIC_NAVIGATION_CACHE_TTL,
PREFETCH_CACHE_TTL,
} = navigation;
- const {
- canonicalizeLoadingShellRscRequestHeaders,
- canonicalizePrewarmableRscRequestHeaders,
- createCanonicalRscRequestUrl,
- createRscRequestUrl,
- } = rscCacheBusting;
+ const { createRscRequestUrl } = rscCacheBusting;
const {
NEXT_ROUTER_PREFETCH_HEADER,
NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
@@ -535,31 +532,16 @@ function prefetchUrl(
headers.set(NEXT_ROUTER_PREFETCH_HEADER, "1");
headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, "1");
}
- const canUseCanonicalSharedRequest =
- process.env.__VINEXT_CANONICAL_RSC_REQUESTS === "1" &&
- interceptionContext === null &&
- mountedSlotsHeader === null &&
- (rewrittenPrefetchHref === null || rewrittenPrefetchHref === fullHref) &&
- !requiresRouteTreePrefetch &&
- !__prefetchInlining;
- const usesCanonicalLoadingShell =
- canUseCanonicalSharedRequest &&
- isOptimisticRouteShellPrefetch &&
- canonicalizeLoadingShellRscRequestHeaders(headers);
- const usesCanonicalFullRoute =
- canUseCanonicalSharedRequest &&
- !isOptimisticRouteShellPrefetch &&
- canonicalizePrewarmableRscRequestHeaders(headers);
- const usesCanonicalPrewarmedRequest = usesCanonicalLoadingShell || usesCanonicalFullRoute;
- // Distinguish the same visible URL when it is prefetched from different
- // request contexts such as /feed vs /gallery or different mounted slots.
- const rscUrl = usesCanonicalFullRoute
- ? createCanonicalRscRequestUrl(fullHref)
- : await createRscRequestUrl(fullHref, headers);
- const additionalRscUrls =
- rewrittenPrefetchHref && rewrittenPrefetchHref !== fullHref
- ? [await createRscRequestUrl(rewrittenPrefetchHref, headers)]
- : [];
+ const { additionalRscUrls, rscUrl, usesCanonicalPrewarmedRequest } =
+ await resolveAppPrefetchRscRequest({
+ fullHref,
+ headers,
+ interceptionContext,
+ mountedSlotsHeader,
+ prefetchInlining: __prefetchInlining,
+ requiresRouteTreePrefetch,
+ rewrittenPrefetchHref,
+ });
if (navigationEpoch !== linkPrefetchNavigationEpoch) return;
const cacheKey = AppElementsWire.encodeCacheKey(rscUrl, interceptionContext);
const prefetched = getPrefetchedUrls();
diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts
index f75740271..8ec72c3b0 100644
--- a/packages/vinext/src/shims/navigation.ts
+++ b/packages/vinext/src/shims/navigation.ts
@@ -31,9 +31,6 @@ import {
isAppOwnedHistoryState,
} from "../server/app-history-state.js";
import {
- canonicalizeLoadingShellRscRequestHeaders,
- canonicalizePrewarmableRscRequestHeaders,
- createCanonicalRscRequestUrl,
createRscRequestHeaders,
createRscRequestUrl,
stripRscCacheBustingSearchParam,
@@ -2899,8 +2896,13 @@ const _appRouter: AppRouterInstance = {
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 [
+ { resolveAutoAppRoutePrefetch, resolveFullAppRoutePrefetch },
+ { resolveAppPrefetchRscRequest },
+ ] = await Promise.all([
+ import("./internal/app-route-prefetch-policy.js"),
+ import("./internal/app-prefetch-rsc-request.js"),
+ ]);
if (setup.cancelled) return;
const policy =
kind === "full"
@@ -2931,33 +2933,16 @@ const _appRouter: AppRouterInstance = {
: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
);
}
- const canUseCanonicalSharedRequest =
- process.env.__VINEXT_CANONICAL_RSC_REQUESTS === "1" &&
- interceptionContext === null &&
- mountedSlotsHeader === null &&
- (rewrittenPrefetchHref === null || rewrittenPrefetchHref === fullHref) &&
- !requiresRouteTreePrefetch &&
- !__prefetchInlining;
- const usesCanonicalLoadingShell =
- canUseCanonicalSharedRequest &&
- !reusable &&
- !hasSearchParams &&
- canonicalizeLoadingShellRscRequestHeaders(headers);
- const usesCanonicalFullRoute =
- canUseCanonicalSharedRequest &&
- reusable &&
- canonicalizePrewarmableRscRequestHeaders(headers);
- const usesCanonicalPrewarmedRequest = usesCanonicalLoadingShell || usesCanonicalFullRoute;
- // 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([
- usesCanonicalFullRoute
- ? createCanonicalRscRequestUrl(fullHref)
- : createRscRequestUrl(fullHref, headers),
- ...(rewrittenPrefetchHref !== null && rewrittenPrefetchHref !== fullHref
- ? [createRscRequestUrl(rewrittenPrefetchHref, headers)]
- : []),
- ]);
+ const { additionalRscUrls, rscUrl, usesCanonicalPrewarmedRequest } =
+ await resolveAppPrefetchRscRequest({
+ fullHref,
+ headers,
+ interceptionContext,
+ mountedSlotsHeader,
+ prefetchInlining: __prefetchInlining,
+ requiresRouteTreePrefetch,
+ rewrittenPrefetchHref,
+ });
// 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
diff --git a/tests/app-prefetch-rsc-request.test.ts b/tests/app-prefetch-rsc-request.test.ts
new file mode 100644
index 000000000..d51e61a3d
--- /dev/null
+++ b/tests/app-prefetch-rsc-request.test.ts
@@ -0,0 +1,133 @@
+import { afterEach, describe, expect, it, vi } from "vite-plus/test";
+import {
+ createRscRequestHeaders,
+ createRscRequestUrl,
+} from "../packages/vinext/src/server/app-rsc-cache-busting.js";
+import { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL } from "../packages/vinext/src/server/app-rsc-render-mode.js";
+import {
+ NEXT_ROUTER_PREFETCH_HEADER,
+ NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
+} from "../packages/vinext/src/server/headers.js";
+import { resolveAppPrefetchRscRequest } from "../packages/vinext/src/shims/internal/app-prefetch-rsc-request.js";
+
+const DEFAULT_OPTIONS = {
+ interceptionContext: null,
+ mountedSlotsHeader: null,
+ prefetchInlining: false,
+ requiresRouteTreePrefetch: false,
+ rewrittenPrefetchHref: null,
+} as const;
+
+type ContextualCaseOverrides = {
+ canonical?: boolean;
+ interceptionContext?: string | null;
+ mountedSlotsHeader?: string | null;
+ prefetchInlining?: boolean;
+ requiresRouteTreePrefetch?: boolean;
+};
+
+const CONTEXTUAL_CASES: Array<[string, ContextualCaseOverrides]> = [
+ ["canonical sharing is disabled", { canonical: false }],
+ ["an interception context is present", { interceptionContext: "/feed" }],
+ ["mounted slots are present", { mountedSlotsHeader: "slot-a" }],
+ ["a route-tree prefetch is required", { requiresRouteTreePrefetch: true }],
+ ["prefetch inlining is enabled", { prefetchInlining: true }],
+];
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+describe("shared App Router prefetch RSC request resolution", () => {
+ it("normalizes a shareable full request to the deploy-warmer identity", async () => {
+ vi.stubEnv("__VINEXT_CANONICAL_RSC_REQUESTS", "1");
+ const headers = createRscRequestHeaders({
+ nextUrl: "/source",
+ prefetchRouterState: { pathAndSearch: "/source", routeId: "route:/source" },
+ });
+ headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, "1");
+
+ const resolved = await resolveAppPrefetchRscRequest({
+ ...DEFAULT_OPTIONS,
+ fullHref: "/target?tab=latest#section",
+ headers,
+ });
+
+ expect(resolved).toEqual({
+ additionalRscUrls: [],
+ rscUrl: "/target?tab=latest&_rsc",
+ usesCanonicalPrewarmedRequest: true,
+ });
+ expect(Object.fromEntries(headers)).toEqual({
+ accept: "text/x-component",
+ rsc: "1",
+ });
+ });
+
+ it("normalizes a shareable loading shell to its deterministic warmed identity", async () => {
+ vi.stubEnv("__VINEXT_CANONICAL_RSC_REQUESTS", "1");
+ const headers = createRscRequestHeaders({
+ nextUrl: "/source",
+ prefetchRouterState: { pathAndSearch: "/source", routeId: "route:/source" },
+ renderMode: APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
+ });
+ headers.set(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER, "/__PAGE__");
+
+ const resolved = await resolveAppPrefetchRscRequest({
+ ...DEFAULT_OPTIONS,
+ fullHref: "/target",
+ headers,
+ });
+
+ expect(resolved).toEqual({
+ additionalRscUrls: [],
+ rscUrl: "/target?_rsc=9qLBDIU2NgN178cB",
+ usesCanonicalPrewarmedRequest: true,
+ });
+ expect(headers.get(NEXT_ROUTER_PREFETCH_HEADER)).toBe("1");
+ expect(headers.get(NEXT_ROUTER_SEGMENT_PREFETCH_HEADER)).toBe("1");
+ expect(headers.get("next-router-state-tree")).toBeNull();
+ expect(headers.get("next-url")).toBeNull();
+ });
+
+ it.each(CONTEXTUAL_CASES)("keeps the request contextual when %s", async (_label, overrides) => {
+ const { canonical = true, ...requestOverrides } = overrides;
+ vi.stubEnv("__VINEXT_CANONICAL_RSC_REQUESTS", canonical ? "1" : "");
+ const headers = createRscRequestHeaders({ nextUrl: "/source" });
+ const expectedUrl = await createRscRequestUrl("/target", new Headers(headers));
+
+ const resolved = await resolveAppPrefetchRscRequest({
+ ...DEFAULT_OPTIONS,
+ ...requestOverrides,
+ fullHref: "/target",
+ headers,
+ });
+
+ expect(resolved).toEqual({
+ additionalRscUrls: [],
+ rscUrl: expectedUrl,
+ usesCanonicalPrewarmedRequest: false,
+ });
+ expect(headers.get("next-url")).toBe("/source");
+ });
+
+ it("keeps rewritten source and destination request identities together", async () => {
+ vi.stubEnv("__VINEXT_CANONICAL_RSC_REQUESTS", "1");
+ const headers = createRscRequestHeaders({ nextUrl: "/source" });
+ const sourceUrl = await createRscRequestUrl("/source", new Headers(headers));
+ const destinationUrl = await createRscRequestUrl("/destination", new Headers(headers));
+
+ const resolved = await resolveAppPrefetchRscRequest({
+ ...DEFAULT_OPTIONS,
+ fullHref: "/source",
+ headers,
+ rewrittenPrefetchHref: "/destination",
+ });
+
+ expect(resolved).toEqual({
+ additionalRscUrls: [destinationUrl],
+ rscUrl: sourceUrl,
+ usesCanonicalPrewarmedRequest: false,
+ });
+ });
+});