From d7f66559fd71930c904da21bae27de80a7bcda30 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 22 Aug 2026 00:02:11 +0100 Subject: [PATCH] refactor(rsc): share canonical prefetch request resolution --- .../internal/app-prefetch-rsc-request.ts | 69 +++++++++ packages/vinext/src/shims/link.tsx | 44 ++---- packages/vinext/src/shims/navigation.ts | 49 +++---- tests/app-prefetch-rsc-request.test.ts | 133 ++++++++++++++++++ 4 files changed, 232 insertions(+), 63 deletions(-) create mode 100644 packages/vinext/src/shims/internal/app-prefetch-rsc-request.ts create mode 100644 tests/app-prefetch-rsc-request.test.ts 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, + }); + }); +});