Skip to content
Merged
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
69 changes: 69 additions & 0 deletions packages/vinext/src/shims/internal/app-prefetch-rsc-request.ts
Original file line number Diff line number Diff line change
@@ -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 `<Link>` 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<ResolvedAppPrefetchRscRequest> {
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,
};
}
44 changes: 13 additions & 31 deletions packages/vinext/src/shims/link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@ function prefetchUrl(
navigation,
{ AppElementsWire },
rscCacheBusting,
{ resolveAppPrefetchRscRequest },
{
APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL,
APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
49 changes: 17 additions & 32 deletions packages/vinext/src/shims/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ import {
isAppOwnedHistoryState,
} from "../server/app-history-state.js";
import {
canonicalizeLoadingShellRscRequestHeaders,
canonicalizePrewarmableRscRequestHeaders,
createCanonicalRscRequestUrl,
createRscRequestHeaders,
createRscRequestUrl,
stripRscCacheBustingSearchParam,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
133 changes: 133 additions & 0 deletions tests/app-prefetch-rsc-request.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading