diff --git a/apps/web/app/benchmarks/commit/[sha]/page.tsx b/apps/web/app/benchmarks/commit/[sha]/page.tsx index 80236db38f..492d80319b 100644 --- a/apps/web/app/benchmarks/commit/[sha]/page.tsx +++ b/apps/web/app/benchmarks/commit/[sha]/page.tsx @@ -7,6 +7,12 @@ import { createBenchmarkMetadata } from "../../metadata"; export const revalidate = 300; +// An empty list opts every path into on-demand ISR; without +// generateStaticParams a dynamic-segment route renders per request. +export function generateStaticParams() { + return []; +} + export async function generateMetadata({ params, }: { diff --git a/apps/web/app/benchmarks/pull/[number]/page.tsx b/apps/web/app/benchmarks/pull/[number]/page.tsx index 251b261660..e747eef296 100644 --- a/apps/web/app/benchmarks/pull/[number]/page.tsx +++ b/apps/web/app/benchmarks/pull/[number]/page.tsx @@ -7,6 +7,12 @@ import { createBenchmarkMetadata } from "../../metadata"; export const revalidate = 300; +// An empty list opts every path into on-demand ISR; without +// generateStaticParams a dynamic-segment route renders per request. +export function generateStaticParams() { + return []; +} + export async function generateMetadata({ params, }: { diff --git a/examples/app-router-cloudflare/app/page.tsx b/examples/app-router-cloudflare/app/page.tsx index 14587ed391..cc36dbd097 100644 --- a/examples/app-router-cloudflare/app/page.tsx +++ b/examples/app-router-cloudflare/app/page.tsx @@ -1,5 +1,8 @@ import { Counter } from "./components/counter"; +// Render on every request so the timestamp shows a fresh server render. +export const dynamic = "force-dynamic"; + export default function HomePage() { return (
diff --git a/examples/response-store-demo/README.md b/examples/response-store-demo/README.md index f9afcb8b12..edcc2a46f1 100644 --- a/examples/response-store-demo/README.md +++ b/examples/response-store-demo/README.md @@ -29,7 +29,18 @@ vinext({ cache: responseStoreAdapter({ shards: 4 }) }); - `/use-cache-expired` verifies that hard-expired data blocks on loopback regeneration. - `/api/now` exercises cached App Route responses. - `/pages-prewarm` exercises Pages Router ISR. +- `/static-default` verifies that a static page with no revalidate source is stored until it's + revalidated, as in Next.js. +- `/search-params/suspense` verifies that `useSearchParams()` inside Suspense keeps a static page + cached with the fallback in its HTML, and `/search-params/unwrapped/[slug]` that a call + outside Suspense returns a 500, as in Next.js. `/search-params/dynamic` reads `headers()`, so it + server-renders the real query and is never stored. +- `/client-search-params/ignores` verifies that a `"use client"` page that never reads its + `searchParams` prop is stored once for every query, and `/client-search-params/reads` that a + client page reading it is never stored, as in Next.js. - `/force-dynamic` verifies that explicit build-time dynamic config bypasses response-cache lookup. +- `/dynamic-segment/[slug]` verifies that a dynamic-segment route without `generateStaticParams` + bypasses response-cache lookup, as in Next.js. - `/dynamic` and `/vary` verify that unsafe completed responses bypass shared storage. Canonical App Router RSC requests use the same response-stage transport: diff --git a/examples/response-store-demo/app/client-search-params/ignores/page.tsx b/examples/response-store-demo/app/client-search-params/ignores/page.tsx new file mode 100644 index 0000000000..4bb13cb6be --- /dev/null +++ b/examples/response-store-demo/app/client-search-params/ignores/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +// A client page that never reads its searchParams prop. Its RSC payload +// carries no query, so one stored page serves every query, as in Next.js. +export default function ClientSearchParamsIgnoresPage() { + return ( + <> +

+ /client-search-params/ignores +

+

This client page doesn't read searchParams, so it stays static.

+ + ); +} diff --git a/examples/response-store-demo/app/client-search-params/layout.tsx b/examples/response-store-demo/app/client-search-params/layout.tsx new file mode 100644 index 0000000000..147c6427e6 --- /dev/null +++ b/examples/response-store-demo/app/client-search-params/layout.tsx @@ -0,0 +1,22 @@ +import Link from "next/link"; +import type { ReactNode } from "react"; + +// The render ID comes from this server layout, so a stored page keeps it and a +// fresh render changes it. +export default function ClientSearchParamsLayout({ children }: { children: ReactNode }) { + const renderId = crypto.randomUUID(); + + return ( +
+ + {children} +
+

+ Render ID: {renderId} +

+
+
+ ); +} diff --git a/examples/response-store-demo/app/client-search-params/reads/page.tsx b/examples/response-store-demo/app/client-search-params/reads/page.tsx new file mode 100644 index 0000000000..9904768097 --- /dev/null +++ b/examples/response-store-demo/app/client-search-params/reads/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { use } from "react"; + +// A client page that reads its searchParams prop. As in Next.js, the read +// makes the render dynamic, so the page server-renders the real query and is +// never stored, with or without a query. +export default function ClientSearchParamsReadsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const { q } = use(searchParams); + + return ( + <> +

+ /client-search-params/reads +

+

+ Query: {typeof q === "string" ? q : "(none)"} +

+ + ); +} diff --git a/examples/response-store-demo/app/dynamic-segment/[slug]/page.tsx b/examples/response-store-demo/app/dynamic-segment/[slug]/page.tsx new file mode 100644 index 0000000000..f44028f59c --- /dev/null +++ b/examples/response-store-demo/app/dynamic-segment/[slug]/page.tsx @@ -0,0 +1,35 @@ +import Link from "next/link"; + +// Without generateStaticParams, Next.js renders a dynamic-segment route per +// request even though it sets `revalidate`. +export const revalidate = 60; + +export default async function DynamicSegmentPage({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + const renderId = crypto.randomUUID(); + + return ( +
+ +

+ /dynamic-segment/{slug} +

+

+ This dynamic-segment route sets revalidate = 60 but has no{" "} + generateStaticParams, so vinext renders it per request without consulting the + configured response cache. +

+
+

+ Render ID: {renderId} +

+
+
+ ); +} diff --git a/examples/response-store-demo/app/search-params/dynamic/page.tsx b/examples/response-store-demo/app/search-params/dynamic/page.tsx new file mode 100644 index 0000000000..6b489d9606 --- /dev/null +++ b/examples/response-store-demo/app/search-params/dynamic/page.tsx @@ -0,0 +1,33 @@ +import Link from "next/link"; +import { headers } from "next/headers"; +import { Suspense } from "react"; +import { SearchValue } from "../search-value"; + +// The page reads headers(), so it's rendered per request: useSearchParams() +// server-renders the real query and the page is never stored. +export default async function SearchParamsDynamicPage() { + await headers(); + const renderId = crypto.randomUUID(); + + return ( +
+ +

+ /search-params/dynamic +

+

+ Query:{" "} + loading}> + + +

+
+

+ Render ID: {renderId} +

+
+
+ ); +} diff --git a/examples/response-store-demo/app/search-params/search-value.tsx b/examples/response-store-demo/app/search-params/search-value.tsx new file mode 100644 index 0000000000..e58c803af2 --- /dev/null +++ b/examples/response-store-demo/app/search-params/search-value.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; + +export function SearchValue() { + const searchParams = useSearchParams(); + return {searchParams.get("q") ?? "(none)"}; +} diff --git a/examples/response-store-demo/app/search-params/suspense/page.tsx b/examples/response-store-demo/app/search-params/suspense/page.tsx new file mode 100644 index 0000000000..bbdccf0545 --- /dev/null +++ b/examples/response-store-demo/app/search-params/suspense/page.tsx @@ -0,0 +1,32 @@ +import Link from "next/link"; +import { Suspense } from "react"; +import { SearchValue } from "../search-value"; + +// useSearchParams() inside Suspense on a static page: as in Next.js, the +// server renders the fallback and the browser fills in the query, so one +// stored page serves every query. +export default function SearchParamsSuspensePage() { + const renderId = crypto.randomUUID(); + + return ( +
+ +

+ /search-params/suspense +

+

+ Query:{" "} + loading}> + + +

+
+

+ Render ID: {renderId} +

+
+
+ ); +} diff --git a/examples/response-store-demo/app/search-params/unwrapped/[slug]/page.tsx b/examples/response-store-demo/app/search-params/unwrapped/[slug]/page.tsx new file mode 100644 index 0000000000..2f9fd415d3 --- /dev/null +++ b/examples/response-store-demo/app/search-params/unwrapped/[slug]/page.tsx @@ -0,0 +1,26 @@ +import Link from "next/link"; +import { SearchValue } from "../../search-value"; + +// No path is generated at build, so each one renders on demand. With +// useSearchParams() outside Suspense, Next.js answers such a path with a 500 +// (and fails `next build` for a generated one), and so does vinext in +// production. +export function generateStaticParams() { + return []; +} + +export default function SearchParamsUnwrappedPage() { + return ( +
+ +

+ /search-params/unwrapped/[slug] +

+

+ Query: +

+
+ ); +} diff --git a/examples/response-store-demo/app/static-default/page.tsx b/examples/response-store-demo/app/static-default/page.tsx new file mode 100644 index 0000000000..14f951ffa5 --- /dev/null +++ b/examples/response-store-demo/app/static-default/page.tsx @@ -0,0 +1,28 @@ +import Link from "next/link"; + +// No revalidate export, fetch revalidate or cacheLife: Next.js defaults a +// static page to `revalidate = false` and serves it until it's revalidated. +export default function StaticDefaultPage() { + const renderId = crypto.randomUUID(); + + return ( +
+ +

+ /static-default +

+

+ This page has no revalidate source, so vinext stores it with revalidate = false{" "} + and serves it until revalidatePath, revalidateTag or the next + deploy. +

+
+

+ Render ID: {renderId} +

+
+
+ ); +} diff --git a/examples/tpr-demo/app/products/[id]/page.tsx b/examples/tpr-demo/app/products/[id]/page.tsx index e351c041a5..e36e2a8c5e 100644 --- a/examples/tpr-demo/app/products/[id]/page.tsx +++ b/examples/tpr-demo/app/products/[id]/page.tsx @@ -3,6 +3,12 @@ import Link from "next/link"; export const revalidate = 3600; // ISR: revalidate every hour +// An empty list opts every path into on-demand ISR; without +// generateStaticParams a dynamic-segment route renders per request. +export function generateStaticParams() { + return []; +} + export default async function ProductPage({ params, }: { diff --git a/packages/cloudflare/src/cache/cdn-adapter.runtime.ts b/packages/cloudflare/src/cache/cdn-adapter.runtime.ts index e635d32fae..e78576d631 100644 --- a/packages/cloudflare/src/cache/cdn-adapter.runtime.ts +++ b/packages/cloudflare/src/cache/cdn-adapter.runtime.ts @@ -115,12 +115,21 @@ function hasExplicitCloudflareNonCacheableResponsePolicy(headers: Headers): bool ); } +/** Shared-cache policy headers in the order Cloudflare honors them. */ +const RESPONSE_POLICY_PRECEDENCE = [ + "Cloudflare-CDN-Cache-Control", + "CDN-Cache-Control", + "Cache-Control", +] as const; + +/** Name the highest-precedence policy header Cloudflare honors on a response. */ +function readCloudflareResponsePolicyHeaderName(headers: Headers): string | null { + return RESPONSE_POLICY_PRECEDENCE.find((name) => headers.has(name)) ?? null; +} + function readCloudflareResponseCacheControl(headers: Headers): string | null { - return ( - headers.get("Cloudflare-CDN-Cache-Control") ?? - headers.get("CDN-Cache-Control") ?? - headers.get("Cache-Control") - ); + const name = readCloudflareResponsePolicyHeaderName(headers); + return name ? headers.get(name) : null; } /** The request-context cache surface this adapter relies on (narrowed from `unknown`). */ @@ -211,6 +220,9 @@ export class CloudflareCdnCacheAdapter implements CdnCacheAdapter { readCacheControl(headers: Headers): string | null { return readCloudflareResponseCacheControl(headers); }, + readCacheControlHeaderName(headers: Headers): string | null { + return readCloudflareResponsePolicyHeaderName(headers); + }, hasExplicitNonCacheablePolicy(headers: Headers, baseline?: Headers): boolean { if (baseline) { for (const name of EDGE_POLICY_HEADERS) { diff --git a/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts b/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts index bf01363b8e..1059ab4871 100644 --- a/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts +++ b/packages/cloudflare/src/cache/kv-data-adapter.runtime.ts @@ -471,8 +471,12 @@ export class KVCacheHandler implements CacheHandler { if (effectiveRevalidate === 0) return Promise.resolve(); const now = Date.now(); + // `revalidate = false` never goes stale, so it gets no revalidateAt and, + // below, no KV TTL: the entry stays until it is invalidated. const revalidateAt = - typeof effectiveRevalidate === "number" && effectiveRevalidate > 0 + typeof effectiveRevalidate === "number" && + effectiveRevalidate > 0 && + Number.isFinite(effectiveRevalidate) ? now + effectiveRevalidate * 1000 : null; const expireAt = @@ -482,7 +486,9 @@ export class KVCacheHandler implements CacheHandler { const cacheControl: CacheControlMetadata | undefined = typeof effectiveRevalidate === "number" ? { - revalidate: effectiveRevalidate, + // JSON can't hold Infinity, so store `revalidate = false` as false, + // like Next.js does. Reads turn it back into Infinity. + revalidate: Number.isFinite(effectiveRevalidate) ? effectiveRevalidate : false, ...(effectiveExpire === undefined ? {} : { expire: effectiveExpire }), // Client-router reuse bound — must survive KV so warm hits replay // the producing render's claim (see CacheControlMetadata.stale). @@ -535,15 +541,10 @@ export class KVCacheHandler implements CacheHandler { const tagList = Array.isArray(tags) ? tags : [tags]; const now = Date.now(); const validTags = tagList.filter((t) => validateTag(t) !== null); - // Store invalidation timestamp for each tag - // Use a long TTL (30 days) so recent invalidations are always found - await Promise.all( - validTags.map((tag) => - this.kv.put(this._tagKey(tag), String(now), { - expirationTtl: 30 * 24 * 3600, - }), - ), - ); + // Store invalidation timestamp for each tag. Markers never expire: an + // entry with no TTL (`revalidate = false`) must not outlive the marker that + // invalidated it. Newer entries pass the marker by `lastModified`. + await Promise.all(validTags.map((tag) => this.kv.put(this._tagKey(tag), String(now)))); const order = ++this._tagCacheOrder; // Update local tag cache immediately so invalidations are reflected // without waiting for the TTL to expire @@ -684,7 +685,9 @@ function validateCacheEntry(raw: unknown): KVCacheEntry | null { } if (obj.cacheControl !== undefined) { if (!isUnknownRecord(obj.cacheControl)) return null; - if (typeof obj.cacheControl.revalidate !== "number") return null; + if (typeof obj.cacheControl.revalidate !== "number" && obj.cacheControl.revalidate !== false) { + return null; + } if (obj.cacheControl.expire !== undefined && typeof obj.cacheControl.expire !== "number") { return null; } @@ -700,6 +703,11 @@ function validateCacheEntry(raw: unknown): KVCacheEntry | null { if (typeof value.kind !== "string" || !VALID_KINDS.has(value.kind)) return null; } + // Serve the same `revalidate = false` policy the other backends keep in memory. + if (obj.cacheControl?.revalidate === false) { + obj.cacheControl = { ...obj.cacheControl, revalidate: Infinity }; + } + return raw as KVCacheEntry; } diff --git a/packages/cloudflare/src/cache/response-store-adapter.worker.ts b/packages/cloudflare/src/cache/response-store-adapter.worker.ts index 68b61f0562..79b82ca250 100644 --- a/packages/cloudflare/src/cache/response-store-adapter.worker.ts +++ b/packages/cloudflare/src/cache/response-store-adapter.worker.ts @@ -12,6 +12,10 @@ import type { VinextResponseStageDispatchOptions, VinextResponseStageTransport, } from "vinext/server/multi-stage"; +import { + VINEXT_PARAMS_HEADER, + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, +} from "vinext/internal/server/headers"; import { loadVinextRequestStage } from "vinext/server/request-stage"; import { loadVinextResponseStage } from "vinext/server/response-stage"; import { traceCachedResponseStart } from "vinext/internal/server/response-start-tracing"; @@ -224,7 +228,7 @@ export function createVinextResponseStoreOptions {}); throw new Error("Vinext response-stage regeneration was not cacheable"); } - return response; + return withoutRequestScopedHeaders(response, invocation.props); } if ( input.id === CACHE_FUNCTION_REVALIDATOR_ID && @@ -298,6 +302,31 @@ async function cacheRequest(invocation: StoredInvocation): Promise { return new Request(url, { headers: CACHE_REQUEST_VARY_HEADERS, method: "GET" }); } +/** + * The request stage recomposes the routed params and path on every App page + * RSC response, HITs included, so such an entry shared across queries must not + * carry the values of the request that filled it. Every other response kind + * (route handlers, metadata routes, HTML, Pages) keeps its headers as rendered. + */ +function withoutRequestScopedHeaders(response: Response, responseStageProps: unknown): Response { + if ( + responseStageProps === null || + typeof responseStageProps !== "object" || + Reflect.get(responseStageProps, "kind") !== "app-page" || + Reflect.get(responseStageProps, "isRscRequest") !== true + ) { + return response; + } + const headers = new Headers(response.headers); + headers.delete(VINEXT_PARAMS_HEADER); + headers.delete(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER); + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }); +} + function isCacheable(response: Response): boolean { const policy = response.headers.get("Cloudflare-CDN-Cache-Control") ?? @@ -392,6 +421,12 @@ const handler = { ); } + // Core supplies a query-free identity only for shared App page dispatches, + // whose admission requires a negative searchParams proof. The render + // still receives the real request; the key, route replay and RSC seed + // use the identity. + const identityRequest = options.cacheIdentity?.request ?? stageRequest; + const identityProps = options.cacheIdentity?.props ?? props; const isWarmup = request.headers.get("user-agent") === WARMUP_USER_AGENT; const canSeedRsc = isWarmup && @@ -406,17 +441,17 @@ const handler = { const rscSeed = canSeedRsc ? { props: { - ...(props as Record), + ...(identityProps as Record), isRscRequest: true, renderMode: "navigation", }, request: new Request( - new URL(createCanonicalRscRequestUrl(stageRequest.url), stageRequest.url), + new URL(createCanonicalRscRequestUrl(identityRequest.url), identityRequest.url), { headers: createCanonicalRscRequestHeaders() }, ), } : undefined; - const invocation = prepareInvocation(stageRequest, props); + const invocation = prepareInvocation(identityRequest, identityProps); const rscInvocation = rscSeed ? prepareInvocation(rscSeed.request, rscSeed.props) : undefined; const rscKey = rscInvocation ? await cacheRequest(rscInvocation) : undefined; const key = await cacheRequest(invocation); @@ -438,9 +473,13 @@ const handler = { ? {} : { streamResponse: true }; const serializedInvocation = JSON.stringify(invocation); + // Data-cache writes replay the render that produced them, real query + // included, so they keep the full invocation. const rendered = await invokeResponseStage(stageRequest, props, env, ctx, "shared", capture, { replayable: isReplayableInvocation(stageRequest, props), - serialized: serializedInvocation, + serialized: options.cacheIdentity + ? serializeInvocation(stageRequest, props) + : serializedInvocation, }); if (capture.admittedResponse) { ctx.waitUntil( @@ -450,7 +489,7 @@ const handler = { await admitted.body?.cancel().catch(() => {}); return; } - await responseStore.put(key, admitted, { + await responseStore.put(key, withoutRequestScopedHeaders(admitted, props), { coalesce: true, revalidator: { id: ROUTE_REVALIDATOR_ID, args: [serializedInvocation] }, }); @@ -476,7 +515,7 @@ const handler = { } const [foreground, cacheBody] = rendered.body ? rendered.body.tee() : [null, null]; - const cacheResponse = new Response(cacheBody, rendered); + const cacheResponse = withoutRequestScopedHeaders(new Response(cacheBody, rendered), props); await responseStore.put(key, cacheResponse, { coalesce: true, revalidator: { id: ROUTE_REVALIDATOR_ID, args: [serializedInvocation] }, @@ -488,6 +527,8 @@ const handler = { rscHeaders.delete("Content-Length"); rscHeaders.delete("Link"); rscHeaders.delete("X-Vinext-Response-Store-Replayable"); + rscHeaders.delete(VINEXT_PARAMS_HEADER); + rscHeaders.delete(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER); rscHeaders.set("Content-Type", VINEXT_RSC_CONTENT_TYPE); rscHeaders.set("Vary", VINEXT_RSC_VARY_HEADER); applyRscCompatibilityIdHeader(rscHeaders); diff --git a/packages/cloudflare/src/cache/response-store-cdn.runtime.ts b/packages/cloudflare/src/cache/response-store-cdn.runtime.ts index feb13c2e06..a332a5f4a0 100644 --- a/packages/cloudflare/src/cache/response-store-cdn.runtime.ts +++ b/packages/cloudflare/src/cache/response-store-cdn.runtime.ts @@ -14,6 +14,7 @@ import { class ResponseStoreCdnCacheAdapter implements CdnCacheAdapter { readonly ownsBackgroundRevalidation = false; readonly requiresCompletedResponseAdmission = true; + readonly responseStageCacheIdentity = "query-free"; readonly responsePolicy: CdnCacheAdapter["responsePolicy"]; constructor(private readonly headers: CdnCacheAdapter) { diff --git a/packages/cloudflare/src/cacheability-artifact.ts b/packages/cloudflare/src/cacheability-artifact.ts index 299ac2bfca..a9c015055d 100644 --- a/packages/cloudflare/src/cacheability-artifact.ts +++ b/packages/cloudflare/src/cacheability-artifact.ts @@ -3,6 +3,8 @@ import path from "node:path"; import { Buffer } from "node:buffer"; import { CACHEABILITY_MANIFEST_MODULE, + CACHEABILITY_REQUEST_PROJECTION_MODULE, + projectCacheabilityManifestForRequestStage, type CacheabilityManifest, } from "vinext/internal/server/cacheability-manifest"; import { @@ -158,7 +160,7 @@ function resolveGeneratedServerConfig(root: string, configuredPath: string | und return configPath; } -function assertManifestModuleReachable(configPath: string): void { +function assertModuleReachable(configPath: string, moduleName: string): void { const serverDirectory = path.dirname(configPath); let config: unknown; try { @@ -203,16 +205,11 @@ function assertManifestModuleReachable(configPath: string): void { if (!entry || typeof entry.file !== "string") continue; const modulePath = path.resolve(serverDirectory, entry.file); if (fs.existsSync(modulePath) && fs.lstatSync(modulePath).isFile()) { - const relativeManifest = path - .relative( - path.dirname(modulePath), - path.join(serverDirectory, CACHEABILITY_MANIFEST_MODULE), - ) + const relativeModule = path + .relative(path.dirname(modulePath), path.join(serverDirectory, moduleName)) .split(path.sep) .join("/"); - const specifier = relativeManifest.startsWith(".") - ? relativeManifest - : `./${relativeManifest}`; + const specifier = relativeModule.startsWith(".") ? relativeModule : `./${relativeModule}`; reachable = hasStaticModuleSpecifier(fs.readFileSync(modulePath, "utf8"), specifier); } for (const references of [entry.imports, entry.dynamicImports]) { @@ -223,15 +220,31 @@ function assertManifestModuleReachable(configPath: string): void { } if (!reachable) { throw new Error( - `Two-stage CDN warming requires the generated Worker graph to statically import ${CACHEABILITY_MANIFEST_MODULE}.`, + `Two-stage CDN warming requires the generated Worker graph to statically import ${moduleName}.`, ); } } +function writeStringModule(modulePath: string, value: string): void { + const source = `export default ${JSON.stringify(value)};\n`; + const pendingPath = `${modulePath}.${process.pid}.tmp`; + try { + fs.writeFileSync(pendingPath, source, "utf8"); + fs.renameSync(pendingPath, modulePath); + } finally { + if (fs.existsSync(pendingPath)) fs.unlinkSync(pendingPath); + } +} + /** * Write the version-specific manifest into the built Worker artifact. * The application build already imports this stable module asset, so the * completed dist directory remains the exact input to the final upload. + * + * App Router builds also emit the request stage's projection module. It + * carries the App page routes that can admit a query-free entry, so the + * request stage can strip the query from those dispatches without loading the + * full manifest. */ export function writeCacheabilityManifestArtifact( root: string, @@ -239,7 +252,7 @@ export function writeCacheabilityManifestArtifact( manifest: CacheabilityManifest, ): string { const configPath = resolveGeneratedServerConfig(root, configuredPath); - assertManifestModuleReachable(configPath); + assertModuleReachable(configPath, CACHEABILITY_MANIFEST_MODULE); const serverDirectory = path.dirname(configPath); const manifestPath = path.join(serverDirectory, CACHEABILITY_MANIFEST_MODULE); if (!fs.existsSync(manifestPath) || !fs.lstatSync(manifestPath).isFile()) { @@ -253,13 +266,26 @@ export function writeCacheabilityManifestArtifact( throw cacheabilityManifestByteLimitError(manifestBytes); } - const manifestSource = `export default ${JSON.stringify(serializedManifest)};\n`; - const pendingManifestPath = `${manifestPath}.${process.pid}.tmp`; - try { - fs.writeFileSync(pendingManifestPath, manifestSource, "utf8"); - fs.renameSync(pendingManifestPath, manifestPath); - } finally { - if (fs.existsSync(pendingManifestPath)) fs.unlinkSync(pendingManifestPath); + const projectionPath = path.join(serverDirectory, CACHEABILITY_REQUEST_PROJECTION_MODULE); + const hasProjectionModule = + fs.existsSync(projectionPath) && fs.lstatSync(projectionPath).isFile(); + // Without the projection, the request stage would never drop the query for + // the App page paths this manifest certifies. + if (Object.values(manifest.routes).some((route) => route.kind === "app-page")) { + if (!hasProjectionModule) { + throw new Error( + `Two-stage CDN warming requires ${CACHEABILITY_REQUEST_PROJECTION_MODULE} in the generated Worker artifact. Rebuild the app before deploying.`, + ); + } + assertModuleReachable(configPath, CACHEABILITY_REQUEST_PROJECTION_MODULE); + } + + writeStringModule(manifestPath, serializedManifest); + if (hasProjectionModule) { + writeStringModule( + projectionPath, + JSON.stringify(projectCacheabilityManifestForRequestStage(manifest)), + ); } return path.relative(root, configPath); } diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index d8722c746e..a41f1e2c32 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -10,6 +10,7 @@ import { normalizeCacheabilityRoutePathname, type CacheabilityManifest, type CacheabilityManifestRoute, + type CacheabilityRepresentation, } from "vinext/internal/server/cacheability-manifest"; import type { PrerenderRoutePattern } from "vinext/internal/build/prerender-paths"; import { @@ -32,6 +33,8 @@ const DEFAULT_CACHEABILITY_PROBE_REQUEST_TIMEOUT_MS = 30_000; const MAX_CACHEABILITY_PROBE_ENVELOPE_BYTES = 64 * 1024; type ProbePayload = { + dynamicUsage?: true; + explicitConfigCachePolicy?: true; kind?: string; pattern?: string; reason?: string; @@ -98,27 +101,31 @@ function sharedPathPrefix(pathnames: readonly string[]): string | null { function compactManifestRoutePaths(route: CacheabilityManifestRoute): CacheabilityManifestRoute { const pathnames = [ ...(route.runtimePaths ?? []), + ...Object.values(route.runtimeRepresentationPaths ?? {}).flatMap((paths) => paths ?? []), ...Object.values(route.staticPaths ?? {}).flatMap((paths) => paths ?? []), ]; const pathPrefix = sharedPathPrefix(pathnames); if (!pathPrefix) return route; + const compactPathLists = ( + lists: Partial>, + ): Partial> => + Object.fromEntries( + Object.entries(lists).map(([representation, paths]) => [ + representation, + paths!.map((pathname) => pathname.slice(pathPrefix.length)), + ]), + ); const compacted: CacheabilityManifestRoute = { ...route, pathPrefix, ...(route.runtimePaths ? { runtimePaths: route.runtimePaths.map((pathname) => pathname.slice(pathPrefix.length)) } : {}), - ...(route.staticPaths - ? { - staticPaths: Object.fromEntries( - Object.entries(route.staticPaths).map(([representation, paths]) => [ - representation, - paths!.map((pathname) => pathname.slice(pathPrefix.length)), - ]), - ), - } + ...(route.runtimeRepresentationPaths + ? { runtimeRepresentationPaths: compactPathLists(route.runtimeRepresentationPaths) } : {}), + ...(route.staticPaths ? { staticPaths: compactPathLists(route.staticPaths) } : {}), }; return Buffer.byteLength(JSON.stringify(compacted)) < Buffer.byteLength(JSON.stringify(route)) ? compacted @@ -383,6 +390,10 @@ export async function probeStagedWorkerCacheability(options: { let dynamicPathCount = 0; type ConcretePathResult = { + /** The render used a dynamic API. OR-merged across groups sharing a result key. */ + dynamicUsage: boolean; + /** A next.config header policy applied. OR-merged across groups sharing a result key. */ + explicitConfigCachePolicy: boolean; rendererStatic: boolean; representation: CdnWarmTarget["kind"]; state: Exclude; @@ -400,6 +411,8 @@ export async function probeStagedWorkerCacheability(options: { }; type ConcretePathGroup = { deferred: boolean; + /** An unlisted path whose render failed; it is left to the route rule. */ + dropped?: boolean; pattern: PatternClassification; primary: CdnWarmTarget; result?: ConcretePathResult; @@ -472,12 +485,41 @@ export async function probeStagedWorkerCacheability(options: { group.targets.push(target); targetGroups.set(concreteKey, group); } + // A path is listed when the route that owns it at runtime lists it in its + // own static generation; discovery marks every other path unlisted, even one + // another route generates. A path the request stage moves, to another route + // or another pathname, counts as listed only when its destination route lists + // the resolved pathname. Those build-time facts are keyed by route and + // pathname, and fixed before any probe runs: groups move between patterns as + // route-moving probes complete, so the listing is never read back from them. + // Discovery's fact wins over the pattern's shape: a literal route that isn't + // static or SSG doesn't list its own path. Only a literal route discovery has + // no fact for lists it. + const buildTimeListing = new Map(); + const isListedAt = ( + route: Pick, + routePathname: string, + ): boolean => { + const key = cacheabilityManifestRouteKey(route.kind, route.pattern); + const listed = buildTimeListing.get(`${key}\0${routePathname}`); + if (listed !== undefined) return listed; + return ( + !/(^|\/):/.test(route.pattern) && + normalizeCacheabilityRoutePathname(route.pattern) === routePathname + ); + }; + const isListedGroup = (group: ConcretePathGroup): boolean => + isListedAt(group.pattern.route, group.routePathname); const groups: ConcretePathGroup[] = Array.from(targetGroups.values(), (targetGroup) => { targetGroup.targets.sort((first, second) => { const preference = targetPreference(first) - targetPreference(second); return preference || first.sourcePathname.localeCompare(second.sourcePathname); }); const group = { ...targetGroup, deferred: false, primary: targetGroup.targets[0] }; + buildTimeListing.set( + `${group.pattern.key}\0${group.routePathname}`, + group.primary.route!.cacheabilityProbe?.unlisted !== true, + ); targetGroup.pattern.groups.push(group); return group; }); @@ -654,6 +696,9 @@ export async function probeStagedWorkerCacheability(options: { result.scope !== "identity" || !group.pattern.requestStageMayTerminate)) || (result.rendererStatic !== undefined && typeof result.rendererStatic !== "boolean") || + (result.dynamicUsage !== undefined && result.dynamicUsage !== true) || + (result.explicitConfigCachePolicy !== undefined && + result.explicitConfigCachePolicy !== true) || (result.retryable !== undefined && result.retryable !== true) || (result.retryable === true && result.state !== "probe-failed") || !Number.isInteger(result.status) || @@ -665,6 +710,52 @@ export async function probeStagedWorkerCacheability(options: { reportProgress(); return "done"; } + if ( + result.state === "probe-failed" && + result.kind === "app-page" && + result.status! >= 500 && + result.reason === `route returned HTTP ${result.status}` && + target.route && + // A route the request stage may not move to, or a move without its + // concrete pathname, fails as any other resolution below. + ((result.kind === target.route.kind && result.pattern === target.route.pattern) || + (target.route.cacheabilityProbe?.routeMayResolve === true && + result.routePathname !== undefined)) && + !isListedAt( + { kind: result.kind, pattern: result.pattern }, + result.routePathname === undefined + ? group.routePathname + : normalizeCacheabilityRoutePathname(result.routePathname), + ) + ) { + // Next.js's build never renders an unlisted App page path, so its + // render error doesn't fail the deploy. The path is neither classified + // nor warmed. Listing is judged under the route the request resolved. + // A failure the request stage moved proves only its own destination, so + // the paired representations stay at the original route, as they do + // after a moved success, and only the failed primary is dropped. + if ( + target.route.cacheabilityProbe?.routeMayResolve === true && + (result.kind !== target.route.kind || + result.pattern !== target.route.pattern || + (result.routePathname !== undefined && + normalizeCacheabilityRoutePathname(result.routePathname) !== group.routePathname)) + ) { + deferPairedRepresentationsAtOriginalRoute(group); + } + group.dropped = true; + if ( + !group.pattern.groups.some( + (candidate) => candidate !== group && candidate.resultKey === group.resultKey, + ) + ) { + group.pattern.resultKeys.delete(group.resultKey); + } + skippedPathCount += 1; + completedPathCount += 1; + reportProgress(); + return "done"; + } if (result.state === "probe-failed") { failures.push(`${target.label}: ${result.reason ?? "probe failed"}`); completedPathCount += 1; @@ -715,6 +806,8 @@ export async function probeStagedWorkerCacheability(options: { } const classification: ConcretePathResult = { + dynamicUsage: result.dynamicUsage === true, + explicitConfigCachePolicy: result.explicitConfigCachePolicy === true, rendererStatic: result.rendererStatic === true, representation: target.kind, state: result.state, @@ -722,15 +815,21 @@ export async function probeStagedWorkerCacheability(options: { }; group.result = classification; const previousClassification = group.pattern.results.get(group.resultKey); - if ( + const retainedClassification = !previousClassification || (previousClassification.state === "static-candidate" && classification.state === "dynamic") || (previousClassification.state === classification.state && previousClassification.rendererStatic && !classification.rendererStatic) - ) { - group.pattern.results.set(group.resultKey, classification); - } + ? classification + : previousClassification; + group.pattern.results.set(group.resultKey, { + ...retainedClassification, + dynamicUsage: classification.dynamicUsage || previousClassification?.dynamicUsage === true, + explicitConfigCachePolicy: + classification.explicitConfigCachePolicy || + previousClassification?.explicitConfigCachePolicy === true, + }); const patternIsDefinitelyDynamic = result.state === "dynamic" && result.scope === "pattern" && group.pattern.canPrune; if (patternIsDefinitelyDynamic) { @@ -871,10 +970,12 @@ export async function probeStagedWorkerCacheability(options: { classified += 1; } if (limitFailure) throw limitFailure; - // Next.js classifies every generateStaticParams result independently. Store - // each observed concrete path exactly once, then compact the shared route - // prefix. Paired HTML/RSC or HTML/data representations reuse the path's - // membership but must pass their own completed-render admission check. + // Next.js classifies every generateStaticParams result independently. A + // path appears at most once per representation list, and never both + // runtime-checked and static. A certified-static App page is listed under + // HTML and its full RSC payload, which Next.js serves from one render. + // Paired representations must still pass their own completed-render + // admission check. The shared route prefix is compacted last. for (const pattern of patterns.values()) { if (pattern.pruned) { classified += 1; @@ -896,15 +997,81 @@ export async function probeStagedWorkerCacheability(options: { } continue; } - if (pattern.results.size === 0 && !pattern.groups.some((group) => group.deferred)) continue; + const hasStaticFallback = fallbackRoutes.has(pattern.key); + if (pattern.results.size === 0 && !pattern.groups.some((group) => group.deferred)) { + // Every probed path moved to another route or was dropped, so the route + // keeps its fallback-only entry. + if (hasStaticFallback) { + const route: CacheabilityManifestRoute = { + kind: pattern.route.kind, + pattern: pattern.route.pattern, + state: "static-candidate", + }; + if (!addRouteWithinManifestLimits(pattern.key, route)) break; + classified += 1; + } + continue; + } classified += 1; if (Array.from(pattern.results.values()).some((result) => result.state === "dynamic")) { dynamic += 1; } + // App pages follow Next.js's build. Unknown paths of a dynamic-segment + // route get on-demand ISR only with a static fallback, or when the route + // lists paths and none it rendered used a dynamic API. A path that used a + // dynamic API, and an unlisted path of a route without on-demand ISR, get + // no state in any representation: never admitted and not warmed. A path + // cacheable only through a next.config policy stays runtime-checked. + const isAppPage = pattern.route.kind === "app-page"; + const listedResults = pattern.groups.flatMap((group) => { + const result = + !group.deferred && !group.dropped && isListedGroup(group) + ? pattern.results.get(group.resultKey) + : undefined; + return result ? [result] : []; + }); + const hasOnDemandIsr = + hasStaticFallback || + (/(^|\/):/.test(pattern.route.pattern) && + listedResults.length > 0 && + !listedResults.some((result) => result.dynamicUsage)); + const hasNoState = (group: ConcretePathGroup): boolean => { + const result = pattern.results.get(group.resultKey); + if (!isAppPage || !result) return false; + if ( + result.state === "static-candidate" && + !result.rendererStatic && + result.explicitConfigCachePolicy + ) { + return false; + } + return result.dynamicUsage || (!hasOnDemandIsr && !isListedGroup(group)); + }; + const rendererStaticTargets = new Map(); const runtimePathSet = new Set(); + const loadingShellRuntimePathSet = new Set(); for (const group of pattern.groups) { + if (group.dropped) continue; + if (!group.deferred && hasNoState(group)) { + // A dynamic API can sit below the loading boundary, so the loading + // shell of a path the route would otherwise keep stays warmable. Its + // completed render decides admission. + const loadingShellTargets = group.targets.filter( + (target) => target.kind === "rsc-loading-shell", + ); + if ( + loadingShellTargets.length > 0 && + pattern.results.get(group.resultKey)?.dynamicUsage && + (hasOnDemandIsr || isListedGroup(group)) + ) { + loadingShellRuntimePathSet.add(group.routePathname); + cacheableTargets.push(...loadingShellTargets); + speculativeTargets.push(...loadingShellTargets); + } + continue; + } if (group.deferred) { runtimePathSet.add(group.routePathname); cacheableTargets.push(...group.targets); @@ -938,30 +1105,60 @@ export async function probeStagedWorkerCacheability(options: { speculativeTargets.push(...pairedTargets); } } + // The HTML probe ran SSR, so it saw every read the full RSC render can + // make. The loading shell renders the loading boundary, which a static + // page's render may never reach, so a path listed static elsewhere leaves + // its loading shell runtime-checked. A path probed only through RSC keeps + // its single listing. + const staticRepresentations = ( + representation: CdnWarmTarget["kind"], + ): CdnWarmTarget["kind"][] => + isAppPage && representation === "html" ? ["html", "rsc-full"] : [representation]; const staticPaths: CacheabilityManifestRoute["staticPaths"] = {}; for (const [routePathname, staticTarget] of rendererStaticTargets) { // Conflicting observations for one resolved route identity must retain // runtime admission rather than certifying the static observation. if (runtimePathSet.has(routePathname)) continue; - const paths = staticPaths[staticTarget.kind] ?? []; - paths.push(routePathname); - staticPaths[staticTarget.kind] = paths; + for (const representation of staticRepresentations(staticTarget.kind)) { + const paths = staticPaths[representation] ?? []; + paths.push(routePathname); + staticPaths[representation] = paths; + } } for (const paths of Object.values(staticPaths)) paths?.sort(); + const loadingShellRuntimePaths = Array.from(loadingShellRuntimePathSet) + .filter( + (routePathname) => + !runtimePathSet.has(routePathname) && + !staticPaths["rsc-loading-shell"]?.includes(routePathname), + ) + .sort(); + const runtimeRepresentationPaths: CacheabilityManifestRoute["runtimeRepresentationPaths"] = + loadingShellRuntimePaths.length > 0 + ? { "rsc-loading-shell": loadingShellRuntimePaths } + : undefined; const allObservedPathsStatic = pattern.results.size === pattern.resultKeys.size && Array.from(pattern.results.values()).every((result) => result.state === "static-candidate"); const allObservedPathsStaticallyGenerated = allObservedPathsStatic && Array.from(pattern.results.values()).every((result) => result.rendererStatic); - const hasStaticFallback = fallbackRoutes.has(pattern.key); + const allowsUnknown = isAppPage ? hasOnDemandIsr : allObservedPathsStaticallyGenerated; const soleGroup = pattern.groups.length === 1 ? pattern.groups[0] : null; const literalPatternNamesSolePath = soleGroup !== null && !/(^|\/):/.test(pattern.route.pattern) && normalizeCacheabilityRoutePathname(pattern.route.pattern) === soleGroup.routePathname; let route: CacheabilityManifestRoute; - if (literalPatternNamesSolePath) { + if (literalPatternNamesSolePath && !soleGroup.deferred && hasNoState(soleGroup)) { + if (!runtimeRepresentationPaths) continue; + route = { + kind: pattern.route.kind, + pattern: pattern.route.pattern, + runtimeRepresentation: "rsc-loading-shell", + state: "runtime-check", + }; + } else if (literalPatternNamesSolePath) { const result = pattern.results.get(soleGroup.resultKey); route = result?.state === "static-candidate" @@ -971,32 +1168,55 @@ export async function probeStagedWorkerCacheability(options: { pattern: pattern.route.pattern, state: "static-candidate", } - : result.rendererStatic - ? { - kind: pattern.route.kind, - pattern: pattern.route.pattern, - state: "runtime-check", - staticRepresentation: result.representation, - } - : { + : result.rendererStatic && isAppPage && result.representation === "html" + ? compactManifestRoutePaths({ kind: pattern.route.kind, pattern: pattern.route.pattern, state: "runtime-check", - } + staticPaths: Object.fromEntries( + staticRepresentations("html").map((representation) => [ + representation, + [soleGroup.routePathname], + ]), + ), + }) + : result.rendererStatic + ? { + kind: pattern.route.kind, + pattern: pattern.route.pattern, + state: "runtime-check", + staticRepresentation: result.representation, + } + : { + kind: pattern.route.kind, + pattern: pattern.route.pattern, + state: "runtime-check", + } : { kind: pattern.route.kind, pattern: pattern.route.pattern, state: "runtime-check", }; + } else if ( + isAppPage && + !hasOnDemandIsr && + runtimePathSet.size === 0 && + !runtimeRepresentationPaths && + Object.keys(staticPaths).length === 0 + ) { + // Without path lists or on-demand ISR, a runtime-check entry would admit + // every path of the route. + continue; } else { route = compactManifestRoutePaths({ kind: pattern.route.kind, pattern: pattern.route.pattern, state: "runtime-check", - ...(hasStaticFallback || allObservedPathsStaticallyGenerated + ...(hasStaticFallback || allowsUnknown ? { allowUnknown: true, unknownState: "static-candidate" as const } : {}), ...(runtimePathSet.size > 0 ? { runtimePaths: Array.from(runtimePathSet).sort() } : {}), + ...(runtimeRepresentationPaths ? { runtimeRepresentationPaths } : {}), ...(Object.keys(staticPaths).length > 0 ? { staticPaths } : {}), }); } diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 8a98f83d56..9565100488 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -213,7 +213,9 @@ function readPrerenderPathManifest(manifestPath: string): PrerenderPathManifest (route.cacheabilityProbe.routeMayResolve === undefined || typeof route.cacheabilityProbe.routeMayResolve === "boolean") && (route.cacheabilityProbe.requestStageMayTerminate === undefined || - typeof route.cacheabilityProbe.requestStageMayTerminate === "boolean"))), + typeof route.cacheabilityProbe.requestStageMayTerminate === "boolean") && + (route.cacheabilityProbe.unlisted === undefined || + typeof route.cacheabilityProbe.unlisted === "boolean"))), ))) || (manifest.loadingShellPaths !== undefined && (!Array.isArray(manifest.loadingShellPaths) || diff --git a/packages/cloudflare/tests/response-store-adapter.e2e.test.ts b/packages/cloudflare/tests/response-store-adapter.e2e.test.ts index b44de05236..0ebed6edb0 100644 --- a/packages/cloudflare/tests/response-store-adapter.e2e.test.ts +++ b/packages/cloudflare/tests/response-store-adapter.e2e.test.ts @@ -166,6 +166,202 @@ describe("Cloudflare Workers Response Store adapter", () => { } }); + test("stores a static page with no revalidate source until it is revalidated", async () => { + const first = await cacheStatus("/static-default"); + const second = await cacheStatus("/static-default"); + assert.equal(first.status, "MISS"); + assert.equal(second.status, "HIT"); + assert.equal( + htmlValue(second.body, "static-default-render-id"), + htmlValue(first.body, "static-default-render-id"), + ); + + const rscInit = { headers: { Accept: "text/x-component", RSC: "1" } }; + const firstRsc = await request("/static-default.rsc?_rsc=", rscInit); + await firstRsc.arrayBuffer(); + const secondRsc = await request("/static-default.rsc?_rsc=", rscInit); + await secondRsc.arrayBuffer(); + assert.equal(secondRsc.status, 200); + assert.equal(secondRsc.headers.get("x-vinext-cache"), "HIT"); + }); + + test("never stores a dynamic-segment route without generateStaticParams", async () => { + const first = await request("/dynamic-segment/a"); + const firstBody = await first.text(); + const second = await request("/dynamic-segment/a"); + const secondBody = await second.text(); + const rsc = await request("/dynamic-segment/a.rsc?_rsc=", { + headers: { Accept: "text/x-component", RSC: "1" }, + }); + await rsc.text(); + + for (const response of [first, second, rsc]) { + assert.equal(response.status, 200); + assert.notEqual(response.headers.get("x-vinext-cache"), "HIT"); + assert.equal( + response.headers.get("cache-control"), + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + } + assert.notEqual( + htmlValue(firstBody, "dynamic-segment-render-id"), + htmlValue(secondBody, "dynamic-segment-render-id"), + ); + assert.doesNotMatch(JSON.stringify((await metadataEntries()).flat()), /dynamic-segment/); + }); + + test("shares one App page entry across queries and recomposes RSC params and path", async () => { + const pathname = "/cached/query-identity"; + const [firstQuery, secondQuery] = [crypto.randomUUID(), crypto.randomUUID()]; + const first = await cacheStatus(`${pathname}?q=${firstQuery}`); + const second = await cacheStatus(`${pathname}?utm_source=${secondQuery}`); + assert.equal(first.status, "MISS"); + assert.equal(second.status, "HIT"); + assert.equal(htmlValue(second.body, "rendered-at"), htmlValue(first.body, "rendered-at")); + assert.doesNotMatch(second.body, new RegExp(firstQuery)); + + const rscInit = { headers: { Accept: "text/x-component", RSC: "1" } }; + const firstRsc = await request(`${pathname}?q=${firstQuery}&_rsc`, rscInit); + const firstRscBody = await firstRsc.text(); + const secondRsc = await request(`${pathname}?q=${secondQuery}&_rsc`, rscInit); + assert.equal(firstRsc.headers.get("x-vinext-cache"), "MISS"); + assert.equal(secondRsc.headers.get("x-vinext-cache"), "HIT"); + assert.equal(await secondRsc.text(), firstRscBody); + const params = encodeURIComponent(JSON.stringify({ slug: "query-identity" })); + for (const [response, query] of [ + [firstRsc, firstQuery], + [secondRsc, secondQuery], + ] as const) { + assert.equal(response.headers.get("x-vinext-params"), params); + assert.equal( + response.headers.get("x-vinext-rendered-path-and-search"), + encodeURIComponent(`${pathname}?q=${query}`), + ); + } + + const routeEntries = (await metadataEntries()) + .flat() + .map((entry) => JSON.stringify(entry)) + .filter((entry) => entry.includes('"vinext:response"') && entry.includes(pathname)); + assert.equal(routeEntries.length, 2, JSON.stringify(routeEntries)); + for (const entry of routeEntries) { + assert.doesNotMatch(entry, new RegExp(`${firstQuery}|${secondQuery}`)); + } + }); + + test("keeps the query out of a static page that reads useSearchParams() inside Suspense", async () => { + // One stored document serves every query, as Next.js prerenders it once. + const [firstQuery, secondQuery] = [crypto.randomUUID(), crypto.randomUUID()]; + const first = await request(`/search-params/suspense?q=${firstQuery}`); + const firstBody = await first.text(); + const second = await request(`/search-params/suspense?q=${secondQuery}`); + const secondBody = await second.text(); + + assert.equal(first.status, 200); + assert.equal(second.status, 200); + assert.equal(first.headers.get("x-vinext-cache"), "MISS"); + assert.equal(second.headers.get("x-vinext-cache"), "HIT"); + assert.equal( + htmlValue(secondBody, "search-suspense-render-id"), + htmlValue(firstBody, "search-suspense-render-id"), + ); + for (const body of [firstBody, secondBody]) { + // The server renders the fallback, and the browser reads the query. + assert.equal(htmlValue(body, "search-fallback"), "loading"); + assert.doesNotMatch(body, new RegExp(`${firstQuery}|${secondQuery}`)); + assert.match(body, /searchParamsFromBrowser:true/); + assert.match(body, /"searchParams":\[\]/); + } + }); + + test("fails a static page that reads useSearchParams() outside Suspense", async () => { + const url = `/search-params/unwrapped/on-demand?q=${crypto.randomUUID()}`; + for (let attempt = 0; attempt < 2; attempt++) { + const response = await request(url); + await response.text(); + assert.equal(response.status, 500); + assert.notEqual(response.headers.get("x-vinext-cache"), "HIT"); + } + assert.doesNotMatch(JSON.stringify((await metadataEntries()).flat()), /unwrapped/); + }); + + test("server-renders the real query on a page that turns dynamic", async () => { + const query = crypto.randomUUID(); + const url = `/search-params/dynamic?q=${query}`; + const first = await request(url); + const firstBody = await first.text(); + const second = await request(url); + const secondBody = await second.text(); + + for (const response of [first, second]) { + assert.equal(response.status, 200); + assert.notEqual(response.headers.get("x-vinext-cache"), "HIT"); + } + assert.equal(htmlValue(firstBody, "search-value"), query); + // The render is dynamic before the head is written, so the payload keeps + // the server's query instead of deferring to the browser URL. + assert.doesNotMatch(firstBody, /searchParamsFromBrowser:true/); + assert.match(firstBody, new RegExp(`"searchParams":\\[\\["q","${query}"\\]\\]`)); + assert.notEqual( + htmlValue(secondBody, "search-dynamic-render-id"), + htmlValue(firstBody, "search-dynamic-render-id"), + ); + }); + + test("stores a static client page that doesn't read searchParams", async () => { + const query = crypto.randomUUID(); + const url = `/client-search-params/ignores?q=${query}`; + const first = await cacheStatus(url); + const second = await cacheStatus(url); + + assert.equal(first.status, "MISS"); + assert.equal(second.status, "HIT"); + assert.equal( + htmlValue(second.body, "client-search-render-id"), + htmlValue(first.body, "client-search-render-id"), + ); + for (const { body } of [first, second]) { + assert.doesNotMatch(body, new RegExp(query)); + } + + // The page's searchParams no longer travel through Flight. + const rsc = await request(`/client-search-params/ignores.rsc?q=${query}&_rsc=`, { + headers: { Accept: "text/x-component", RSC: "1" }, + }); + assert.equal(rsc.status, 200); + assert.doesNotMatch(await rsc.text(), new RegExp(query)); + }); + + test("never stores a static client page that reads searchParams", async () => { + const query = crypto.randomUUID(); + const url = `/client-search-params/reads?q=${query}`; + const first = await request(url); + const firstBody = await first.text(); + const second = await request(url); + const secondBody = await second.text(); + const queryless = await request("/client-search-params/reads"); + const querylessBody = await queryless.text(); + const querylessAgain = await request("/client-search-params/reads"); + const querylessAgainBody = await querylessAgain.text(); + + for (const response of [first, second, queryless, querylessAgain]) { + assert.equal(response.status, 200); + assert.notEqual(response.headers.get("x-vinext-cache"), "HIT"); + } + // The read makes the render dynamic, so SSR renders the real query. + assert.equal(htmlValue(firstBody, "client-search-value"), query); + assert.equal(htmlValue(querylessBody, "client-search-value"), "(none)"); + assert.notEqual( + htmlValue(secondBody, "client-search-render-id"), + htmlValue(firstBody, "client-search-render-id"), + ); + assert.notEqual( + htmlValue(querylessAgainBody, "client-search-render-id"), + htmlValue(querylessBody, "client-search-render-id"), + ); + assert.doesNotMatch(JSON.stringify((await metadataEntries()).flat()), /client-search-params/); + }); + test("runs cold fills, hits, and SWR loopback in one Worker", async () => { const inline = new Miniflare({ unsafeEphemeralDurableObjects: true, @@ -251,7 +447,7 @@ describe("Cloudflare Workers Response Store adapter", () => { test("passes adapter sharding into the Response Store", async () => { await Promise.all( Array.from({ length: 16 }, async (_, index) => { - const response = await request(`/cached/local?shard=${index}`); + const response = await request(`/cached/shard-${index}`); assert.equal(response.status, 200); await response.arrayBuffer(); }), @@ -358,7 +554,7 @@ describe("Cloudflare Workers Response Store adapter", () => { assert.match(rsc.headers.get("content-type") ?? "", /^text\/x-component/); assert.ok((await rsc.arrayBuffer()).byteLength > 0); - const retryPath = `${pathname}?retry=1`; + const retryPath = "/cached/intro-retry"; const storedHtml = await request(retryPath); assert.equal(storedHtml.headers.get("x-vinext-cache"), "MISS"); await storedHtml.arrayBuffer(); @@ -369,7 +565,7 @@ describe("Cloudflare Workers Response Store adapter", () => { assert.equal(retriedWarmup.headers.get("x-vinext-cache"), "MISS"); await retriedWarmup.arrayBuffer(); - const repairedRsc = await request(`${retryPath}&_rsc`, { + const repairedRsc = await request(`${retryPath}?_rsc`, { headers: { Accept: "text/x-component", RSC: "1" }, }); assert.equal(repairedRsc.headers.get("x-vinext-cache"), "HIT"); @@ -456,6 +652,16 @@ describe("Cloudflare Workers Response Store adapter", () => { await Promise.all(responses.map((response) => response.arrayBuffer())); }); + test("sends Next.js's never-cache header for a render that is dynamic before headers", async () => { + // `connection()` at the top of the page. + const response = await request("/use-cache"); + await response.text(); + assert.equal( + response.headers.get("cache-control"), + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + test("keeps dynamic and unsupported Vary responses out of shared storage", async () => { const firstDynamic = await cacheStatus("/dynamic"); const secondDynamic = await cacheStatus("/dynamic"); diff --git a/packages/vinext/src/build/client-build-config.ts b/packages/vinext/src/build/client-build-config.ts index 2d2f1c501f..f6560d6f31 100644 --- a/packages/vinext/src/build/client-build-config.ts +++ b/packages/vinext/src/build/client-build-config.ts @@ -339,7 +339,8 @@ export function createMultiStageCodeSplittingConfig( /** * Keep router stage chunks beside the server entry so their generated - * `./vinext-client-assets.js` external continues to resolve. Other chunks keep + * `./vinext-client-assets.js` and deploy-filled cacheability externals continue + * to resolve. Other chunks keep * the host's existing output pattern (or vinext's server-assets default). */ export function createMultiStageChunkFileNames( @@ -359,6 +360,7 @@ export function createMultiStageChunkFileNames( "pages-request-stage-entry", "pages-response-stage-entry", "virtual_vinext-rsc-entry", + "virtual_vinext-request-stage", "virtual_vinext-response-stage", ].some((entryName) => name.includes(entryName)) ) { diff --git a/packages/vinext/src/build/layout-classification.ts b/packages/vinext/src/build/layout-classification.ts index d1b4c9a533..2b3a6b8c43 100644 --- a/packages/vinext/src/build/layout-classification.ts +++ b/packages/vinext/src/build/layout-classification.ts @@ -15,7 +15,7 @@ import { classifyLayoutSegmentConfig } from "./report.js"; import { AppElementsWire } from "../server/app-elements.js"; -import { createAppPageTreePath } from "../server/app-page-route-wiring.js"; +import { createAppPageTreePath } from "../server/app-page-params.js"; import type { ClassificationReason, LayoutBuildClassification, diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 85d3e0d737..3db53ce4f4 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -11,6 +11,7 @@ import { appRouteHasMainTreeLoadingBoundary, appRouter, matchAppRoute, + type AppRoute, } from "../routing/app-router.js"; import { apiRouter, matchRoute, pagesRouter } from "../routing/pages-router.js"; import { @@ -23,12 +24,15 @@ import { classifyAppRoute, classifyAppRouteHandler, classifyPagesRoute, + extractExportConstNumber, extractExportConstString, extractMiddlewareMatcherConfig, + hasRuntimeExportedName, } from "./report.js"; import { buildUrlFromParams, resolveParentParams, type StaticParamsMap } from "./prerender.js"; import { readPrerenderSecret } from "./server-manifest.js"; import { startProdServer } from "../server/prod-server.js"; +import { loadMdxEsmReader } from "../utils/mdx-scan.js"; import { findDir } from "../utils/project.js"; import { BLOCKED_PAGES, PHASE_PRODUCTION_BUILD } from "vinext/shims/constants"; import { VINEXT_PRERENDER_SECRET_HEADER } from "../server/headers.js"; @@ -37,7 +41,15 @@ import { enterPrerenderPhase } from "./prerender-phase.js"; import type { CdnCacheAdapterCapabilities } from "../cache/cache-adapters-virtual.js"; import { isExternalUrl, matchHeaders, matchesRewriteSource } from "../config/config-matchers.js"; import { pagesRouteHasPriorityOverAppRoute } from "../server/hybrid-route-priority.js"; -import { resolveAppPageDynamicConfig } from "../server/app-segment-config.js"; +import { + collectAppPageStaticGenerationRuntimes, + hasAppPageGenerateStaticParamsAtLastDynamicSegment, + isAppPageStaticEligible, + isEdgeRuntime, + resolveAppPageDynamicConfig, + resolveAppPageSegmentConfig, + resolveAppPageStaticGenerationRuntime, +} from "../server/app-segment-config.js"; import { extractLocaleFromUrl, normalizeDefaultLocalePathname } from "../server/pages-i18n.js"; import { normalizePathTrailingSlash } from "vinext/shims/url-utils"; import { buildPagesDataHref } from "vinext/shims/internal/pages-data-url"; @@ -60,6 +72,14 @@ export type PrerenderRoutePattern = { routeMayResolve?: boolean; /** A request representation may terminate before reaching the response stage. */ requestStageMayTerminate?: boolean; + /** + * Not listed by the route's own static generation (`generateStaticParams`, + * `getStaticPaths` or a route without dynamic segments): picked from + * traffic, or, for an App page route, not listed by this route itself, + * which includes a route that isn't static or SSG and a path another + * route generates. A path both listed and picked counts as listed. + */ + unlisted?: boolean; }; }; export type PrerenderPathManifest = { @@ -724,6 +744,100 @@ function extractPagesStaticPathLocale( return { explicitLocalePrefix: parts[0], locale, url: `${rest || "/"}${query}` }; } +/** + * Whether Next.js classifies an App page route as static or SSG, read from its + * layout, page and parallel-slot sources with the helpers dispatch applies to + * the loaded modules. Only such a route has listed paths. + * + * An MDX source read without the MDX parser has unknown exports, so its route + * is "unreadable". Its paths are still listed: the built runtime reads the real + * exports and never stores a route that isn't static, while an unlisted path's + * render failure would be dropped instead of failing. But no probe renders an + * unreadable route's fallback, so it never certifies one. + */ +function classifyAppPageRouteStaticEligibility( + route: AppRoute, + readMdxEsm: ((source: string) => string) | null, +): "eligible" | "ineligible" | "unreadable" { + let unreadable = false; + const readSegmentConfig = (filePath: string | null | undefined) => { + if (!filePath) return null; + let code = fs.readFileSync(filePath, "utf8"); + if (filePath.toLowerCase().endsWith(".mdx")) { + let esm: string | null = null; + try { + esm = readMdxEsm?.(code) ?? null; + } catch { + // A source MDX can't parse can't build either. + } + unreadable ||= esm === null; + code = esm ?? ""; + } + const dynamic = extractExportConstString(code, "dynamic"); + const revalidate = extractExportConstNumber(code, "revalidate"); + const runtime = extractExportConstString(code, "runtime"); + return { + ...(dynamic === null ? {} : { dynamic }), + ...(hasRuntimeExportedName(code, "generateStaticParams") + ? { generateStaticParams() {} } + : {}), + ...(revalidate === null ? {} : { revalidate }), + ...(runtime === null ? {} : { runtime }), + }; + }; + const layouts = route.layouts.map(readSegmentConfig); + const page = readSegmentConfig(route.pagePath); + const parallelBranches = route.parallelSlots.map((slot) => ({ + configLayouts: (slot.configLayoutPaths ?? []).map(readSegmentConfig), + configLayoutTreePositions: slot.configLayoutTreePositions ?? [], + isDefault: !slot.pagePath, + layout: readSegmentConfig(slot.layoutPath), + name: slot.name, + ownerTreePosition: slot.ownerTreePosition ?? null, + page: readSegmentConfig(slot.pagePath ?? slot.defaultPath), + routeSegments: slot.routeSegments, + })); + if (unreadable) return "unreadable"; + const segmentConfig = resolveAppPageSegmentConfig({ + layouts, + layoutTreePositions: route.layoutTreePositions, + page, + parallelBranches, + routeSegments: route.routeSegments, + }); + const eligible = isAppPageStaticEligible({ + dynamicConfig: segmentConfig.dynamicConfig, + hasGenerateStaticParams: hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + childrenSlot: route.childrenSlot ?? null, + layouts, + layoutTreePositions: route.layoutTreePositions, + page, + parallelBranches, + routeSegments: route.routeSegments, + }), + isDynamicRoute: route.isDynamic, + isStaticGenerationEdgeRuntime: isEdgeRuntime( + resolveAppPageStaticGenerationRuntime( + collectAppPageStaticGenerationRuntimes({ + childrenSlot: route.childrenSlot ?? null, + layouts, + layoutTreePositions: route.layoutTreePositions, + page, + parallelBranches, + routeSegments: route.routeSegments, + }), + ), + ), + revalidateSeconds: segmentConfig.revalidateSeconds, + }); + return eligible ? "eligible" : "ineligible"; +} + +/** Keys a path an App page route lists by the route and the pathname together. */ +function appPageListingKey(routePattern: string, pathname: string): string { + return `${routePattern}\0${pathname}`; +} + async function collectAppPaths(options: { appDir: string; baseUrl: string | null; @@ -731,12 +845,18 @@ async function collectAppPaths(options: { enumerateDynamicPaths: boolean; pageExtensions: readonly string[]; retryOptions?: PathDiscoveryRetryOptions; + root: string; secretHeaders: Record; }): Promise<{ fallbackRoutePatterns: PrerenderRoutePattern[]; nonDynamicPaths: string[]; paths: string[]; routeHandlerPaths: string[]; + /** + * Keys (`appPageListingKey`) of the paths each static or SSG App page route + * lists in its own static generation. + */ + listedRoutePaths: Set; }> { const routes = await appRouter(options.appDir, options.pageExtensions); const paths: string[] = []; @@ -746,6 +866,7 @@ async function collectAppPaths(options: { const fallbackRoutePatterns: PrerenderRoutePattern[] = []; const nonDynamicPaths: string[] = []; const seenNonDynamicPaths = new Set(); + const listedRoutePaths = new Set(); const staticParamsCache = new Map[] | null>>(); let requireNonEmptyStaticParams = false; const staticParamsMap = new Proxy({} as StaticParamsMap, { @@ -802,6 +923,10 @@ async function collectAppPaths(options: { }, }); + const readMdxEsm = + !options.cacheComponents && options.pageExtensions.includes("mdx") + ? await loadMdxEsmReader(options.root) + : null; for (const route of routes) { const isRouteHandler = route.routePath !== null && route.pagePath === null; const renderEntryPath = isRouteHandler ? route.routePath : getAppRouteRenderEntryPath(route); @@ -813,6 +938,16 @@ async function collectAppPaths(options: { const { type } = classifyAppRoute(renderEntryPath, route.routePath, route.isDynamic); if (type === "api") continue; } + // Next.js's build lists paths only for a static or SSG page route, and + // renders each under the route that generated it. Paths discovered for any + // other route, for example through a sibling page's generateStaticParams, + // stay warm paths but aren't listed. A cacheComponents build keeps every + // page route eligible, as dispatch does. + const staticEligibility = + isRouteHandler || options.cacheComponents + ? "eligible" + : classifyAppPageRouteStaticEligibility(route, readMdxEsm); + const isStaticEligible = staticEligibility !== "ineligible"; const addDiscoveredPath = (pathname: string): void => { if (isRouteHandler) { @@ -820,6 +955,7 @@ async function collectAppPaths(options: { return; } addPath(paths, seen, pathname); + if (isStaticEligible) listedRoutePaths.add(appPageListingKey(route.pattern, pathname)); }; if (!route.isDynamic) { @@ -909,7 +1045,7 @@ async function collectAppPaths(options: { }); const hasStaticFallback = paramSets !== null || dynamicConfig === "force-static" || dynamicConfig === "error"; - if (hasStaticFallback && !hasDynamicSegment) { + if (hasStaticFallback && !hasDynamicSegment && staticEligibility === "eligible") { fallbackRoutePatterns.push({ kind: "app-page", pattern: route.pattern }); } continue; @@ -929,6 +1065,7 @@ async function collectAppPaths(options: { nonDynamicPaths, paths, routeHandlerPaths, + listedRoutePaths, }; } @@ -1120,6 +1257,7 @@ function annotateCacheabilityProbeSafety( config: Pick, routeMayResolve: ReadonlySet, requestStageMayTerminate: ReadonlySet, + unlisted: ReadonlySet, isResponsePolicyHeader: (name: string) => boolean, ): Record { const cachePolicyRules = config.headers.filter((rule) => @@ -1171,6 +1309,7 @@ function annotateCacheabilityProbeSafety( canPrunePattern, ...(routeMayResolve.has(pathname) ? { routeMayResolve: true } : {}), ...(requestStageMayTerminate.has(pathname) ? { requestStageMayTerminate: true } : {}), + ...(unlisted.has(pathname) ? { unlisted: true } : {}), }, }, ]; @@ -1332,6 +1471,8 @@ export async function discoverPrerenderPathManifest( const discoveredRouteHandlerPaths: string[] = []; const seenRouteHandlerPaths = new Set(); const discoveredNonDynamicPathSet = new Set(); + const appListedRoutePaths = new Set(); + const candidateOnlyPathSet = new Set(); const fallbackRoutePatterns: PrerenderRoutePattern[] = []; await withPrerenderEndpoints(async () => { let prodServer: { server: HttpServer; port: number } | null = null; @@ -1397,6 +1538,7 @@ export async function discoverPrerenderPathManifest( enumerateDynamicPaths: options.candidatePathsOnly !== true, pageExtensions: config.pageExtensions, retryOptions: pathDiscoveryRetryOptions, + root, secretHeaders, }); for (const pathname of appPathResult.paths) { @@ -1409,6 +1551,7 @@ export async function discoverPrerenderPathManifest( discoveredNonDynamicPathSet.add(pathname); } fallbackRoutePatterns.push(...appPathResult.fallbackRoutePatterns); + for (const key of appPathResult.listedRoutePaths) appListedRoutePaths.add(key); } if (pagesDir) { @@ -1451,6 +1594,7 @@ export async function discoverPrerenderPathManifest( pathname = pathname.slice(config.basePath.length); else continue; } + if (!seen.has(pathname)) candidateOnlyPathSet.add(pathname); addPath(paths, seen, pathname); if (pagesDir) addPath(discoveredPagesPaths, seenPagesPaths, pathname); } @@ -1591,11 +1735,28 @@ export async function discoverPrerenderPathManifest( "", ), ); + // A path's listing belongs to the route that owns it at runtime. A path + // another route generates, for example a catch-all generating a path a more + // specific route owns, is build-rendered under the generating route, never + // under its owner. Pages pages and Route Handlers keep a traffic-picked path + // unlisted. + const unlistedPathSet = new Set( + Object.entries(appOwnedWarmPaths.routePatterns).flatMap(([pathname, route]) => + ( + route.kind === "app-page" + ? !appListedRoutePaths.has(appPageListingKey(route.pattern, pathname)) + : candidateOnlyPathSet.has(pathname) + ) + ? [pathname] + : [], + ), + ); const routePatterns = annotateCacheabilityProbeSafety( appOwnedWarmPaths.routePatterns, config, routeMayResolveWarmPathSet, requestStageMayTerminateWarmPathSet, + unlistedPathSet, (name) => name.trim().toLowerCase() === "cache-control" || options.isResponsePolicyHeader?.(name) === true, diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 6a849fe7a1..35a6c324f3 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -162,6 +162,15 @@ export function hasExportedName(code: string, name: string): boolean { return false; } +/** + * Whether the module's runtime value exports include the given name. Type-only + * exports and re-export aliases under another name don't count. + */ +export function hasRuntimeExportedName(code: string, name: string): boolean { + const program = parseRouteModule(code); + return program !== null && hasRuntimeExportedNameInProgram(program, name); +} + function hasNamedExportInProgram(program: Program, name: string): boolean { for (const node of program.body) { if (node.type !== "ExportNamedDeclaration") continue; diff --git a/packages/vinext/src/client/navigation-runtime.ts b/packages/vinext/src/client/navigation-runtime.ts index 00eb1a829a..40c4c3980f 100644 --- a/packages/vinext/src/client/navigation-runtime.ts +++ b/packages/vinext/src/client/navigation-runtime.ts @@ -16,6 +16,11 @@ export type NavigationRuntimeRscBootstrap = { searchParamsFromBrowser?: boolean; nav?: NavigationRuntimeSnapshot; params?: Record; + /** + * The query SSR rendered client pages with, sent after the head when the + * render turned dynamic too late for `nav` to carry it. + */ + renderedSearch?: string; rsc: NavigationRuntimeRscChunk[]; /** * Client reuse bound in seconds resolved from the initial render's completed @@ -188,6 +193,7 @@ function isNavigationRuntimeRscBootstrap(value: unknown): value is NavigationRun const searchParamsFromBrowser = Reflect.get(value, "searchParamsFromBrowser"); const nav = Reflect.get(value, "nav"); const params = Reflect.get(value, "params"); + const renderedSearch = Reflect.get(value, "renderedSearch"); const rsc = Reflect.get(value, "rsc"); const staleTimeSeconds = Reflect.get(value, "staleTimeSeconds"); // getNavigationRuntime() runs at bootstrap/read boundaries, not per chunk. @@ -202,6 +208,7 @@ function isNavigationRuntimeRscBootstrap(value: unknown): value is NavigationRun (searchParamsFromBrowser === undefined || typeof searchParamsFromBrowser === "boolean") && (nav === undefined || isNavigationRuntimeSnapshot(nav)) && (params === undefined || isNavigationRuntimeParams(params)) && + (renderedSearch === undefined || typeof renderedSearch === "string") && Array.isArray(rsc) && rsc.every(isNavigationRuntimeRscChunk) && isOptionalStaleTimeSeconds(staleTimeSeconds) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index a4a176d2b2..dc74a2d529 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -361,6 +361,7 @@ export function generateAppRequestRscEntry( return ` import ${JSON.stringify(serverGlobalsPath)}; import { createAppRscRequestHandler } from "vinext/server/app-rsc-handler"; +import __cacheabilityRequestProjection from "virtual:vinext-cacheability-request-projection"; import { createAppRscRouteMatcher as __createAppRscRouteMatcher } from ${JSON.stringify(appRscRouteMatchingPath)}; import { dispatchAppRequestStage as __dispatchAppRequestStage } from ${JSON.stringify(appRequestStageDispatchPath)}; import { registerConfiguredCacheAdapters as __registerConfiguredCacheAdapters } from "virtual:vinext-cdn-cache-adapter"; @@ -466,6 +467,7 @@ ${generateDevOriginCheckCode(config?.allowedDevOrigins)} const __requestHandler = createAppRscRequestHandler({ basePath: __basePath, buildId: process.env.__VINEXT_BUILD_ID ?? null, + cacheabilityRequestProjection: __cacheabilityRequestProjection, clearRequestContext: __clearRequestContext, configHeaders: ${JSON.stringify(config?.headers ?? [])}, configRedirects: ${JSON.stringify(config?.redirects ?? [])}, @@ -818,8 +820,15 @@ import { probeAppPageLayoutWithTracking as __probeAppPageLayoutWithTracking, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from ${JSON.stringify(appPageRouteWiringPath)}; -import { buildPageElements as __buildPageElements } from ${JSON.stringify(appPageElementBuilderPath)}; -import { buildAppPageProbes as __buildAppPageProbes } from ${JSON.stringify(appPageProbePath)}; +import { + buildPageElements as __buildPageElements, + resolveSlotParamOverrides as __resolveSlotParamOverrides, +} from ${JSON.stringify(appPageElementBuilderPath)}; +import { + buildAppPageInterceptSourceProbes as __buildAppPageInterceptSourceProbes, + buildAppPageProbes as __buildAppPageProbes, + resolveAppPageProbeIntercept as __resolveAppPageProbeIntercept, +} from ${JSON.stringify(appPageProbePath)}; import { dispatchAppPage as __dispatchAppPage, } from ${JSON.stringify(appPageDispatchPath)}; @@ -835,9 +844,18 @@ import { resolveAppPageGenerateStaticParamsSources as __resolveAppPageGenerateStaticParamsSources, } from ${JSON.stringify(appPageRequestPath)}; import { + collectAppPageStaticGenerationRuntimes as __collectAppPageStaticGenerationRuntimes, + hasAppPageAnyGenerateStaticParams as __hasAppPageAnyGenerateStaticParams, + hasAppPageGenerateStaticParamsAtLastDynamicSegment as __hasAppPageGenerateStaticParamsAtLastDynamicSegment, + isAppPageInterceptTargetDynamic as __isAppPageInterceptTargetDynamic, + isAppPageInterceptAttached as __isAppPageInterceptAttached, + isAppPageStaticEligible as __isAppPageStaticEligible, isEdgeRuntime as __isEdgeRuntime, resolveAppPageFetchCacheMode as __resolveAppPageFetchCacheMode, + resolveAppPageInterceptSegmentConfig as __resolveAppPageInterceptSegmentConfig, + resolveAppPageInterceptTree as __resolveAppPageInterceptTree, resolveAppPageSegmentConfig as __resolveAppPageSegmentConfig, + resolveAppPageStaticGenerationRuntime as __resolveAppPageStaticGenerationRuntime, } from ${JSON.stringify(appSegmentConfigPath)}; import { makeThenableParams } from ${JSON.stringify(thenableParamsShimPath)}; import { @@ -876,7 +894,7 @@ import { suppressHookWarningAls } from ${JSON.stringify(appHookWarningSuppressio import { clearAppRequestContext as __clearRequestContext, setAppNavigationContext as setNavigationContext } from ${JSON.stringify(appRequestContextPath)}; __configureMemoryCacheHandler({ cacheMaxMemorySize: ${JSON.stringify(cacheMaxMemorySize)} }); import { createAppPrerenderStaticParamsResolver as __createAppPrerenderStaticParamsResolver } from ${JSON.stringify(appPrerenderStaticParamsPath)}; -import { ensureAppRouteModulesLoaded as __ensureRouteLoaded, loadAppInterceptPage as __loadAppInterceptPage } from ${JSON.stringify(appRouteModuleLoaderPath)}; +import { ensureAppRouteModulesLoaded as __ensureRouteLoaded, loadAppInterceptLayouts as __loadAppInterceptLayouts, loadAppInterceptPage as __loadAppInterceptPage } from ${JSON.stringify(appRouteModuleLoaderPath)}; import { getRenderedConcreteUrlPathsForRoute as __getRenderedConcreteUrlPathsForRoute, initPregeneratedPathsFromGlobals as __initPregeneratedPathsFromGlobals, @@ -914,7 +932,12 @@ const __classDebug = process.env.VINEXT_DEBUG_CLASSIFICATION } : undefined; -function __resolveRouteFetchCacheMode(route) { +// With an intercept, the tree a direct intercepted RSC response renders, from +// __resolveRouteInterceptSegmentConfig. +function __resolveRouteFetchCacheMode(route, intercept) { + if (intercept) { + return __resolveRouteInterceptSegmentConfig(route, intercept).fetchCache ?? null; + } return __resolveAppPageFetchCacheMode({ layouts: route.layouts, page: route.page, @@ -926,7 +949,12 @@ function __resolveRouteFetchCacheMode(route) { }); } -function __resolveRouteDynamicConfig(route) { +// With an intercept, the tree a direct intercepted RSC response renders, from +// __resolveRouteInterceptSegmentConfig. +function __resolveRouteDynamicConfig(route, intercept) { + if (intercept) { + return __resolveRouteInterceptSegmentConfig(route, intercept).dynamicConfig ?? null; + } return __resolveAppPageSegmentConfig({ layouts: route.layouts, page: route.page, @@ -938,7 +966,12 @@ function __resolveRouteDynamicConfig(route) { }).dynamicConfig ?? null; } -function __resolveRouteRevalidateSeconds(route) { +// With an intercept, the tree a direct intercepted RSC response renders, from +// __resolveRouteInterceptSegmentConfig. +function __resolveRouteRevalidateSeconds(route, intercept) { + if (intercept) { + return __resolveRouteInterceptSegmentConfig(route, intercept).revalidateSeconds; + } return __resolveAppPageSegmentConfig({ layouts: route.layouts, page: route.page, @@ -950,6 +983,16 @@ function __resolveRouteRevalidateSeconds(route) { }).revalidateSeconds; } +// With an intercept, the tree a direct intercepted RSC response renders, from +// __resolveRouteInterceptSegmentConfig. +function __resolveRouteDynamicStaleTimeSeconds(route, intercept) { + if (intercept) { + return __resolveRouteInterceptSegmentConfig(route, intercept).dynamicStaleTimeSeconds; + } + return __resolveRouteSegmentConfig(route, __resolveRouteSegmentConfigBranches(route)) + .dynamicStaleTimeSeconds; +} + function __resolveRouteRuntime(route) { return __resolveAppPageSegmentConfig({ layouts: route.layouts, @@ -962,6 +1005,221 @@ function __resolveRouteRuntime(route) { }).runtime ?? null; } +function __resolveRouteSegmentConfigBranches(route) { + return Object.values(route.slots ?? {}).map((slot) => ({ + layout: slot.layout, + configLayouts: slot.configLayouts, + configLayoutTreePositions: slot.configLayoutTreePositions, + default: slot.default, + isDefault: !slot.page, + name: slot.name, + ownerTreePosition: slot.ownerTreePosition, + page: slot.page ?? slot.default, + routeSegments: slot.routeSegments, + })); +} + +function __resolveRouteSegmentConfig(route, segmentConfigBranches) { + return __resolveAppPageSegmentConfig({ + layouts: route.layouts, + layoutTreePositions: route.layoutTreePositions, + page: route.page, + parallelBranches: segmentConfigBranches, + parallelPages: Object.values(route.slots ?? {}).map((slot) => slot.page ?? slot.default), + routeSegments: route.routeSegments, + }); +} + +// The parts of a route's static generation classification that come from its +// module tree rather than its effective segment config. +function __resolveRouteStaticGeneration(route, segmentConfigBranches) { + return { + hasGenerateStaticParams: __hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + childrenSlot: route.childrenSlot, + layouts: route.layouts, + layoutTreePositions: route.layoutTreePositions, + page: route.page, + parallelBranches: segmentConfigBranches, + routeSegments: route.routeSegments, + }), + isStaticGenerationEdgeRuntime: __isEdgeRuntime( + __resolveAppPageStaticGenerationRuntime( + __collectAppPageStaticGenerationRuntimes({ + childrenSlot: route.childrenSlot, + layouts: route.layouts, + layoutTreePositions: route.layoutTreePositions, + page: route.page, + parallelBranches: segmentConfigBranches, + routeSegments: route.routeSegments, + }), + ), + ), + }; +} + +// The tree a direct intercepted RSC response renders: the source route with +// the intercepting branch in the intercepted slot, or in place of the source's +// page for a sibling-page intercept. +function __resolveRouteInterceptTree(route, intercept, keepActiveSiblings = false) { + const tree = __resolveAppPageInterceptTree( + __resolveRouteInterceptTreeOptions(route, intercept, keepActiveSiblings), + ); + return { branches: tree.parallelBranches, route: { ...route, ...tree } }; +} + +function __resolveRouteInterceptTreeOptions(route, intercept, keepActiveSiblings = false) { + return { + childrenSlot: route.childrenSlot, + interceptBranchSegments: intercept.interceptBranchSegments, + interceptLayoutSegments: intercept.interceptLayoutSegments, + interceptLayouts: intercept.interceptLayouts, + interceptOwnerDefault: intercept.interceptOwnerDefault, + interceptPage: intercept.interceptPage, + isSiblingPageIntercept: intercept.interceptSlotKey === __SIBLING_PAGE_INTERCEPT_SLOT_KEY, + keepActiveSiblings, + layouts: route.layouts, + layoutTreePositions: route.layoutTreePositions, + page: route.page, + parallelBranches: __resolveRouteSegmentConfigBranches(route), + routeSegments: route.routeSegments, + slotIndex: Object.keys(route.slots ?? {}).indexOf(intercept.interceptSlotKey), + }; +} + +// The segment config of a direct intercepted RSC response: Next.js's tree for +// the intercepting route, which swaps the other slots, and a slot intercept's +// children, for their defaults, merged with the active pages vinext renders +// in their place, so the render and its cache entry cover both. +function __resolveRouteInterceptSegmentConfig(route, intercept) { + const [interceptTree, renderedTree] = [false, true].map((keepActiveSiblings) => { + const tree = __resolveRouteInterceptTree(route, intercept, keepActiveSiblings); + return { + layouts: tree.route.layouts, + page: tree.route.page, + parallelBranches: tree.branches, + }; + }); + return __resolveAppPageInterceptSegmentConfig(interceptTree, renderedTree); +} + +// Whether Next.js classifies a route as static or SSG, from the same inputs +// dispatch reads for the matched route. With an intercept, the route is the +// source a direct intercepted RSC response renders, and the intercepting +// branch takes the intercepted slot, or the source's page for a sibling-page +// intercept: Next.js classifies the intercepting route's own tree, which is +// dynamic when the route it intercepts, built from its folders, is. Next.js's tree +// swaps the other slots, and a slot intercept's children, for their defaults, +// but vinext renders their active pages, so both trees must be static. A slot +// intercept whose slot the source lacks renders the source unchanged, so the +// source alone classifies it. +function __resolveRouteStaticEligible(route, intercept) { + if (!intercept) return __isRouteTreeStaticEligible(route, null, null); + if (!__isAppPageInterceptAttached(__resolveRouteInterceptTreeOptions(route, intercept))) { + return __isRouteTreeStaticEligible(route, null, null); + } + return [false, true].every((keepActiveSiblings) => + __isRouteTreeStaticEligible( + route, + __resolveRouteInterceptTree(route, intercept, keepActiveSiblings), + intercept, + ), + ); +} + +// Whether any segment of the tree a direct intercepted RSC response renders +// exports generateStaticParams, which sets that tree's revalidate default like +// the matched route's hasAnyGenerateStaticParams. Next.js calls the generators +// of the intercepting route's own tree, not those of the active sibling pages +// vinext renders where that tree has their defaults. +function __resolveRouteHasAnyGenerateStaticParams(route, intercept) { + const tree = __resolveRouteInterceptTree(route, intercept); + return __hasAppPageAnyGenerateStaticParams({ + childrenSlot: tree.route.childrenSlot, + layouts: tree.route.layouts, + layoutTreePositions: tree.route.layoutTreePositions, + page: tree.route.page, + parallelBranches: tree.branches, + routeSegments: tree.route.routeSegments, + }); +} + +// The segment config of the intercepting route's own tree, for the +// generated-param gate of a direct intercepted RSC response. Next.js builds +// that route's prerender manifest entry from its own loader tree, not the +// branch it replaces or the active sibling pages vinext renders where that +// tree has their defaults. +function __resolveRouteInterceptTreeSegmentConfig(route, intercept) { + const tree = __resolveRouteInterceptTree(route, intercept); + return __resolveAppPageSegmentConfig({ + layouts: tree.route.layouts, + layoutTreePositions: tree.route.layoutTreePositions, + page: tree.route.page, + parallelBranches: tree.branches, + routeSegments: tree.route.routeSegments, + }); +} + +// Only a segment of the intercepting route's own tree turns its fallback off. +function __resolveRouteDynamicParamsConfig(route, intercept) { + return __resolveRouteInterceptTreeSegmentConfig(route, intercept).dynamicParamsConfig; +} + +// Only the intercepting route's own force-dynamic leaves it out of the +// prerender manifest, and so out of the generated-param gate, in production. +function __resolveRouteInterceptTreeDynamicConfig(route, intercept) { + return __resolveRouteInterceptTreeSegmentConfig(route, intercept).dynamicConfig ?? null; +} + +// The generateStaticParams sources a route's generated-param gate checks. +// With an intercept, those of the tree a direct intercepted RSC response +// renders: Next.js calls the generators of the intercepting route's own tree. +// A slot intercept whose slot the source lacks renders the source unchanged. +function __resolveRouteGenerateStaticParams(route, intercept) { + if ( + intercept && + __isAppPageInterceptAttached(__resolveRouteInterceptTreeOptions(route, intercept)) + ) { + const tree = __resolveRouteInterceptTree(route, intercept); + return __resolveAppPageGenerateStaticParamsSources({ + layouts: tree.route.layouts, + layoutTreePositions: tree.route.layoutTreePositions, + page: tree.route.page, + parallelBranches: tree.branches, + routeSegments: tree.route.routeSegments, + }); + } + return __resolveAppPageGenerateStaticParamsSources({ + layouts: route.layouts, + layoutTreePositions: route.layoutTreePositions, + page: route.page, + parallelBranches: Object.values(route.slots ?? {}).map((slot) => ({ + layout: slot.layout, + configLayouts: slot.configLayouts, + configLayoutTreePositions: slot.configLayoutTreePositions, + page: slot.page ?? slot.default, + paramNames: slot.slotParamNames, + patternParts: slot.slotPatternParts, + routeSegments: slot.routeSegments, + })), + routePatternParts: route.patternParts, + routeSegments: route.routeSegments, + }); +} + +function __isRouteTreeStaticEligible(route, tree, intercept) { + const effectiveRoute = tree?.route ?? route; + const segmentConfigBranches = tree?.branches ?? __resolveRouteSegmentConfigBranches(route); + const segmentConfig = __resolveRouteSegmentConfig(effectiveRoute, segmentConfigBranches); + return __isAppPageStaticEligible({ + ...__resolveRouteStaticGeneration(effectiveRoute, segmentConfigBranches), + dynamicConfig: segmentConfig.dynamicConfig, + isDynamicRoute: intercept + ? __isAppPageInterceptTargetDynamic(intercept.interceptTargetPatternParts) + : route.isDynamic, + revalidateSeconds: segmentConfig.revalidateSeconds, + }); +} + ${imports.join("\n")} ${ @@ -1269,36 +1527,10 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle renderMode, }) { const PageComponent = route.page?.default; - const __segmentConfig = __resolveAppPageSegmentConfig({ - layouts: route.layouts, - layoutTreePositions: route.layoutTreePositions, - page: route.page, - parallelBranches: Object.values(route.slots ?? {}).map((slot) => ({ - layout: slot.layout, - configLayouts: slot.configLayouts, - configLayoutTreePositions: slot.configLayoutTreePositions, - page: slot.page ?? slot.default, - routeSegments: slot.routeSegments, - })), - parallelPages: Object.values(route.slots ?? {}).map((slot) => slot.page ?? slot.default), - routeSegments: route.routeSegments, - }); - const __generateStaticParams = __resolveAppPageGenerateStaticParamsSources({ - layouts: route.layouts, - layoutTreePositions: route.layoutTreePositions, - page: route.page, - parallelBranches: Object.values(route.slots ?? {}).map((slot) => ({ - layout: slot.layout, - configLayouts: slot.configLayouts, - configLayoutTreePositions: slot.configLayoutTreePositions, - page: slot.page ?? slot.default, - paramNames: slot.slotParamNames, - patternParts: slot.slotPatternParts, - routeSegments: slot.routeSegments, - })), - routePatternParts: route.patternParts, - routeSegments: route.routeSegments, - }); + const __segmentConfigBranches = __resolveRouteSegmentConfigBranches(route); + const __segmentConfig = __resolveRouteSegmentConfig(route, __segmentConfigBranches); + const __staticGeneration = __resolveRouteStaticGeneration(route, __segmentConfigBranches); + const __generateStaticParams = __resolveRouteGenerateStaticParams(route); const _asyncRouteParams = makeThenableParams(params); return __dispatchAppPage({ basePath: __basePath, @@ -1316,6 +1548,7 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle renderMode, observeMetadataSearchParamsAccess: buildOptions?.observeMetadataSearchParamsAccess === true, observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess === true, + isForceStatic: buildOptions?.isForceStatic === true, serveStreamingMetadata: buildOptions?.serveStreamingMetadata, isProduction: process.env.NODE_ENV === "production", }, layoutParamAccess, displayPathname, scriptNonce); @@ -1336,6 +1569,7 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle dynamicParamsConfig: __segmentConfig.dynamicParamsConfig, fetchCache: __segmentConfig.fetchCache ?? null, isEdgeRuntime: __isEdgeRuntime(__segmentConfig.runtime), + isStaticGenerationEdgeRuntime: __staticGeneration.isStaticGenerationEdgeRuntime, findIntercept(pathname) { return findIntercept( pathname === cleanPathname ? interceptionPathname : pathname, @@ -1352,7 +1586,8 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle return routes[sourceRouteIndex]; }, hasCustomGlobalError: ${globalErrorVar ? `Boolean(${globalErrorVar}?.default)` : "false"}, - hasGenerateStaticParams: __generateStaticParams.length > 0, + hasAnyGenerateStaticParams: __generateStaticParams.length > 0, + hasGenerateStaticParams: __staticGeneration.hasGenerateStaticParams, hasPageDefaultExport: !!PageComponent, hasPageModule: !!route.page, handlerStart, @@ -1403,15 +1638,16 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle }); }, async probePage(probeSearchParams = searchParams) { - const __probeIntercept = findIntercept( - interceptionPathname, - interceptionContext, - interceptionId, + // An intercept the route has no slot for renders nothing, so it is + // neither loaded nor probed, as on the render path. + const __probeIntercept = __resolveAppPageProbeIntercept( + route, + findIntercept(interceptionPathname, interceptionContext, interceptionId), ); // The intercepting-route page module is lazy (page: null + __pageLoader). // Resolve it before probing so buildAppPageProbes inspects the real page // component for dynamic bailout — matching the render path, which also - // hydrates it (resolveAppPageInterceptState). Without this the intercept + // hydrates it (resolveAppPageIntercept). Without this the intercept // probe branch silently inspects an undefined component and never // observes the page's searchParams/headers access. Shared loader, so // the import is isolated from the request context here too. @@ -1427,6 +1663,33 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle makeThenableParams, })); }, + async probeInterceptSource(sourceRoute, sourceParams, sourceSearchParams) { + // A source without the intercept's slot renders unchanged, so the + // intercept's modules are neither loaded nor probed. + const __probeIntercept = __resolveAppPageProbeIntercept( + sourceRoute, + findIntercept(interceptionPathname, interceptionContext, interceptionId), + ); + if (__probeIntercept) { + await Promise.all([ + __loadAppInterceptPage(__probeIntercept), + __loadAppInterceptLayouts(__probeIntercept), + ]); + } + return Promise.all(__buildAppPageInterceptSourceProbes({ + route: sourceRoute, + pageComponent: sourceRoute.page?.default, + intercept: __probeIntercept, + sourceParams, + // The intercepted render matches inherited slots' params against + // the request path, as buildPageElements does. + slotParamOverrides: __resolveSlotParamOverrides(sourceRoute, cleanPathname), + searchParams: sourceSearchParams, + mountedSlotsHeader, + renderMode, + makeThenableParams, + })); + }, renderErrorBoundaryPage(renderErr, errorOrigin) { const __activeIntercept = findIntercept( interceptionPathname, @@ -1459,14 +1722,32 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle request, revalidateSeconds: __segmentConfig.revalidateSeconds, renderedPathAndSearch, - resolveRouteFetchCacheMode(targetRoute) { - return __resolveRouteFetchCacheMode(targetRoute); + resolveRouteFetchCacheMode(targetRoute, intercept) { + return __resolveRouteFetchCacheMode(targetRoute, intercept); + }, + resolveRouteRevalidateSeconds(targetRoute, intercept) { + return __resolveRouteRevalidateSeconds(targetRoute, intercept); + }, + resolveRouteDynamicConfig(targetRoute, intercept) { + return __resolveRouteDynamicConfig(targetRoute, intercept); + }, + resolveRouteDynamicStaleTimeSeconds(targetRoute, intercept) { + return __resolveRouteDynamicStaleTimeSeconds(targetRoute, intercept); + }, + resolveRouteDynamicParamsConfig(targetRoute, intercept) { + return __resolveRouteDynamicParamsConfig(targetRoute, intercept); + }, + resolveRouteInterceptTreeDynamicConfig(targetRoute, intercept) { + return __resolveRouteInterceptTreeDynamicConfig(targetRoute, intercept); + }, + resolveRouteGenerateStaticParams(targetRoute, intercept) { + return __resolveRouteGenerateStaticParams(targetRoute, intercept); }, - resolveRouteRevalidateSeconds(targetRoute) { - return __resolveRouteRevalidateSeconds(targetRoute); + resolveRouteHasAnyGenerateStaticParams(targetRoute, intercept) { + return __resolveRouteHasAnyGenerateStaticParams(targetRoute, intercept); }, - resolveRouteDynamicConfig(targetRoute) { - return __resolveRouteDynamicConfig(targetRoute); + resolveRouteStaticEligible(targetRoute, intercept) { + return __resolveRouteStaticEligible(targetRoute, intercept); }, rootForbiddenModule, rootNotFoundModule, @@ -1699,6 +1980,7 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle renderMode: actionRenderMode, observeMetadataSearchParamsAccess, observePageSearchParamsAccess, + isForceStatic, scriptNonce: targetScriptNonce, }) { return buildPageElements(actionRoute, actionParams, actionCleanPathname, { @@ -1710,6 +1992,7 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle renderMode: actionRenderMode, observeMetadataSearchParamsAccess: observeMetadataSearchParamsAccess === true, observePageSearchParamsAccess: observePageSearchParamsAccess === true, + isForceStatic: isForceStatic === true, }, undefined, actionCleanPathname, targetScriptNonce ?? scriptNonce); }, cleanPathname, @@ -1769,14 +2052,14 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle readFormDataWithLimit: __readFormDataWithLimit, renderToReadableStream, reportRequestError: _reportRequestError, - resolveRouteFetchCacheMode(targetRoute) { - return __resolveRouteFetchCacheMode(targetRoute); + resolveRouteFetchCacheMode(targetRoute, intercept) { + return __resolveRouteFetchCacheMode(targetRoute, intercept); }, - resolveRouteRevalidateSeconds(targetRoute) { - return __resolveRouteRevalidateSeconds(targetRoute); + resolveRouteRevalidateSeconds(targetRoute, intercept) { + return __resolveRouteRevalidateSeconds(targetRoute, intercept); }, - resolveRouteDynamicConfig(targetRoute) { - return __resolveRouteDynamicConfig(targetRoute); + resolveRouteDynamicConfig(targetRoute, intercept) { + return __resolveRouteDynamicConfig(targetRoute, intercept); }, resolveRouteRuntime: __resolveRouteRuntime, request, @@ -1799,6 +2082,7 @@ ${responseStageOnly ? "const __responseStageOptions = {" : "const __appRscHandle interceptNotFoundBranchSegments: intercept.interceptNotFoundBranchSegments, interceptNotFound: intercept.notFound, interceptNotFoundTreePosition: intercept.notFoundTreePosition, + interceptOwnerDefault: intercept.ownerDefault, interceptSlotId: intercept.slotId, interceptSlotKey: intercept.slotKey, interceptSourceMatchedUrl: interceptionContext, diff --git a/packages/vinext/src/entries/app-rsc-manifest.ts b/packages/vinext/src/entries/app-rsc-manifest.ts index 6e1397a35b..497bf7406f 100644 --- a/packages/vinext/src/entries/app-rsc-manifest.ts +++ b/packages/vinext/src/entries/app-rsc-manifest.ts @@ -208,6 +208,7 @@ function registerRouteModules(routes: AppRoute[], imports: ImportAllocator): voi for (const ir of slot.interceptingRoutes) { imports.getLazyLoaderVar(ir.pagePath); if (ir.notFoundPath) imports.getLazyLoaderVar(ir.notFoundPath); + if (slot.ownerDefaultPath) imports.getLazyLoaderVar(slot.ownerDefaultPath); for (const layoutPath of ir.layoutPaths) { imports.getLazyLoaderVar(layoutPath); } @@ -309,6 +310,8 @@ function buildRouteEntries(routes: AppRoute[], imports: ImportAllocator): string notFound: null, __loadNotFound: ${ir.notFoundPath ? imports.getLazyLoaderVar(ir.notFoundPath) : "null"}, notFoundTreePosition: ${ir.notFoundTreePosition ?? "null"}, + ownerDefault: null, + __loadOwnerDefault: ${slot.ownerDefaultPath ? imports.getLazyLoaderVar(slot.ownerDefaultPath) : "null"}, params: ${JSON.stringify(ir.params)}, }`, ); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index ad3475df42..a786a61426 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -52,7 +52,10 @@ import { isImageOptimizationPath, resolveDevImageRedirect, } from "./server/image-optimization.js"; -import { CACHEABILITY_MANIFEST_MODULE } from "./server/cacheability-manifest.js"; +import { + CACHEABILITY_MANIFEST_MODULE, + CACHEABILITY_REQUEST_PROJECTION_MODULE, +} from "./server/cacheability-manifest.js"; import { PREGENERATED_CONCRETE_PATHS_MODULE } from "./server/pregenerated-concrete-paths.js"; import { installSocketErrorBackstop } from "./server/socket-error-backstop.js"; @@ -1140,6 +1143,9 @@ const VIRTUAL_RSC_ENTRY = "virtual:vinext-rsc-entry"; const RESOLVED_RSC_ENTRY = VIRTUAL_PREFIX + VIRTUAL_RSC_ENTRY; const VIRTUAL_CACHEABILITY_MANIFEST = "virtual:vinext-cacheability-manifest"; const RESOLVED_CACHEABILITY_MANIFEST = VIRTUAL_PREFIX + VIRTUAL_CACHEABILITY_MANIFEST; +const VIRTUAL_CACHEABILITY_REQUEST_PROJECTION = "virtual:vinext-cacheability-request-projection"; +const RESOLVED_CACHEABILITY_REQUEST_PROJECTION = + VIRTUAL_PREFIX + VIRTUAL_CACHEABILITY_REQUEST_PROJECTION; const VIRTUAL_PREGENERATED_CONCRETE_PATHS = "virtual:vinext-pregenerated-concrete-paths"; const RESOLVED_PREGENERATED_CONCRETE_PATHS = VIRTUAL_PREFIX + VIRTUAL_PREGENERATED_CONCRETE_PATHS; const VIRTUAL_APP_REQUEST_ENTRY = "virtual:vinext-app-request-entry"; @@ -4198,6 +4204,16 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { } return RESOLVED_CACHEABILITY_MANIFEST; } + if (cleanId === VIRTUAL_CACHEABILITY_REQUEST_PROJECTION) { + if ( + hasAppDir && + this.environment?.name === "rsc" && + this.environment.config?.command === "build" + ) { + return { id: `./${CACHEABILITY_REQUEST_PROJECTION_MODULE}`, external: true }; + } + return RESOLVED_CACHEABILITY_REQUEST_PROJECTION; + } if (cleanId === VIRTUAL_PREGENERATED_CONCRETE_PATHS) { const isWorkerBuildEnvironment = hasAppDir ? this.environment?.name === "rsc" @@ -4363,7 +4379,10 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return `export default ${JSON.stringify(metadata)};`; } // App Router virtual modules - if (id === RESOLVED_CACHEABILITY_MANIFEST) { + if ( + id === RESOLVED_CACHEABILITY_MANIFEST || + id === RESOLVED_CACHEABILITY_REQUEST_PROJECTION + ) { return "export default null;"; } if (id === RESOLVED_PREGENERATED_CONCRETE_PATHS) { @@ -4901,6 +4920,11 @@ export const loadServerActionClient = ${ source: "export default null;\n", }); if (hasAppDir) { + this.emitFile({ + type: "asset", + fileName: CACHEABILITY_REQUEST_PROJECTION_MODULE, + source: "export default null;\n", + }); this.emitFile({ type: "asset", fileName: PREGENERATED_CONCRETE_PATHS_MODULE, diff --git a/packages/vinext/src/plugins/rsc-client-shim-excludes.ts b/packages/vinext/src/plugins/rsc-client-shim-excludes.ts index fbc8a528ac..bbd1805c46 100644 --- a/packages/vinext/src/plugins/rsc-client-shim-excludes.ts +++ b/packages/vinext/src/plugins/rsc-client-shim-excludes.ts @@ -2,6 +2,7 @@ const RSC_CLIENT_SHIM_OPTIMIZE_DEPS_EXCLUDE = Object.freeze([ // @vitejs/plugin-rsc tracks package client references by the original // bare source. If Vite pre-bundles these known client shims, the generated // client-package proxy can lose the matching export metadata in dev. + "vinext/shims/client-page-root", "vinext/shims/error-boundary", "vinext/shims/form", "vinext/shims/layout-segment-context", diff --git a/packages/vinext/src/routing/app-route-graph.ts b/packages/vinext/src/routing/app-route-graph.ts index f7f5f2255f..a362af495c 100644 --- a/packages/vinext/src/routing/app-route-graph.ts +++ b/packages/vinext/src/routing/app-route-graph.ts @@ -39,7 +39,11 @@ type InterceptingRoute = { pagePath: string; /** Filesystem segments from app/ root to the intercepting page directory. */ sourcePageSegments?: string[]; - /** Absolute layout paths inside the intercepting route tree, outermost to innermost */ + /** + * Absolute layout paths inside the intercepting route tree, outermost to + * innermost. A slot intercept's chain starts with the layouts of the slot's + * folders above the marker, below the slot root. + */ layoutPaths: string[]; /** Normalized branch segments accumulated at each intercept layout. */ layoutSegments?: string[][]; @@ -83,6 +87,11 @@ type ParallelSlot = { pagePath: string | null; /** Absolute path to the slot's default.tsx fallback */ defaultPath: string | null; + /** + * Absolute path to the owner directory's default.tsx, which Next.js puts in + * place of the owner's children when this slot intercepts. + */ + ownerDefaultPath?: string | null; /** Absolute path to the slot's layout component (wraps slot content) */ layoutPath: string | null; /** Nested active-branch layouts whose exports contribute route config. */ @@ -1576,6 +1585,51 @@ function findSlotConfigLayoutTreePositions( }); } +/** + * The layouts of the folders from below an intercept's branch root (a slot's + * root, or a sibling-page intercept's source page folder) down to the folder + * that holds its interception marker, with the root-relative segments + * accumulated at each. Next.js builds the intercepting route's loader tree + * from every folder on the intercepting page's path, each with its layout, so + * these wrap the marker's branch, and its page and metadata. + * https://github.com/vercel/next.js/blob/v16.2.7/crates/next-core/src/app_structure.rs#L1182-L1260 + */ +function findInterceptAncestorLayoutEntries( + branchRootDir: string, + interceptParentDir: string, + matcher: ValidFileMatcher, +): { path: string; segments: string[] }[] { + const segments = path.relative(branchRootDir, interceptParentDir).split(path.sep).filter(Boolean); + const layouts: { path: string; segments: string[] }[] = []; + let currentDir = branchRootDir; + for (const [index, segment] of segments.entries()) { + currentDir = path.join(currentDir, segment); + const layoutPath = findFile(currentDir, "layout", matcher); + if (layoutPath) layouts.push({ path: layoutPath, segments: segments.slice(0, index + 1) }); + } + return layouts; +} + +/** + * The loading boundaries of the same folders, at their root-relative tree + * positions. + */ +function findInterceptAncestorLoadingEntries( + branchRootDir: string, + interceptParentDir: string, + matcher: ValidFileMatcher, +): { path: string; treePosition: number }[] { + const segments = path.relative(branchRootDir, interceptParentDir).split(path.sep).filter(Boolean); + const loadings: { path: string; treePosition: number }[] = []; + let currentDir = branchRootDir; + for (const [index, segment] of segments.entries()) { + currentDir = path.join(currentDir, segment); + const loadingPath = findFile(currentDir, "loading", matcher); + if (loadingPath) loadings.push({ path: loadingPath, treePosition: index + 1 }); + } + return loadings; +} + function findSlotLoadingEntries( slotDir: string, pagePath: string | null, @@ -2468,6 +2522,7 @@ function discoverParallelSlots( hasPage: pagePath !== null, pagePath, defaultPath, + ownerDefaultPath: findFile(dir, "default", matcher), layoutPath: findFile(slotDir, "layout", matcher), configLayoutPaths, configLayoutTreePositions: findSlotConfigLayoutTreePositions(slotDir, configLayoutPaths), @@ -2586,6 +2641,23 @@ function discoverSiblingInterceptingRoutes( // Collect all intercept targets from the marker subtree. const restOfName = entry.name.slice(marker.prefix.length); const parentDir = dir; // directory that owns the marker (the "intercepting route" dir) + // Find the route that serves the parentDir. Fall back to scanning all + // routes that live under parentDir (handles the case where the route + // pattern is a catch-all like /templates/:catchAll+ rather than /templates). + const owner = findOwnerRouteForDir(parentDir, appDir, routes, routesByDir); + // The intercepting page continues the main tree below its source + // page's folder, so the folders between that folder and the marker, + // such as a route group, are on its path too. + const ownerFilePath = owner ? (owner.pagePath ?? owner.routePath) : null; + const ownerDir = ownerFilePath ? path.dirname(ownerFilePath) : null; + const ownerRelativeParentDir = ownerDir ? path.relative(ownerDir, parentDir) : ""; + const siblingSourceDir = + ownerDir && + ownerRelativeParentDir && + !ownerRelativeParentDir.startsWith("..") && + !path.isAbsolute(ownerRelativeParentDir) + ? ownerDir + : null; const results: InterceptingRoute[] = []; collectInterceptingPages( childDir, @@ -2598,6 +2670,12 @@ function discoverSiblingInterceptingRoutes( null, results, matcher, + [], + siblingSourceDir + ? findInterceptAncestorLoadingEntries(siblingSourceDir, parentDir, matcher) + : [], + siblingSourceDir ? ownerRelativeParentDir.split(path.sep).filter(Boolean).length : 0, + siblingSourceDir, ); for (const ir of results) { ir.slotId = createAppRouteGraphSiblingInterceptSlotId(ir.sourceMatchPattern); @@ -2606,10 +2684,6 @@ function discoverSiblingInterceptingRoutes( ir.sourceMatchPattern, ir.targetPattern, ); - // Find the route that serves the parentDir. Fall back to scanning all - // routes that live under parentDir (handles the case where the route - // pattern is a catch-all like /templates/:catchAll+ rather than /templates). - const owner = findOwnerRouteForDir(parentDir, appDir, routes, routesByDir); if (owner) { owner.siblingIntercepts.push(ir); } @@ -2794,6 +2868,11 @@ function collectInterceptingPages( parentLayoutPaths: readonly string[] = [], parentLoadingEntries: readonly { path: string; treePosition: number }[] = [], treePositionOffset = 0, + /** + * The source page's folder of a sibling-page intercept whose marker sits + * below it; null for slot intercepts and markers in the source's folder. + */ + siblingSourceDir: string | null = null, ): void { const currentLayoutPath = findFile(currentDir, "layout", matcher); const layoutPaths = currentLayoutPath @@ -2827,29 +2906,39 @@ function collectInterceptingPages( page, matcher, ); - const slotParentSegments = slotRootDir - ? path.relative(slotRootDir, interceptParentDir).split(path.sep).filter(Boolean) + const branchRootDir = slotRootDir ?? siblingSourceDir; + const parentSegments = branchRootDir + ? path.relative(branchRootDir, interceptParentDir).split(path.sep).filter(Boolean) : []; const branchSegments = [ - ...slotParentSegments, + ...parentSegments, interceptSegment, ...path.relative(interceptRoot, path.dirname(page)).split(path.sep).filter(Boolean), ]; + const ancestorLayouts = branchRootDir + ? findInterceptAncestorLayoutEntries(branchRootDir, interceptParentDir, matcher) + : []; results.push({ branchSegments, convention, - layoutPaths: [...layoutPaths], - layoutSegments: layoutPaths.map((layoutPath) => { - const relativeDir = path.relative(interceptRoot, path.dirname(layoutPath)); - return [ - ...slotParentSegments, - interceptSegment, - ...relativeDir.split(path.sep).filter(Boolean), - ]; - }), + layoutPaths: [...ancestorLayouts.map((layout) => layout.path), ...layoutPaths], + layoutSegments: [ + ...ancestorLayouts.map((layout) => layout.segments), + ...layoutPaths.map((layoutPath) => { + const relativeDir = path.relative(interceptRoot, path.dirname(layoutPath)); + return [ + ...parentSegments, + interceptSegment, + ...relativeDir.split(path.sep).filter(Boolean), + ]; + }), + ], loadingPaths: loadingEntries.map((loading) => loading.path), loadingTreePositions: loadingEntries.map((loading) => loading.treePosition), - notFoundBranchSegments: branchSegments, + // A sibling's not-found position counts from its marker. + notFoundBranchSegments: slotRootDir + ? branchSegments + : branchSegments.slice(parentSegments.length), notFoundPath: notFoundBoundary.path, notFoundTreePosition: notFoundBoundary.treePosition === null @@ -2887,6 +2976,7 @@ function collectInterceptingPages( layoutPaths, loadingEntries, treePositionOffset, + siblingSourceDir, ); } } diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index e5543bddec..2e8af9b519 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -33,10 +33,12 @@ import { PREFETCH_CACHE_TTL, getClientNavigationRenderContext, getBfcacheIdMapContext, + getNavigationContext, getMountedSlotsHeader, getPrefetchCache, hasPrefetchCacheEntryForNavigation, invalidatePrefetchCache, + parseRenderedPathAndSearchHeader, preloadHybridClientRouteOwner, seedPrefetchResponseSnapshot, decodeRedirectError, @@ -113,6 +115,7 @@ import { consumeInitialFormState, createVinextHydrateRootOptions, hydrateRootInTransition, + resolveFetchedHydrationLocation, } from "./app-browser-hydration.js"; import { AppElementsWire, @@ -162,7 +165,13 @@ import { } from "vinext/shims/error-boundary"; import DefaultGlobalError from "vinext/shims/default-global-error"; import { AppRouterContext } from "vinext/shims/internal/app-router-context"; -import { BfcacheIdentityMapContext, ElementsContext, Slot } from "vinext/shims/slot"; +import { + BfcacheIdentityMapContext, + ElementsContext, + Slot, + bindAppElementsRenderedSearch, + setAppElementsRenderedSearch, +} from "vinext/shims/slot"; import type { RouteManifest, RouteManifestInterception } from "../routing/app-route-graph.js"; import { matchRoutePattern } from "../routing/route-pattern.js"; import { splitPathnameForRouteMatch } from "../routing/utils.js"; @@ -171,7 +180,7 @@ import { createDevOnCaughtError, createOnUncaughtError, createProdOnCaughtError, - prodOnRecoverableError, + createProdOnRecoverableError, } from "./app-browser-error.js"; import { createHydrationCachePublication, @@ -209,6 +218,7 @@ import { import { VINEXT_CLIENT_REUSE_MANIFEST_HEADER, VINEXT_PARAMS_HEADER, + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, VINEXT_RSC_REDIRECT_HEADER, VINEXT_RSC_REDIRECT_TYPE_HEADER, } from "./headers.js"; @@ -586,7 +596,7 @@ async function fetchPersistedInterceptedSlotRefresh(options: { headers, signal: options.signal, }); - return decodeAppElementsPromise(createFromFetch(Promise.resolve(response))); + return decodeSupplementalRefresh(response, options.targetPathname); } async function fetchPersistedSourcePageRefresh(options: { @@ -600,7 +610,28 @@ async function fetchPersistedSourcePageRefresh(options: { headers, signal: options.signal, }); - return decodeAppElementsPromise(createFromFetch(Promise.resolve(response))); + return decodeSupplementalRefresh(response, options.targetPathname); +} + +/** + * Decode a kept branch a refresh fetched from its own URL. Its client pages + * read the query the server rendered it with, not the navigation's. + */ +async function decodeSupplementalRefresh( + response: Response, + targetPathname: string, +): Promise { + const elements = await decodeAppElementsPromise( + createFromFetch(Promise.resolve(response)), + ); + const renderedPathAndSearch = parseRenderedPathAndSearchHeader( + response.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER), + ); + setAppElementsRenderedSearch( + elements, + new URL(renderedPathAndSearch ?? targetPathname, window.location.origin).search, + ); + return elements; } function isSettledPrefetchCacheEntry( @@ -819,6 +850,7 @@ async function commitSameUrlNavigatePayload( actionInitiation: ActionInitiationSnapshot, returnValue?: ServerActionResult["returnValue"], revalidation: ServerActionRevalidationKind = "none", + renderedPathAndSearch: string | null = null, ): Promise { let shouldRetrySupplementalRefresh = false; let supplementalHandle: ReturnType< @@ -886,10 +918,22 @@ async function commitSameUrlNavigatePayload( }); } } - const navigationSnapshot = createClientNavigationRenderSnapshot( - actionInitiation.href, - actionInitiation.routerState.navigationSnapshot.params, - ); + // The re-render keeps the URL, but a rewrite on the POST can resolve + // another query. Without the header, keep the query the page rendered. + const navigationSnapshot = + renderedPathAndSearch === null + ? withRenderedSearchOf( + createClientNavigationRenderSnapshot( + actionInitiation.href, + actionInitiation.routerState.navigationSnapshot.params, + ), + actionInitiation.routerState.navigationSnapshot, + ) + : createClientNavigationRenderSnapshot( + actionInitiation.href, + actionInitiation.routerState.navigationSnapshot.params, + renderedPathAndSearch, + ); try { const result = await browserNavigationController.commitSameUrlNavigatePayload( nextElements, @@ -1492,6 +1536,46 @@ function restoreEmbeddedHydrationNavigationContext( ); } +/** + * The path and query SSR rendered, from the navigation payload the document + * embeds: the effective query, which a rewrite may have changed, or the + * browser URL's when a stored document leaves the query out. + */ +function getHydrationRenderedPathAndSearch(): string | null { + const context = getNavigationContext(); + if (!context) return null; + const search = context.searchParams.toString(); + return search ? `${context.pathname}?${search}` : context.pathname; +} + +/** + * A render that turns dynamic after the head sends its client pages' query + * later in the document (see `app-ssr-entry.ts`), possibly after bootstrap, so + * the hydration snapshot reads it when a client page first renders. + */ +function withLateRenderedSearch( + snapshot: ClientNavigationRenderSnapshot, + rsc: NavigationRuntimeRscBootstrap | undefined, +): ClientNavigationRenderSnapshot { + if (!rsc) return snapshot; + const headRenderedSearch = snapshot.renderedSearch; + return Object.defineProperty(snapshot, "renderedSearch", { + configurable: true, + enumerable: true, + get: () => rsc.renderedSearch ?? headRenderedSearch, + }); +} + +/** Carry the rendered query over to a snapshot of the same URL. */ +function withRenderedSearchOf( + snapshot: ClientNavigationRenderSnapshot, + source: ClientNavigationRenderSnapshot, +): ClientNavigationRenderSnapshot { + return source.renderedSearch === undefined + ? snapshot + : { ...snapshot, renderedSearch: source.renderedSearch }; +} + function restorePopstateScrollPosition( state: unknown, options?: { @@ -1668,7 +1752,15 @@ async function readInitialRscStream(): Promise | null } } - restoreHydrationNavigationContext(window.location.pathname, window.location.search, params); + // Like the embedded payload, carry the query the server rendered, which a + // rewrite may have changed, under the public pathname. + const rendered = resolveFetchedHydrationLocation( + parseRenderedPathAndSearchHeader( + rscResponse.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER), + ), + window.location, + ); + restoreHydrationNavigationContext(rendered.pathname, rendered.search, params); return rscResponse.body; } @@ -1720,6 +1812,7 @@ function registerServerActionCallback(): void { navigationSnapshot: createClientNavigationRenderSnapshot( target.href, actionInitiation.routerState.navigationSnapshot.params, + target.renderedPathAndSearch, ), navId: actionInitiation.navigationId, operationLane: resolveServerActionOperationLane(revalidation), @@ -1779,11 +1872,20 @@ function bootstrapHydration( const hydrationCachePublication = createHydrationCachePublication(); const cacheGeneration = clientNavigationCacheGeneration; const [reactBranch, cacheBranch] = rscStream.tee(); - const root = decodeAppElementsPromise(createFromReadableStream(reactBranch)); - const initialNavigationSnapshot = createClientNavigationRenderSnapshot( - window.location.href, - latestClientParams, + const initialNavigationSnapshot = withLateRenderedSearch( + createClientNavigationRenderSnapshot( + window.location.href, + latestClientParams, + getHydrationRenderedPathAndSearch(), + ), + initialRscBootstrap, ); + const root = decodeAppElementsPromise( + createFromReadableStream(reactBranch), + ).then((elements) => { + bindAppElementsRenderedSearch(elements, initialNavigationSnapshot); + return elements; + }); const initialParams = initialNavigationSnapshot.params; const initialPathAndSearch = createSnapshotPathAndSearch(initialNavigationSnapshot); const initialCacheBuffer = new Response(cacheBranch).arrayBuffer(); @@ -1853,10 +1955,9 @@ function bootstrapHydration( hydrationCachePublication.fail(); reportUncaughtError(...args); }; - const onRecoverableError = (...args: Parameters) => { + const onRecoverableError = createProdOnRecoverableError(() => { hydrationCachePublication.fail(); - prodOnRecoverableError(...args); - }; + }); const invalidateOnCaughtError = void>(handler: T): T => ((...args: Parameters) => { hydrationCachePublication.fail(); @@ -2244,6 +2345,7 @@ function bootstrapHydration( const cachedNavigationSnapshot = createClientNavigationRenderSnapshot( currentHref, cachedParams, + cachedRoute.response.renderedPathAndSearch, ); const cachedPayload = cachedRoute.elements ? Promise.resolve(cachedRoute.elements) @@ -2525,7 +2627,13 @@ function bootstrapHydration( const navParams: Record = responseParams ?? (IS_STATIC_EXPORT ? resolveStaticExportRouteParams(currentHref) : {}); // Build snapshot from local params, not latestClientParams - const navigationSnapshot = createClientNavigationRenderSnapshot(currentHref, navParams); + const navigationSnapshot = createClientNavigationRenderSnapshot( + currentHref, + navParams, + parseRenderedPathAndSearchHeader( + navResponse.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER), + ), + ); // Tee the response body so React can consume it incrementally — // shell parses fast, and any Suspense boundary inside (e.g. the @@ -2950,10 +3058,6 @@ function bootstrapHydration( return; } clearClientNavigationCaches(); - const navigationSnapshot = createClientNavigationRenderSnapshot( - window.location.href, - latestClientParams, - ); // Clear stale errors from the dev overlay before dispatching the // fresh tree. If the new tree renders cleanly, the overlay stays // empty; if it throws again, devOnCaughtError/devOnUncaughtError @@ -2968,19 +3072,32 @@ function bootstrapHydration( browserNavigationController.getBrowserRouterState().elements, ), }); + const hmrHref = window.location.href; + const hmrParams = latestClientParams; + // Same URL, so the same rendered query unless the response says otherwise. + const sameQuerySnapshot = withRenderedSearchOf( + createClientNavigationRenderSnapshot(hmrHref, hmrParams), + browserNavigationController.getBrowserRouterState().navigationSnapshot, + ); + const hmrResponse = fetch( + await createRscRequestUrl(window.location.pathname + window.location.search, hmrHeaders), + { headers: hmrHeaders }, + ); + // Enter the controller before the response arrives, so this update + // supersedes an older one that is still decoding. await browserNavigationController.hmrReplaceTree( decodeAppElementsPromise( - createFromFetch( - fetch( - await createRscRequestUrl( - window.location.pathname + window.location.search, - hmrHeaders, - ), - { headers: hmrHeaders }, - ).then(stripRscCompletionMetadataResponse), - ), + createFromFetch(hmrResponse.then(stripRscCompletionMetadataResponse)), ), - navigationSnapshot, + hmrResponse.then((response) => { + // A rewrite can resolve another query than the tree had. + const renderedPathAndSearch = parseRenderedPathAndSearchHeader( + response.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER), + ); + return renderedPathAndSearch === null + ? sameQuerySnapshot + : createClientNavigationRenderSnapshot(hmrHref, hmrParams, renderedPathAndSearch); + }), ); }; diff --git a/packages/vinext/src/server/app-browser-error.ts b/packages/vinext/src/server/app-browser-error.ts index 74de9121ea..cc1d98f57c 100644 --- a/packages/vinext/src/server/app-browser-error.ts +++ b/packages/vinext/src/server/app-browser-error.ts @@ -1,3 +1,4 @@ +import { isBailoutToCSRError } from "vinext/shims/navigation-errors"; import { isNavigationSignalError } from "../utils/navigation-signal.js"; import { isUnknownRecord } from "../utils/record.js"; @@ -75,6 +76,19 @@ export function createDevOnCaughtError( }; } -export function prodOnRecoverableError(error: unknown): void { - reportGlobalError(error instanceof Error && error.cause !== undefined ? error.cause : error); +// Production onRecoverableError handler for hydrateRoot. Ported from Next.js's +// react-client-callbacks/on-recoverable-error: a useSearchParams() bail-out +// leaves its Suspense boundary for the browser to client-render, which React +// reports as a recoverable error carrying the bail-out digest. That switch is +// the intended render, not a failure, so it is neither reported nor passed to +// onReportedError. +export function createProdOnRecoverableError( + onReportedError: () => void, +): (error: unknown) => void { + return (error) => { + const cause = error instanceof Error && error.cause !== undefined ? error.cause : error; + if (isBailoutToCSRError(cause)) return; + onReportedError(); + reportGlobalError(cause); + }; } diff --git a/packages/vinext/src/server/app-browser-hydration.ts b/packages/vinext/src/server/app-browser-hydration.ts index b6e91d14a1..51f6accf1d 100644 --- a/packages/vinext/src/server/app-browser-hydration.ts +++ b/packages/vinext/src/server/app-browser-hydration.ts @@ -63,3 +63,23 @@ export function hydrateRootInTransition(options: { return root; } + +/** + * The pathname and query to hydrate with when the initial Flight payload is + * fetched instead of embedded, matching what SSR rendered: the query the + * server rendered (`X-Vinext-Rendered-Path-And-Search`), which a rewrite may + * have changed, under the public pathname `usePathname()` exposes. The + * header's path is the rewrite target, so it is never the pathname. + */ +export function resolveFetchedHydrationLocation( + renderedPathAndSearch: string | null, + location: Pick, +): { pathname: string; search: string } { + return { + pathname: location.pathname, + search: + renderedPathAndSearch === null + ? location.search + : new URL(renderedPathAndSearch, location.origin).search, + }; +} diff --git a/packages/vinext/src/server/app-browser-navigation-controller.ts b/packages/vinext/src/server/app-browser-navigation-controller.ts index 456727f3d7..1f187961de 100644 --- a/packages/vinext/src/server/app-browser-navigation-controller.ts +++ b/packages/vinext/src/server/app-browser-navigation-controller.ts @@ -152,7 +152,7 @@ type BrowserNavigationController = { ): Promise; hmrReplaceTree( nextElements: Promise, - navigationSnapshot: ClientNavigationRenderSnapshot, + navigationSnapshot: ClientNavigationRenderSnapshot | Promise, ): Promise; /** * Force-drain the queued pre-paint effect for the given renderId without @@ -507,14 +507,20 @@ export function createAppBrowserNavigationController( async function hmrReplaceTree( nextElements: Promise, - navigationSnapshot: ClientNavigationRenderSnapshot, + pendingNavigationSnapshot: + | ClientNavigationRenderSnapshot + | Promise, ): Promise { + // Claim the generation before the snapshot (read from the response + // headers) resolves, so an older update still decoding cannot commit. const hmrUpdateId = ++latestHmrUpdateId; const startedDuringUserNavigation = pendingUserNavigationLane === "navigation"; if (!hasBrowserRouterState()) return; const currentState = getBrowserRouterState(); const renderId = allocateRenderId(); + // Await both so a failed response does not leave either rejection unobserved. + const [navigationSnapshot] = await Promise.all([pendingNavigationSnapshot, nextElements]); const pending = await createPendingNavigationCommit({ currentState, nextElements, diff --git a/packages/vinext/src/server/app-browser-server-action-client.ts b/packages/vinext/src/server/app-browser-server-action-client.ts index c034118a9b..6e4b6a61d5 100644 --- a/packages/vinext/src/server/app-browser-server-action-client.ts +++ b/packages/vinext/src/server/app-browser-server-action-client.ts @@ -3,6 +3,7 @@ import { createTemporaryReferenceSet, encodeReply, } from "@vitejs/plugin-rsc/browser"; +import { parseRenderedPathAndSearchHeader } from "vinext/shims/navigation"; import { DANGEROUS_URL_BLOCK_MESSAGE, isDangerousScheme } from "vinext/shims/url-safety"; import { createServerActionResultFacts, @@ -27,6 +28,7 @@ import { ACTION_REDIRECT_HEADER, ACTION_REDIRECT_STATUS_HEADER, ACTION_REDIRECT_TYPE_HEADER, + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, } from "./headers.js"; import { hasBasePath } from "../utils/base-path.js"; @@ -43,6 +45,8 @@ type ActionRedirectTarget = { href: string; type: string; status: number; + /** The path and query the server rendered the target with, when known. */ + renderedPathAndSearch: string | null; }; export type ClientServerActionDeps = { @@ -54,6 +58,7 @@ export type ClientServerActionDeps = { actionInitiation: ClientServerActionInitiation, returnValue: ServerActionResult["returnValue"] | undefined, revalidation: ServerActionRevalidationKind, + renderedPathAndSearch: string | null, ): Promise; navigationPlanner: typeof import("./navigation-planner.js").navigationPlanner; performHardNavigation(url: string, historyMode?: "assign" | "replace"): void; @@ -106,6 +111,10 @@ function resolveActionRedirectTarget( href: redirectUrl.href, type: response.headers.get(ACTION_REDIRECT_TYPE_HEADER) ?? "push", status: statusHeader ? parseInt(statusHeader, 10) : 307, + // The target's own render, which a rewrite may give another query. + renderedPathAndSearch: parseRenderedPathAndSearchHeader( + response.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER), + ), }; } catch { performHardNavigation(actionRedirect); @@ -236,6 +245,10 @@ export async function invokeClientServerAction( deps.syncServerActionHttpFallbackHead( shouldSyncServerActionHttpFallbackHead(result) ? fetchResponse.status : null, ); + // A rewrite on the POST can re-render the page with another query. + const renderedPathAndSearch = parseRenderedPathAndSearchHeader( + fetchResponse.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER), + ); if (isServerActionResult(result)) { if (result.root !== undefined) { @@ -251,6 +264,7 @@ export async function invokeClientServerAction( actionInitiation, returnValue, revalidation, + renderedPathAndSearch, ); } if (result.returnValue) { @@ -267,5 +281,6 @@ export async function invokeClientServerAction( actionInitiation, undefined, revalidation, + renderedPathAndSearch, ); } diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index ea5811842e..011a6231d1 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -38,6 +38,7 @@ import { createSnapshotPathAndSearch, type ClientNavigationRenderSnapshot, } from "vinext/shims/navigation"; +import { bindAppElementsRenderedSearch } from "vinext/shims/slot"; import { normalizePathnameForRouteMatch } from "../routing/utils.js"; import { normalizePath } from "./normalize-path.js"; import type { BfcacheIdMap } from "./app-history-state.js"; @@ -665,6 +666,10 @@ export function createPendingNavigationCommitFromElements( options: CreatePendingNavigationCommitOptions & { nextElements: AppElements }, ): PendingNavigationCommit { const elements = options.nextElements; + // Before any merge into the visible tree: a page still streaming when a + // later navigation keeps its branch first renders under that navigation, + // but reads the query of the response that delivered it. + bindAppElementsRenderedSearch(elements, options.navigationSnapshot); const metadata = AppElementsWire.readMetadata(elements); const cacheEntryReuseProof = metadata.cacheEntryReuseProof ?? diff --git a/packages/vinext/src/server/app-page-cache-finalizer.ts b/packages/vinext/src/server/app-page-cache-finalizer.ts index 3758ce0ef1..2d427496fa 100644 --- a/packages/vinext/src/server/app-page-cache-finalizer.ts +++ b/packages/vinext/src/server/app-page-cache-finalizer.ts @@ -13,9 +13,14 @@ import { createEmptyAppPageRenderObservationState, type AppPageRenderObservationState, } from "./app-page-render-observation.js"; -import { buildAppPageCacheValue, isrCacheControl, type AppPageCacheSetter } from "./isr-cache.js"; +import { + buildAppPageCacheValue, + isrCacheControl, + resolveRouteExpireSeconds, + type AppPageCacheSetter, +} from "./isr-cache.js"; import type { CacheControlMetadata } from "vinext/shims/cache-handler"; -import type { RenderObservation } from "./cache-proof.js"; +import { hasQueryInvariantRenderProof, type RenderObservation } from "./cache-proof.js"; import { resolveClientStaleTimeSeconds } from "../utils/cache-control-metadata.js"; import { readStreamAsText } from "../utils/text-stream.js"; import { markFrameworkLinkHeaders } from "./app-response-header-provenance.js"; @@ -53,6 +58,11 @@ type FinalizeAppPageCacheabilityEvaluationOptions = { getPageTags: () => string[]; getRequestCacheLife?: () => AppPageRequestCacheLife | null; expireSeconds?: number; + /** + * `false` for routes Next.js classifies as dynamic (ƒ), which are never + * cacheable even when a cacheLife resolves during the render. + */ + isStaticEligible: boolean; revalidateSeconds: number | null; }; @@ -65,8 +75,8 @@ type FinalizeAppPageHtmlCacheResponseOptions = { clientTraceMetadataMarker?: string; consumeDynamicUsage: () => boolean; consumeRenderObservationState?: () => AppPageRenderObservationState; - createHtmlRenderObservation?: BuildAppPageCacheRenderObservation; - createRscRenderObservation?: BuildAppPageCacheRenderObservation; + createHtmlRenderObservation: BuildAppPageCacheRenderObservation; + createRscRenderObservation: BuildAppPageCacheRenderObservation; getPageTags: () => string[]; getRequestCacheLife?: () => AppPageRequestCacheLife | null; isrDebug?: AppPageDebugLogger; @@ -78,6 +88,7 @@ type FinalizeAppPageHtmlCacheResponseOptions = { omitPendingDynamicCacheState?: boolean; preserveClientResponseHeaders?: boolean; expireSeconds?: number; + isStaticEligible: boolean; revalidateSeconds: number | null; linkHeader: string | null; waitUntil?: (promise: Promise) => void; @@ -89,7 +100,7 @@ type ScheduleAppPageRscCacheWriteOptions = { cleanPathname: string; consumeDynamicUsage: () => boolean; consumeRenderObservationState?: () => AppPageRenderObservationState; - createRscRenderObservation?: BuildAppPageCacheRenderObservation; + createRscRenderObservation: BuildAppPageCacheRenderObservation; dynamicUsedDuringBuild: boolean; getPageTags: () => string[]; getRequestCacheLife?: () => AppPageRequestCacheLife | null; @@ -103,6 +114,7 @@ type ScheduleAppPageRscCacheWriteOptions = { renderMode?: AppRscRenderMode; preserveClientResponseHeaders?: boolean; expireSeconds?: number; + isStaticEligible: boolean; revalidateSeconds: number | null; waitUntil?: (promise: Promise) => void; }; @@ -160,7 +172,6 @@ function resolveAppPageCacheControl(options: { revalidateSeconds: number | null; }): CacheControlMetadata | null { let revalidateSeconds = options.revalidateSeconds; - let expireSeconds = options.expireSeconds; const requestCacheLife = options.requestCacheLife; if (requestCacheLife?.revalidate !== undefined) { @@ -169,9 +180,8 @@ function resolveAppPageCacheControl(options: { ? requestCacheLife.revalidate : Math.min(revalidateSeconds, requestCacheLife.revalidate); } - if (requestCacheLife?.expire !== undefined) { - expireSeconds = requestCacheLife.expire; - } + const expireSeconds = + requestCacheLife?.expire ?? resolveRouteExpireSeconds(revalidateSeconds, options.expireSeconds); if (revalidateSeconds === null || Number.isNaN(revalidateSeconds) || revalidateSeconds <= 0) { return null; @@ -205,6 +215,7 @@ function finalizeEvaluatedAppPageResponse( if (completed) return; completed = true; + const observationState = options.consumeRenderObservationState?.(); let outcome: RouteCacheabilityOutcome; if ( options.capturedDynamicUsageBeforeContextCleanup?.() === true || @@ -215,6 +226,8 @@ function finalizeEvaluatedAppPageResponse( dynamicUsage: true, reason: "dynamic API used during render", }; + } else if (options.isStaticEligible === false) { + outcome = { cacheable: false, reason: "route is not statically generated" }; } else if ( response.headers.has("set-cookie") || hasExplicitNonCacheableResponsePolicy(response.headers) @@ -230,11 +243,13 @@ function finalizeEvaluatedAppPageResponse( ? { cacheable: true, cacheControl: appPageCacheControlHeader(cacheControl), + ...(observationState && !observationState.requestApis.includes("searchParams") + ? { searchParamsUnread: true } + : {}), tags: options.getPageTags(), } : { cacheable: false, reason: "render did not produce a cache policy" }; } - options.consumeRenderObservationState?.(); complete(outcome); }; @@ -340,15 +355,24 @@ export function finalizeAppPageHtmlCacheResponse( const pageTags = options.getPageTags(); const observationState = options.consumeRenderObservationState?.() ?? createEmptyAppPageRenderObservationState(); - const htmlRenderObservation = options.createHtmlRenderObservation?.({ + const htmlRenderObservation = options.createHtmlRenderObservation({ cacheTags: pageTags, state: observationState, }); - const rscRenderObservation = options.createRscRenderObservation?.({ + const rscRenderObservation = options.createRscRenderObservation({ cacheTags: pageTags, state: observationState, }); const linkHeader = options.linkHeader; + // Every query shares these entries, so a render not proven to leave the + // query unread is never stored. + if ( + !hasQueryInvariantRenderProof(htmlRenderObservation) || + !hasQueryInvariantRenderProof(rscRenderObservation) + ) { + options.isrDebug?.("HTML cache write skipped (searchParams not proven unread)", htmlKey); + return; + } const writes = [ options.isrSet( htmlKey, @@ -475,10 +499,16 @@ export function scheduleAppPageRscCacheWrite( const pageTags = options.getPageTags(); const observationState = options.consumeRenderObservationState?.() ?? createEmptyAppPageRenderObservationState(); - const rscRenderObservation = options.createRscRenderObservation?.({ + const rscRenderObservation = options.createRscRenderObservation({ cacheTags: pageTags, state: observationState, }); + // Every query shares this entry, so a render not proven to leave the + // query unread is never stored. + if (!hasQueryInvariantRenderProof(rscRenderObservation)) { + options.isrDebug?.("RSC cache write skipped (searchParams not proven unread)", rscKey); + return; + } await options.isrSet(rscKey, buildAppPageCacheValue("", rscData, 200, rscRenderObservation), { cacheControl, tags: pageTags, diff --git a/packages/vinext/src/server/app-page-cache-render.ts b/packages/vinext/src/server/app-page-cache-render.ts index afb83a53c8..217bea17c1 100644 --- a/packages/vinext/src/server/app-page-cache-render.ts +++ b/packages/vinext/src/server/app-page-cache-render.ts @@ -3,7 +3,11 @@ import type { NavigationContext } from "vinext/shims/navigation"; import type { RootParams } from "vinext/shims/root-params"; import { _consumeRequestScopedCacheLife } from "vinext/shims/cache-request-state"; import type { CacheControlMetadata } from "vinext/shims/cache-handler"; -import { consumeDynamicUsage, consumeInvalidDynamicUsageError } from "vinext/shims/headers"; +import { + consumeDynamicUsage, + consumeInvalidDynamicUsageError, + isRenderDynamicLatched, +} from "vinext/shims/headers"; import { getCollectedFetchTags } from "vinext/shims/fetch-cache"; import { consumeAppPageRenderObservationState, @@ -68,6 +72,8 @@ export type RenderAppPageCacheArtifactsResult = { rscData?: ArrayBuffer; rscRenderObservation?: ReturnType; tags: string[]; + /** The render used a dynamic API, so its output must not be stored. */ + usedDynamicApi: boolean; }; /** @@ -162,7 +168,9 @@ async function renderAppPageCacheArtifactsImpl( ); const observationState = consumeAppPageRenderObservationState(); consumeInvalidDynamicUsageError(); - consumeDynamicUsage(); + // SSR runs in a child scope, so a client page's searchParams read there + // reaches only the render's dynamic latch. + const usedDynamicApi = consumeDynamicUsage() || isRenderDynamicLatched(); const htmlRenderObservation = createAppPageRenderObservation({ boundaryOutcome: { kind: "success" }, @@ -185,6 +193,7 @@ async function renderAppPageCacheArtifactsImpl( htmlRenderObservation, ...(linkHeader ? { linkHeader } : {}), tags, + usedDynamicApi, cacheControl: typeof cacheLife?.revalidate === "number" ? // `stale` must survive regeneration: this producer feeds diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index 316c22f103..262b1e92ed 100644 --- a/packages/vinext/src/server/app-page-cache.ts +++ b/packages/vinext/src/server/app-page-cache.ts @@ -7,20 +7,25 @@ import { } from "./app-rsc-cache-busting.js"; import { applyCdnResponseHeaders } from "./cache-control.js"; import { decideIsr } from "./isr-decision.js"; -import { VINEXT_MOUNTED_SLOTS_HEADER } from "./headers.js"; +import { + VINEXT_MOUNTED_SLOTS_HEADER, + VINEXT_PARAMS_HEADER, + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, +} from "./headers.js"; import { applyClientStaleTimeHeader, applyEdgeRuntimeHeader } from "./app-page-response.js"; import { resolveClientStaleTimeSeconds } from "../utils/cache-control-metadata.js"; import { setCacheStateHeaders } from "./cache-headers.js"; import { buildAppPageCacheValue, isrCacheControl, + resolveRouteExpireSeconds, type AppPageCacheSetter, type ISRCacheEntry, } from "./isr-cache.js"; import { mergeMiddlewareResponseHeaders } from "./middleware-response-headers.js"; import { encodeCacheTag } from "../utils/encode-cache-tag.js"; import type { AppRscRenderMode } from "./app-rsc-render-mode.js"; -import { hasCompleteNegativeRequestApiProof, type RenderObservation } from "./cache-proof.js"; +import { hasQueryInvariantRenderProof, type RenderObservation } from "./cache-proof.js"; import { isAppPprDynamicFallbackShellHtml } from "./app-ppr-fallback-shell.js"; import { buildPageCacheTags } from "./implicit-tags.js"; import { markFrameworkLinkHeaders } from "./app-response-header-provenance.js"; @@ -63,11 +68,19 @@ type AppPageCacheOutcomeRecorder = (metric: AppPageCacheOutcomeMetric) => void; type AppPageCacheRenderResult = { cacheControl?: CacheControlMetadata; html: string; - htmlRenderObservation?: RenderObservation; + htmlRenderObservation: RenderObservation; linkHeader?: string; rscData: ArrayBuffer; - rscRenderObservation?: RenderObservation; + rscRenderObservation: RenderObservation; + /** + * The route-level revalidate of the tree this render regenerated, or null + * when it has none and the render's cacheLife sets it. An intercepted entry + * regenerates a different tree from the matched route's, so its write takes + * this instead of the matched route's. + */ + revalidateSeconds?: number | null; tags: string[]; + usedDynamicApi: boolean; }; type BuildAppPageCachedResponseOptions = { @@ -79,6 +92,10 @@ type BuildAppPageCachedResponseOptions = { middlewareHeaders?: Headers | null; middlewareStatus?: number | null; mountedSlotsHeader?: string | null; + /** The current request's route params, which the client reads on RSC responses. */ + params?: Record; + /** The current request's path and query, which the client reads on RSC responses. */ + renderedPathAndSearch?: string | null; revalidateSeconds: number; }; @@ -86,6 +103,7 @@ type ReadAppPageCacheResponseOptions = { cleanPathname: string; clearRequestContext: () => void; isEdgeRuntime?: boolean; + isRoutePPREnabled?: boolean; isRscRequest: boolean; isrDebug?: AppPageDebugLogger; isrGet: AppPageCacheGetter; @@ -98,7 +116,14 @@ type ReadAppPageCacheResponseOptions = { middlewareHeaders?: Headers | null; middlewareStatus?: number | null; mountedSlotsHeader?: string | null; + /** + * Resolves the current request's route params for a cached RSC response. + * Called only once an entry can answer the request, since resolving them + * may load route modules. + */ + resolveParams?: () => Promise>; recordCacheOutcome?: AppPageCacheOutcomeRecorder; + renderedPathAndSearch?: string | null; renderMode?: AppRscRenderMode; expireSeconds?: number; revalidateSeconds: number; @@ -174,6 +199,7 @@ function buildAppPageCachedHeaders(options: { isEdgeRuntime?: boolean; middlewareHeaders?: Headers | null; mountedSlotsHeader?: string | null; + params?: Record; staleTimeSeconds?: number; }): Headers { const headers = new Headers({ @@ -186,6 +212,11 @@ function buildAppPageCachedHeaders(options: { setCacheStateHeaders(headers, options.cacheState); applyEdgeRuntimeHeader(headers, options.isEdgeRuntime); + // Set before middleware's headers merge, so middleware's value wins as it + // does on a fresh RSC response. + if (options.params && Object.keys(options.params).length > 0) { + headers.set(VINEXT_PARAMS_HEADER, encodeURIComponent(JSON.stringify(options.params))); + } if (options.mountedSlotsHeader) { headers.set(VINEXT_MOUNTED_SLOTS_HEADER, options.mountedSlotsHeader); } @@ -207,37 +238,58 @@ function getCachedAppPageValue(entry: ISRCacheEntry | null): CachedAppPageValue return entry?.value.value && entry.value.value.kind === "APP_PAGE" ? entry.value.value : null; } -function hasQueryInvariantAppPageProof(cachedValue: CachedAppPageValue): boolean { - return ( - cachedValue.renderObservation !== undefined && - hasCompleteNegativeRequestApiProof(cachedValue.renderObservation, ["searchParams"]) - ); -} - function resolveRegeneratedAppPageCacheControl(options: { expireSeconds?: number; renderCacheControl?: CacheControlMetadata; - routeRevalidateSeconds: number; + routeRevalidateSeconds: number | null; }): CacheControlMetadata { - let revalidateSeconds = options.routeRevalidateSeconds; const renderRevalidateSeconds = options.renderCacheControl?.revalidate; - // An indefinite nested cache lifetime does not tighten the route's own - // finite revalidation policy. + // The render's cacheLife lowers the route's revalidate, down to a + // `revalidate = 0` route's zero, and sets it for a route without one. An + // indefinite nested cache lifetime does not tighten the route's own finite + // revalidation policy. + let revalidateSeconds: number; if (typeof renderRevalidateSeconds === "number") { revalidateSeconds = - revalidateSeconds > 0 - ? Math.min(revalidateSeconds, renderRevalidateSeconds) - : renderRevalidateSeconds; + options.routeRevalidateSeconds === null + ? renderRevalidateSeconds + : Math.min(options.routeRevalidateSeconds, renderRevalidateSeconds); + } else { + revalidateSeconds = options.routeRevalidateSeconds ?? 0; } return isrCacheControl(revalidateSeconds, { - expireSeconds: options.renderCacheControl?.expire ?? options.expireSeconds, + expireSeconds: + options.renderCacheControl?.expire ?? + resolveRouteExpireSeconds(revalidateSeconds, options.expireSeconds), // Carry the regenerating render's own claim onto the refreshed entry, so a // background regen does not quietly drop it and widen client reuse. staleSeconds: resolveClientStaleTimeSeconds(options.renderCacheControl), }); } +/** + * When a regeneration fails, Next.js re-stores the previous entry with a short + * revalidate so it isn't retried on every request + * (`server/response-cache/index.ts`). `revalidate = false`, which vinext holds + * as Infinity, retries after 3 s like Next.js's `revalidate || 3`. + */ +function resolveRegenerationFailureCacheControl( + previous: CacheControlMetadata, +): CacheControlMetadata { + const previousRevalidate = + typeof previous.revalidate === "number" && Number.isFinite(previous.revalidate) + ? previous.revalidate + : 0; + const revalidate = Math.min(Math.max(previousRevalidate || 3, 3), 30); + return { + revalidate, + ...(previous.expire === undefined ? {} : { expire: Math.max(revalidate + 3, previous.expire) }), + // The client reuse bound belongs to the stored payload, which is unchanged. + ...(previous.stale === undefined ? {} : { stale: previous.stale }), + }; +} + export function buildAppPageCachedResponse( cachedValue: CachedAppPageValue, options: BuildAppPageCachedResponseOptions, @@ -268,8 +320,17 @@ export function buildAppPageCachedResponse( isEdgeRuntime: options.isEdgeRuntime, middlewareHeaders: options.middlewareHeaders, mountedSlotsHeader: options.mountedSlotsHeader, + // The params and path describe the current request, not the shared RSC + // bytes, so a hit composes them as a fresh render does. + params: options.params, staleTimeSeconds, }); + if (options.renderedPathAndSearch) { + rscHeaders.set( + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, + encodeURIComponent(options.renderedPathAndSearch), + ); + } applyRscCompatibilityIdHeader(rscHeaders); applyRscDeploymentIdHeader(rscHeaders); @@ -376,6 +437,18 @@ export async function readAppPageCacheResponse( ) : options.isrHtmlKey(options.cleanPathname); const artifact = options.isRscRequest ? "rsc" : "html"; + // Resolving params can load route modules, so its failure is the request's, + // not a cache read error to turn into a MISS; it is rethrown past the catch. + let paramsFailure: { error: unknown } | undefined; + const resolveCachedResponseParams = async (cachedValue: CachedAppPageValue) => { + if (!options.isRscRequest || !cachedValue.rscData) return undefined; + try { + return await options.resolveParams?.(); + } catch (error) { + paramsFailure = { error }; + throw error; + } + }; try { const cached = await options.isrGet(isrKey); @@ -406,7 +479,7 @@ export async function readAppPageCacheResponse( if ( cachedValue && options.hasRequestSearchParams === true && - !hasQueryInvariantAppPageProof(cachedValue) + !hasQueryInvariantRenderProof(cachedValue.renderObservation) ) { recordAppPageCacheOutcome(options.recordCacheOutcome, { artifact, @@ -428,6 +501,8 @@ export async function readAppPageCacheResponse( middlewareHeaders: options.middlewareHeaders, middlewareStatus: options.middlewareStatus, mountedSlotsHeader: options.mountedSlotsHeader, + params: await resolveCachedResponseParams(cachedValue), + renderedPathAndSearch: options.renderedPathAndSearch, revalidateSeconds: options.revalidateSeconds, }); @@ -456,6 +531,32 @@ export async function readAppPageCacheResponse( } if (cached?.isStale && cachedValue) { + // Re-store a key's previous entry with a backoff policy after a failed + // render, as Next.js does. Another regeneration can write the key too (an + // HTML one also writes the RSC key), so a newer entry is left alone. A + // missing one is restored, as Next.js restores unconditionally. + const keepPreviousEntry = async (key: string, previous: ISRCacheEntry): Promise => { + const previousValue = getCachedAppPageValue(previous); + const previousCacheControl = previous.value.cacheControl; + // Its tags come from its render observation; an entry without one can't + // be re-stored with the tags it was written with, so it is left alone. + // An expired one must not be served, so re-storing would revive it. + const previousTags = previousValue?.renderObservation?.cacheTags; + if (!previousValue || !previousCacheControl || !previousTags || previous.isExpired) return; + try { + const current = await options.isrGet(key); + if (!current || current.value.lastModified === previous.value.lastModified) { + await options.isrSet(key, previousValue, { + cacheControl: resolveRegenerationFailureCacheControl(previousCacheControl), + tags: [...previousTags], + }); + } + } catch (storeError) { + // Report the regeneration's own failure, not the store's. + console.error(`[vinext] Failed to keep the previous entry for ${key}:`, storeError); + } + }; + // Preserve the legacy behavior from the inline generator: stale entries // still trigger background regeneration even if this request cannot use // the stale payload and will fall through to a fresh render. @@ -463,59 +564,113 @@ export async function readAppPageCacheResponse( // The regeneration key is derived from exactly the same inputs as `isrKey` // above (the RSC variant when `isRscRequest`, the HTML key otherwise), so // reuse it instead of recomputing the hash. - options.scheduleBackgroundRegeneration(isrKey, async () => { + const regenerate = async (): Promise => { const revalidatedPage = await options.renderFreshPageForCache(); const cacheControl = resolveRegeneratedAppPageCacheControl({ expireSeconds: options.expireSeconds, renderCacheControl: revalidatedPage.cacheControl, - routeRevalidateSeconds: options.revalidateSeconds, + // The matched route's read seed is 0 only when it has no + // route-level revalidate, since a `revalidate = 0` route is never + // read from the cache. + routeRevalidateSeconds: + revalidatedPage.revalidateSeconds === undefined + ? options.revalidateSeconds || null + : revalidatedPage.revalidateSeconds, }); - const writes = [ - options.isrSet( - // For an RSC request `isrKey` is already the RSC variant key, so - // reuse it; an HTML-triggered regen still needs the RSC key here, - // computed lazily so a deduped (skipped) regen pays nothing. - options.isRscRequest - ? isrKey - : options.isrRscKey( - options.cleanPathname, - null, - options.renderMode, - options.interceptionContext, - options.interceptionId, - ), - buildAppPageCacheValue( - "", - revalidatedPage.rscData, - 200, - revalidatedPage.rscRenderObservation, - ), - { cacheControl, tags: revalidatedPage.tags }, + // Like Next.js, a regeneration whose render turned dynamic fails + // without PPR, whose shell expects it: a dynamic API use, or an + // effective revalidate of 0 from the regenerated tree's config, its + // fetches or its cacheLife. + // https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/build/templates/app-page.ts + if ( + options.isRoutePPREnabled !== true && + (revalidatedPage.usedDynamicApi || cacheControl.revalidate === 0) + ) { + throw new Error( + `Page changed from static to dynamic at runtime ${options.cleanPathname}` + + "\nsee more here https://nextjs.org/docs/messages/app-static-to-dynamic-error", + ); + } + // Every query shares these entries, so a render not proven to leave + // the query unread is never stored. + if ( + !hasQueryInvariantRenderProof(revalidatedPage.rscRenderObservation) || + (!options.isRscRequest && + !hasQueryInvariantRenderProof(revalidatedPage.htmlRenderObservation)) + ) { + options.isrDebug?.("regen write skipped (searchParams not proven unread)", isrKey); + return; + } + // For an RSC request `isrKey` is already the RSC variant key, so + // reuse it; an HTML-triggered regen still needs the RSC key here, + // computed lazily so a deduped (skipped) regen pays nothing. + const rscKey = options.isRscRequest + ? isrKey + : options.isrRscKey( + options.cleanPathname, + null, + options.renderMode, + options.interceptionContext, + options.interceptionId, + ); + // Next.js's IncrementalCache.set only warns when its cache handler + // fails, so a failed store is not a failed regeneration: it neither + // throws nor re-stores the previous entry with a backoff policy. + const store = async (key: string, value: CachedAppPageValue): Promise => { + try { + await options.isrSet(key, value, { cacheControl, tags: revalidatedPage.tags }); + return true; + } catch (storeError) { + console.warn(`[vinext] Failed to update prerender cache for ${key}:`, storeError); + return false; + } + }; + const storedRsc = await store( + rscKey, + buildAppPageCacheValue( + "", + revalidatedPage.rscData, + 200, + revalidatedPage.rscRenderObservation, ), - ]; + ); + // A failed RSC write leaves the previous page untouched, so the HTML + // key isn't written either. + if (!storedRsc) return; if (!options.isRscRequest) { // HTML cache is slot-state-independent (canonical), so only refresh it // during HTML-triggered regens. RSC-triggered regens only update the // requesting client's RSC slot variant; a stale HTML cache entry will // be regenerated independently by the next full-page HTML request. - writes.push( - options.isrSet( - isrKey, - buildAppPageCacheValue( - revalidatedPage.html, - undefined, - 200, - revalidatedPage.htmlRenderObservation, - revalidatedPage.linkHeader ? { link: revalidatedPage.linkHeader } : undefined, - ), - { cacheControl, tags: revalidatedPage.tags }, + const storedHtml = await store( + isrKey, + buildAppPageCacheValue( + revalidatedPage.html, + undefined, + 200, + revalidatedPage.htmlRenderObservation, + revalidatedPage.linkHeader ? { link: revalidatedPage.linkHeader } : undefined, ), ); + // A failed HTML write leaves this regeneration's RSC beside the + // stale HTML: the state an RSC-triggered regeneration leaves anyway, + // and the stale HTML regenerates on its next request. + if (!storedHtml) return; } - - await Promise.all(writes); options.isrDebug?.("regen complete", options.cleanPathname); + }; + // As in Next.js, a failed render keeps the previous entry, re-stored + // with a backoff policy. + options.scheduleBackgroundRegeneration(isrKey, async () => { + try { + await regenerate(); + } catch (error) { + // Keep the previous entry under this key only: an RSC-triggered + // regeneration must not write its payload under the HTML key. + await keepPreviousEntry(isrKey, cached); + throw error; + } }); const staleResponse = buildAppPageCachedResponse(cachedValue, { @@ -527,6 +682,8 @@ export async function readAppPageCacheResponse( middlewareHeaders: options.middlewareHeaders, middlewareStatus: options.middlewareStatus, mountedSlotsHeader: options.mountedSlotsHeader, + params: await resolveCachedResponseParams(cachedValue), + renderedPathAndSearch: options.renderedPathAndSearch, revalidateSeconds: options.revalidateSeconds, }); @@ -564,6 +721,7 @@ export async function readAppPageCacheResponse( options.isrDebug?.("MISS (no cache entry)", options.cleanPathname); } } catch (isrReadError) { + if (paramsFailure) throw paramsFailure.error; recordAppPageCacheOutcome(options.recordCacheOutcome, { artifact, cacheKey: isrKey, diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index c7d1da5c14..c06f26fae4 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -44,6 +44,7 @@ import { } from "./app-page-boundary.js"; import { buildAppPageSpecialErrorResponse, + probeAppPageComponent, probeAppPageThrownError, resolveAppPageSpecialError, type AppPageFontPreload, @@ -52,21 +53,29 @@ import { } from "./app-page-execution.js"; import { buildRscRedirectFlightStream } from "./app-rsc-redirect-flight.js"; import { resolveAppPageMethodResponse } from "./app-page-method.js"; +import { + hasAppPageInterceptDynamicSegment, + isAppPageStaticEligible, +} from "./app-segment-config.js"; import { resolveAppPageNavigationParams } from "./app-page-element-builder.js"; import { buildAppPageElement, + isAppPageInterceptAttachedToRoute, + loadAppPageCurrentRouteIntercept, + matchAppPageCurrentRouteIntercept, resolveAppPageInterceptionRerenderTarget, resolveAppPageIntercept, validateAppPageDynamicParams, type ValidateAppPageDynamicParamsOptions, } from "./app-page-request.js"; -import { renderAppPageLifecycle } from "./app-page-render.js"; +import { applyIneligibleRouteCachePolicy, renderAppPageLifecycle } from "./app-page-render.js"; import { consumeAppPageRenderObservationState, discardAppPageRenderState, } from "./app-page-render-observation.js"; import { mergeMiddlewareResponseHeaders, + resolveUncacheableCacheControl, type AppPageMiddlewareContext, } from "./app-page-response.js"; import { @@ -81,7 +90,11 @@ import { type AppRscRenderMode, } from "./app-rsc-render-mode.js"; import { shouldServeStreamingMetadata } from "./streaming-metadata.js"; -import { createAppPageTreePath } from "./app-page-route-wiring.js"; +import { + createAppPageTreePath, + getAppPageSegmentParamName, + stripAppPageInterceptionMarker, +} from "./app-page-params.js"; import { createAppPageRscErrorTracker, type AppPageSsrHandler } from "./app-page-stream.js"; import { VINEXT_INTERCEPTION_ID_HEADER, VINEXT_PRERENDER_SPECULATIVE_HEADER } from "./headers.js"; import type { ClientReuseManifestParseResult } from "./client-reuse-manifest.js"; @@ -98,7 +111,13 @@ import { isRouteCacheabilityIdentityProbe, isRouteCacheabilityProbe, markRouteCacheabilityPatternDynamic, + readRouteCacheabilityState, } from "vinext/shims/cacheability-classification"; +import { + cacheabilityManifestPageState, + type CacheabilityManifest, + type CacheabilityRepresentation, +} from "./cacheability-manifest.js"; import type { AppRenderErrorContextOverrides } from "./app-rsc-error-handler.js"; import { traceResponseStart } from "./response-start-tracing.js"; @@ -139,6 +158,7 @@ type AppPageDispatchIntercept = { interceptNotFoundBranchSegments?: readonly string[] | null; notFound?: unknown; notFoundTreePosition?: number | null; + ownerDefault?: unknown; matchedParams: AppPageParams; sourceMatchedParams?: AppPageParams; page: TPage; @@ -162,6 +182,7 @@ type AppPageDispatchInterceptOptions = { interceptNotFoundBranchSegments?: readonly string[] | null; interceptNotFound?: unknown; interceptNotFoundTreePosition?: number | null; + interceptOwnerDefault?: unknown; interceptPage: TPage; interceptParams: AppPageParams; interceptSlotId?: string | null; @@ -172,6 +193,22 @@ type AppPageDispatchInterceptOptions = { interceptTargetRouteGraphId?: string | null; }; +/** + * The intercepting branch a direct intercepted RSC response renders in place + * of the source route's slot (or page), and the pattern of the route it + * intercepts. + */ +export type AppPageStaticEligibilityIntercept = Pick< + AppPageDispatchInterceptOptions, + | "interceptBranchSegments" + | "interceptLayoutSegments" + | "interceptLayouts" + | "interceptOwnerDefault" + | "interceptPage" + | "interceptSlotKey" + | "interceptTargetPatternParts" +>; + type AppPageModule = { default?: unknown; dynamic?: unknown; @@ -304,6 +341,7 @@ export type DispatchAppPageOptions = { searchParams: URLSearchParams, layoutParamAccess?: AppLayoutParamAccessTracker, options?: { + isForceStatic?: boolean; observeMetadataSearchParamsAccess?: boolean; observePageSearchParamsAccess?: boolean; serveStreamingMetadata?: boolean; @@ -340,7 +378,13 @@ export type DispatchAppPageOptions = { getFontStyles: () => string[]; getNavigationContext: () => NavigationContext | null; getSourceRoute: (sourceRouteIndex: number) => TRoute | undefined; + /** + * Whether `generateStaticParams` is exported at or below the route's last + * dynamic segment (`hasAppPageGenerateStaticParamsAtLastDynamicSegment`). + */ hasGenerateStaticParams: boolean; + /** Whether any segment of the route exports `generateStaticParams`. */ + hasAnyGenerateStaticParams: boolean; hasCustomGlobalError?: boolean; hasPageDefaultExport: boolean; hasPageModule: boolean; @@ -348,6 +392,11 @@ export type DispatchAppPageOptions = { htmlLimitedBots?: string; interceptionContext: string | null; isEdgeRuntime?: boolean; + /** + * Whether the page or its nearest layout sets `runtime = "edge"`, which + * disables static generation. Parallel slots do not count. + */ + isStaticGenerationEdgeRuntime?: boolean; isProgressiveActionRender?: boolean; isProduction: boolean; isRscRequest: boolean; @@ -385,6 +434,16 @@ export type DispatchAppPageOptions = { rootParams?: RootParams; probeLayoutAt: (layoutIndex: number, layoutParamAccess?: AppLayoutParamAccessTracker) => unknown; probePage: (searchParams?: URLSearchParams) => unknown; + /** + * Probes what a direct intercepted RSC response renders ahead of its loading + * boundaries: the source route with the intercepting branch, for this + * request's render mode (`buildAppPageInterceptSourceProbes`). + */ + probeInterceptSource?: ( + route: TRoute, + params: AppPageParams, + searchParams: URLSearchParams, + ) => unknown; expireSeconds?: number; renderErrorBoundaryPage: ( error: unknown, @@ -412,9 +471,76 @@ export type DispatchAppPageOptions = { request: Request; revalidateSeconds: number | null; renderedPathAndSearch?: string | null; - resolveRouteFetchCacheMode?: (route: TRoute) => FetchCacheMode | null; - resolveRouteRevalidateSeconds?: (route: TRoute) => number | null; - resolveRouteDynamicConfig?: (route: TRoute) => string | null | undefined; + /** + * With an intercept, these resolve the tree a direct intercepted RSC + * response renders: the source route with the intercepting branch in place + * of what it intercepts, as `resolveRouteStaticEligible` classifies it, + * merged with the active sibling pages vinext renders where Next.js's tree + * has their defaults. + */ + resolveRouteFetchCacheMode?: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => FetchCacheMode | null; + resolveRouteRevalidateSeconds?: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => number | null; + resolveRouteDynamicConfig?: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => string | null | undefined; + resolveRouteDynamicStaleTimeSeconds?: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => number | undefined; + /** + * `dynamicParamsConfig` for the tree a direct intercepted RSC response + * renders, from the segments of the intercepting route's own tree. + */ + resolveRouteDynamicParamsConfig?: ( + route: TRoute, + intercept: AppPageStaticEligibilityIntercept, + ) => boolean | undefined; + /** + * The `dynamic` config of the intercepting route's own tree, without the + * active sibling pages `resolveRouteDynamicConfig` merges in: whether + * Next.js's production build leaves that route out of its prerender + * manifest as `force-dynamic`. + */ + resolveRouteInterceptTreeDynamicConfig?: ( + route: TRoute, + intercept: AppPageStaticEligibilityIntercept, + ) => string | null | undefined; + /** + * `generateStaticParams` for the tree a direct intercepted RSC response + * renders: the generators of the intercepting route's own tree. + */ + resolveRouteGenerateStaticParams?: ( + route: TRoute, + intercept: AppPageStaticEligibilityIntercept, + ) => ValidateAppPageDynamicParamsOptions["generateStaticParams"]; + /** + * `hasAnyGenerateStaticParams` for the tree a direct intercepted RSC + * response renders: whether any segment of the intercepting route's own + * tree exports `generateStaticParams`. + */ + resolveRouteHasAnyGenerateStaticParams?: ( + route: TRoute, + intercept: AppPageStaticEligibilityIntercept, + ) => boolean; + /** + * `isAppPageStaticEligible` for another route, from that route's own segment + * config, `generateStaticParams`, dynamism and runtime. A direct intercepted + * RSC response renders its source route with the intercepting branch in the + * intercepted slot, so it passes that branch and the pattern of the route it + * intercepts: + * Next.js classifies the intercepting route's own loader tree. + */ + resolveRouteStaticEligible: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => boolean; rootForbiddenModule?: AppPageModule | null; rootNotFoundModule?: AppPageModule | null; rootUnauthorizedModule?: AppPageModule | null; @@ -531,18 +657,26 @@ export function shouldReadAppPageCache(options: { ); } -function resolveAppPageCacheReadRevalidateSeconds(options: { +type AppPageRouteRevalidateOptions = { isDynamicError: boolean; isForceStatic: boolean; revalidateSeconds: number | null; -}): number { +}; + +/** A route's own revalidate, or null when its render's cacheLife sets it. */ +function resolveAppPageRouteRevalidateSeconds( + options: AppPageRouteRevalidateOptions, +): number | null { if (options.revalidateSeconds === null && (options.isForceStatic || options.isDynamicError)) { return Infinity; } + return options.revalidateSeconds; +} +function resolveAppPageCacheReadRevalidateSeconds(options: AppPageRouteRevalidateOptions): number { // cacheLife-only routes discover their actual revalidate during the fresh // render; this seed only gets them into the cache read path. - return options.revalidateSeconds ?? 0; + return resolveAppPageRouteRevalidateSeconds(options) ?? 0; } export function hasSearchParams(searchParams: URLSearchParams | null | undefined): boolean { @@ -604,6 +738,24 @@ async function runAppPageRevalidationContext< } } +/** + * The intercepting branch a direct intercepted RSC response renders, for the + * route config resolvers. + */ +export function toRouteConfigIntercept( + interceptOpts: AppPageDispatchInterceptOptions, +): AppPageStaticEligibilityIntercept { + return { + interceptBranchSegments: interceptOpts.interceptBranchSegments, + interceptLayoutSegments: interceptOpts.interceptLayoutSegments, + interceptLayouts: interceptOpts.interceptLayouts, + interceptOwnerDefault: interceptOpts.interceptOwnerDefault, + interceptPage: interceptOpts.interceptPage, + interceptSlotKey: interceptOpts.interceptSlotKey, + interceptTargetPatternParts: interceptOpts.interceptTargetPatternParts, + }; +} + function toInterceptOptions( interceptionContext: string | null, intercept: AppPageDispatchIntercept, @@ -620,6 +772,7 @@ function toInterceptOptions( interceptNotFoundBranchSegments: intercept.interceptNotFoundBranchSegments, interceptNotFound: intercept.notFound, interceptNotFoundTreePosition: intercept.notFoundTreePosition, + interceptOwnerDefault: intercept.ownerDefault, interceptPage: intercept.page, interceptParams: intercept.matchedParams, interceptSlotId: intercept.slotId ?? null, @@ -631,6 +784,59 @@ function toInterceptOptions( }; } +/** + * Probe the source route that a direct intercepted RSC response renders, since + * that response picks its headers before its render, and report whether it + * read a dynamic API while its element was built (viewport and metadata + * resolution) or probed. The caller discards earlier usage, which doesn't + * belong to the source, before building the element. + */ +async function probeAppPageInterceptSourceDynamicUsage( + options: DispatchAppPageOptions, + route: TRoute, + params: AppPageParams, + searchParams: URLSearchParams, +): Promise { + const probeInterceptSource = options.probeInterceptSource; + if (probeInterceptSource) { + // Special errors and other probe failures surface through the intercepted + // response's own render, as before. + await probeAppPageComponent({ + awaitAsyncResult: true, + async onError() { + return null; + }, + probePage: () => probeInterceptSource(route, params, searchParams), + runWithSuppressedHookWarning(probe) { + return options.runWithSuppressedHookWarning(probe); + }, + }); + } + return consumeDynamicUsage(); +} + +/** + * Workers Cache never admits a path its deploy manifest gives no state: a + * listed path that used a dynamic API, or an unlisted path of a route without + * on-demand ISR. Next.js serves those per request, so they render normally, + * with real values. Without a manifest, admission uses the `runtime` policy + * and every path keeps candidate mode, as on core. + */ +function hasNoManifestAdmissionState(routePattern: string): boolean { + const admission = readRouteCacheabilityState()?.admission; + return ( + admission?.policy === "manifest" && + admission.representation !== undefined && + admission.routePathname !== undefined && + cacheabilityManifestPageState( + admission.manifest as CacheabilityManifest, + { kind: "app-page", pattern: routePattern }, + admission.representation as CacheabilityRepresentation, + admission.routePathname, + ) === null + ); +} + export async function dispatchAppPage( options: DispatchAppPageOptions, ): Promise { @@ -654,23 +860,124 @@ async function dispatchAppPageInner( options.clearRequestContext(); return new Response(null, { status: 204 }); } - const dynamicConfig = options.dynamicConfig; - // Next.js treats a dynamic route with generateStaticParams as SSG even when - // the generator returns no concrete paths. Its default `revalidate = false` - // then applies to the first on-demand render of an unknown path. - // https://github.com/vercel/next.js/blob/canary/packages/next/src/build/index.ts - const currentRevalidateSeconds = - options.revalidateSeconds ?? (options.hasGenerateStaticParams ? Infinity : null); + const isDraftMode = isDraftModeRequest(options.request, options.draftModeSecret); + + // The lookup this request's interception resolution shares with its + // current-route match, so an RSC request doesn't match its path twice. + let requestIntercept: AppPageDispatchIntercept | null | undefined; + const findIntercept = (pathname: string) => { + if (pathname !== options.cleanPathname) return options.findIntercept(pathname); + if (requestIntercept === undefined) requestIntercept = options.findIntercept(pathname); + return requestIntercept; + }; + const routeHasSlot = (sourceRoute: TRoute, slotKey: string) => + !!sourceRoute.slots && Object.hasOwn(sourceRoute.slots, slotKey); + + // A route renders an interception whose source is the route itself with the + // intercepting branch in its slot (or page). Next.js serves that + // intercepting route as its own app path, so its own segment config, not + // this route's, decides how it renders and whether the response may be read + // from or written to the cache, in every render: dev, draft mode, actions + // and cacheComponents builds included. Matching loads nothing, so a request + // without the interception does no extra work. Loading it is like loading + // the route's own modules: generated params are still checked only once the + // cache misses. + const matchedCurrentRouteIntercept = await matchAppPageCurrentRouteIntercept({ + cleanPathname: options.cleanPathname, + currentRoute: route, + findIntercept, + getSourceRoute: options.getSourceRoute, + isRscRequest: options.isRscRequest, + }); + if (matchedCurrentRouteIntercept) { + await loadAppPageCurrentRouteIntercept(matchedCurrentRouteIntercept, route, routeHasSlot); + } + // An intercept the route has no slot for renders nothing, so the route + // renders with its own params. + const attachedCurrentRouteIntercept = + matchedCurrentRouteIntercept && + isAppPageInterceptAttachedToRoute(matchedCurrentRouteIntercept, route, routeHasSlot) + ? matchedCurrentRouteIntercept + : undefined; + const currentRouteIntercept = matchedCurrentRouteIntercept + ? toRouteConfigIntercept( + toInterceptOptions(options.interceptionContext, matchedCurrentRouteIntercept), + ) + : undefined; + // With an intercept, a null from these resolvers means the intercepting + // tree has no such config: this route's config belongs to the branch the + // intercept replaced. + const dynamicConfig = currentRouteIntercept + ? (options.resolveRouteDynamicConfig?.(route, currentRouteIntercept) ?? undefined) + : options.dynamicConfig; + const configRevalidateSeconds = currentRouteIntercept + ? (options.resolveRouteRevalidateSeconds?.(route, currentRouteIntercept) ?? null) + : options.revalidateSeconds; + const dynamicStaleTimeSeconds = currentRouteIntercept + ? options.resolveRouteDynamicStaleTimeSeconds?.(route, currentRouteIntercept) + : options.dynamicStaleTimeSeconds; + const dynamicParamsConfig = currentRouteIntercept + ? options.resolveRouteDynamicParamsConfig?.(route, currentRouteIntercept) + : options.dynamicParamsConfig; const interceptionId = options.isRscRequest ? options.request.headers.get(VINEXT_INTERCEPTION_ID_HEADER) : null; const isForceStatic = dynamicConfig === "force-static"; const isDynamicError = dynamicConfig === "error"; const isForceDynamic = dynamicConfig === "force-dynamic"; - if (isRouteCacheabilityProbe() && (isForceDynamic || currentRevalidateSeconds === 0)) { - markRouteCacheabilityPatternDynamic( - isForceDynamic ? 'dynamic = "force-dynamic"' : "revalidate = 0", - ); + // Only routes Next.js classifies as static or SSG from their config are + // full-page cache candidates. Every other route renders per request and is + // never stored, whatever its revalidate or cacheLife. cacheComponents builds + // (PPR fallback shells) follow a different model and keep their own rules. + const isNextStaticEligible = currentRouteIntercept + ? options.resolveRouteStaticEligible(route, currentRouteIntercept) + : isAppPageStaticEligible({ + dynamicConfig, + hasGenerateStaticParams: options.hasGenerateStaticParams, + isDynamicRoute: route.isDynamic, + isStaticGenerationEdgeRuntime: options.isStaticGenerationEdgeRuntime === true, + revalidateSeconds: configRevalidateSeconds, + }); + const isStaticEligible = options.pprRuntime !== undefined || isNextStaticEligible; + // Next.js defaults every static or SSG route to `revalidate = false`, so a + // render that uses no dynamic API is stored until it is revalidated. This + // includes a generateStaticParams route whose generator returns no concrete + // paths, on the first on-demand render of an unknown path. Dev has no ISR. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/build/index.ts + // Any segment's generator still sets this default, which the fetch shim and + // cacheComponents fallback shells read. An intercepting tree, like its + // regeneration, takes it from its own classification and generators. + const hasStaticRevalidateDefault = + options.isProduction && options.pprRuntime === undefined && isNextStaticEligible; + const hasAnyGenerateStaticParams = currentRouteIntercept + ? options.resolveRouteHasAnyGenerateStaticParams?.(route, currentRouteIntercept) === true + : options.hasAnyGenerateStaticParams; + const currentRevalidateSeconds = + configRevalidateSeconds ?? + (hasStaticRevalidateDefault || hasAnyGenerateStaticParams ? Infinity : null); + if (isRouteCacheabilityProbe()) { + // A route that isn't static or SSG is never stored, so its probe reports + // the whole pattern dynamic, whichever of its paths discovery listed. A + // pattern-wide decision comes from the route's own config: an intercepting + // tree's config covers only the requests that render it. + const isRouteForceDynamic = options.dynamicConfig === "force-dynamic"; + const isRouteStaticEligible = + options.pprRuntime !== undefined || + isAppPageStaticEligible({ + dynamicConfig: options.dynamicConfig, + hasGenerateStaticParams: options.hasGenerateStaticParams, + isDynamicRoute: route.isDynamic, + isStaticGenerationEdgeRuntime: options.isStaticGenerationEdgeRuntime === true, + revalidateSeconds: options.revalidateSeconds ?? null, + }); + const patternDynamicReason = isRouteForceDynamic + ? 'dynamic = "force-dynamic"' + : options.revalidateSeconds === 0 + ? "revalidate = 0" + : !isRouteStaticEligible + ? "route is not statically generated" + : null; + if (patternDynamicReason) markRouteCacheabilityPatternDynamic(patternDynamicReason); } const isPrerender = process.env.VINEXT_PRERENDER === "1"; let traceOperation: "prerender" | "render" = isPrerender ? "prerender" : "render"; @@ -684,7 +991,6 @@ async function dispatchAppPageInner( const placeGeneratedMetadataInBody = (!isPrerender || options.pprFallbackShell !== undefined) && serveStreamingMetadata; const isPrefetchDynamicShell = options.renderMode === APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL; - const isDraftMode = isDraftModeRequest(options.request, options.draftModeSecret); const requestHeadersContext = getHeadersContext(); const shouldUseEmptySearchParams = isForceStatic || isPrefetchDynamicShell; const hasRequestSearchParams = @@ -697,22 +1003,41 @@ async function dispatchAppPageInner( const hasActiveLoadingBoundary = activeLoadingTreePositions.length > 0; setCurrentFetchSoftTags(buildAppPageTags(options.cleanPathname, [], route.routeSegments)); - setCurrentFetchCacheMode(options.fetchCache ?? null); + setCurrentFetchCacheMode( + currentRouteIntercept + ? (options.resolveRouteFetchCacheMode?.(route, currentRouteIntercept) ?? null) + : (options.fetchCache ?? null), + ); setCurrentFetchRevalidate(currentRevalidateSeconds); setCurrentForceDynamicFetchDefault(isForceDynamic); if (options.hasPageModule && !options.hasPageDefaultExport) { options.clearRequestContext(); - return new Response("Page has no default export", { status: 500 }); + return applyIneligibleRouteCachePolicy( + new Response("Page has no default export", { status: 500 }), + { + isDraftMode, + isDynamicError, + isForceDynamic, + isForceStatic, + isProduction: options.isProduction, + isProgressiveActionRender: options.isProgressiveActionRender === true, + isRscRequest: options.isRscRequest, + isStaticEligible, + middlewareContext: options.middlewareContext, + peekDynamicUsage, + revalidateSeconds: currentRevalidateSeconds, + scriptNonce: options.scriptNonce, + }, + ); } + // The cacheComponents exemption only covers caching. Methods follow the + // route's own Next.js classification. const methodResponse = resolveAppPageMethodResponse({ - dynamicConfig, - hasGenerateStaticParams: options.hasGenerateStaticParams, - isDynamicRoute: route.isDynamic, + isStaticEligible: isNextStaticEligible, middlewareHeaders: options.middlewareContext.headers, request: options.request, - revalidateSeconds: currentRevalidateSeconds, }); if (methodResponse) { options.clearRequestContext(); @@ -743,8 +1068,14 @@ async function dispatchAppPageInner( }); } - if ( - !isRouteCacheabilityProbe() && + const resolveInterceptStaticEligible = ( + sourceRoute: TRoute, + interceptOpts: AppPageDispatchInterceptOptions, + ) => + options.pprRuntime !== undefined || + options.resolveRouteStaticEligible(sourceRoute, toRouteConfigIntercept(interceptOpts)); + + const isCacheEligibleRender = options.bypassInterceptionContextCache !== true && shouldReadAppPageCache({ isDraftMode, @@ -754,8 +1085,18 @@ async function dispatchAppPageInner( isRscRequest: options.isRscRequest, revalidateSeconds: currentRevalidateSeconds, scriptNonce: options.scriptNonce, - }) - ) { + }); + const shouldReadCache = !isRouteCacheabilityProbe() && isCacheEligibleRender; + // A render that may be stored, and so must not let the request's query reach + // its output unless it turns out dynamic. The Workers Cache deploy probe + // renders this way too, so its manifest matches the runtime. PPR fallback + // shells follow cacheComponents' model instead. + const isCacheCandidate = + isCacheEligibleRender && + isStaticEligible && + options.pprRuntime === undefined && + !hasNoManifestAdmissionState(route.pattern); + if (shouldReadCache && isStaticEligible) { traceOperation = resolveAppPageTraceOperation({ hasRequestSearchParams, isDynamicError, @@ -764,13 +1105,57 @@ async function dispatchAppPageInner( isPrerender, }); const { readAppPageCacheResponse } = await import("./app-page-cache.js"); + const cacheReadRevalidateSeconds = resolveAppPageCacheReadRevalidateSeconds({ + isDynamicError, + isForceStatic, + revalidateSeconds: currentRevalidateSeconds, + }); const reportedSsrRevalidationErrors = new Set(); let revalidationRscErrorTracker: ReturnType | null = null; + // The route, params and intercept a render for this request's cache entry + // uses. A hit sends the same navigation params a fresh render would. + const resolveCacheRenderTarget = async () => { + const revalidationTarget = await resolveAppPageInterceptionRerenderTarget({ + cleanPathname: options.cleanPathname, + currentParams: options.params, + currentRoute: route, + findIntercept: options.findIntercept, + getRouteParamNames(sourceRoute) { + return sourceRoute.params; + }, + getSourceRoute(sourceRouteIndex) { + return options.getSourceRoute(sourceRouteIndex); + }, + isRscRequest: options.isRscRequest, + routeHasSlot, + toInterceptOpts(intercept) { + return toInterceptOptions(options.interceptionContext, intercept); + }, + }); + // Hydrate the (possibly different) source route before reading its + // slots for navigation params and its page module for fetch-cache-mode + // resolution. + await options.ensureRouteLoaded?.(revalidationTarget.route); + // Use the full navigationParams (not narrowed params) as the base so + // interception-specific extras from a source-route intercept survive + // the slot param merge. resolveAppPageNavigationParams preserves all + // base keys and overlays active slot params on top; when narrowed + // params were used here, non-slot extras were silently dropped. + const mergedNavigationParams = resolveAppPageNavigationParams( + revalidationTarget.route, + revalidationTarget.navigationParams, + options.cleanPathname, + revalidationTarget.interceptOpts, + ); + revalidationTarget.navigationParams = mergedNavigationParams; + return revalidationTarget; + }; const cachedPageResponse = await readAppPageCacheResponse({ cleanPathname: options.cleanPathname, clearRequestContext: options.clearRequestContext, hasRequestSearchParams, isEdgeRuntime: options.isEdgeRuntime, + isRoutePPREnabled: options.pprRuntime !== undefined, isRscRequest: options.isRscRequest, isrDebug: options.isrDebug, isrGet: options.isrGet, @@ -782,59 +1167,77 @@ async function dispatchAppPageInner( middlewareHeaders: options.middlewareContext.headers, middlewareStatus: options.middlewareContext.status, mountedSlotsHeader: options.mountedSlotsHeader, + async resolveParams() { + return (await resolveCacheRenderTarget()).navigationParams; + }, + renderedPathAndSearch: options.renderedPathAndSearch, renderMode: options.renderMode, expireSeconds: options.expireSeconds, - revalidateSeconds: resolveAppPageCacheReadRevalidateSeconds({ - isDynamicError, - isForceStatic, - revalidateSeconds: currentRevalidateSeconds, - }), + revalidateSeconds: cacheReadRevalidateSeconds, renderFreshPageForCache: async () => { - const revalidationTarget = await resolveAppPageInterceptionRerenderTarget({ - cleanPathname: options.cleanPathname, - currentParams: options.params, - currentRoute: route, - findIntercept: options.findIntercept, - getRouteParamNames(sourceRoute) { - return sourceRoute.params; - }, - getSourceRoute(sourceRouteIndex) { - return options.getSourceRoute(sourceRouteIndex); - }, - isRscRequest: options.isRscRequest, - toInterceptOpts(intercept) { - return toInterceptOptions(options.interceptionContext, intercept); - }, - }); - // Use the full navigationParams (not narrowed params) as the base so - // interception-specific extras from a source-route intercept survive - // the slot param merge. resolveAppPageNavigationParams preserves all - // base keys and overlays active slot params on top; when narrowed - // params were used here, non-slot extras were silently dropped. - const mergedNavigationParams = resolveAppPageNavigationParams( - revalidationTarget.route, - revalidationTarget.navigationParams, - options.cleanPathname, - revalidationTarget.interceptOpts, - ); - revalidationTarget.navigationParams = mergedNavigationParams; + const revalidationTarget = await resolveCacheRenderTarget(); - // Hydrate the (possibly different) source route before reading its - // page module for fetch-cache-mode resolution. - await options.ensureRouteLoaded?.(revalidationTarget.route); + // A stale intercepted entry regenerates the tree its direct render + // rendered, so it takes that tree's config, not the source route's. + const revalidationConfigIntercept = revalidationTarget.interceptOpts + ? toRouteConfigIntercept(revalidationTarget.interceptOpts) + : undefined; + // With an intercept, a null from these resolvers means the intercepted + // tree has no such config, even when the intercept is on the matched + // route itself: the matched tree's config belongs to the branch the + // intercept replaced. + const usesMatchedRouteConfig = + revalidationConfigIntercept === undefined && revalidationTarget.route === route; const revalidationDynamicConfig = - options.resolveRouteDynamicConfig?.(revalidationTarget.route) ?? - (revalidationTarget.route === route ? dynamicConfig : undefined); + options.resolveRouteDynamicConfig?.( + revalidationTarget.route, + revalidationConfigIntercept, + ) ?? (usesMatchedRouteConfig ? dynamicConfig : undefined); + const revalidationConfigRevalidateSeconds = + options.resolveRouteRevalidateSeconds?.( + revalidationTarget.route, + revalidationConfigIntercept, + ) ?? (usesMatchedRouteConfig ? currentRevalidateSeconds : null); + // The regenerated entry stores the rendered tree's revalidate, lowered + // by what the render collects, like Next.js' prerender store: it starts + // at the tree's segment config, and a static tree without one, or one + // with a generator, keeps `revalidate = false`. + // https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/server/app-render/create-component-tree.tsx + const revalidationRouteRevalidateSeconds = + revalidationConfigIntercept === undefined + ? resolveAppPageRouteRevalidateSeconds({ + isDynamicError, + isForceStatic, + revalidateSeconds: currentRevalidateSeconds, + }) + : resolveAppPageRouteRevalidateSeconds({ + isDynamicError: revalidationDynamicConfig === "error", + isForceStatic: revalidationDynamicConfig === "force-static", + revalidateSeconds: + revalidationConfigRevalidateSeconds ?? + ((options.isProduction && + options.pprRuntime === undefined && + options.resolveRouteStaticEligible( + revalidationTarget.route, + revalidationConfigIntercept, + )) || + options.resolveRouteHasAnyGenerateStaticParams?.( + revalidationTarget.route, + revalidationConfigIntercept, + ) === true + ? Infinity + : null), + }); return runAppPageRevalidationContext( { cleanPathname: options.cleanPathname, displayPathname: options.displayPathname, currentFetchCacheMode: - options.resolveRouteFetchCacheMode?.(revalidationTarget.route) ?? - (revalidationTarget.route === route ? (options.fetchCache ?? null) : null), - currentFetchRevalidate: - options.resolveRouteRevalidateSeconds?.(revalidationTarget.route) ?? - (revalidationTarget.route === route ? currentRevalidateSeconds : null), + options.resolveRouteFetchCacheMode?.( + revalidationTarget.route, + revalidationConfigIntercept, + ) ?? (usesMatchedRouteConfig ? (options.fetchCache ?? null) : null), + currentFetchRevalidate: revalidationConfigRevalidateSeconds, draftModeSecret: options.draftModeSecret, dynamicConfig: revalidationDynamicConfig, params: revalidationTarget.navigationParams, @@ -851,6 +1254,7 @@ async function dispatchAppPageInner( new URLSearchParams(), undefined, { + isForceStatic: revalidationDynamicConfig === "force-static", observeMetadataSearchParamsAccess: revalidationDynamicConfig !== "force-static", observePageSearchParamsAccess: revalidationDynamicConfig !== "force-static", // Cache regeneration produces a complete static artifact, so metadata @@ -903,9 +1307,11 @@ async function dispatchAppPageInner( htmlRenderObservation: rendered.htmlRenderObservation, linkHeader: rendered.linkHeader, rscData: rendered.rscData!, - rscRenderObservation: rendered.rscRenderObservation, + rscRenderObservation: rendered.rscRenderObservation!, tags: rendered.tags, cacheControl: rendered.cacheControl, + revalidateSeconds: revalidationRouteRevalidateSeconds, + usedDynamicApi: rendered.usedDynamicApi, }; }, ); @@ -929,29 +1335,81 @@ async function dispatchAppPageInner( // Next.js' production force-dynamic routes are absent from the prerender // manifest, so they never enter its generated-path fallback gate. Dev still // resolves and exact-matches generateStaticParams for the same route. - if (options.skipStaticParamsValidation !== true && !(options.isProduction && isForceDynamic)) { + // A current-route interception serves the intercepting route, whose own + // tree's dynamicParams and generators gate the params it renders with. Only + // that tree's own force-dynamic leaves it out of the manifest: the active + // sibling pages vinext also renders make its render dynamic, not its route. + const isManifestForceDynamic = currentRouteIntercept + ? options.resolveRouteInterceptTreeDynamicConfig?.(route, currentRouteIntercept) === + "force-dynamic" + : isForceDynamic; + if ( + options.skipStaticParamsValidation !== true && + !(options.isProduction && isManifestForceDynamic) + ) { + // The intercepting tree's own dynamic segments make it dynamic under a + // static route, and its optional catch-alls are part of its params. + const interceptBranchSegments = attachedCurrentRouteIntercept?.interceptBranchSegments ?? []; const dynamicParamsResponse = await validateAppPageDynamicParams({ - enforceStaticParamsOnly: options.dynamicParamsConfig === false, - generateStaticParams: options.generateStaticParams, - isDynamicRoute: route.isDynamic, - params: options.staticParamsValidationParams ?? options.params, + enforceStaticParamsOnly: dynamicParamsConfig === false, + generateStaticParams: currentRouteIntercept + ? options.resolveRouteGenerateStaticParams?.(route, currentRouteIntercept) + : options.generateStaticParams, + isDynamicRoute: route.isDynamic || hasAppPageInterceptDynamicSegment(interceptBranchSegments), + optionalCatchAllParamNames: attachedCurrentRouteIntercept + ? [...route.routeSegments, ...interceptBranchSegments] + .map(stripAppPageInterceptionMarker) + .filter((segment) => segment.startsWith("[[...")) + .flatMap((segment) => getAppPageSegmentParamName(segment) ?? []) + : undefined, + // The trusted prerender values cover only the source route's params, so + // the intercepting branch's own matched params join them. + params: attachedCurrentRouteIntercept + ? { + ...attachedCurrentRouteIntercept.matchedParams, + ...options.staticParamsValidationParams, + } + : (options.staticParamsValidationParams ?? options.params), }); if (dynamicParamsResponse) { // A generated-param miss belongs to a matched App route, so render the // route's not-found boundary just like a page-level notFound() signal. // The plain response remains a defensive fallback if boundary rendering // is unavailable, but the normal path must include Next.js's canonical - // not-found markup (and custom not-found.tsx when present). + // not-found markup (and custom not-found.tsx when present). A + // current-route interception's miss belongs to the intercepting tree, so + // it renders through that tree like a notFound() from its page does. const renderedNotFound = await options.renderHttpAccessFallbackPage( 404, - { matchedParams: options.params }, + { + intercept: attachedCurrentRouteIntercept + ? toInterceptOptions(options.interceptionContext, attachedCurrentRouteIntercept) + : undefined, + matchedParams: options.params, + }, options.middlewareContext, ); + // The current-route interception classified above decides this + // response's cacheability too, like the render it replaces. + const cachePolicy = { + isDraftMode, + isDynamicError, + isForceDynamic, + isForceStatic, + isProduction: options.isProduction, + isProgressiveActionRender: options.isProgressiveActionRender === true, + isRscRequest: options.isRscRequest, + isStaticEligible, + middlewareContext: options.middlewareContext, + peekDynamicUsage, + revalidateSeconds: currentRevalidateSeconds, + scriptNonce: options.scriptNonce, + }; if (renderedNotFound) { - return renderedNotFound; + return applyIneligibleRouteCachePolicy(renderedNotFound, cachePolicy); } options.clearRequestContext(); - return dynamicParamsResponse; + return applyIneligibleRouteCachePolicy(dynamicParamsResponse, cachePolicy); } } @@ -970,6 +1428,10 @@ async function dispatchAppPageInner( let interceptDynamicConfig: string | null | undefined; let interceptDynamicConfigResolved = false; + // Whether the tree that the intercepted response renders (the source route + // with the intercepting branch) is force-dynamic or revalidate = 0, from the + // config activated for its render, or read a dynamic API while probed. + let isInterceptSourceKnownDynamic = false; const interceptResult = await resolveAppPageIntercept< TRoute, unknown, @@ -989,9 +1451,12 @@ async function dispatchAppPageInner( // The intercept route's fetch defaults must also stay active past this // call — its server components fetch lazily during the // renderToReadableStream in renderInterceptResponse below. + const routeConfigIntercept = interceptOpts + ? toRouteConfigIntercept(interceptOpts) + : undefined; const sourceDynamicConfig = interceptDynamicConfigResolved ? interceptDynamicConfig - : options.resolveRouteDynamicConfig?.(interceptRoute); + : options.resolveRouteDynamicConfig?.(interceptRoute, routeConfigIntercept); if (sourceDynamicConfig === "force-static" || sourceDynamicConfig === "error") { const { createStaticGenerationHeadersContext } = await import("./app-static-generation.js"); setHeadersContext( @@ -1006,27 +1471,45 @@ async function dispatchAppPageInner( } else { setHeadersContext(requestHeadersContext); } - setCurrentFetchCacheMode(options.resolveRouteFetchCacheMode?.(interceptRoute) ?? null); - setCurrentFetchRevalidate(options.resolveRouteRevalidateSeconds?.(interceptRoute) ?? null); + const sourceRevalidateSeconds = + options.resolveRouteRevalidateSeconds?.(interceptRoute, routeConfigIntercept) ?? null; + isInterceptSourceKnownDynamic = + sourceDynamicConfig === "force-dynamic" || sourceRevalidateSeconds === 0; + setCurrentFetchCacheMode( + options.resolveRouteFetchCacheMode?.(interceptRoute, routeConfigIntercept) ?? null, + ); + setCurrentFetchRevalidate(sourceRevalidateSeconds); setCurrentForceDynamicFetchDefault(sourceDynamicConfig === "force-dynamic"); - return options.buildPageElement( + // Usage recorded so far belongs to the matched target, not the source. + consumeDynamicUsage(); + const interceptElement = await options.buildPageElement( interceptRoute, interceptParams, interceptOpts, interceptSearchParams, interceptLayoutParamAccess, { + isForceStatic: sourceDynamicConfig === "force-static", observeMetadataSearchParamsAccess: sourceDynamicConfig !== "force-static", observePageSearchParamsAccess: sourceDynamicConfig !== "force-static", serveStreamingMetadata: placeGeneratedMetadataInBody, }, ); + if ( + await probeAppPageInterceptSourceDynamicUsage( + options, + interceptRoute, + interceptParams, + interceptSearchParams, + ) + ) { + isInterceptSourceKnownDynamic = true; + } + return interceptElement; }, cleanPathname: options.cleanPathname, currentRoute: route, - findIntercept(pathname) { - return options.findIntercept(pathname); - }, + findIntercept, getRouteParamNames(sourceRoute) { return sourceRoute.params; }, @@ -1038,7 +1521,8 @@ async function dispatchAppPageInner( resolveNavigationParams(sourceRoute, navigationParams, pathname, interceptOpts) { return resolveAppPageNavigationParams(sourceRoute, navigationParams, pathname, interceptOpts); }, - renderInterceptResponse(sourceRoute, interceptElement) { + routeHasSlot, + renderInterceptResponse(sourceRoute, interceptElement, interceptOpts) { const interceptOnError = options.createRscOnErrorHandler( options.cleanPathname, sourceRoute.pattern, @@ -1052,6 +1536,15 @@ async function dispatchAppPageInner( "Content-Type": VINEXT_RSC_CONTENT_TYPE, Vary: VINEXT_RSC_VARY_HEADER, }); + // This response renders the source route with the intercepting branch, + // so it takes that tree's cacheability and the source's dynamic config, + // not the matched target's. A render known dynamic before it starts is + // never cacheable, like the source's own render. Middleware's policy + // still wins, merged after, as in the RSC builder. + const isSourceStaticEligible = resolveInterceptStaticEligible(sourceRoute, interceptOpts); + if (!isSourceStaticEligible || isDraftMode || isInterceptSourceKnownDynamic) { + interceptHeaders.set("Cache-Control", resolveUncacheableCacheControl(options.isProduction)); + } mergeMiddlewareResponseHeaders(interceptHeaders, options.middlewareContext.headers); applyRscCompatibilityIdHeader(interceptHeaders); applyRscDeploymentIdHeader(interceptHeaders); @@ -1060,9 +1553,12 @@ async function dispatchAppPageInner( headers: interceptHeaders, }); }, - async resolveSearchParams(sourceRoute, searchParams) { + async resolveSearchParams(sourceRoute, searchParams, interceptOpts) { await options.ensureRouteLoaded?.(sourceRoute); - interceptDynamicConfig = options.resolveRouteDynamicConfig?.(sourceRoute); + interceptDynamicConfig = options.resolveRouteDynamicConfig?.( + sourceRoute, + toRouteConfigIntercept(interceptOpts), + ); interceptDynamicConfigResolved = true; return interceptDynamicConfig === "force-static" ? new URLSearchParams() : searchParams; }, @@ -1089,6 +1585,7 @@ async function dispatchAppPageInner( pageSearchParams, layoutParamAccess, { + isForceStatic, observeMetadataSearchParamsAccess: !isForceStatic, observePageSearchParamsAccess: !isForceStatic, serveStreamingMetadata: placeGeneratedMetadataInBody, @@ -1194,7 +1691,9 @@ async function dispatchAppPageInner( }, handlerStart: options.handlerStart, hasLoadingBoundary: hasActiveLoadingBoundary, - omitPendingDynamicCacheState: hasRequestSearchParams, + // Only candidate HTML renders gate searchParams, so their MISS is final. + omitPendingDynamicCacheState: + hasRequestSearchParams && !(isCacheCandidate && !options.isRscRequest), formState: options.formState ?? null, isProgressiveActionRender: options.isProgressiveActionRender === true, isDynamicError, @@ -1202,6 +1701,8 @@ async function dispatchAppPageInner( isForceDynamic, isForceStatic, isEdgeRuntime: options.isEdgeRuntime === true, + isStaticEligible, + isCacheCandidate, isPrerender, isSpeculativePrerender, isProduction: options.isProduction, @@ -1265,7 +1766,7 @@ async function dispatchAppPageInner( return runWithIsolatedDynamicUsage(fn); }, }, - dynamicStaleTimeSeconds: options.dynamicStaleTimeSeconds, + dynamicStaleTimeSeconds, revalidateSeconds: currentRevalidateSeconds, mountedSlotsHeader: options.mountedSlotsHeader, renderMode: options.renderMode ?? APP_RSC_RENDER_MODE_NAVIGATION, diff --git a/packages/vinext/src/server/app-page-element-builder.ts b/packages/vinext/src/server/app-page-element-builder.ts index 1461fe0482..8f673e7a06 100644 --- a/packages/vinext/src/server/app-page-element-builder.ts +++ b/packages/vinext/src/server/app-page-element-builder.ts @@ -1,6 +1,7 @@ import { Suspense, createElement } from "react"; import { makeThenableParams } from "vinext/shims/thenable-params"; import { withUseCachePageMarker } from "vinext/shims/internal/app-page-props-cache-key"; +import { ClientPageRoot } from "vinext/shims/client-page-root"; import { prepareAppPageHead, resolveActiveParallelRouteHeadInputs, @@ -14,7 +15,6 @@ import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "./app-rsc-route-matching.js"; import { buildAppPageElements, createAppPageSourcePage, - createAppPageTreePath, resolveAppPageLoadingModuleAtOrAbove, type AppPageErrorModule, type AppPageModule, @@ -48,24 +48,23 @@ import { makeObservedAppPageSearchParamsThenable, } from "./app-page-search-params-observation.js"; import { shouldServeStreamingMetadata } from "./streaming-metadata.js"; -import { resolveAppPageBranchParams, resolveAppPageSegmentParams } from "./app-page-params.js"; +import { + createAppPageTreePath, + resolveAppPageBranchParams, + resolveAppPageSegmentParams, + resolveInterceptLayoutParams, +} from "./app-page-params.js"; import { createAppPageRenderDependency, invokeAppComponent, + isAppClientReference, isAppRenderSuspension, isReactOwnedAppComponent, renderAfterAppDependencies, type AppPageRenderDependency, } from "./app-render-dependency.js"; import { isPromiseLike } from "../utils/promise.js"; - -function resolveInterceptLayoutParams( - branchSegments: readonly string[], - layoutSegments: readonly string[], - params: AppPageParams, -): AppPageParams { - return resolveAppPageBranchParams(branchSegments, layoutSegments.length, params, layoutSegments); -} +import { APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS } from "./app-page-interception-markers.js"; function traceAppPageLayoutModules( modules: readonly (AppPageModule | null | undefined)[], @@ -158,6 +157,8 @@ export type AppPagePageRequest = renderMode?: AppRscRenderMode; /** Observe page `searchParams` access for cache-safety classification. */ observePageSearchParamsAccess?: boolean; + /** The route is `dynamic = "force-static"`, so pages read an empty query. */ + isForceStatic?: boolean; /** Observe page metadata `searchParams` access for cache-safety classification. */ observeMetadataSearchParamsAccess?: boolean; /** Whether generated metadata may stream into the response body. */ @@ -262,6 +263,7 @@ export async function buildPageElements< renderMode = APP_RSC_RENDER_MODE_NAVIGATION, observeMetadataSearchParamsAccess = false, observePageSearchParamsAccess = false, + isForceStatic = false, serveStreamingMetadata, isProduction = process.env.NODE_ENV === "production", } = pageRequest; @@ -556,7 +558,6 @@ export async function buildPageElements< void streamingMetadataOutlet?.catch(() => null); const pageProps: Record = { params: makeThenableParams(effectiveParams) }; - const hasRequestSearchParams = Object.keys(pageSearchParams).length > 0; const pageTreePosition = (sourcePageSegments ?? route.routeSegments ?? []).length; const hasPageLoadingBoundary = resolveAppPageLoadingModuleAtOrAbove(route, pageTreePosition) !== null || @@ -569,6 +570,12 @@ export async function buildPageElements< }, pageTreePosition, ) !== null); + // A client page reads an empty query in SSR and the browser alike when the + // server renders every page with one. A static export build renders each + // page once without a query, so a client page read must not make it dynamic, + // which would drop it from the export. + const hasEmptyClientPageSearchParams = + isForceStatic || (isProduction && process.env.__NEXT_CONFIG_OUTPUT === "export"); const pageRenderDependency = EffectivePageComponent && !isReactOwnedAppComponent(EffectivePageComponent) ? createAppPageRenderDependency() @@ -578,13 +585,30 @@ export async function buildPageElements< props: Readonly>, renderDependency?: AppPageRenderDependency | null, ) => { + if (searchParams && isAppClientReference(PageComponent)) { + // Like Next.js's ClientPageRoot, a client page gets `searchParams` where + // it renders, not through Flight. Flight would call `then` on the prop + // while serializing it, so every client page would count as reading the + // query, and its RSC payload would carry it. Slot props arrive with the + // route's searchParams attached, so drop them here. + const { searchParams: _slotSearchParams, ...pageProps } = props; + return createElement(ClientPageRoot, { + Component: PageComponent, + pageProps, + ...(hasEmptyClientPageSearchParams ? { emptySearchParams: true } : {}), + }); + } + if (isReactOwnedAppComponent(PageComponent)) { + // Class components and other non-function exports, which React renders + // itself. A read marks the render dynamic only to keep this branch + // consistent with function component pages: React 19's Flight server + // calls any function that isn't a client reference as a function + // component, so an ES class page can't render in RSC at all. const invocationProps: Record = { ...props }; if (searchParams) { invocationProps.searchParams = observePageSearchParamsAccess - ? makeObservedAppPageSearchParamsThenable(pageSearchParams, { - markDynamic: hasRequestSearchParams, - }) + ? makeObservedAppPageSearchParamsThenable(pageSearchParams) : makeThenableParams(pageSearchParams); } return createElement(PageComponent, withUseCachePageMarker(PageComponent, invocationProps)); @@ -830,13 +854,6 @@ function buildSlotOverrides 0 ? overrides : null; } -export const APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS = [ - { prefix: "(...)", levels: Number.POSITIVE_INFINITY }, - { prefix: "(..)(..)", levels: 2 }, - { prefix: "(..)", levels: 1 }, - { prefix: "(.)", levels: 0 }, -] as const; - function resolveInterceptedSlotSource( sourcePageSegments: readonly string[] | null | undefined, slotKey: string, diff --git a/packages/vinext/src/server/app-page-head.ts b/packages/vinext/src/server/app-page-head.ts index c68887e466..1afb1374c4 100644 --- a/packages/vinext/src/server/app-page-head.ts +++ b/packages/vinext/src/server/app-page-head.ts @@ -16,6 +16,7 @@ import { tagAppPageMetadataError } from "./app-page-execution.js"; import { createAppMetadataModuleRoute, traceGenerateMetadata } from "./app-metadata-tracing.js"; import { resolveAppPageBranchParams, resolveAppPageSegmentParams } from "./app-page-params.js"; import type { MetadataFileRoute } from "./metadata-routes.js"; +import { searchParamsToRecord } from "../utils/query.js"; /** * Wrapped {@link _resolveModuleMetadata} that tags any thrown error with the @@ -339,24 +340,8 @@ function hasGenerateMetadata(module: AppPageHeadModule | null | undefined): bool export function collectAppPageSearchParams( searchParams: URLSearchParams | null | undefined, ): AppPageSearchParamsCollection { - const pageSearchParams: AppPageSearchParams = Object.create(null); - let hasSearchParams = false; - - searchParams?.forEach((value, key) => { - hasSearchParams = true; - const currentValue = pageSearchParams[key]; - if (Array.isArray(currentValue)) { - pageSearchParams[key] = [...currentValue, value]; - return; - } - if (currentValue !== undefined) { - pageSearchParams[key] = [currentValue, value]; - return; - } - pageSearchParams[key] = value; - }); - - return { hasSearchParams, pageSearchParams }; + const pageSearchParams = searchParamsToRecord(searchParams); + return { hasSearchParams: Object.keys(pageSearchParams).length > 0, pageSearchParams }; } function createMetadataSources( diff --git a/packages/vinext/src/server/app-page-interception-markers.ts b/packages/vinext/src/server/app-page-interception-markers.ts new file mode 100644 index 0000000000..c5ca358cdb --- /dev/null +++ b/packages/vinext/src/server/app-page-interception-markers.ts @@ -0,0 +1,7 @@ +// Intercepting route folder markers and how many segments each climbs. +export const APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS = [ + { prefix: "(...)", levels: Number.POSITIVE_INFINITY }, + { prefix: "(..)(..)", levels: 2 }, + { prefix: "(..)", levels: 1 }, + { prefix: "(.)", levels: 0 }, +] as const; diff --git a/packages/vinext/src/server/app-page-method.ts b/packages/vinext/src/server/app-page-method.ts index 6401653bb2..670a2b4309 100644 --- a/packages/vinext/src/server/app-page-method.ts +++ b/packages/vinext/src/server/app-page-method.ts @@ -2,43 +2,18 @@ import { isPossibleAppRouteActionRequest } from "./app-action-request.js"; import { mergeMiddlewareResponseHeaders } from "./middleware-response-headers.js"; import { methodNotAllowedResponse } from "./http-error-responses.js"; -type AppPageMethodPolicyOptions = { - dynamicConfig?: string; - hasGenerateStaticParams: boolean; - isDynamicRoute: boolean; - revalidateSeconds: number | null; -}; - type ResolveAppPageMethodResponseOptions = { + /** `isAppPageStaticEligible` for the route. */ + isStaticEligible: boolean; middlewareHeaders?: Headers | null; request: Pick; -} & AppPageMethodPolicyOptions; +}; function isNonGetOrHead(method: string): boolean { const normalizedMethod = method.toUpperCase(); return normalizedMethod !== "GET" && normalizedMethod !== "HEAD"; } -export function isStaticOrSsgAppPageCandidate(options: AppPageMethodPolicyOptions): boolean { - if (options.dynamicConfig === "force-dynamic" || options.revalidateSeconds === 0) { - return false; - } - - if (options.dynamicConfig === "force-static" || options.dynamicConfig === "error") { - return true; - } - - if (options.revalidateSeconds !== null && options.revalidateSeconds > 0) { - return true; - } - - if (options.hasGenerateStaticParams) { - return true; - } - - return !options.isDynamicRoute; -} - export function resolveAppPageMethodResponse( options: ResolveAppPageMethodResponseOptions, ): Response | null { @@ -50,7 +25,9 @@ export function resolveAppPageMethodResponse( return null; } - if (!isStaticOrSsgAppPageCandidate(options)) { + // Next.js answers non-GET/HEAD requests to static and SSG pages with 405. + // Dynamic pages render for every method. + if (!options.isStaticEligible) { return null; } diff --git a/packages/vinext/src/server/app-page-params.ts b/packages/vinext/src/server/app-page-params.ts index 248d8fa960..da12f27d60 100644 --- a/packages/vinext/src/server/app-page-params.ts +++ b/packages/vinext/src/server/app-page-params.ts @@ -1,4 +1,17 @@ import type { AppPageParams } from "./app-page-boundary.js"; +import { APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS } from "./app-page-interception-markers.js"; + +/** + * A loader-tree segment without its interception marker. An intercepting + * route's tree keeps its markers, and `(.)[photo]` names the `photo` param. + * https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/shared/lib/router/utils/get-segment-param.tsx + */ +export function stripAppPageInterceptionMarker(segment: string): string { + const marker = APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS.find(({ prefix }) => + segment.startsWith(prefix), + ); + return marker ? segment.slice(marker.prefix.length) : segment; +} export function getAppPageSegmentParamName(segment: string): string | null { if (segment.startsWith("[[...") && segment.endsWith("]]") && segment.length > 7) { @@ -21,6 +34,17 @@ export function getAppPageSegmentParamName(segment: string): string | null { return null; } +export function createAppPageTreePath( + routeSegments: readonly string[] | null | undefined, + treePosition: number, +): string { + const treePathSegments = routeSegments?.slice(0, treePosition) ?? []; + if (treePathSegments.length === 0) { + return "/"; + } + return `/${treePathSegments.join("/")}`; +} + function isEmptyOptionalCatchAll(segment: string, paramValue: string | string[]): boolean { return segment.startsWith("[[...") && Array.isArray(paramValue) && paramValue.length === 0; } @@ -93,3 +117,21 @@ export function resolveAppPageBranchParams( ); return scopedParams; } + +/** Params of a layout at `treePosition` in a parallel slot's branch. */ +export function resolveSlotLayoutParams( + routeSegments: readonly string[], + treePosition: number, + params: AppPageParams, +): AppPageParams { + return resolveAppPageBranchParams(routeSegments, treePosition, params); +} + +/** Params of a sibling-page intercepting layout at `layoutSegments`. */ +export function resolveInterceptLayoutParams( + branchSegments: readonly string[], + layoutSegments: readonly string[], + params: AppPageParams, +): AppPageParams { + return resolveAppPageBranchParams(branchSegments, layoutSegments.length, params, layoutSegments); +} diff --git a/packages/vinext/src/server/app-page-prefetch-plan.ts b/packages/vinext/src/server/app-page-prefetch-plan.ts new file mode 100644 index 0000000000..fbb9b88885 --- /dev/null +++ b/packages/vinext/src/server/app-page-prefetch-plan.ts @@ -0,0 +1,210 @@ +import { + APP_RSC_RENDER_MODE_NAVIGATION, + APP_RSC_RENDER_MODE_PREFETCH_EMPTY, + APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + type AppRscRenderMode, +} from "./app-rsc-render-mode.js"; + +// Which parts of a route an App page render mode includes, shared by route +// wiring (which builds the payload) and the probes that run ahead of a +// response's headers. This module has no runtime dependency on either, so both +// can import it. + +type AppPagePrefetchModule = Readonly<{ default?: unknown }>; + +export type AppPageLoadingEntry = { + loadingModule?: TModule | null | undefined; + treePosition: number; +}; + +type AppPagePrefetchRouteLoadings = Readonly<{ + loading?: TModule | null; + loadings?: readonly (TModule | null | undefined)[] | null; + loadingTreePositions?: readonly number[] | null; +}>; + +type AppPagePrefetchSlot = Readonly<{ + loading?: TModule | null; + loadings?: readonly (TModule | null | undefined)[] | null; + loadingTreePositions?: readonly number[] | null; + name?: string; + ownerTreePosition?: number | null; +}>; + +type AppPagePrefetchSlotOverride = Readonly<{ + loadingModules?: readonly (TModule | null | undefined)[] | null; + loadingTreePositions?: readonly number[] | null; +}>; + +type AppPagePrefetchRoute = + AppPagePrefetchRouteLoadings & + Readonly<{ + routeSegments?: readonly string[] | null; + slots?: Readonly | null | undefined>> | null; + }>; + +export function createAppPageLoadingEntries( + route: AppPagePrefetchRouteLoadings, +): AppPageLoadingEntry[] { + return (route.loadings ?? []).flatMap((loadingModule, index) => { + if (!loadingModule) return []; + const treePosition = route.loadingTreePositions?.[index]; + if (treePosition === undefined) return []; + return [{ loadingModule, treePosition }]; + }); +} + +function getPrefetchLoadingEntry( + route: AppPagePrefetchRouteLoadings & + Readonly<{ routeSegments?: readonly string[] | null }>, +): AppPageLoadingEntry | null { + let rootEntry: AppPageLoadingEntry | null = null; + let firstNestedEntry: AppPageLoadingEntry | null = null; + for (const [index, loadingModule] of (route.loadings ?? []).entries()) { + if (!loadingModule?.default) continue; + const treePosition = route.loadingTreePositions?.[index]; + if (treePosition === undefined) continue; + if (treePosition === 0) { + rootEntry ??= { loadingModule, treePosition }; + } else if (firstNestedEntry === null || treePosition < firstNestedEntry.treePosition) { + firstNestedEntry = { loadingModule, treePosition }; + } + } + // The root layout is already shared for a client-side prefetch. Prefer the + // first loading boundary below it, falling back to the root loading UI only + // when no nested boundary exists. + if (firstNestedEntry) return firstNestedEntry; + if (rootEntry) return rootEntry; + + // Legacy/eager route fixtures may only expose the leaf loading field. + return route.loading?.default + ? { loadingModule: route.loading, treePosition: route.routeSegments?.length ?? 0 } + : null; +} + +export function createAppPageSlotLoadingEntries( + slot: AppPagePrefetchSlot, + override: AppPagePrefetchSlotOverride | null, +): AppPageLoadingEntry[] { + const entries: AppPageLoadingEntry[] = []; + const slotLoadingModules = + (slot.loadings?.length ?? 0) > 0 ? slot.loadings! : slot.loading ? [slot.loading] : []; + const slotLoadingTreePositions = + (slot.loadingTreePositions?.length ?? 0) > 0 ? slot.loadingTreePositions! : [0]; + + for (const [index, loadingModule] of slotLoadingModules.entries()) { + const treePosition = slotLoadingTreePositions[index]; + if (!loadingModule?.default || treePosition === undefined) continue; + // An interception replaces the slot's normal active branch. Only the slot + // root is necessarily shared; nested normal-branch loadings belong to a + // sibling subtree and must not wrap the intercepting page. + if (override && treePosition !== 0) continue; + entries.push({ loadingModule, treePosition }); + } + + for (const [index, loadingModule] of (override?.loadingModules ?? []).entries()) { + const treePosition = override?.loadingTreePositions?.[index]; + if (!loadingModule?.default || treePosition === undefined) continue; + entries.push({ loadingModule, treePosition }); + } + + return entries; +} + +export function getFirstLoadingEntry( + entries: readonly AppPageLoadingEntry[], +): AppPageLoadingEntry | null { + return entries.reduce | null>( + (first, entry) => (first === null || entry.treePosition < first.treePosition ? entry : first), + null, + ); +} + +export type AppPagePrefetchPlan = Readonly<{ + /** A `prefetch-empty` render includes no layout, template, page or slot. */ + isPrefetchEmpty: boolean; + isPrefetchLoadingShell: boolean; + /** The route loading boundary a loading-shell prefetch stops at. */ + prefetchLoadingEntry: AppPageLoadingEntry | null; + prefetchSlotLoadingEntries: readonly { ownerTreePosition: number }[]; + /** Whether a loading-shell prefetch includes the route segment at this position. */ + includesTreePosition(treePosition: number): boolean; + /** Whether the payload includes a parallel slot at all. */ + includesSlot(ownerTreePosition: number, targetTreePosition: number): boolean; + /** + * The loading boundary a loading-shell prefetch renders for an included slot + * in place of its page, or null when the shell omits the slot. A slot owned + * at the route's cutoff renders the route's loading UI and none of its + * branch layouts. + */ + resolveSlotLoadingEntry( + ownerTreePosition: number, + slotLoadingEntries: readonly AppPageLoadingEntry[], + ): { entry: AppPageLoadingEntry | null; isOwnedAtRoutePrefetchCutoff: boolean }; +}>; + +export function resolveAppPagePrefetchPlan(options: { + renderMode: AppRscRenderMode | undefined; + resolveSlotOverride: ( + slotKey: string, + slotName: string, + ) => AppPagePrefetchSlotOverride | null | undefined; + route: AppPagePrefetchRoute; +}): AppPagePrefetchPlan { + const renderMode = options.renderMode ?? APP_RSC_RENDER_MODE_NAVIGATION; + const isPrefetchEmpty = renderMode === APP_RSC_RENDER_MODE_PREFETCH_EMPTY; + const isPrefetchLoadingShell = renderMode === APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL; + const prefetchLoadingEntry = isPrefetchLoadingShell + ? getPrefetchLoadingEntry(options.route) + : null; + const prefetchSlotLoadingEntries = isPrefetchLoadingShell + ? Object.entries(options.route.slots ?? {}).flatMap(([slotKey, slot]) => { + if (!slot) return []; + const override = options.resolveSlotOverride(slotKey, slot.name ?? slotKey) ?? null; + const firstLoadingEntry = getFirstLoadingEntry( + createAppPageSlotLoadingEntries(slot, override), + ); + return firstLoadingEntry ? [{ ownerTreePosition: slot.ownerTreePosition ?? 0 }] : []; + }) + : []; + // The children spine must reach every slot owner whose branch has a loading + // boundary. A loading on the spine itself stops traversal first, matching + // Next.js's per-parallel-route pre-PPR component-tree walk. + const prefetchCutoffTreePosition = isPrefetchLoadingShell + ? (prefetchLoadingEntry?.treePosition ?? + prefetchSlotLoadingEntries.reduce( + (deepest, entry) => Math.max(deepest, entry.ownerTreePosition), + 0, + )) + : null; + const includesTreePosition = (treePosition: number): boolean => + prefetchCutoffTreePosition === null || treePosition <= prefetchCutoffTreePosition; + return { + isPrefetchEmpty, + isPrefetchLoadingShell, + prefetchLoadingEntry, + prefetchSlotLoadingEntries, + includesTreePosition, + includesSlot(ownerTreePosition, targetTreePosition) { + if (isPrefetchEmpty) return false; + if (!isPrefetchLoadingShell) return true; + return prefetchLoadingEntry + ? ownerTreePosition <= prefetchLoadingEntry.treePosition + : includesTreePosition(targetTreePosition); + }, + resolveSlotLoadingEntry(ownerTreePosition, slotLoadingEntries) { + const isOwnedAtRoutePrefetchCutoff = + isPrefetchLoadingShell && + prefetchLoadingEntry !== null && + ownerTreePosition === prefetchLoadingEntry.treePosition; + return { + entry: isOwnedAtRoutePrefetchCutoff + ? prefetchLoadingEntry + : isPrefetchLoadingShell + ? getFirstLoadingEntry(slotLoadingEntries) + : null, + isOwnedAtRoutePrefetchCutoff, + }; + }, + }; +} diff --git a/packages/vinext/src/server/app-page-probe.ts b/packages/vinext/src/server/app-page-probe.ts index eefb0cf16a..b267ca9c83 100644 --- a/packages/vinext/src/server/app-page-probe.ts +++ b/packages/vinext/src/server/app-page-probe.ts @@ -1,4 +1,4 @@ -import { Fragment, isValidElement, type ReactElement, type ReactNode } from "react"; +import { createElement, Fragment, isValidElement, type ReactElement, type ReactNode } from "react"; import { markAppPagePropsForUseCache, withUseCachePageMarker, @@ -13,7 +13,24 @@ import { type LayoutFlags, } from "./app-page-execution.js"; import { makeObservedAppPageSearchParamsThenable } from "./app-page-search-params-observation.js"; +import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "./app-rsc-route-matching.js"; import { isPromiseLike } from "../utils/promise.js"; +import type { AppPageParams } from "./app-page-boundary.js"; +import { + createAppPageTreePath, + resolveAppPageSegmentParams, + resolveInterceptLayoutParams, + resolveSlotLayoutParams, +} from "./app-page-params.js"; +import { createAppElementsWireSlotId } from "./app-elements-wire-key.js"; +import { + createAppPageLoadingEntries, + createAppPageSlotLoadingEntries, + getFirstLoadingEntry, + resolveAppPagePrefetchPlan, + type AppPageLoadingEntry, +} from "./app-page-prefetch-plan.js"; +import type { AppRscRenderMode } from "./app-rsc-render-mode.js"; const DEFAULT_SUBTREE_PROBE_MAX_DEPTH = 32; const DEFAULT_SUBTREE_PROBE_MAX_NODES = 1000; @@ -287,23 +304,37 @@ type AppPageProbeModule = Readonly<{ default?: unknown }> | null | undefined; type AppPageProbeSlot = | Readonly<{ + name?: string; + default?: AppPageProbeModule; + layout?: AppPageProbeModule; + layoutIndex?: number; + ownerTreePosition?: number | null; page?: AppPageProbeModule; loading?: AppPageProbeModule; loadings?: readonly AppPageProbeModule[] | null; loadingTreePositions?: readonly number[] | null; + configLayouts?: readonly AppPageProbeModule[] | null; + configLayoutTreePositions?: readonly number[] | null; + routeSegments?: readonly string[] | null; }> | null | undefined; type AppPageProbeRoute = Readonly<{ + layoutTreePositions?: readonly number[] | null; + routeSegments?: readonly string[] | null; slots?: Readonly> | null; }>; type AppPageProbeIntercept = | Readonly<{ page?: AppPageProbeModule; + interceptBranchSegments?: readonly string[] | null; + interceptLayouts?: readonly AppPageProbeModule[] | null; + interceptLayoutSegments?: readonly (readonly string[])[] | null; interceptLoadings?: readonly AppPageProbeModule[] | null; - matchedParams?: unknown; + interceptLoadingTreePositions?: readonly number[] | null; + matchedParams?: AppPageParams; /** * Key of the parallel-route slot this interception overrides. At render * time the matched route's `slots[slotKey].page` is replaced by the @@ -315,6 +346,46 @@ type AppPageProbeIntercept = | null | undefined; +/** + * The interception a request's probes cover, or null when it renders nothing. + * A slot intercept whose slot the route doesn't have leaves the route's tree + * unchanged (the renderer has no slot to put it in), so its page is neither + * loaded nor probed. A sibling-page intercept replaces the route's page. + */ +export function resolveAppPageProbeIntercept( + route: AppPageProbeRoute, + intercept: TIntercept, +): TIntercept | null { + const slotKey = intercept?.slotKey; + if ( + slotKey && + slotKey !== SIBLING_PAGE_INTERCEPT_SLOT_KEY && + !(route.slots && Object.hasOwn(route.slots, slotKey)) + ) { + return null; + } + return intercept ?? null; +} + +/** + * Where an active interception renders, as the element builder places it: in + * place of the page for a sibling-page intercept, or in the slot it names. + * Null when the route has no such slot, which keeps its own tree unchanged + * (buildSlotOverrides, resolveAppPageInterceptTree). + */ +function resolveAppPageInterceptPlacement( + route: AppPageProbeRoute, + intercept: AppPageProbeIntercept, +): + | { kind: "page" } + | { kind: "slot"; slotKey: string; slot: NonNullable } + | null { + if (!intercept?.slotKey) return null; + if (intercept.slotKey === SIBLING_PAGE_INTERCEPT_SLOT_KEY) return { kind: "page" }; + const slot = route.slots?.[intercept.slotKey]; + return slot ? { kind: "slot", slotKey: intercept.slotKey, slot } : null; +} + /** * Fan out the per-request page probes for the App Router dispatch lifecycle. * @@ -328,32 +399,27 @@ type AppPageProbeIntercept = * Entry Modules Should Stay Thin"). Returns a list of resolved promises so the * caller can `Promise.all` them. * - * The fan-out is scoped to the page components that render for this request: + * The fan-out is scoped to the page components that render for this request, + * as the element builder and route wiring select them: * - * - **Interception override:** when an interception matches it replaces the - * page of the slot named by `intercept.slotKey` (the element builder sets - * `overrides[slotKey].pageModule` to the interception page, which wins over - * `slot.page` in `app-page-route-wiring.tsx`). We probe the interception page - * in place of that slot's own page rather than probing both — probing the - * overridden slot page would mark an otherwise-static request dynamic for a - * component that never renders. - * - **Non-overridden slots:** `slot.page?.default` is exactly what renders. - * `app-page-route-wiring.tsx` resolves a slot to `overrideOrPageComponent ?? - * defaultComponent`, so whenever a slot has a `page.tsx` that page renders. - * When a slot has only a `default.tsx` (including the soft-nav case at - * `app-page-route-wiring.tsx:741` that skips an already-mounted slot), there - * is no `slot.page?.default`, so `probeAppPage` short-circuits to `null` and - * probes nothing — a no-op, not an over-bail. + * - **Interception:** a matched interception renders in place of the page for + * a sibling-page intercept, or replaces the page of the slot named by + * `intercept.slotKey` (`buildSlotOverrides`). We probe the interception page + * in place of what it replaces rather than probing both, since probing a + * component that never renders would mark an otherwise-static request + * dynamic. A route without the named slot renders unchanged, so its + * interception isn't probed. + * - **Other slots:** `slot.page?.default` is exactly what renders when the + * slot has a page. A default-only slot probes nothing — a no-op, not an + * over-bail. * * Interception only fires for RSC navigations (`resolveAppPageInterceptState` - * returns `kind: "none"` when `!isRscRequest`, app-page-request.ts:324), so the - * interception handling here is gated on `isRscRequest`. For non-RSC (HTML) - * requests the matched route renders normally, so we probe every slot's own - * page and skip the interception probe entirely. The remaining "source-route" - * interception case (where a *different* route renders, app-page-request.ts:342) - * never reaches this probe: `dispatchAppPage` returns the intercepted response - * before calling `probePage`, so by the time this runs any matched interception - * is the current-route override case above. + * returns `kind: "none"` when `!isRscRequest`), so the interception handling + * here is gated on `isRscRequest`. For non-RSC (HTML) requests the matched + * route renders normally, so we probe every slot's own page and skip the + * interception probe entirely. The matched route's probe sees the + * current-route override case; a direct intercepted RSC response probes the + * source route that it renders with `buildAppPageInterceptSourceProbes`. * * A `default.tsx` that itself awaits `searchParams` is not probed here, but the * real render still observes that access and skips the query-invariant cache @@ -379,13 +445,18 @@ export function buildAppPageProbes(options: { // Interception only fires for RSC navigations; on HTML requests the matched // route renders normally, so ignore any interception match entirely. const intercept = options.isRscRequest ? options.intercept : null; + const placement = resolveAppPageInterceptPlacement(route, intercept); - const probes: unknown[] = [probeAppPage({ pageComponent, asyncRouteParams, searchParams })]; + // A sibling-page intercept's page (probed below) renders in place of this one. + const probes: unknown[] = + placement?.kind === "page" + ? [] + : [probeAppPage({ pageComponent, asyncRouteParams, searchParams })]; // A slot whose page is replaced by an active interception override does not // render its own `page.tsx`; the interception page (probed below) renders in // its place, so skip the overridden slot to avoid a false dynamic bailout. - const overriddenSlotKey = intercept?.slotKey ?? null; + const overriddenSlotKey = placement?.kind === "slot" ? placement.slotKey : null; for (const [slotKey, slot] of Object.entries(route.slots ?? {})) { if (overriddenSlotKey !== null && slotKey === overriddenSlotKey) { @@ -403,7 +474,7 @@ export function buildAppPageProbes(options: { ); } - const interceptedSlot = intercept?.slotKey ? route.slots?.[intercept.slotKey] : null; + const interceptedSlot = placement?.kind === "slot" ? placement.slot : null; const interceptedSlotHasRootLoading = Boolean( interceptedSlot?.loading?.default || interceptedSlot?.loadings?.some( @@ -414,7 +485,7 @@ export function buildAppPageProbes(options: { intercept?.interceptLoadings?.some((loading) => loading?.default) || interceptedSlotHasRootLoading, ); - if (intercept && !interceptHasLoadingBoundary) { + if (intercept && placement && !interceptHasLoadingBoundary) { probes.push( probeAppPage({ pageComponent: intercept.page?.default, @@ -427,6 +498,304 @@ export function buildAppPageProbes(options: { return probes.map((probe) => Promise.resolve(probe)); } +type AppPageInterceptSourceProbeRoute = AppPageProbeRoute & + Readonly<{ + layouts?: readonly AppPageProbeModule[] | null; + loading?: AppPageProbeModule; + loadings?: readonly AppPageProbeModule[] | null; + loadingTreePositions?: readonly number[] | null; + templates?: readonly AppPageProbeModule[] | null; + templateTreePositions?: readonly number[] | null; + }>; + +type AppPageProbedLayout = { + layoutModule: AppPageProbeModule; + /** Null for a template, which gets only its children. */ + params: AppPageParams | null; + treePosition: number; +}; + +function ignoreProbeOutcome(): void {} + +/** + * Probes what a direct intercepted RSC response renders ahead of its loading + * boundaries, since it sets its headers before its render: the source route's + * layouts and templates, its page (or a sibling-page intercept's layouts and + * page), each parallel slot's layout chain and page or default, and the first + * loading component each of those branches renders as its fallback, with the + * intercepting branch in the slot it intercepts. What renders follows route + * wiring (app-page-route-wiring.tsx), including the render mode's prefetch + * plan (a loading-shell prefetch's selected route and slot loading components + * take the pages' place) and the default-only slots the client keeps mounted, + * and each component gets the params the rendered tree passes it. + * + * Each probe settles without rejecting: the response's own render surfaces + * special errors and other failures. + */ +export function buildAppPageInterceptSourceProbes(options: { + route: AppPageInterceptSourceProbeRoute; + pageComponent: unknown; + intercept?: AppPageProbeIntercept; + /** The source route's params, which the intercepted render passes its tree. */ + sourceParams: AppPageParams; + /** + * The params an inherited slot renders with in place of `sourceParams`, + * by slot key, as `resolveSlotParamOverrides` matches them for the request. + */ + slotParamOverrides?: Readonly> | null; + searchParams: URLSearchParams | null | undefined; + mountedSlotsHeader: string | null | undefined; + renderMode: AppRscRenderMode | undefined; + makeThenableParams: (params: unknown) => unknown; +}): Promise[] { + const { route, intercept, sourceParams, searchParams, makeThenableParams } = options; + const placement = resolveAppPageInterceptPlacement(route, intercept); + const interceptedSlotOverride = + placement?.kind === "slot" + ? { + loadingModules: intercept?.interceptLoadings, + loadingTreePositions: intercept?.interceptLoadingTreePositions, + } + : null; + const resolveSlotOverride = (slotKey: string) => + placement?.kind === "slot" && slotKey === placement.slotKey ? interceptedSlotOverride : null; + const prefetchPlan = resolveAppPagePrefetchPlan({ + renderMode: options.renderMode, + resolveSlotOverride, + route, + }); + if (prefetchPlan.isPrefetchEmpty) return []; + + const probes: Promise[] = []; + const probe = (run: () => unknown) => { + probes.push(Promise.resolve().then(run).then(ignoreProbeOutcome, ignoreProbeOutcome)); + }; + // Like the layout probes, stop at a loading boundary: what's below it + // streams behind its fallback, after the response headers. + const probeLayoutsAbove = ( + layouts: readonly AppPageProbedLayout[], + loadingTreePosition: number, + ) => { + for (const { layoutModule, params, treePosition } of layouts) { + const LayoutComponent = layoutModule?.default; + if (typeof LayoutComponent !== "function" || treePosition > loadingTreePosition) continue; + probe(() => + probeReactServerSubtree( + createElement( + LayoutComponent as (props: { params?: unknown }) => ReactNode, + params ? { params: makeThenableParams(params) } : null, + createElement(Fragment), + ), + ), + ); + } + }; + const firstLoadingTreePosition = (entries: readonly AppPageLoadingEntry[]) => + getFirstLoadingEntry(entries)?.treePosition ?? Infinity; + // A loading-shell prefetch renders its selected loading components directly, + // in place of the page or slot page they'd wrap. Otherwise each branch's + // first loading component renders as its Suspense fallback, ahead of what + // that boundary wraps. + const probeLoading = (entry: AppPageLoadingEntry | null) => { + const LoadingComponent = entry?.loadingModule?.default; + if (typeof LoadingComponent !== "function") return; + probe(() => probeReactServerSubtree(createElement(LoadingComponent as () => ReactNode))); + }; + + const routeLoadingEntries = createAppPageLoadingEntries(route).filter( + (entry) => entry.loadingModule?.default, + ); + // Legacy/eager route fixtures may only expose the leaf loading field. + if (routeLoadingEntries.length === 0 && route.loading?.default) { + routeLoadingEntries.push({ + loadingModule: route.loading, + treePosition: route.routeSegments?.length ?? 0, + }); + } + const routeLoadingTreePosition = firstLoadingTreePosition(routeLoadingEntries); + const routeLayouts: AppPageProbedLayout[] = []; + for (const [index, layoutModule] of (route.layouts ?? []).entries()) { + const treePosition = route.layoutTreePositions?.[index] ?? 0; + routeLayouts.push({ + layoutModule, + params: resolveAppPageSegmentParams(route.routeSegments, treePosition, sourceParams), + treePosition, + }); + } + for (const [index, layoutModule] of (route.templates ?? []).entries()) { + routeLayouts.push({ + layoutModule, + params: null, + treePosition: route.templateTreePositions?.[index] ?? 0, + }); + } + // A loading-shell prefetch renders no route Suspense boundary, only the + // layouts down to its cutoff and the loading UI selected there. + probeLayoutsAbove( + routeLayouts.filter(({ treePosition }) => prefetchPlan.includesTreePosition(treePosition)), + prefetchPlan.isPrefetchLoadingShell ? Infinity : routeLoadingTreePosition, + ); + probeLoading( + prefetchPlan.isPrefetchLoadingShell + ? prefetchPlan.prefetchLoadingEntry + : getFirstLoadingEntry(routeLoadingEntries), + ); + + // Every route loading boundary wraps the page, which a loading-shell + // prefetch omits. + if (routeLoadingTreePosition === Infinity && !prefetchPlan.isPrefetchLoadingShell) { + if (placement?.kind === "page") { + // The intercepting page and its layouts take the source page's place. + const interceptParams = intercept?.matchedParams ?? sourceParams; + const interceptLoadingEntries = (intercept?.interceptLoadings ?? []).flatMap( + (loadingModule, index) => { + const treePosition = intercept?.interceptLoadingTreePositions?.[index]; + return loadingModule?.default && treePosition !== undefined + ? [{ loadingModule, treePosition }] + : []; + }, + ); + probeLayoutsAbove( + (intercept?.interceptLayouts ?? []).map((layoutModule, index) => { + const layoutSegments = intercept?.interceptLayoutSegments?.[index] ?? []; + return { + layoutModule, + params: resolveInterceptLayoutParams( + intercept?.interceptBranchSegments ?? layoutSegments, + layoutSegments, + interceptParams, + ), + treePosition: layoutSegments.length, + }; + }), + firstLoadingTreePosition(interceptLoadingEntries), + ); + probeLoading(getFirstLoadingEntry(interceptLoadingEntries)); + if (interceptLoadingEntries.length === 0) { + probe(() => + probeAppPage({ + pageComponent: intercept?.page?.default, + asyncRouteParams: makeThenableParams(interceptParams), + searchParams, + }), + ); + } + } else { + probe(() => + probeAppPage({ + pageComponent: options.pageComponent, + asyncRouteParams: makeThenableParams(sourceParams), + searchParams, + }), + ); + } + } + + const mountedSlotIds = options.mountedSlotsHeader + ? new Set(options.mountedSlotsHeader.split(" ")) + : null; + const layoutTreePositions = route.layoutTreePositions ?? []; + for (const [slotKey, slot] of Object.entries(route.slots ?? {})) { + if (!slot) continue; + const targetIndex = + (slot.layoutIndex ?? -1) >= 0 ? slot.layoutIndex! : layoutTreePositions.length - 1; + const targetTreePosition = layoutTreePositions[targetIndex] ?? 0; + const ownerTreePosition = slot.ownerTreePosition ?? targetTreePosition; + if (!prefetchPlan.includesSlot(ownerTreePosition, targetTreePosition)) continue; + // A route loading boundary at or above the slot's owner wraps the whole + // slot, except in a loading-shell prefetch. + if (!prefetchPlan.isPrefetchLoadingShell && routeLoadingTreePosition <= ownerTreePosition) { + continue; + } + const isIntercepted = placement?.kind === "slot" && placement.slotKey === slotKey; + const pageComponent = + (isIntercepted ? intercept?.page?.default : undefined) ?? slot.page?.default; + const defaultComponent = slot.default?.default; + const slotId = + slot.name === undefined + ? null + : createAppElementsWireSlotId( + slot.name, + createAppPageTreePath(route.routeSegments, targetTreePosition), + ); + // The payload leaves out a default-only slot the client keeps mounted. + if (!pageComponent && (!defaultComponent || (slotId && mountedSlotIds?.has(slotId)))) { + continue; + } + + const slotLoadingEntries = createAppPageSlotLoadingEntries( + slot, + isIntercepted ? interceptedSlotOverride : null, + ); + let slotLoadingTreePosition = firstLoadingTreePosition(slotLoadingEntries); + if (prefetchPlan.isPrefetchLoadingShell) { + // The shell renders a loading boundary in place of the slot's page. + const shellLoading = prefetchPlan.resolveSlotLoadingEntry( + ownerTreePosition, + slotLoadingEntries, + ); + if (!shellLoading.entry) continue; + slotLoadingTreePosition = shellLoading.isOwnedAtRoutePrefetchCutoff + ? -1 + : shellLoading.entry.treePosition; + // The route's loading UI, when the slot is owned at its cutoff, is + // already probed above. + if (!shellLoading.isOwnedAtRoutePrefetchCutoff) probeLoading(shellLoading.entry); + } else { + probeLoading(getFirstLoadingEntry(slotLoadingEntries)); + } + + const slotOwnerParams = resolveAppPageSegmentParams( + route.routeSegments, + targetTreePosition, + sourceParams, + ); + const slotParams = isIntercepted + ? (intercept?.matchedParams ?? sourceParams) + : (options.slotParamOverrides?.[slotKey] ?? sourceParams); + // The slot's own layout wraps its page, not its default. + const slotLayouts: AppPageProbedLayout[] = pageComponent + ? [{ layoutModule: slot.layout, params: slotOwnerParams, treePosition: 0 }] + : []; + if (isIntercepted) { + const branchSegments = intercept?.interceptBranchSegments ?? []; + for (const [index, layoutModule] of (intercept?.interceptLayouts ?? []).entries()) { + const treePosition = + intercept?.interceptLayoutSegments?.[index]?.length ?? branchSegments.length; + slotLayouts.push({ + layoutModule, + params: resolveSlotLayoutParams(branchSegments, treePosition, slotParams), + treePosition, + }); + } + } else { + for (const [index, layoutModule] of (slot.configLayouts ?? []).entries()) { + const treePosition = slot.configLayoutTreePositions?.[index] ?? 0; + slotLayouts.push({ + layoutModule, + params: { + ...slotOwnerParams, + ...resolveSlotLayoutParams(slot.routeSegments ?? [], treePosition, slotParams), + }, + treePosition, + }); + } + } + probeLayoutsAbove(slotLayouts, slotLoadingTreePosition); + if (slotLoadingTreePosition === Infinity) { + probe(() => + probeAppPage({ + pageComponent: pageComponent ?? defaultComponent, + asyncRouteParams: makeThenableParams(slotParams), + searchParams, + }), + ); + } + } + + return probes; +} + type ProbeAppPageBeforeRenderResult = { response: Response | null; layoutFlags: LayoutFlags; diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index e7e0c1e4ee..4ef804fb2e 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -7,6 +7,9 @@ import { runWithFetchDedupe } from "vinext/shims/fetch-cache"; import { resolveClientStaleTimeSeconds } from "../utils/cache-control-metadata.js"; import { AppElementsWire, isAppElementsRecord, type AppOutgoingElements } from "./app-elements.js"; import { hasDigest } from "./app-rsc-errors.js"; +import { internalServerErrorResponse } from "./http-error-responses.js"; +import { mergeMiddlewareResponseHeaders } from "./middleware-response-headers.js"; +import { isBailoutToCSRError } from "vinext/shims/navigation-errors"; import { finalizeAppPageCacheabilityEvaluationResponse, finalizeAppPageHtmlCacheResponse, @@ -23,10 +26,12 @@ import { } from "./app-page-execution.js"; import { probeAppPageBeforeRender } from "./app-page-probe.js"; import { + applyEdgeRuntimeHeader, buildAppPageHtmlResponse, buildAppPageRscResponse, resolveAppPageHtmlResponsePolicy, resolveAppPageRscResponsePolicy, + resolveUncacheableCacheControl, type AppPageMiddlewareContext, type AppPageResponseTiming, } from "./app-page-response.js"; @@ -62,6 +67,7 @@ import type { } from "./client-reuse-manifest.js"; import { applyCdnResponseHeaders, + isCdnResponsePolicyHeader, NEVER_CACHE_CONTROL, NO_STORE_CACHE_CONTROL, } from "./cache-control.js"; @@ -83,14 +89,22 @@ import type { StaticLayoutObservationSkipRejection, } from "./app-layout-param-observation.js"; import { getStaticLayoutObservationSkipRejection } from "./app-layout-param-observation.js"; -import { peekDynamicUsage } from "vinext/shims/headers"; +import { isRenderDynamicLatched, peekDynamicUsage } from "vinext/shims/headers"; +import { + bindRequestContext, + preserveFullyBufferedBodyMetadata, +} from "vinext/shims/unified-request-context"; import { VINEXT_RSC_COMPLETION_METADATA_HEADER } from "./headers.js"; import { appendRscCompletionMetadata } from "./rsc-completion-metadata.js"; import type { AppRenderErrorContextOverrides } from "./app-rsc-error-handler.js"; import { recordAppPageRenderError, traceAppPageRender } from "./app-page-tracing.js"; import type { FrameworkSpan } from "./framework-tracer.js"; import { traceResponseStartWithCompletion } from "./response-start-tracing.js"; -import { recordRouteCacheabilityClientTraceMetadataMarker } from "vinext/shims/cacheability-classification"; +import { copyLinkHeaderProvenance } from "./app-response-header-provenance.js"; +import { + isRouteCacheabilityEvaluation, + recordRouteCacheabilityClientTraceMetadataMarker, +} from "vinext/shims/cacheability-classification"; type AppPageBoundaryOnError = ( error: unknown, @@ -155,6 +169,17 @@ type RenderAppPageLifecycleOptionsBase = { isEdgeRuntime?: boolean; isForceDynamic: boolean; isForceStatic: boolean; + /** + * Whether Next.js would classify the route as static or SSG from its config + * (`isAppPageStaticEligible`). Other routes are never full-page cached. + */ + isStaticEligible: boolean; + /** + * Production render that may be stored under a query-free key: SSR + * `useSearchParams()` waits until the render is known to be dynamic, and + * bails out to client rendering otherwise. + */ + isCacheCandidate?: boolean; isProgressiveActionRender?: boolean; isPrerender?: boolean; isSpeculativePrerender?: boolean; @@ -313,11 +338,84 @@ function applyRequestCacheLife(options: { return { expireSeconds, revalidateSeconds }; } +/** + * A render that is known dynamic is never cacheable: a route Next.js can't make + * static, draft mode, `force-dynamic`, `revalidate = 0`, or a dynamic API read + * before the response left the render. Responses that leave the render before + * its response policy, such as error boundaries and special errors, get the + * same header as the normal render. + */ +export function applyIneligibleRouteCachePolicy( + response: Response, + options: Pick< + RenderAppPageLifecycleOptions, + | "isDraftMode" + | "isDynamicError" + | "isForceDynamic" + | "isForceStatic" + | "isProduction" + | "isProgressiveActionRender" + | "isRscRequest" + | "isStaticEligible" + | "middlewareContext" + | "peekDynamicUsage" + | "revalidateSeconds" + | "scriptNonce" + >, +): Response { + const cacheControl = resolveEarlyResponseCacheControl(options); + if (!cacheControl) return response; + // Middleware's own cache policy wins, as in the normal response builders. + // Only keep what this response already carries from it. + const middlewarePolicy = [...(options.middlewareContext.headers ?? [])].filter( + ([name, value]) => isCdnResponsePolicyHeader(name) && response.headers.get(name) === value, + ); + if (middlewarePolicy.some(([name]) => name === "cache-control")) return response; + // Some early responses have immutable headers, so stamp a copy. + const stamped = preserveFullyBufferedBodyMetadata( + response, + new Response(response.body, response as ResponseInit), + ); + copyLinkHeaderProvenance(response.headers, stamped.headers); + applyCdnResponseHeaders(stamped.headers, { cacheControl }); + for (const [name, value] of middlewarePolicy) stamped.headers.set(name, value); + return stamped; +} + +/** The known-dynamic branches of the RSC and HTML response policies, in their order. */ +function resolveEarlyResponseCacheControl( + options: Parameters[1], +): string | null { + const uncacheable = resolveUncacheableCacheControl(options.isProduction); + if (!options.isStaticEligible || options.isDraftMode || options.isForceDynamic) { + return uncacheable; + } + // The HTML policy checks nonce-bearing and progressive action renders next, + // and keeps them no-store whatever else they do. + if (!options.isRscRequest && (options.scriptNonce || options.isProgressiveActionRender)) { + return NO_STORE_CACHE_CONTROL; + } + // As in the HTML response policy, only force-static and dynamic = "error" + // without a revalidate period stay static after a dynamic API read. + const ignoresDynamicUsage = + (options.isForceStatic || options.isDynamicError) && + (options.revalidateSeconds === null || options.revalidateSeconds === Infinity); + const isKnownDynamic = + options.revalidateSeconds === 0 || + (!ignoresDynamicUsage && (options.peekDynamicUsage?.() ?? peekDynamicUsage())); + return isKnownDynamic ? uncacheable : null; +} + function resolveAppPageCacheWriteRevalidateSeconds(options: { isDynamicError: boolean; isForceStatic: boolean; + isStaticEligible: boolean; revalidateSeconds: number | null; }): number | null { + if (!options.isStaticEligible) { + return null; + } + if (options.revalidateSeconds === null && (options.isForceStatic || options.isDynamicError)) { return Infinity; } @@ -661,7 +759,9 @@ export async function renderAppPageLifecycle( ): Promise { if (options.isRscRequest) { const prepared = await prepareAppPageElement(options); - return prepared instanceof Response ? prepared : renderAppPageLifecycleImpl(prepared); + return prepared instanceof Response + ? applyIneligibleRouteCachePolicy(prepared, options) + : renderAppPageLifecycleImpl(prepared); } const operation = options.traceOperation ?? (options.isPrerender ? "prerender" : "render"); @@ -676,7 +776,9 @@ export async function renderAppPageLifecycle( try { const prepared = await prepareAppPageElement(options); if (prepared instanceof Response) { - const traced = traceResponseStartWithCompletion(prepared); + const traced = traceResponseStartWithCompletion( + applyIneligibleRouteCachePolicy(prepared, options), + ); resolveResponse(traced.response); await traced.started; return; @@ -723,8 +825,37 @@ async function renderAppPageLifecycleImpl( // cannot hide it from the other. let dynamicUsageObserved = false; let dynamicUsageFinalized = false; + const isCacheCandidateHtmlRender = + options.isCacheCandidate === true && options.isPrerender !== true && !options.isRscRequest; + // HTML renders that decide whether the page is static also read the + // request's dynamic latch: a candidate's store decision, the Worker's probe + // and admission, and the build prerender. The latch sees usage in child + // scopes that never reach this render's flag: the layout probe, and SSR, where + // a client page reads its searchParams. A candidate's SSR useSearchParams() + // gate also opens with the real query once the render latches dynamic. + // PPR fallback shells discard their warmup render's usage and keep their + // client page query untracked, so they don't read it. + const readsRenderDynamicLatch = + isCacheCandidateHtmlRender || + (!options.isRscRequest && + options.pprFallbackShellSignal === undefined && + (options.isPrerender === true || isRouteCacheabilityEvaluation())); + // Some readers, such as the streamed completion marker, run in the response + // stream's pull context rather than this render's request scope. + const readDynamicUsage = bindRequestContext( + (): boolean => + options.consumeDynamicUsage() || (readsRenderDynamicLatch && isRenderDynamicLatched()), + ); + // Deferred finalization can also consume the render's observations from + // outside its request scope, such as a disconnecting client's cancel. + const consumeRenderObservationState = + options.consumeRenderObservationState && + bindRequestContext(options.consumeRenderObservationState); + const peekRenderDynamicUsage = (): boolean => + (options.peekDynamicUsage?.() ?? peekDynamicUsage()) || + (readsRenderDynamicLatch && isRenderDynamicLatched()); const consumeRenderDynamicUsage = (): boolean => { - if (!dynamicUsageObserved) dynamicUsageObserved = options.consumeDynamicUsage(); + if (!dynamicUsageObserved) dynamicUsageObserved = readDynamicUsage(); return dynamicUsageObserved; }; const finalizeRenderDynamicUsage = (): boolean => { @@ -762,7 +893,7 @@ async function renderAppPageLifecycleImpl( classification: options.classification, }); if (preRenderResult.response) { - return preRenderResult.response; + return applyIneligibleRouteCachePolicy(preRenderResult.response, options); } const layoutFlags = preRenderResult.layoutFlags; @@ -869,14 +1000,20 @@ async function renderAppPageLifecycleImpl( const shouldWaitForAllReady = options.isPrerender === true && options.isSpeculativePrerender !== true; const shouldReadRequestCacheLifeForPrerender = options.isPrerender === true; + // A cache candidate's cacheLife can still lower its lifetime after headers, + // including under the default `revalidate = false`. const mayResolveCacheLifeAfterHeaders = options.isProgressiveActionRender !== true && - (revalidateSeconds === null || (revalidateSeconds > 0 && revalidateSeconds !== Infinity)) && + (revalidateSeconds === null || revalidateSeconds > 0) && !options.isDraftMode && !options.isForceDynamic && !shouldBypassRscCache; + // Only cache candidates capture the RSC payload. A dynamic route's payload is + // never stored, even when a cacheLife resolves during its render. const shouldCaptureRscForCacheMetadata = - (options.isProduction || options.isPrerender === true) && mayResolveCacheLifeAfterHeaders; + (options.isProduction || options.isPrerender === true) && + mayResolveCacheLifeAfterHeaders && + options.isStaticEligible; const createBufferedRscStream = (close: boolean): ReadableStream => new ReadableStream({ start(controller) { @@ -933,6 +1070,7 @@ async function renderAppPageLifecycleImpl( isForceDynamic: options.isForceDynamic, isForceStatic: options.isForceStatic, isProduction: options.isProduction, + isStaticEligible: options.isStaticEligible, expireSeconds, revalidateSeconds, }); @@ -1041,7 +1179,7 @@ async function renderAppPageLifecycleImpl( bypassInterceptionContextCache: options.bypassInterceptionContextCache, cleanPathname: options.cleanPathname, consumeDynamicUsage: finalizeRenderDynamicUsage, - consumeRenderObservationState: options.consumeRenderObservationState, + consumeRenderObservationState, createRscRenderObservation(input) { return createAppPageRenderObservation({ boundaryOutcome: { kind: "success" }, @@ -1071,9 +1209,11 @@ async function renderAppPageLifecycleImpl( renderMode: options.renderMode, preserveClientResponseHeaders: rscResponsePolicy.cacheState !== "MISS", expireSeconds, + isStaticEligible: options.isStaticEligible, revalidateSeconds: resolveAppPageCacheWriteRevalidateSeconds({ isDynamicError: options.isDynamicError, isForceStatic: options.isForceStatic, + isStaticEligible: options.isStaticEligible, revalidateSeconds, }), waitUntil(promise) { @@ -1109,6 +1249,22 @@ async function renderAppPageLifecycleImpl( } }, renderErrorBoundaryResponse(error) { + if (isCacheCandidateHtmlRender && isBailoutToCSRError(error)) { + // A gated useSearchParams() outside Suspense. Next.js rethrows the + // bail-out instead of rendering an error boundary, so the request + // fails with a 500 and nothing is stored. + console.error( + `${error.reason} should be wrapped in a suspense boundary at page "${options.routePattern}". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout`, + ); + options.clearRequestContext(); + const headers = new Headers(); + mergeMiddlewareResponseHeaders(headers, options.middlewareContext.headers); + headers.set("Cache-Control", NEVER_CACHE_CONTROL); + applyEdgeRuntimeHeader(headers, options.isEdgeRuntime); + const response = internalServerErrorResponse(undefined, { headers }); + applyCdnResponseHeaders(response.headers, { cacheControl: NEVER_CACHE_CONTROL }); + return Promise.resolve(response); + } const capturedRscError = rscErrorTracker.getCapturedError(); return options.renderErrorBoundaryResponse( capturedRscError ?? error, @@ -1199,6 +1355,7 @@ async function renderAppPageLifecycleImpl( waitForAllReady: shouldWaitForAllReady, isStaticGeneration: options.isPrerender === true, isForceStatic: options.isForceStatic, + isCacheCandidate: isCacheCandidateHtmlRender, onSsrError: createAppPageSsrErrorHandler(onSsrError, rscErrorTracker.isCapturedError), }); }, @@ -1209,7 +1366,7 @@ async function renderAppPageLifecycleImpl( }); options.onRenderComplete?.(htmlRender.renderComplete); if (htmlRender.response) { - return htmlRender.response; + return applyIneligibleRouteCachePolicy(htmlRender.response, options); } let htmlStream = htmlRender.htmlStream; if (!htmlStream) { @@ -1245,7 +1402,10 @@ async function renderAppPageLifecycleImpl( const specialError = resolveAppPageSpecialError(captured); if (specialError) { void htmlStream.cancel().catch(() => {}); - return options.renderPageSpecialError(specialError); + return applyIneligibleRouteCachePolicy( + await options.renderPageSpecialError(specialError), + options, + ); } } } @@ -1256,7 +1416,7 @@ async function renderAppPageLifecycleImpl( const stopSpeculativeMetadataWaitOnDynamicUsage = options.isSpeculativePrerender === true && shouldReadRequestCacheLifeForPrerender ? () => { - if (dynamicUsedDuringRender || (options.peekDynamicUsage?.() ?? peekDynamicUsage())) { + if (dynamicUsedDuringRender || peekRenderDynamicUsage()) { dynamicUsedDuringRender = true; dynamicUsedDuringHtmlRender = true; return true; @@ -1270,6 +1430,16 @@ async function renderAppPageLifecycleImpl( stopSpeculativeMetadataWaitOnDynamicUsage, ); } + if (stopSpeculativeMetadataWaitOnDynamicUsage) { + // A speculative prerender returns SSR's stream at the shell, but client + // code rendered after it, such as a client page reading its searchParams + // inside Suspense, can still make the page dynamic, and the headers must + // say so. Wait for SSR to finish unless the render is already dynamic. + // This adds no time to a static render, whose body only closes once SSR + // finishes anyway. A render that turns dynamic stops the wait, and + // prerender skips it without reading the rest of its body. + await waitUnlessDynamic(htmlRender.renderComplete, stopSpeculativeMetadataWaitOnDynamicUsage); + } if (shouldReadRequestCacheLifeForPrerender) { requestCacheLifeForPrerender = readRequestCacheLifeForPrerender(options); ({ expireSeconds, revalidateSeconds } = applyRequestCacheLife({ @@ -1306,6 +1476,7 @@ async function renderAppPageLifecycleImpl( isForceDynamic: options.isForceDynamic, isForceStatic: options.isForceStatic, isProduction: options.isProduction, + isStaticEligible: options.isStaticEligible, expireSeconds, revalidateSeconds, }); @@ -1371,7 +1542,7 @@ async function renderAppPageLifecycleImpl( cleanPathname: options.cleanPathname, clientTraceMetadataMarker, consumeDynamicUsage: consumeRenderDynamicUsage, - consumeRenderObservationState: options.consumeRenderObservationState, + consumeRenderObservationState, createHtmlRenderObservation(input) { return createAppPageRenderObservation({ boundaryOutcome: { kind: "success" }, @@ -1411,9 +1582,11 @@ async function renderAppPageLifecycleImpl( omitPendingDynamicCacheState: options.omitPendingDynamicCacheState, preserveClientResponseHeaders: !htmlResponsePolicy.shouldWriteToCache, expireSeconds, + isStaticEligible: options.isStaticEligible, revalidateSeconds: resolveAppPageCacheWriteRevalidateSeconds({ isDynamicError: options.isDynamicError, isForceStatic: options.isForceStatic, + isStaticEligible: options.isStaticEligible, revalidateSeconds, }), linkHeader: linkHeader ?? null, @@ -1438,7 +1611,7 @@ async function renderAppPageLifecycleImpl( return dynamicUsedBeforeContextCleanup; }, consumeDynamicUsage: consumeRenderDynamicUsage, - consumeRenderObservationState: options.consumeRenderObservationState, + consumeRenderObservationState, getPageTags() { return options.getPageTags(); }, @@ -1446,9 +1619,11 @@ async function renderAppPageLifecycleImpl( return readRequestCacheLifeForCachePolicy(options); }, expireSeconds, + isStaticEligible: options.isStaticEligible, revalidateSeconds: resolveAppPageCacheWriteRevalidateSeconds({ isDynamicError: options.isDynamicError, isForceStatic: options.isForceStatic, + isStaticEligible: options.isStaticEligible, revalidateSeconds, }), }); @@ -1473,13 +1648,21 @@ async function settleCapturedRscRenderForCacheMetadata( return; } + await waitUnlessDynamic(capturedRscDataPromise, shouldStopWaiting); +} + +/** + * Wait for `promise` to settle, or until `shouldStopWaiting` reports that the + * render turned dynamic. Rejections are ignored: the response stream and the + * cache-write path own render error propagation. + */ +async function waitUnlessDynamic( + promise: Promise, + shouldStopWaiting: () => boolean, +): Promise { let settled = false; - const settledPromise = capturedRscDataPromise - .catch(() => { - // The response stream and cache-write path own render error propagation. - // This pre-read only makes "use cache" metadata available before headers - // and ISR seed metadata are finalized. - }) + const settledPromise = promise + .catch(() => {}) .then(() => { settled = true; }); diff --git a/packages/vinext/src/server/app-page-request.ts b/packages/vinext/src/server/app-page-request.ts index d6fe14ab99..60e4447185 100644 --- a/packages/vinext/src/server/app-page-request.ts +++ b/packages/vinext/src/server/app-page-request.ts @@ -1,14 +1,16 @@ import type { AppPageSpecialError } from "./app-page-execution.js"; import { runWithFetchDedupe } from "vinext/shims/fetch-cache"; -import { getAppPageSegmentParamName } from "./app-page-params.js"; +import { getAppPageSegmentParamName, stripAppPageInterceptionMarker } from "./app-page-params.js"; import { matchRoutePattern } from "../routing/route-pattern.js"; import { notFoundResponse } from "./http-error-responses.js"; import type { AppLayoutParamAccessTracker } from "./app-layout-param-observation.js"; import { loadAppInterceptLayouts, loadAppInterceptNotFound, + loadAppInterceptOwnerDefault, loadAppInterceptPage, } from "./app-route-module-loader.js"; +import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "./app-rsc-route-matching.js"; type AppPageParams = Record; type GenerateStaticParams = (args: { params: AppPageParams }) => unknown; @@ -95,11 +97,15 @@ type AppPageInterceptMatch = { notFound?: unknown; __loadNotFound?: (() => Promise) | null; notFoundTreePosition?: number | null; + ownerDefault?: unknown; + __loadOwnerDefault?: (() => Promise) | null; __loadState?: { page: TPage; pageLoading: Promise | null; notFound?: unknown; notFoundLoading?: Promise | null; + ownerDefault?: unknown; + ownerDefaultLoading?: Promise | null; interceptLayoutsLoading: Promise | null; }; slotId?: string | null; @@ -115,16 +121,22 @@ type AppPageInterceptState = | { kind: "current-route"; intercept: AppPageInterceptMatch } | { kind: "source-route"; intercept: AppPageInterceptMatch; sourceRoute: TRoute }; -type ResolveAppPageInterceptStateOptions = { +type MatchAppPageInterceptOptions = { cleanPathname: string; currentRoute: TRoute; findIntercept: (pathname: string) => AppPageInterceptMatch | null; - getRouteParamNames: (route: TRoute) => readonly string[]; getSourceRoute: (sourceRouteIndex: number) => Awaitable; isRscRequest: boolean; - toInterceptOpts: (intercept: AppPageInterceptMatch) => TInterceptOpts; }; +type ResolveAppPageInterceptStateOptions = + MatchAppPageInterceptOptions & { + getRouteParamNames: (route: TRoute) => readonly string[]; + /** When given, an intercept the rendered route has no slot for isn't loaded. */ + routeHasSlot?: (route: TRoute, slotKey: string) => boolean; + toInterceptOpts: (intercept: AppPageInterceptMatch) => TInterceptOpts; + }; + type ResolveAppPageInterceptionRerenderTargetOptions = { cleanPathname: string; currentParams: AppPageParams; @@ -133,6 +145,8 @@ type ResolveAppPageInterceptionRerenderTargetOptions readonly string[]; getSourceRoute: (sourceRouteIndex: number) => Awaitable; isRscRequest: boolean; + /** When given, an intercept the rendered route has no slot for isn't loaded. */ + routeHasSlot?: (route: TRoute, slotKey: string) => boolean; toInterceptOpts: (intercept: AppPageInterceptMatch) => TInterceptOpts; }; @@ -174,10 +188,17 @@ type ResolveAppPageInterceptOptions = { pathname: string, interceptOpts: TInterceptOpts, ) => AppPageParams; - renderInterceptResponse: (route: TRoute, element: TElement) => Promise | Response; + /** Whether `route` has the parallel slot keyed `slotKey`. */ + routeHasSlot: (route: TRoute, slotKey: string) => boolean; + renderInterceptResponse: ( + route: TRoute, + element: TElement, + interceptOpts: TInterceptOpts, + ) => Promise | Response; resolveSearchParams?: ( route: TRoute, searchParams: URLSearchParams, + interceptOpts: TInterceptOpts, ) => Awaitable; searchParams: URLSearchParams; setNavigationContext: (context: { @@ -249,6 +270,11 @@ function remapRouteParams( return params; } +/** The param a loader-tree segment names, interception marker included. */ +function getGenerateStaticParamsSegmentParamName(segment: string): string | null { + return getAppPageSegmentParamName(stripAppPageInterceptionMarker(segment)); +} + function collectParentParamNames( routeSegments: readonly string[], boundaryPosition: number, @@ -257,7 +283,7 @@ function collectParentParamNames( const names: string[] = []; for (const segment of routeSegments.slice(0, limit)) { - const name = getAppPageSegmentParamName(segment); + const name = getGenerateStaticParamsSegmentParamName(segment); if (name && !names.includes(name)) { names.push(name); } @@ -295,7 +321,7 @@ function getParallelParentParamNames( const branchParamNames = collectParentParamNames(branch.routeSegments ?? [], boundaryPosition); const branchParamNameSet = new Set( (branch.routeSegments ?? []).flatMap((segment) => { - const name = getAppPageSegmentParamName(segment); + const name = getGenerateStaticParamsSegmentParamName(segment); return name ? [name] : []; }), ); @@ -336,7 +362,7 @@ export function resolveAppPageGenerateStaticParamsSources( } const routeParamNames = options.routeSegments.flatMap((segment) => { - const name = getAppPageSegmentParamName(segment); + const name = getGenerateStaticParamsSegmentParamName(segment); return name ? [name] : []; }); for (const [independentChain, parallelBranch] of (options.parallelBranches ?? []).entries()) { @@ -587,6 +613,14 @@ export async function validateAppPageDynamicParams( if (generateStaticParamsSources.length === 0) { return notFoundResponse(); } + // Matching leaves an omitted optional catch-all out of the params, but + // Next.js generates that path only from an explicit empty value, so its + // key is still compared. + const paramKeys = + options.requiredParamNames ?? + Array.from( + new Set([...Object.keys(options.params), ...(options.optionalCatchAllParamNames ?? [])]), + ); const chainedSources = generateStaticParamsSources.filter((source) => source.chained); let chainedStaticParams: Record[] | null = null; @@ -620,7 +654,7 @@ export async function validateAppPageDynamicParams( options.params, result.staticParams, options.requiredParamNames === undefined, - options.requiredParamNames, + paramKeys, options.optionalCatchAllParamNames, ) ) { @@ -638,7 +672,15 @@ export async function validateAppPageDynamicParams( // results are validated against those combinations above; without a // parallel result, the primary chain itself must match exactly. // https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/build/static-paths/app.ts - if (!areStaticParamsAllowed(options.params, chainedStaticParams)) { + if ( + !areStaticParamsAllowed( + options.params, + chainedStaticParams, + false, + paramKeys, + options.optionalCatchAllParamNames, + ) + ) { return notFoundResponse(); } } @@ -646,8 +688,8 @@ export async function validateAppPageDynamicParams( return null; } -async function resolveAppPageInterceptState( - options: ResolveAppPageInterceptStateOptions, +async function matchAppPageInterceptState( + options: MatchAppPageInterceptOptions, ): Promise> { if (!options.isRscRequest) { return { kind: "none" }; @@ -658,12 +700,6 @@ async function resolveAppPageInterceptState( return { kind: "none" }; } - await loadAppInterceptPage(intercept); - await loadAppInterceptNotFound(intercept); - if (intercept.__loadInterceptLayouts || intercept.__loadInterceptLoadings) { - await loadAppInterceptLayouts(intercept); - } - const sourceRoute = await options.getSourceRoute(intercept.sourceRouteIndex); if (!sourceRoute) { return { kind: "none" }; @@ -676,6 +712,104 @@ async function resolveAppPageInterceptState( return { kind: "source-route", intercept, sourceRoute }; } +async function resolveAppPageInterceptState( + options: ResolveAppPageInterceptStateOptions, +): Promise> { + const interceptState = await matchAppPageInterceptState(options); + if (interceptState.kind === "none") return interceptState; + const renderRoute = + interceptState.kind === "source-route" ? interceptState.sourceRoute : options.currentRoute; + if (!options.routeHasSlot) { + await loadAppPageInterceptModules(interceptState.intercept); + return interceptState; + } + if ( + isAppPageInterceptAttachedToRoute(interceptState.intercept, renderRoute, options.routeHasSlot) + ) { + await loadAppPageInterceptModules(interceptState.intercept); + // A rerender classifies the intercepting route's tree like the render it + // repeats, so that tree's owner default is loaded too. + await loadAppPageInterceptOwnerDefault( + interceptState.intercept, + renderRoute, + options.routeHasSlot, + ); + } + return interceptState; +} + +async function loadAppPageInterceptModules( + intercept: AppPageInterceptMatch, +): Promise { + await loadAppInterceptPage(intercept); + await loadAppInterceptNotFound(intercept); + if (intercept.__loadInterceptLayouts || intercept.__loadInterceptLoadings) { + await loadAppInterceptLayouts(intercept); + } +} + +/** + * The interception an RSC request renders its own matched route with, when + * that route is also the interception's source. Nothing is loaded yet. + */ +export async function matchAppPageCurrentRouteIntercept( + options: MatchAppPageInterceptOptions, +): Promise | null> { + const interceptState = await matchAppPageInterceptState(options); + return interceptState.kind === "current-route" ? interceptState.intercept : null; +} + +/** + * Whether an interception puts its branch into `route`'s tree. A sibling-page + * intercept always replaces the route's page; a slot intercept needs the route + * to have the intercepted slot, and otherwise the route renders unchanged, as + * `isAppPageInterceptAttached` classifies it. + */ +export function isAppPageInterceptAttachedToRoute( + intercept: AppPageInterceptMatch, + route: TRoute, + routeHasSlot: (route: TRoute, slotKey: string) => boolean, +): boolean { + return ( + intercept.slotKey === SIBLING_PAGE_INTERCEPT_SLOT_KEY || routeHasSlot(route, intercept.slotKey) + ); +} + +/** + * Load the modules of a current-route interception's intercepting tree, as + * `resolveAppPageIntercept` does before rendering it. An intercept the route + * has no slot for renders nothing, so none of its modules are evaluated. + */ +export async function loadAppPageCurrentRouteIntercept( + intercept: AppPageInterceptMatch, + currentRoute: TRoute, + routeHasSlot: (route: TRoute, slotKey: string) => boolean, +): Promise { + if (!isAppPageInterceptAttachedToRoute(intercept, currentRoute, routeHasSlot)) return; + await loadAppPageInterceptModules(intercept); + await loadAppPageInterceptOwnerDefault(intercept, currentRoute, routeHasSlot); +} + +/** + * Only the intercepting route's tree holds the default of the folder that + * owns a slot intercept, in place of that folder's children. That tree exists + * only when the concrete source has the intercepted slot: a sibling-page + * intercept replaces the source's page instead, and a source variant without + * the slot renders unchanged, so neither evaluates the owner's default. + */ +async function loadAppPageInterceptOwnerDefault( + intercept: AppPageInterceptMatch, + sourceRoute: TRoute, + routeHasSlot: (route: TRoute, slotKey: string) => boolean, +): Promise { + if ( + intercept.slotKey !== SIBLING_PAGE_INTERCEPT_SLOT_KEY && + routeHasSlot(sourceRoute, intercept.slotKey) + ) { + await loadAppInterceptOwnerDefault(intercept); + } +} + export async function resolveAppPageInterceptionRerenderTarget( options: ResolveAppPageInterceptionRerenderTargetOptions, ): Promise> { @@ -686,6 +820,7 @@ export async function resolveAppPageInterceptionRerenderTarget( options: ResolveAppPageInterceptOptions, ): Promise> { - const interceptState = await resolveAppPageInterceptState({ + const interceptState = await matchAppPageInterceptState({ cleanPathname: options.cleanPathname, currentRoute: options.currentRoute, findIntercept: options.findIntercept, - getRouteParamNames: options.getRouteParamNames, getSourceRoute: options.getSourceRoute, isRscRequest: options.isRscRequest, - toInterceptOpts: options.toInterceptOpts, }); if (interceptState.kind === "source-route") { const renderRoute = interceptState.sourceRoute; + // A slot intercept the source has no slot for renders the source + // unchanged, so none of its modules are evaluated. + if ( + isAppPageInterceptAttachedToRoute(interceptState.intercept, renderRoute, options.routeHasSlot) + ) { + await loadAppPageInterceptModules(interceptState.intercept); + } + // Only a rendered interception classifies the intercepting route's tree. + await loadAppPageInterceptOwnerDefault( + interceptState.intercept, + renderRoute, + options.routeHasSlot, + ); const interceptOpts = options.toInterceptOpts(interceptState.intercept); const sourceMatchedParams = interceptState.intercept.sourceMatchedParams ?? interceptState.intercept.matchedParams; @@ -746,7 +892,7 @@ export async function resolveAppPageIntercept( diff --git a/packages/vinext/src/server/app-page-response.ts b/packages/vinext/src/server/app-page-response.ts index 4a0af620fc..24b551d5a6 100644 --- a/packages/vinext/src/server/app-page-response.ts +++ b/packages/vinext/src/server/app-page-response.ts @@ -1,5 +1,6 @@ import { buildRevalidateCacheControl, + NEVER_CACHE_CONTROL, NO_STORE_CACHE_CONTROL, STATIC_CACHE_CONTROL, } from "./cache-control.js"; @@ -54,6 +55,11 @@ type ResolveAppPageResponsePolicyBaseOptions = { isForceDynamic: boolean; isForceStatic: boolean; isProduction: boolean; + /** + * `false` for routes Next.js classifies as dynamic (ƒ). Their responses are + * never cached, whatever their revalidate. + */ + isStaticEligible: boolean; expireSeconds?: number; revalidateSeconds: number | null; }; @@ -148,15 +154,28 @@ function applyPrerenderCacheTagsHeader(headers: Headers, cacheTags: readonly str } } +/** + * Next.js sends `private, no-cache, no-store, max-age=0, must-revalidate` for + * an App page response it knows is dynamic. Dev responses never cache, so they + * keep vinext's dev `no-store` header. + */ +export function resolveUncacheableCacheControl(isProduction: boolean): string { + return isProduction ? NEVER_CACHE_CONTROL : NO_STORE_CACHE_CONTROL; +} + export function resolveAppPageRscResponsePolicy( options: ResolveAppPageRscResponsePolicyOptions, ): AppPageResponsePolicy { if (options.isDraftMode) { - return { cacheControl: NO_STORE_CACHE_CONTROL }; + return { cacheControl: resolveUncacheableCacheControl(options.isProduction) }; + } + + if (options.isStaticEligible === false) { + return { cacheControl: resolveUncacheableCacheControl(options.isProduction) }; } if (options.isForceDynamic || options.dynamicUsedDuringBuild) { - return { cacheControl: NO_STORE_CACHE_CONTROL }; + return { cacheControl: resolveUncacheableCacheControl(options.isProduction) }; } // revalidate = 0 means "always dynamic, never cache" — equivalent to @@ -164,12 +183,14 @@ export function resolveAppPageRscResponsePolicy( // isForceStatic/isDynamicError branch below, which uses !revalidateSeconds // and would incorrectly catch 0 as a falsy value. if (options.revalidateSeconds === 0) { - return { cacheControl: NO_STORE_CACHE_CONTROL }; + return { cacheControl: resolveUncacheableCacheControl(options.isProduction) }; } + // Only force-static and dynamic = "error" renders can't turn dynamic while + // streaming, so only they keep their static headers unconditionally. if ( - ((options.isForceStatic || options.isDynamicError) && !options.revalidateSeconds) || - options.revalidateSeconds === Infinity + (options.isForceStatic || options.isDynamicError) && + (options.revalidateSeconds === null || options.revalidateSeconds === Infinity) ) { return { cacheControl: STATIC_CACHE_CONTROL, @@ -177,6 +198,15 @@ export function resolveAppPageRscResponsePolicy( }; } + if (options.revalidateSeconds === Infinity) { + return { + cacheControl: STATIC_CACHE_CONTROL, + // Like a finite revalidate, a production MISS lets the finalizer apply + // pending-dynamic headers in case the render reaches a dynamic API late. + cacheState: options.isProduction ? "MISS" : "STATIC", + }; + } + if (options.revalidateSeconds) { return { cacheControl: buildRevalidateCacheControl(options.revalidateSeconds, options.expireSeconds), @@ -196,14 +226,21 @@ export function resolveAppPageHtmlResponsePolicy( ): AppPageHtmlResponsePolicy { if (options.isDraftMode) { return { - cacheControl: NO_STORE_CACHE_CONTROL, + cacheControl: resolveUncacheableCacheControl(options.isProduction), + shouldWriteToCache: false, + }; + } + + if (options.isStaticEligible === false) { + return { + cacheControl: resolveUncacheableCacheControl(options.isProduction), shouldWriteToCache: false, }; } if (options.isForceDynamic) { return { - cacheControl: NO_STORE_CACHE_CONTROL, + cacheControl: resolveUncacheableCacheControl(options.isProduction), shouldWriteToCache: false, }; } @@ -228,12 +265,15 @@ export function resolveAppPageHtmlResponsePolicy( // === 0 and would incorrectly return a static Cache-Control. if (options.revalidateSeconds === 0) { return { - cacheControl: NO_STORE_CACHE_CONTROL, + cacheControl: resolveUncacheableCacheControl(options.isProduction), shouldWriteToCache: false, }; } - if ((options.isForceStatic || options.isDynamicError) && options.revalidateSeconds === null) { + if ( + (options.isForceStatic || options.isDynamicError) && + (options.revalidateSeconds === null || options.revalidateSeconds === Infinity) + ) { return { cacheControl: STATIC_CACHE_CONTROL, cacheState: options.isProduction ? "MISS" : "STATIC", @@ -243,7 +283,7 @@ export function resolveAppPageHtmlResponsePolicy( if (options.dynamicUsedDuringRender) { return { - cacheControl: NO_STORE_CACHE_CONTROL, + cacheControl: resolveUncacheableCacheControl(options.isProduction), shouldWriteToCache: false, }; } diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index 7c3d681589..222cc7e46f 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -46,9 +46,10 @@ import { type AppRenderDependency, } from "./app-render-dependency.js"; import { - resolveAppPageBranchParams, + createAppPageTreePath, resolveAppPageSegmentParamScopeKeys, resolveAppPageSegmentParams, + resolveSlotLayoutParams, } from "./app-page-params.js"; import { probeReactServerSubtree } from "./app-page-probe.js"; import { @@ -65,6 +66,12 @@ import { type AppPageSemanticSegment, } from "./app-page-segment-state.js"; import { createAppPageSegmentPlan } from "./app-page-segment-plan.js"; +import { + createAppPageLoadingEntries, + createAppPageSlotLoadingEntries, + resolveAppPagePrefetchPlan, + type AppPageLoadingEntry, +} from "./app-page-prefetch-plan.js"; import type { AppPageRenderIdentity } from "./app-page-render-identity.js"; import { resolveAppPageModuleTraceSegment, @@ -87,14 +94,6 @@ const APP_PAGE_LAYOUT_PROBE_CHILD = ; const DEFAULT_GLOBAL_ERROR_COMPONENT = DefaultGlobalError as AppPageErrorComponent; const DEFAULT_NOT_FOUND_COMPONENT = DefaultNotFound as AppPageComponent; -function resolveSlotLayoutParams( - routeSegments: readonly string[], - treePosition: number, - params: AppPageParams, -): AppPageParams { - return resolveAppPageBranchParams(routeSegments, treePosition, params); -} - export type AppPageModule = Record & { default?: AppPageComponent | null | undefined; }; @@ -289,11 +288,6 @@ type AppPageErrorEntry = { - loadingModule?: TModule | null | undefined; - treePosition: number; -}; - function getDefaultExport( module: TModule | null | undefined, ): AppPageComponent | null { @@ -306,17 +300,6 @@ function getErrorBoundaryExport( return module?.default ?? null; } -export function createAppPageTreePath( - routeSegments: readonly string[] | null | undefined, - treePosition: number, -): string { - const treePathSegments = routeSegments?.slice(0, treePosition) ?? []; - if (treePathSegments.length === 0) { - return "/"; - } - return `/${treePathSegments.join("/")}`; -} - function readFiniteRevalidateSeconds(module: AppPageModule | null | undefined): number | null { const revalidate = module?.revalidate; return typeof revalidate === "number" && Number.isFinite(revalidate) && revalidate > 0 @@ -467,17 +450,6 @@ function createAppPageErrorEntries( }); } -function createAppPageLoadingEntries( - route: Pick, "loadings" | "loadingTreePositions">, -): AppPageLoadingEntry[] { - return (route.loadings ?? []).flatMap((loadingModule, index) => { - if (!loadingModule) return []; - const treePosition = route.loadingTreePositions?.[index]; - if (treePosition === undefined) return []; - return [{ loadingModule, treePosition }]; - }); -} - export function resolveAppPageLoadingModuleAtOrAbove( route: Pick, "loading" | "loadings" | "loadingTreePositions">, treePosition: number, @@ -499,74 +471,6 @@ export function resolveAppPageLoadingModuleAtOrAbove( - route: Pick< - AppPageRouteWiringRoute, - "loading" | "loadings" | "loadingTreePositions" | "routeSegments" - >, -): AppPageLoadingEntry | null { - let rootEntry: AppPageLoadingEntry | null = null; - let firstNestedEntry: AppPageLoadingEntry | null = null; - for (const [index, loadingModule] of (route.loadings ?? []).entries()) { - if (!getDefaultExport(loadingModule)) continue; - const treePosition = route.loadingTreePositions?.[index]; - if (treePosition === undefined) continue; - if (treePosition === 0) { - rootEntry ??= { loadingModule, treePosition }; - } else if (firstNestedEntry === null || treePosition < firstNestedEntry.treePosition) { - firstNestedEntry = { loadingModule, treePosition }; - } - } - // The root layout is already shared for a client-side prefetch. Prefer the - // first loading boundary below it, falling back to the root loading UI only - // when no nested boundary exists. - if (firstNestedEntry) return firstNestedEntry; - if (rootEntry) return rootEntry; - - // Legacy/eager route fixtures may only expose the leaf loading field. - return getDefaultExport(route.loading) - ? { loadingModule: route.loading, treePosition: route.routeSegments?.length ?? 0 } - : null; -} - -function createAppPageSlotLoadingEntries( - slot: Pick, "loading" | "loadings" | "loadingTreePositions">, - override: Pick, "loadingModules" | "loadingTreePositions"> | null, -): AppPageLoadingEntry[] { - const entries: AppPageLoadingEntry[] = []; - const slotLoadingModules = - (slot.loadings?.length ?? 0) > 0 ? slot.loadings! : slot.loading ? [slot.loading] : []; - const slotLoadingTreePositions = - (slot.loadingTreePositions?.length ?? 0) > 0 ? slot.loadingTreePositions! : [0]; - - for (const [index, loadingModule] of slotLoadingModules.entries()) { - const treePosition = slotLoadingTreePositions[index]; - if (!getDefaultExport(loadingModule) || treePosition === undefined) continue; - // An interception replaces the slot's normal active branch. Only the slot - // root is necessarily shared; nested normal-branch loadings belong to a - // sibling subtree and must not wrap the intercepting page. - if (override && treePosition !== 0) continue; - entries.push({ loadingModule, treePosition }); - } - - for (const [index, loadingModule] of (override?.loadingModules ?? []).entries()) { - const treePosition = override?.loadingTreePositions?.[index]; - if (!getDefaultExport(loadingModule) || treePosition === undefined) continue; - entries.push({ loadingModule, treePosition }); - } - - return entries; -} - -function getFirstLoadingEntry( - entries: readonly AppPageLoadingEntry[], -): AppPageLoadingEntry | null { - return entries.reduce | null>( - (first, entry) => (first === null || entry.treePosition < first.treePosition ? entry : first), - null, - ); -} - function createAppPageParallelSlotEntries< TModule extends AppPageModule, TErrorModule extends AppPageErrorModule, @@ -873,9 +777,6 @@ export function buildAppPageElements< // Loading-shell prefetches intentionally omit the page, so they cannot wait // on a dependency that only the page invocation can release. const pageRenderDependency = isPrefetchLoadingShell ? null : options.pageRenderDependency; - const prefetchLoadingEntry = isPrefetchLoadingShell - ? getPrefetchLoadingEntry(options.route) - : null; const metadataPlacement = options.metadataPlacement ?? "head"; const layoutEntriesByTreePosition = new Map>(); const templateEntriesByTreePosition = new Map>(); @@ -944,35 +845,17 @@ export function buildAppPageElements< routePath: options.routePath, }, ); - const prefetchSlotLoadingEntries = isPrefetchLoadingShell - ? Object.entries(options.route.slots ?? {}).flatMap(([slotKey, slot]) => { - const override = resolveSlotOverride(slotKey, slot.name) ?? null; - const firstLoadingEntry = getFirstLoadingEntry( - createAppPageSlotLoadingEntries(slot, override), - ); - return firstLoadingEntry ? [{ ownerTreePosition: slot.ownerTreePosition ?? 0 }] : []; - }) - : []; - // The children spine must reach every slot owner whose branch has a loading - // boundary. A loading on the spine itself stops traversal first, matching - // Next.js's per-parallel-route pre-PPR component-tree walk. - const prefetchCutoffTreePosition = isPrefetchLoadingShell - ? (prefetchLoadingEntry?.treePosition ?? - prefetchSlotLoadingEntries.reduce( - (deepest, entry) => Math.max(deepest, entry.ownerTreePosition), - 0, - )) - : null; - const includesPrefetchTreePosition = (treePosition: number): boolean => - prefetchCutoffTreePosition === null || treePosition <= prefetchCutoffTreePosition; + // Shared with the probes a direct intercepted RSC response runs before its + // headers, so they include what this render does. + const prefetchPlan = resolveAppPagePrefetchPlan({ + renderMode, + resolveSlotOverride, + route: options.route, + }); + const { prefetchLoadingEntry, prefetchSlotLoadingEntries } = prefetchPlan; + const includesPrefetchTreePosition = prefetchPlan.includesTreePosition; const segmentPlan = createAppPageSegmentPlan({ - includeSlot(ownerTreePosition, targetTreePosition) { - if (isPrefetchEmpty) return false; - if (!isPrefetchLoadingShell) return true; - return prefetchLoadingEntry - ? ownerTreePosition <= prefetchLoadingEntry.treePosition - : includesPrefetchTreePosition(targetTreePosition); - }, + includeSlot: prefetchPlan.includesSlot, interception, layoutEntries, matchedParams: options.matchedParams, @@ -1232,10 +1115,6 @@ export function buildAppPageElements< slotOverride, targetIndex, } = slotPlan; - const isOwnedAtRoutePrefetchCutoff = - isPrefetchLoadingShell && - prefetchLoadingEntry !== null && - ownerTreePosition === prefetchLoadingEntry.treePosition; if (!includedInPayload) continue; const slotOwnerParams = resolveAppPageSegmentParams( options.route.routeSegments, @@ -1248,11 +1127,8 @@ export function buildAppPageElements< slot, hasSlotTreeOverride ? (slotOverride ?? null) : null, ); - const prefetchSlotLoadingEntry = isOwnedAtRoutePrefetchCutoff - ? prefetchLoadingEntry - : isPrefetchLoadingShell - ? getFirstLoadingEntry(slotLoadingEntries) - : null; + const { entry: prefetchSlotLoadingEntry, isOwnedAtRoutePrefetchCutoff } = + prefetchPlan.resolveSlotLoadingEntry(ownerTreePosition, slotLoadingEntries); if (isPrefetchLoadingShell && prefetchSlotLoadingEntry === null) { continue; } diff --git a/packages/vinext/src/server/app-page-search-params-observation.ts b/packages/vinext/src/server/app-page-search-params-observation.ts index 91f075b4ec..706dd4657b 100644 --- a/packages/vinext/src/server/app-page-search-params-observation.ts +++ b/packages/vinext/src/server/app-page-search-params-observation.ts @@ -10,27 +10,28 @@ import { type ThenableParamsObserver, } from "vinext/shims/thenable-params"; import type { AppPageSearchParams } from "./app-page-head.js"; +import { searchParamsToRecord } from "../utils/query.js"; type AppPageSearchParamsObservationOptions = { - markDynamic?: boolean; observeReactPromiseStatus?: boolean; }; -function markAppPageSearchParamsAccess(markDynamic: boolean): void { +type ClientPageSsrSearchParamsOptions = { + isForceStatic?: boolean; + isPprFallbackShell?: boolean; +}; + +function markAppPageSearchParamsAccess(): void { throwIfStaticGenerationAccessError(); throwIfInsideCacheScope("searchParams"); - if (markDynamic) { - markDynamicUsage(); - } + markDynamicUsage(); markRenderRequestApiUsage("searchParams"); } -export function createAppPageSearchParamsObserver( - options: AppPageSearchParamsObservationOptions = {}, -): ThenableParamsObserver { +export function createAppPageSearchParamsObserver(): ThenableParamsObserver { return { observeParamAccess() { - markAppPageSearchParamsAccess(options.markDynamic !== false); + markAppPageSearchParamsAccess(); }, }; } @@ -39,7 +40,7 @@ export function makeObservedAppPageSearchParamsThenable( pageSearchParams: AppPageSearchParams, options: AppPageSearchParamsObservationOptions = {}, ): ThenableParams { - const observer = createAppPageSearchParamsObserver(options); + const observer = createAppPageSearchParamsObserver(); if (options.observeReactPromiseStatus === true) { return makeThenableParams(pageSearchParams, { ...observer, @@ -48,3 +49,55 @@ export function makeObservedAppPageSearchParamsThenable( } return makeThenableParams(pageSearchParams, observer); } + +/** + * The `searchParams` a client page receives during SSR (see + * `shims/client-page-root.tsx`). Its RSC payload carries no query, so this is + * the only place a client page can read it on the server, and a read counts + * like a server page's: the render is dynamic and won't be stored. + * + * `force-static` renders read an empty query, which isn't a read, and PPR + * fallback shells keep their untracked query, so neither is observed. + */ +export function makeClientPageSsrSearchParamsThenable( + searchParams: URLSearchParams, + options: ClientPageSsrSearchParamsOptions, +): ThenableParams { + return makeClientPageSsrSearchParamsThenableFromRecord( + searchParamsToRecord(searchParams), + options, + ); +} + +/** + * The client page `searchParams` of one SSR render, one promise per page, + * keyed by the page's props object as the browser keys its own. React writes + * `status` and `value` onto a promise it tracks, so a promise shared across + * pages would show one page's `use()` to a sibling in SSR only. + * + * The cache lives in this render's navigation context and is dropped with it. + */ +export function createClientPageSsrSearchParamsSource( + searchParams: URLSearchParams, + options: ClientPageSsrSearchParamsOptions, +): (pageProps: object) => ThenableParams { + const pageSearchParams = searchParamsToRecord(searchParams); + const byPage = new WeakMap>(); + return (pageProps) => { + let thenable = byPage.get(pageProps); + if (!thenable) { + thenable = makeClientPageSsrSearchParamsThenableFromRecord(pageSearchParams, options); + byPage.set(pageProps, thenable); + } + return thenable; + }; +} + +function makeClientPageSsrSearchParamsThenableFromRecord( + pageSearchParams: AppPageSearchParams, + options: ClientPageSsrSearchParamsOptions, +): ThenableParams { + return options.isForceStatic !== true && options.isPprFallbackShell !== true + ? makeObservedAppPageSearchParamsThenable(pageSearchParams) + : makeThenableParams(pageSearchParams); +} diff --git a/packages/vinext/src/server/app-page-stream.ts b/packages/vinext/src/server/app-page-stream.ts index 903a70ac78..8470dd93c7 100644 --- a/packages/vinext/src/server/app-page-stream.ts +++ b/packages/vinext/src/server/app-page-stream.ts @@ -149,6 +149,8 @@ export type AppPageSsrHandler = { isStaticGeneration?: boolean; /** `dynamic = "force-static"` suppresses the useSearchParams bailout. */ isForceStatic?: boolean; + /** Production render that may be stored under a query-free key. */ + isCacheCandidate?: boolean; /** Dev-only: original server error to surface in the browser overlay. */ initialDevServerError?: unknown; /** Report an SSR/Fizz render failure through instrumentation. */ @@ -203,6 +205,8 @@ type RenderAppPageHtmlStreamOptions = { isStaticGeneration?: boolean; /** `dynamic = "force-static"` suppresses the useSearchParams bailout. */ isForceStatic?: boolean; + /** Production render that may be stored under a query-free key. */ + isCacheCandidate?: boolean; /** Override the default shell-error recovery decision passed to handleSsr. */ fallbackToErrorDocumentOnShellError?: boolean; /** Dev-only: original server error to surface in the browser overlay. */ @@ -293,6 +297,7 @@ export async function renderAppPageHtmlStream( waitForAllReady: options.waitForAllReady, isStaticGeneration: options.isStaticGeneration, isForceStatic: options.isForceStatic, + isCacheCandidate: options.isCacheCandidate, initialDevServerError: options.initialDevServerError, onSsrError: options.onSsrError, mirrorNextFlight: options.mirrorNextFlight, diff --git a/packages/vinext/src/server/app-render-dependency.tsx b/packages/vinext/src/server/app-render-dependency.tsx index 5bddc2d0b5..a7dda31a0f 100644 --- a/packages/vinext/src/server/app-render-dependency.tsx +++ b/packages/vinext/src/server/app-render-dependency.tsx @@ -28,6 +28,11 @@ const REACT_FORWARD_REF = Symbol.for("react.forward_ref"); const REACT_LAZY = Symbol.for("react.lazy"); const REACT_MEMO = Symbol.for("react.memo"); +/** Whether `component` is a "use client" export seen from the RSC environment. */ +export function isAppClientReference(component: unknown): boolean { + return (component as AppDependencyComponent | null)?.$$typeof === REACT_CLIENT_REFERENCE; +} + export function isReactOwnedAppComponent(component: unknown): boolean { const candidate = component as AppDependencyComponent | null; diff --git a/packages/vinext/src/server/app-response-stage-entry.ts b/packages/vinext/src/server/app-response-stage-entry.ts index 9d167ec969..2876bf039e 100644 --- a/packages/vinext/src/server/app-response-stage-entry.ts +++ b/packages/vinext/src/server/app-response-stage-entry.ts @@ -108,6 +108,7 @@ export async function handleResponseStage( policyHeaders: props.cacheability.policyHeaders, probeMode: props.cacheability.probeMode, rawManifest: __cacheabilityManifest, + recomposesRequestScopedHeaders: props.kind === "app-page" && props.isRscRequest, registerCacheAdapters: () => registerConfiguredCacheAdapters(env), request, representation: props.cacheability.representation, diff --git a/packages/vinext/src/server/app-route-module-loader.ts b/packages/vinext/src/server/app-route-module-loader.ts index a914eff9d0..0d9cefc4a1 100644 --- a/packages/vinext/src/server/app-route-module-loader.ts +++ b/packages/vinext/src/server/app-route-module-loader.ts @@ -42,6 +42,8 @@ type LazyLoadableIntercept = { __pageLoader?: LazyModuleThunk | null; notFound?: unknown; __loadNotFound?: LazyModuleThunk | null; + ownerDefault?: unknown; + __loadOwnerDefault?: LazyModuleThunk | null; interceptLayouts?: readonly unknown[] | null; __loadInterceptLayouts?: LazyModuleLoaderArray | null; interceptLoadings?: readonly unknown[] | null; @@ -51,6 +53,8 @@ type LazyLoadableIntercept = { pageLoading?: Promise | null; notFound?: unknown; notFoundLoading?: Promise | null; + ownerDefault?: unknown; + ownerDefaultLoading?: Promise | null; interceptLayoutsLoading: Promise | null; }; }; @@ -166,8 +170,8 @@ function pushArrayLoads( async function hydrateInterceptModule( intercept: LazyLoadableIntercept, loader: LazyModuleThunk | null | undefined, - field: "page" | "notFound", - loadingField: "pageLoading" | "notFoundLoading", + field: "page" | "notFound" | "ownerDefault", + loadingField: "pageLoading" | "notFoundLoading" | "ownerDefaultLoading", ): Promise { const loadState = intercept.__loadState; const cached = loadState?.[field]; @@ -207,6 +211,20 @@ export function loadAppInterceptNotFound(intercept: LazyLoadableIntercept): Prom return hydrateInterceptModule(intercept, intercept.__loadNotFound, "notFound", "notFoundLoading"); } +/** + * Hydrate the `default` module of the folder that owns a slot intercept onto + * `intercept.ownerDefault`. Only the intercepting route's tree holds it, in + * place of that folder's children, so a direct request never evaluates it. + */ +export function loadAppInterceptOwnerDefault(intercept: LazyLoadableIntercept): Promise { + return hydrateInterceptModule( + intercept, + intercept.__loadOwnerDefault, + "ownerDefault", + "ownerDefaultLoading", + ); +} + export function loadAppInterceptLayouts( intercept: LazyLoadableIntercept, ): Promise { diff --git a/packages/vinext/src/server/app-rsc-cache-busting.ts b/packages/vinext/src/server/app-rsc-cache-busting.ts index 50472ad8e3..729bcd69f2 100644 --- a/packages/vinext/src/server/app-rsc-cache-busting.ts +++ b/packages/vinext/src/server/app-rsc-cache-busting.ts @@ -295,6 +295,18 @@ export function stripRscCacheBustingSearchParam(url: URL): void { url.search = pairs.length > 0 ? `?${pairs.join("&")}` : ""; } +/** + * Reduce a URL's query to the one `_rsc` value that selects its representation. + * For RSC requests that is the value `resolveInvalidRscCacheBustingRequest` + * validated (the first pair), re-serialized; ignored duplicates and encodings + * are dropped. Other requests keep no `_rsc`, because it selects nothing there. + */ +export function keepOnlyValidatedRscCacheBustingSearchParam(url: URL, isRscRequest: boolean): void { + const hash = isRscRequest ? url.searchParams.get(VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM) : null; + url.search = ""; + if (hash !== null) setRscCacheBustingSearchParam(url, hash); +} + /** * Remove a trailing `.rsc` suffix from a pathname. Returns the pathname * unchanged when the suffix is absent. diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 8af564dd95..3c64c19b5c 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -140,15 +140,21 @@ import { markRouteCacheabilityDynamic, preserveRouteCacheabilityResponsePolicy, } from "vinext/shims/cacheability-classification"; +import { getCdnCacheAdapter } from "vinext/shims/cdn-cache"; import { APP_METADATA_RESPONSE_STAGE_NO_MATCH_HEADER, APP_WORKER_RESPONSE_STAGE_PROTOCOL_VERSION, + createSharedAppPageCacheIdentity, + isStaticCandidateAppPageDispatch, prepareSharedAppPageDispatch, + withoutAppPageDispatchQuery, type AppMatchedWorkerResponseStageProps, + type AppWorkerResponseStageProps, type DispatchAppWorkerResponseStage, type RenderAppWorkerResponseStageLocally, } from "./app-worker-stages.js"; import type { VinextCacheabilityProbeMode } from "./multi-stage.js"; +import { parseCacheabilityManifest, type CacheabilityManifest } from "./cacheability-manifest.js"; import { consumePagesResponseStagePolicyOwner, withoutResponseStageVary, @@ -247,6 +253,41 @@ function requestOptsOutOfWorkerResponseStage( return false; } +let parsedCacheabilityRequestProjection: + | { buildId: string; manifest: CacheabilityManifest | null; raw: string } + | undefined; + +function readCacheabilityRequestProjection( + options: Pick< + CreateAppRscHandlerOptions, + "buildId" | "cacheabilityRequestProjection" + >, +): CacheabilityManifest | null { + const raw = options.cacheabilityRequestProjection; + if (!raw || !options.buildId) return null; + if ( + parsedCacheabilityRequestProjection?.raw !== raw || + parsedCacheabilityRequestProjection.buildId !== options.buildId + ) { + parsedCacheabilityRequestProjection = { + buildId: options.buildId, + manifest: parseCacheabilityManifest(raw, options.buildId), + raw, + }; + } + return parsedCacheabilityRequestProjection.manifest; +} + +function adapterUsesQueryFreeCacheIdentity(): boolean { + const adapter = getCdnCacheAdapter(); + // The identity is query-free, so it is safe only behind completed-response + // admission, which refuses App pages without a negative searchParams proof. + return ( + adapter.requiresCompletedResponseAdmission === true && + adapter.responseStageCacheIdentity === "query-free" + ); +} + function hasUrlParserDotSegment(pathname: string): boolean { return pathname.split("/").some((segment) => { const decodedDots = segment.replaceAll(/%2e/gi, "."); @@ -466,6 +507,12 @@ type NavigationContextValue = { export type CreateAppRscHandlerOptions = { basePath: string; buildId: string | null; + /** + * Serialized request-stage projection of the Workers Cache manifest. Shared + * App page dispatches whose manifest state is `static-candidate` drop the + * user query. Null outside a `vinext deploy` artifact. + */ + cacheabilityRequestProjection?: string | null; clearRequestContext: () => void; configHeaders: NextHeader[]; configRedirects: NextRedirect[]; @@ -1130,6 +1177,7 @@ async function handleAppRscRequest( props.kind === "app-page" ? prepareSharedAppPageDispatch(stageRequest, cache) : stageRequest; + let canonicalRsc: { headers: Headers; navigation: boolean } | null = null; if ( cache === "shared" && props.kind === "app-page" && @@ -1147,26 +1195,65 @@ async function handleAppRscRequest( ? canonicalizeLoadingShellRscRequestHeaders(headers) : false; if (canonicalized) { - const rscPath = - props.renderMode === "navigation" - ? createCanonicalRscRequestUrl(dispatchRequest.url) - : await createRscRequestUrl(dispatchRequest.url, headers); - dispatchRequest = cloneRequestWithUrl( - cloneRequestWithHeaders(dispatchRequest, headers), - new URL(rscPath, dispatchRequest.url).toString(), - ); + canonicalRsc = { headers, navigation: props.renderMode === "navigation" }; + dispatchRequest = cloneRequestWithHeaders(dispatchRequest, headers); + } + } + let stageProps: AppWorkerResponseStageProps = { + ...props, + cacheability: { + ...props.cacheability, + policyHeaders: responseStagePolicy, + }, + }; + // Workers Cache keys a dispatch by its URL and props, and a hit runs + // no code, so a static-candidate path drops the user query from the + // dispatch itself. A next.config public policy keeps the full URL, + // as the query-free cache identity below does. The canonical RSC + // URL is built afterwards, because it keeps the request's search. + if ( + cache === "shared" && + responseStagePolicy === null && + stageProps.kind === "app-page" + ) { + const projection = readCacheabilityRequestProjection(options); + if ( + projection && + isStaticCandidateAppPageDispatch(projection, dispatchRequest, stageProps) + ) { + const queryFree = withoutAppPageDispatchQuery(dispatchRequest.url, stageProps); + dispatchRequest = cloneRequestWithUrl(dispatchRequest, queryFree.url); + stageProps = queryFree.props; } } + if (canonicalRsc) { + const rscPath = canonicalRsc.navigation + ? createCanonicalRscRequestUrl(dispatchRequest.url) + : await createRscRequestUrl(dispatchRequest.url, canonicalRsc.headers); + dispatchRequest = cloneRequestWithUrl( + dispatchRequest, + new URL(rscPath, dispatchRequest.url).toString(), + ); + } + // Shared dispatches are GET/HEAD only. A next.config public policy + // is admitted whatever the render read, so it keeps the full-URL + // identity, as Next.js CDN caching does. Interception and mounted-slot + // payloads stay contextual, as the RSC canonicalization above does. + const cacheIdentity = + cache === "shared" && + responseStagePolicy === null && + stageProps.kind === "app-page" && + stageProps.matchKind !== "interception" && + stageProps.interceptionContext === null && + stageProps.interceptionId === null && + stageProps.mountedSlotsHeader === null && + adapterUsesQueryFreeCacheIdentity() + ? createSharedAppPageCacheIdentity(dispatchRequest, stageProps) + : undefined; let response = await dispatchResponseStage( dispatchRequest, - { - ...props, - cacheability: { - ...props.cacheability, - policyHeaders: responseStagePolicy, - }, - }, - { cache }, + stageProps, + cacheIdentity ? { cache, cacheIdentity } : { cache }, ); if (stageRequest.method.toUpperCase() === "HEAD" && response.body) { await response.body.cancel(); diff --git a/packages/vinext/src/server/app-rsc-route-matching.ts b/packages/vinext/src/server/app-rsc-route-matching.ts index 9d67f82e43..c714538304 100644 --- a/packages/vinext/src/server/app-rsc-route-matching.ts +++ b/packages/vinext/src/server/app-rsc-route-matching.ts @@ -57,6 +57,8 @@ type AppRscInterceptForMatching = { notFound?: unknown; __loadNotFound?: (() => Promise) | null; notFoundTreePosition?: number | null; + ownerDefault?: unknown; + __loadOwnerDefault?: (() => Promise) | null; params: readonly string[]; }; @@ -114,6 +116,8 @@ type AppRscInterceptLoadState = { pageLoading: Promise | null; notFound: unknown; notFoundLoading: Promise | null; + ownerDefault?: unknown; + ownerDefaultLoading?: Promise | null; interceptLayoutsLoading: Promise | null; }; @@ -140,6 +144,8 @@ type AppRscInterceptLookupEntry = { notFound: unknown; __loadNotFound?: (() => Promise) | null; notFoundTreePosition?: number | null; + ownerDefault?: unknown; + __loadOwnerDefault?: (() => Promise) | null; __loadState: AppRscInterceptLoadState; params: readonly string[]; slotId: string | null; @@ -488,11 +494,15 @@ function createInterceptLookup( notFound: intercept.notFound, __loadNotFound: intercept.__loadNotFound, notFoundTreePosition: intercept.notFoundTreePosition, + ownerDefault: intercept.ownerDefault ?? null, + __loadOwnerDefault: intercept.__loadOwnerDefault, __loadState: { page: intercept.page, pageLoading: null, notFound: intercept.notFound, notFoundLoading: null, + ownerDefault: intercept.ownerDefault ?? null, + ownerDefaultLoading: null, interceptLayoutsLoading: null, }, params: intercept.params, diff --git a/packages/vinext/src/server/app-segment-config.ts b/packages/vinext/src/server/app-segment-config.ts index 92b85e14c2..890aafb969 100644 --- a/packages/vinext/src/server/app-segment-config.ts +++ b/packages/vinext/src/server/app-segment-config.ts @@ -1,5 +1,6 @@ import type { FetchCacheMode } from "vinext/shims/fetch-cache"; import { isEdgeApiRuntime } from "./edge-api-runtime.js"; +import { getAppPageSegmentParamName, stripAppPageInterceptionMarker } from "./app-page-params.js"; type AppRouteSegmentDynamic = "auto" | "error" | "force-dynamic" | "force-static"; @@ -25,11 +26,31 @@ type EffectiveAppPageSegmentConfig = { type ParallelAppPageSegmentConfigBranch = { configLayouts?: readonly (AppRouteSegmentConfigModule | null | undefined)[] | null; configLayoutTreePositions?: readonly number[] | null; + /** + * The slot's `default` module, which replaces an active slot page when a + * sibling branch intercepts. + */ + default?: AppRouteSegmentConfigModule | null; + /** Whether the slot renders its `default` module instead of a matched page. */ + isDefault?: boolean; layout?: AppRouteSegmentConfigModule | null; + /** The slot's name, which orders sibling slots in the loader tree. */ + name?: string; + /** The main-tree position of the folder that owns the slot. */ + ownerTreePosition?: number | null; page?: AppRouteSegmentConfigModule | null; routeSegments?: readonly string[] | null; }; +/** + * The route's implicit `children` slot. A route that only a nested slot page + * materializes renders the owner's `default` (or nothing) as its children. + */ +type AppPageChildrenSlot = { + ownerTreePath: string; + state: "active" | "default" | "unmatched"; +}; + type ResolveAppPageSegmentConfigOptions = { layouts?: readonly (AppRouteSegmentConfigModule | null | undefined)[]; layoutTreePositions?: readonly number[]; @@ -96,7 +117,8 @@ function resolveDynamicStaleTimeSeconds( } function isDynamicSegment(segment: string): boolean { - return segment.startsWith("[") && segment.endsWith("]"); + const name = stripAppPageInterceptionMarker(segment); + return name.startsWith("[") && name.endsWith("]"); } function resolveSegmentConfigOwnerPosition( @@ -258,6 +280,12 @@ function describeFetchCacheConflict(value: FetchCacheMode): string { */ export function resolveAppPageSegmentConfig( options: ResolveAppPageSegmentConfigOptions, +): EffectiveAppPageSegmentConfig { + return applyAppPageFetchCacheDefault(reduceAppPageSegmentConfig(options)); +} + +function reduceAppPageSegmentConfig( + options: ResolveAppPageSegmentConfigOptions, ): EffectiveAppPageSegmentConfig { const segments = [...(options.layouts ?? []), options.page]; const parallelSegments = getParallelSegments(options); @@ -278,13 +306,14 @@ export function resolveAppPageSegmentConfig( let hasOnlyNoStore = false; let hasParentDefaultNoStore = false; + const primaryRuntime = resolveAppPageStaticGenerationRuntime( + segments.map((segment) => segment?.runtime), + ); + if (primaryRuntime !== undefined) config.runtime = primaryRuntime; + for (const segment of segments) { if (!segment) continue; - if (isRouteSegmentRuntime(segment.runtime)) { - config.runtime = segment.runtime; - } - if (isRouteSegmentFetchCache(segment.fetchCache)) { const fetchCache = segment.fetchCache; @@ -382,6 +411,12 @@ export function resolveAppPageSegmentConfig( config.revalidateSeconds = 0; } + return config; +} + +function applyAppPageFetchCacheDefault( + config: EffectiveAppPageSegmentConfig, +): EffectiveAppPageSegmentConfig { // Static-only dynamic modes supply fetchCache defaults unless a segment does. // `dynamic = "force-dynamic"` is handled at the fetch decision layer: it // defaults no-config fetches to no-store but must not override explicit @@ -417,3 +452,641 @@ export function resolveAppRouteHandlerFetchCacheMode( export function isEdgeRuntime(runtime: string | undefined): boolean { return isEdgeApiRuntime(runtime); } + +/** + * Resolve a `runtime` from a chain of segment values, outermost first: the + * last valid value wins, as a child's value wins over its parent's. + * `collectAppPageStaticGenerationRuntimes` supplies the value Next.js uses to + * decide whether a page can be statically generated. + */ +export function resolveAppPageStaticGenerationRuntime( + values: readonly unknown[], +): EffectiveAppPageSegmentConfig["runtime"] { + let runtime: EffectiveAppPageSegmentConfig["runtime"]; + for (const value of values) { + if (isRouteSegmentRuntime(value)) runtime = value; + } + return runtime; +} + +/** + * Where the children slot renders its `default` instead of a page: the + * main-tree position of the folder that owns it. `null` when children renders + * a page. + */ +function resolveChildrenDefaultTreePosition( + childrenSlot: AppPageChildrenSlot | null | undefined, +): number | null { + if (!childrenSlot || childrenSlot.state === "active") return null; + return treePathDepth(childrenSlot.ownerTreePath); +} + +function treePathDepth(treePath: string): number { + return treePath.split("/").filter(Boolean).length; +} + +/** + * The `runtime` Next.js's default build (Turbopack) derives for an App page, + * for `resolveAppPageStaticGenerationRuntime`. It reads the whole loader tree: + * at each node the values of every parallel branch (children, matched slots + * and slots that render `default`) merge, and a conflict fails the build; the + * node's own layout, page or default module then fills in only an unset value. + * So an edge slot page next to a Node children page makes the route edge. + * https://github.com/vercel/next.js/blob/v16.2.7/crates/next-core/src/segment_config.rs#L1323-L1357 + * https://github.com/vercel/next.js/blob/v16.2.7/crates/next-core/src/next_app/app_page_entry.rs#L40-L41 + */ +export function collectAppPageStaticGenerationRuntimes( + options: Parameters[0], +): unknown[] { + const segments = collectAppPageStaticParamsWalkSegments(options); + const isAt = (segment: AppPageStaticParamsWalkSegment, treePath: readonly number[]) => + segment.treePath.length === treePath.length && + treePath.every((index, depth) => segment.treePath[depth] === index); + const resolveAt = (treePath: readonly number[]): EffectiveAppPageSegmentConfig["runtime"] => { + let runtime: EffectiveAppPageSegmentConfig["runtime"]; + for (const segment of segments) { + if ( + segment.treePath.length === treePath.length + 1 && + treePath.every((index, depth) => segment.treePath[depth] === index) + ) { + runtime = mergeParallelRuntime(runtime, resolveAt(segment.treePath)); + } + } + if (runtime !== undefined) return runtime; + const own = segments.find((segment) => isAt(segment, treePath)); + const segmentModule = own?.identity[1] as AppRouteSegmentConfigModule | null | undefined; + return resolveAppPageStaticGenerationRuntime([segmentModule?.runtime]); + }; + return [resolveAt([])]; +} + +/** + * Merge a parallel branch's runtime into its siblings'. Next.js fails the + * build when two siblings set different values; the edge one is kept here, + * since it keeps the route out of static generation either way. + */ +function mergeParallelRuntime( + current: EffectiveAppPageSegmentConfig["runtime"], + sibling: EffectiveAppPageSegmentConfig["runtime"], +): EffectiveAppPageSegmentConfig["runtime"] { + if (current === undefined) return sibling; + if (sibling === undefined || current === sibling) return current; + return isEdgeRuntime(current) ? current : sibling; +} + +/** + * The modules and shape of an App page's loader tree, as the static + * generation helpers read it. + */ +export type AppPageSegmentConfigTree = Pick< + ResolveAppPageSegmentConfigOptions, + "layoutTreePositions" | "layouts" | "page" | "parallelBranches" | "routeSegments" +> & { childrenSlot?: AppPageChildrenSlot | null }; + +type AppPageInterceptAttachment = AppPageSegmentConfigTree & { + /** Whether the intercept replaces the source's page rather than a slot. */ + isSiblingPageIntercept: boolean; + /** Index of the intercepted slot's branch in `parallelBranches`, or -1. */ + slotIndex: number; +}; + +function resolveAppPageInterceptedSlot( + options: AppPageInterceptAttachment, +): ParallelAppPageSegmentConfigBranch | null { + return options.slotIndex === -1 ? null : (options.parallelBranches?.[options.slotIndex] ?? null); +} + +/** + * Whether an attached intercept's branch adds a dynamic segment to the source + * route's tree, whose params the intercepting tree then renders. + */ +export function hasAppPageInterceptDynamicSegment( + interceptBranchSegments: readonly string[] | null | undefined, +): boolean { + return (interceptBranchSegments ?? []).some( + (segment) => getAppPageSegmentParamName(stripAppPageInterceptionMarker(segment)) !== null, + ); +} + +/** + * Whether an intercepting route is dynamic. Next.js's `isDynamicRoute` + * classifies an intercepting app path by the route it intercepts, built from + * its folders (`interceptTargetPatternParts`), not by its source route's + * segments. + * https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/shared/lib/router/utils/is-dynamic.ts + */ +export function isAppPageInterceptTargetDynamic( + interceptTargetPatternParts: readonly string[] | null | undefined, +): boolean { + return (interceptTargetPatternParts ?? []).some((part) => part.startsWith(":")); +} + +/** + * Whether an intercept puts its branch into the source route's tree. A + * sibling-page intercept always replaces the source's page; a slot intercept + * needs the source to have the intercepted slot, and otherwise the source + * renders unchanged (see `resolveAppPageInterceptTree`). + */ +export function isAppPageInterceptAttached(options: AppPageInterceptAttachment): boolean { + return options.isSiblingPageIntercept || resolveAppPageInterceptedSlot(options) !== null; +} + +/** + * A direct intercepted RSC response renders the source route with the + * intercepting branch in place of what it intercepts. Next.js serves it from + * the intercepting route, an app path of its own whose loader tree holds the + * source's layouts and that branch, and classifies that tree. + * + * - A slot intercept replaces the intercepted slot's branch, which holds the + * layouts of the slot's folders above the marker, then those at and below + * it. The children of the slot's folder are replaced too, by the folder's + * `default` module (or the built-in `default-null`) as a `__DEFAULT__` + * leaf, which drops the source's page and every folder below. vinext still + * renders the source's page, so `keepActiveSiblings` keeps it. + * - A slot intercept whose slot the source route doesn't have renders nothing + * in its place: vinext renders the source route unchanged, so its tree stays. + * - A sibling-page intercept replaces the source's page: its folders, layouts + * and page continue the main tree below the source page's folder. + * - At each folder on the intercepting branch's path, every other slot is + * replaced by its `default` module alone, as a `__DEFAULT__` leaf without + * the slot's layouts. vinext still renders those slots' active pages, so + * `keepActiveSiblings` resolves the tree it renders instead. + * https://github.com/vercel/next.js/blob/v16.2.7/crates/next-core/src/app_structure.rs#L1270-L1290 + * https://github.com/vercel/next.js/blob/v16.2.7/crates/next-core/src/app_structure.rs#L1414-L1473 + * https://github.com/vercel/next.js/blob/v16.2.7/crates/next-core/src/app_structure.rs#L1515-L1548 + */ +export function resolveAppPageInterceptTree( + options: AppPageInterceptAttachment & { + interceptBranchSegments?: readonly string[] | null; + interceptLayoutSegments?: readonly (readonly string[])[] | null; + interceptLayouts?: readonly (AppRouteSegmentConfigModule | null | undefined)[] | null; + /** + * The `default` module of the folder that owns an intercepted slot, which + * replaces that folder's children in the intercepting route's tree. + */ + interceptOwnerDefault?: AppRouteSegmentConfigModule | null; + interceptPage?: AppRouteSegmentConfigModule | null; + /** + * Whether other slots, and a slot intercept's children, keep the branches + * vinext renders, not their defaults. + */ + keepActiveSiblings?: boolean; + }, +): AppPageSegmentConfigTree { + const interceptLayouts = options.interceptLayouts ?? []; + const interceptLayoutDepths = interceptLayouts.map( + (_, index) => options.interceptLayoutSegments?.[index]?.length ?? 0, + ); + const tree: AppPageSegmentConfigTree = { + childrenSlot: options.childrenSlot, + layoutTreePositions: options.layoutTreePositions, + layouts: options.layouts, + page: options.page, + parallelBranches: options.parallelBranches, + routeSegments: options.routeSegments, + }; + const sourceDepth = options.routeSegments?.length ?? 0; + // Next.js swaps each non-intercepting sibling at or above the intercept's + // folder for its default. Slots owned deeper sit inside the children it + // replaces, so they drop out with them. + const replaceSiblingsWithDefaults = (interceptOwner: number, interceptIndex: number) => + (options.parallelBranches ?? []).map((branch, index) => { + if (!branch || index === interceptIndex || options.keepActiveSiblings) return branch; + const owner = resolveParallelBranchOwnerPosition(branch, sourceDepth); + if (owner > interceptOwner) return null; + return { + configLayouts: [], + configLayoutTreePositions: [], + isDefault: true, + layout: null, + name: branch.name, + ownerTreePosition: owner, + page: branch.isDefault ? branch.page : (branch.default ?? null), + routeSegments: [], + }; + }); + if (!options.isSiblingPageIntercept) { + const slot = resolveAppPageInterceptedSlot(options); + if (!slot) return tree; + const interceptOwner = resolveParallelBranchOwnerPosition(slot, sourceDepth); + const parallelBranches = replaceSiblingsWithDefaults(interceptOwner, options.slotIndex); + parallelBranches[options.slotIndex] = { + configLayouts: interceptLayouts, + configLayoutTreePositions: interceptLayoutDepths, + isDefault: false, + layout: slot.layout ?? null, + name: slot.name, + ownerTreePosition: slot.ownerTreePosition, + page: options.interceptPage ?? null, + routeSegments: options.interceptBranchSegments ?? [], + }; + if (options.keepActiveSiblings) return { ...tree, parallelBranches }; + // Next.js's `keys_to_replace` covers `children` as well. Without a + // `default` of its own, an interception path's children get the + // built-in `default-null`, which exports no config. + const ownerSegments = (options.routeSegments ?? []).slice(0, interceptOwner); + const ownerLayouts = (options.layouts ?? []).flatMap((layout, index) => { + const position = options.layoutTreePositions?.[index] ?? 0; + return position > interceptOwner ? [] : [{ layout, position }]; + }); + return { + childrenSlot: { ownerTreePath: `/${ownerSegments.join("/")}`, state: "default" }, + layoutTreePositions: ownerLayouts.map(({ position }) => position), + layouts: ownerLayouts.map(({ layout }) => layout), + page: options.interceptOwnerDefault ?? null, + parallelBranches, + routeSegments: ownerSegments, + }; + } + const routeSegments = options.routeSegments ?? []; + const layouts = options.layouts ?? []; + return { + ...tree, + childrenSlot: null, + layoutTreePositions: [ + ...layouts.map((_, index) => options.layoutTreePositions?.[index] ?? 0), + ...interceptLayoutDepths.map((depth) => routeSegments.length + depth), + ], + layouts: [...layouts, ...interceptLayouts], + page: options.interceptPage ?? null, + parallelBranches: replaceSiblingsWithDefaults(sourceDepth, -1), + routeSegments: [...routeSegments, ...(options.interceptBranchSegments ?? [])], + }; +} + +type AppPageSegmentConfigModules = Pick< + ResolveAppPageSegmentConfigOptions, + "layouts" | "page" | "parallelBranches" +>; + +// A route-wide `fetchCache` mode of either tree applies to a direct +// intercepted response, in this order: a `force-*` mode overrides an `only-*` +// one, as within a tree, and the no-store mode wins a conflict between trees. +const ROUTE_WIDE_FETCH_CACHE_MODES: readonly FetchCacheMode[] = [ + "force-no-store", + "force-cache", + "only-no-store", + "only-cache", +]; + +/** + * The segment config a direct intercepted RSC response renders under. + * Next.js takes it from the segments its intercepting route renders + * (`interceptTree`, from `resolveAppPageInterceptTree`), but vinext also + * renders the active pages that tree swaps for defaults (`renderedTree`, with + * `keepActiveSiblings`). Each tree is reduced on its own, since a slot's + * `default` and its active page never render together, and the two policies + * merge: the shortest revalidate, a `force-dynamic`, a `dynamicParams = false` + * and a route-wide `fetchCache` mode from either tree apply, and the + * intercepting route's own tree keeps precedence for the rest. The shortest + * `unstable_dynamicStaleTime` of either tree's pages (a slot's `default` + * included, as Next.js reads it) applies too. + * https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/server/app-render/create-component-tree.tsx + * https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/server/app-render/app-render.tsx + */ +export function resolveAppPageInterceptSegmentConfig( + interceptTree: AppPageSegmentConfigModules, + renderedTree: AppPageSegmentConfigModules, +): EffectiveAppPageSegmentConfig { + // The `dynamic = "error"` fetchCache default applies once, to the merged + // `dynamic` mode, not to either tree's. + const [intercept, rendered] = [interceptTree, renderedTree].map((tree) => + reduceAppPageSegmentConfig({ + layouts: tree.layouts, + page: tree.page, + parallelBranches: tree.parallelBranches, + parallelPages: (tree.parallelBranches ?? []).map((branch) => branch?.page), + }), + ); + const dynamicConfig = + intercept.dynamicConfig === "force-dynamic" || rendered.dynamicConfig === "force-dynamic" + ? "force-dynamic" + : (intercept.dynamicConfig ?? rendered.dynamicConfig); + const dynamicParamsConfig = + intercept.dynamicParamsConfig === false || rendered.dynamicParamsConfig === false + ? false + : (intercept.dynamicParamsConfig ?? rendered.dynamicParamsConfig); + const dynamicStaleTimeSeconds = resolveDynamicStaleTimeSeconds( + intercept.dynamicStaleTimeSeconds, + rendered.dynamicStaleTimeSeconds, + ); + const fetchCache = + ROUTE_WIDE_FETCH_CACHE_MODES.find( + (mode) => intercept.fetchCache === mode || rendered.fetchCache === mode, + ) ?? + intercept.fetchCache ?? + rendered.fetchCache; + const runtime = intercept.runtime ?? rendered.runtime; + return applyAppPageFetchCacheDefault({ + ...(dynamicConfig === undefined ? {} : { dynamicConfig }), + ...(dynamicParamsConfig === undefined ? {} : { dynamicParamsConfig }), + ...(dynamicStaleTimeSeconds === undefined ? {} : { dynamicStaleTimeSeconds }), + ...(fetchCache === undefined ? {} : { fetchCache }), + revalidateSeconds: resolveRevalidateSeconds( + intercept.revalidateSeconds, + rendered.revalidateSeconds, + ), + ...(runtime === undefined ? {} : { runtime }), + }); +} + +/** + * The main-tree position of the folder that owns a slot, for a route whose + * main tree is `routeDepth` folders deep. + */ +function resolveParallelBranchOwnerPosition( + branch: ParallelAppPageSegmentConfigBranch, + routeDepth: number, +): number { + return Math.min( + branch.ownerTreePosition ?? + routeDepth - (branch.isDefault ? 0 : (branch.routeSegments ?? []).length), + routeDepth, + ); +} + +/** + * One segment of an App page's loader tree, as Next.js's build visits it when + * it classifies the route. + */ +export type AppPageStaticParamsWalkSegment = { + /** Whether the segment is a dynamic URL segment (`[slug]`, `[...slug]`). */ + dynamic: boolean; + /** Whether the segment's layout (or page) exports `generateStaticParams`. */ + generateStaticParams: boolean; + /** + * The segment name and the file that supplies its module. Next.js visits + * each distinct pair once, so a slot's layout-less `[slug]` folder that + * repeats the main tree's does not count twice. + */ + identity: readonly [name: string, file: unknown]; + /** + * The segment's position in the loader tree: its index among its parent's + * children at each level, from the root. The root layout's segment is `[]`. + */ + treePath: readonly number[]; +}; + +function compareTreePaths(a: readonly number[], b: readonly number[]): number { + if (a.length !== b.length) return a.length - b.length; + for (let index = 0; index < a.length; index++) { + if (a[index] !== b[index]) return a[index] - b[index]; + } + return 0; +} + +/** + * Whether `generateStaticParams` is exported at or below the route's last + * dynamic segment: by that segment's layout, a deeper layout, or the page. + * Next.js classifies such a route as SSG (static generation with an on-demand + * fallback). A dynamic-segment route without it is dynamic (ƒ) and is never + * full-page cached. + * + * Port of Next.js's `lastDynamicSegmentHadGenerateStaticParams` walk: segments + * are visited breadth-first, a dynamic segment without `generateStaticParams` + * clears the flag, and any segment with it sets the flag. + * https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/build/static-paths/app.ts#L926-L935 + * https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/build/segment-config/app/app-segments.ts#L72-L126 + */ +export function lastDynamicSegmentHasGenerateStaticParams( + segments: readonly AppPageStaticParamsWalkSegment[], +): boolean { + const visited: AppPageStaticParamsWalkSegment["identity"][] = []; + const ordered = [...segments].sort((a, b) => compareTreePaths(a.treePath, b.treePath)); + let hasGenerateStaticParams = false; + + for (const segment of ordered) { + const [name, file] = segment.identity; + if (visited.some(([seenName, seenFile]) => seenName === name && seenFile === file)) continue; + visited.push(segment.identity); + + if (segment.dynamic && !segment.generateStaticParams) { + hasGenerateStaticParams = false; + } else if (segment.generateStaticParams) { + hasGenerateStaticParams = true; + } + } + + return hasGenerateStaticParams; +} + +const PAGE_SEGMENT_NAME = "__PAGE__"; + +function hasGenerateStaticParamsExport( + segment: AppRouteSegmentConfigModule | null | undefined, +): boolean { + return typeof segment?.generateStaticParams === "function"; +} + +const DEFAULT_SEGMENT_NAME = "__DEFAULT__"; + +/** + * Turbopack reads a folder's subfolders from a `BTreeMap`, whose + * keys compare as Rust `str`s: by UTF-8 bytes. That is Unicode code point + * order, which JavaScript's `<` (UTF-16 code units) doesn't match above the + * Basic Multilingual Plane. + * https://github.com/vercel/next.js/blob/v16.2.7/crates/next-core/src/app_structure.rs#L188-L192 + * https://github.com/vercel/next.js/blob/v16.2.7/turbopack/crates/turbo-rcstr/src/lib.rs#L350-L354 + */ +function compareFolderNames(a: string, b: string): number { + const left = Array.from(a, (char) => char.codePointAt(0) ?? 0); + const right = Array.from(b, (char) => char.codePointAt(0) ?? 0); + for (let index = 0; index < Math.min(left.length, right.length); index++) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return left.length - right.length; +} + +/** + * Collect the loader-tree segments of an App page route from its layout, page + * and parallel-slot modules, for `lastDynamicSegmentHasGenerateStaticParams`. + * + * Children follow the loader tree Next.js's default build (Turbopack) makes: + * `children` first, then every slot in folder-name order, whether it matched + * a page or renders `default`. A default slot is a single `__DEFAULT__` + * segment without the slot's layout. + * https://github.com/vercel/next.js/blob/v16.2.7/crates/next-core/src/app_structure.rs#L1489-L1511 + * https://github.com/vercel/next.js/blob/v16.2.7/crates/next-core/src/app_structure.rs#L1515-L1548 + */ +export function collectAppPageStaticParamsWalkSegments( + options: Pick< + ResolveAppPageSegmentConfigOptions, + "layoutTreePositions" | "layouts" | "page" | "parallelBranches" | "routeSegments" + > & { childrenSlot?: AppPageChildrenSlot | null }, +): AppPageStaticParamsWalkSegment[] { + // When children renders the owner's `default`, the route's deeper URL + // segments come from a slot, and the main tree ends at the owner with a + // `__DEFAULT__` segment in the children position. + const childrenDefaultPosition = resolveChildrenDefaultTreePosition(options.childrenSlot); + const routeSegments = + childrenDefaultPosition === null + ? (options.routeSegments ?? []) + : (options.routeSegments ?? []).slice(0, childrenDefaultPosition); + const layoutsByPosition = new Map(); + options.layouts?.forEach((layout, index) => { + if (layout) layoutsByPosition.set(options.layoutTreePositions?.[index] ?? 0, layout); + }); + + const branchesByOwner = new Map(); + for (const branch of options.parallelBranches ?? []) { + if (!branch) continue; + const owner = resolveParallelBranchOwnerPosition(branch, routeSegments.length); + branchesByOwner.set(owner, [...(branchesByOwner.get(owner) ?? []), branch]); + } + + const segments: AppPageStaticParamsWalkSegment[] = []; + let treePath: number[] = []; + // A folder's segment takes its module from the folder's layout. The page is + // a child segment of the deepest folder. + for (let position = 0; position <= routeSegments.length + 1; position++) { + if (position <= routeSegments.length) { + const name = position === 0 ? "" : routeSegments[position - 1]; + const layout = layoutsByPosition.get(position); + segments.push({ + dynamic: position > 0 && isDynamicSegment(name), + generateStaticParams: hasGenerateStaticParamsExport(layout), + identity: [name, layout], + treePath, + }); + } else { + segments.push({ + dynamic: false, + generateStaticParams: hasGenerateStaticParamsExport(options.page), + identity: [PAGE_SEGMENT_NAME, options.page ?? undefined], + treePath, + }); + break; + } + + // `children` takes index 0; the slots follow it. + const slots = [...(branchesByOwner.get(position) ?? [])].sort((a, b) => + compareFolderNames(a.name ?? "", b.name ?? ""), + ); + slots.forEach((branch, index) => { + const slotPath = [...treePath, index + 1]; + if (branch.isDefault) { + segments.push({ + dynamic: false, + generateStaticParams: hasGenerateStaticParamsExport(branch.page), + identity: [DEFAULT_SEGMENT_NAME, branch.page ?? undefined], + treePath: slotPath, + }); + } else { + segments.push(...collectActiveSlotSegments(branch, slotPath)); + } + }); + if (position === childrenDefaultPosition) { + segments.push({ + dynamic: false, + generateStaticParams: hasGenerateStaticParamsExport(options.page), + identity: [DEFAULT_SEGMENT_NAME, options.page ?? undefined], + treePath: [...treePath, 0], + }); + break; + } + treePath = [...treePath, 0]; + } + + return segments; +} + +function collectActiveSlotSegments( + branch: ParallelAppPageSegmentConfigBranch, + slotPath: readonly number[], +): AppPageStaticParamsWalkSegment[] { + const branchSegments = branch.routeSegments ?? []; + const configLayoutsByPosition = new Map(); + branch.configLayouts?.forEach((layout, index) => { + if (layout) configLayoutsByPosition.set(branch.configLayoutTreePositions?.[index] ?? 0, layout); + }); + + const segments: AppPageStaticParamsWalkSegment[] = [ + { + dynamic: false, + generateStaticParams: hasGenerateStaticParamsExport(branch.layout), + identity: [`@${branch.name ?? ""}`, branch.layout ?? undefined], + treePath: slotPath, + }, + ]; + // Each folder inside the slot has one child: the next folder, then the page. + // A slot's root page inside a route group has no segments, but the group's + // layout still has a tree position. + const depth = Math.max(branchSegments.length, ...(branch.configLayoutTreePositions ?? [])); + let treePath = [...slotPath]; + for (let index = 0; index < depth; index++) { + treePath = [...treePath, 0]; + const name = branchSegments[index]; + const layout = configLayoutsByPosition.get(index + 1); + segments.push({ + dynamic: name !== undefined && isDynamicSegment(name), + generateStaticParams: hasGenerateStaticParamsExport(layout), + identity: [name ?? "", layout], + treePath, + }); + } + segments.push({ + dynamic: false, + generateStaticParams: hasGenerateStaticParamsExport(branch.page), + identity: [PAGE_SEGMENT_NAME, branch.page ?? undefined], + treePath: [...treePath, 0], + }); + return segments; +} + +/** + * Whether an App page route exports `generateStaticParams` at or below its last + * dynamic segment, read from its layout, page and parallel-slot modules. + */ +export function hasAppPageGenerateStaticParamsAtLastDynamicSegment( + options: Parameters[0], +): boolean { + return lastDynamicSegmentHasGenerateStaticParams(collectAppPageStaticParamsWalkSegments(options)); +} + +/** + * Whether any segment of an App page route's loader tree exports + * `generateStaticParams`, read from its layout, page and parallel-slot + * modules. Next.js collects every segment of the route's own loader tree, its + * parallel routes included, and calls each segment's generator. + * https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/build/segment-config/app/app-segments.ts#L72-L126 + */ +export function hasAppPageAnyGenerateStaticParams( + options: Parameters[0], +): boolean { + return collectAppPageStaticParamsWalkSegments(options).some( + (segment) => segment.generateStaticParams, + ); +} + +/** + * Whether Next.js would classify an App page route as static or SSG from its + * config alone. Only such routes are full-page cache candidates; every other + * route renders per request with real values and is never ISR-cached. + * + * - `runtime = "edge"` disables static generation, whatever else the route + * sets. + * - `dynamic = "force-dynamic"` and `revalidate = 0` are dynamic. + * - `dynamic = "force-static"` and `dynamic = "error"` are static. + * - Otherwise a route is static when it has no dynamic segments, or when + * `generateStaticParams` sits at or below its last dynamic segment. + * + * Next.js also treats a route as SSG when an ancestor's `generateStaticParams` + * returns every pathname param (`hadAllParamsGenerated`). That depends on the + * generator's output, which vinext doesn't compute per request, so such a + * route is treated as dynamic here. Adding `generateStaticParams` to the last + * dynamic segment (even returning `[]`) opts it in, on both. + * + * https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/build/index.ts#L2333-L2408 + */ +export function isAppPageStaticEligible(options: { + dynamicConfig?: string; + hasGenerateStaticParams: boolean; + isDynamicRoute: boolean; + isStaticGenerationEdgeRuntime: boolean; + revalidateSeconds: number | null; +}): boolean { + if (options.isStaticGenerationEdgeRuntime) return false; + if (options.dynamicConfig === "force-dynamic" || options.revalidateSeconds === 0) return false; + if (options.dynamicConfig === "force-static" || options.dynamicConfig === "error") return true; + return !options.isDynamicRoute || options.hasGenerateStaticParams; +} diff --git a/packages/vinext/src/server/app-server-action-execution.ts b/packages/vinext/src/server/app-server-action-execution.ts index 32e6706891..ad1f4e3882 100644 --- a/packages/vinext/src/server/app-server-action-execution.ts +++ b/packages/vinext/src/server/app-server-action-execution.ts @@ -29,6 +29,7 @@ import { NEXT_ROUTER_STATE_TREE_HEADER, RSC_HEADER, VINEXT_MW_CTX_HEADER, + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, } from "./headers.js"; import { VINEXT_RSC_CONTENT_TYPE, @@ -39,6 +40,10 @@ import { } from "./app-rsc-cache-busting.js"; import { applyEdgeRuntimeHeader } from "./app-page-response.js"; import { resolveAppPageActionRerenderTarget } from "./app-page-request.js"; +import { + toRouteConfigIntercept, + type AppPageStaticEligibilityIntercept, +} from "./app-page-dispatch.js"; import { resolveAppPageNavigationParams } from "./app-page-element-builder.js"; import { deferUntilStreamConsumed } from "./app-page-stream.js"; import { buildAppPageTags } from "./implicit-tags.js"; @@ -197,6 +202,7 @@ type BuildServerActionPageElementOptions, ) => BodyInit | null | Promise; reportRequestError: AppServerActionErrorReporter; - resolveRouteFetchCacheMode?: (route: TRoute) => FetchCacheMode | null; - resolveRouteRevalidateSeconds?: (route: TRoute) => number | null; - resolveRouteDynamicConfig?: (route: TRoute) => string | null | undefined; + /** + * With an intercept, these resolve the tree the rerendered intercepting + * route renders, as `dispatchAppPage`'s resolvers do. + */ + resolveRouteFetchCacheMode?: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => FetchCacheMode | null; + resolveRouteRevalidateSeconds?: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => number | null; + resolveRouteDynamicConfig?: ( + route: TRoute, + intercept?: AppPageStaticEligibilityIntercept, + ) => string | null | undefined; resolveRouteRuntime?: (route: TRoute) => AppServerActionRouteRuntime; request: Request; sanitizeErrorForClient: (error: unknown) => unknown; @@ -1843,6 +1862,7 @@ export async function handleServerActionRscRequest< const match = options.currentRouteMatch; let element: TElement; let errorPattern = match ? match.route.pattern : options.cleanPathname; + let renderedPathAndSearch: string | null = null; const actionRerenderIsRscRequest = true; if (match) { const { route: actionRoute, params: actionParams } = match; @@ -1854,6 +1874,10 @@ export async function handleServerActionRscRequest< getRouteParamNames: options.getRouteParamNames, getSourceRoute: options.getSourceRoute, isRscRequest: actionRerenderIsRscRequest, + // As in dispatch: an attached slot intercept loads the owner default + // its tree's config reads, and one the route has no slot for loads + // nothing. + routeHasSlot: (route, slotKey) => !!route.slots && Object.hasOwn(route.slots, slotKey), toInterceptOpts: options.toInterceptOpts, }); @@ -1871,8 +1895,19 @@ export async function handleServerActionRscRequest< ); // Hydrate the re-render target before reading its page module. await options.ensureRouteLoaded?.(actionRerenderTarget.route); + // The rerender repeats the intercepted render, so it takes that render's + // config. As above, toInterceptOpts produces the dispatch intercept + // options, which the generic TInterceptOpts doesn't express. + const actionRerenderConfigIntercept = actionRerenderTarget.interceptOpts + ? toRouteConfigIntercept( + actionRerenderTarget.interceptOpts as unknown as Parameters< + typeof toRouteConfigIntercept + >[0], + ) + : undefined; const actionRerenderDynamicConfig = options.resolveRouteDynamicConfig?.( actionRerenderTarget.route, + actionRerenderConfigIntercept, ); const actionRerenderSearchParams = prepareActionPageRerenderContext({ draftModeCookie: actionDraftCookie, @@ -1887,11 +1922,21 @@ export async function handleServerActionRscRequest< searchParams: actionRerenderSearchParams, params: resolvedActionNavigationParams, }); + const renderedSearch = options.searchParams.toString(); + renderedPathAndSearch = renderedSearch + ? `${options.cleanPathname}?${renderedSearch}` + : options.cleanPathname; setCurrentFetchCacheMode( - options.resolveRouteFetchCacheMode?.(actionRerenderTarget.route) ?? null, + options.resolveRouteFetchCacheMode?.( + actionRerenderTarget.route, + actionRerenderConfigIntercept, + ) ?? null, ); setCurrentFetchRevalidate( - options.resolveRouteRevalidateSeconds?.(actionRerenderTarget.route) ?? null, + options.resolveRouteRevalidateSeconds?.( + actionRerenderTarget.route, + actionRerenderConfigIntercept, + ) ?? null, ); setCurrentForceDynamicFetchDefault(actionRerenderDynamicConfig === "force-dynamic"); setCurrentFetchSoftTags( @@ -1908,6 +1953,7 @@ export async function handleServerActionRscRequest< route: actionRerenderTarget.route, searchParams: actionRerenderSearchParams, renderMode: APP_RSC_RENDER_MODE_NAVIGATION, + isForceStatic: actionRerenderDynamicConfig === "force-static", observeMetadataSearchParamsAccess: actionRerenderDynamicConfig !== "force-static", observePageSearchParamsAccess: actionRerenderDynamicConfig !== "force-static", }); @@ -1946,6 +1992,14 @@ export async function handleServerActionRscRequest< mergeMiddlewareResponseHeaders(actionHeaders, options.middlewareHeaders); applyRscCompatibilityIdHeader(actionHeaders); setActionRevalidatedHeader(actionHeaders, actionRevalidationKind); + // A rewrite can give the re-render another query than the page had, and + // its client pages read this one, as after a navigation. + if (renderedPathAndSearch !== null) { + actionHeaders.set( + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, + encodeURIComponent(renderedPathAndSearch), + ); + } const actionResponse = createServerActionRscResponse( rscStream, { diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts index bb7fa4b7fa..12de1c437e 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -19,6 +19,9 @@ import { setNavigationContext, } from "vinext/shims/navigation-server"; import { runWithNavigationContext } from "vinext/shims/navigation-state"; +import { startCandidateSearchParamsGate } from "./app-ssr-search-params-gate.js"; +import { onRenderDynamicLatched } from "vinext/shims/headers"; +import { createClientPageSsrSearchParamsSource } from "./app-page-search-params-observation.js"; import { runWithRootParamsScope, type RootParams } from "vinext/shims/root-params"; import { isOpenRedirectShaped } from "./open-redirect.js"; import { notFoundResponse } from "./http-error-responses.js"; @@ -35,6 +38,7 @@ import { } from "./html.js"; import { renderBeforeInteractiveInlineScripts } from "./before-interactive-head.js"; import { + createNavigationRuntimeRenderedSearchScript, createNavigationRuntimeRscMetadataScript, createRscEmbedTransform, createTickBufferedTransform, @@ -411,6 +415,12 @@ export async function handleSsr( isStaticGeneration?: boolean; /** `dynamic = "force-static"` suppresses the useSearchParams bailout. */ isForceStatic?: boolean; + /** + * Production render that may be stored under a query-free key. SSR + * `useSearchParams()` waits on a per-request gate, and the navigation + * payload leaves the query for the browser to read. + */ + isCacheCandidate?: boolean; fallbackToErrorDocumentOnShellError?: boolean; dynamicStaleTimeSeconds?: number; getInitialNavigationCacheMetadata?: () => InitialNavigationCacheMetadata; @@ -418,10 +428,37 @@ export async function handleSsr( ): Promise { return runWithNavigationContext(async () => { const assetCrossOrigin = pagesClientAssets.crossOrigin ?? ""; + // Static generation already bails out at once, and force-static reads an + // empty query, so neither needs the gate. + const searchParamsGate = + options?.isCacheCandidate === true && + options.isStaticGeneration !== true && + options.isForceStatic !== true + ? startCandidateSearchParamsGate() + : null; + // The gate stops listening once it settles, but a client page behind a + // later boundary can still turn the render dynamic. + let rendersDynamic = false; + const stopWatchingDynamic = searchParamsGate + ? onRenderDynamicLatched(() => { + rendersDynamic = true; + }) + : null; + const requiredNavigationContext = requireNavigationContext(navContext); const ssrNavigationContext = { - ...requireNavigationContext(navContext), + ...requiredNavigationContext, isStaticGeneration: options?.isStaticGeneration, isForceStatic: options?.isForceStatic, + searchParamsGate: searchParamsGate?.gate, + // A client page reading this marks the render dynamic, like a server + // page's searchParams. + getClientPageSearchParams: createClientPageSsrSearchParamsSource( + requiredNavigationContext.searchParams, + { + isForceStatic: options?.isForceStatic, + isPprFallbackShell: options?.pprFallbackShellSignal !== undefined, + }, + ), }; await clientReferencePreloader.preload(); @@ -431,6 +468,7 @@ export async function handleSsr( clearServerInsertedHTML(); const cleanup = (): void => { + stopWatchingDynamic?.(); setNavigationContext(null); clearServerInsertedHTML(); }; @@ -464,6 +502,10 @@ export async function handleSsr( }); } + if (searchParamsGate) { + ssrStream = searchParamsGate.settleWhenConsumed(ssrStream); + } + let flightRoot: PromiseLike | null = null; function VinextFlightRoot(): ReactNode { @@ -716,6 +758,20 @@ export async function handleSsr( return traceMetaHTML; }; let didInjectHeadHTML = false; + let headHidQuery = false; + let didSendRenderedSearch = false; + // A render that turns dynamic after the head hid its query won't be + // stored either, so a later flush sends the query its client pages + // read, ahead of their HTML (see `client-page-root.tsx`). + const getRenderedSearchHTML = (): string => { + if (!headHidQuery || !rendersDynamic || didSendRenderedSearch) return ""; + didSendRenderedSearch = true; + const search = ssrNavigationContext.searchParams.toString(); + return createInlineScriptTag( + createNavigationRuntimeRenderedSearchScript(search ? `?${search}` : ""), + options?.scriptNonce, + ); + }; const getInsertedHTML = (): string => { const insertedHTML = renderInsertedHtml(renderServerInsertedHTML()); const errorMetaHTML = errorMetaRenderer.flush(); @@ -723,17 +779,28 @@ export async function handleSsr( options?.initialDevServerError, options?.scriptNonce, ); - if (didInjectHeadHTML) return insertedHTML + errorMetaHTML; + if (didInjectHeadHTML) return insertedHTML + errorMetaHTML + getRenderedSearchHTML(); didInjectHeadHTML = true; + // A stored document must not carry the request's query, so the + // browser reads it from its own URL instead. A gate that has + // already opened means the render won't be stored, so it keeps the + // effective query (which a rewrite may have changed). + const hidesQuery = searchParamsGate !== null && searchParamsGate.gate.decision !== "real"; + headHidQuery = hidesQuery; + const isSearchParamsFromBrowser = + hidesQuery || + (options?.isStaticGeneration === true ? options.isForceStatic !== true : undefined); return buildHeadInjectionHtml( - ssrNavigationContext, + hidesQuery + ? { ...ssrNavigationContext, searchParams: new URLSearchParams() } + : ssrNavigationContext, bootstrapModuleUrl, options?.formState ?? null, insertedHTML + errorMetaHTML + getTraceMetaHTML() + initialDevServerErrorHTML, fontHTML, options?.dynamicStaleTimeSeconds, - options?.isStaticGeneration === true ? options.isForceStatic !== true : undefined, + isSearchParamsFromBrowser, options?.scriptNonce, ); }; diff --git a/packages/vinext/src/server/app-ssr-search-params-gate.ts b/packages/vinext/src/server/app-ssr-search-params-gate.ts new file mode 100644 index 0000000000..961275d910 --- /dev/null +++ b/packages/vinext/src/server/app-ssr-search-params-gate.ts @@ -0,0 +1,70 @@ +import { + isRenderDynamicLatched, + markDynamicUsage, + onRenderDynamicLatched, +} from "vinext/shims/headers"; +import { createSearchParamsGate, type SearchParamsGate } from "vinext/shims/search-params-gate"; + +type CandidateSearchParamsGate = { + gate: SearchParamsGate; + /** + * Wrap the Flight stream SSR reads. The gate settles once SSR has read it to + * the end, and opens if it is cancelled or fails. + */ + settleWhenConsumed: (flightStream: ReadableStream) => ReadableStream; +}; + +/** + * Start the SSR `useSearchParams()` gate of a cache-candidate render. + * + * The gate opens with real values as soon as the render uses a dynamic API, + * including before this call. Otherwise it bails out when the render settles: + * the RSC stream has ended and SSR has read the whole Flight response, so no + * server component can mark the render dynamic any more. + */ +export function startCandidateSearchParamsGate(): CandidateSearchParamsGate { + const controller = createSearchParamsGate({ onOpen: markDynamicUsage }); + const unsubscribe = onRenderDynamicLatched(() => controller.open()); + if (isRenderDynamicLatched()) controller.open(); + + let finished = false; + const finish = (endedNormally: boolean): void => { + if (finished) return; + finished = true; + unsubscribe(); + // Only a normal end proves no server component can still mark the render + // dynamic. A failed or cancelled stream opens the gate instead, so the + // render is never stored and its real failure isn't replaced by a bailout. + if (endedNormally) controller.settle(); + else controller.open(); + }; + + return { + gate: controller.gate, + settleWhenConsumed: (flightStream) => { + const reader = flightStream.getReader(); + return new ReadableStream({ + async pull(streamController) { + let result: ReadableStreamReadResult; + try { + result = await reader.read(); + } catch (error) { + finish(false); + streamController.error(error); + return; + } + if (result.done) { + streamController.close(); + finish(true); + return; + } + streamController.enqueue(result.value); + }, + cancel(reason) { + finish(false); + return reader.cancel(reason); + }, + }); + }, + }; +} diff --git a/packages/vinext/src/server/app-ssr-stream.ts b/packages/vinext/src/server/app-ssr-stream.ts index b6958acbad..0459d509e4 100644 --- a/packages/vinext/src/server/app-ssr-stream.ts +++ b/packages/vinext/src/server/app-ssr-stream.ts @@ -81,6 +81,20 @@ export function createNavigationRuntimeRscMetadataScript( ); } +/** + * The query a client page read during SSR, for a render that turned dynamic + * after its head told the browser to read the query from its own URL. + */ +export function createNavigationRuntimeRenderedSearchScript(search: string): string { + return ( + "Object.assign(" + + navigationRuntimeRscBootstrapExpression() + + ",{renderedSearch:" + + safeJsonStringify(search) + + "})" + ); +} + function createNavigationRuntimeRscChunkScript(chunk: RscEmbeddedChunk): string { return navigationRuntimeRscBootstrapExpression() + ".rsc.push(" + safeJsonStringify(chunk) + ")"; } diff --git a/packages/vinext/src/server/app-worker-stages.ts b/packages/vinext/src/server/app-worker-stages.ts index fcbd3f268c..f63b44c664 100644 --- a/packages/vinext/src/server/app-worker-stages.ts +++ b/packages/vinext/src/server/app-worker-stages.ts @@ -1,3 +1,11 @@ +import { keepOnlyValidatedRscCacheBustingSearchParam } from "./app-rsc-cache-busting.js"; +import { VINEXT_RSC_VARY_HEADER } from "./app-rsc-vary.js"; +import { + cacheabilityManifestPageState, + cacheabilityRequestIdentity, + cacheabilityRoutePathname, + type CacheabilityManifest, +} from "./cacheability-manifest.js"; import type { AppRscRenderMode } from "./app-rsc-render-mode.js"; import type { VinextResponseStageCacheability, @@ -117,6 +125,75 @@ export function prepareSharedAppPageDispatch( : request; } +/** + * Build the query-free cache identity of a shared App page dispatch. The user + * query leaves both the URL and `resolvedUrl`; the `.rsc` suffix, an RSC + * request's validated `_rsc`, and the render mode stay because they select the + * representation. HTML identities also drop the RSC selector headers. + */ +export function createSharedAppPageCacheIdentity( + request: Request, + props: AppMatchedWorkerResponseStageProps, +): NonNullable { + const queryFree = withoutAppPageDispatchQuery(request.url, props); + const headers = new Headers(request.headers); + if (!props.isRscRequest) { + // HTML dispatch reads none of the RSC selectors: its render mode is fixed + // to navigation, and every other selector is RSC-gated or already in the + // props. Transports key these Vary fields, so keep them out of the key. + for (const name of VINEXT_RSC_VARY_HEADER.split(",")) headers.delete(name.trim()); + } + return { + props: queryFree.props, + request: new Request(queryFree.url, { headers, method: request.method }), + }; +} + +/** + * Drop the user query from an App page dispatch URL and its `resolvedUrl`, + * keeping an RSC request's validated `_rsc`, the rule the query-free cache + * identity uses. + */ +export function withoutAppPageDispatchQuery( + requestUrl: string, + props: AppMatchedWorkerResponseStageProps, +): { props: AppMatchedWorkerResponseStageProps; url: string } { + const url = new URL(requestUrl); + keepOnlyValidatedRscCacheBustingSearchParam(url, props.isRscRequest); + const searchIndex = props.resolvedUrl.indexOf("?"); + return { + props: { + ...props, + resolvedUrl: searchIndex === -1 ? props.resolvedUrl : props.resolvedUrl.slice(0, searchIndex), + }, + url: url.toString(), + }; +} + +/** + * Whether the Workers Cache request-stage projection gives a shared App page + * dispatch the `static-candidate` state, so its dispatch drops the user query. + * It is decided from the inputs completed-response admission uses: the + * header-canonicalized request's representation, the pathname after rewrites + * and the matched route pattern. + */ +export function isStaticCandidateAppPageDispatch( + projection: CacheabilityManifest, + request: Request, + props: AppMatchedWorkerResponseStageProps, +): boolean { + const identity = cacheabilityRequestIdentity(request, props.cacheability.representation); + if (!identity) return false; + return ( + cacheabilityManifestPageState( + projection, + { kind: "app-page", pattern: props.routePattern }, + identity.representation, + cacheabilityRoutePathname(props.cacheability.resolvedRoutePathname, identity.representation), + ) === "static-candidate" + ); +} + function isAppPageParams(value: unknown): value is AppPageParams { if (!value || typeof value !== "object" || Array.isArray(value)) return false; for (const param of Object.values(value)) { diff --git a/packages/vinext/src/server/cache-control.ts b/packages/vinext/src/server/cache-control.ts index e18f8ab696..654f37ef14 100644 --- a/packages/vinext/src/server/cache-control.ts +++ b/packages/vinext/src/server/cache-control.ts @@ -67,6 +67,15 @@ export function readCdnResponseCacheControl(headers: Headers | undefined): strin return policy ? policy.readCacheControl(headers) : headers.get("Cache-Control"); } +/** Name the lowercased policy header whose value `readCdnResponseCacheControl` read. */ +export function readCdnResponsePolicyHeaderName(headers: Headers): string | null { + const policy = getCdnCacheAdapter().responsePolicy; + if (!policy) return headers.has("Cache-Control") ? "cache-control" : null; + // Only the adapter knows which header its precedence picked; an adapter + // that does not say leaves the policy unattributed. + return policy.readCacheControlHeaderName?.(headers)?.toLowerCase() ?? null; +} + /** Ask the active adapter whether one policy header explicitly disables storage. */ export function isNonCacheableCdnResponsePolicy(name: string, value: string): boolean { if (name.toLowerCase() === "cache-control") return isNonCacheableCacheControl(value); diff --git a/packages/vinext/src/server/cache-proof.ts b/packages/vinext/src/server/cache-proof.ts index ab229fd0ab..ac2c8b9261 100644 --- a/packages/vinext/src/server/cache-proof.ts +++ b/packages/vinext/src/server/cache-proof.ts @@ -1299,6 +1299,17 @@ export function hasCompleteNegativeRequestApiProof( return true; } +/** + * Whether a stored App page's render is proven not to have read the request's + * query, so one entry may serve every query. Core only writes and reads + * entries with this proof. + */ +export function hasQueryInvariantRenderProof(observation: RenderObservation | undefined): boolean { + return ( + observation !== undefined && hasCompleteNegativeRequestApiProof(observation, ["searchParams"]) + ); +} + function isStaticLayoutOutputScope( output: CacheProofOutputScope, ): output is StaticLayoutCacheProofOutputScope { diff --git a/packages/vinext/src/server/cacheability-manifest.ts b/packages/vinext/src/server/cacheability-manifest.ts index 039775e826..ea71f498f5 100644 --- a/packages/vinext/src/server/cacheability-manifest.ts +++ b/packages/vinext/src/server/cacheability-manifest.ts @@ -15,6 +15,8 @@ import { import { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL } from "./app-rsc-render-mode.js"; export const CACHEABILITY_MANIFEST_MODULE = "__vinext_cacheability_manifest.js"; +/** Request-stage projection of the manifest: the App page routes that can admit a query-free entry. */ +export const CACHEABILITY_REQUEST_PROJECTION_MODULE = "__vinext_cacheability_request_projection.js"; export type CacheabilityRouteKind = "app-page" | "app-route" | "pages-page"; export type CacheabilityRepresentation = @@ -42,7 +44,9 @@ export type CacheabilityManifestRoute = { staticRepresentation?: CacheabilityRepresentation; /** Exact dynamic paths observed in a mixed or pattern-dynamic route. */ runtimePaths?: string[]; - /** Exact paths statically certified by the representation that was probed. */ + /** Exact paths runtime-checked only in the listed representation. */ + runtimeRepresentationPaths?: Partial>; + /** Exact paths statically certified per representation. A path may appear in several lists. */ staticPaths?: Partial>; }; @@ -138,6 +142,10 @@ function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | nu route.runtimePaths === undefined ? undefined : parsePathList(route.runtimePaths, pathPrefix); const staticPaths = route.staticPaths === undefined ? undefined : parseStaticPaths(route.staticPaths, pathPrefix); + const runtimeRepresentationPaths = + route.runtimeRepresentationPaths === undefined + ? undefined + : parseStaticPaths(route.runtimeRepresentationPaths, pathPrefix); const staticRepresentation = isRepresentation(route.staticRepresentation) ? route.staticRepresentation : undefined; @@ -157,16 +165,19 @@ function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | nu pathPrefix !== undefined || staticRepresentation !== undefined || runtimePaths !== undefined || - staticPaths !== undefined)) || + staticPaths !== undefined || + runtimeRepresentationPaths !== undefined)) || (staticRepresentation !== undefined && (route.state !== "runtime-check" || /(^|\/):/.test(route.pattern) || runtimePaths !== undefined || - staticPaths !== undefined)) || - (pathPrefix !== undefined && !runtimePaths && !staticPaths) || + staticPaths !== undefined || + runtimeRepresentationPaths !== undefined)) || + (pathPrefix !== undefined && !runtimePaths && !staticPaths && !runtimeRepresentationPaths) || (route.runtimePaths !== undefined && !runtimePaths) || (route.staticPaths !== undefined && !staticPaths) || - ((runtimePaths || staticPaths || route.allowUnknown === true) && + (route.runtimeRepresentationPaths !== undefined && !runtimeRepresentationPaths) || + ((runtimePaths || staticPaths || runtimeRepresentationPaths || route.allowUnknown === true) && route.state !== "runtime-check") ) { return null; @@ -183,15 +194,29 @@ function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | nu ...(runtimeRepresentation ? { runtimeRepresentation } : {}), ...(staticRepresentation ? { staticRepresentation } : {}), ...(runtimePaths ? { runtimePaths } : {}), + ...(runtimeRepresentationPaths ? { runtimeRepresentationPaths } : {}), ...(staticPaths ? { staticPaths } : {}), }; - const observedPaths = new Set(); - for (const tokens of [runtimePaths, ...Object.values(staticPaths ?? {})]) { + // A certified App page path is listed under each representation that shares + // its render (HTML and its RSC versions). Each list is sorted and unique, and + // a runtime-checked path is never also certified static. + const runtimePathSet = new Set( + (runtimePaths ?? []).map((token) => expandPathToken(pathPrefix, token)!), + ); + for (const tokens of Object.values(staticPaths ?? {})) { + for (const token of tokens ?? []) { + if (runtimePathSet.has(expandPathToken(pathPrefix, token)!)) return null; + } + } + // A representation-only runtime path is neither runtime-checked in every + // representation nor certified static in its own. + for (const [representation, tokens] of Object.entries(runtimeRepresentationPaths ?? {})) { + const staticTokens = new Set(staticPaths?.[representation as CacheabilityRepresentation]); for (const token of tokens ?? []) { - const pathname = expandPathToken(pathPrefix, token)!; - if (observedPaths.has(pathname)) return null; - observedPaths.add(pathname); + if (runtimePathSet.has(expandPathToken(pathPrefix, token)!) || staticTokens.has(token)) { + return null; + } } } return key === cacheabilityManifestRouteKey(parsed.kind, parsed.pattern) ? parsed : null; @@ -365,7 +390,15 @@ export function cacheabilityManifestRouteState( if (route.runtimeRepresentation !== undefined) { return representation === route.runtimeRepresentation ? route.state : null; } - if (!route.staticPaths && !route.runtimePaths && route.allowUnknown !== true) { + if (representation && includesPath(route.runtimeRepresentationPaths?.[representation])) { + return route.state; + } + if ( + !route.staticPaths && + !route.runtimePaths && + !route.runtimeRepresentationPaths && + route.allowUnknown !== true + ) { return route.state; } if (includesPath(route.runtimePaths)) return route.state; @@ -385,3 +418,83 @@ export function findCacheabilityManifestRoute( ): CacheabilityManifestRoute | null { return manifest.routes[cacheabilityManifestRouteKey(kind, pattern)] ?? null; } + +export function resolveCacheabilityRepresentation( + representation: CacheabilityRepresentation, + routeKind: "app-page" | "app-route" | "pages-api" | "pages-page", +): CacheabilityRepresentation { + // Accept describes the representation a caller would prefer; it does not + // determine whether the resolved pathname belongs to an App Page or a Route + // Handler. Browser fetch() uses Accept: */* by default, while Route Handlers + // may legitimately be requested with Accept: text/html. Once routing has + // resolved the owner, make that result authoritative for non-RSC requests. + if (representation !== "html" && representation !== "app-route") { + return representation; + } + return routeKind === "app-route" || routeKind === "pages-api" ? "app-route" : "html"; +} + +/** Whether completed-response admission can store a page route under this representation. */ +export function cacheabilityRepresentationMatchesPageRoute( + routeKind: "app-page" | "pages-page", + representation: CacheabilityRepresentation, +): boolean { + return routeKind === "app-page" + ? representation === "html" || + representation === "rsc-full" || + representation === "rsc-loading-shell" + : representation === "html" || representation === "pages-data"; +} + +/** + * The manifest state completed-response admission gives a page request, or + * null when admission refuses it. `requestRepresentation` is the request + * identity's, and `routePathname` is built from the resolved pathname with + * `cacheabilityRoutePathname`, as admission builds it. The Workers Cache + * request stage decides its query-free dispatch with this same function, so + * the dispatch and admission always see the same state. + */ +export function cacheabilityManifestPageState( + manifest: CacheabilityManifest, + route: { kind: "app-page" | "pages-page"; pattern: string }, + requestRepresentation: CacheabilityRepresentation, + routePathname: string, +): CacheabilityManifestRouteState | null { + const representation = resolveCacheabilityRepresentation(requestRepresentation, route.kind); + if (!cacheabilityRepresentationMatchesPageRoute(route.kind, representation)) return null; + const manifestRoute = findCacheabilityManifestRoute(manifest, route.kind, route.pattern); + return manifestRoute + ? cacheabilityManifestRouteState(manifestRoute, routePathname, representation) + : null; +} + +/** Whether any path or representation of a route can resolve to `static-candidate`. */ +function canResolveStaticCandidate(route: CacheabilityManifestRoute): boolean { + return ( + route.state === "static-candidate" || + route.unknownState === "static-candidate" || + route.staticRepresentation !== undefined || + route.staticPaths !== undefined + ); +} + +/** + * Project the manifest onto the Workers Cache request stage, which strips the + * query from App page dispatches whose manifest state is `static-candidate`. + * The projection keeps every App page route record that can resolve to that + * state, unchanged, so a lookup against it returns `static-candidate` exactly + * when the full manifest does. Every other route is left out. + */ +export function projectCacheabilityManifestForRequestStage( + manifest: CacheabilityManifest, +): CacheabilityManifest { + return { + buildId: manifest.buildId, + routes: Object.fromEntries( + Object.entries(manifest.routes).filter( + ([, route]) => route.kind === "app-page" && canResolveStaticCandidate(route), + ), + ), + version: 1, + }; +} diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index f4c3f8ced9..7503f79d10 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -1,6 +1,7 @@ import type { ExecutionContextLike } from "vinext/shims/request-context"; import { CACHEABILITY_REQUEST_STATE, + recordConfigCdnCachePolicyHeader, type RouteCacheabilityOutcome, type RouteCacheabilityState, } from "vinext/shims/cacheability-classification"; @@ -8,9 +9,11 @@ import { applyCdnResponseBuildIdentityHeaders, applyCdnResponseHeaders, hasExplicitNonCacheableResponsePolicy, + isCdnResponsePolicyHeader, isNonCacheableCacheControl, NO_STORE_CACHE_CONTROL, readCdnResponseCacheControl, + readCdnResponsePolicyHeaderName, } from "./cache-control.js"; import { VINEXT_CACHEABILITY_PROBE_HEADER, @@ -25,11 +28,14 @@ import { CACHEABILITY_PROBE_TIMEOUT_MS, } from "./cacheability-limits.js"; import { + cacheabilityManifestPageState, cacheabilityManifestRouteState, + cacheabilityRepresentationMatchesPageRoute, cacheabilityRequestIdentity, cacheabilityRoutePathname, findCacheabilityManifestRoute, parseCacheabilityManifest, + resolveCacheabilityRepresentation, type CacheabilityManifest, type CacheabilityManifestRoute, type CacheabilityRouteKind, @@ -45,6 +51,10 @@ type CacheabilityProbeRouteState = type CacheabilityProbeResult = { cacheControl?: string; + /** The render used a dynamic API, a private cache or a route-wide dynamic config. */ + dynamicUsage?: true; + /** A matching next.config header policy applied to the response. */ + explicitConfigCachePolicy?: true; kind?: "app-page" | "app-route" | "pages-api" | "pages-page"; pattern?: string; reason?: string; @@ -245,21 +255,6 @@ function readState(ctx: ExecutionContextLike): RouteCacheabilityState | null { ); } -function resolveCacheabilityRepresentation( - representation: CacheabilityRepresentation, - routeKind: "app-page" | "app-route" | "pages-api" | "pages-page", -): CacheabilityRepresentation { - // Accept describes the representation a caller would prefer; it does not - // determine whether the resolved pathname belongs to an App Page or a Route - // Handler. Browser fetch() uses Accept: */* by default, while Route Handlers - // may legitimately be requested with Accept: text/html. Once routing has - // resolved the owner, make that result authoritative for non-RSC requests. - if (representation !== "html" && representation !== "app-route") { - return representation; - } - return routeKind === "app-route" || routeKind === "pages-api" ? "app-route" : "html"; -} - /** Apply request-stage-vetted positive config policy inside the admission boundary. */ export function applyResponseStageCachePolicy( response: Response, @@ -267,8 +262,7 @@ export function applyResponseStageCachePolicy( policyHeaders: ReadonlyArray | null | undefined, ): Response { if (!policyHeaders?.length) return response; - const state = readState(ctx); - if (state) state.explicitConfigCachePolicy = true; + recordConfigCachePolicy(readState(ctx), policyHeaders); try { applyResponseStagePolicyHeaders(response.headers, policyHeaders); @@ -290,8 +284,19 @@ export function recordResponseStageCachePolicy( policyHeaders: ReadonlyArray | null | undefined, ): void { if (!policyHeaders?.length) return; - const state = readState(ctx); - if (state) state.explicitConfigCachePolicy = true; + recordConfigCachePolicy(readState(ctx), policyHeaders); +} + +function recordConfigCachePolicy( + state: RouteCacheabilityState | null, + policyHeaders: ReadonlyArray, +): void { + if (!state) return; + state.explicitConfigCachePolicy = true; + // A Vary-only policy leaves the renderer's cache policy in place. + for (const [name, value] of policyHeaders) { + if (isCdnResponsePolicyHeader(name)) recordConfigCdnCachePolicyHeader(state, name, value); + } } function probeResponse( @@ -299,14 +304,22 @@ function probeResponse( routeState: CacheabilityProbeRouteState, outcome: RouteCacheabilityOutcome, status: number, - rendererStatic?: boolean, + renderer?: { dynamicUsage: boolean; static: boolean }, ): Response { + // Next.js's build treats a private cache and a route-wide dynamic config as + // dynamic usage too. + const dynamicUsage = + renderer?.dynamicUsage === true || + outcome.dynamicUsage === true || + state.patternDynamicReason !== undefined; const body: CacheabilityProbeResult = { cacheControl: outcome.cacheControl, + ...(dynamicUsage ? { dynamicUsage: true as const } : {}), + ...(state.explicitConfigCachePolicy ? { explicitConfigCachePolicy: true as const } : {}), kind: state.route?.kind, pattern: state.route?.pattern, reason: outcome.reason, - ...(rendererStatic !== undefined ? { rendererStatic } : {}), + ...(renderer ? { rendererStatic: renderer.static } : {}), ...(outcome.retryable ? { retryable: true as const } : {}), ...(state.resolvedRoutePathname ? { routePathname: state.resolvedRoutePathname } : {}), ...(routeState === "dynamic" @@ -817,17 +830,10 @@ async function finalizeWorkerCacheabilityAdmission( ) { return responseWithCachePolicy(response, response.body, null); } - const representation = resolveCacheabilityRepresentation( - admission.representation as CacheabilityRepresentation, - state.route.kind, - ); - const representationMatchesRoute = - state.route.kind === "app-page" - ? representation === "html" || - representation === "rsc-full" || - representation === "rsc-loading-shell" - : representation === "html" || representation === "pages-data"; - if (!representationMatchesRoute) { + const pageRoute = { kind: state.route.kind, pattern: state.route.pattern }; + const requestRepresentation = admission.representation as CacheabilityRepresentation; + const representation = resolveCacheabilityRepresentation(requestRepresentation, pageRoute.kind); + if (!cacheabilityRepresentationMatchesPageRoute(pageRoute.kind, representation)) { return responseWithCachePolicy(response, response.body, null); } @@ -835,11 +841,15 @@ async function finalizeWorkerCacheabilityAdmission( let manifestRouteState: ReturnType = null; if (admission.policy === "manifest") { const manifest = admission.manifest as CacheabilityManifest; - manifestRoute = findCacheabilityManifestRoute(manifest, state.route.kind, state.route.pattern); - manifestRouteState = - manifestRoute && admission.routePathname - ? cacheabilityManifestRouteState(manifestRoute, admission.routePathname, representation) - : null; + manifestRoute = findCacheabilityManifestRoute(manifest, pageRoute.kind, pageRoute.pattern); + manifestRouteState = admission.routePathname + ? cacheabilityManifestPageState( + manifest, + pageRoute, + requestRepresentation, + admission.routePathname, + ) + : null; if (!manifestRoute || !manifestRouteState) { return responseWithCachePolicy(response, response.body, null); } @@ -889,6 +899,28 @@ async function finalizeWorkerCacheabilityAdmission( } return responseWithCachePolicy(response, captured.body, null); } + // Every query can share a rendered App page's response, so the renderer's + // policy is admitted only with proof the render left searchParams unread. A + // later next.config policy replaces the renderer's and is cached per URL, as + // in Next.js, even when it matches the renderer's value. Config replaces it + // only through the header that wins the adapter's precedence; a Vary-only + // rule or a lower-priority header leaves the renderer's policy in place. + // A changed effective value alone is not provenance: an adapter that cannot + // name its winning header may map a renderer-owned header to a new value, + // so an unattributed policy keeps the proof requirement. + const effectivePolicyHeader = readCdnResponsePolicyHeaderName(response.headers); + const replacesRendererPolicy = + outcome !== rendererOutcome && + effectivePolicyHeader !== null && + state.configCdnCachePolicy?.get(effectivePolicyHeader) === + response.headers.get(effectivePolicyHeader); + if ( + state.route.kind === "app-page" && + !replacesRendererPolicy && + rendererOutcome?.searchParamsUnread !== true + ) { + return responseWithCachePolicy(response, captured.body, null); + } return responseWithCachePolicy( response, representation === "html" && state.clientTraceMetadataMarker @@ -993,6 +1025,9 @@ export async function finalizeWorkerCacheabilityResponse( : "dynamic", outcome, response.status, - rendererOutcome?.cacheable === true && rendererOutcome.dynamicUsage !== true, + { + dynamicUsage: rendererOutcome?.dynamicUsage === true, + static: rendererOutcome?.cacheable === true && rendererOutcome.dynamicUsage !== true, + }, ); } diff --git a/packages/vinext/src/server/config-headers.ts b/packages/vinext/src/server/config-headers.ts index 8c0b193cfc..cfb9f8d95a 100644 --- a/packages/vinext/src/server/config-headers.ts +++ b/packages/vinext/src/server/config-headers.ts @@ -6,8 +6,8 @@ import { } from "../config/config-matchers.js"; import type { HeaderRecord } from "./request-pipeline.js"; import { + markRouteCacheabilityConfigCdnCachePolicy, markRouteCacheabilityDynamic, - markRouteCacheabilityExplicitConfigPolicy, markRouteCacheabilityFinalResponseUncacheable, } from "vinext/shims/cacheability-classification"; import { isCdnResponsePolicyHeader, isNonCacheableCdnResponsePolicy } from "./cache-control.js"; @@ -76,7 +76,7 @@ function markExplicitConfigResponseVeto( continue; } if (isCdnResponsePolicyHeader(name)) { - markRouteCacheabilityExplicitConfigPolicy(); + markRouteCacheabilityConfigCdnCachePolicy(name, header.value); } if (isCdnResponsePolicyHeader(name) && isNonCacheableCdnResponsePolicy(name, header.value)) { markRouteCacheabilityFinalResponseUncacheable( diff --git a/packages/vinext/src/server/isr-cache.ts b/packages/vinext/src/server/isr-cache.ts index 9bbc2f3c04..942637fbec 100644 --- a/packages/vinext/src/server/isr-cache.ts +++ b/packages/vinext/src/server/isr-cache.ts @@ -115,6 +115,19 @@ export function isrCacheControl( }; } +/** + * The route-level `expireTime` only bounds a finite revalidate. Like Next.js, + * a `revalidate = false` entry keeps no expire of its own and stays until it + * is invalidated; only a cacheLife expire can bound it. + * https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/build/index.ts#L3035-L3058 + */ +export function resolveRouteExpireSeconds( + revalidateSeconds: number | false | null, + expireSeconds: number | undefined, +): number | undefined { + return revalidateSeconds === false || revalidateSeconds === Infinity ? undefined : expireSeconds; +} + /** * Write policy for one ISR entry: the cache metadata the producing render * resolved, plus the tags that can invalidate it. Routers differ only in which diff --git a/packages/vinext/src/server/multi-stage.ts b/packages/vinext/src/server/multi-stage.ts index 7d7f54b21b..320879902f 100644 --- a/packages/vinext/src/server/multi-stage.ts +++ b/packages/vinext/src/server/multi-stage.ts @@ -23,17 +23,39 @@ export type VinextResponseStageDispatchOptions = { /** * Whether the adapter may use its shared response transport. Bypassed work * still uses the same response stage, but must not pass through a host cache. - * A shared transport must partition its baseline lookup by the request - * method, complete request URL (including scheme, authority, exact path, and - * query), plus the complete serialized stage props; each can affect handler - * selection or response bytes. Framework-managed selectors are already - * represented by that URL and the + * Without a `cacheIdentity`, a shared transport must partition its baseline + * lookup by the request method, complete request URL (including scheme, + * authority, exact path, and query), plus the complete serialized stage + * props; each can affect handler selection or response bytes. + * Framework-managed selectors are already represented by that URL and the * serialized props. A verbatim-capable transport must partition stored * variants by every named `Vary` request header and never store `Vary: *`. * Other transports must reject application-defined variance themselves or * opt into completed-response admission and honor core's `no-store` policy. */ cache: "shared" | "bypass"; + /** + * Query-free identity of a shared App page GET/HEAD dispatch, supplied only + * to adapters that declare `responseStageCacheIdentity: "query-free"` and + * require completed-response admission. A transport given one partitions + * its baseline lookup and stored entry by this request's method and complete + * URL plus these complete serialized props, instead of the dispatched request + * and props, and replays this identity for background regeneration. + * Everything else in the contract above still applies. + * + * The identity drops the user query from the URL and `resolvedUrl`, keeping + * framework representation selectors: the `.rsc` suffix, the render mode, + * and, for RSC requests only, the one validated `_rsc` value. That is safe only because completed-response + * admission refuses a cacheable App page response unless its render proved + * it never read `searchParams`. The dispatched request still carries the real + * query for the render itself. Core omits the identity when a `next.config` + * public cache policy applies (cached per full URL, as Next.js CDN caching + * is), and for bypassed, interception, and mounted-slot dispatches. + */ + cacheIdentity?: { + props: unknown; + request: Request; + }; }; export type VinextCacheabilityProbeMode = "probe" | "identity"; @@ -65,7 +87,8 @@ export type VinextResponseStageCacheability = { * them over in-process dispatch, platform RPC, a service binding, or HTTP. If * it caches shared dispatches, its baseline identity must include the request * method, complete request URL (including scheme, authority, exact path, and - * query), plus the complete serialized props. An adapter that advertises + * query), plus the complete serialized props, or the equivalent fields of + * `options.cacheIdentity` when core supplies one. An adapter that advertises * `responseVary: "verbatim"` must also partition stored variants by every * request header named in the returned `Vary` fields and reject `Vary: *` from * storage. Adapters without that capability must reject application-defined diff --git a/packages/vinext/src/server/response-stage-cacheability.ts b/packages/vinext/src/server/response-stage-cacheability.ts index deef23b0e2..065ca545e8 100644 --- a/packages/vinext/src/server/response-stage-cacheability.ts +++ b/packages/vinext/src/server/response-stage-cacheability.ts @@ -7,6 +7,8 @@ import { getCdnCacheAdapter } from "vinext/shims/cdn-cache"; import type { VinextResponseStageDispatchOptions } from "./multi-stage.js"; import type { WorkerCacheabilityProbeMode } from "./cacheability-request.js"; import type { CacheabilityRepresentation } from "./cacheability-manifest.js"; +import { preserveFullyBufferedBodyMetadata } from "./fully-buffered-response.js"; +import { VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER } from "./headers.js"; export type ResponseStageCacheabilityOptions = { buildId: string | null | undefined; @@ -22,6 +24,12 @@ export type ResponseStageCacheabilityOptions = { resolvedRoutePathname?: string; /** Trusted representation retained when request-stage normalization changes the URL shape. */ representation?: CacheabilityRepresentation; + /** + * The request stage recomposes `X-Vinext-Params` and + * `X-Vinext-Rendered-Path-And-Search` per request (App page RSC), so a + * shared response drops them before admission and storage. + */ + recomposesRequestScopedHeaders?: boolean; /** Generated adapter registration, deferred until the response stage executes. */ registerCacheAdapters(): void; request: Request; @@ -68,7 +76,14 @@ export async function withResponseStageCacheability( ); } - if (!cacheability) return render(context); + const stripsRequestScopedHeaders = + options.recomposesRequestScopedHeaders === true && + options.cache === "shared" && + !options.probeMode; + if (!cacheability) { + const rendered = await render(context); + return stripsRequestScopedHeaders ? withoutRequestScopedHeaders(rendered) : rendered; + } if (options.policyHeadersAppliedBeforeRender) { cacheability.recordResponseStageCachePolicy(context, options.policyHeaders); } @@ -78,7 +93,10 @@ export async function withResponseStageCacheability( if (options.probeMode && options.forceDynamic && !options.policyHeaders?.length && state) { state.patternDynamicReason = 'dynamic = "force-dynamic"'; } - const rendered = await render(context); + const renderedResponse = await render(context); + const rendered = stripsRequestScopedHeaders + ? withoutRequestScopedHeaders(renderedResponse) + : renderedResponse; const response = options.policyHeadersAppliedBeforeRender ? rendered : cacheability.applyResponseStageCachePolicy(rendered, context, options.policyHeaders); @@ -95,3 +113,23 @@ export async function withResponseStageCacheability( } return complete(response); } + +function withoutRequestScopedHeaders(response: Response): Response { + if ( + !response.headers.has(VINEXT_PARAMS_HEADER) && + !response.headers.has(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER) + ) { + return response; + } + const headers = new Headers(response.headers); + headers.delete(VINEXT_PARAMS_HEADER); + headers.delete(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER); + return preserveFullyBufferedBodyMetadata( + response, + new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }), + ); +} diff --git a/packages/vinext/src/shims/cacheability-classification.ts b/packages/vinext/src/shims/cacheability-classification.ts index c92d9b86c6..a664b3539b 100644 --- a/packages/vinext/src/shims/cacheability-classification.ts +++ b/packages/vinext/src/shims/cacheability-classification.ts @@ -8,6 +8,8 @@ export type RouteCacheabilityOutcome = { classificationFailure?: boolean; dynamicUsage?: boolean; reason?: string; + /** App pages: the render proved it left searchParams unread, so its response holds for every query. */ + searchParamsUnread?: true; /** A transient classification failure that may succeed on another bounded attempt. */ retryable?: true; tags?: readonly string[]; @@ -33,6 +35,8 @@ export type RouteCacheabilityState = { completedResponseBody?: boolean; /** Whether admission must translate a completed response through the active adapter. */ applyCompletedResponsePolicy?: boolean; + /** Core- or adapter-owned cache policy headers set by next.config, by lowercased name. */ + configCdnCachePolicy?: Map; explicitConfigCachePolicy?: boolean; explicitResponseCachePolicy?: boolean; finalResponseVetoReason?: string; @@ -150,6 +154,25 @@ export function markRouteCacheabilityExplicitConfigPolicy(): void { state.explicitConfigCachePolicy = true; } +/** Record a cache policy header, not just Vary, that a next.config rule set. */ +export function markRouteCacheabilityConfigCdnCachePolicy(name: string, value: string): void { + const state = readRouteCacheabilityState(); + if (!state) return; + recordConfigCdnCachePolicyHeader(state, name, value); +} + +/** Record a next.config cache policy header with the value a response carries. */ +export function recordConfigCdnCachePolicyHeader( + state: RouteCacheabilityState, + name: string, + value: string, +): void { + state.explicitConfigCachePolicy = true; + // Headers.set trims HTTP whitespace, so compare against what it applies. + const applied = new Headers([[name, value]]).get(name) ?? value; + (state.configCdnCachePolicy ??= new Map()).set(name.toLowerCase(), applied); +} + /** Record a public cache policy supplied by the Route Handler itself. */ export function markRouteCacheabilityExplicitResponsePolicy(): void { const state = readRouteCacheabilityState(); diff --git a/packages/vinext/src/shims/cdn-cache.ts b/packages/vinext/src/shims/cdn-cache.ts index c083479d77..f8cf08c297 100644 --- a/packages/vinext/src/shims/cdn-cache.ts +++ b/packages/vinext/src/shims/cdn-cache.ts @@ -68,6 +68,12 @@ export type CdnResponsePolicy = { * rules without knowing which provider header carried it. */ readCacheControl(headers: Headers): string | null; + /** + * Name the response header `readCacheControl` interpreted, or `null` when + * none did. Core uses it to tell which stage owns the effective policy; + * without it, core cannot attribute that policy to next.config. + */ + readCacheControlHeaderName?(headers: Headers): string | null; /** Whether provider policy explicitly opts out of storage. */ hasExplicitNonCacheablePolicy(headers: Headers, baseline?: Headers): boolean; }; @@ -128,6 +134,14 @@ export type CdnCacheAdapter = { */ readonly requiresCompletedResponseAdmission?: boolean; + /** + * This adapter's response-stage transport keys shared App page dispatches by + * `VinextResponseStageDispatchOptions.cacheIdentity` when core supplies one. + * Core supplies it only when completed-response admission is also required, + * because that admission is what makes a query-free identity safe. + */ + readonly responseStageCacheIdentity?: "query-free"; + /** * Optionally return a foreground page response while completed-response * admission continues on an independent body branch. Returning `null` keeps diff --git a/packages/vinext/src/shims/client-page-root.tsx b/packages/vinext/src/shims/client-page-root.tsx new file mode 100644 index 0000000000..3c43493c02 --- /dev/null +++ b/packages/vinext/src/shims/client-page-root.tsx @@ -0,0 +1,171 @@ +"use client"; + +/** + * Wrapper for a "use client" App Router page, like Next.js's `ClientPageRoot` + * (client/components/client-page.tsx). + * + * The server never sends a client page's `searchParams` through Flight. Flight + * calls `then` on every promise prop while serializing it, so the page would + * always count as reading the query, and the RSC payload would carry it. This + * wrapper builds the prop where the page renders instead: + * + * - SSR: a promise per page from the navigation context (`app-ssr-entry.ts`), + * keyed by the page's props object like the browser's, so React's + * bookkeeping from one page's `use()` never shows on a sibling's. Reading + * it marks the render dynamic, so a render that uses the query is never + * stored, and a page that never reads it stays cacheable. + * - Browser: the query the server rendered this page with, captured when the + * page's server output first renders. Next.js reads it from the page's own + * segment payload, so a rewritten query survives, and a page that stays + * mounted (an intercepted modal's background, a kept parallel slot) keeps + * its query when the URL changes. The router tags each payload's elements + * with the query they were rendered with (`RenderedSearchContext`), so a + * kept page that first renders under a later navigation (still streaming, + * or refreshed from its own URL) reads its own response's query. + * - Browser, with Cache Components: the query `useSearchParams()` returns, as + * Next.js reads it from `SearchParamsContext` in that mode. That is the + * public URL's query rather than a rewritten one, and a kept page follows + * the URL. + * + * `emptySearchParams` pages (`dynamic = "force-static"`, static export) always + * get an empty, untracked query, as the server renders them. + * + * This module runs in the browser, so it must not import server-only modules. + */ +import { createElement, use, useMemo, type ComponentType } from "react"; +import { searchParamsToRecord } from "../utils/query.js"; +import { + isOwnPropertyCheck, + isWellKnownProperty, +} from "./internal/thenable-well-known-properties.js"; +import { getNavigationContext } from "./navigation-server.js"; +import { getClientNavigationRenderContext, useSearchParams } from "./navigation.js"; +import { RenderedSearchContext } from "./slot.js"; + +type ClientPageSearchParams = Record; + +export type ClientPageRootProps = { + Component: ComponentType>; + /** The page's other props (`params`, slot props). Never `searchParams`. */ + pageProps: Readonly>; + /** The page always reads an empty query: `force-static`, or static export. */ + emptySearchParams?: boolean; +}; + +const isServer = typeof window === "undefined"; + +function isCacheComponentsEnabled(): boolean { + return String(process.env.__NEXT_CACHE_COMPONENTS) === "true"; +} + +/** + * Build an untracked `searchParams` promise, like Next.js 15's browser + * `makeUntrackedExoticSearchParams`: a settled promise whose query keys are + * also readable synchronously, except names Promise and React rely on. + * + * It matches the SSR thenable (`makeThenableParams`) wherever a page could + * tell them apart during hydration, so it is a proxy in the same way: the + * query keys are virtual, and the promise's methods run on the promise + * itself. As real own properties, a `constructor` key would replace the + * promise's species, and `await` would throw. + */ +export function createClientPageSearchParams( + searchParams: URLSearchParams | null | undefined, +): Promise { + // Spreading keeps a `__proto__` key an own entry, on Object.prototype. + const record: ClientPageSearchParams = { ...searchParamsToRecord(searchParams) }; + // No `status` or `value` until React tracks it, like Next.js's browser and + // SSR promises and the SSR thenable, so a page reading them directly + // renders the same in SSR and hydration. + const promise = Promise.resolve(record); + const isQueryKey = (prop: PropertyKey): prop is string => + typeof prop === "string" && !isWellKnownProperty(prop) && Object.hasOwn(record, prop); + return new Proxy(promise, { + get(target, prop, receiver) { + if (isQueryKey(prop)) return record[prop]; + const value: unknown = Reflect.get(target, prop); + if (typeof value !== "function") return value; + return value.bind(isOwnPropertyCheck(prop) ? receiver : target); + }, + getOwnPropertyDescriptor(target, prop) { + return isQueryKey(prop) + ? { configurable: true, enumerable: true, value: record[prop], writable: true } + : Reflect.getOwnPropertyDescriptor(target, prop); + }, + has(target, prop) { + return isQueryKey(prop) || Reflect.has(target, prop); + }, + ownKeys() { + return Object.keys(record).filter((key) => !isWellKnownProperty(key)); + }, + }); +} + +// Keyed by the page's server-sent props object: Flight builds a new one for +// every server render, and the router keeps the same object for as long as it +// keeps the segment. So each server render of the page gets one promise, with +// the query of the navigation that delivered it. +const browserSearchParams = new WeakMap>(); + +function useBrowserSearchParams( + pageProps: Readonly>, + emptySearchParams: boolean, +): Promise { + const cached = browserSearchParams.get(pageProps); + if (cached) return cached; + + // The first render of this server output is the render of the navigation + // that delivered it, and the router provides that navigation's snapshot. + // Only a miss reads it, so a kept page doesn't re-render on later + // navigations. + let search: string | null = null; + if (!emptySearchParams) { + const context = getClientNavigationRenderContext(); + const snapshot = context ? use(context) : null; + search = + use(RenderedSearchContext) ?? + (snapshot ? (snapshot.renderedSearch ?? snapshot.search) : window.location.search); + } + const searchParams = createClientPageSearchParams( + search === null ? null : new URLSearchParams(search), + ); + browserSearchParams.set(pageProps, searchParams); + return searchParams; +} + +/** + * Cache Components hands a client page the query `useSearchParams()` returns, + * not the one its payload rendered, so a rewritten query stays internal. + */ +function useCanonicalSearchParams(emptySearchParams: boolean): Promise { + const urlSearchParams = useSearchParams(); + return useMemo( + () => createClientPageSearchParams(emptySearchParams ? null : urlSearchParams), + [emptySearchParams, urlSearchParams], + ); +} + +/* oxlint-disable eslint-plugin-react-hooks/rules-of-hooks -- isServer and Cache Components are fixed per build. */ +export function ClientPageRoot({ Component, pageProps, emptySearchParams }: ClientPageRootProps) { + let searchParams: Promise; + if (!isServer) { + searchParams = isCacheComponentsEnabled() + ? useCanonicalSearchParams(emptySearchParams === true) + : useBrowserSearchParams(pageProps, emptySearchParams === true); + } else if (emptySearchParams === true) { + // Nothing to read, so nothing to track. + searchParams = createClientPageSearchParams(null); + } else { + // Every App Router SSR render sets this. Without it there is no query this + // render may safely read, so the page gets an empty one. + searchParams = + getNavigationContext()?.getClientPageSearchParams?.(pageProps) ?? + createClientPageSearchParams(null); + } + // The same inputs give the same element, so React skips the page. + return useMemo( + () => createElement(Component, { ...pageProps, searchParams }), + [Component, pageProps, searchParams], + ); +} +/* oxlint-enable eslint-plugin-react-hooks/rules-of-hooks */ diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts index 8bbe417131..0e46898b5e 100644 --- a/packages/vinext/src/shims/headers.ts +++ b/packages/vinext/src/shims/headers.ts @@ -19,6 +19,7 @@ import { getOrCreateAls } from "./internal/als-registry.js"; import { serializeSetCookie, validateCookieName } from "./internal/cookie-serialize.js"; import { parseEdgeRequestCookieHeader } from "../utils/parse-cookie.js"; import { + ensureRenderDynamicLatch, isInsideUnifiedScope, getRequestContext, runWithUnifiedStateMutation, @@ -50,9 +51,25 @@ type HeadersContextFromRequestOptions = { export type HeadersAccessPhase = "render" | "action" | "route-handler"; +/** + * Whether the current render has used a dynamic API. Unlike + * `dynamicUsageDetected`, nothing clears it, and one object is shared by every + * child scope of the request, so usage inside isolated scopes (such as the + * layout probe) stays visible to later readers. + */ +export type RenderDynamicLatch = { + dynamic: boolean; + listeners: Set<() => void>; +}; + +export function createRenderDynamicLatch(): RenderDynamicLatch { + return { dynamic: false, listeners: new Set() }; +} + export type VinextHeadersShimState = { headersContext: HeadersContext | null; dynamicUsageDetected: boolean; + renderDynamicLatch: RenderDynamicLatch; renderRequestApiUsage: Set; connectionProbe: ConnectionProbeState | null; /** Error recorded by throwIfInsideCacheScope for dev diagnostics, persists even if caught by user code. */ @@ -93,6 +110,7 @@ const _als = getOrCreateAls("vinext.nextHeadersShim.als" const _fallbackState = (_g[_FALLBACK_KEY] ??= { headersContext: null, dynamicUsageDetected: false, + renderDynamicLatch: createRenderDynamicLatch(), renderRequestApiUsage: new Set(), connectionProbe: null, invalidDynamicUsageError: null, @@ -204,9 +222,52 @@ export function markDynamicUsage(): void { return; } state.dynamicUsageDetected = true; + // A probe scope cloned before an HMR update may not share its parent's + // latch, so latch each propagation target too. Set every flag before any + // listener runs. + const latches = [ensureRenderDynamicLatch(state)]; forEachConnectionProbeTarget(state, (target) => { target.dynamicUsageDetected = true; + latches.push(ensureRenderDynamicLatch(target)); }); + for (const latch of latches) { + latchRenderDynamic(latch); + } +} + +function latchRenderDynamic(latch: RenderDynamicLatch): void { + if (latch.dynamic) return; + latch.dynamic = true; + const listeners = [...latch.listeners]; + latch.listeners.clear(); + for (const listener of listeners) { + try { + listener(); + } catch (error) { + // Listeners are never notified again, so one failing must not skip the + // rest, and its error isn't the dynamic API caller's to handle. + console.error(error); + } + } +} + +/** Whether the current render has used a dynamic API at any point so far. */ +export function isRenderDynamicLatched(): boolean { + return ensureRenderDynamicLatch(_getState()).dynamic; +} + +/** + * Call `listener` once when the current render first uses a dynamic API. + * Returns an unsubscribe function. The listener never runs if the render is + * already latched; check `isRenderDynamicLatched()` first. + */ +export function onRenderDynamicLatched(listener: () => void): () => void { + const latch = ensureRenderDynamicLatch(_getState()); + if (latch.dynamic) return () => {}; + latch.listeners.add(listener); + return () => { + latch.listeners.delete(listener); + }; } function forEachConnectionProbeTarget( @@ -260,8 +321,11 @@ export async function runWithIsolatedDynamicUsage( ); } + const parentState = _getState(); const childState: VinextHeadersShimState = { - ..._getState(), + ...parentState, + // Share the parent's latch, creating it first on a stale fallback state. + renderDynamicLatch: ensureRenderDynamicLatch(parentState), dynamicUsageDetected: false, }; return await _als.run(childState, () => runInChildState(childState)); @@ -353,6 +417,8 @@ export async function runWithConnectionProbe( const childState: VinextHeadersShimState = { ...parentState, + // Share the parent's latch, creating it first on a stale fallback state. + renderDynamicLatch: ensureRenderDynamicLatch(parentState), connectionProbe: probe, }; return await _als.run(childState, () => runInChildState(childState)); @@ -612,6 +678,7 @@ export function setHeadersContext(ctx: HeadersContext | null): void { if (ctx !== null) { state.headersContext = ctx; state.dynamicUsageDetected = false; + state.renderDynamicLatch = createRenderDynamicLatch(); state.renderRequestApiUsage = new Set(); state.pendingSetCookies = []; state.draftModeCookieHeader = null; @@ -645,6 +712,7 @@ export function runWithHeadersContext( return runWithUnifiedStateMutation((uCtx) => { uCtx.headersContext = ctx; uCtx.dynamicUsageDetected = false; + uCtx.renderDynamicLatch = createRenderDynamicLatch(); uCtx.renderRequestApiUsage = new Set(); uCtx.connectionProbe = null; uCtx.pendingSetCookies = []; @@ -656,6 +724,7 @@ export function runWithHeadersContext( const state: VinextHeadersShimState = { headersContext: ctx, dynamicUsageDetected: false, + renderDynamicLatch: createRenderDynamicLatch(), renderRequestApiUsage: new Set(), connectionProbe: null, invalidDynamicUsageError: null, diff --git a/packages/vinext/src/shims/internal/thenable-well-known-properties.ts b/packages/vinext/src/shims/internal/thenable-well-known-properties.ts new file mode 100644 index 0000000000..9f25d33ddf --- /dev/null +++ b/packages/vinext/src/shims/internal/thenable-well-known-properties.ts @@ -0,0 +1,66 @@ +// Properties that cannot be shadowed by param names because they need to +// remain the true underlying value for Promises / React to work correctly. +// Shared by the server's `makeThenableParams` and the browser's client page +// `searchParams`, so this module must stay free of server-only imports. +// +// Next.js comments out `value` and `error` in reflect-utils.ts because they +// use `Promise.resolve(underlyingParams)` directly in production, so React +// mutations on the promise object are never shadowed. vinext uses a Proxy +// that intercepts sync reads through a separate `plain` object, which means +// a param named `value` or `error` would shadow React's `.status`/`.value` +// attachments that React adds to resolved promises for `use()` caching. +// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/utils/reflect-utils.ts +const WELL_KNOWN_PROPERTIES = [ + // Object prototype + "hasOwnProperty", + "isPrototypeOf", + "propertyIsEnumerable", + "toString", + "valueOf", + "toLocaleString", + + // Promise prototype + "then", + "catch", + "finally", + + // React Promise extension (status is explicitly reserved by Next.js; + // value/error are reserved here because our Proxy-based approach creates + // a shadowing risk that native Promise does not have) + "status", + "value", + "error", + + // React introspection + "displayName", + "_debugInfo", + + // Common tested properties + "toJSON", + "$$typeof", + "__esModule", + + // Tested by flight when checking for iterables + "@@iterator", +] as const; + +// The type-level set of well-known properties is derived directly from the +// runtime array above, so they can never drift out of sync. These properties +// are omitted from the synchronous intersection because the Proxy returns +// Promise/React internals for them, not the param value. After awaiting, the +// resolved object contains the actual param values for all keys. +export type WellKnownProperty = (typeof WELL_KNOWN_PROPERTIES)[number]; + +const wellKnownProperties = new Set(WELL_KNOWN_PROPERTIES); + +export function isWellKnownProperty(prop: PropertyKey): boolean { + return wellKnownProperties.has(prop); +} + +/** + * Methods that ask about the thenable's own properties. They must run on the + * proxy, not the underlying promise, so they see the param keys. + */ +export function isOwnPropertyCheck(prop: PropertyKey): boolean { + return prop === "hasOwnProperty" || prop === "propertyIsEnumerable"; +} diff --git a/packages/vinext/src/shims/navigation-context-state.ts b/packages/vinext/src/shims/navigation-context-state.ts index 9796754519..f0402c6bf2 100644 --- a/packages/vinext/src/shims/navigation-context-state.ts +++ b/packages/vinext/src/shims/navigation-context-state.ts @@ -1,4 +1,5 @@ import * as React from "react"; +import type { SearchParamsGate } from "./search-params-gate.js"; const LAYOUT_SEGMENT_CONTEXT_KEY = Symbol.for("vinext.layoutSegmentContext"); const SERVER_INSERTED_HTML_CONTEXT_KEY = Symbol.for("vinext.serverInsertedHTMLContext"); @@ -23,6 +24,15 @@ export type NavigationContext = { isStaticGeneration?: boolean; /** `dynamic = "force-static"` suppresses the static useSearchParams bailout. */ isForceStatic?: boolean; + /** SSR-only: set for a cache-candidate render. See `search-params-gate.ts`. */ + searchParamsGate?: SearchParamsGate; + /** + * SSR-only: the `searchParams` a client page receives, one promise per page + * for the render, so `use()` keeps seeing the same promise and React's + * bookkeeping from one page's `use()` never reaches another. See + * `client-page-root.tsx`. + */ + getClientPageSearchParams?: (pageProps: object) => Promise>; }; type NavigationContextsGlobal = typeof globalThis & { diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index d1eb6dce40..6f05dcc271 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -1303,7 +1303,8 @@ function parseServerStaleTimeHeaders(headers: Headers): ServerStaleTime | undefi return seconds === undefined ? undefined : { kind: "resolved", seconds }; } -function parseRenderedPathAndSearchHeader(value: string | null): string | null { +/** Decode an `X-Vinext-Rendered-Path-And-Search` header value. */ +export function parseRenderedPathAndSearchHeader(value: string | null): string | null { if (value === null || value === "") return null; try { const decoded = decodeURIComponent(value); @@ -2035,6 +2036,12 @@ export type ClientNavigationRenderSnapshot = { search: string; searchParams: ReadonlyURLSearchParams; params: Record; + /** + * The query the server rendered this tree with, when it is known. A rewrite + * can change it (`/search/:q` -> `/search?q=:q`), so it can differ from + * `search`. Client page `searchParams` read it (see `client-page-root.tsx`). + */ + renderedSearch?: string; }; const _CLIENT_NAV_RENDER_CTX_KEY = Symbol.for("vinext.clientNavigationRenderContext"); @@ -2067,9 +2074,14 @@ export function useClientNavigationRenderSnapshot(): ClientNavigationRenderSnaps } /* oxlint-enable eslint-plugin-react-hooks/rules-of-hooks */ +/** + * @param renderedPathAndSearch The path and query the server rendered for this + * navigation (`X-Vinext-Rendered-Path-And-Search`), when known. + */ export function createClientNavigationRenderSnapshot( href: string, params: Record, + renderedPathAndSearch?: string | null, ): ClientNavigationRenderSnapshot { const origin = typeof window !== "undefined" ? window.location.origin : "http://localhost"; const url = new URL(href, origin); @@ -2079,6 +2091,9 @@ export function createClientNavigationRenderSnapshot( search: url.search, searchParams: new ReadonlyURLSearchParams(url.search), params, + ...(renderedPathAndSearch != null + ? { renderedSearch: new URL(renderedPathAndSearch, origin).search } + : {}), }; } @@ -2245,6 +2260,16 @@ export function useSearchParams(): ReadonlyURLSearchParams { // into the static HTML while the browser fills in the real URL values. throw new NavigationBailoutToCSRError("useSearchParams()"); } + const gate = ctx?.searchParamsGate; + if (gate) { + // A cache-candidate render waits until it is known whether the render + // will be stored: real values once it is dynamic, otherwise the same + // client-render boundary as a static render. + React.use(gate.decided); + if (gate.decision === "bailout") { + throw new NavigationBailoutToCSRError("useSearchParams()"); + } + } markPprFallbackShellDynamicBoundary(); // During SSR for "use client" components, the navigation context may not be set. // getServerSearchParamsSnapshot also covers the Pages Router compat shim. diff --git a/packages/vinext/src/shims/search-params-gate.ts b/packages/vinext/src/shims/search-params-gate.ts new file mode 100644 index 0000000000..009a3ef01c --- /dev/null +++ b/packages/vinext/src/shims/search-params-gate.ts @@ -0,0 +1,66 @@ +/** + * Per-request gate for SSR `useSearchParams()` in a cache-candidate render. + * + * A candidate render may be stored under a query-free key, so the query can + * only reach its output once the render is known to be uncacheable. Each call + * waits until the gate is decided, once per request: + * + * - `"real"`: the render used a dynamic API, so it won't be stored. Calls read + * the real query, as in a Next.js dynamic render. + * - `"bailout"`: the render settled without one. Calls throw + * `BailoutToCSRError`, so React client-renders the nearest Suspense boundary, + * as in Next.js's static HTML. + */ +export type SearchParamsGateDecision = "real" | "bailout"; + +// React reads these fields to use() a settled promise without suspending. +type ReactThenableFields = { status?: string; value?: unknown }; + +export type SearchParamsGate = { + decision: SearchParamsGateDecision | null; + /** + * Resolves once the gate is decided. It carries React's thenable status + * fields, so `use()` reads a decided gate without suspending. + */ + decided: Promise; +}; + +export type SearchParamsGateController = { + gate: SearchParamsGate; + /** Decide `"real"` unless the gate is already decided. */ + open(): void; + /** Decide `"bailout"` unless the gate is already decided. */ + settle(): void; +}; + +export function createSearchParamsGate(options: { + /** Runs when the gate opens, so real values always mark the render dynamic. */ + onOpen: () => void; +}): SearchParamsGateController { + let resolve!: () => void; + const decided = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + const thenable = decided as Promise & ReactThenableFields; + thenable.status = "pending"; + const gate: SearchParamsGate = { decision: null, decided }; + + const decide = (decision: SearchParamsGateDecision): boolean => { + if (gate.decision !== null) return false; + gate.decision = decision; + thenable.status = "fulfilled"; + thenable.value = undefined; + resolve(); + return true; + }; + + return { + gate, + open() { + if (decide("real")) options.onOpen(); + }, + settle() { + decide("bailout"); + }, + }; +} diff --git a/packages/vinext/src/shims/slot.tsx b/packages/vinext/src/shims/slot.tsx index c0bb6c35fa..e48ca0b699 100644 --- a/packages/vinext/src/shims/slot.tsx +++ b/packages/vinext/src/shims/slot.tsx @@ -24,6 +24,7 @@ import { getBfcacheSegmentIdContext, notFound, } from "./navigation-server.js"; +import type { ClientNavigationRenderSnapshot } from "./navigation.js"; const EMPTY_ELEMENTS: AppElements = Object.freeze({}); const warnedMissingEntryIds = new Set(); @@ -43,6 +44,55 @@ export const ChildrenContext = React.createContext(null); export const ParallelSlotsContext = React.createContext > | null>(null); + +/** + * The query the server rendered a Slot's element with. Client pages read it + * (see `client-page-root.tsx`), so a page that first renders under a later + * navigation (a kept branch a refresh fetched from its own URL, or one still + * streaming when an intercepted navigation keeps it) reads its own response's + * query. + */ +export const RenderedSearchContext = React.createContext(undefined); + +type RenderedSearchSource = + | string + | Pick; + +// Keyed by element value, which merges carry over by reference. +const renderedSearchByElement = new WeakMap(); + +/** Record the query the server rendered these elements with. */ +export function setAppElementsRenderedSearch(elements: AppElements, search: string): void { + for (const element of Object.values(elements)) { + if (typeof element === "object" && element !== null) { + renderedSearchByElement.set(element, search); + } + } +} + +/** + * Bind a payload's elements to the snapshot of the navigation that delivers + * them, unless they already carry a query (a merged supplemental refresh, or + * an element an earlier payload delivered). Read at render time, so a query + * that arrives after the head (initial hydration) is still seen. + */ +export function bindAppElementsRenderedSearch( + elements: AppElements, + snapshot: Pick, +): void { + for (const element of Object.values(elements)) { + if (typeof element === "object" && element !== null && !renderedSearchByElement.has(element)) { + renderedSearchByElement.set(element, snapshot); + } + } +} + +function getElementRenderedSearch(element: unknown): string | undefined { + if (typeof element !== "object" || element === null) return undefined; + const source = renderedSearchByElement.get(element); + return typeof source === "object" ? (source.renderedSearch ?? source.search) : source; +} + const BfcacheIdMapContext = getBfcacheIdMapContext(); const BfcacheSegmentIdContext = getBfcacheSegmentIdContext(); const EMPTY_BFCACHE_STATE_KEYS: Readonly> = Object.freeze({}); @@ -630,9 +680,11 @@ export function Slot({ } const content = ( - - {element} - + + + {element} + + ); return BfcacheIdMapContext && BfcacheSegmentIdContext ? ( diff --git a/packages/vinext/src/shims/thenable-params.ts b/packages/vinext/src/shims/thenable-params.ts index 520843513e..bcf5a9d19e 100644 --- a/packages/vinext/src/shims/thenable-params.ts +++ b/packages/vinext/src/shims/thenable-params.ts @@ -2,68 +2,16 @@ import { createPprFallbackShellSuspensePromiseForState, getPprFallbackShellState, } from "./ppr-fallback-shell.js"; +import { + isOwnPropertyCheck, + isWellKnownProperty, + type WellKnownProperty, +} from "./internal/thenable-well-known-properties.js"; function hasParamProperty>(obj: T, prop: PropertyKey): boolean { return Object.prototype.hasOwnProperty.call(obj, prop); } -// Properties that cannot be shadowed by param names because they need to -// remain the true underlying value for Promises / React to work correctly. -// -// Next.js comments out `value` and `error` in reflect-utils.ts because they -// use `Promise.resolve(underlyingParams)` directly in production, so React -// mutations on the promise object are never shadowed. vinext uses a Proxy -// that intercepts sync reads through a separate `plain` object, which means -// a param named `value` or `error` would shadow React's `.status`/`.value` -// attachments that React adds to resolved promises for `use()` caching. -// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/utils/reflect-utils.ts -const WELL_KNOWN_PROPERTIES = [ - // Object prototype - "hasOwnProperty", - "isPrototypeOf", - "propertyIsEnumerable", - "toString", - "valueOf", - "toLocaleString", - - // Promise prototype - "then", - "catch", - "finally", - - // React Promise extension (status is explicitly reserved by Next.js; - // value/error are reserved here because our Proxy-based approach creates - // a shadowing risk that native Promise does not have) - "status", - "value", - "error", - - // React introspection - "displayName", - "_debugInfo", - - // Common tested properties - "toJSON", - "$$typeof", - "__esModule", - - // Tested by flight when checking for iterables - "@@iterator", -] as const; - -// The type-level set of well-known properties is derived directly from the -// runtime array above, so they can never drift out of sync. These properties -// are omitted from the synchronous intersection because the Proxy returns -// Promise/React internals for them, not the param value. After awaiting, the -// resolved object contains the actual param values for all keys. -type WellKnownProperty = (typeof WELL_KNOWN_PROPERTIES)[number]; - -const wellKnownProperties = new Set(WELL_KNOWN_PROPERTIES); - -function isWellKnownProperty(prop: PropertyKey): boolean { - return wellKnownProperties.has(prop); -} - export type ThenableParams> = Promise & Omit; @@ -288,7 +236,11 @@ export function makeThenableParams>( } const value = Reflect.get(target, prop, receiver); - return typeof value === "function" ? value.bind(target) : value; + if (typeof value !== "function") return value; + // Own-property checks answer through the traps below, so they see (and + // observe) the param keys, as `Object.prototype.hasOwnProperty.call` + // does. + return value.bind(isOwnPropertyCheck(prop) ? receiver : target); }, getOwnPropertyDescriptor(target, prop) { if (typeof prop === "string" && !isWellKnownProperty(prop)) { diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index fea1fdfc7f..fa827ea49a 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -110,6 +110,9 @@ export function createRequestContext(opts?: Partial): Uni pendingRevalidatedTags: new Set(), pendingRevalidations: new Set>(), dynamicUsageDetected: false, + // Seed from a pre-populated dynamicUsageDetected, since `...opts` below only + // overrides the flag; an explicitly passed latch still wins. + renderDynamicLatch: { dynamic: opts?.dynamicUsageDetected === true, listeners: new Set() }, renderRequestApiUsage: new Set(), connectionProbe: null, invalidDynamicUsageError: null, @@ -330,6 +333,25 @@ export function runWithRequestContext( return _als.run(ctx, fn); } +/** + * Return the state's render dynamic latch. A state created before an HMR update + * (a unified context, or the headers shim's fallback state, which persists on + * globalThis) may predate the latch, so create it on first access, starting it + * latched if the state has already recorded dynamic usage so that usage isn't + * lost. Scopes cloned from a state must call this on the parent first so the + * child shares the latch. + * + * @internal + */ +export function ensureRenderDynamicLatch( + state: Pick, +): VinextHeadersShimState["renderDynamicLatch"] { + return (state.renderDynamicLatch ??= { + dynamic: state.dynamicUsageDetected, + listeners: new Set(), + }); +} + /** * Run `fn` in a nested unified scope derived from the current request context. * Used by legacy runWith* wrappers to reset or override one sub-state while @@ -354,6 +376,9 @@ export function runWithUnifiedStateMutation( const parentCtx = _als.getStore(); if (!parentCtx) return fn(); + // A context created before an HMR update may predate the latch. Create it on + // the parent so the child shares it instead of latching a copy of its own. + ensureRenderDynamicLatch(parentCtx); const childCtx = { ...parentCtx }; // NOTE: This is a shallow clone. Object/array fields (afterContext, pendingSetCookies, // serverInsertedHTMLCallbacks, currentRequestTags, ssrHeadChildren), Set @@ -362,9 +387,11 @@ export function runWithUnifiedStateMutation( // Map fields (unstableCacheObservations, _privateCache), // requestCache WeakMap, and object fields (headersContext, // i18nContext, serverContext, ssrContext, executionContext, - // requestScopedCacheLife) still share references with the parent until - // replaced. requestCache is intentionally shared — nested scopes within - // the same request should see the same cached values. The mutate + // requestScopedCacheLife, renderDynamicLatch) still share references with + // the parent until replaced. requestCache is intentionally shared — nested + // scopes within the same request should see the same cached values. + // renderDynamicLatch must stay shared: dynamic usage in an isolated child + // scope has to reach gates issued later in the same render. The mutate // callback must replace those reference-typed slices (for example // `ctx.currentRequestTags = []` or `ctx.renderRequestApiUsage = new Set()`) // rather than mutating them in-place (for @@ -376,6 +403,17 @@ export function runWithUnifiedStateMutation( return _als.run(childCtx, fn); } +/** + * Bind `fn` to the current request context, so it reads this request's state + * even when it's called from another async context, such as a response + * stream's pull. + */ +export function bindRequestContext(fn: () => T): () => T { + const ctx = _als.getStore(); + if (!ctx) return fn; + return () => _als.run(ctx, fn); +} + /** * Get the current unified request context. * Returns the ALS store when inside a `runWithRequestContext()` scope, diff --git a/packages/vinext/src/utils/mdx-scan.ts b/packages/vinext/src/utils/mdx-scan.ts index 03d2cce8d3..9bbdc2e714 100644 --- a/packages/vinext/src/utils/mdx-scan.ts +++ b/packages/vinext/src/utils/mdx-scan.ts @@ -1,4 +1,6 @@ import fs from "node:fs"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; import path from "pathslash"; /** Module-level cache for hasMdxFiles — avoids re-scanning per Vite environment. */ @@ -38,3 +40,45 @@ function scanDirForMdx(dir: string): boolean { } return false; } + +type MdxProcessorModule = { + createProcessor(): { + parse(source: string): { children: { type: string; value?: unknown }[] }; + }; +}; + +/** + * Load a reader that keeps only the ESM of an MDX module, so the JavaScript + * export helpers can read it. It takes the ESM nodes from the MDX parser that + * `@mdx-js/rollup` compiles with, resolved beside the plugin vinext + * auto-injects or one the app installs, or from the app itself. Returns null + * when that parser isn't installed. + */ +export async function loadMdxEsmReader(root: string): Promise<((source: string) => string) | null> { + const fromVinext = createRequire(import.meta.url); + // An app that registers its own MDX plugin builds with the parser it installs. + const fromRoot = createRequire(path.join(root, "package.json")); + const candidates = [ + () => createRequire(fromVinext.resolve("@mdx-js/rollup")).resolve("@mdx-js/mdx"), + () => createRequire(fromRoot.resolve("@mdx-js/rollup")).resolve("@mdx-js/mdx"), + () => fromRoot.resolve("@mdx-js/mdx"), + ]; + let mdx: MdxProcessorModule | null = null; + for (const resolveEntry of candidates) { + try { + mdx = (await import(pathToFileURL(resolveEntry()).href)) as MdxProcessorModule; + break; + } catch { + // Try the next place the parser can be installed. + } + } + if (!mdx) return null; + const processor = mdx.createProcessor(); + return (source) => + processor + .parse(source) + .children.flatMap((node) => + node.type === "mdxjsEsm" && typeof node.value === "string" ? [node.value] : [], + ) + .join("\n\n"); +} diff --git a/packages/vinext/src/utils/query.ts b/packages/vinext/src/utils/query.ts index ea859694e2..12065d6011 100644 --- a/packages/vinext/src/utils/query.ts +++ b/packages/vinext/src/utils/query.ts @@ -76,6 +76,30 @@ export function parseQueryString(url: string): Record return query; } +/** + * Convert URLSearchParams into the record an App Router page receives as + * `searchParams`, with repeated keys promoted to arrays. The record has no + * prototype, so a key such as `__proto__` stays an ordinary entry. + */ +export function searchParamsToRecord( + searchParams: URLSearchParams | null | undefined, +): Record { + const record: Record = Object.create(null); + searchParams?.forEach((value, key) => { + const currentValue = record[key]; + if (Array.isArray(currentValue)) { + record[key] = [...currentValue, value]; + return; + } + if (currentValue !== undefined) { + record[key] = [currentValue, value]; + return; + } + record[key] = value; + }); + return record; +} + /** * Convert a Next.js-style query object into URLSearchParams while preserving * repeated keys for array values. diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 02667f1861..d9f44a84a7 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -4,13 +4,14 @@ import { createDevOnCaughtError, createOnUncaughtError, createProdOnCaughtError, - prodOnRecoverableError, + createProdOnRecoverableError, } from "../packages/vinext/src/server/app-browser-error.js"; import { clearAppNavigationFailureTarget, handleAppNavigationFailure, stageAppNavigationFailureTarget, } from "../packages/vinext/src/client/app-nav-failure-handler.js"; +import { BailoutToCSRError } from "../packages/vinext/src/shims/navigation-errors.js"; import { applyServerActionResultDecision } from "../packages/vinext/src/server/app-browser-server-action-navigation.js"; import { createDiscardedServerActionRefreshScheduler, @@ -29,6 +30,7 @@ import { consumeInitialFormState, createVinextHydrateRootOptions, hydrateRootInTransition, + resolveFetchedHydrationLocation, } from "../packages/vinext/src/server/app-browser-hydration.js"; import { createAppBrowserNavigationController } from "../packages/vinext/src/server/app-browser-navigation-controller.js"; import { shouldRecoverSamePathSearchCommitOnResponseCompletion } from "../packages/vinext/src/server/app-browser-navigation-response.js"; @@ -88,7 +90,10 @@ import { type AppElementsInterception, type AppElementsSlotBinding, } from "../packages/vinext/src/server/app-elements.js"; -import { createClientNavigationRenderSnapshot } from "../packages/vinext/src/shims/navigation.js"; +import { + createClientNavigationRenderSnapshot, + type ClientNavigationRenderSnapshot, +} from "../packages/vinext/src/shims/navigation.js"; import { beginAppRouterScrollIntent, clearAppRouterScrollIntent, @@ -3064,6 +3069,52 @@ describe("app browser entry state helpers", () => { } }); + it("does not commit an older decoding HMR payload while a newer update awaits its response headers", async () => { + const { controller, detach, stateRef, setBrowserRouterState } = createControllerHarness(); + let resolveFirstHmrPayload!: (elements: AppElements) => void; + let resolveSecondHmrPayload!: (elements: AppElements) => void; + let resolveSecondSnapshot!: (snapshot: ClientNavigationRenderSnapshot) => void; + const firstHmrPayload = new Promise((resolve) => { + resolveFirstHmrPayload = resolve; + }); + const secondHmrPayload = new Promise((resolve) => { + resolveSecondHmrPayload = resolve; + }); + const secondSnapshot = new Promise((resolve) => { + resolveSecondSnapshot = resolve; + }); + + try { + const firstHmrPromise = controller.hmrReplaceTree( + firstHmrPayload, + stateRef.current.navigationSnapshot, + ); + // The newer update enters before its response (and so its snapshot) arrives. + const secondHmrPromise = controller.hmrReplaceTree(secondHmrPayload, secondSnapshot); + + resolveFirstHmrPayload(createResolvedElements("route:/hmr-a", "/")); + await firstHmrPromise; + + expect(stateRef.current.routeId).toBe("route:/initial"); + expect(setBrowserRouterState).not.toHaveBeenCalled(); + + const renderedSnapshot = createClientNavigationRenderSnapshot( + "https://example.com/initial", + {}, + "/initial?rewritten=1", + ); + resolveSecondSnapshot(renderedSnapshot); + resolveSecondHmrPayload(createResolvedElements("route:/hmr-b", "/")); + await secondHmrPromise; + + expect(stateRef.current.routeId).toBe("route:/hmr-b"); + expect(stateRef.current.navigationSnapshot).toBe(renderedSnapshot); + expect(setBrowserRouterState).toHaveBeenCalledTimes(1); + } finally { + detach(); + } + }); + it("does not preserve unapproved old elements on navigation replace commits", async () => { const currentState = createState({ elements: createResolvedElements("route:/initial", "/", null, { @@ -9274,7 +9325,7 @@ describe("prodOnCaughtError (hydrateRoot prod handler)", () => { }); }); -describe("prodOnRecoverableError (hydrateRoot prod handler)", () => { +describe("createProdOnRecoverableError (hydrateRoot prod handler)", () => { function withFakeReportError(fn: (reportErrorSpy: ReturnType) => T): T { const reportErrorSpy = vi.fn(); const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, "reportError"); @@ -9296,18 +9347,38 @@ describe("prodOnRecoverableError (hydrateRoot prod handler)", () => { it("reports recoverable hydration errors through reportError", () => { withFakeReportError((reportErrorSpy) => { + const onReportedError = vi.fn(); const err = new Error("Minified React error #418"); - prodOnRecoverableError(err); + createProdOnRecoverableError(onReportedError)(err); expect(reportErrorSpy).toHaveBeenCalledWith(err); + expect(onReportedError).toHaveBeenCalledTimes(1); }); }); it("reports the underlying cause when React provides one", () => { withFakeReportError((reportErrorSpy) => { + const onReportedError = vi.fn(); const cause = new Error("server/client text mismatch"); const err = new Error("recoverable", { cause }); - prodOnRecoverableError(err); + createProdOnRecoverableError(onReportedError)(err); expect(reportErrorSpy).toHaveBeenCalledWith(cause); + expect(onReportedError).toHaveBeenCalledTimes(1); + }); + }); + + it("ignores a server bail-out to client rendering", () => { + withFakeReportError((reportErrorSpy) => { + const onReportedError = vi.fn(); + const handler = createProdOnRecoverableError(onReportedError); + // React's production error for a Suspense boundary the server left to + // client-render carries the server's onError digest on the error itself. + const clientRenderedBoundary = Object.assign(new Error("Minified React error #419"), { + digest: "BAILOUT_TO_CLIENT_SIDE_RENDERING", + }); + handler(clientRenderedBoundary); + handler(new Error("recoverable", { cause: new BailoutToCSRError("useSearchParams()") })); + expect(reportErrorSpy).not.toHaveBeenCalled(); + expect(onReportedError).not.toHaveBeenCalled(); }); }); }); @@ -9387,6 +9458,30 @@ describe("app browser form-state hydration", () => { }); }); +describe("fetched initial Flight payload hydration", () => { + const location = { + origin: "https://example.com", + pathname: "/alias", + search: "?q=public", + }; + + it("keeps the public pathname under a rewrite's rendered query", () => { + // /alias rewrites to /page?q=rewritten. SSR rendered usePathname() as + // /alias, so hydration must too, while client pages read the rewrite's query. + expect(resolveFetchedHydrationLocation("/page?q=rewritten", location)).toEqual({ + pathname: "/alias", + search: "?q=rewritten", + }); + }); + + it("keeps the browser URL without a rendered path header", () => { + expect(resolveFetchedHydrationLocation(null, location)).toEqual({ + pathname: "/alias", + search: "?q=public", + }); + }); +}); + describe("mounted slot helpers", () => { it("collects only mounted slot ids", () => { const elements: AppElements = createResolvedElements("route:/dashboard", "/", null, { diff --git a/tests/app-browser-server-action-client.test.ts b/tests/app-browser-server-action-client.test.ts index b8179bce2b..9b59ca6bf8 100644 --- a/tests/app-browser-server-action-client.test.ts +++ b/tests/app-browser-server-action-client.test.ts @@ -11,6 +11,7 @@ import { import { ACTION_REDIRECT_HEADER, ACTION_REVALIDATED_HEADER, + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, } from "../packages/vinext/src/server/headers.js"; import { navigationPlanner } from "../packages/vinext/src/server/navigation-planner.js"; @@ -62,6 +63,8 @@ describe("app browser server action client", () => { status: 303, headers: { [ACTION_REDIRECT_HEADER]: "/target", + // The target's render, rewritten from /target to /page?q=rewritten. + [VINEXT_RENDERED_PATH_AND_SEARCH_HEADER]: encodeURIComponent("/page?q=rewritten"), "content-type": "text/x-component", }, }), @@ -96,7 +99,11 @@ describe("app browser server action client", () => { expect(renderRedirectPayload).toHaveBeenCalledWith( normalizeAppElements(wireElements), - expect.objectContaining({ href: "https://example.com/target" }), + // Client pages in the target read the query it rendered with. + expect.objectContaining({ + href: "https://example.com/target", + renderedPathAndSearch: "/page?q=rewritten", + }), expect.any(Object), "none", ); @@ -365,6 +372,8 @@ describe("app browser server action client", () => { headers: { "content-type": "text/x-component", [ACTION_REVALIDATED_HEADER]: "2", + // A rewrite on the POST re-rendered the page with another query. + [VINEXT_RENDERED_PATH_AND_SEARCH_HEADER]: encodeURIComponent("/source?q=rewritten"), }, }), ), @@ -390,11 +399,12 @@ describe("app browser server action client", () => { expect(clearClientNavigationCaches).toHaveBeenCalledTimes(1); expect(commitSameUrlNavigatePayload).toHaveBeenCalledTimes(1); - const [elementsArg, , returnValueArg, revalidationArg] = + const [elementsArg, , returnValueArg, revalidationArg, renderedPathAndSearchArg] = commitSameUrlNavigatePayload.mock.calls[0]; await expect(elementsArg).resolves.toEqual(normalizeAppElements(wireElements)); expect(returnValueArg).toEqual({ ok: true, data: "value-after-revalidation" }); expect(revalidationArg).toBe("dynamicOnly"); + expect(renderedPathAndSearchArg).toBe("/source?q=rewritten"); }); it("commits the RSC tree for revalidated void actions with no return value", async () => { diff --git a/tests/app-page-cache-render.test.ts b/tests/app-page-cache-render.test.ts index 92834f0688..a7c38b0e29 100644 --- a/tests/app-page-cache-render.test.ts +++ b/tests/app-page-cache-render.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vite-plus/test"; import React from "react"; import { renderAppPageCacheArtifacts } from "../packages/vinext/src/server/app-page-cache-render.js"; +import { hasQueryInvariantRenderProof } from "../packages/vinext/src/server/cache-proof.js"; import { _setRequestScopedCacheLife } from "../packages/vinext/src/shims/cache-request-state.js"; +import { markDynamicUsage } from "../packages/vinext/src/shims/headers.js"; +import { runWithNavigationContext } from "../packages/vinext/src/shims/navigation-state.js"; +import { + createRequestContext, + runWithRequestContext, +} from "../packages/vinext/src/shims/unified-request-context.js"; import { registerFrameworkTracingIntegration } from "../packages/vinext/src/server/tracer.js"; import type { FrameworkTracingBackendSpan, @@ -169,4 +176,86 @@ describe("renderAppPageCacheArtifacts", () => { expect(result.cacheControl).toEqual({ revalidate: 1, expire: 60, stale: 30 }); expect(result.html).toBe("page"); }); + + it("proves a static regeneration left the query unread, so core stores it", async () => { + const result = await renderAppPageCacheArtifacts({ + captureRscData: true, + cleanPathname: "/posts/post", + element: React.createElement("div", null, "page"), + getFontLinks: () => [], + getFontPreloads: () => [], + getFontStyles: () => [], + getNavigationContext: () => null, + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, options) { + if (options?.sideStream && options.capturedRscDataRef) { + options.capturedRscDataRef.value = new Response(options.sideStream).arrayBuffer(); + } + return createStream(["page"]); + }, + }), + navigationParams: {}, + onError: () => undefined, + renderToReadableStream: () => createStream(["flight-data"]), + route: { pattern: "/posts/[slug]", routeSegments: [] }, + }); + + expect(hasQueryInvariantRenderProof(result.htmlRenderObservation)).toBe(true); + expect(hasQueryInvariantRenderProof(result.rscRenderObservation)).toBe(true); + expect(result.usedDynamicApi).toBe(false); + }); + + it("reports a regeneration that used a dynamic API", async () => { + const result = await renderAppPageCacheArtifacts({ + captureRscData: false, + cleanPathname: "/posts/post", + element: React.createElement("div", null, "page"), + getFontLinks: () => [], + getFontPreloads: () => [], + getFontStyles: () => [], + getNavigationContext: () => null, + loadSsrHandler: async () => ({ + async handleSsr() { + markDynamicUsage(); + return createStream(["page"]); + }, + }), + navigationParams: {}, + onError: () => undefined, + renderToReadableStream: () => createStream(["flight-data"]), + route: { pattern: "/posts/[slug]", routeSegments: [] }, + }); + + expect(result.usedDynamicApi).toBe(true); + }); + + it("reports a dynamic API used in SSR's child scope, such as a client page reading searchParams", async () => { + // SSR runs in a child scope of the render, so its mark reaches the + // render's dynamic latch but not the render's own flag. + const result = await runWithRequestContext(createRequestContext(), () => + renderAppPageCacheArtifacts({ + captureRscData: false, + cleanPathname: "/posts/post", + element: React.createElement("div", null, "page"), + getFontLinks: () => [], + getFontPreloads: () => [], + getFontStyles: () => [], + getNavigationContext: () => null, + loadSsrHandler: async () => ({ + handleSsr() { + return runWithNavigationContext(async () => { + markDynamicUsage(); + return createStream(["page"]); + }); + }, + }), + navigationParams: {}, + onError: () => undefined, + renderToReadableStream: () => createStream(["flight-data"]), + route: { pattern: "/posts/[slug]", routeSegments: [] }, + }), + ); + + expect(result.usedDynamicApi).toBe(true); + }); }); diff --git a/tests/app-page-cache.test.ts b/tests/app-page-cache.test.ts index 4f029cab7a..f00c9355df 100644 --- a/tests/app-page-cache.test.ts +++ b/tests/app-page-cache.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { type AppPageCacheOutcomeMetric, buildAppPageCacheTags, @@ -9,31 +9,48 @@ import { readAppPageFallbackShellCacheResponse, scheduleAppPageRscCacheWrite, } from "../packages/vinext/src/server/app-page-cache.js"; -import type { ISRCacheEntry } from "../packages/vinext/src/server/isr-cache.js"; +import { + isrGet, + isrSet, + type AppPageCacheSetter, + type ISRCacheEntry, +} from "../packages/vinext/src/server/isr-cache.js"; import { VINEXT_RSC_COMPATIBILITY_ID_HEADER, VINEXT_RSC_VARY_HEADER, } from "../packages/vinext/src/server/app-rsc-cache-busting.js"; +import type { RenderObservation } from "../packages/vinext/src/server/cache-proof.js"; import { - buildRenderObservation, - buildRenderRequestApiObservations, - type RenderObservation, -} from "../packages/vinext/src/server/cache-proof.js"; -import type { CachedAppPageValue } from "../packages/vinext/src/shims/cache.js"; + MemoryCacheHandler, + setCacheHandler, + type CachedAppPageValue, +} from "../packages/vinext/src/shims/cache.js"; +import type { CacheControlMetadata } from "../packages/vinext/src/shims/cache-handler.js"; import { markAppPprDynamicFallbackShellHtml } from "../packages/vinext/src/server/app-ppr-fallback-shell.js"; -import { NEXT_ROUTER_STALE_TIME_HEADER } from "../packages/vinext/src/server/headers.js"; +import { + NEXT_ROUTER_STALE_TIME_HEADER, + VINEXT_PARAMS_HEADER, + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, +} from "../packages/vinext/src/server/headers.js"; import { markClientTraceMetadataBlock, renderClientTraceMetadataTags, } from "../packages/vinext/src/server/client-trace-metadata.js"; import { markFrameworkLinkHeaders } from "../packages/vinext/src/server/app-response-header-provenance.js"; import { finalizeAppRscResponse } from "../packages/vinext/src/server/app-rsc-response-finalizer.js"; +import { buildAppPageRscResponse } from "../packages/vinext/src/server/app-page-response.js"; import { DefaultCdnCacheAdapter, setCdnCacheAdapter, type CdnCacheAdapter, } from "../packages/vinext/src/shims/cdn-cache.js"; import { withEnvVar } from "./env-test-helpers.js"; +import { + buildQueryInvariantRenderObservation, + buildSearchParamsReadRenderObservation, + queryInvariantObservationBuilders, + queryInvariantRegenObservations, +} from "./render-observation-test-helpers.js"; function createHeaderClearingCdnAdapter(): CdnCacheAdapter { return { @@ -61,7 +78,7 @@ afterEach(() => setCdnCacheAdapter(new DefaultCdnCacheAdapter())); function buildISRCacheEntry( value: CachedAppPageValue, isStale = false, - cacheControl?: { revalidate: number; expire?: number; stale?: number }, + cacheControl?: CacheControlMetadata, ): ISRCacheEntry { return { isStale, @@ -93,27 +110,6 @@ function buildCachedAppPageValue( return value; } -function buildQueryInvariantRenderObservation(): RenderObservation { - return buildRenderObservation({ - boundaryOutcome: { kind: "success" }, - cacheability: "public", - cacheTags: [], - completeness: "complete", - dynamicFetches: [], - output: { - kind: "app-html", - renderEpoch: null, - rootBoundaryId: null, - routeId: "route:/cached", - }, - pathTags: [], - requestApis: buildRenderRequestApiObservations({ - completeness: "complete", - observed: [], - }), - }); -} - describe("app page cache helpers", () => { it("builds implicit page cache tags with unique extra tags", () => { expect(buildAppPageCacheTags("/blog/hello", ["custom", "_N_T_/blog/layout"])).toEqual([ @@ -291,6 +287,157 @@ describe("app page cache helpers", () => { await expect(response?.arrayBuffer()).resolves.toEqual(rscData); }); + it("composes the current request's params and path on cached RSC responses", async () => { + const cachedValue = buildCachedAppPageValue("

cached

", new ArrayBuffer(0)); + const readCached = (isRscRequest: boolean, cacheState: "HIT" | "STALE") => + readAppPageCacheResponse({ + cleanPathname: "/posts/한글", + clearRequestContext() {}, + isRscRequest, + async isrGet() { + return buildISRCacheEntry(cachedValue, cacheState === "STALE", { revalidate: 60 }); + }, + isrHtmlKey(pathname) { + return "html:" + pathname; + }, + isrRscKey(pathname) { + return "rsc:" + pathname; + }, + async isrSet() {}, + async resolveParams() { + return { slug: "한글" }; + }, + renderedPathAndSearch: "/posts/한글?q=1", + revalidateSeconds: 60, + async renderFreshPageForCache() { + throw new Error("regeneration is not awaited here"); + }, + scheduleBackgroundRegeneration() {}, + }); + + for (const cacheState of ["HIT", "STALE"] as const) { + const rsc = await readCached(true, cacheState); + expect(rsc?.headers.get("x-vinext-cache")).toBe(cacheState); + expect(rsc?.headers.get(VINEXT_PARAMS_HEADER)).toBe( + encodeURIComponent(JSON.stringify({ slug: "한글" })), + ); + expect(rsc?.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBe( + encodeURIComponent("/posts/한글?q=1"), + ); + } + + const html = await readCached(false, "HIT"); + expect(html?.headers.get(VINEXT_PARAMS_HEADER)).toBeNull(); + expect(html?.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBeNull(); + + const withoutParams = buildAppPageCachedResponse(cachedValue, { + cacheState: "HIT", + isRscRequest: true, + params: {}, + revalidateSeconds: 60, + }); + expect(withoutParams?.headers.get(VINEXT_PARAMS_HEADER)).toBeNull(); + }); + + it.each(["HIT", "STALE"] as const)( + "surfaces a params resolution failure on a cached RSC %s instead of a cache read error", + async (cacheState) => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const recordCacheOutcome = vi.fn(); + const loadFailure = new Error("route module failed to load"); + + await expect( + readAppPageCacheResponse({ + cleanPathname: "/posts/intercepted", + clearRequestContext() {}, + isRscRequest: true, + async isrGet() { + return buildISRCacheEntry( + buildCachedAppPageValue("", new TextEncoder().encode("flight").buffer), + cacheState === "STALE", + { revalidate: 60 }, + ); + }, + isrHtmlKey(pathname) { + return "html:" + pathname; + }, + isrRscKey(pathname) { + return "rsc:" + pathname; + }, + async isrSet() {}, + recordCacheOutcome, + async resolveParams() { + throw loadFailure; + }, + revalidateSeconds: 60, + async renderFreshPageForCache() { + throw new Error("regeneration is not awaited here"); + }, + scheduleBackgroundRegeneration() {}, + }), + ).rejects.toBe(loadFailure); + expect(recordCacheOutcome).not.toHaveBeenCalled(); + expect(consoleError).not.toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } + }, + ); + + it("keeps middleware's params and path headers from a MISS on the cached RSC HIT", async () => { + const middlewareHeaders = new Headers({ + [VINEXT_PARAMS_HEADER]: encodeURIComponent(JSON.stringify({ slug: "middleware" })), + [VINEXT_RENDERED_PATH_AND_SEARCH_HEADER]: encodeURIComponent("/middleware"), + }); + const params = { slug: "request" }; + const renderedPathAndSearch = "/posts/request"; + + const miss = buildAppPageRscResponse(new Response("flight").body!, { + middlewareContext: { headers: middlewareHeaders, status: null }, + params, + policy: { cacheState: "MISS" }, + renderedPathAndSearch, + }); + const hit = await readAppPageCacheResponse({ + cleanPathname: "/posts/request", + clearRequestContext() {}, + isRscRequest: true, + async isrGet() { + return buildISRCacheEntry( + buildCachedAppPageValue("", new TextEncoder().encode("flight").buffer), + false, + { revalidate: 60 }, + ); + }, + isrHtmlKey(pathname) { + return "html:" + pathname; + }, + isrRscKey(pathname) { + return "rsc:" + pathname; + }, + async isrSet() {}, + middlewareHeaders, + async resolveParams() { + return params; + }, + renderedPathAndSearch, + revalidateSeconds: 60, + async renderFreshPageForCache() { + throw new Error("a fresh entry does not regenerate"); + }, + scheduleBackgroundRegeneration() {}, + }); + + expect(hit?.headers.get("x-vinext-cache")).toBe("HIT"); + for (const header of [VINEXT_PARAMS_HEADER, VINEXT_RENDERED_PATH_AND_SEARCH_HEADER]) { + expect(hit?.headers.get(header)).toBe(miss.headers.get(header)); + } + expect(hit?.headers.get(VINEXT_PARAMS_HEADER)).toBe( + encodeURIComponent(JSON.stringify({ slug: "middleware" })), + ); + }); + it("uses stored cache-control metadata instead of global config for cached HIT responses", async () => { const cachedValue = buildCachedAppPageValue("

cached

"); @@ -347,6 +494,8 @@ describe("app page cache helpers", () => { expireSeconds: 31_536_000, revalidateSeconds: 60, renderFreshPageForCache: vi.fn(async () => ({ + ...queryInvariantRegenObservations(), + usedDynamicApi: false, html: "

fresh

", rscData: new ArrayBuffer(0), tags: [], @@ -562,6 +711,8 @@ describe("app page cache helpers", () => { async renderFreshPageForCache() { didRenderFresh = true; return { + ...queryInvariantRegenObservations(), + usedDynamicApi: false, html: "

fresh

", rscData: new ArrayBuffer(0), tags: [], @@ -799,6 +950,8 @@ describe("app page cache helpers", () => { async renderFreshPageForCache() { return { cacheControl: { revalidate: 10, expire: 20 }, + ...queryInvariantRegenObservations(), + usedDynamicApi: false, html: "

fresh

", linkHeader: "; rel=preload; as=style", rscData, @@ -829,6 +982,55 @@ describe("app page cache helpers", () => { ]); }); + it.each([ + { isRscRequest: false, unproven: "html" }, + { isRscRequest: false, unproven: "rsc" }, + { isRscRequest: true, unproven: "rsc" }, + ] as const)( + "skips regeneration writes when the $unproven render may have read searchParams (RSC request: $isRscRequest)", + async ({ isRscRequest, unproven }) => { + const scheduledRegenerations: Array<() => Promise> = []; + const isrSet = vi.fn(async () => {}); + + const response = await readAppPageCacheResponse({ + cleanPathname: "/stale", + clearRequestContext() {}, + isRscRequest, + async isrGet() { + return buildISRCacheEntry( + buildCachedAppPageValue("

stale

", new ArrayBuffer(0)), + true, + ); + }, + isrHtmlKey(pathname) { + return "html:" + pathname; + }, + isrRscKey(pathname) { + return "rsc:" + pathname; + }, + isrSet, + revalidateSeconds: 60, + async renderFreshPageForCache() { + return { + ...queryInvariantRegenObservations(), + usedDynamicApi: false, + [`${unproven}RenderObservation`]: buildSearchParamsReadRenderObservation(), + html: "

fresh

", + rscData: new ArrayBuffer(0), + tags: [], + }; + }, + scheduleBackgroundRegeneration(_key, renderFn) { + scheduledRegenerations.push(renderFn); + }, + }); + + expect(response?.headers.get("x-vinext-cache")).toBe("STALE"); + await scheduledRegenerations[0](); + expect(isrSet).not.toHaveBeenCalled(); + }, + ); + it("preserves route-level revalidate when regenerated App page fetches live longer", async () => { const scheduledRegenerations: Array<() => Promise> = []; const isrSetCalls: Array<{ @@ -862,6 +1064,8 @@ describe("app page cache helpers", () => { async renderFreshPageForCache() { return { cacheControl: { revalidate: 9 }, + ...queryInvariantRegenObservations(), + usedDynamicApi: false, html: "

fresh

", rscData, tags: ["/config-and-fetch-revalidate", "_N_T_/config-and-fetch-revalidate"], @@ -889,6 +1093,60 @@ describe("app page cache helpers", () => { ]); }); + // Next.js pairs expireTime only with a finite revalidate, so a regenerated + // revalidate = false entry keeps no expire unless a cacheLife sets one. + // https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/build/index.ts#L3035-L3058 + for (const renderCacheControl of [undefined, { revalidate: Infinity, expire: 600 }]) { + it(`regenerates a revalidate = false App page ${renderCacheControl ? "with its cacheLife expire" : "without the route expireTime"}`, async () => { + const scheduledRegenerations: Array<() => Promise> = []; + const isrSetCalls: Array<[string, CacheControlMetadata]> = []; + + await readAppPageCacheResponse({ + cleanPathname: "/static", + clearRequestContext() {}, + isRscRequest: false, + async isrGet() { + return buildISRCacheEntry(buildCachedAppPageValue("

stale

"), true, { + revalidate: Infinity, + }); + }, + isrHtmlKey(pathname) { + return "html:" + pathname; + }, + isrRscKey(pathname) { + return "rsc:" + pathname; + }, + async isrSet(key, _data, policy) { + isrSetCalls.push([key, policy.cacheControl]); + }, + expireSeconds: 31_536_000, + revalidateSeconds: Infinity, + async renderFreshPageForCache() { + return { + cacheControl: renderCacheControl, + ...queryInvariantRegenObservations(), + usedDynamicApi: false, + html: "

fresh

", + rscData: new TextEncoder().encode("fresh-flight").buffer, + tags: ["/static", "_N_T_/static"], + }; + }, + scheduleBackgroundRegeneration(_key, renderFn) { + scheduledRegenerations.push(renderFn); + }, + }); + await scheduledRegenerations[0](); + + const cacheControl = renderCacheControl + ? { revalidate: Infinity, expire: 600 } + : { revalidate: Infinity }; + expect(isrSetCalls).toEqual([ + ["rsc:/static", cacheControl], + ["html:/static", cacheControl], + ]); + }); + } + it("serves stale static fallback shells without regenerating the shared shell key", async () => { const debugCalls: Array<[string, string]> = []; @@ -1014,6 +1272,8 @@ describe("app page cache helpers", () => { revalidateSeconds: 60, async renderFreshPageForCache() { return { + ...queryInvariantRegenObservations(), + usedDynamicApi: false, html: "

fresh

", rscData: new TextEncoder().encode("fresh-flight").buffer, tags: ["/stale-html-miss", "_N_T_/stale-html-miss"], @@ -1149,6 +1409,8 @@ describe("app page cache helpers", () => { }, }), { + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: Promise.resolve(rscData), cleanPathname: "/fresh", consumeDynamicUsage() { @@ -1228,6 +1490,8 @@ describe("app page cache helpers", () => { }, }), { + ...queryInvariantObservationBuilders, + isStaticEligible: true, bypassInterceptionContextCache: true, capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer), cleanPathname: "/about", @@ -1255,6 +1519,8 @@ describe("app page cache helpers", () => { markFrameworkLinkHeaders(rendered.headers, rendered.headers.get("link")); const response = finalizeAppPageHtmlCacheResponse(rendered, { + ...queryInvariantObservationBuilders, + isStaticEligible: true, bypassInterceptionContextCache: true, capturedRscDataPromise: null, cleanPathname: "/about", @@ -1302,6 +1568,8 @@ describe("app page cache helpers", () => { const response = finalizeAppPageHtmlCacheResponse( new Response(`${authored}${injected}
page
`), { + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: null, cleanPathname: "/traced", clientTraceMetadataMarker: marker, @@ -1331,6 +1599,8 @@ describe("app page cache helpers", () => { const debugCalls: Array<[string, string]> = []; const isrSet = vi.fn(); const options = { + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer), cleanPathname: "/dynamic-html", consumeDynamicUsage() { @@ -1402,6 +1672,8 @@ describe("app page cache helpers", () => { }, }), { + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedDynamicUsageBeforeContextCleanup() { return true; }, @@ -1455,6 +1727,8 @@ describe("app page cache helpers", () => { }> = []; const didSchedule = scheduleAppPageRscCacheWrite({ + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer), cleanPathname: "/fresh-rsc", consumeDynamicUsage() { @@ -1505,12 +1779,107 @@ describe("app page cache helpers", () => { expect(debugCalls).toEqual([["RSC cache written", "rsc:/fresh-rsc"]]); }); + it.each(["createHtmlRenderObservation", "createRscRenderObservation"] as const)( + "skips HTML and RSC cache writes, keeping headers, when %s may have read searchParams", + async (unprovenBuilder) => { + const pendingCacheWrites: Promise[] = []; + const debugCalls: Array<[string, string]> = []; + const isrSet = vi.fn(async () => {}); + const finalize = (builders: typeof queryInvariantObservationBuilders) => + finalizeAppPageHtmlCacheResponse( + new Response("

fresh

", { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "s-maxage=60, stale-while-revalidate", + "X-Vinext-Cache": "MISS", + }, + }), + { + ...builders, + isStaticEligible: true, + capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer), + cleanPathname: "/fresh", + consumeDynamicUsage() { + return false; + }, + getPageTags() { + return ["/fresh"]; + }, + isrDebug(event, detail) { + debugCalls.push([event, detail]); + }, + isrHtmlKey(pathname) { + return "html:" + pathname; + }, + isrRscKey(pathname) { + return "rsc:" + pathname; + }, + isrSet, + revalidateSeconds: 60, + linkHeader: null, + waitUntil(promise) { + pendingCacheWrites.push(promise); + }, + }, + ); + + const proven = finalize(queryInvariantObservationBuilders); + const response = finalize({ + ...queryInvariantObservationBuilders, + [unprovenBuilder]: buildSearchParamsReadRenderObservation, + }); + + expect([...response.headers]).toEqual([...proven.headers]); + await expect(response.text()).resolves.toBe("

fresh

"); + await proven.text(); + await Promise.all(pendingCacheWrites); + expect(isrSet.mock.calls.map(([key]) => key)).toEqual(["html:/fresh", "rsc:/fresh"]); + expect(debugCalls).toContainEqual([ + "HTML cache write skipped (searchParams not proven unread)", + "html:/fresh", + ]); + }, + ); + + it("skips RSC cache writes when the render may have read searchParams", async () => { + const pendingCacheWrites: Promise[] = []; + const isrSet = vi.fn(async () => {}); + + const didSchedule = scheduleAppPageRscCacheWrite({ + createRscRenderObservation: buildSearchParamsReadRenderObservation, + isStaticEligible: true, + capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer), + cleanPathname: "/fresh-rsc", + consumeDynamicUsage() { + return false; + }, + dynamicUsedDuringBuild: false, + getPageTags() { + return ["/fresh-rsc"]; + }, + isrRscKey(pathname) { + return "rsc:" + pathname; + }, + isrSet, + revalidateSeconds: 60, + waitUntil(promise) { + pendingCacheWrites.push(promise); + }, + }); + + expect(didSchedule).toBe(true); + await Promise.all(pendingCacheWrites); + expect(isrSet).not.toHaveBeenCalled(); + }); + it("skips persistent RSC cache writes for mounted-slot variants", async () => { const pendingCacheWrites: Promise[] = []; const isrRscKey = vi.fn(); const isrSet = vi.fn(); const didSchedule = scheduleAppPageRscCacheWrite({ + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer), cleanPathname: "/fresh-rsc", consumeDynamicUsage() { @@ -1551,6 +1920,8 @@ describe("app page cache helpers", () => { }, }), { + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer), cleanPathname: "/fresh-rsc", consumeDynamicUsage() { @@ -1593,6 +1964,8 @@ describe("app page cache helpers", () => { }, }), { + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: null, cleanPathname: "/dynamic-rsc", consumeDynamicUsage() { @@ -1633,6 +2006,8 @@ describe("app page cache helpers", () => { }, }), { + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer), cleanPathname: "/fresh-rsc", consumeDynamicUsage() { @@ -1680,6 +2055,8 @@ describe("app page cache helpers", () => { }, }), { + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer), cleanPathname: "/fresh-rsc", consumeDynamicUsage() { @@ -1720,6 +2097,8 @@ describe("app page cache helpers", () => { const isrSet = vi.fn(); const didSchedule = scheduleAppPageRscCacheWrite({ + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer), cleanPathname: "/dynamic-rsc", consumeDynamicUsage() { @@ -1759,6 +2138,8 @@ describe("app page cache helpers", () => { const isrSet = vi.fn(); const didSchedule = scheduleAppPageRscCacheWrite({ + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer), cleanPathname: "/invalid-cache-life", consumeDynamicUsage() { @@ -1795,3 +2176,509 @@ describe("app page cache helpers", () => { ]); }); }); + +describe("app page regeneration failures", () => { + // A failed regeneration re-reads its key before keeping the previous entry, + // so each read must return the same entry. + beforeEach(() => { + vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + setCacheHandler(new MemoryCacheHandler()); + }); + + const staleObservation: RenderObservation = { + ...buildQueryInvariantRenderObservation(), + cacheTags: ["_N_T_/stale", "posts"], + }; + + function readStale(options: { + isRoutePPREnabled?: boolean; + isRscRequest?: boolean; + isrGet: (key: string) => Promise; + isrSet: AppPageCacheSetter; + renderFreshPageForCache: () => Promise>; + scheduled: Array<() => Promise>; + }) { + return readAppPageCacheResponse({ + cleanPathname: "/stale", + clearRequestContext() {}, + isRoutePPREnabled: options.isRoutePPREnabled, + isRscRequest: options.isRscRequest ?? false, + isrGet: options.isrGet, + isrHtmlKey(pathname) { + return "html:" + pathname; + }, + isrRscKey(pathname) { + return "rsc:" + pathname; + }, + isrSet: options.isrSet, + revalidateSeconds: 60, + renderFreshPageForCache: options.renderFreshPageForCache, + scheduleBackgroundRegeneration(_key, renderFn) { + options.scheduled.push(renderFn); + }, + }); + } + + function freshPage(overrides: { + cacheControl?: CacheControlMetadata; + revalidateSeconds?: number | null; + usedDynamicApi: boolean; + }) { + return { + ...queryInvariantRegenObservations(), + html: "

fresh

", + rscData: new TextEncoder().encode("fresh-flight").buffer, + tags: ["_N_T_/stale"], + ...overrides, + }; + } + + it.each([ + { failure: "throws", isRscRequest: false }, + { failure: "throws", isRscRequest: true }, + { failure: "turns dynamic", isRscRequest: false }, + { failure: "turns dynamic", isRscRequest: true }, + ] as const)( + "serves stale and re-stores only its own key when a regeneration $failure (RSC request: $isRscRequest)", + async ({ failure, isRscRequest }) => { + const cachedValue = buildCachedAppPageValue( + isRscRequest ? "" : "

stale

", + isRscRequest ? new TextEncoder().encode("stale-flight").buffer : undefined, + 200, + staleObservation, + ); + const scheduled: Array<() => Promise> = []; + const isrSet = vi.fn(async () => {}); + + const response = await readStale({ + isRscRequest, + async isrGet() { + return buildISRCacheEntry(cachedValue, true, { revalidate: 60, expire: 300, stale: 30 }); + }, + isrSet, + async renderFreshPageForCache() { + if (failure === "throws") throw new Error("regeneration failed"); + return freshPage({ usedDynamicApi: true }); + }, + scheduled, + }); + + expect(response?.headers.get("x-vinext-cache")).toBe("STALE"); + await expect(scheduled[0]()).rejects.toThrow( + failure === "throws" + ? "regeneration failed" + : "Page changed from static to dynamic at runtime /stale", + ); + expect(isrSet).toHaveBeenCalledOnce(); + const [key, data, policy] = isrSet.mock.calls[0]; + expect(key).toBe(isRscRequest ? "rsc:/stale" : "html:/stale"); + expect(data).toBe(cachedValue); + expect(policy).toEqual({ + cacheControl: { revalidate: 30, expire: 300, stale: 30 }, + tags: ["_N_T_/stale", "posts"], + }); + }, + ); + + // Next.js fails a non-PPR regeneration whose render's revalidate is 0, + // whether a dynamic API or the rendered tree's config, fetches or cacheLife + // set it (`build/templates/app-page.ts`). + describe("a regeneration whose effective revalidate is 0 without a dynamic API", () => { + type Case = { + cacheControl?: CacheControlMetadata; + label: string; + revalidateSeconds: number | null; + }; + const cases: Case[] = [ + { label: "a revalidate = 0 tree", revalidateSeconds: 0 }, + { + cacheControl: { revalidate: 300 }, + label: "a revalidate = 0 tree whose cacheLife is positive", + revalidateSeconds: 0, + }, + { cacheControl: { revalidate: 0 }, label: "a zero cacheLife", revalidateSeconds: 60 }, + { label: "a tree without a revalidate or cacheLife", revalidateSeconds: null }, + ]; + + async function regenerate( + page: Case, + isRoutePPREnabled?: boolean, + ): Promise<{ + isrSet: ReturnType>; + regeneration: Promise; + }> { + const cachedValue = buildCachedAppPageValue( + "

stale

", + undefined, + 200, + staleObservation, + ); + const previous = buildISRCacheEntry(cachedValue, true, { revalidate: 60 }); + const scheduled: Array<() => Promise> = []; + const isrSet = vi.fn(async () => {}); + await readStale({ + isRoutePPREnabled, + async isrGet() { + return previous; + }, + isrSet, + async renderFreshPageForCache() { + return freshPage({ + cacheControl: page.cacheControl, + revalidateSeconds: page.revalidateSeconds, + usedDynamicApi: false, + }); + }, + scheduled, + }); + return { isrSet, regeneration: scheduled[0]() }; + } + + it.each(cases)("fails at $label and keeps the previous entry", async (page) => { + const { isrSet, regeneration } = await regenerate(page); + + await expect(regeneration).rejects.toThrow( + "Page changed from static to dynamic at runtime /stale", + ); + expect(isrSet).toHaveBeenCalledOnce(); + expect(isrSet).toHaveBeenCalledWith("html:/stale", expect.anything(), { + cacheControl: { revalidate: 30 }, + tags: ["_N_T_/stale", "posts"], + }); + expect(isrSet.mock.calls[0][1]).toMatchObject({ html: "

stale

" }); + }); + + it.each(cases)("stores at $label with PPR", async (page) => { + const { isrSet, regeneration } = await regenerate(page, true); + + await expect(regeneration).resolves.toBeUndefined(); + expect(isrSet.mock.calls.map(([key, data, policy]) => [key, data.html, policy])).toEqual([ + ["rsc:/stale", "", { cacheControl: { revalidate: 0 }, tags: ["_N_T_/stale"] }], + [ + "html:/stale", + "

fresh

", + { cacheControl: { revalidate: 0 }, tags: ["_N_T_/stale"] }, + ], + ]); + }); + + it("stores a tree without a revalidate at its cacheLife", async () => { + const { isrSet, regeneration } = await regenerate({ + cacheControl: { revalidate: 300 }, + label: "a cacheLife-only tree", + revalidateSeconds: null, + }); + + await expect(regeneration).resolves.toBeUndefined(); + expect(isrSet).toHaveBeenCalledWith("html:/stale", expect.anything(), { + cacheControl: { revalidate: 300 }, + tags: ["_N_T_/stale"], + }); + }); + }); + + it("doesn't keep the previous entry over a newer one another regeneration wrote", async () => { + const cachedValue = buildCachedAppPageValue("

stale

", undefined, 200, staleObservation); + const newerValue = buildCachedAppPageValue("

newer

", undefined, 200, staleObservation); + const scheduled: Array<() => Promise> = []; + const isrSet = vi.fn(async () => {}); + let reads = 0; + + await readStale({ + async isrGet() { + reads++; + // The second read is the failure handler's, after an HTML + // regeneration has written a newer entry under this key. + return reads === 1 + ? buildISRCacheEntry(cachedValue, true, { revalidate: 60 }) + : { isStale: false, value: { lastModified: Date.now() + 1, value: newerValue } }; + }, + isrSet, + async renderFreshPageForCache() { + throw new Error("regeneration failed"); + }, + scheduled, + }); + + await expect(scheduled[0]()).rejects.toThrow("regeneration failed"); + expect(isrSet).not.toHaveBeenCalled(); + }); + + it("keeps the previous entry when the key has gone missing", async () => { + const cachedValue = buildCachedAppPageValue("

stale

", undefined, 200, staleObservation); + const scheduled: Array<() => Promise> = []; + const isrSet = vi.fn(async () => {}); + let reads = 0; + + await readStale({ + async isrGet() { + reads++; + return reads === 1 ? buildISRCacheEntry(cachedValue, true, { revalidate: 60 }) : null; + }, + isrSet, + async renderFreshPageForCache() { + throw new Error("regeneration failed"); + }, + scheduled, + }); + + await expect(scheduled[0]()).rejects.toThrow("regeneration failed"); + expect(isrSet).toHaveBeenCalledWith("html:/stale", cachedValue, expect.anything()); + }); + + // Next.js's set only warns when its cache handler fails: no backoff + // re-store, and the regeneration doesn't fail. + describe("when storing the regenerated page fails", () => { + const cachedValue = buildCachedAppPageValue("

stale

", undefined, 200, staleObservation); + const previousRscValue = buildCachedAppPageValue( + "", + new TextEncoder().encode("stale-flight").buffer, + 200, + staleObservation, + ); + + function failingStore(failingKey: string) { + const previousHtml = buildISRCacheEntry(cachedValue, true, { revalidate: 60, expire: 300 }); + const previousRsc = buildISRCacheEntry(previousRscValue, true, { + revalidate: 60, + expire: 300, + }); + const store = new Map([ + ["html:/stale", previousHtml], + ["rsc:/stale", previousRsc], + ]); + const isrSet = vi.fn(async (key, data, policy) => { + if (key === failingKey) throw new Error("store failed"); + store.set(key, { + isStale: false, + value: { cacheControl: policy.cacheControl, lastModified: Date.now(), value: data }, + }); + }); + return { isrSet, previousHtml, previousRsc, store }; + } + + async function regenerate( + store: Map, + isrSet: AppPageCacheSetter, + ): Promise { + const scheduled: Array<() => Promise> = []; + await readStale({ + async isrGet(key) { + return store.get(key) ?? null; + }, + isrSet, + async renderFreshPageForCache() { + return freshPage({ usedDynamicApi: false }); + }, + scheduled, + }); + await expect(scheduled[0]()).resolves.toBeUndefined(); + } + + it("warns and doesn't write the HTML key when the RSC write fails", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { isrSet, previousHtml, previousRsc, store } = failingStore("rsc:/stale"); + + await regenerate(store, isrSet); + + expect(consoleWarn).toHaveBeenCalledWith( + "[vinext] Failed to update prerender cache for rsc:/stale:", + expect.objectContaining({ message: "store failed" }), + ); + // The RSC key is written first, and nothing follows its failed write. + expect(isrSet.mock.calls.map(([key]) => key)).toEqual(["rsc:/stale"]); + expect(store.get("html:/stale")).toBe(previousHtml); + expect(store.get("rsc:/stale")).toBe(previousRsc); + }); + + // The fresh RSC beside the stale HTML is the state an RSC-triggered + // regeneration leaves anyway; the stale HTML regenerates on its next request. + it("warns and keeps the fresh RSC when the HTML write fails", async () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { isrSet, previousHtml, store } = failingStore("html:/stale"); + + await regenerate(store, isrSet); + + expect(consoleWarn).toHaveBeenCalledWith( + "[vinext] Failed to update prerender cache for html:/stale:", + expect.objectContaining({ message: "store failed" }), + ); + expect(isrSet.mock.calls.map(([key]) => key)).toEqual(["rsc:/stale", "html:/stale"]); + const storedRsc = store.get("rsc:/stale")?.value.value; + expect( + storedRsc?.kind === "APP_PAGE" && storedRsc.rscData + ? new TextDecoder().decode(storedRsc.rscData) + : undefined, + ).toBe("fresh-flight"); + expect(store.get("html:/stale")).toBe(previousHtml); + }); + }); + + it.each([ + { previous: { revalidate: 1 }, restored: { revalidate: 3 } }, + { previous: { revalidate: 10, expire: 12 }, restored: { revalidate: 10, expire: 13 } }, + { previous: { revalidate: 600, expire: 3600 }, restored: { revalidate: 30, expire: 3600 } }, + { previous: { revalidate: Infinity }, restored: { revalidate: 3 } }, + { previous: { revalidate: false }, restored: { revalidate: 3 } }, + ] satisfies Array<{ previous: CacheControlMetadata; restored: CacheControlMetadata }>)( + "clamps a failed regeneration's re-stored policy from $previous.revalidate s", + async ({ previous, restored }) => { + const scheduled: Array<() => Promise> = []; + const isrSet = vi.fn(async () => {}); + + await readStale({ + async isrGet() { + return buildISRCacheEntry( + buildCachedAppPageValue("

stale

", undefined, 200, staleObservation), + true, + previous, + ); + }, + isrSet, + async renderFreshPageForCache() { + throw new Error("regeneration failed"); + }, + scheduled, + }); + + await expect(scheduled[0]()).rejects.toThrow("regeneration failed"); + expect(isrSet.mock.calls[0][2]).toEqual({ + cacheControl: restored, + tags: ["_N_T_/stale", "posts"], + }); + }, + ); + + it.each([ + { + entry: "no stored policy", + build: () => + buildISRCacheEntry( + buildCachedAppPageValue("

stale

", undefined, 200, staleObservation), + true, + ), + }, + { + // Its tags can't be recovered, so re-storing it would drop them. + entry: "no render observation", + build: () => + buildISRCacheEntry(buildCachedAppPageValue("

stale

"), true, { revalidate: 60 }), + }, + ])("leaves an entry with $entry alone when its regeneration fails", async ({ build }) => { + const scheduled: Array<() => Promise> = []; + const isrSet = vi.fn(async () => {}); + + await readStale({ + async isrGet() { + return build(); + }, + isrSet, + async renderFreshPageForCache() { + throw new Error("regeneration failed"); + }, + scheduled, + }); + + await expect(scheduled[0]()).rejects.toThrow("regeneration failed"); + expect(isrSet).not.toHaveBeenCalled(); + }); + + it("keeps the regeneration's own error when re-storing the previous entry fails", async () => { + const scheduled: Array<() => Promise> = []; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + await readStale({ + async isrGet() { + return buildISRCacheEntry( + buildCachedAppPageValue("

stale

", undefined, 200, staleObservation), + true, + { revalidate: 60 }, + ); + }, + async isrSet() { + throw new Error("store unavailable"); + }, + async renderFreshPageForCache() { + throw new Error("regeneration failed"); + }, + scheduled, + }); + + await expect(scheduled[0]()).rejects.toThrow("regeneration failed"); + expect(consoleError).toHaveBeenCalledWith( + "[vinext] Failed to keep the previous entry for html:/stale:", + expect.objectContaining({ message: "store unavailable" }), + ); + consoleError.mockRestore(); + }); + + it("doesn't retry a throwing regeneration until the re-stored revalidate elapses", async () => { + setCacheHandler(new MemoryCacheHandler()); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(1_000); + const scheduled: Array<() => Promise> = []; + const renderFreshPageForCache = async (): Promise> => { + throw new Error("regeneration failed"); + }; + + await readStale({ + async isrGet() { + return buildISRCacheEntry( + buildCachedAppPageValue("

stale

", undefined, 200, staleObservation), + true, + { revalidate: 10 }, + ); + }, + isrSet, + renderFreshPageForCache, + scheduled, + }); + await expect(scheduled[0]()).rejects.toThrow("regeneration failed"); + + vi.setSystemTime(10_500); + const beforeRetry = await readStale({ isrGet, isrSet, renderFreshPageForCache, scheduled }); + expect(beforeRetry?.headers.get("x-vinext-cache")).toBe("HIT"); + expect(scheduled).toHaveLength(1); + + vi.setSystemTime(11_500); + const afterRetry = await readStale({ isrGet, isrSet, renderFreshPageForCache, scheduled }); + expect(afterRetry?.headers.get("x-vinext-cache")).toBe("STALE"); + expect(scheduled).toHaveLength(2); + }); + + it("doesn't retry a failed regeneration of a revalidate = false entry for 3 s", async () => { + setCacheHandler(new MemoryCacheHandler()); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(1_000); + const cachedValue = buildCachedAppPageValue("

stale

", undefined, 200, staleObservation); + const scheduled: Array<() => Promise> = []; + const renderFreshPageForCache = async () => freshPage({ usedDynamicApi: true }); + + // The first read finds the entry stale, as after an on-demand revalidation. + await readStale({ + async isrGet() { + return buildISRCacheEntry(cachedValue, true, { revalidate: false }); + }, + isrSet, + renderFreshPageForCache, + scheduled, + }); + await expect(scheduled[0]()).rejects.toThrow("Page changed from static to dynamic"); + + vi.setSystemTime(3_500); + const beforeRetry = await readStale({ isrGet, isrSet, renderFreshPageForCache, scheduled }); + expect(beforeRetry?.headers.get("x-vinext-cache")).toBe("HIT"); + await expect(beforeRetry?.text()).resolves.toBe("

stale

"); + expect(scheduled).toHaveLength(1); + + vi.setSystemTime(4_500); + const afterRetry = await readStale({ isrGet, isrSet, renderFreshPageForCache, scheduled }); + expect(afterRetry?.headers.get("x-vinext-cache")).toBe("STALE"); + expect(scheduled).toHaveLength(2); + }); +}); diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index f6e2ba84c1..e7a01ff97d 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -12,12 +12,15 @@ import { buildPageElements, type AppPageBuildRoute, } from "../packages/vinext/src/server/app-page-element-builder.js"; -import { probeAppPage } from "../packages/vinext/src/server/app-page-probe.js"; import { + buildAppPageInterceptSourceProbes, + probeAppPage, +} from "../packages/vinext/src/server/app-page-probe.js"; +import { + createAppPageTreePath, resolveAppPageSegmentParamScopeKeys, resolveAppPageSegmentParams, } from "../packages/vinext/src/server/app-page-params.js"; -import { createAppPageTreePath } from "../packages/vinext/src/server/app-page-route-wiring.js"; import { createArtifactCompatibilityEnvelope, createArtifactCompatibilityGraphVersion, @@ -31,8 +34,17 @@ import { buildRenderRequestApiObservations, type RenderObservation, } from "../packages/vinext/src/server/cache-proof.js"; -import { APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL } from "../packages/vinext/src/server/app-rsc-render-mode.js"; +import { + APP_RSC_RENDER_MODE_PREFETCH_DYNAMIC_SHELL, + APP_RSC_RENDER_MODE_PREFETCH_EMPTY, + APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + type AppRscRenderMode, +} from "../packages/vinext/src/server/app-rsc-render-mode.js"; import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js"; +import { + resolveAppPageInterceptSegmentConfig, + resolveAppPageInterceptTree, +} from "../packages/vinext/src/server/app-segment-config.js"; import { after, connection } from "../packages/vinext/src/shims/server.js"; import type { AppPageMiddlewareContext } from "../packages/vinext/src/server/app-page-response.js"; import type { ISRCacheEntry } from "../packages/vinext/src/server/isr-cache.js"; @@ -43,8 +55,17 @@ import { runWithExecutionContext, type ExecutionContextLike, } from "../packages/vinext/src/shims/request-context.js"; +import { + CACHEABILITY_REQUEST_STATE, + type RouteCacheabilityState, +} from "../packages/vinext/src/shims/cacheability-classification.js"; +import { + cacheabilityManifestRouteKey, + parseCacheabilityManifest, +} from "../packages/vinext/src/server/cacheability-manifest.js"; import { createRequestContext, + getRequestContext, runWithRequestContext, } from "../packages/vinext/src/shims/unified-request-context.js"; import { @@ -52,6 +73,8 @@ import { consumeRenderRequestApiUsage, draftMode, getHeadersContext, + headers, + headersContextFromRequest, markDynamicUsage, markRenderRequestApiUsage, setHeadersContext, @@ -59,7 +82,10 @@ import { import { isPromiseLike } from "../packages/vinext/src/utils/promise.js"; import { isUnknownRecord } from "../packages/vinext/src/utils/record.js"; import { extractRscCompletionMetadata } from "../packages/vinext/src/server/rsc-completion-metadata.js"; -import { VINEXT_INTERCEPTION_ID_HEADER } from "../packages/vinext/src/server/headers.js"; +import { + VINEXT_DYNAMIC_STALE_TIME_HEADER, + VINEXT_INTERCEPTION_ID_HEADER, +} from "../packages/vinext/src/server/headers.js"; type TestRoute = { __buildTimeClassifications?: ReadonlyMap | null; @@ -84,12 +110,16 @@ type TestRoute = { loading?: { default?: unknown } | null; loadings?: readonly ({ default?: unknown } | null | undefined)[] | null; loadingTreePositions?: readonly number[] | null; + layout?: { default?: unknown } | null; + name?: string; page?: { default?: unknown; generateMetadata?: unknown } | null; slotParamNames?: readonly string[] | null; slotPatternParts?: readonly string[] | null; } > >; + templates?: readonly ({ default?: unknown } | null | undefined)[]; + templateTreePositions?: readonly number[]; unauthorizeds?: readonly ({ default?: unknown } | null | undefined)[]; }; type DispatchOptions = Parameters>[0]; @@ -286,12 +316,17 @@ type CreateDispatchOptionsOverrides = { cleanPathname?: string; clearRequestContext?: DispatchOptions["clearRequestContext"]; createRscOnErrorHandler?: DispatchOptions["createRscOnErrorHandler"]; + probeInterceptSource?: DispatchOptions["probeInterceptSource"]; dynamicConfig?: DispatchOptions["dynamicConfig"]; dynamicParamsConfig?: DispatchOptions["dynamicParamsConfig"]; + dynamicStaleTimeSeconds?: DispatchOptions["dynamicStaleTimeSeconds"]; findIntercept?: DispatchOptions["findIntercept"]; ensureRouteLoaded?: DispatchOptions["ensureRouteLoaded"]; generateStaticParams?: DispatchOptions["generateStaticParams"]; hasCustomGlobalError?: DispatchOptions["hasCustomGlobalError"]; + hasAnyGenerateStaticParams?: DispatchOptions["hasAnyGenerateStaticParams"]; + hasGenerateStaticParams?: DispatchOptions["hasGenerateStaticParams"]; + isStaticGenerationEdgeRuntime?: DispatchOptions["isStaticGenerationEdgeRuntime"]; formState?: DispatchOptions["formState"]; getSourceRoute?: DispatchOptions["getSourceRoute"]; getNavigationContext?: DispatchOptions["getNavigationContext"]; @@ -322,6 +357,12 @@ type CreateDispatchOptionsOverrides = { resolveRouteFetchCacheMode?: DispatchOptions["resolveRouteFetchCacheMode"]; resolveRouteRevalidateSeconds?: DispatchOptions["resolveRouteRevalidateSeconds"]; resolveRouteDynamicConfig?: DispatchOptions["resolveRouteDynamicConfig"]; + resolveRouteDynamicStaleTimeSeconds?: DispatchOptions["resolveRouteDynamicStaleTimeSeconds"]; + resolveRouteDynamicParamsConfig?: DispatchOptions["resolveRouteDynamicParamsConfig"]; + resolveRouteInterceptTreeDynamicConfig?: DispatchOptions["resolveRouteInterceptTreeDynamicConfig"]; + resolveRouteGenerateStaticParams?: DispatchOptions["resolveRouteGenerateStaticParams"]; + resolveRouteHasAnyGenerateStaticParams?: DispatchOptions["resolveRouteHasAnyGenerateStaticParams"]; + resolveRouteStaticEligible?: DispatchOptions["resolveRouteStaticEligible"]; route?: TestRoute; scheduleBackgroundRegeneration?: DispatchOptions["scheduleBackgroundRegeneration"]; searchParams?: URLSearchParams; @@ -350,10 +391,12 @@ function createDispatchOptions(overrides: CreateDispatchOptionsOverrides = {}) { bypassInterceptionContextCache: overrides.bypassInterceptionContextCache, cleanPathname: overrides.cleanPathname ?? "/posts/hello", clearRequestContext, + probeInterceptSource: overrides.probeInterceptSource, createRscOnErrorHandler: overrides.createRscOnErrorHandler ?? (() => () => undefined), draftModeSecret: "draft-secret", dynamicConfig: overrides.dynamicConfig, dynamicParamsConfig: overrides.dynamicParamsConfig, + dynamicStaleTimeSeconds: overrides.dynamicStaleTimeSeconds, ensureRouteLoaded: overrides.ensureRouteLoaded, findIntercept: overrides.findIntercept ?? (() => null), generateStaticParams: overrides.generateStaticParams ?? null, @@ -374,7 +417,12 @@ function createDispatchOptions(overrides: CreateDispatchOptionsOverrides = {}) { params: { slug: "hello" }, })), getSourceRoute: overrides.getSourceRoute ?? (() => undefined), - hasGenerateStaticParams: typeof overrides.generateStaticParams === "function", + hasAnyGenerateStaticParams: + overrides.hasAnyGenerateStaticParams ?? + overrides.hasGenerateStaticParams ?? + typeof overrides.generateStaticParams === "function", + hasGenerateStaticParams: + overrides.hasGenerateStaticParams ?? typeof overrides.generateStaticParams === "function", hasCustomGlobalError: overrides.hasCustomGlobalError, hasPageDefaultExport: true, hasPageModule: true, @@ -384,6 +432,7 @@ function createDispatchOptions(overrides: CreateDispatchOptionsOverrides = {}) { actionFailed: overrides.actionFailed, interceptionContext: overrides.interceptionContext ?? null, isProgressiveActionRender: overrides.isProgressiveActionRender, + isStaticGenerationEdgeRuntime: overrides.isStaticGenerationEdgeRuntime, isProduction: overrides.isProduction ?? false, isRscRequest: overrides.isRscRequest ?? false, isrGet, @@ -418,6 +467,13 @@ function createDispatchOptions(overrides: CreateDispatchOptionsOverrides = {}) { resolveRouteFetchCacheMode: overrides.resolveRouteFetchCacheMode, resolveRouteRevalidateSeconds: overrides.resolveRouteRevalidateSeconds, resolveRouteDynamicConfig: overrides.resolveRouteDynamicConfig, + resolveRouteDynamicStaleTimeSeconds: overrides.resolveRouteDynamicStaleTimeSeconds, + resolveRouteDynamicParamsConfig: overrides.resolveRouteDynamicParamsConfig, + resolveRouteInterceptTreeDynamicConfig: overrides.resolveRouteInterceptTreeDynamicConfig, + resolveRouteGenerateStaticParams: overrides.resolveRouteGenerateStaticParams, + resolveRouteHasAnyGenerateStaticParams: overrides.resolveRouteHasAnyGenerateStaticParams, + resolveRouteStaticEligible: + overrides.resolveRouteStaticEligible ?? ((candidate) => !candidate.isDynamic), route, runWithSuppressedHookWarning(probe: () => Promise) { return probe(); @@ -695,6 +751,10 @@ describe("app page dispatch", () => { afterEach(() => { consumeDynamicUsage(); consumeRenderRequestApiUsage(); + // Tests that run outside a request share the fallback state, whose dynamic + // latch is otherwise only reset by a new headers context. + setHeadersContext(headersContextFromRequest(new Request("https://example.test/"))); + setHeadersContext(null); vi.unstubAllEnvs(); }); @@ -724,6 +784,137 @@ describe("app page dispatch", () => { await expect(response.text()).resolves.toBe("cached"); }); + it("sends the request's navigation params and path on a cached RSC hit", async () => { + const { options } = createDispatchOptions({ + async buildPageElement() { + throw new Error("cache hit should not render the page"); + }, + cleanPathname: "/posts/first", + isProduction: true, + isRscRequest: true, + isrGet: vi.fn(async () => + buildISRCacheEntry(buildCachedAppPageValue("", new TextEncoder().encode("flight").buffer)), + ), + hasGenerateStaticParams: true, + params: { slug: "first" }, + revalidateSeconds: 60, + route: createRoute({ + isDynamic: true, + params: ["slug"], + // An active slot's params are part of what a fresh render sends. + slots: { + "sidebar@app/posts/@sidebar": { + page: { default: "sidebar-page" }, + slotParamNames: ["section"], + slotPatternParts: [":section", ":slug"], + }, + }, + }), + }); + options.renderedPathAndSearch = "/posts/first"; + + const response = await dispatchAppPage(options); + + expect(response.headers.get("x-vinext-cache")).toBe("HIT"); + expect(response.headers.get("x-vinext-params")).toBe( + encodeURIComponent(JSON.stringify({ slug: "first", section: "posts" })), + ); + expect(response.headers.get("x-vinext-rendered-path-and-search")).toBe( + encodeURIComponent("/posts/first"), + ); + }); + + it("hydrates an intercepting source route before resolving a cached RSC hit's params", async () => { + // The source route's slots are lazy until ensureRouteLoaded runs. + const sidebarSlot: { + page?: { default?: unknown } | null; + slotParamNames: readonly string[]; + slotPatternParts: readonly string[]; + } = { slotParamNames: ["catchAll"], slotPatternParts: [":catchAll+"] }; + const sourceRoute = createRoute({ + params: [], + pattern: "/feed", + routeSegments: ["feed"], + slots: { + "modal@app/feed/@modal": { + page: { default: "modal-page" }, + slotParamNames: ["id"], + slotPatternParts: ["photos", ":id"], + }, + "sidebar@app/feed/@sidebar": sidebarSlot, + }, + }); + const { options } = createDispatchOptions({ + async buildPageElement() { + throw new Error("cache hit should not render the page"); + }, + cleanPathname: "/photos/123", + async ensureRouteLoaded(loadedRoute) { + if (loadedRoute === sourceRoute) sidebarSlot.page = { default: "sidebar-page" }; + }, + hasGenerateStaticParams: true, + isProduction: true, + isRscRequest: true, + isrGet: vi.fn(async () => + buildISRCacheEntry(buildCachedAppPageValue("", new TextEncoder().encode("flight").buffer)), + ), + params: { id: "123" }, + revalidateSeconds: 60, + route: createRoute({ isDynamic: true, params: ["id"], pattern: "/photos/[id]" }), + }); + + const response = await dispatchAppPage({ + ...options, + findIntercept() { + return { + interceptBranchSegments: ["(.)photos", "[id]"], + interceptionGraphId: "graph-interception:/feed->/photos/:id", + matchedParams: { id: "123" }, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }; + }, + getSourceRoute(sourceRouteIndex) { + return sourceRouteIndex === 1 ? sourceRoute : undefined; + }, + }); + + expect(response.headers.get("x-vinext-cache")).toBe("HIT"); + expect(response.headers.get("x-vinext-params")).toBe( + encodeURIComponent(JSON.stringify({ id: "123", catchAll: ["photos", "123"] })), + ); + }); + + it("validates generated params before hydrating a route for cached RSC hit params", async () => { + const ensureRouteLoaded = vi.fn(async () => { + throw new Error("route modules should not load for a generated-param miss"); + }); + const renderHttpAccessFallbackPage = vi.fn( + async () => new Response("not found", { status: 404 }), + ); + const { options } = createDispatchOptions({ + async buildPageElement() { + throw new Error("unknown static params should not render the page"); + }, + ensureRouteLoaded, + async generateStaticParams() { + return [{ slug: "known" }]; + }, + isProduction: true, + isRscRequest: true, + isrGet: vi.fn(async () => null), + revalidateSeconds: 60, + route: createRoute({ isDynamic: true, params: ["slug"] }), + }); + options.renderHttpAccessFallbackPage = renderHttpAccessFallbackPage; + + const response = await dispatchAppPage({ ...options, dynamicParamsConfig: false }); + + expect(response.status).toBe(404); + expect(ensureRouteLoaded).not.toHaveBeenCalled(); + }); + it("treats unproofed cached production HTML as a miss for query-bearing requests", async () => { const isrGet = vi.fn(async () => buildISRCacheEntry(buildCachedAppPageValue("cached empty query")), @@ -754,13 +945,16 @@ describe("app page dispatch", () => { expect(isrGet).toHaveBeenCalled(); expect(probePage).not.toHaveBeenCalled(); expect(response.headers.get("x-vinext-cache")).toBeNull(); - expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate"); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); await expect(response.text()).resolves.toBe("page"); }); - it("caches fresh query-bearing HTML when the page probe does not read searchParams", async () => { + it("renders query-bearing HTML as a cache candidate and stores it when searchParams is unread", async () => { const probePage = vi.fn(() => null); const isrSet = vi.fn(async () => {}); + const ssrOptions: { isCacheCandidate?: boolean }[] = []; const waitUntilPromises: Promise[] = []; const executionContext = { waitUntil(promise) { @@ -770,6 +964,12 @@ describe("app page dispatch", () => { const { options } = createDispatchOptions({ isProduction: true, isrSet, + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, handleSsrOptions) { + ssrOptions.push({ isCacheCandidate: handleSsrOptions?.isCacheCandidate }); + return createStream(["page"]); + }, + }), probePage, revalidateSeconds: 60, searchParams: new URLSearchParams("utm_source=google"), @@ -779,8 +979,11 @@ describe("app page dispatch", () => { dispatchAppPage(options), ); + // SSR keeps the query out of the output unless the render turns dynamic, + // so a candidate miss reports MISS with or without a query. + expect(ssrOptions).toEqual([{ isCacheCandidate: true }]); expect(probePage).not.toHaveBeenCalled(); - expect(response.headers.get("x-vinext-cache")).toBeNull(); + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate"); await expect(response.text()).resolves.toBe("page"); await Promise.all(waitUntilPromises.splice(0)); @@ -798,6 +1001,180 @@ describe("app page dispatch", () => { expect(cachePolicy.cacheControl.expire).toBeUndefined(); }); + it("renders draft-mode and dev HTML outside cache-candidate mode", async () => { + for (const overrides of [ + { + isProduction: true, + request: new Request("https://example.com/posts/hello?q=1", { + headers: { cookie: "__prerender_bypass=draft-secret" }, + }), + }, + { isProduction: false }, + ]) { + const ssrOptions: { isCacheCandidate?: boolean }[] = []; + const { options } = createDispatchOptions({ + ...overrides, + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, handleSsrOptions) { + ssrOptions.push({ isCacheCandidate: handleSsrOptions?.isCacheCandidate }); + return createStream(["page"]); + }, + }), + revalidateSeconds: 60, + searchParams: new URLSearchParams("q=1"), + }); + + const response = await dispatchAppPage(options); + await response.text(); + expect(ssrOptions).toEqual([{ isCacheCandidate: false }]); + } + }); + + // The deploy probe must render the way the runtime does, or the manifest it + // produces would certify a render the runtime never serves. + it("renders the Workers Cache deploy probe in cache-candidate mode without reading the cache", async () => { + const isrGet = vi.fn(async () => null); + const ssrOptions: { isCacheCandidate?: boolean }[] = []; + const context: ExecutionContextLike = { waitUntil() {} }; + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 10_000, + mode: "probe", + }; + Reflect.set(context, CACHEABILITY_REQUEST_STATE, state); + const { options } = createDispatchOptions({ + isProduction: true, + isrGet, + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, handleSsrOptions) { + ssrOptions.push({ isCacheCandidate: handleSsrOptions?.isCacheCandidate }); + return createStream(["page"]); + }, + }), + revalidateSeconds: 60, + }); + + const response = await runWithExecutionContext(context, () => dispatchAppPage(options)); + await response.text(); + + expect(ssrOptions).toEqual([{ isCacheCandidate: true }]); + expect(isrGet).not.toHaveBeenCalled(); + }); + + // Discovery can list paths of a route Next.js classifies as dynamic, such as + // one whose only generateStaticParams sits above its last dynamic segment. + // The probe must report the whole pattern dynamic so it gets no entry. + it("reports a route that isn't statically generated as pattern-dynamic to the deploy probe", async () => { + const cases: [Partial[0]>, string | undefined][] = [ + [ + { + hasAnyGenerateStaticParams: true, + hasGenerateStaticParams: false, + route: createRoute({ isDynamic: true, params: ["slug"] }), + }, + "route is not statically generated", + ], + [{ isStaticGenerationEdgeRuntime: true }, "route is not statically generated"], + [ + { + hasGenerateStaticParams: true, + route: createRoute({ isDynamic: true, params: ["slug"] }), + }, + undefined, + ], + [{}, undefined], + ]; + for (const [overrides, expected] of cases) { + const context: ExecutionContextLike = { waitUntil() {} }; + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 10_000, + mode: "probe", + }; + Reflect.set(context, CACHEABILITY_REQUEST_STATE, state); + const { options } = createDispatchOptions({ isProduction: true, ...overrides }); + + const response = await runWithExecutionContext(context, () => dispatchAppPage(options)); + await response.text(); + + expect(state.patternDynamicReason).toBe(expected); + } + }); + + // Next.js serves a path its build never certified per request, so a Workers + // Cache path its manifest gives no state renders with real values from the + // start. A certified path, and every path without a manifest, keeps + // candidate mode. + it("renders a path its Workers Cache manifest gives no state outside cache-candidate mode", async () => { + const manifest = parseCacheabilityManifest( + JSON.stringify({ + buildId: "build-a", + routes: { + [cacheabilityManifestRouteKey("app-page", "/posts/[slug]")]: { + kind: "app-page", + pattern: "/posts/[slug]", + state: "runtime-check", + staticPaths: { html: ["/posts/listed"] }, + }, + }, + version: 1, + }), + "build-a", + ); + const cases: [RouteCacheabilityState["admission"], string, boolean][] = [ + [ + { manifest, policy: "manifest", representation: "html", routePathname: "/posts/hello" }, + "/posts/hello", + false, + ], + [ + { manifest, policy: "manifest", representation: "html", routePathname: "/posts/listed" }, + "/posts/listed", + true, + ], + [ + // A curl-style request maps to html at admission, as it does here. + { + manifest, + policy: "manifest", + representation: "app-route", + routePathname: "/posts/listed", + }, + "/posts/listed", + true, + ], + [ + { policy: "runtime", representation: "html", routePathname: "/posts/hello" }, + "/posts/hello", + true, + ], + ]; + for (const [admission, cleanPathname, expected] of cases) { + const ssrOptions: { isCacheCandidate?: boolean }[] = []; + const context: ExecutionContextLike = { waitUntil() {} }; + const state: RouteCacheabilityState = { + admission, + captureDeadlineAt: Date.now() + 10_000, + mode: "admit", + }; + Reflect.set(context, CACHEABILITY_REQUEST_STATE, state); + const { options } = createDispatchOptions({ + cleanPathname, + isProduction: true, + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, handleSsrOptions) { + ssrOptions.push({ isCacheCandidate: handleSsrOptions?.isCacheCandidate }); + return createStream(["page"]); + }, + }), + revalidateSeconds: 60, + searchParams: new URLSearchParams("q=1"), + }); + + const response = await runWithExecutionContext(context, () => dispatchAppPage(options)); + await response.text(); + expect(ssrOptions, JSON.stringify(admission)).toEqual([{ isCacheCandidate: expected }]); + } + }); + it("writes HTML-captured RSC data under the plain key when interception context is absent", async () => { const isrSet = vi.fn(async () => {}); const waitUntilPromises: Promise[] = []; @@ -1362,7 +1739,9 @@ describe("app page dispatch", () => { ); const completed = extractRscCompletionMetadata(await response.arrayBuffer()); await Promise.all(waitUntilPromises.splice(0)); - expect(response.headers.get("x-vinext-cache")).not.toBe("HIT"); + // A query-bearing RSC render may still read searchParams after its + // headers are built, so it sends no provisional cache state. + expect(response.headers.get("x-vinext-cache")).toBeNull(); expect(response.headers.get("x-vinext-rsc-completion-metadata")).toBe("1"); expect(completed.metadata).toEqual({ dynamicStaleTimeSeconds: 0, @@ -2366,12 +2745,17 @@ describe("app page dispatch", () => { }, }, }); - const currentRoute = createRoute({ params: ["id"], pattern: "/photos/[id]" }); + const currentRoute = createRoute({ + isDynamic: true, + params: ["id"], + pattern: "/photos/[id]", + }); const middlewareHeaders = new Headers({ "x-from-middleware": "yes" }); const setNavigationContext = vi.fn(); let capturedInterceptOpts: Parameters[2]; const { options } = createDispatchOptions({ cleanPathname: "/photos/123", + isProduction: true, async buildPageElement(route, params, opts) { capturedInterceptOpts = opts; return `${route.pattern}:${JSON.stringify(params)}:${opts?.interceptSlotKey ?? "direct"}`; @@ -2414,6 +2798,9 @@ describe("app page dispatch", () => { expect(response.status).toBe(202); expect(response.headers.get("content-type")).toBe("text/x-component"); expect(response.headers.get("x-from-middleware")).toBe("yes"); + // The response renders the static /feed source, so the dynamic target's + // classification doesn't make it uncacheable. + expect(response.headers.get("cache-control")).toBeNull(); await expect(response.text()).resolves.toBe("/feed:{}:modal@app/feed/@modal"); expect(capturedInterceptOpts).toMatchObject({ interceptGraphId: "graph-interception:/feed->/photos/:id", @@ -2428,120 +2815,1488 @@ describe("app page dispatch", () => { }); }); - it("fresh-renders mounted-slot intercepted RSC requests without persistent cache reuse", async () => { + it("sends the never-cache header on intercepted RSC of a source route that can't be static", async () => { + // app/photos/[id]/page.tsx generates its params, so the target is static, + // but app/feed/page.tsx sets dynamic = "force-dynamic". const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] }); const currentRoute = createRoute({ + isDynamic: true, params: ["id"], pattern: "/photos/[id]", routeSegments: ["photos", "[id]"], }); - const staleRscData = new TextEncoder().encode("stale-flight").buffer; - const buildPageElement = vi.fn( - async ( - route: TestRoute, - params: Record, - opts: Parameters[2], - searchParams: URLSearchParams, - ) => - JSON.stringify({ - params, - route: route.pattern, - search: searchParams.toString(), - slot: opts?.interceptSlotKey ?? "direct", - }), - ); - let scheduledRender: unknown = null; - const scheduleBackgroundRegeneration: DispatchOptions["scheduleBackgroundRegeneration"] = ( - _key, - renderFn, - ) => { - scheduledRender = renderFn; - }; - const resolveRouteFetchCacheMode = vi.fn((route: TestRoute) => - route === sourceRoute ? "force-cache" : null, - ); - const resolveRouteRevalidateSeconds = vi.fn((route: TestRoute) => - route === sourceRoute ? 30 : null, - ); + const resolveRouteStaticEligible = vi.fn((route: TestRoute) => route !== sourceRoute); const { options } = createDispatchOptions({ - buildPageElement, + async buildPageElement(route) { + return route.pattern; + }, cleanPathname: "/photos/123", findIntercept: () => ({ matchedParams: { id: "123" }, page: { default: "modal-page" }, - slotId: "slot:modal:/feed", slotKey: "modal@app/feed/@modal", sourceRouteIndex: 1, }), + generateStaticParams: async () => [{ id: "123" }], getSourceRoute(sourceRouteIndex) { return sourceRouteIndex === 1 ? sourceRoute : undefined; }, - interceptionContext: "/feed", isProduction: true, isRscRequest: true, - isrGet: vi.fn(async () => - buildISRCacheEntry( - buildCachedAppPageValue( - "", - staleRscData, - undefined, - buildQueryInvariantRenderObservation(), - ), - true, - ), - ), - isrRscKey(pathname, mountedSlotsHeader, _renderMode, interceptionContext) { - return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}:${interceptionContext ?? "none"}`; + renderToReadableStream(element) { + return createStream([typeof element === "string" ? element : "unexpected-element"]); }, - loadSsrHandler: async () => ({ - async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) { - if (captureOptions?.capturedRscDataRef) { - captureOptions.capturedRscDataRef.value = Promise.resolve( - new TextEncoder().encode("fresh-intercepted-flight").buffer, - ); - } - void captureOptions?.sideStream?.cancel().catch(() => {}); - return createStream(["fresh"]); - }, - }), - mountedSlotsHeader: "slot:modal:/feed", - revalidateSeconds: 60, - resolveRouteFetchCacheMode, - resolveRouteRevalidateSeconds, + resolveRouteDynamicConfig: (route) => (route === sourceRoute ? "force-dynamic" : undefined), + resolveRouteStaticEligible, route: currentRoute, - scheduleBackgroundRegeneration, - searchParams: new URLSearchParams("tab=popular"), }); const response = await dispatchAppPage(options); - expect(response.headers.get("x-vinext-cache")).toBeNull(); - await expect(response.text()).resolves.toBe("flight"); - expect(scheduledRender).toBeNull(); - - const [routeArg, paramsArg, optsArg, searchParamsArg] = buildPageElement.mock.calls[0]; - expect(resolveRouteFetchCacheMode).toHaveBeenCalledWith(sourceRoute); - expect(resolveRouteRevalidateSeconds).toHaveBeenCalledWith(sourceRoute); - expect(routeArg).toBe(sourceRoute); - expect(paramsArg).toEqual({}); - expect(searchParamsArg.toString()).toBe("tab=popular"); - expect(optsArg).toMatchObject({ - interceptionContext: "/feed", - interceptParams: { id: "123" }, - interceptSlotId: "slot:modal:/feed", - interceptSlotKey: "modal@app/feed/@modal", - interceptSourceMatchedUrl: "/feed", - }); - expect(options.isrGet).not.toHaveBeenCalled(); - expect(options.isrSet).not.toHaveBeenCalled(); + await expect(response.text()).resolves.toBe("/feed"); + expect(resolveRouteStaticEligible).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); }); - it("bypasses shared caches for an unverified interception context", async () => { - const isrGet = vi.fn(async () => - buildISRCacheEntry( - buildCachedAppPageValue( - "", - new TextEncoder().encode("cached-flight").buffer, + it("classifies intercepted RSC with the intercepting branch in the source's slot", async () => { + // app/feed/page.tsx is static, but app/feed/@modal/(.)photos/[id]/page.tsx + // sets dynamic = "force-dynamic". Next.js serves the response from the + // intercepting route, whose tree includes that page. + const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const interceptLayouts = [{ default: "modal-layout" }]; + const interceptPage = { default: "modal-page", dynamic: "force-dynamic" }; + const resolveRouteStaticEligible = vi.fn( + (_route, intercept) => + (intercept?.interceptPage as { dynamic?: string } | undefined)?.dynamic !== "force-dynamic", + ); + const { options } = createDispatchOptions({ + async buildPageElement(route) { + return route.pattern; + }, + cleanPathname: "/photos/123", + findIntercept: () => ({ + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayouts, + interceptLayoutSegments: [["(.)photos"]], + matchedParams: { id: "123" }, + page: interceptPage, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + targetPatternParts: ["feed", "photos", ":id"], + }), + getSourceRoute(sourceRouteIndex) { + return sourceRouteIndex === 1 ? sourceRoute : undefined; + }, + isProduction: true, + isRscRequest: true, + renderToReadableStream(element) { + return createStream([typeof element === "string" ? element : "unexpected-element"]); + }, + resolveRouteStaticEligible, + route: currentRoute, + }); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("/feed"); + expect(resolveRouteStaticEligible).toHaveBeenCalledWith(sourceRoute, { + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayoutSegments: [["(.)photos"]], + interceptLayouts, + interceptPage, + interceptSlotKey: "modal@app/feed/@modal", + interceptTargetPatternParts: ["feed", "photos", ":id"], + }); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + // Dev keeps its no-store header. + const devResponse = await dispatchAppPage({ ...options, isProduction: false }); + expect(devResponse.headers.get("cache-control")).toBe("no-store, must-revalidate"); + await devResponse.text(); + }); + + it("classifies intercepted RSC with the owner's default the matched intercept loads", async () => { + // app/feed/default.tsx sets dynamic = "force-static" and takes app/feed's + // children in the intercepting route's tree. Only the matched intercept + // loads it, so classification must see it hydrated. + const sourceRoute = createRoute({ + params: [], + pattern: "/feed", + routeSegments: ["feed"], + slots: { "modal@app/feed/@modal": { default: { default: "modal-default" } } }, + }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const feedDefault = { default: "feed-default", dynamic: "force-static" }; + const intercept = { + matchedParams: { id: "123" }, + ownerDefault: null, + __loadOwnerDefault: vi.fn(async () => feedDefault), + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }; + const resolveRouteStaticEligible = vi.fn( + (_route, matched) => + (matched?.interceptOwnerDefault as { dynamic?: string } | undefined)?.dynamic === + "force-static", + ); + const { options } = createDispatchOptions({ + async buildPageElement(route) { + return route.pattern; + }, + cleanPathname: "/photos/123", + findIntercept: () => ({ ...intercept }), + getSourceRoute(sourceRouteIndex) { + return sourceRouteIndex === 1 ? sourceRoute : undefined; + }, + isProduction: true, + isRscRequest: true, + renderToReadableStream(element) { + return createStream([typeof element === "string" ? element : "unexpected-element"]); + }, + resolveRouteStaticEligible, + route: currentRoute, + }); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("/feed"); + expect(intercept.__loadOwnerDefault).toHaveBeenCalledTimes(1); + expect(resolveRouteStaticEligible).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptOwnerDefault: feedDefault }), + ); + expect(response.headers.get("cache-control")).toBeNull(); + }); + + it("renders intercepted RSC without the owner's default when the source lacks the slot", async () => { + // A route-group variant of app/feed matched as the source has no @modal, + // so its own page renders and app/feed/default.tsx, which throws at the + // top level here, must not be evaluated. + const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const intercept = { + matchedParams: { id: "123" }, + ownerDefault: null, + __loadOwnerDefault: vi.fn(async () => { + throw new Error("app/feed/default.tsx must not be evaluated"); + }), + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }; + const resolveRouteStaticEligible = vi.fn( + () => true, + ); + const { options } = createDispatchOptions({ + async buildPageElement(route) { + return route.pattern; + }, + cleanPathname: "/photos/123", + findIntercept: () => ({ ...intercept }), + getSourceRoute(sourceRouteIndex) { + return sourceRouteIndex === 1 ? sourceRoute : undefined; + }, + isProduction: true, + isRscRequest: true, + renderToReadableStream(element) { + return createStream([typeof element === "string" ? element : "unexpected-element"]); + }, + resolveRouteStaticEligible, + route: currentRoute, + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe("/feed"); + expect(intercept.__loadOwnerDefault).not.toHaveBeenCalled(); + expect(resolveRouteStaticEligible).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptOwnerDefault: null }), + ); + }); + + describe("current-route interception cacheability", () => { + // app/feed/page.tsx is static and owns app/feed/@modal, whose + // (.)feed/[id] interception the /feed route renders itself. Next.js + // classifies that intercepting route's own tree. + const neverCache = "private, no-cache, no-store, max-age=0, must-revalidate"; + + function createCurrentRouteDispatch( + interceptPage: Record, + overrides: CreateDispatchOptionsOverrides = {}, + ) { + const route = createRoute({ + params: [], + pattern: "/feed", + routeSegments: ["feed"], + slots: { "modal@app/feed/@modal": { default: { default: "modal-default" } } }, + }); + const cachedEntry = buildISRCacheEntry( + buildCachedAppPageValue( + "", + new TextEncoder().encode("cached-flight").buffer, + undefined, + buildQueryInvariantRenderObservation(), + ), + ); + const isrGet = vi.fn(async () => cachedEntry); + const isrSet = vi.fn(async () => {}); + const resolveRouteStaticEligible = vi.fn( + (_route, intercept) => + (intercept?.interceptPage as { dynamic?: string } | undefined)?.dynamic !== + "force-dynamic", + ); + // The intercepting tree's config is its page's, as generated. + const interceptConfig = ( + intercept: Parameters>[1], + ) => + intercept?.interceptPage as + | { dynamic?: string; revalidate?: number; unstable_dynamicStaleTime?: number } + | undefined; + const buildPageElement = vi.fn( + async (_route, _params, opts) => opts?.interceptSlotKey ?? "direct", + ); + const { options } = createDispatchOptions({ + buildPageElement, + cleanPathname: "/feed", + findIntercept: () => ({ + matchedParams: {}, + page: interceptPage, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 0, + }), + getSourceRoute: (sourceRouteIndex) => (sourceRouteIndex === 0 ? route : undefined), + interceptionContext: "/feed", + isProduction: true, + isRscRequest: true, + isrGet, + isrSet, + params: {}, + renderToReadableStream: () => createStream(["fresh-flight"]), + resolveRouteDynamicConfig: (_route, intercept) => interceptConfig(intercept)?.dynamic, + resolveRouteRevalidateSeconds: (_route, intercept) => + interceptConfig(intercept)?.revalidate ?? null, + resolveRouteDynamicStaleTimeSeconds: (_route, intercept) => + interceptConfig(intercept)?.unstable_dynamicStaleTime, + resolveRouteStaticEligible, + revalidateSeconds: 60, + route, + ...overrides, + }); + return { buildPageElement, isrGet, isrSet, options, resolveRouteStaticEligible, route }; + } + + // Renders a cache miss and waits for its background write. + async function dispatchMiss(overrides: CreateDispatchOptionsOverrides & { page: object }) { + const { page, ...dispatchOverrides } = overrides; + const isrGet = vi.fn(async () => null); + const dispatch = createCurrentRouteDispatch(page as Record, { + isrGet, + ...dispatchOverrides, + }); + const waitUntilPromises: Promise[] = []; + const response = await runWithExecutionContext( + { waitUntil: (promise) => waitUntilPromises.push(promise) }, + () => dispatchAppPage(dispatch.options), + ); + await expect(response.text()).resolves.toBe("fresh-flight"); + await Promise.all(waitUntilPromises); + return { ...dispatch, isrGet, response }; + } + + it.each([ + ["force-dynamic", { dynamicConfig: "force-dynamic" }], + ["revalidate = 0", { revalidateSeconds: 0 }], + ])( + "admits and stores a static sibling-page intercept over a %s page", + async (_name, baseConfig) => { + // The intercepting branch replaces app/feed/page.tsx, and its tree + // keeps none of that page's config. + const page = { default: "photo-page", revalidate: 30 }; + const { isrGet, isrSet, response } = await dispatchMiss({ + ...baseConfig, + findIntercept: () => ({ + matchedParams: {}, + page, + slotKey: "__vinext_page_intercept", + sourceRouteIndex: 0, + }), + page, + }); + + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + expect(isrGet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet.mock.calls[0]![2].cacheControl.revalidate).toBe(30); + }, + ); + + it("reads, advertises and stores its variant with the intercepting tree's revalidate", async () => { + const page = { default: "modal-page", revalidate: 10 }; + const hit = createCurrentRouteDispatch(page, { revalidateSeconds: 3600 }); + const hitResponse = await dispatchAppPage(hit.options); + + expect(hitResponse.headers.get("x-vinext-cache")).toBe("HIT"); + expect(hitResponse.headers.get("cache-control")).toBe("s-maxage=10, stale-while-revalidate"); + + const { isrSet, response } = await dispatchMiss({ page, revalidateSeconds: 3600 }); + + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet.mock.calls[0]![2].cacheControl.revalidate).toBe(10); + }); + + it("stores a force-static intercepting tree over a route without a revalidate", async () => { + // app/feed is edge, so it has no static revalidate default of its own. + const { isrSet, response } = await dispatchMiss({ + isStaticGenerationEdgeRuntime: true, + page: { default: "modal-page", dynamic: "force-static" }, + revalidateSeconds: null, + }); + + expect(response.headers.get("x-vinext-cache")).toBe("STATIC"); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet.mock.calls[0]![2].cacheControl.revalidate).toBe(Infinity); + }); + + // In a cacheComponents build only generateStaticParams sets the + // `revalidate = false` default of a tree without a revalidate. + async function dispatchPprMiss( + interceptExports: { generateStaticParams?: () => unknown[] }, + routeHasGenerator: boolean, + ) { + const page = { default: "modal-page", ...interceptExports }; + // The intercepting tree's generators are its page's, as generated. + const resolveRouteHasAnyGenerateStaticParams = vi.fn< + NonNullable + >( + (_route, intercept) => + intercept.interceptPage === page && typeof page.generateStaticParams === "function", + ); + const result = await dispatchMiss({ + hasAnyGenerateStaticParams: routeHasGenerator, + page, + pprRuntime: appPagePprRuntime, + resolveRouteHasAnyGenerateStaticParams, + revalidateSeconds: null, + }); + return { ...result, page, resolveRouteHasAnyGenerateStaticParams }; + } + + it("keeps the revalidate = false default from the intercepting tree's generateStaticParams in a cacheComponents build", async () => { + const { isrSet, page, resolveRouteHasAnyGenerateStaticParams, response, route } = + await dispatchPprMiss({ generateStaticParams: () => [] }, false); + + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + expect(resolveRouteHasAnyGenerateStaticParams).toHaveBeenCalledWith( + route, + expect.objectContaining({ interceptPage: page }), + ); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet.mock.calls[0]![2].cacheControl.revalidate).toBe(Infinity); + }); + + it("drops the revalidate = false default from the replaced branch's generateStaticParams in a cacheComponents build", async () => { + // app/feed/@modal/page.tsx exports generateStaticParams, but the + // intercepting page that replaces it doesn't. + const { isrSet, response } = await dispatchPprMiss({}, true); + + expect(response.headers.get("x-vinext-cache")).toBeNull(); + expect(isrSet).not.toHaveBeenCalled(); + }); + + it("neither reads nor stores its variant when the intercepting tree can't be static", async () => { + const interceptPage = { default: "modal-page", dynamic: "force-dynamic" }; + const { buildPageElement, isrGet, isrSet, options, resolveRouteStaticEligible, route } = + createCurrentRouteDispatch(interceptPage); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("fresh-flight"); + expect(response.headers.get("x-vinext-cache")).toBeNull(); + expect(response.headers.get("cache-control")).toBe(neverCache); + expect(isrGet).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + expect(resolveRouteStaticEligible).toHaveBeenCalledWith( + route, + expect.objectContaining({ interceptPage }), + ); + expect(buildPageElement).toHaveBeenCalledWith( + route, + {}, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + expect.any(URLSearchParams), + expect.anything(), + expect.anything(), + ); + }); + + it("reads its variant when the intercepting tree is static but the route's own isn't", async () => { + // app/feed/@modal/page.tsx is edge, but the intercepting branch takes + // its place in the intercepting route's tree. + const { isrGet, options } = createCurrentRouteDispatch( + { default: "modal-page" }, + { isStaticGenerationEdgeRuntime: true }, + ); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("cached-flight"); + expect(response.headers.get("x-vinext-cache")).toBe("HIT"); + expect(isrGet).toHaveBeenCalledTimes(1); + }); + + it("sends the uncacheable header on an uncached render of a dynamic intercepting tree", async () => { + // Outside production the cache is never read, so only the render + // policy classifies the intercepting tree. Dev keeps its no-store header. + const { isrGet, isrSet, options } = createCurrentRouteDispatch( + { default: "modal-page", dynamic: "force-dynamic" }, + { isProduction: false }, + ); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("fresh-flight"); + expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate"); + expect(isrGet).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + }); + + // Renders a sibling-page intercept whose dynamic config is the inverse of + // the page it replaces, and records the render policy its element is + // built under. + async function renderInverseConfigIntercept( + interceptDynamic: string, + overrides: CreateDispatchOptionsOverrides, + ) { + const renders: Record[] = []; + const page = { default: "photo-page", dynamic: interceptDynamic }; + const { options } = createCurrentRouteDispatch(page, { + async buildPageElement(_route, _params, opts, searchParams, _paramAccess, buildOptions) { + renders.push({ + forceDynamicFetchDefault: getRequestContext().currentForceDynamicFetchDefault, + observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess, + requestHeader: getHeadersContext()?.headers.get("x-request-value") ?? null, + search: searchParams.toString(), + }); + return opts?.interceptSlotKey ?? "direct"; + }, + findIntercept: () => ({ + matchedParams: {}, + page, + slotKey: "__vinext_page_intercept", + sourceRouteIndex: 0, + }), + isrGet: vi.fn(async () => null), + searchParams: new URLSearchParams("q=1"), + ...overrides, + }); + const requestContext = createRequestContext({ + headersContext: { + cookies: new Map(), + headers: new Headers({ "x-request-value": "request" }), + }, + }); + const response = await runWithRequestContext(requestContext, () => dispatchAppPage(options)); + await expect(response.text()).resolves.toBe("fresh-flight"); + return renders; + } + + const staticRender = { + forceDynamicFetchDefault: false, + observePageSearchParamsAccess: false, + requestHeader: null, + search: "", + }; + const dynamicRender = { + forceDynamicFetchDefault: true, + observePageSearchParamsAccess: true, + requestHeader: "request", + search: "q=1", + }; + + it.each([ + ["in dev", { isProduction: false }], + ["in a cacheComponents build", { pprRuntime: appPagePprRuntime }], + ["for an unverified interception context", { bypassInterceptionContextCache: true }], + ])( + "renders a force-static intercepting tree over a force-dynamic page as static %s", + async (_name, overrides) => { + const renders = await renderInverseConfigIntercept("force-static", { + dynamicConfig: "force-dynamic", + revalidateSeconds: 0, + ...overrides, + }); + + expect(renders).toEqual([staticRender]); + }, + ); + + it.each([ + ["in dev", { isProduction: false }], + ["in a cacheComponents build", { pprRuntime: appPagePprRuntime }], + ["for an unverified interception context", { bypassInterceptionContextCache: true }], + ])( + "renders a force-dynamic intercepting tree over a force-static page as dynamic %s", + async (_name, overrides) => { + const renders = await renderInverseConfigIntercept("force-dynamic", { + dynamicConfig: "force-static", + revalidateSeconds: null, + ...overrides, + }); + + expect(renders).toEqual([dynamicRender]); + }, + ); + + it.each([ + ["force-dynamic", undefined, undefined], + ["force-static", "force-dynamic", 'dynamic = "force-dynamic"'], + ])( + "marks a cacheability probe's pattern from the route's own config, not a %s intercepting tree's", + async (interceptDynamic, routeDynamic, patternDynamicReason) => { + // The pattern covers every request to the route, while the + // intercepting tree renders only this one. + const context: ExecutionContextLike = { waitUntil() {} }; + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 10_000, + mode: "probe", + }; + Reflect.set(context, CACHEABILITY_REQUEST_STATE, state); + const forceDynamicFetchDefaults: boolean[] = []; + const page = { default: "photo-page", dynamic: interceptDynamic }; + const { options } = createCurrentRouteDispatch(page, { + async buildPageElement(_route, _params, opts) { + forceDynamicFetchDefaults.push(getRequestContext().currentForceDynamicFetchDefault); + return opts?.interceptSlotKey ?? "direct"; + }, + dynamicConfig: routeDynamic, + findIntercept: () => ({ + matchedParams: {}, + page, + slotKey: "__vinext_page_intercept", + sourceRouteIndex: 0, + }), + revalidateSeconds: routeDynamic === "force-dynamic" ? 0 : null, + }); + + const response = await runWithExecutionContext(context, () => + runWithRequestContext(createRequestContext(), () => dispatchAppPage(options)), + ); + await response.text(); + + expect(state.patternDynamicReason).toBe(patternDynamicReason); + expect(forceDynamicFetchDefaults).toEqual([interceptDynamic === "force-dynamic"]); + }, + ); + + it.each([ + ["its own", 5, "5"], + // Without one, the configured staleTimes.dynamic applies. + ["the configured", undefined, "0"], + ])( + "advertises %s dynamic stale time for a sibling-page intercept, not the replaced page's", + async (_name, interceptStaleTime, header) => { + // app/feed/page.tsx sets unstable_dynamicStaleTime = 300, but the + // intercepting page replaces it. + const page = { + default: "photo-page", + dynamic: "force-dynamic", + unstable_dynamicStaleTime: interceptStaleTime, + }; + const { options } = createCurrentRouteDispatch(page, { + dynamicConfig: "force-dynamic", + dynamicStaleTimeSeconds: 300, + findIntercept: () => ({ + matchedParams: {}, + page, + slotKey: "__vinext_page_intercept", + sourceRouteIndex: 0, + }), + }); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("fresh-flight"); + expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBe(header); + }, + ); + + it("classifies the intercepting tree with the owner's default it loads", async () => { + const feedDefault = { default: "feed-default", dynamic: "force-static" }; + const __loadOwnerDefault = vi.fn(async () => feedDefault); + // The manifest's intercept is one object, which keeps what it loads. + const intercept = { + __loadOwnerDefault, + matchedParams: {}, + ownerDefault: null, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 0, + }; + const { options, resolveRouteStaticEligible, route } = createCurrentRouteDispatch( + { default: "modal-page" }, + { findIntercept: () => intercept }, + ); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("cached-flight"); + expect(__loadOwnerDefault).toHaveBeenCalledTimes(1); + expect(resolveRouteStaticEligible).toHaveBeenCalledWith( + route, + expect.objectContaining({ interceptOwnerDefault: feedDefault }), + ); + }); + + function createUnattachedInterceptDispatch(overrides: CreateDispatchOptionsOverrides = {}) { + // A route-group variant of app/feed without @modal is the source, so it + // renders unchanged and the unused intercepting tree, whose modules + // throw at the top level here, must not be evaluated. + const route = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] }); + const loaders = { + __loadInterceptLayouts: [ + vi.fn(async () => { + throw new Error("the intercepting layout must not be evaluated"); + }), + ], + __loadNotFound: vi.fn(async () => { + throw new Error("the intercepting not-found must not be evaluated"); + }), + __loadOwnerDefault: vi.fn(async () => { + throw new Error("app/feed/default.tsx must not be evaluated"); + }), + __pageLoader: vi.fn(async () => { + throw new Error("the intercepting page must not be evaluated"); + }), + }; + const dispatch = createCurrentRouteDispatch( + { default: "modal-page" }, + { + findIntercept: () => ({ + ...loaders, + interceptLayouts: [null], + matchedParams: {}, + notFound: null, + ownerDefault: null, + page: null, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 0, + }), + getSourceRoute: () => route, + route, + ...overrides, + }, + ); + return { ...dispatch, loaders, route }; + } + + function expectNoInterceptLoads( + loaders: ReturnType["loaders"], + ) { + expect(loaders.__pageLoader).not.toHaveBeenCalled(); + expect(loaders.__loadNotFound).not.toHaveBeenCalled(); + expect(loaders.__loadOwnerDefault).not.toHaveBeenCalled(); + expect(loaders.__loadInterceptLayouts[0]).not.toHaveBeenCalled(); + } + + it("serves a cached variant without loading a slot intercept the route has no slot for", async () => { + const { isrGet, loaders, options, resolveRouteStaticEligible, route } = + createUnattachedInterceptDispatch(); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("cached-flight"); + expect(response.headers.get("x-vinext-cache")).toBe("HIT"); + expect(isrGet).toHaveBeenCalledTimes(1); + expectNoInterceptLoads(loaders); + expect(resolveRouteStaticEligible).toHaveBeenCalledWith( + route, + expect.objectContaining({ interceptPage: null, interceptSlotKey: "modal@app/feed/@modal" }), + ); + }); + + it("regenerates a stale variant without loading a slot intercept the route has no slot for", async () => { + const staleEntry = buildISRCacheEntry( + buildCachedAppPageValue( + "", + new TextEncoder().encode("cached-flight").buffer, + undefined, + buildQueryInvariantRenderObservation(), + ), + true, + ); + let regeneration: Promise | undefined; + const { buildPageElement, loaders, options } = createUnattachedInterceptDispatch({ + isrGet: vi.fn(async () => staleEntry), + scheduleBackgroundRegeneration: vi.fn((_key, renderFn) => { + regeneration = renderFn(); + }), + }); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("cached-flight"); + expect(response.headers.get("x-vinext-cache")).toBe("STALE"); + // Only the rerender target's module loading matters here, not the write. + await regeneration?.catch(() => {}); + expect(buildPageElement).toHaveBeenCalledTimes(1); + expectNoInterceptLoads(loaders); + }); + + it("renders a cache miss without loading a slot intercept the route has no slot for", async () => { + const isrGet = vi.fn(async () => null); + const { buildPageElement, loaders, options } = createUnattachedInterceptDispatch({ isrGet }); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("fresh-flight"); + expect(isrGet).toHaveBeenCalledTimes(1); + expect(buildPageElement).toHaveBeenCalledTimes(1); + expectNoInterceptLoads(loaders); + }); + + function createGeneratedParamsDispatch(overrides: CreateDispatchOptionsOverrides = {}) { + // /feed/[slug] renders the (.)feed/[slug]/[id] interception in its own + // @modal slot. app/feed/[slug]/layout.tsx exports dynamicParams = false + // and generateStaticParams, so the intercepting tree keeps both. + const generateStaticParams = vi.fn(async () => [{ slug: "known" }]); + const route = createRoute({ + isDynamic: true, + params: ["slug"], + pattern: "/feed/:slug", + routeSegments: ["feed", "[slug]"], + slots: { "modal@app/feed/[slug]/@modal": {} }, + }); + const dispatch = createCurrentRouteDispatch( + { default: "modal-page" }, + { + cleanPathname: "/feed/unknown", + dynamicParamsConfig: false, + findIntercept: () => ({ + matchedParams: { slug: "unknown" }, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/[slug]/@modal", + sourceRouteIndex: 0, + }), + generateStaticParams, + getSourceRoute: () => route, + params: { slug: "unknown" }, + resolveRouteDynamicParamsConfig: () => false, + resolveRouteGenerateStaticParams: () => generateStaticParams, + route, + ...overrides, + }, + ); + return { ...dispatch, generateStaticParams, route }; + } + + it("serves a cached variant without generating the route's static params", async () => { + // Like any other request, generated params are checked only on a miss. + const { generateStaticParams, isrGet, options } = createGeneratedParamsDispatch(); + + const response = await dispatchAppPage(options); + + await expect(response.text()).resolves.toBe("cached-flight"); + expect(response.headers.get("x-vinext-cache")).toBe("HIT"); + expect(isrGet).toHaveBeenCalledTimes(1); + expect(generateStaticParams).not.toHaveBeenCalled(); + }); + + it("404s a generated-params miss once the cache misses", async () => { + const isrGet = vi.fn(async () => null); + const { buildPageElement, generateStaticParams, isrSet, options } = + createGeneratedParamsDispatch({ isrGet }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(404); + expect(isrGet).toHaveBeenCalledTimes(1); + expect(generateStaticParams).toHaveBeenCalledTimes(1); + expect(buildPageElement).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + }); + + it.each([ + ["rendered", true], + ["plain", false], + ])( + "sends the never-cache header on a %s generated-params miss of a dynamic intercepting tree", + async (_kind, rendersNotFound) => { + // The intercepting tree skipped the cache, so its 404 is never-cache + // like its normal render, even though the route's own tree is static. + // Its revalidate = 0 keeps the params gate, which a force-dynamic tree + // skips in production. + const interceptPage = { default: "modal-page", revalidate: 0 }; + const { buildPageElement, generateStaticParams, isrGet, isrSet, options } = + createGeneratedParamsDispatch({ + findIntercept: () => ({ + matchedParams: { slug: "unknown" }, + page: interceptPage, + slotKey: "modal@app/feed/[slug]/@modal", + sourceRouteIndex: 0, + }), + resolveRouteStaticEligible: (_route, intercept) => intercept === undefined, + }); + if (rendersNotFound) { + options.renderHttpAccessFallbackPage = vi.fn( + async () => new Response("not-found", { status: 404 }), + ); + } + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(404); + expect(response.headers.get("cache-control")).toBe(neverCache); + expect(isrGet).not.toHaveBeenCalled(); + expect(generateStaticParams).toHaveBeenCalledTimes(1); + expect(buildPageElement).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ["in dev", { isProduction: false }, "no-store, must-revalidate"], + [ + "when the interception cache is bypassed", + { bypassInterceptionContextCache: true }, + neverCache, + ], + ])( + "sends the never-cache header on a generated-params miss of a dynamic intercepting tree %s", + async (_kind, readSkippingOverrides, cacheControl) => { + // No cache read classifies the intercepting tree here, so the miss must + // load and classify it before responding, like its normal render. Its + // revalidate = 0 keeps the params gate, which a force-dynamic tree + // skips in production. + const interceptPage = { default: "modal-page", revalidate: 0 }; + const __pageLoader = vi.fn(async () => interceptPage); + const { buildPageElement, generateStaticParams, isrGet, isrSet, options } = + createGeneratedParamsDispatch({ + findIntercept: () => ({ + __pageLoader, + matchedParams: { slug: "unknown" }, + page: null, + slotKey: "modal@app/feed/[slug]/@modal", + sourceRouteIndex: 0, + }), + resolveRouteStaticEligible: (_route, intercept) => intercept === undefined, + ...readSkippingOverrides, + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(404); + expect(response.headers.get("cache-control")).toBe(cacheControl); + expect(__pageLoader).toHaveBeenCalledTimes(1); + expect(isrGet).not.toHaveBeenCalled(); + expect(generateStaticParams).toHaveBeenCalledTimes(1); + expect(buildPageElement).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + }, + ); + + it("404s a generated-params miss in dev without loading a slot intercept the route has no slot for", async () => { + const generateStaticParams = vi.fn(async () => [{ slug: "known" }]); + const { buildPageElement, isrGet, loaders, options, route } = + createUnattachedInterceptDispatch({ + cleanPathname: "/feed/unknown", + dynamicParamsConfig: false, + generateStaticParams, + isProduction: false, + params: { slug: "unknown" }, + // An unattached intercept leaves the route's own tree, as generated. + resolveRouteDynamicParamsConfig: () => false, + resolveRouteGenerateStaticParams: () => generateStaticParams, + }); + // The route-group variant is dynamic too: app/(group)/feed/[slug]. + route.isDynamic = true; + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(404); + expect(isrGet).not.toHaveBeenCalled(); + expect(generateStaticParams).toHaveBeenCalledTimes(1); + expect(buildPageElement).not.toHaveBeenCalled(); + expectNoInterceptLoads(loaders); + }); + + // app/feed/[slug]/@modal/(.)[slug]/page.tsx intercepts /feed/[slug] from + // itself, in place of app/feed/[slug]/@modal/default.tsx, and the + // intercepting tree drops app/feed/[slug]/page.tsx. Only the intercepting + // tree's own segments set its dynamicParams and generators, as generated. + type InterceptPage = { + default: string; + dynamicParams?: boolean; + generateStaticParams?: () => Promise[]>; + }; + function dispatchInterceptedGeneratedParams( + interceptPage: InterceptPage, + slug: string, + overrides: CreateDispatchOptionsOverrides = {}, + ) { + const isrGet = vi.fn(async () => null); + const resolveRouteDynamicParamsConfig = vi.fn< + NonNullable + >((_route, intercept) => (intercept.interceptPage as InterceptPage).dynamicParams); + const resolveRouteGenerateStaticParams = vi.fn< + NonNullable + >((_route, intercept) => { + const generator = (intercept.interceptPage as InterceptPage).generateStaticParams; + return generator ? [generator] : []; + }); + const dispatch = createGeneratedParamsDispatch({ + cleanPathname: `/feed/${slug}`, + findIntercept: () => ({ + matchedParams: { slug }, + page: interceptPage, + slotKey: "modal@app/feed/[slug]/@modal", + sourceRouteIndex: 0, + }), + isrGet, + params: { slug }, + resolveRouteDynamicParamsConfig, + resolveRouteGenerateStaticParams, + ...overrides, + }); + return { + ...dispatch, + isrGet, + resolveRouteDynamicParamsConfig, + resolveRouteGenerateStaticParams, + response: dispatchAppPage(dispatch.options), + }; + } + + it("404s a param the intercepting tree's dynamicParams = false doesn't generate over a route that allows it", async () => { + const interceptGenerator = vi.fn(async () => [{ slug: "known" }]); + const interceptPage = { + default: "modal-page", + dynamicParams: false, + generateStaticParams: interceptGenerator, + }; + // app/feed/[slug]/page.tsx allows any slug and has no generator. + const routeConfig = { dynamicParamsConfig: undefined, generateStaticParams: undefined }; + const miss = dispatchInterceptedGeneratedParams(interceptPage, "unknown", routeConfig); + const response = await miss.response; + + expect(response.status).toBe(404); + expect(miss.isrGet).toHaveBeenCalledTimes(1); + expect(miss.resolveRouteDynamicParamsConfig).toHaveBeenCalledWith( + miss.route, + expect.objectContaining({ interceptPage }), + ); + expect(miss.resolveRouteGenerateStaticParams).toHaveBeenCalledWith( + miss.route, + expect.objectContaining({ interceptPage }), + ); + expect(interceptGenerator).toHaveBeenCalledTimes(1); + expect(miss.buildPageElement).not.toHaveBeenCalled(); + + const known = dispatchInterceptedGeneratedParams(interceptPage, "known", routeConfig); + const knownResponse = await known.response; + + expect(knownResponse.status).toBe(200); + await expect(knownResponse.text()).resolves.toBe("fresh-flight"); + }); + + it("renders any param under an intercepting tree that allows it over a dynamicParams = false route", async () => { + // app/feed/[slug]/page.tsx exports dynamicParams = false and generates + // only "known", but the intercepting tree drops it. + const { buildPageElement, generateStaticParams, response } = + dispatchInterceptedGeneratedParams({ default: "modal-page" }, "other"); + + const rendered = await response; + expect(rendered.status).toBe(200); + await expect(rendered.text()).resolves.toBe("fresh-flight"); + expect(generateStaticParams).not.toHaveBeenCalled(); + expect(buildPageElement).toHaveBeenCalledTimes(1); + }); + + it("checks the intercepting tree's generators, not the replaced branch's, over a dynamicParams = false route", async () => { + const interceptGenerator = vi.fn(async () => [{ slug: "other" }]); + const interceptPage = { + default: "modal-page", + dynamicParams: false, + generateStaticParams: interceptGenerator, + }; + // The route's generators allow only "known"; the intercepting tree's + // allow only "other". + const other = dispatchInterceptedGeneratedParams(interceptPage, "other"); + const otherResponse = await other.response; + + expect(otherResponse.status).toBe(200); + await expect(otherResponse.text()).resolves.toBe("fresh-flight"); + expect(interceptGenerator).toHaveBeenCalledTimes(1); + expect(other.generateStaticParams).not.toHaveBeenCalled(); + + const known = dispatchInterceptedGeneratedParams(interceptPage, "known"); + const knownResponse = await known.response; + + expect(knownResponse.status).toBe(404); + expect(known.generateStaticParams).not.toHaveBeenCalled(); + expect(known.buildPageElement).not.toHaveBeenCalled(); + }); + + it("checks the params the intercepting tree renders with", async () => { + // app/feed/[slug]/@modal/(.)[photo]/page.tsx names the param photo. + const interceptGenerator = vi.fn(async () => [{ photo: "known" }]); + const interceptPage = { + default: "modal-page", + dynamicParams: false, + generateStaticParams: interceptGenerator, + }; + const { options } = createGeneratedParamsDispatch({ + cleanPathname: "/feed/unknown", + findIntercept: () => ({ + matchedParams: { photo: "unknown", slug: "unknown" }, + page: interceptPage, + slotKey: "modal@app/feed/[slug]/@modal", + sourceRouteIndex: 0, + }), + isrGet: vi.fn(async () => null), + resolveRouteDynamicParamsConfig: () => false, + resolveRouteGenerateStaticParams: () => [interceptGenerator], + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(404); + expect(interceptGenerator).toHaveBeenCalledTimes(1); + }); + + it("checks the intercepting branch's params alongside a prerendered source's", async () => { + // A prerendered /feed/known renders app/feed/[slug]/@modal/(.)[photo], + // whose generator lists only the known photo. + const interceptGenerator = vi.fn(async () => [{ photo: "known", slug: "known" }]); + const interceptPage = { + default: "modal-page", + dynamicParams: false, + generateStaticParams: interceptGenerator, + }; + const dispatch = (photo: string) => { + const { options } = createGeneratedParamsDispatch({ + cleanPathname: "/feed/known", + findIntercept: () => ({ + interceptBranchSegments: ["(.)[photo]"], + matchedParams: { photo, slug: "known" }, + page: interceptPage, + slotKey: "modal@app/feed/[slug]/@modal", + sourceRouteIndex: 0, + }), + isrGet: vi.fn(async () => null), + params: { slug: "known" }, + resolveRouteGenerateStaticParams: () => [interceptGenerator], + }); + return dispatchAppPage({ ...options, staticParamsValidationParams: { slug: "known" } }); + }; + + expect((await dispatch("unknown")).status).toBe(404); + const known = await dispatch("known"); + expect(known.status).toBe(200); + await known.text(); + }); + + // A cache miss of the static /feed rendering its @modal intercept with the + // given branch, params and generators, under dynamicParams = false. + async function dispatchStaticSourceIntercept( + interceptBranchSegments: string[], + matchedParams: Record, + generateStaticParams: () => Promise[]>, + ) { + const interceptPage = { default: "modal-page", dynamicParams: false, generateStaticParams }; + const { buildPageElement, options } = createCurrentRouteDispatch(interceptPage, { + findIntercept: () => ({ + interceptBranchSegments, + matchedParams, + page: interceptPage, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 0, + }), + isrGet: vi.fn(async () => null), + resolveRouteDynamicParamsConfig: () => false, + resolveRouteGenerateStaticParams: () => [generateStaticParams], + }); + const response = await dispatchAppPage(options); + return { buildPageElement, response }; + } + + it("gates the params of an intercepting tree's own dynamic segments under a static route", async () => { + // app/feed/page.tsx is static, but app/feed/@modal/(.)photo/[id] adds + // [id] to the intercepting route's tree. + const generator = vi.fn(async () => [{ id: "known" }]); + const unknown = await dispatchStaticSourceIntercept( + ["photo", "[id]"], + { id: "unknown" }, + generator, + ); + + expect(unknown.response.status).toBe(404); + expect(generator).toHaveBeenCalledTimes(1); + expect(unknown.buildPageElement).not.toHaveBeenCalled(); + + const known = await dispatchStaticSourceIntercept( + ["photo", "[id]"], + { id: "known" }, + generator, + ); + + expect(known.response.status).toBe(200); + await expect(known.response.text()).resolves.toBe("fresh-flight"); + }); + + it("gates an intercepting tree's omitted optional catch-all by an explicit empty value", async () => { + // app/feed/@modal/(.)[[...photo]] matches without a photo param, a path + // Next.js generates only from an explicit empty value. + const known = await dispatchStaticSourceIntercept(["[[...photo]]"], {}, async () => [ + { photo: ["known"] }, + ]); + + expect(known.response.status).toBe(404); + expect(known.buildPageElement).not.toHaveBeenCalled(); + + const empty = await dispatchStaticSourceIntercept(["[[...photo]]"], {}, async () => [ + { photo: [] }, + ]); + + expect(empty.response.status).toBe(200); + await expect(empty.response.text()).resolves.toBe("fresh-flight"); + }); + + it("gates an intercepting tree's marker-prefixed dynamic segments", async () => { + // The route graph keeps the markers of app/feed/@modal/(.)[id] and + // app/feed/@modal/(.)[[...photo]]. + const dynamic = await dispatchStaticSourceIntercept( + ["(.)[id]"], + { id: "unknown" }, + async () => [{ id: "known" }], + ); + + expect(dynamic.response.status).toBe(404); + expect(dynamic.buildPageElement).not.toHaveBeenCalled(); + + const omitted = await dispatchStaticSourceIntercept(["(.)[[...photo]]"], {}, async () => [ + { photo: ["known"] }, + ]); + + expect(omitted.response.status).toBe(404); + expect(omitted.buildPageElement).not.toHaveBeenCalled(); + }); + + it("renders a generated-params miss of an intercepting tree through that tree's not-found", async () => { + // app/feed/[slug]/@modal/(..)[slug]/not-found.tsx is the intercepting + // tree's own not-found boundary. + const interceptNotFound = { default: "modal-not-found" }; + const interceptPage = { + default: "modal-page", + dynamicParams: false, + generateStaticParams: vi.fn(async () => [{ slug: "known" }]), + }; + const renderHttpAccessFallbackPage = vi.fn( + async () => new Response("modal-not-found", { status: 404 }), + ); + const { buildPageElement, options } = createGeneratedParamsDispatch({ + findIntercept: () => ({ + interceptBranchSegments: ["(..)[slug]"], + matchedParams: { slug: "unknown" }, + notFound: interceptNotFound, + notFoundTreePosition: 1, + page: interceptPage, + slotKey: "modal@app/feed/[slug]/@modal", + sourceRouteIndex: 0, + }), + isrGet: vi.fn(async () => null), + resolveRouteDynamicParamsConfig: () => false, + resolveRouteGenerateStaticParams: () => [interceptPage.generateStaticParams], + }); + options.renderHttpAccessFallbackPage = renderHttpAccessFallbackPage; + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(404); + await expect(response.text()).resolves.toBe("modal-not-found"); + expect(renderHttpAccessFallbackPage).toHaveBeenCalledTimes(1); + expect(renderHttpAccessFallbackPage).toHaveBeenCalledWith( + 404, + { + intercept: expect.objectContaining({ + interceptBranchSegments: ["(..)[slug]"], + interceptNotFound, + interceptNotFoundTreePosition: 1, + interceptPage, + interceptParams: { slug: "unknown" }, + interceptSlotKey: "modal@app/feed/[slug]/@modal", + }), + matchedParams: { slug: "unknown" }, + }, + options.middlewareContext, + ); + expect(buildPageElement).not.toHaveBeenCalled(); + }); + + it("keeps a static intercepting tree's params gate under a force-dynamic active sibling in production", async () => { + // app/feed/[slug]/page.tsx, which vinext renders beside the intercept + // where Next.js's intercepting route has the owner's default, exports + // dynamic = "force-dynamic". That makes the render dynamic, but the + // intercepting route's own tree is not force-dynamic, so its prerender + // manifest entry still has the NOT_FOUND fallback. + const interceptGenerator = vi.fn(async () => [{ slug: "known" }]); + const interceptPage = { + default: "modal-page", + dynamicParams: false, + generateStaticParams: interceptGenerator, + }; + const resolveRouteInterceptTreeDynamicConfig = vi.fn< + NonNullable + >(() => null); + const miss = dispatchInterceptedGeneratedParams(interceptPage, "unknown", { + resolveRouteDynamicConfig: () => "force-dynamic", + resolveRouteInterceptTreeDynamicConfig, + }); + const response = await miss.response; + + expect(response.status).toBe(404); + expect(miss.isrGet).not.toHaveBeenCalled(); + expect(resolveRouteInterceptTreeDynamicConfig).toHaveBeenCalledWith( + miss.route, + expect.objectContaining({ interceptPage }), + ); + expect(interceptGenerator).toHaveBeenCalledTimes(1); + expect(miss.buildPageElement).not.toHaveBeenCalled(); + }); + + it("skips the params gate of a force-dynamic intercepting tree in production", async () => { + // Next.js's production build leaves the intercepting route out of its + // prerender manifest when its own tree is force-dynamic. + const interceptGenerator = vi.fn(async () => [{ slug: "known" }]); + const interceptPage = { + default: "modal-page", + dynamicParams: false, + generateStaticParams: interceptGenerator, + }; + const miss = dispatchInterceptedGeneratedParams(interceptPage, "unknown", { + resolveRouteDynamicConfig: () => "force-dynamic", + resolveRouteInterceptTreeDynamicConfig: () => "force-dynamic", + }); + const response = await miss.response; + + expect(response.status).toBe(200); + await expect(response.text()).resolves.toBe("fresh-flight"); + expect(interceptGenerator).not.toHaveBeenCalled(); + }); + }); + + it("fresh-renders mounted-slot intercepted RSC requests without persistent cache reuse", async () => { + const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const staleRscData = new TextEncoder().encode("stale-flight").buffer; + const buildPageElement = vi.fn( + async ( + route: TestRoute, + params: Record, + opts: Parameters[2], + searchParams: URLSearchParams, + ) => + JSON.stringify({ + params, + route: route.pattern, + search: searchParams.toString(), + slot: opts?.interceptSlotKey ?? "direct", + }), + ); + let scheduledRender: unknown = null; + const scheduleBackgroundRegeneration: DispatchOptions["scheduleBackgroundRegeneration"] = ( + _key, + renderFn, + ) => { + scheduledRender = renderFn; + }; + const resolveRouteFetchCacheMode = vi.fn((route: TestRoute) => + route === sourceRoute ? "force-cache" : null, + ); + const resolveRouteRevalidateSeconds = vi.fn((route: TestRoute) => + route === sourceRoute ? 30 : null, + ); + const { options } = createDispatchOptions({ + buildPageElement, + cleanPathname: "/photos/123", + findIntercept: () => ({ + matchedParams: { id: "123" }, + page: { default: "modal-page" }, + slotId: "slot:modal:/feed", + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }), + getSourceRoute(sourceRouteIndex) { + return sourceRouteIndex === 1 ? sourceRoute : undefined; + }, + interceptionContext: "/feed", + isProduction: true, + isRscRequest: true, + isrGet: vi.fn(async () => + buildISRCacheEntry( + buildCachedAppPageValue( + "", + staleRscData, + undefined, + buildQueryInvariantRenderObservation(), + ), + true, + ), + ), + isrRscKey(pathname, mountedSlotsHeader, _renderMode, interceptionContext) { + return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}:${interceptionContext ?? "none"}`; + }, + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) { + if (captureOptions?.capturedRscDataRef) { + captureOptions.capturedRscDataRef.value = Promise.resolve( + new TextEncoder().encode("fresh-intercepted-flight").buffer, + ); + } + void captureOptions?.sideStream?.cancel().catch(() => {}); + return createStream(["fresh"]); + }, + }), + mountedSlotsHeader: "slot:modal:/feed", + revalidateSeconds: 60, + resolveRouteFetchCacheMode, + resolveRouteRevalidateSeconds, + route: currentRoute, + scheduleBackgroundRegeneration, + searchParams: new URLSearchParams("tab=popular"), + }); + + const response = await dispatchAppPage(options); + + expect(response.headers.get("x-vinext-cache")).toBeNull(); + await expect(response.text()).resolves.toBe("flight"); + expect(scheduledRender).toBeNull(); + + const [routeArg, paramsArg, optsArg, searchParamsArg] = buildPageElement.mock.calls[0]; + expect(resolveRouteFetchCacheMode).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); + expect(resolveRouteRevalidateSeconds).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); + expect(routeArg).toBe(sourceRoute); + expect(paramsArg).toEqual({}); + expect(searchParamsArg.toString()).toBe("tab=popular"); + expect(optsArg).toMatchObject({ + interceptionContext: "/feed", + interceptParams: { id: "123" }, + interceptSlotId: "slot:modal:/feed", + interceptSlotKey: "modal@app/feed/@modal", + interceptSourceMatchedUrl: "/feed", + }); + expect(options.isrGet).not.toHaveBeenCalled(); + expect(options.isrSet).not.toHaveBeenCalled(); + }); + + it("fails regenerating a stale intercepted entry whose source route turned revalidate = 0", async () => { + // app/feed/page.tsx now sets dynamic = "force-dynamic" (revalidate 0), + // while the matched /photos/[id] route keeps revalidate = 60. + const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + let scheduledRender: unknown = null; + const { options } = createDispatchOptions({ + async buildPageElement(route) { + return route.pattern; + }, + cleanPathname: "/photos/123", + findIntercept: () => ({ + matchedParams: { id: "123" }, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }), + getSourceRoute(sourceRouteIndex) { + return sourceRouteIndex === 1 ? sourceRoute : undefined; + }, + interceptionContext: "/feed", + isProduction: true, + isRscRequest: true, + isrGet: vi.fn(async () => + buildISRCacheEntry( + buildCachedAppPageValue( + "", + new TextEncoder().encode("stale-flight").buffer, + undefined, + buildQueryInvariantRenderObservation(), + ), + true, + ), + ), + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) { + if (captureOptions?.capturedRscDataRef) { + captureOptions.capturedRscDataRef.value = Promise.resolve( + new TextEncoder().encode("fresh-flight").buffer, + ); + } + void captureOptions?.sideStream?.cancel().catch(() => {}); + return createStream(["fresh"]); + }, + }), + renderToReadableStream: () => createStream(["fresh-flight"]), + resolveRouteRevalidateSeconds: (route) => (route === sourceRoute ? 0 : null), + revalidateSeconds: 60, + route: currentRoute, + scheduleBackgroundRegeneration(_key, renderFn) { + scheduledRender = renderFn; + }, + }); + + const response = await dispatchAppPage(options); + await response.text(); + if (typeof scheduledRender !== "function") { + throw new Error("expected the stale entry to schedule regeneration"); + } + + await expect(scheduledRender()).rejects.toThrow( + "Page changed from static to dynamic at runtime /photos/123", + ); + expect(options.isrSet).not.toHaveBeenCalled(); + }); + + it("bypasses shared caches for an unverified interception context", async () => { + const isrGet = vi.fn(async () => + buildISRCacheEntry( + buildCachedAppPageValue( + "", + new TextEncoder().encode("cached-flight").buffer, undefined, buildQueryInvariantRenderObservation(), ), @@ -2669,57 +4424,469 @@ describe("app page dispatch", () => { }, }); - expect(response.status).toBe(200); - expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(sourceRoute); - }); + expect(response.status).toBe(200); + expect(resolveRouteDynamicConfig).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); + }); + + it("does not leak the current route's force-dynamic config into the intercept source route", async () => { + // When the current route is force-dynamic but the intercepted source route is not, + // the dispatch must resolve the source route's dynamic config so that fetch + // defaults do NOT leak from the current route into the source route. + const sourceRoute = createRoute({ + params: [], + pattern: "/feed", + routeSegments: ["feed"], + }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + layouts: [{ default: () => null, dynamic: "force-dynamic" }], + }); + + const resolveRouteDynamicConfig = vi.fn((route: TestRoute) => + route === currentRoute ? "force-dynamic" : undefined, + ); + + const { options } = createDispatchOptions({ + async buildPageElement(route, params, opts) { + return `${route.pattern}:${JSON.stringify(params)}:${opts?.interceptSlotKey ?? "direct"}`; + }, + dynamicConfig: "force-dynamic", + isRscRequest: true, + route: currentRoute, + resolveRouteDynamicConfig, + }); + + const response = await dispatchAppPage({ + ...options, + findIntercept() { + return { + matchedParams: { id: "123" }, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }; + }, + getSourceRoute(sourceRouteIndex) { + return sourceRouteIndex === 1 ? sourceRoute : undefined; + }, + }); + + expect(response.status).toBe(200); + expect(resolveRouteDynamicConfig).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); + }); + + describe("intercepted RSC of a known-dynamic source or current route", () => { + const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const knownDynamicConfigs = [ + { dynamicConfig: "force-dynamic", revalidateSeconds: 0 }, + { dynamicConfig: undefined, revalidateSeconds: 0 }, + ]; + + function dispatchIntercept(overrides: CreateDispatchOptionsOverrides) { + // Start from a request headers context, not one an earlier test left. + setHeadersContext(null); + const { options } = createDispatchOptions({ + async buildPageElement(route) { + return route.pattern; + }, + cleanPathname: "/photos/123", + findIntercept: () => ({ + matchedParams: { id: "123" }, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }), + getSourceRoute(sourceRouteIndex) { + return sourceRouteIndex === 1 ? sourceRoute : undefined; + }, + isProduction: true, + isRscRequest: true, + renderToReadableStream(element) { + return createStream([typeof element === "string" ? element : "unexpected-element"]); + }, + route: currentRoute, + ...overrides, + }); + return dispatchAppPage(options); + } + + it("sends the never-cache header for a source that reads a dynamic API while probed", async () => { + // app/feed has no dynamic config, but a component its intercepted + // render includes calls headers(). + const probedSourceRoute = createRoute({ + layouts: [{ default: () => null }], + params: [], + pattern: "/feed", + routeSegments: ["feed"], + }); + const probeInterceptSource = vi.fn>( + () => { + markDynamicUsage(); + }, + ); + const response = await dispatchIntercept({ + probeInterceptSource, + getSourceRoute: (index) => (index === 1 ? probedSourceRoute : undefined), + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(probeInterceptSource).toHaveBeenCalledWith( + probedSourceRoute, + {}, + expect.any(URLSearchParams), + ); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + it("waits for the source probes before choosing its headers", async () => { + const response = await dispatchIntercept({ + async probeInterceptSource() { + await new Promise((resolve) => setTimeout(resolve, 0)); + markDynamicUsage(); + }, + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + // The source's own tree includes app/feed/@modal/page.tsx, which the + // intercepting page replaces in the rendered tree. + const staticInterceptPage = { default: "modal-page" }; + const interceptStaticPage = () => ({ + matchedParams: { id: "123" }, + page: staticInterceptPage, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }); + + it("reads revalidate from the tree with the intercepting page in the replaced slot", async () => { + // app/feed/@modal/page.tsx sets revalidate = 0, but the static + // intercepting page replaces it. + const resolveRouteRevalidateSeconds = vi.fn< + NonNullable + >((route, intercept) => + route === sourceRoute && intercept?.interceptPage !== staticInterceptPage ? 0 : null, + ); + const response = await dispatchIntercept({ + findIntercept: interceptStaticPage, + pprRuntime: appPagePprRuntime, + resolveRouteRevalidateSeconds, + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(resolveRouteRevalidateSeconds).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ + interceptPage: staticInterceptPage, + interceptSlotKey: "modal@app/feed/@modal", + }), + ); + expect(response.headers.get("cache-control")).toBeNull(); + }); + + it("reads fetchCache from the tree with the intercepting page in the replaced slot", async () => { + // app/feed/@modal/page.tsx sets fetchCache = "force-no-store", but the + // intercepting page replaces it and sets "force-cache". + const fetchCacheModes: unknown[] = []; + const response = await runWithRequestContext(createRequestContext(), () => + dispatchIntercept({ + async buildPageElement(route) { + fetchCacheModes.push(getRequestContext().currentFetchCacheMode); + return route.pattern; + }, + findIntercept: interceptStaticPage, + resolveRouteFetchCacheMode: (route, intercept) => + route === sourceRoute && intercept?.interceptPage === staticInterceptPage + ? "force-cache" + : "force-no-store", + }), + ); + + await expect(response.text()).resolves.toBe("/feed"); + expect(fetchCacheModes).toEqual(["force-cache"]); + }); + + it("counts an intercepting page's dynamic API read when a replaced slot page is force-static", async () => { + // app/feed/@modal/page.tsx sets dynamic = "force-static", but the + // intercepting page replaces it and calls headers(). + const response = await dispatchIntercept({ + probeInterceptSource() { + markDynamicUsage(); + }, + findIntercept: interceptStaticPage, + resolveRouteDynamicConfig: (route, intercept) => + route === sourceRoute && intercept?.interceptPage !== staticInterceptPage + ? "force-static" + : undefined, + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + it("counts a dynamic API read while the source element is built", async () => { + // app/feed/page.tsx's generateMetadata (or generateViewport) calls + // headers(), which runs while the element tree is built, not in the + // layout and page probes. + const response = await dispatchIntercept({ + async buildPageElement(route) { + if (route === sourceRoute) markDynamicUsage(); + return route.pattern; + }, + probeInterceptSource() {}, + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + it("doesn't count dynamic API reads from before the source is probed", async () => { + const response = await dispatchIntercept({ + probeInterceptSource() {}, + findIntercept() { + // Stands in for anything the matched target read earlier. + markDynamicUsage(); + return { + matchedParams: { id: "123" }, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }; + }, + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(response.headers.get("cache-control")).toBeNull(); + }); + + describe("with the source probes the generated entry composes", () => { + // Like probeInterceptSource in app-rsc-entry.ts. + const probeSourceLikeEntry = + (renderMode?: AppRscRenderMode): NonNullable => + (route, params, searchParams) => + Promise.all( + buildAppPageInterceptSourceProbes({ + route, + pageComponent: undefined, + intercept: { page: { default: "modal-page" }, slotKey: "modal@app/feed/@modal" }, + sourceParams: params, + searchParams, + mountedSlotsHeader: null, + renderMode, + makeThenableParams: (value) => makeThenableParams(value as Record), + }), + ); + // Stands in for a component that calls headers() or cookies(). + function DynamicComponent(props: { children?: React.ReactNode }) { + markDynamicUsage(); + return props.children ?? null; + } + // A dynamic component that never settles. + function PendingDynamicComponent(): Promise { + markDynamicUsage(); + return new Promise(() => {}); + } + const dispatchSource = (source: Partial, renderMode?: AppRscRenderMode) => + Promise.race([ + dispatchIntercept({ + getSourceRoute: (index) => + index === 1 + ? createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"], ...source }) + : undefined, + probeInterceptSource: probeSourceLikeEntry(renderMode), + }), + new Promise<"stalled">((resolve) => setTimeout(() => resolve("stalled"), 100)), + ]); + const neverCache = "private, no-cache, no-store, max-age=0, must-revalidate"; + + it("sends the never-cache header for a source template that reads a dynamic API", async () => { + // app/feed/template.tsx + const response = await dispatchSource({ + templates: [{ default: DynamicComponent }], + templateTreePositions: [1], + }); + + expect(response).not.toBe("stalled"); + expect((response as Response).headers.get("cache-control")).toBe(neverCache); + }); + + it("sends the never-cache header for an ordinary slot layout that reads a dynamic API", async () => { + // app/feed/@sidebar/layout.tsx around app/feed/@sidebar/page.tsx + const response = await dispatchSource({ + slots: { + "modal@app/feed/@modal": { name: "modal" }, + "sidebar@app/feed/@sidebar": { + layout: { default: DynamicComponent }, + name: "sidebar", + page: { default: () => null }, + }, + }, + }); + + expect(response).not.toBe("stalled"); + expect((response as Response).headers.get("cache-control")).toBe(neverCache); + }); + + // app/feed/@sidebar/default.tsx, which the prefetch doesn't render + const slotsWithDefault = (DefaultComponent: unknown) => ({ + "modal@app/feed/@modal": { name: "modal" }, + "sidebar@app/feed/@sidebar": { default: { default: DefaultComponent }, name: "sidebar" }, + // app/feed/@team/loading.tsx gives a loading-shell prefetch its shell. + "team@app/feed/@team": { + loading: { default: () => null }, + name: "team", + page: { default: () => null }, + }, + }); + + it("sends the never-cache header for a loading-shell route loading UI that reads a dynamic API", async () => { + // app/feed/loading.tsx, which the shell renders in place of the page + const response = await dispatchSource( + { loadings: [{ default: DynamicComponent }], loadingTreePositions: [1] }, + APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + ); - it("does not leak the current route's force-dynamic config into the intercept source route", async () => { - // When the current route is force-dynamic but the intercepted source route is not, - // the dispatch must resolve the source route's dynamic config so that fetch - // defaults do NOT leak from the current route into the source route. - const sourceRoute = createRoute({ - params: [], - pattern: "/feed", - routeSegments: ["feed"], - }); - const currentRoute = createRoute({ - params: ["id"], - pattern: "/photos/[id]", - routeSegments: ["photos", "[id]"], - layouts: [{ default: () => null, dynamic: "force-dynamic" }], - }); + expect(response).not.toBe("stalled"); + expect((response as Response).headers.get("cache-control")).toBe(neverCache); + }); - const resolveRouteDynamicConfig = vi.fn((route: TestRoute) => - route === currentRoute ? "force-dynamic" : undefined, - ); + it("sends the never-cache header for a loading-shell slot loading UI that reads a dynamic API", async () => { + // app/feed/@team/loading.tsx, which the shell renders in place of the + // slot's page + const response = await dispatchSource( + { + slots: { + "modal@app/feed/@modal": { name: "modal" }, + "team@app/feed/@team": { + loading: { default: DynamicComponent }, + name: "team", + page: { default: PendingDynamicComponent }, + }, + }, + }, + APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + ); - const { options } = createDispatchOptions({ - async buildPageElement(route, params, opts) { - return `${route.pattern}:${JSON.stringify(params)}:${opts?.interceptSlotKey ?? "direct"}`; - }, - dynamicConfig: "force-dynamic", - isRscRequest: true, - route: currentRoute, - resolveRouteDynamicConfig, + expect(response).not.toBe("stalled"); + expect((response as Response).headers.get("cache-control")).toBe(neverCache); + }); + + for (const renderMode of [ + APP_RSC_RENDER_MODE_PREFETCH_EMPTY, + APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, + ] as const) { + for (const [label, DefaultComponent] of [ + ["dynamic", DynamicComponent], + ["pending dynamic", PendingDynamicComponent], + ] as const) { + it(`doesn't probe a ${label} slot default a ${renderMode} render leaves out`, async () => { + const response = await dispatchSource( + { slots: slotsWithDefault(DefaultComponent) }, + renderMode, + ); + + expect(response).not.toBe("stalled"); + expect((response as Response).headers.get("cache-control")).toBeNull(); + }); + } + } }); - const response = await dispatchAppPage({ - ...options, - findIntercept() { - return { - matchedParams: { id: "123" }, - page: { default: "modal-page" }, - slotKey: "modal@app/feed/@modal", - sourceRouteIndex: 1, + for (const config of knownDynamicConfigs) { + const label = config.dynamicConfig ?? "revalidate = 0"; + + // A standard build already classifies such a source as non-static. A + // cacheComponents build keeps every route static-eligible, so only the + // source's own dynamic config marks the response. + it(`sends the never-cache header for a ${label} source in a cacheComponents build`, async () => { + const response = await dispatchIntercept({ + pprRuntime: appPagePprRuntime, + resolveRouteDynamicConfig: (route) => + route === sourceRoute ? config.dynamicConfig : undefined, + resolveRouteRevalidateSeconds: (route) => + route === sourceRoute ? config.revalidateSeconds : null, + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + it(`sends the never-cache header for a ${label} intercepting page in a cacheComponents build`, async () => { + // app/feed/@modal/(.)photos/[id]/page.tsx sets the config, so the + // intercepting route's tree is known dynamic. + const interceptPage = { + default: "modal-page", + dynamic: config.dynamicConfig, + revalidate: config.revalidateSeconds, }; - }, - getSourceRoute(sourceRouteIndex) { - return sourceRouteIndex === 1 ? sourceRoute : undefined; - }, - }); + const response = await dispatchIntercept({ + findIntercept: () => ({ + matchedParams: { id: "123" }, + page: interceptPage, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }), + pprRuntime: appPagePprRuntime, + resolveRouteDynamicConfig: (route, intercept) => + route === sourceRoute && intercept?.interceptPage === interceptPage + ? config.dynamicConfig + : undefined, + resolveRouteRevalidateSeconds: (route, intercept) => + route === sourceRoute && intercept?.interceptPage === interceptPage + ? config.revalidateSeconds + : null, + }); - expect(response.status).toBe(200); - expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(sourceRoute); + await expect(response.text()).resolves.toBe("/feed"); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); + + for (const pprRuntime of [undefined, appPagePprRuntime]) { + const build = pprRuntime ? "a cacheComponents build" : "a standard build"; + + it(`keeps a ${label} current route's header off a static source in ${build}`, async () => { + const response = await dispatchIntercept({ + dynamicConfig: config.dynamicConfig, + pprRuntime, + resolveRouteDynamicConfig: (route) => + route === currentRoute ? config.dynamicConfig : undefined, + resolveRouteRevalidateSeconds: (route) => + route === currentRoute ? config.revalidateSeconds : null, + resolveRouteStaticEligible: (route) => route !== currentRoute, + revalidateSeconds: config.revalidateSeconds, + }); + + await expect(response.text()).resolves.toBe("/feed"); + expect(response.headers.get("cache-control")).toBeNull(); + }); + } + } }); it("passes empty searchParams to a force-static intercept source route", async () => { @@ -2776,13 +4943,18 @@ describe("app page dispatch", () => { await expect(response.text()).resolves.toBe("empty"); expect(buildPageElement.mock.calls[0]?.[3].toString()).toBe(""); + // Client pages read an empty query in the browser too. + expect(buildPageElement.mock.calls[0]?.[5]?.isForceStatic).toBe(true); expect(setNavigationContext).toHaveBeenLastCalledWith( expect.objectContaining({ searchParams: expect.any(URLSearchParams) }), ); const navigationContext = setNavigationContext.mock.calls.at(-1)?.[0]; expect(navigationContext?.searchParams.toString()).toBe(""); expect(resolveRouteDynamicConfig).toHaveBeenCalledTimes(1); - expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(sourceRoute); + expect(resolveRouteDynamicConfig).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); }); it("observes searchParams access for a dynamic-error intercept source route", async () => { @@ -2841,6 +5013,7 @@ describe("app page dispatch", () => { await expect(response.text()).resolves.toBe("popular"); expect(buildOptions).toEqual([ { + isForceStatic: false, observeMetadataSearchParamsAccess: true, observePageSearchParamsAccess: true, serveStreamingMetadata: true, @@ -2848,6 +5021,32 @@ describe("app page dispatch", () => { ]); }); + it.each([ + { dynamicConfig: "force-static", isForceStatic: true }, + { dynamicConfig: undefined, isForceStatic: false }, + ])( + "tells the page builder whether the route is force-static ($dynamicConfig)", + async ({ dynamicConfig, isForceStatic }) => { + // Client pages carry it to the browser, which keeps force-static + // searchParams empty as SSR renders them. + const buildPageElement = vi.fn(async () => "page"); + const { options } = createDispatchOptions({ + buildPageElement, + dynamicConfig, + isRscRequest: true, + renderToReadableStream(element) { + return createStream([typeof element === "string" ? element : "unexpected-element"]); + }, + searchParams: new URLSearchParams("tab=popular"), + }); + + await expect((await dispatchAppPage(options)).text()).resolves.toBe("page"); + expect(buildPageElement.mock.calls.map((call) => call[5]?.isForceStatic)).toEqual([ + isForceStatic, + ]); + }, + ); + it("preserves request headers for an ordinary intercept source route", async () => { const sourceRoute = createRoute({ params: [], pattern: "/feed", routeSegments: ["feed"] }); const currentRoute = createRoute({ @@ -3025,7 +5224,7 @@ describe("app page dispatch", () => { }); it.each(["page", "metadata"] as const)( - "records searchParams access when stale regeneration reads them in %s", + "keeps the previous entry when a stale regeneration reads searchParams in %s", async (reader) => { async function Page(props: Record): Promise { if (reader !== "page") return React.createElement("main", null, "static body"); @@ -3052,6 +5251,14 @@ describe("app page dispatch", () => { const route = createRoute({ pattern: "/regen-proof", routeSegments: ["regen-proof"] }); let scheduledRender: unknown = null; const written: CachedAppPageValue[] = []; + const staleValue = buildCachedAppPageValue( + "stale", + undefined, + undefined, + buildQueryInvariantRenderObservation(), + ); + const staleEntry = buildISRCacheEntry(staleValue, true); + staleEntry.value.cacheControl = { revalidate: 60 }; const buildPageElement = vi.fn( (_route, params, _opts, searchParams, layoutParamAccess, buildOptions) => buildPageElements({ @@ -3080,17 +5287,7 @@ describe("app page dispatch", () => { buildPageElement, cleanPathname: "/regen-proof", isProduction: true, - isrGet: vi.fn(async () => - buildISRCacheEntry( - buildCachedAppPageValue( - "stale", - undefined, - undefined, - buildQueryInvariantRenderObservation(), - ), - true, - ), - ), + isrGet: vi.fn(async () => staleEntry), isrSet: vi.fn(async (_key, value) => { written.push(value); }), @@ -3120,17 +5317,172 @@ describe("app page dispatch", () => { throw new Error("expected stale response to schedule regeneration"); } - await scheduledRender(); - - expect( - written.map( - (value) => - value.renderObservation?.requestApis.find((api) => api.kind === "searchParams")?.status, - ), - ).toEqual(["observed", "observed"]); + // Reading searchParams makes the regeneration dynamic, so it fails and + // core re-stores only the previous entry. + await expect(scheduledRender()).rejects.toThrow( + "Page changed from static to dynamic at runtime /regen-proof", + ); + expect(written).toEqual([staleValue]); }, ); + it("doesn't fail a PPR regeneration that reads headers()", async () => { + async function Page(): Promise { + const requestHeaders = await headers(); + return React.createElement("main", null, requestHeaders.get("x-test") ?? "none"); + } + const route = createRoute({ pattern: "/regen-ppr", routeSegments: ["regen-ppr"] }); + let scheduledRender: unknown = null; + const staleValue = buildCachedAppPageValue( + "stale", + undefined, + undefined, + buildQueryInvariantRenderObservation(), + ); + const buildPageElement = vi.fn( + (_route, params, _opts, searchParams, layoutParamAccess) => + buildPageElements({ + layoutParamAccess, + metadataRoutes: [], + params, + pageRequest: { + isRscRequest: false, + mountedSlotsHeader: null, + opts: undefined, + request: new Request("https://example.test/regen-ppr"), + searchParams, + }, + route: { + layouts: [], + page: { default: Page }, + pattern: "/regen-ppr", + routeSegments: ["regen-ppr"], + }, + routePath: "/regen-ppr", + }).then(toDispatchElementRecord), + ); + const { options } = createDispatchOptions({ + buildPageElement, + cleanPathname: "/regen-ppr", + isProduction: true, + isrGet: vi.fn(async () => { + const entry = buildISRCacheEntry(staleValue, true); + entry.value.cacheControl = { revalidate: 60 }; + return entry; + }), + loadSsrHandler: async () => ({ + async handleSsr(rscStream, _navigationContext, _fontData, captureOptions) { + if (captureOptions?.capturedRscDataRef) { + captureOptions.capturedRscDataRef.value = Promise.resolve( + new TextEncoder().encode("fresh-flight").buffer, + ); + } + void captureOptions?.sideStream?.cancel().catch(() => {}); + return createStream([`${await new Response(rscStream).text()}`]); + }, + }), + pprRuntime: appPagePprRuntime, + renderToReadableStream: renderPagePayloadToStream, + revalidateSeconds: 60, + route, + scheduleBackgroundRegeneration(_key, renderFn) { + scheduledRender = renderFn; + }, + }); + + const response = await dispatchAppPage(options); + await response.text(); + if (typeof scheduledRender !== "function") { + throw new Error("expected stale response to schedule regeneration"); + } + await expect(scheduledRender()).resolves.toBeUndefined(); + }); + + it("stores a force-static regeneration that reads headers() and searchParams", async () => { + async function Page(props: Record): Promise { + const requestHeaders = await headers(); + const query = isPromiseLike(props.searchParams) ? await props.searchParams : {}; + return React.createElement( + "main", + null, + `${requestHeaders.get("x-test") ?? "none"}:${isQueryRecord(query) ? String(query.q ?? "empty") : "invalid"}`, + ); + } + const route = createRoute({ pattern: "/regen-static", routeSegments: ["regen-static"] }); + let scheduledRender: unknown = null; + const writtenKeys: string[] = []; + const { options } = createDispatchOptions({ + buildPageElement: (_route, params, _opts, searchParams, layoutParamAccess, buildOptions) => + buildPageElements({ + layoutParamAccess, + metadataRoutes: [], + params, + pageRequest: { + isRscRequest: false, + mountedSlotsHeader: null, + opts: undefined, + request: new Request("https://example.test/regen-static"), + searchParams, + observeMetadataSearchParamsAccess: buildOptions?.observeMetadataSearchParamsAccess, + observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess, + }, + route: { + layouts: [], + page: { default: Page }, + pattern: "/regen-static", + routeSegments: ["regen-static"], + }, + routePath: "/regen-static", + }).then(toDispatchElementRecord), + cleanPathname: "/regen-static", + dynamicConfig: "force-static", + isProduction: true, + isrGet: vi.fn(async () => { + const entry = buildISRCacheEntry( + buildCachedAppPageValue( + "stale", + undefined, + undefined, + buildQueryInvariantRenderObservation(), + ), + true, + ); + entry.value.cacheControl = { revalidate: 60 }; + return entry; + }), + isrSet: vi.fn(async (key) => { + writtenKeys.push(key); + }), + loadSsrHandler: async () => ({ + async handleSsr(rscStream, _navigationContext, _fontData, captureOptions) { + if (captureOptions?.capturedRscDataRef) { + captureOptions.capturedRscDataRef.value = Promise.resolve( + new TextEncoder().encode("fresh-flight").buffer, + ); + } + void captureOptions?.sideStream?.cancel().catch(() => {}); + return createStream([`${await new Response(rscStream).text()}`]); + }, + }), + renderToReadableStream: renderPagePayloadToStream, + revalidateSeconds: 60, + route, + scheduleBackgroundRegeneration(_key, renderFn) { + scheduledRender = renderFn; + }, + }); + + const response = await dispatchAppPage(options); + await response.text(); + if (typeof scheduledRender !== "function") { + throw new Error("expected stale response to schedule regeneration"); + } + + // force-static makes those reads static, so they are no dynamic signal. + await scheduledRender(); + expect(writtenKeys.sort()).toEqual(["html:/regen-static", "rsc:/regen-static"]); + }); + it("does not report a stale RSC failure again after Flight recreates the error", async () => { const route = createRoute({ pattern: "/posts/[slug]", routeSegments: ["posts", "[slug]"] }); let scheduledRender: unknown = null; @@ -3256,47 +5608,440 @@ describe("app page dispatch", () => { interceptionContext: "/feed", isProduction: true, isRscRequest: true, - isrGet: vi.fn(async () => - buildISRCacheEntry( - buildCachedAppPageValue( - "", - new TextEncoder().encode("stale-flight").buffer, - undefined, - buildQueryInvariantRenderObservation(), - ), - true, - ), - ), - isrRscKey(pathname, mountedSlotsHeader, _renderMode, interceptionContext) { - return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}:${interceptionContext ?? "none"}`; + isrGet: vi.fn(async () => + buildISRCacheEntry( + buildCachedAppPageValue( + "", + new TextEncoder().encode("stale-flight").buffer, + undefined, + buildQueryInvariantRenderObservation(), + ), + true, + ), + ), + isrRscKey(pathname, mountedSlotsHeader, _renderMode, interceptionContext) { + return `rsc:${pathname}:${mountedSlotsHeader ?? "none"}:${interceptionContext ?? "none"}`; + }, + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) { + if (captureOptions?.capturedRscDataRef) { + captureOptions.capturedRscDataRef.value = Promise.resolve( + new TextEncoder().encode("fresh-flight").buffer, + ); + } + void captureOptions?.sideStream?.cancel().catch(() => {}); + return createStream(["fresh"]); + }, + }), + mountedSlotsHeader: "slot:modal:/feed", + revalidateSeconds: 60, + resolveRouteDynamicConfig, + route: currentRoute, + scheduleBackgroundRegeneration, + searchParams: new URLSearchParams("tab=popular"), + }); + + const response = await dispatchAppPage(options); + + expect(response.headers.get("x-vinext-cache")).toBeNull(); + await expect(response.text()).resolves.toBe("flight"); + expect(scheduledRender).toBeNull(); + expect(resolveRouteDynamicConfig).toHaveBeenCalledWith( + targetRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); + const [routeArg] = buildPageElement.mock.calls[0]; + expect(routeArg).toBe(targetRoute); + }); + + async function regenerateStaleInterceptedRscEntry(overrides: { + currentRoute: TestRoute; + dynamicConfig?: string; + fetchCache?: DispatchOptions["fetchCache"]; + interceptPage: unknown; + pprRuntime?: DispatchOptions["pprRuntime"]; + readsHeaders?: boolean; + resolveRouteDynamicConfig: DispatchOptions["resolveRouteDynamicConfig"]; + resolveRouteFetchCacheMode: DispatchOptions["resolveRouteFetchCacheMode"]; + resolveRouteHasAnyGenerateStaticParams?: DispatchOptions["resolveRouteHasAnyGenerateStaticParams"]; + resolveRouteRevalidateSeconds: DispatchOptions["resolveRouteRevalidateSeconds"]; + revalidateSeconds: number; + sourceRoute: TestRoute; + }) { + let scheduledRender: unknown = null; + const isrSet = vi.fn(async () => {}); + const renders: Record[] = []; + const staleValue = buildCachedAppPageValue( + "", + new TextEncoder().encode("stale-flight").buffer, + undefined, + buildQueryInvariantRenderObservation(), + ); + const staleEntry = buildISRCacheEntry(staleValue, true); + staleEntry.value.cacheControl = { revalidate: 60 }; + const { options } = createDispatchOptions({ + async buildPageElement(route, _params, _opts, _searchParams, _paramAccess, buildOptions) { + // Stands in for headers(), which force-static answers without + // marking the render dynamic. + if (overrides.readsHeaders !== false) markDynamicUsage(); + const context = getRequestContext(); + renders.push({ + dynamicUsage: consumeDynamicUsage(), + fetchCacheMode: context.currentFetchCacheMode, + fetchRevalidate: context.currentFetchRevalidate, + observePageSearchParamsAccess: buildOptions?.observePageSearchParamsAccess, + }); + return route.pattern; + }, + cleanPathname: "/photos/123", + dynamicConfig: overrides.dynamicConfig, + findIntercept: () => ({ + matchedParams: { id: "123" }, + page: overrides.interceptPage, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 1, + }), + getSourceRoute: (index) => (index === 1 ? overrides.sourceRoute : undefined), + interceptionContext: "/feed", + isProduction: true, + isRscRequest: true, + isrGet: vi.fn(async () => staleEntry), + isrRscKey(pathname, _mountedSlotsHeader, _renderMode, interceptionContext) { + return `rsc:${pathname}:${interceptionContext ?? "none"}`; }, + isrSet, loadSsrHandler: async () => ({ async handleSsr(_rscStream, _navigationContext, _fontData, captureOptions) { if (captureOptions?.capturedRscDataRef) { captureOptions.capturedRscDataRef.value = Promise.resolve( - new TextEncoder().encode("fresh-flight").buffer, + new TextEncoder().encode("regenerated-flight").buffer, ); } void captureOptions?.sideStream?.cancel().catch(() => {}); - return createStream(["fresh"]); + return createStream(["regenerated"]); }, }), - mountedSlotsHeader: "slot:modal:/feed", - revalidateSeconds: 60, - resolveRouteDynamicConfig, - route: currentRoute, - scheduleBackgroundRegeneration, - searchParams: new URLSearchParams("tab=popular"), + pprRuntime: overrides.pprRuntime, + resolveRouteDynamicConfig: overrides.resolveRouteDynamicConfig, + resolveRouteFetchCacheMode: overrides.resolveRouteFetchCacheMode, + resolveRouteHasAnyGenerateStaticParams: overrides.resolveRouteHasAnyGenerateStaticParams, + resolveRouteRevalidateSeconds: overrides.resolveRouteRevalidateSeconds, + revalidateSeconds: overrides.revalidateSeconds, + route: overrides.currentRoute, + scheduleBackgroundRegeneration(_key, renderFn) { + scheduledRender = renderFn; + }, }); + options.fetchCache = overrides.fetchCache; const response = await dispatchAppPage(options); + await expect(response.text()).resolves.toBe("stale-flight"); + if (typeof scheduledRender !== "function") { + throw new Error("expected the stale intercepted entry to schedule regeneration"); + } + const regeneration: Promise = scheduledRender(); + return { isrSet, regeneration, renders, staleValue }; + } - expect(response.headers.get("x-vinext-cache")).toBeNull(); - await expect(response.text()).resolves.toBe("flight"); - expect(scheduledRender).toBeNull(); - expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(targetRoute); - const [routeArg] = buildPageElement.mock.calls[0]; - expect(routeArg).toBe(targetRoute); + it("regenerates a stale intercepted RSC entry with its intercepted tree's config", async () => { + // app/feed/@modal/(.)photos/[id]/page.tsx sets dynamic = "force-static", + // fetchCache = "force-cache" and revalidate = 30; app/feed's own tree + // doesn't, and app/photos/[id] sets revalidate = 60. + const sourceRoute = createRoute({ pattern: "/feed", routeSegments: ["feed"] }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const interceptPage = { default: "modal-page" }; + const isInterceptTree = ( + route: TestRoute, + intercept?: Parameters>[1], + ) => route === sourceRoute && intercept?.interceptPage === interceptPage; + const { isrSet, regeneration, renders } = await regenerateStaleInterceptedRscEntry({ + currentRoute, + interceptPage, + resolveRouteDynamicConfig: (route, intercept) => + isInterceptTree(route, intercept) ? "force-static" : undefined, + resolveRouteFetchCacheMode: (route, intercept) => + isInterceptTree(route, intercept) ? "force-cache" : "force-no-store", + resolveRouteRevalidateSeconds: (route, intercept) => + isInterceptTree(route, intercept) ? 30 : null, + revalidateSeconds: 60, + sourceRoute, + }); + await regeneration; + + expect(renders).toEqual([ + { + dynamicUsage: false, + fetchCacheMode: "force-cache", + fetchRevalidate: 30, + observePageSearchParamsAccess: false, + }, + ]); + // The render collected no revalidate of its own, so the entry keeps the + // intercepted tree's, not the matched route's 60 seconds. + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledWith("rsc:/photos/123:/feed", expect.anything(), { + cacheControl: { revalidate: 30 }, + tags: expect.any(Array), + }); + }); + + // Resolves app/feed's intercepted tree like the generated entry: Next.js's + // tree for the intercepting route, merged with app/feed/@sidebar's active + // page, which vinext renders where Next.js renders its default. + function regenerateStaleInterceptWithActiveSidebar( + interceptPage: Record, + sidebarPage: Record, + ) { + const sourceRoute = createRoute({ pattern: "/feed", routeSegments: ["feed"] }); + const resolveConfig = ( + route: TestRoute, + intercept?: Parameters>[1], + ) => { + if (route !== sourceRoute || !intercept) return null; + const [interceptTree, renderedTree] = [false, true].map((keepActiveSiblings) => + resolveAppPageInterceptTree({ + interceptBranchSegments: ["(.)photos", "[id]"], + interceptPage: intercept.interceptPage as Record, + isSiblingPageIntercept: false, + keepActiveSiblings, + layouts: [{}, {}], + layoutTreePositions: [0, 1], + page: {}, + parallelBranches: [ + { isDefault: true, layout: null, name: "modal", ownerTreePosition: 1, page: {} }, + { + default: {}, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: sidebarPage, + routeSegments: [], + }, + ], + routeSegments: ["feed"], + slotIndex: 0, + }), + ); + return resolveAppPageInterceptSegmentConfig(interceptTree, renderedTree); + }; + return regenerateStaleInterceptedRscEntry({ + currentRoute: createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }), + interceptPage, + resolveRouteDynamicConfig: (route, intercept) => + resolveConfig(route, intercept)?.dynamicConfig ?? null, + resolveRouteFetchCacheMode: (route, intercept) => + resolveConfig(route, intercept)?.fetchCache ?? null, + resolveRouteRevalidateSeconds: (route, intercept) => + resolveConfig(route, intercept)?.revalidateSeconds ?? null, + revalidateSeconds: 60, + sourceRoute, + }); + } + + it("regenerates a stale intercepted RSC entry under an active sibling's shorter revalidate", async () => { + // app/feed/@modal/(.)photos/[id]/page.tsx sets dynamic = "force-static" + // and revalidate = 30; app/feed/@sidebar/page.tsx, which renders beside + // it, sets revalidate = 10. + const { isrSet, regeneration, renders } = await regenerateStaleInterceptWithActiveSidebar( + { default: "modal-page", dynamic: "force-static", revalidate: 30 }, + { default: "sidebar-page", revalidate: 10 }, + ); + await expect(regeneration).resolves.toBeUndefined(); + + expect(renders).toEqual([ + { + dynamicUsage: false, + fetchCacheMode: null, + fetchRevalidate: 10, + observePageSearchParamsAccess: false, + }, + ]); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledWith("rsc:/photos/123:/feed", expect.anything(), { + cacheControl: { revalidate: 10 }, + tags: expect.any(Array), + }); + }); + + it("regenerates a stale intercepted RSC entry with an active sibling's dynamic and fetchCache", async () => { + // app/feed/@sidebar/page.tsx sets dynamic = "force-static" and + // fetchCache = "force-no-store"; the intercepting page sets neither. + const { isrSet, regeneration, renders } = await regenerateStaleInterceptWithActiveSidebar( + { default: "modal-page" }, + { default: "sidebar-page", dynamic: "force-static", fetchCache: "force-no-store" }, + ); + await expect(regeneration).resolves.toBeUndefined(); + + expect(renders).toEqual([ + { + dynamicUsage: false, + fetchCacheMode: "force-no-store", + fetchRevalidate: null, + observePageSearchParamsAccess: false, + }, + ]); + // A force-static tree without a revalidate keeps `revalidate = false`. + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledWith("rsc:/photos/123:/feed", expect.anything(), { + cacheControl: { revalidate: Infinity }, + tags: expect.any(Array), + }); + }); + + it("regenerates an intercept on the matched route without the replaced branch's config", async () => { + // app/photos/[id]/@modal/page.tsx sets dynamic = "force-static", + // fetchCache = "force-cache" and revalidate = 60, which the matched + // route's tree takes. The intercepting page that replaces it on the same + // route sets none of them. + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const interceptPage = { default: "modal-page" }; + const { isrSet, regeneration, renders, staleValue } = await regenerateStaleInterceptedRscEntry({ + currentRoute, + dynamicConfig: "force-static", + fetchCache: "force-cache", + interceptPage, + resolveRouteDynamicConfig: (_route, intercept) => (intercept ? null : "force-static"), + resolveRouteFetchCacheMode: (_route, intercept) => (intercept ? null : "force-cache"), + resolveRouteRevalidateSeconds: (_route, intercept) => (intercept ? null : 60), + revalidateSeconds: 60, + sourceRoute: currentRoute, + }); + // Without the replaced branch's force-static, the render's headers() read + // is dynamic usage, which fails the regeneration as in Next.js. + await expect(regeneration).rejects.toThrow( + "Page changed from static to dynamic at runtime /photos/123", + ); + + expect(renders).toEqual([ + { + dynamicUsage: true, + fetchCacheMode: null, + fetchRevalidate: null, + observePageSearchParamsAccess: true, + }, + ]); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledWith("rsc:/photos/123:/feed", staleValue, { + cacheControl: { revalidate: 30 }, + tags: expect.any(Array), + }); + }); + + it("regenerates a stale intercepted RSC entry with its tree's generateStaticParams default in a cacheComponents build", async () => { + // app/feed/@modal/(.)photos/[id]/page.tsx exports generateStaticParams + // and no revalidate, so the intercepting tree keeps `revalidate = false`. + const sourceRoute = createRoute({ pattern: "/feed", routeSegments: ["feed"] }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const interceptPage = { default: "modal-page", generateStaticParams: () => [] }; + const resolveRouteHasAnyGenerateStaticParams = vi.fn< + NonNullable + >((route, intercept) => route === sourceRoute && intercept.interceptPage === interceptPage); + const { isrSet, regeneration } = await regenerateStaleInterceptedRscEntry({ + currentRoute, + interceptPage, + pprRuntime: appPagePprRuntime, + resolveRouteDynamicConfig: () => null, + resolveRouteFetchCacheMode: () => null, + resolveRouteHasAnyGenerateStaticParams, + resolveRouteRevalidateSeconds: (route, intercept) => (intercept ? null : 60), + revalidateSeconds: 60, + sourceRoute, + }); + await regeneration; + + expect(resolveRouteHasAnyGenerateStaticParams).toHaveBeenCalledWith( + sourceRoute, + expect.objectContaining({ interceptPage }), + ); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledWith("rsc:/photos/123:/feed", expect.anything(), { + cacheControl: { revalidate: Infinity }, + tags: expect.any(Array), + }); + }); + + describe("a stale intercepted entry whose intercepted tree turned dynamic", () => { + // app/feed/@modal/(.)photos/[id]/page.tsx now sets revalidate = 0 or + // dynamic = "force-dynamic" (which resolves revalidate = 0), and its + // render reads no request API. app/photos/[id] sets revalidate = 60. + const sourceRoute = createRoute({ pattern: "/feed", routeSegments: ["feed"] }); + const currentRoute = createRoute({ + params: ["id"], + pattern: "/photos/[id]", + routeSegments: ["photos", "[id]"], + }); + const interceptPage = { default: "modal-page" }; + const isInterceptTree = ( + route: TestRoute, + intercept?: Parameters>[1], + ) => route === sourceRoute && intercept?.interceptPage === interceptPage; + const regenerate = ( + dynamicConfig: string | undefined, + pprRuntime?: DispatchOptions["pprRuntime"], + ) => + regenerateStaleInterceptedRscEntry({ + currentRoute, + interceptPage, + pprRuntime, + readsHeaders: false, + resolveRouteDynamicConfig: (route, intercept) => + isInterceptTree(route, intercept) ? dynamicConfig : undefined, + resolveRouteFetchCacheMode: () => null, + resolveRouteRevalidateSeconds: (route, intercept) => + isInterceptTree(route, intercept) ? 0 : null, + revalidateSeconds: 60, + sourceRoute, + }); + + it.each([ + ["revalidate = 0", undefined], + ['dynamic = "force-dynamic"', "force-dynamic"], + ])("fails its regeneration at %s and keeps the previous entry", async (_label, config) => { + const { isrSet, regeneration, renders, staleValue } = await regenerate(config); + + // Like Next.js, an effective revalidate of 0 fails a regeneration + // without PPR even when the render used no dynamic API. + await expect(regeneration).rejects.toThrow( + "Page changed from static to dynamic at runtime /photos/123", + ); + expect(renders).toEqual([expect.objectContaining({ dynamicUsage: false })]); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledWith("rsc:/photos/123:/feed", staleValue, { + cacheControl: { revalidate: 30 }, + tags: expect.any(Array), + }); + }); + + it("stores its PPR regeneration", async () => { + const { isrSet, regeneration } = await regenerate(undefined, appPagePprRuntime); + + await expect(regeneration).resolves.toBeUndefined(); + expect(isrSet).toHaveBeenCalledTimes(1); + expect(isrSet).toHaveBeenCalledWith( + "rsc:/photos/123:/feed", + expect.objectContaining({ + rscData: new TextEncoder().encode("regenerated-flight").buffer, + }), + { cacheControl: { revalidate: 0 }, tags: expect.any(Array) }, + ); + }); }); it("does not leak the current route's force-dynamic config into the revalidation target route", async () => { @@ -3388,7 +6133,10 @@ describe("app page dispatch", () => { // the target route's dynamic config instead of inheriting the current route's. const response = await dispatchAppPage(options); expect(response.status).toBe(200); - expect(resolveRouteDynamicConfig).toHaveBeenCalledWith(targetRoute); + expect(resolveRouteDynamicConfig).toHaveBeenCalledWith( + targetRoute, + expect.objectContaining({ interceptSlotKey: "modal@app/feed/@modal" }), + ); }); it("serves exact cache HIT instead of fallback shell", async () => { @@ -3599,4 +6347,291 @@ describe("app page dispatch", () => { expect(buildPageElement).toHaveBeenCalled(); expect(response.headers.get("x-vinext-cache")).toBe("MISS"); }); + + describe("static eligibility (Next.js route classification)", () => { + const NEVER_CACHE_CONTROL = "private, no-cache, no-store, max-age=0, must-revalidate"; + + function createDynamicSegmentRoute(): TestRoute { + return createRoute({ isDynamic: true, params: ["slug"] }); + } + + async function dispatchAndDrain(options: DispatchOptions) { + const waitUntilPromises: Promise[] = []; + const response = await runWithExecutionContext( + { + waitUntil(promise) { + waitUntilPromises.push(promise); + }, + }, + () => dispatchAppPage(options), + ); + const body = await response.text(); + await Promise.all(waitUntilPromises.splice(0)); + return { body, response }; + } + + // Next.js classifies a dynamic-segment route without generateStaticParams + // as dynamic (ƒ): it renders per request and is never full-page cached, + // even with a revalidate export. + // https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/build/index.ts#L2358-L2408 + for (const isRscRequest of [false, true]) { + it(`never caches a dynamic-segment route without generateStaticParams (${isRscRequest ? "RSC" : "HTML"})`, async () => { + const isrGet = vi.fn(async () => null); + const isrSet = vi.fn(async () => {}); + const { options } = createDispatchOptions({ + isProduction: true, + isRscRequest, + isrGet, + isrSet, + revalidateSeconds: 60, + route: createDynamicSegmentRoute(), + }); + + const { response } = await dispatchAndDrain(options); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe(NEVER_CACHE_CONTROL); + expect(response.headers.get("x-vinext-cache")).toBeNull(); + expect(isrGet).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + }); + } + + it("caches a dynamic-segment route whose generateStaticParams sits at its last dynamic segment", async () => { + const isrGet = vi.fn(async () => null); + const isrSet = vi.fn(async () => {}); + const { options } = createDispatchOptions({ + hasGenerateStaticParams: true, + isProduction: true, + isrGet, + isrSet, + revalidateSeconds: 60, + route: createDynamicSegmentRoute(), + }); + + const { response } = await dispatchAndDrain(options); + + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + expect(isrGet).toHaveBeenCalledWith("html:/posts/hello"); + expect(isrSet.mock.calls.map(([key]) => key)).toContain("html:/posts/hello"); + expect(isrSet.mock.calls[0]![2].cacheControl.revalidate).toBe(60); + }); + + it("keeps the revalidate = false default from a parent generateStaticParams in a cacheComponents build", async () => { + const isrSet = vi.fn(async () => {}); + const { options } = createDispatchOptions({ + hasAnyGenerateStaticParams: true, + hasGenerateStaticParams: false, + isProduction: true, + isrSet, + pprRuntime: appPagePprRuntime, + route: createDynamicSegmentRoute(), + }); + + await dispatchAndDrain(options); + + const htmlWrite = isrSet.mock.calls.find(([key]) => key === "html:/posts/hello"); + expect(htmlWrite?.[2].cacheControl.revalidate).toBe(Infinity); + }); + + for (const dynamicConfig of ["force-static", "error"]) { + it(`caches a dynamic-segment route with dynamic = "${dynamicConfig}"`, async () => { + const isrSet = vi.fn(async () => {}); + const { options } = createDispatchOptions({ + dynamicConfig, + isProduction: true, + isrSet, + route: createDynamicSegmentRoute(), + }); + + await dispatchAndDrain(options); + + expect(isrSet.mock.calls.map(([key]) => key)).toContain("html:/posts/hello"); + }); + } + + // Next.js disables static generation for edge-runtime pages whatever their + // segments or config. + // https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/build/index.ts#L2333-L2340 + for (const [name, overrides] of [ + ["without dynamic segments", {}], + ["with revalidate = 60", { revalidateSeconds: 60 }], + [ + "with generateStaticParams", + { hasGenerateStaticParams: true, route: createDynamicSegmentRoute() }, + ], + ["with force-static", { dynamicConfig: "force-static" }], + ] satisfies [string, CreateDispatchOptionsOverrides][]) { + it(`never caches an edge-runtime page ${name}`, async () => { + const isrGet = vi.fn(async () => null); + const isrSet = vi.fn(async () => {}); + const { options } = createDispatchOptions({ + isProduction: true, + isStaticGenerationEdgeRuntime: true, + isrGet, + isrSet, + ...overrides, + }); + + const { response } = await dispatchAndDrain(options); + + expect(response.headers.get("cache-control")).toBe(NEVER_CACHE_CONTROL); + expect(isrGet).not.toHaveBeenCalled(); + expect(isrSet).not.toHaveBeenCalled(); + }); + } + + for (const fallback of ["rendered", "plain"] as const) { + it(`never caches a ${fallback} generated-param miss of an edge-runtime page`, async () => { + const middlewareCacheControl = "public, max-age=5"; + const dispatch = async (middlewareHeaders?: Headers) => { + const { options } = createDispatchOptions({ + async generateStaticParams() { + return [{ slug: "known" }]; + }, + hasGenerateStaticParams: true, + isProduction: true, + isStaticGenerationEdgeRuntime: true, + route: createDynamicSegmentRoute(), + }); + options.renderHttpAccessFallbackPage = async () => + fallback === "rendered" + ? new Response("not found", { + headers: middlewareHeaders ?? undefined, + status: 404, + }) + : null; + if (middlewareHeaders) options.middlewareContext.headers = middlewareHeaders; + return dispatchAppPage({ ...options, dynamicParamsConfig: false }); + }; + + const response = await dispatch(); + expect(response.status).toBe(404); + expect(response.headers.get("cache-control")).toBe(NEVER_CACHE_CONTROL); + + // Middleware's own cache policy still wins. + if (fallback === "rendered") { + const withMiddlewarePolicy = await dispatch( + new Headers({ "cache-control": middlewareCacheControl }), + ); + expect(withMiddlewarePolicy.headers.get("cache-control")).toBe(middlewareCacheControl); + } + }); + } + + it("never caches the missing default export response of a non-static page", async () => { + const dispatch = async (overrides: CreateDispatchOptionsOverrides) => { + const { options: dispatchOptions } = createDispatchOptions({ + isProduction: true, + ...overrides, + }); + return dispatchAppPage({ ...dispatchOptions, hasPageDefaultExport: false }); + }; + + for (const response of [ + await dispatch({ isStaticGenerationEdgeRuntime: true }), + await dispatch({ route: createDynamicSegmentRoute() }), + ]) { + expect(response.status).toBe(500); + expect(response.headers.get("cache-control")).toBe(NEVER_CACHE_CONTROL); + } + + // A static page's response keeps no policy of its own. + const staticResponse = await dispatch({}); + expect(staticResponse.status).toBe(500); + expect(staticResponse.headers.get("cache-control")).toBeNull(); + }); + + it("renders non-GET requests to a dynamic-segment route without generateStaticParams", async () => { + const buildPageElement = vi.fn(async () => React.createElement("main", null, "page")); + const { options } = createDispatchOptions({ + buildPageElement, + request: new Request("https://example.test/posts/hello", { method: "POST" }), + route: createDynamicSegmentRoute(), + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(200); + expect(buildPageElement).toHaveBeenCalled(); + }); + + it("renders non-GET requests to a dynamic-segment route in a cacheComponents build", async () => { + const buildPageElement = vi.fn(async () => React.createElement("main", null, "page")); + const { options } = createDispatchOptions({ + buildPageElement, + pprRuntime: appPagePprRuntime, + request: new Request("https://example.test/posts/hello", { method: "POST" }), + route: createDynamicSegmentRoute(), + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(200); + expect(buildPageElement).toHaveBeenCalled(); + }); + + it("reports a dynamic-segment route without generateStaticParams as uncacheable to adapter admission", async () => { + const context: ExecutionContextLike = { waitUntil() {} }; + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 10_000, + mode: "admit", + }; + Reflect.set(context, CACHEABILITY_REQUEST_STATE, state); + const { options } = createDispatchOptions({ + isProduction: true, + revalidateSeconds: 60, + route: createDynamicSegmentRoute(), + }); + + const response = await runWithExecutionContext(context, () => dispatchAppPage(options)); + await response.text(); + + await expect(state.completion).resolves.toEqual({ + cacheable: false, + reason: "route is not statically generated", + }); + }); + + // Next.js defaults every static or SSG route to `revalidate = false`, so a + // static page with no revalidate source is served until it's revalidated. + // https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/build/index.ts + for (const isRscRequest of [false, true]) { + it(`stores a static route with no revalidate source indefinitely (${isRscRequest ? "RSC" : "HTML"})`, async () => { + const isrSet = vi.fn(async () => {}); + const { options } = createDispatchOptions({ + isProduction: true, + isRscRequest, + isrSet, + route: createRoute({ pattern: "/about", routeSegments: ["about"] }), + cleanPathname: "/about", + }); + + const { response } = await dispatchAndDrain(options); + + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + const key = isRscRequest ? "rsc:/about" : "html:/about"; + const write = isrSet.mock.calls.find(([writtenKey]) => writtenKey === key); + expect(write?.[2].cacheControl.revalidate).toBe(Infinity); + }); + } + + it("reports a static route with no revalidate source as cacheable to adapter admission", async () => { + const context: ExecutionContextLike = { waitUntil() {} }; + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 10_000, + mode: "admit", + }; + Reflect.set(context, CACHEABILITY_REQUEST_STATE, state); + const { options } = createDispatchOptions({ isProduction: true }); + + const response = await runWithExecutionContext(context, () => dispatchAppPage(options)); + await response.text(); + + await expect(state.completion).resolves.toMatchObject({ + cacheable: true, + cacheControl: "s-maxage=31536000, stale-while-revalidate", + }); + }); + }); }); diff --git a/tests/app-page-element-builder.test.ts b/tests/app-page-element-builder.test.ts index e2adcc89d6..66dced5ac9 100644 --- a/tests/app-page-element-builder.test.ts +++ b/tests/app-page-element-builder.test.ts @@ -1,4 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { toSlash } from "pathslash"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import React from "react"; import { APP_INTERCEPTION_KEY, @@ -31,6 +35,9 @@ import { type AppPageBuildRoute, } from "../packages/vinext/src/server/app-page-element-builder.js"; import { probeAppPage } from "../packages/vinext/src/server/app-page-probe.js"; +import { appRouter, invalidateAppRouteCache } from "../packages/vinext/src/routing/app-router.js"; +import { isPromiseLike } from "../packages/vinext/src/utils/promise.js"; +import { ClientPageRoot } from "../packages/vinext/src/shims/client-page-root.js"; import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "../packages/vinext/src/server/app-rsc-route-matching.js"; import { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL } from "../packages/vinext/src/server/app-rsc-render-mode.js"; @@ -43,6 +50,19 @@ const { markDynamicUsageMock, markRenderRequestApiUsageMock } = vi.hoisted(() => markRenderRequestApiUsageMock: vi.fn(), })); +// A stand-in that records its props, so tests can see what reaches Flight +// even when the page element sits inside a wrapper component. +const { clientPageRootProps } = vi.hoisted(() => ({ + clientPageRootProps: [] as Record[], +})); + +vi.mock("../packages/vinext/src/shims/client-page-root.js", () => ({ + ClientPageRoot(props: Record) { + clientPageRootProps.push(props); + return null; + }, +})); + const recordedTraceDescriptors: ResolvedFrameworkSpanDescriptor[] = []; registerFrameworkTracingIntegration({ id: "app-page-element-builder-tracing-test", @@ -152,6 +172,46 @@ async function buildSearchPageSearchParams(options?: { return { searchParams: capturedSearchParams }; } +function createClientReference(): React.ComponentType & { $$typeof: symbol } { + return Object.assign(() => null, { $$typeof: Symbol.for("react.client.reference") }); +} + +function findElementOfType( + node: unknown, + type: unknown, +): React.ReactElement> | null { + if (Array.isArray(node)) { + for (const child of node) { + const found = findElementOfType(child, type); + if (found) return found; + } + return null; + } + if (!React.isValidElement>(node)) return null; + if (node.type === type) return node; + for (const value of Object.values(node.props)) { + const found = findElementOfType(value, type); + if (found) return found; + } + return null; +} + +/** Resolve promise props the way Flight does while serializing them. */ +async function serializeLikeFlight(props: Readonly>): Promise { + const resolved: Record = {}; + for (const [key, value] of Object.entries(props)) { + if (typeof value === "function") continue; + if (value && typeof value === "object" && !Array.isArray(value)) { + resolved[key] = isPromiseLike(value) + ? await value + : JSON.parse(await serializeLikeFlight(value as Record)); + continue; + } + resolved[key] = value; + } + return JSON.stringify(resolved); +} + async function resetUseCacheRuntime(): Promise { const { MemoryCacheHandler, setCacheHandler } = await import("../packages/vinext/src/shims/cache.js"); @@ -257,6 +317,7 @@ describe("buildPageElements", () => { beforeEach(() => { markDynamicUsageMock.mockClear(); markRenderRequestApiUsageMock.mockClear(); + clientPageRootProps.length = 0; recordedTraceDescriptors.length = 0; }); @@ -1083,10 +1144,11 @@ describe("buildPageElements", () => { await expect(renderElementEntry(result, "slot:modal:/")).resolves.toContain("memo slot"); }); - it("records serialized queryless searchParams without marking client pages dynamic", async () => { - const ClientPage = Object.assign(() => null, { - $$typeof: Symbol.for("react.client.reference"), - }); + it("hands a client page to ClientPageRoot without sending searchParams through Flight", async () => { + // Next.js's ClientPageRoot gets the query where the page renders, so the + // RSC payload carries none and serializing it reads nothing. + // https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/client/components/client-page.tsx + const ClientPage = createClientReference(); const route = createSyntheticRoute({ page: createSyntheticPageModule(ClientPage), layouts: [], @@ -1098,23 +1160,184 @@ describe("buildPageElements", () => { ...createBaseOptions({ route, routePath: "/client-isr", - searchParams: new URLSearchParams(), + searchParams: new URLSearchParams("q=secret"), }), pageRequest: { ...createBaseOptions().pageRequest, isRscRequest: true, observePageSearchParamsAccess: true, - searchParams: new URLSearchParams(), + searchParams: new URLSearchParams("q=secret"), }, }); - const pageElement = (result as Record)["page:/client-isr"]; - if (!React.isValidElement<{ searchParams: Promise> }>(pageElement)) { - throw new Error("Expected client page element"); + const pageRoot = findElementOfType( + (result as Record)["page:/client-isr"], + ClientPageRoot, + ); + if (!pageRoot) { + throw new Error("Expected ClientPageRoot element"); } - await pageElement.props.searchParams; - + expect(pageRoot.props.Component).toBe(ClientPage); + expect(Object.keys(pageRoot.props)).not.toContain("searchParams"); + expect(pageRoot.props.emptySearchParams).toBeUndefined(); + expect(Object.keys(pageRoot.props.pageProps as object)).toEqual(["params"]); + expect(await serializeLikeFlight(pageRoot.props)).not.toContain("secret"); expect(markDynamicUsageMock).not.toHaveBeenCalled(); + expect(markRenderRequestApiUsageMock).not.toHaveBeenCalled(); + }); + + describe("client page searchParams policy", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + async function buildClientPageRoot(pageRequest: { + isForceStatic?: boolean; + isProduction?: boolean; + }): Promise>> { + const ClientPage = createClientReference(); + const route = createSyntheticRoute({ + page: createSyntheticPageModule(ClientPage), + layouts: [], + routeSegments: ["client-policy"], + pattern: "/client-policy", + }); + const result = await buildPageElements({ + ...createBaseOptions({ + route, + routePath: "/client-policy", + searchParams: new URLSearchParams(), + }), + pageRequest: { + ...createBaseOptions().pageRequest, + observePageSearchParamsAccess: pageRequest.isForceStatic !== true, + searchParams: new URLSearchParams(), + ...pageRequest, + }, + }); + const pageRoot = findElementOfType( + (result as Record)["page:/client-policy"], + ClientPageRoot, + ); + if (!pageRoot) throw new Error("Expected ClientPageRoot element"); + return pageRoot; + } + + it("tells the browser a force-static page reads an empty query", async () => { + // The flag travels in Flight, so client navigations keep the page's + // searchParams empty, as SSR renders them. + const pageRoot = await buildClientPageRoot({ isForceStatic: true }); + + expect(pageRoot.props.emptySearchParams).toBe(true); + }); + + it("keeps client page searchParams empty in a static export build", async () => { + // Each page is rendered once without a query, so a read must not make + // the page dynamic and drop it from the export. + vi.stubEnv("__NEXT_CONFIG_OUTPUT", "export"); + + expect((await buildClientPageRoot({ isProduction: true })).props.emptySearchParams).toBe( + true, + ); + // The dev server renders each request, with its query. + expect( + (await buildClientPageRoot({ isProduction: false })).props.emptySearchParams, + ).toBeUndefined(); + }); + }); + + it("drops the route searchParams from a client slot page's props", async () => { + const ClientSlotPage = createClientReference(); + const route = createSyntheticRoute({ + page: createSyntheticPageModule(() => null), + layouts: [], + routeSegments: ["client-slot"], + pattern: "/client-slot", + slots: { + modal: { + layoutIndex: -1, + name: "modal", + page: createSyntheticPageModule(ClientSlotPage), + routeSegments: [], + }, + }, + }); + + const result = await buildPageElements({ + ...createBaseOptions({ + route, + routePath: "/client-slot", + searchParams: new URLSearchParams("q=secret"), + }), + pageRequest: { + ...createBaseOptions().pageRequest, + observePageSearchParamsAccess: true, + searchParams: new URLSearchParams("q=secret"), + }, + }); + // The slot entry wraps its page in a render-dependency component, so + // render it to reach the ClientPageRoot element. + await renderElementEntry(result, "slot:modal:/"); + const slotRootProps = clientPageRootProps.find((props) => props.Component === ClientSlotPage); + if (!slotRootProps) { + throw new Error("Expected ClientPageRoot to render the slot page"); + } + + expect(Object.keys(slotRootProps.pageProps as object)).not.toContain("searchParams"); + expect(await serializeLikeFlight(slotRootProps)).not.toContain("secret"); + expect(markRenderRequestApiUsageMock).not.toHaveBeenCalled(); + }); + + it("renders a client page without searchParams directly when the request has none", async () => { + const ClientPage = createClientReference(); + const route = createSyntheticRoute({ + page: createSyntheticPageModule(ClientPage), + layouts: [], + routeSegments: ["client-boundary"], + pattern: "/client-boundary", + }); + + const result = await buildPageElements( + createBaseOptions({ route, routePath: "/client-boundary", searchParams: null }), + ); + const pageElement = (result as Record)["page:/client-boundary"]; + + expect(React.isValidElement(pageElement) && pageElement.type).toBe(ClientPage); + expect(findElementOfType(pageElement, ClientPageRoot)).toBeNull(); + }); + + it("marks a class component page that reads searchParams dynamic without a query", async () => { + // Kept consistent with function component pages: without a query this + // used to be observed without marking the render dynamic. React 19's + // Flight server can't render an ES class page at all, so this renders it + // with React DOM. + class ClassPage extends React.Component<{ searchParams: Record }> { + render(): React.ReactNode { + return React.createElement("div", null, `q:${String(this.props.searchParams.q)}`); + } + } + const route = createSyntheticRoute({ + page: createSyntheticPageModule(ClassPage), + layouts: [], + routeSegments: ["class-page"], + pattern: "/class-page", + }); + + const result = await buildPageElements({ + ...createBaseOptions({ + route, + routePath: "/class-page", + searchParams: new URLSearchParams(), + }), + pageRequest: { + ...createBaseOptions().pageRequest, + observePageSearchParamsAccess: true, + searchParams: new URLSearchParams(), + }, + }); + + await expect(renderElementEntry(result, "page:/class-page")).resolves.toContain("q:undefined"); + expect(markDynamicUsageMock).toHaveBeenCalled(); expect(markRenderRequestApiUsageMock).toHaveBeenCalledWith("searchParams"); }); @@ -1852,6 +2075,86 @@ describe("buildPageElements", () => { expect(Object.prototype.hasOwnProperty.call(record, "page:/feed")).toBe(true); }); + it("wraps a slot intercept's page and metadata with the slot's layouts above the marker", async () => { + // Next.js builds the loader tree of app/@modal/gallery/(.)photo/page.tsx + // from every folder on its path, so app/@modal/gallery/layout.tsx wraps + // the marker's layout, the intercepting page and its metadata. + const appDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-intercept-ancestor-render-")); + try { + for (const file of [ + "layout.tsx", + "gallery/page.tsx", + "gallery/photo/page.tsx", + "@modal/default.tsx", + "@modal/gallery/layout.tsx", + "@modal/gallery/(.)photo/layout.tsx", + "@modal/gallery/(.)photo/page.tsx", + ]) { + fs.mkdirSync(path.dirname(path.join(appDir, file)), { recursive: true }); + fs.writeFileSync( + path.join(appDir, file), + "export default function Page() { return null; }\n", + ); + } + invalidateAppRouteCache(); + const galleryRoute = (await appRouter(appDir)).find((route) => route.pattern === "/gallery"); + const intercept = galleryRoute?.parallelSlots[0]?.interceptingRoutes[0]; + const layoutModules: Record = { + "@modal/gallery/layout.tsx": { + default: ({ children }: { children?: React.ReactNode }) => + React.createElement("section", null, children), + metadata: { description: "Gallery layout" }, + } as AppPageModule, + "@modal/gallery/(.)photo/layout.tsx": { + default: ({ children }: { children?: React.ReactNode }) => + React.createElement("article", null, children), + metadata: { title: "Photo layout" }, + } as AppPageModule, + }; + + const result = await buildPageElements( + createBaseOptions({ + route: createSyntheticRoute({ + page: createSyntheticPageModule(() => React.createElement("div", null, "Gallery")), + routeSegments: ["gallery"], + pattern: "/gallery", + slots: { + "@modal": { + name: "modal", + default: createSyntheticPageModule(() => null), + layoutIndex: -1, + routeSegments: [], + }, + }, + }), + routePath: "/gallery/photo", + opts: { + interceptBranchSegments: intercept?.branchSegments, + interceptLayouts: intercept?.layoutPaths.map( + (layoutPath) => layoutModules[toSlash(path.relative(appDir, layoutPath))], + ), + interceptLayoutSegments: intercept?.layoutSegments, + interceptPage: createSyntheticPageModule(() => + React.createElement("div", null, "Photo"), + ), + interceptParams: {}, + interceptSlotKey: "@modal", + }, + }), + ); + const html = await renderRouteEntry(result, result[APP_ROUTE_KEY] as string); + + expect(html).toContain(''); + expect(html).toContain("Photo layout"); + await expect(renderElementEntry(result, "slot:modal:/")).resolves.toContain( + "
Photo
", + ); + } finally { + invalidateAppRouteCache(); + fs.rmSync(appDir, { recursive: true, force: true }); + } + }); + it("builds elements for a page that receives search params", async () => { function ParamPage(): React.ReactNode { return React.createElement("div", null, "Params"); diff --git a/tests/app-page-method.test.ts b/tests/app-page-method.test.ts index 7458c2f9a7..89acad30cd 100644 --- a/tests/app-page-method.test.ts +++ b/tests/app-page-method.test.ts @@ -1,16 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { - isStaticOrSsgAppPageCandidate, - resolveAppPageMethodResponse, -} from "../packages/vinext/src/server/app-page-method.js"; +import { resolveAppPageMethodResponse } from "../packages/vinext/src/server/app-page-method.js"; describe("app page method policy", () => { it("returns 405 with Allow for non-action mutation requests to static candidates", async () => { const response = resolveAppPageMethodResponse({ - hasGenerateStaticParams: false, - isDynamicRoute: false, + isStaticEligible: true, request: new Request("https://example.com/about", { method: "POST" }), - revalidateSeconds: null, }); if (!response) { @@ -23,13 +18,11 @@ describe("app page method policy", () => { it("preserves possible server action POSTs", () => { const response = resolveAppPageMethodResponse({ - hasGenerateStaticParams: false, - isDynamicRoute: false, + isStaticEligible: true, request: new Request("https://example.com/about", { headers: { "next-action": "abc123" }, method: "POST", }), - revalidateSeconds: null, }); expect(response).toBeNull(); @@ -42,11 +35,9 @@ describe("app page method policy", () => { }); const response = resolveAppPageMethodResponse({ - hasGenerateStaticParams: false, - isDynamicRoute: false, + isStaticEligible: true, middlewareHeaders, request: new Request("https://example.com/about", { method: "PUT" }), - revalidateSeconds: null, }); if (!response) { @@ -56,42 +47,25 @@ describe("app page method policy", () => { expect(response.headers.get("x-from-middleware")).toBe("1"); }); - it("treats ISR and generateStaticParams routes as SSG candidates", () => { - expect( - isStaticOrSsgAppPageCandidate({ - hasGenerateStaticParams: false, - isDynamicRoute: false, - revalidateSeconds: 60, - }), - ).toBe(true); - - expect( - isStaticOrSsgAppPageCandidate({ - hasGenerateStaticParams: true, - isDynamicRoute: true, - revalidateSeconds: null, - }), - ).toBe(true); - }); - - it("does not guard force-dynamic or revalidate zero pages", () => { + it("does not guard pages that are not static or SSG", () => { + // force-dynamic, revalidate = 0, the edge runtime, and dynamic-segment + // routes without generateStaticParams all render per request in Next.js. expect( resolveAppPageMethodResponse({ - dynamicConfig: "force-dynamic", - hasGenerateStaticParams: false, - isDynamicRoute: false, + isStaticEligible: false, request: new Request("https://example.com/dynamic", { method: "PUT" }), - revalidateSeconds: null, }), ).toBeNull(); + }); - expect( - resolveAppPageMethodResponse({ - hasGenerateStaticParams: false, - isDynamicRoute: false, - request: new Request("https://example.com/no-store", { method: "PUT" }), - revalidateSeconds: 0, - }), - ).toBeNull(); + it("passes GET and HEAD through", () => { + for (const method of ["GET", "HEAD"]) { + expect( + resolveAppPageMethodResponse({ + isStaticEligible: true, + request: new Request("https://example.com/about", { method }), + }), + ).toBeNull(); + } }); }); diff --git a/tests/app-page-probe.test.ts b/tests/app-page-probe.test.ts index 301254edb8..83cd7087fc 100644 --- a/tests/app-page-probe.test.ts +++ b/tests/app-page-probe.test.ts @@ -1,14 +1,18 @@ import React from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { + buildAppPageInterceptSourceProbes, buildAppPageProbes, probeAppPage, probeAppPageBeforeRender, probeReactServerSubtree, + resolveAppPageProbeIntercept, } from "../packages/vinext/src/server/app-page-probe.js"; +import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "../packages/vinext/src/server/app-rsc-route-matching.js"; import { consumeDynamicUsage, consumeRenderRequestApiUsage, + markDynamicUsage, } from "../packages/vinext/src/shims/headers.js"; // Mirrors makeThenableParams() from app-rsc-entry.ts — the function that @@ -718,12 +722,13 @@ describe("buildAppPageProbes", () => { slots: { modal: { page: { default: recordingPage("modal", probed) } }, sidebar: { page: { default: recordingPage("sidebar", probed) } }, + photos: {}, }, }, pageComponent: recordingPage("page", probed), asyncRouteParams: makeThenableParams({ slug: "intro" }), searchParams: new URLSearchParams("q=hello"), - intercept: { page: { default: recordingPage("intercept", probed) } }, + intercept: { page: { default: recordingPage("intercept", probed) }, slotKey: "photos" }, isRscRequest: true, matchedParams: { slug: "intro" }, makeThenableParams: makeThenableParamsLoose, @@ -794,6 +799,32 @@ describe("buildAppPageProbes", () => { expect(probed).not.toContain("modal-page"); }); + it("omits the probe of a slot intercept the route has no slot for", async () => { + // A route-group variant of the source without @modal renders unchanged, + // so the intercepting page never renders and must not bail it out. + const probed: string[] = []; + const route = { slots: { sidebar: { page: { default: recordingPage("sidebar", probed) } } } }; + const intercept = { slotKey: "modal", page: { default: recordingPage("intercept", probed) } }; + const probes = buildAppPageProbes({ + route, + pageComponent: recordingPage("page", probed), + asyncRouteParams: makeThenableParams({}), + searchParams: new URLSearchParams("q=hello"), + intercept, + isRscRequest: true, + matchedParams: {}, + makeThenableParams: makeThenableParamsLoose, + }); + + await Promise.all(probes); + + expect(probed.sort()).toEqual(["page", "sidebar"]); + expect(resolveAppPageProbeIntercept(route, intercept)).toBeNull(); + expect(resolveAppPageProbeIntercept({ slots: { modal: {} } }, intercept)).toBe(intercept); + const siblingIntercept = { ...intercept, slotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY }; + expect(resolveAppPageProbeIntercept(route, siblingIntercept)).toBe(siblingIntercept); + }); + it("omits the interception probe when no interception matches", async () => { const probed: string[] = []; const probes = buildAppPageProbes({ @@ -936,11 +967,11 @@ describe("buildAppPageProbes", () => { const fallbackParams = { username: "ada" }; const probes = buildAppPageProbes({ - route: {}, + route: { slots: { modal: {} } }, pageComponent: () => "page", asyncRouteParams: makeThenableParams({}), searchParams: null, - intercept: { page: { default: InterceptPage } }, + intercept: { page: { default: InterceptPage }, slotKey: "modal" }, isRscRequest: true, matchedParams: fallbackParams, makeThenableParams: makeThenableParamsLoose, @@ -951,4 +982,730 @@ describe("buildAppPageProbes", () => { expect(receivedParams).toHaveLength(1); expect(await (receivedParams[0] as Promise>)).toEqual(fallbackParams); }); + + // app/feed/page.tsx with app/feed/(.)photos/[id]/page.tsx + it("probes a sibling-page intercept's page in place of the source page", async () => { + const probed: string[] = []; + const probe = (sourceIsDynamic: boolean) => + buildAppPageProbes({ + route: {}, + pageComponent: function Page() { + probed.push("page"); + // Stands in for headers() or cookies(). + if (sourceIsDynamic) markDynamicUsage(); + return "page"; + }, + asyncRouteParams: makeThenableParams({}), + searchParams: null, + intercept: { + page: { + default: function Photo() { + probed.push("intercept"); + if (!sourceIsDynamic) markDynamicUsage(); + return "intercept"; + }, + }, + slotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY, + }, + isRscRequest: true, + matchedParams: {}, + makeThenableParams: makeThenableParamsLoose, + }); + consumeDynamicUsage(); + + await Promise.all(probe(true)); + expect(consumeDynamicUsage()).toBe(false); + await Promise.all(probe(false)); + expect(consumeDynamicUsage()).toBe(true); + expect(probed).toEqual(["intercept", "intercept"]); + }); + + // A route-group variant of the source without app/feed/@modal. + it("does not probe an interception for a slot the route doesn't have", async () => { + const probed: string[] = []; + consumeDynamicUsage(); + const probes = buildAppPageProbes({ + route: { slots: { sidebar: { page: { default: recordingPage("sidebar", probed) } } } }, + pageComponent: recordingPage("page", probed), + asyncRouteParams: makeThenableParams({}), + searchParams: null, + intercept: { + page: { + default: function Photo() { + probed.push("intercept"); + markDynamicUsage(); + return "intercept"; + }, + }, + slotKey: "modal", + }, + isRscRequest: true, + matchedParams: {}, + makeThenableParams: makeThenableParamsLoose, + }); + + await Promise.all(probes); + + expect(probed.sort()).toEqual(["page", "sidebar"]); + expect(consumeDynamicUsage()).toBe(false); + }); +}); + +// buildAppPageInterceptSourceProbes() runs, ahead of a direct intercepted RSC +// response's headers, what that response's render includes before any loading +// boundary. +describe("buildAppPageInterceptSourceProbes", () => { + const makeThenableParamsLoose = (params: unknown): unknown => + makeThenableParams((params ?? {}) as Record); + + // A layout, template, page or default that records its render and, when + // `dynamic`, stands in for a headers() or cookies() call. + function recording(label: string, sink: string[], dynamic = false) { + return function Component(props: { children?: React.ReactNode }) { + sink.push(label); + if (dynamic) markDynamicUsage(); + return props.children ?? label; + }; + } + + // A component that awaits forever; awaiting its probe would hold the + // response headers. + function neverSettling(label: string, sink: string[]) { + return function Component(): Promise { + sink.push(label); + markDynamicUsage(); + return new Promise(() => {}); + }; + } + + function probeSource( + options: Partial[0]> & + Pick[0], "route">, + ): Promise { + consumeDynamicUsage(); + return Promise.all( + buildAppPageInterceptSourceProbes({ + pageComponent: undefined, + sourceParams: {}, + searchParams: null, + mountedSlotsHeader: null, + renderMode: undefined, + makeThenableParams: makeThenableParamsLoose, + ...options, + }), + ); + } + + const modalIntercept = (sink: string[]) => ({ + page: { default: recording("intercept", sink) }, + slotKey: "modal", + }); + + it("probes the intercepted slot's layout and the intercepting branch's layouts", async () => { + const probed: string[] = []; + await probeSource({ + route: { + slots: { + modal: { layout: { default: recording("modal", probed) } }, + // Its layout wraps only a page it doesn't have. + sidebar: { layout: { default: recording("sidebar", probed) } }, + }, + }, + intercept: { + ...modalIntercept(probed), + interceptLayouts: [{ default: recording("photos", probed) }, null], + matchedParams: { id: "123" }, + }, + }); + + expect(probed).toEqual(["modal", "photos", "intercept"]); + }); + + it("records an intercepting layout's dynamic API read", async () => { + const probed: string[] = []; + await probeSource({ + route: { slots: { modal: {} } }, + intercept: { + ...modalIntercept(probed), + interceptLayouts: [{ default: recording("photos", probed, true) }], + }, + }); + + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/feed/template.tsx + it("probes the source's templates", async () => { + const probed: string[] = []; + await probeSource({ + route: { + layouts: [{ default: recording("layout", probed) }], + layoutTreePositions: [1], + routeSegments: ["feed"], + templates: [{ default: recording("template", probed, true) }], + templateTreePositions: [1], + }, + pageComponent: recording("page", probed), + }); + + expect(probed).toEqual(["layout", "template", "page"]); + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/feed/loading.tsx above app/feed/[id]/template.tsx + it("stops the source's layouts and templates at its loading boundary", async () => { + const probed: string[] = []; + await probeSource({ + route: { + layouts: [{ default: recording("feed", probed) }, { default: neverSettling("id", probed) }], + layoutTreePositions: [1, 2], + loadings: [{ default: () => null }], + loadingTreePositions: [1], + routeSegments: ["feed", "[id]"], + templates: [ + { default: recording("feed-template", probed) }, + { default: neverSettling("id-template", probed) }, + ], + templateTreePositions: [1, 2], + }, + pageComponent: neverSettling("page", probed), + }); + + expect(probed).toEqual(["feed", "feed-template"]); + expect(consumeDynamicUsage()).toBe(false); + }); + + // app/feed/@sidebar/layout.tsx and app/feed/@sidebar/settings/layout.tsx + it("probes an ordinary slot's layout chain", async () => { + const probed: string[] = []; + const received: Record[] = []; + await probeSource({ + route: { + layoutTreePositions: [0, 2], + routeSegments: ["[team]", "feed"], + slots: { + modal: {}, + sidebar: { + configLayouts: [ + { + default: async function SettingsLayout(props: { + params: Promise>; + }) { + received.push({ ...(await props.params) }); + probed.push("settings"); + return null; + }, + }, + ], + configLayoutTreePositions: [1], + layout: { default: recording("sidebar", probed, true) }, + layoutIndex: 1, + name: "sidebar", + page: { default: recording("sidebar-page", probed) }, + routeSegments: ["settings"], + }, + }, + }, + intercept: modalIntercept(probed), + sourceParams: { team: "acme" }, + }); + + expect(probed.sort()).toEqual(["intercept", "settings", "sidebar", "sidebar-page"]); + expect(received).toEqual([{ team: "acme" }]); + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/feed/@sidebar/settings/loading.tsx + it("stops an ordinary slot's layout chain at its loading boundary", async () => { + const probed: string[] = []; + await probeSource({ + route: { + slots: { + sidebar: { + configLayouts: [ + { default: recording("settings", probed) }, + { default: neverSettling("advanced", probed) }, + ], + configLayoutTreePositions: [1, 2], + layout: { default: recording("sidebar", probed) }, + loadings: [{ default: () => null }], + loadingTreePositions: [1], + page: { default: neverSettling("sidebar-page", probed) }, + }, + }, + }, + }); + + expect(probed.sort()).toEqual(["settings", "sidebar"]); + expect(consumeDynamicUsage()).toBe(false); + }); + + // app/feed/loading.tsx + it("probes the source's loading component, its fallback", async () => { + const probed: string[] = []; + await probeSource({ + route: { + layouts: [{ default: recording("feed", probed) }], + layoutTreePositions: [1], + loadings: [{ default: recording("feed-loading", probed, true) }], + loadingTreePositions: [1], + routeSegments: ["feed"], + }, + pageComponent: neverSettling("page", probed), + }); + + expect(probed.sort()).toEqual(["feed", "feed-loading"]); + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/feed/@sidebar/loading.tsx and app/feed/@modal/(.)photos/loading.tsx + it("probes each slot's first loading component, its fallback", async () => { + const probed: string[] = []; + await probeSource({ + route: { + slots: { + modal: { layout: { default: recording("modal", probed) } }, + sidebar: { + layout: { default: recording("sidebar", probed) }, + loadings: [ + { default: recording("sidebar-loading", probed, true) }, + { default: recording("settings-loading", probed) }, + ], + loadingTreePositions: [0, 1], + page: { default: neverSettling("sidebar-page", probed) }, + }, + }, + }, + intercept: { + interceptBranchSegments: ["(.)photos"], + interceptLayouts: [{ default: recording("photos", probed) }], + interceptLayoutSegments: [["(.)photos"]], + interceptLoadings: [{ default: recording("photos-loading", probed) }], + interceptLoadingTreePositions: [1], + page: { default: neverSettling("intercept", probed) }, + slotKey: "modal", + }, + }); + + expect(probed.sort()).toEqual([ + "modal", + "photos", + "photos-loading", + "sidebar", + "sidebar-loading", + ]); + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/feed/(..)photos/loading.tsx + it("probes a sibling-page intercept's loading component, its fallback", async () => { + const probed: string[] = []; + await probeSource({ + route: {}, + intercept: { + interceptBranchSegments: ["(..)photos", "[id]"], + interceptLayouts: [{ default: recording("photos", probed) }], + interceptLayoutSegments: [["(..)photos"]], + interceptLoadings: [{ default: recording("photos-loading", probed, true) }], + interceptLoadingTreePositions: [1], + page: { default: neverSettling("intercept", probed) }, + slotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY, + }, + pageComponent: neverSettling("page", probed), + }); + + expect(probed.sort()).toEqual(["photos", "photos-loading"]); + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/[id]/@sidebar/[slug]/page.tsx inherited by app/[id]/page.tsx + it("passes an inherited slot the params it rematches for the request", async () => { + const received: Record[] = []; + await probeSource({ + route: { + layoutTreePositions: [0], + routeSegments: ["[id]"], + slots: { + sidebar: { + name: "sidebar", + page: { + default: async function SidebarPage(props: { + params: Promise>; + }) { + const params = await props.params; + received.push({ ...params }); + if (params.slug) markDynamicUsage(); + return null; + }, + }, + }, + }, + }, + sourceParams: { id: "1" }, + slotParamOverrides: { sidebar: { slug: "1" } }, + }); + + expect(received).toEqual([{ slug: "1" }]); + expect(consumeDynamicUsage()).toBe(true); + }); + + it("probes a slot's default where route wiring renders it", async () => { + const probed: string[] = []; + const defaultOnly = (name: string, extra: Record = {}) => ({ + default: { default: recording(name, probed) }, + // Its layout wraps only a page, not its default. + layout: { default: recording(`${name}-layout`, probed) }, + layoutIndex: 1, + name, + ...extra, + }); + const probe = (mountedSlotsHeader: string | null) => + probeSource({ + route: { + layoutTreePositions: [0, 1], + routeSegments: ["feed"], + slots: { + // Its page renders, not its default. + analytics: { + ...defaultOnly("analytics-default"), + page: { default: recording("analytics", probed) }, + }, + // Replaced by the interception. + modal: defaultOnly("modal"), + sidebar: defaultOnly("sidebar"), + // Behind its loading boundary. + team: defaultOnly("team", { loading: { default: () => "loading" } }), + }, + }, + pageComponent: recording("page", probed), + intercept: modalIntercept(probed), + mountedSlotsHeader, + }); + + await probe(null); + expect(probed.splice(0).sort()).toEqual([ + "analytics", + "analytics-default-layout", + "intercept", + "modal-layout", + "page", + "sidebar", + ]); + // The client keeps app/feed/@sidebar mounted, so the payload omits it. + await probe("slot:sidebar:/feed"); + expect(probed.splice(0).sort()).toEqual([ + "analytics", + "analytics-default-layout", + "intercept", + "modal-layout", + "page", + ]); + }); + + it("probes nothing for a prefetch-empty render", async () => { + const probed: string[] = []; + const probes = buildAppPageInterceptSourceProbes({ + route: { + layouts: [{ default: recording("layout", probed, true) }], + slots: { + modal: {}, + // Its default reads a dynamic API and never settles. + sidebar: { default: { default: neverSettling("sidebar", probed) }, name: "sidebar" }, + }, + }, + pageComponent: recording("page", probed, true), + intercept: modalIntercept(probed), + sourceParams: {}, + searchParams: null, + mountedSlotsHeader: null, + renderMode: "prefetch-empty", + makeThenableParams: makeThenableParamsLoose, + }); + consumeDynamicUsage(); + + await Promise.all(probes); + + expect(probes).toEqual([]); + expect(probed).toEqual([]); + expect(consumeDynamicUsage()).toBe(false); + }); + + // app/feed/@team/loading.tsx, with app/feed/@sidebar/default.tsx + it("probes only what a loading-shell prefetch renders", async () => { + const probed: string[] = []; + await probeSource({ + route: { + layouts: [{ default: recording("layout", probed) }], + layoutTreePositions: [0], + slots: { + // The shell renders only branches with a loading boundary. + sidebar: { default: { default: neverSettling("sidebar", probed) }, name: "sidebar" }, + team: { + layout: { default: recording("team-layout", probed) }, + loading: { default: () => null }, + name: "team", + page: { default: neverSettling("team-page", probed) }, + }, + }, + }, + // The shell omits the page. + pageComponent: neverSettling("page", probed), + renderMode: "prefetch-loading-shell", + }); + + expect(probed.sort()).toEqual(["layout", "team-layout"]); + expect(consumeDynamicUsage()).toBe(false); + }); + + // app/feed/@team/loading.tsx, where app/feed/layout.tsx owns app/feed/@team + it("probes no layout below a loading-shell prefetch's cutoff", async () => { + const probed: string[] = []; + await probeSource({ + route: { + layouts: [ + { default: recording("root", probed) }, + { default: recording("feed", probed) }, + { default: neverSettling("id", probed) }, + ], + layoutTreePositions: [0, 1, 2], + routeSegments: ["feed", "[id]"], + slots: { + team: { + layout: { default: recording("team-layout", probed) }, + layoutIndex: 1, + loading: { default: () => null }, + name: "team", + ownerTreePosition: 1, + page: { default: neverSettling("team-page", probed) }, + }, + }, + }, + renderMode: "prefetch-loading-shell", + }); + + // The shell stops at the deepest owner of a slot loading boundary. + expect(probed.sort()).toEqual(["feed", "root", "team-layout"]); + expect(consumeDynamicUsage()).toBe(false); + }); + + // app/loading.tsx and app/feed/[id]/loading.tsx + it("probes the route loading component a loading-shell prefetch renders", async () => { + const probed: string[] = []; + await probeSource({ + route: { + layouts: [ + { default: recording("root", probed) }, + { default: recording("feed", probed) }, + { default: recording("id", probed) }, + ], + layoutTreePositions: [0, 1, 2], + loadings: [ + { default: recording("root-loading", probed) }, + { default: recording("id-loading", probed, true) }, + ], + loadingTreePositions: [0, 2], + routeSegments: ["feed", "[id]"], + }, + pageComponent: neverSettling("page", probed), + renderMode: "prefetch-loading-shell", + }); + + // The shell renders the first loading UI below the root, with no + // Suspense boundary for the root's, so every layout above it renders. + expect(probed.sort()).toEqual(["feed", "id", "id-loading", "root"]); + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/feed/@team/loading.tsx + it("probes the slot loading component a loading-shell prefetch renders", async () => { + const probed: string[] = []; + await probeSource({ + route: { + layouts: [{ default: recording("layout", probed) }], + layoutTreePositions: [0], + slots: { + modal: {}, + team: { + layout: { default: recording("team-layout", probed) }, + loading: { default: recording("team-loading", probed, true) }, + name: "team", + page: { default: neverSettling("team-page", probed) }, + }, + }, + }, + intercept: modalIntercept(probed), + renderMode: "prefetch-loading-shell", + }); + + expect(probed.sort()).toEqual(["layout", "team-layout", "team-loading"]); + expect(consumeDynamicUsage()).toBe(true); + }); + + // app/[team]/feed/@modal/(.)photos/layout.tsx and + // app/[team]/feed/@modal/(.)photos/[id]/layout.tsx + it("passes each layout the params its place in the intercepted tree sees", async () => { + const received: [string, Record][] = []; + const paramsLayout = (label: string, readsHeadersWithId = false) => + async function Layout(props: { + children?: React.ReactNode; + params: Promise>; + }) { + const params = await props.params; + received.push([label, { ...params }]); + // Stands in for a headers() call that only a child param triggers. + if (readsHeadersWithId && params.id) markDynamicUsage(); + return props.children; + }; + + await probeSource({ + route: { + layoutTreePositions: [0, 2], + routeSegments: ["[team]", "feed"], + slots: { modal: { layout: { default: paramsLayout("modal") }, layoutIndex: 1 } }, + }, + intercept: { + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayouts: [ + { default: paramsLayout("photos", true) }, + { default: paramsLayout("photo") }, + ], + interceptLayoutSegments: [["(.)photos"], ["(.)photos", "[id]"]], + matchedParams: { id: "123", team: "acme" }, + page: { default: () => null }, + slotKey: "modal", + }, + sourceParams: { team: "acme" }, + }); + + expect(received).toEqual([ + ["modal", { team: "acme" }], + ["photos", { team: "acme" }], + ["photo", { id: "123", team: "acme" }], + ]); + expect(consumeDynamicUsage()).toBe(false); + }); + + // app/[team]/(.)photos/layout.tsx and app/[team]/(.)photos/[id]/layout.tsx + it("passes a sibling-page intercept's layouts the params their segments see", async () => { + const received: [string, Record][] = []; + const paramsLayout = (label: string) => + async function Layout(props: { + children?: React.ReactNode; + params: Promise>; + }) { + received.push([label, { ...(await props.params) }]); + return props.children; + }; + + await probeSource({ + route: { routeSegments: ["[team]", "feed"] }, + intercept: { + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayouts: [{ default: paramsLayout("photos") }, { default: paramsLayout("photo") }], + interceptLayoutSegments: [["(.)photos"], ["(.)photos", "[id]"]], + matchedParams: { id: "123", team: "acme" }, + slotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY, + }, + sourceParams: { team: "acme" }, + }); + + expect(received).toEqual([ + ["photos", { team: "acme" }], + ["photo", { id: "123", team: "acme" }], + ]); + }); + + // app/feed/@modal/(.)photos/loading.tsx above + // app/feed/@modal/(.)photos/[id]/layout.tsx + it("stops at an intercepting loading boundary", async () => { + const probed: string[] = []; + await probeSource({ + route: { slots: { modal: { layout: { default: recording("modal", probed) } } } }, + intercept: { + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayouts: [ + { default: recording("photos", probed) }, + { default: neverSettling("photo", probed) }, + ], + interceptLayoutSegments: [["(.)photos"], ["(.)photos", "[id]"]], + interceptLoadings: [{ default: () => null }], + interceptLoadingTreePositions: [1], + matchedParams: { id: "123" }, + page: { default: neverSettling("intercept", probed) }, + slotKey: "modal", + }, + }); + + expect(probed).toEqual(["modal", "photos"]); + }); + + it("stops below the intercepted slot's root loading boundary", async () => { + const probed: string[] = []; + await probeSource({ + route: { + slots: { + modal: { + layout: { default: recording("modal", probed) }, + loading: { default: () => null }, + }, + }, + }, + intercept: { + interceptBranchSegments: ["(.)photos"], + interceptLayouts: [{ default: neverSettling("photos", probed) }], + interceptLayoutSegments: [["(.)photos"]], + page: { default: neverSettling("intercept", probed) }, + slotKey: "modal", + }, + }); + + expect(probed).toEqual(["modal"]); + }); + + it("probes no slot inside a source loading boundary", async () => { + const probed: string[] = []; + const probe = (slotKey: string, loadingTreePosition: number) => + probeSource({ + route: { + layoutTreePositions: [0, 1], + loadings: [{ default: () => null }], + loadingTreePositions: [loadingTreePosition], + routeSegments: ["feed", "[id]"], + slots: { modal: { layout: { default: recording("modal", probed) }, layoutIndex: 1 } }, + }, + intercept: { + interceptLayouts: [{ default: recording("photos", probed) }], + interceptLayoutSegments: [["(.)photos"]], + page: { default: () => null }, + slotKey, + }, + }); + + // app/feed/loading.tsx wraps the slot that app/feed/layout.tsx owns. + await probe("modal", 1); + expect(probed.splice(0)).toEqual([]); + // A sibling-page intercept renders in place of the source's page. + await probe(SIBLING_PAGE_INTERCEPT_SLOT_KEY, 2); + expect(probed.splice(0)).toEqual([]); + // A loading boundary below the slot's owner doesn't wrap the slot. + await probe("modal", 2); + expect(probed.splice(0)).toEqual(["modal", "photos"]); + }); + + // A route-group variant of the source without app/feed/@modal. + it("probes no interception for a slot the route doesn't have", async () => { + const probed: string[] = []; + await probeSource({ + route: { slots: { sidebar: { page: { default: recording("sidebar", probed) } } } }, + intercept: { + interceptLayouts: [{ default: recording("photos", probed) }], + page: { default: recording("intercept", probed, true) }, + slotKey: "modal", + }, + pageComponent: recording("page", probed), + }); + + expect(probed.sort()).toEqual(["page", "sidebar"]); + expect(consumeDynamicUsage()).toBe(false); + }); }); diff --git a/tests/app-page-render.test.ts b/tests/app-page-render.test.ts index 6db5b4795a..bfb701ccc7 100644 --- a/tests/app-page-render.test.ts +++ b/tests/app-page-render.test.ts @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import React from "react"; +import { renderToReadableStream } from "react-dom/server.edge"; import { APP_ARTIFACT_COMPATIBILITY_KEY, APP_LAYOUT_FLAGS_KEY, @@ -21,6 +22,32 @@ import type { LayoutClassificationOptions } from "../packages/vinext/src/server/ import { createClientReuseManifestHeaderFromVisibleAppState } from "../packages/vinext/src/server/app-browser-client-reuse-manifest.js"; import { createAppLayoutParamAccessTracker } from "../packages/vinext/src/server/app-layout-param-observation.js"; import { renderAppPageLifecycle } from "../packages/vinext/src/server/app-page-render.js"; +import { consumeAppPageRenderObservationState } from "../packages/vinext/src/server/app-page-render-observation.js"; +import { createClientPageSsrSearchParamsSource } from "../packages/vinext/src/server/app-page-search-params-observation.js"; +import { ClientPageRoot } from "../packages/vinext/src/shims/client-page-root.js"; +import { + setNavigationContext, + useSearchParams, + type NavigationContext, +} from "../packages/vinext/src/shims/navigation.js"; +import { startCandidateSearchParamsGate } from "../packages/vinext/src/server/app-ssr-search-params-gate.js"; +import { finalizeWorkerCacheabilityResponse } from "../packages/vinext/src/server/cacheability-request.js"; +import { BailoutToCSRError } from "../packages/vinext/src/shims/navigation-errors.js"; +import { + consumeDynamicUsage, + headersContextFromRequest, + markDynamicUsage, + markRenderRequestApiUsage, + peekDynamicUsage, + runWithHeadersContext, + runWithIsolatedDynamicUsage, +} from "../packages/vinext/src/shims/headers.js"; +import { + CACHEABILITY_REQUEST_STATE, + type RouteCacheabilityState, +} from "../packages/vinext/src/shims/cacheability-classification.js"; +import { runWithNavigationContext } from "../packages/vinext/src/shims/navigation-state.js"; +import type { ExecutionContextLike } from "../packages/vinext/src/shims/request-context.js"; import { parseClientReuseManifestHeader, type ClientReuseManifestParseResult, @@ -42,7 +69,10 @@ import { DefaultCdnCacheAdapter, setCdnCacheAdapter, } from "../packages/vinext/src/shims/cdn-cache.js"; -import { markDynamicUsage } from "../packages/vinext/src/shims/headers.js"; +import { + hasFrameworkLinkHeaders, + markFrameworkLinkHeaders, +} from "../packages/vinext/src/server/app-response-header-provenance.js"; import { createRequestContext, runWithRequestContext, @@ -186,6 +216,7 @@ function createCommonOptions() { isDraftMode: false, isForceDynamic: false, isForceStatic: false, + isStaticEligible: true, isProgressiveActionRender: false, isProduction: false, isRscRequest: false, @@ -889,6 +920,634 @@ describe("app page render lifecycle", () => { await expect(response.text()).resolves.toBe("boundary:ssr-decoder"); }); + it("sends the never-cache header on early responses of a route that can't be static", async () => { + const common = createCommonOptions(); + const neverCache = "private, no-cache, no-store, max-age=0, must-revalidate"; + + const recovered = await renderAppPageLifecycle({ + ...common.options, + isProduction: true, + isStaticEligible: false, + async loadSsrHandler() { + return { + async handleSsr() { + throw new Error("ssr-decoder"); + }, + }; + }, + }); + expect(recovered.headers.get("cache-control")).toBe(neverCache); + await expect(recovered.text()).resolves.toBe("boundary:ssr-decoder"); + + const special = await renderAppPageLifecycle({ + ...common.options, + isRscRequest: true, + isProduction: true, + isStaticEligible: false, + probePage() { + throw { digest: "NEXT_NOT_FOUND" }; + }, + }); + expect(special.status).toBe(404); + expect(special.headers.get("cache-control")).toBe(neverCache); + + const { element: _element, ...optionsWithoutElement } = common.options; + const prepared = await renderAppPageLifecycle({ + ...optionsWithoutElement, + isProduction: true, + isStaticEligible: false, + async prepareElement() { + return { response: Response.redirect("https://example.test/elsewhere", 307) }; + }, + }); + expect(prepared.status).toBe(307); + expect(prepared.headers.get("location")).toBe("https://example.test/elsewhere"); + expect(prepared.headers.get("cache-control")).toBe(neverCache); + + // The stamped copy keeps renderer Link provenance for the config-header finalizer. + const linked = await renderAppPageLifecycle({ + ...optionsWithoutElement, + isProduction: true, + isStaticEligible: false, + async prepareElement() { + const linkHeader = "; rel=preload; as=style"; + const response = new Response("linked", { headers: { link: linkHeader } }); + markFrameworkLinkHeaders(response.headers, linkHeader); + return { response }; + }, + }); + expect(linked.headers.get("cache-control")).toBe(neverCache); + expect(hasFrameworkLinkHeaders(linked.headers)).toBe(true); + + // A response that already carries middleware's Cache-Control keeps it, as + // the normal response builders let middleware's policy win. + const middlewareCacheControl = "public, max-age=60"; + const withMiddlewarePolicy = await renderAppPageLifecycle({ + ...optionsWithoutElement, + isProduction: true, + isStaticEligible: false, + middlewareContext: { + headers: new Headers({ "cache-control": middlewareCacheControl }), + status: null, + }, + async prepareElement() { + return { + response: new Response("not found", { + status: 404, + headers: { "cache-control": middlewareCacheControl }, + }), + }; + }, + }); + expect(withMiddlewarePolicy.headers.get("cache-control")).toBe(middlewareCacheControl); + + // Draft mode, force-dynamic, revalidate = 0 and a dynamic API read are + // known dynamic too, even where the route itself can be static. + for (const knownDynamic of [ + { isDraftMode: true }, + { isForceDynamic: true }, + { revalidateSeconds: 0 }, + { peekDynamicUsage: () => true }, + ]) { + const redirected = await renderAppPageLifecycle({ + ...optionsWithoutElement, + ...knownDynamic, + isProduction: true, + isStaticEligible: true, + async prepareElement() { + return { response: Response.redirect("https://example.test/elsewhere", 307) }; + }, + }); + expect(redirected.headers.get("cache-control")).toBe(neverCache); + } + + // As in the HTML policy, nonce-bearing and progressive action renders stay + // no-store, with or without another dynamic signal, except where draft + // mode or the route makes them never-cache. + for (const [renderKind, expected] of [ + [{ scriptNonce: "abc" }, "no-store, must-revalidate"], + [{ isProgressiveActionRender: true }, "no-store, must-revalidate"], + [{ scriptNonce: "abc", revalidateSeconds: null }, "no-store, must-revalidate"], + [{ scriptNonce: "abc", peekDynamicUsage: () => true }, "no-store, must-revalidate"], + [{ isProgressiveActionRender: true, revalidateSeconds: 0 }, "no-store, must-revalidate"], + [{ scriptNonce: "abc", isDraftMode: true }, neverCache], + [{ scriptNonce: "abc", isRscRequest: true, peekDynamicUsage: () => true }, neverCache], + ] as const) { + const redirected = await renderAppPageLifecycle({ + ...optionsWithoutElement, + ...renderKind, + isProduction: true, + isStaticEligible: true, + async prepareElement() { + return { response: Response.redirect("https://example.test/elsewhere", 307) }; + }, + }); + expect(redirected.headers.get("cache-control")).toBe(expected); + } + + // A force-static render stays static after a dynamic API read. + const forceStaticRedirect = await renderAppPageLifecycle({ + ...optionsWithoutElement, + isForceStatic: true, + isProduction: true, + isStaticEligible: true, + peekDynamicUsage: () => true, + async prepareElement() { + return { response: Response.redirect("https://example.test/elsewhere", 307) }; + }, + }); + expect(forceStaticRedirect.headers.get("cache-control")).toBeNull(); + + // Dev keeps its no-store header. + const devRecovered = await renderAppPageLifecycle({ + ...common.options, + isProduction: false, + isStaticEligible: false, + async loadSsrHandler() { + return { + async handleSsr() { + throw new Error("ssr-decoder"); + }, + }; + }, + }); + expect(devRecovered.headers.get("cache-control")).toBe("no-store, must-revalidate"); + }); + + it("fails a candidate render with a 500 when useSearchParams() bails out outside Suspense", async () => { + // Next.js rethrows the bail-out instead of rendering error.tsx. + // https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/server/app-render/app-render.tsx#L3477-L3488 + const common = createCommonOptions(); + const clearRequestContext = vi.fn(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const ssrOptions: { isCacheCandidate?: boolean }[] = []; + + try { + const response = await renderAppPageLifecycle({ + ...common.options, + clearRequestContext, + isCacheCandidate: true, + isEdgeRuntime: true, + isProduction: true, + middlewareContext: { + headers: new Headers({ "set-cookie": "mw=1; Path=/", "x-middleware": "kept" }), + status: null, + }, + async loadSsrHandler() { + return { + async handleSsr(_rscStream, _navContext, _fontData, options) { + ssrOptions.push({ isCacheCandidate: options?.isCacheCandidate }); + throw new BailoutToCSRError("useSearchParams()"); + }, + }; + }, + routePattern: "/search", + }); + + expect(ssrOptions).toEqual([{ isCacheCandidate: true }]); + expect(response.status).toBe(500); + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + expect(response.headers.get("set-cookie")).toBe("mw=1; Path=/"); + expect(response.headers.get("x-middleware")).toBe("kept"); + expect(response.headers.get("x-edge-runtime")).toBe("1"); + await expect(response.text()).resolves.toBe("Internal Server Error"); + expect(common.renderErrorBoundaryResponse).not.toHaveBeenCalled(); + expect(common.isrSet).not.toHaveBeenCalled(); + expect(clearRequestContext).toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + 'useSearchParams() should be wrapped in a suspense boundary at page "/search". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout', + ); + } finally { + consoleError.mockRestore(); + } + }); + + it("keeps the error boundary for a bail-out outside a candidate render", async () => { + const common = createCommonOptions(); + const bailout = new BailoutToCSRError("useSearchParams()"); + + const response = await renderAppPageLifecycle({ + ...common.options, + async loadSsrHandler() { + return { + async handleSsr() { + throw bailout; + }, + }; + }, + }); + + expect(common.renderErrorBoundaryResponse).toHaveBeenCalledWith(bailout, "ssr"); + expect(response.status).toBe(200); + }); + + it("never stores a candidate render that latched dynamic outside its own scope", async () => { + // A dynamic API in an isolated scope (the layout probe) or in SSR opens the + // useSearchParams() gate with the real query, but never reaches the + // render's own dynamic flag. + const common = createCommonOptions(); + await runWithHeadersContext( + headersContextFromRequest(new Request("https://example.test/posts/post?q=secret")), + async () => { + await runWithIsolatedDynamicUsage(() => { + markDynamicUsage(); + }); + + const response = await renderAppPageLifecycle({ + ...common.options, + consumeDynamicUsage: vi.fn(() => false), + isCacheCandidate: true, + isProduction: true, + revalidateSeconds: 30, + }); + + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + await response.text(); + await Promise.all(common.waitUntilPromises); + expect(common.isrSet).not.toHaveBeenCalled(); + }, + ); + }); + + describe("client page searchParams read in SSR", () => { + type ClientPageProps = { searchParams: Promise> }; + + function ReadingClientPage({ searchParams }: ClientPageProps): ReactNode { + return React.createElement("p", null, `q:${String(React.use(searchParams).q)}`); + } + + function SyncReadingClientPage({ searchParams }: ClientPageProps): ReactNode { + return React.createElement("p", null, `q:${String(Reflect.get(searchParams, "q"))}`); + } + + function StaticClientPage(): ReactNode { + return React.createElement("p", null, "static client page"); + } + + // Mirrors what handleSsr does for a client page: the query comes from the + // SSR navigation context, never from the RSC payload. + async function renderClientPageCandidate( + Page: (props: ClientPageProps) => ReactNode, + renderOptions?: { isForceStatic?: boolean }, + ) { + const isForceStatic = renderOptions?.isForceStatic === true; + const common = createCommonOptions(); + const html = await runWithHeadersContext( + headersContextFromRequest(new Request("https://example.test/posts/post?q=secret")), + async () => { + const response = await renderAppPageLifecycle({ + ...common.options, + getNavigationContext() { + return { + pathname: "/posts/post", + searchParams: new URLSearchParams("q=secret"), + params: { slug: "post" }, + }; + }, + isCacheCandidate: true, + isForceStatic, + isProduction: true, + revalidateSeconds: Infinity, + async loadSsrHandler() { + return { + async handleSsr(_rscStream, navContext, _fontData, options) { + if (options?.capturedRscDataRef) { + options.capturedRscDataRef.value = Promise.resolve( + new TextEncoder().encode("flight-data").buffer, + ); + if (options.sideStream) void options.sideStream.getReader().cancel(); + } + const ssrNavigationContext = navContext as NavigationContext; + setNavigationContext({ + ...ssrNavigationContext, + getClientPageSearchParams: createClientPageSsrSearchParamsSource( + ssrNavigationContext.searchParams, + { + isForceStatic: options?.isForceStatic, + isPprFallbackShell: options?.pprFallbackShellSignal !== undefined, + }, + ), + }); + return renderToReadableStream( + React.createElement(ClientPageRoot, { + Component: Page as React.ComponentType>, + pageProps: {}, + // What the element builder sets for a force-static route. + ...(isForceStatic ? { emptySearchParams: true } : {}), + }), + ); + }, + }; + }, + }); + const body = await response.text(); + await Promise.all(common.waitUntilPromises); + return body; + }, + ); + setNavigationContext(null); + return { html, isrSet: common.isrSet }; + } + + it("stores a static client page that never reads searchParams", async () => { + const { html, isrSet } = await renderClientPageCandidate(StaticClientPage); + + expect(html).toContain("static client page"); + expect(isrSet).toHaveBeenCalledWith( + "html:/posts/post", + expect.objectContaining({ kind: "APP_PAGE" }), + expect.anything(), + ); + }); + + it("never stores a client page that reads searchParams during SSR", async () => { + // Next.js makes the route dynamic when a client page reads searchParams. + // https://github.com/vercel/next.js/blob/v16.2.7/test/e2e/app-dir/searchparams-static-bailout/searchparams-static-bailout.test.ts + const { html, isrSet } = await renderClientPageCandidate(ReadingClientPage); + + expect(html).toContain("q:secret"); + expect(isrSet).not.toHaveBeenCalled(); + }); + + it("never stores a client page that reads searchParams synchronously during SSR", async () => { + const { html, isrSet } = await renderClientPageCandidate(SyncReadingClientPage); + + expect(html).toContain("q:secret"); + expect(isrSet).not.toHaveBeenCalled(); + }); + + it("stores a force-static client page that reads searchParams, with an empty query", async () => { + // Next.js renders force-static pages with empty searchParams. + const { html, isrSet } = await renderClientPageCandidate(ReadingClientPage, { + isForceStatic: true, + }); + + expect(html).toContain("q:undefined"); + expect(html).not.toContain("secret"); + expect(isrSet).toHaveBeenCalledWith( + "html:/posts/post", + expect.objectContaining({ kind: "APP_PAGE" }), + expect.anything(), + ); + }); + + // The deploy probe and the build prerender decide whether a page is static + // before any request can store it. Next.js's build makes a client page that + // reads searchParams dynamic, so neither may call it static. + describe("classified by the deploy probe or the build prerender", () => { + type ClassifyingRender = "probe" | "prerender" | "speculative prerender"; + + const classifyingRenders: ClassifyingRender[] = [ + "probe", + "prerender", + "speculative prerender", + ]; + const neverSettles = new Promise(() => {}); + + function Delayed({ children, until }: { children?: ReactNode; until: Promise }) { + React.use(until); + return children; + } + + function NeverReady(): ReactNode { + React.use(neverSettles); + return null; + } + + function clientPage(Page: (props: ClientPageProps) => ReactNode): ReactNode { + return React.createElement(ClientPageRoot, { + Component: Page as React.ComponentType>, + pageProps: {}, + }); + } + + // The page renders after the shell, once the Suspense boundary around it + // resolves. + function afterShell(children: ReactNode): ReactNode { + return React.createElement( + React.Suspense, + { fallback: React.createElement("p", null, "loading") }, + React.createElement( + Delayed, + { until: new Promise((resolve) => setTimeout(resolve, 5)) }, + children, + ), + ); + } + + // Renders like dispatch does for the given render, with the real + // dynamic-usage readers. SSR runs in a child scope of the render, as in + // handleSsr, so a mark there never reaches the render's own flag. + async function renderClassifying( + render: ClassifyingRender, + tree: ReactNode, + { + isCacheCandidate = false, + readsSearchParams = false, + }: { isCacheCandidate?: boolean; readsSearchParams?: boolean } = {}, + ) { + const common = createCommonOptions(); + const state: RouteCacheabilityState = { + captureDeadlineAt: Date.now() + 10_000, + mode: "probe", + route: { kind: "app-page", pattern: "/posts/[slug]" }, + }; + const executionContext: ExecutionContextLike = { waitUntil() {} }; + if (render === "probe") { + Reflect.set(executionContext, CACHEABILITY_REQUEST_STATE, state); + } + const requestContext = createRequestContext({ + executionContext, + headersContext: headersContextFromRequest( + new Request("https://example.test/posts/post?q=secret"), + ), + }); + const response = await runWithRequestContext(requestContext, () => { + // Recorded in the render's request scope, as the page's own + // searchParams observer records it. + if (readsSearchParams) markRenderRequestApiUsage("searchParams"); + return renderAppPageLifecycle({ + ...common.options, + consumeDynamicUsage, + consumeRenderObservationState: consumeAppPageRenderObservationState, + peekDynamicUsage, + getNavigationContext() { + return { + pathname: "/posts/post", + searchParams: new URLSearchParams("q=secret"), + params: { slug: "post" }, + }; + }, + isCacheCandidate, + isPrerender: render !== "probe", + isSpeculativePrerender: render === "speculative prerender", + isProduction: true, + revalidateSeconds: Infinity, + async loadSsrHandler() { + return { + handleSsr(rscStream, navContext, _fontData, options) { + return runWithNavigationContext(async () => { + if (options?.capturedRscDataRef) { + options.capturedRscDataRef.value = Promise.resolve( + new TextEncoder().encode("flight-data").buffer, + ); + if (options.sideStream) void options.sideStream.getReader().cancel(); + } + // A candidate render gates useSearchParams() until SSR has + // read the whole Flight response, as handleSsr does. + const searchParamsGate = + options?.isCacheCandidate === true && + options.isStaticGeneration !== true && + options.isForceStatic !== true + ? startCandidateSearchParamsGate() + : null; + if (searchParamsGate) { + void new Response(searchParamsGate.settleWhenConsumed(rscStream)).text(); + } + const ssrNavigationContext = navContext as NavigationContext; + setNavigationContext({ + ...ssrNavigationContext, + searchParamsGate: searchParamsGate?.gate, + getClientPageSearchParams: createClientPageSsrSearchParamsSource( + ssrNavigationContext.searchParams, + {}, + ), + }); + // Cancelling a render that never finishes aborts it. + const htmlStream = await renderToReadableStream(tree, { onError() {} }); + if (options?.waitForAllReady === true) await htmlStream.allReady; + return { + htmlStream, + metadataReady: Promise.resolve(), + renderComplete: htmlStream.allReady, + capturedRscData: options?.capturedRscDataRef?.value ?? null, + }; + }); + }, + }; + }, + }); + }); + return { + response, + completion: render === "probe" ? state.completion : undefined, + executionContext, + }; + } + + async function classify(render: ClassifyingRender, tree: ReactNode) { + const { response, completion } = await renderClassifying(render, tree); + const html = await response.text(); + return { + cacheControl: response.headers.get("cache-control") ?? "", + html, + outcome: await completion, + }; + } + + for (const render of classifyingRenders) { + for (const placement of ["in the shell", "after the shell"] as const) { + const place = placement === "in the shell" ? (tree: ReactNode) => tree : afterShell; + + it(`classifies a client page that reads searchParams ${placement} as dynamic (${render})`, async () => { + const { cacheControl, html, outcome } = await classify( + render, + place(clientPage(ReadingClientPage)), + ); + + expect(html).toContain("q:secret"); + if (render === "probe") { + expect(outcome).toMatchObject({ cacheable: false, dynamicUsage: true }); + } else { + // prerender.ts skips a render whose Cache-Control says no-store. + expect(cacheControl).toContain("no-store"); + } + }); + + it(`classifies a client page that never reads searchParams ${placement} as static (${render})`, async () => { + const { cacheControl, html, outcome } = await classify( + render, + place(clientPage(StaticClientPage)), + ); + + expect(html).toContain("static client page"); + if (render === "probe") { + expect(outcome).toMatchObject({ cacheable: true }); + } else { + expect(cacheControl).not.toContain("no-store"); + } + }); + } + } + + it("proves searchParams went unread only from the render's own request state", async () => { + const unread = await classify("probe", clientPage(StaticClientPage)); + expect(unread.outcome).toMatchObject({ cacheable: true, searchParamsUnread: true }); + + // A disconnecting client finishes the probe from its cancel, outside + // the render's request scope. + const read = await renderClassifying("probe", clientPage(StaticClientPage), { + readsSearchParams: true, + }); + await read.response.body?.cancel(); + expect(await read.completion).not.toHaveProperty("searchParamsUnread"); + }); + + // The Workers Cache deploy probe renders a static page in candidate + // mode. Next.js prerenders a page whose useSearchParams() sits inside + // Suspense once, with the fallback, and serves it for every query, so the + // probe must certify it static and keep the query out of its render. + it("certifies a static page with useSearchParams() inside Suspense in a candidate probe", async () => { + function SearchValue(): ReactNode { + return React.createElement("p", null, `q:${useSearchParams().get("q") ?? ""}`); + } + const { response, executionContext } = await renderClassifying( + "probe", + React.createElement( + React.Suspense, + { fallback: React.createElement("p", null, "search-fallback") }, + React.createElement(SearchValue), + ), + { isCacheCandidate: true }, + ); + const html = await response.clone().text(); + const envelope = (await ( + await finalizeWorkerCacheabilityResponse(response, executionContext) + ).json()) as Record; + + expect(html).toContain("search-fallback"); + expect(html).not.toContain("secret"); + expect(envelope).toMatchObject({ rendererStatic: true, state: "static-candidate" }); + expect(envelope).not.toHaveProperty("dynamicUsage"); + }); + + it("stops waiting for a speculative prerender's SSR once it turns dynamic", async () => { + // A boundary that never resolves doesn't hold a render that is already + // known to be dynamic. + const { response } = await renderClassifying( + "speculative prerender", + React.createElement( + React.Fragment, + null, + afterShell(clientPage(ReadingClientPage)), + React.createElement( + React.Suspense, + { fallback: React.createElement("p", null, "never") }, + React.createElement(NeverReady), + ), + ), + ); + + expect(response.headers.get("cache-control")).toContain("no-store"); + await response.body?.cancel(); + }); + }); + }); + it("writes paired HTML and RSC cache entries for cacheable HTML responses", async () => { const common = createCommonOptions(); @@ -1576,6 +2235,25 @@ describe("app page render lifecycle", () => { await expect(response.text()).resolves.toBe("flight-data"); }); + it("keeps client reuse metadata for routes that can't be static", async () => { + // Being ineligible for the server's full-page cache doesn't make a render + // dynamic for the client router: an explicit full prefetch of a + // client-only page stays reusable, as in Next.js. + const common = createCommonOptions(); + const response = await renderAppPageLifecycle({ + ...common.options, + consumeDynamicUsage: vi.fn(() => false), + dynamicStaleTimeSeconds: 0, + isProduction: true, + isRscRequest: true, + isStaticEligible: false, + revalidateSeconds: 60, + }); + expect(response.headers.get(VINEXT_DYNAMIC_STALE_TIME_HEADER)).toBeNull(); + await expect(response.text()).resolves.toBe("flight-data"); + expect(common.isrSet).not.toHaveBeenCalled(); + }); + it("omits the dynamic stale time header on static production default-config RSC responses", async () => { const common = createCommonOptions(); const response = await renderAppPageLifecycle({ @@ -1849,6 +2527,233 @@ describe("app page render lifecycle", () => { }); }); +describe("routes that are not statically generated", () => { + // Next.js classifies a dynamic-segment route without generateStaticParams as + // dynamic (ƒ). A page-level "use cache" + cacheLife still reuses its data + // cache entry, but the page is sent `private, no-store` and never stored. + for (const isRscRequest of [false, true]) { + it(`never stores a cacheLife-only render (${isRscRequest ? "RSC" : "HTML"})`, async () => { + const common = createCommonOptions(); + + const response = await renderAppPageLifecycle({ + ...common.options, + getRequestCacheLife() { + return { revalidate: 60 }; + }, + isProduction: true, + isRscRequest, + isStaticEligible: false, + revalidateSeconds: null, + }); + + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + expect(response.headers.get("x-vinext-cache")).toBeNull(); + await response.arrayBuffer(); + await Promise.all(common.waitUntilPromises); + expect(common.isrSet).not.toHaveBeenCalled(); + }); + } + + it("never stores a render with a revalidate export", async () => { + const common = createCommonOptions(); + + const response = await renderAppPageLifecycle({ + ...common.options, + isProduction: true, + isStaticEligible: false, + revalidateSeconds: 60, + }); + + expect(response.headers.get("cache-control")).toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + await response.text(); + await Promise.all(common.waitUntilPromises); + expect(common.isrSet).not.toHaveBeenCalled(); + }); +}); + +describe("static routes under the default revalidate = false", () => { + // Next.js defaults every static or SSG route to `revalidate = false` and + // stores both the HTML and RSC payload from one render. A cacheLife that + // resolves while streaming still lowers the stored lifetime. + const STATIC_CACHE_CONTROL = "s-maxage=31536000, stale-while-revalidate"; + + it("stores HTML and RSC indefinitely when no cacheLife resolves", async () => { + const common = createCommonOptions(); + + const response = await renderAppPageLifecycle({ + ...common.options, + isProduction: true, + revalidateSeconds: Infinity, + }); + + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + await response.text(); + await Promise.all(common.waitUntilPromises); + expect(common.isrSet.mock.calls.map(([key, , policy]) => [key, policy.cacheControl])).toEqual([ + ["html:/posts/post", { revalidate: Infinity }], + ["rsc:/posts/post", { revalidate: Infinity }], + ]); + }); + + // Next.js pairs expireTime only with a finite revalidate, so the default + // keeps no expire of its own unless a cacheLife sets one. + // https://github.com/vercel/next.js/blob/v16.2.7/packages/next/src/build/index.ts#L3035-L3058 + it("does not store the route expireTime with the indefinite lifetime", async () => { + const common = createCommonOptions(); + + const response = await renderAppPageLifecycle({ + ...common.options, + expireSeconds: 31_536_000, + isProduction: true, + revalidateSeconds: Infinity, + }); + + await response.text(); + await Promise.all(common.waitUntilPromises); + expect(common.isrSet.mock.calls.map(([key, , policy]) => [key, policy.cacheControl])).toEqual([ + ["html:/posts/post", { revalidate: Infinity }], + ["rsc:/posts/post", { revalidate: Infinity }], + ]); + }); + + it("keeps a cacheLife expire with the indefinite lifetime", async () => { + const common = createCommonOptions(); + + const response = await renderAppPageLifecycle({ + ...common.options, + expireSeconds: 31_536_000, + getRequestCacheLife() { + return { expire: 600 }; + }, + isProduction: true, + revalidateSeconds: Infinity, + }); + + await response.text(); + await Promise.all(common.waitUntilPromises); + expect(common.isrSet.mock.calls.map(([key, , policy]) => [key, policy.cacheControl])).toEqual([ + ["html:/posts/post", { revalidate: Infinity, expire: 600 }], + ["rsc:/posts/post", { revalidate: Infinity, expire: 600 }], + ]); + }); + + it("pairs the route expireTime with a cacheLife that makes the lifetime finite", async () => { + const common = createCommonOptions(); + + const response = await renderAppPageLifecycle({ + ...common.options, + expireSeconds: 31_536_000, + getRequestCacheLife() { + return { revalidate: 60 }; + }, + isProduction: true, + revalidateSeconds: Infinity, + }); + + await response.text(); + await Promise.all(common.waitUntilPromises); + expect(common.isrSet.mock.calls.map(([key, , policy]) => [key, policy.cacheControl])).toEqual([ + ["html:/posts/post", { revalidate: 60, expire: 31_536_000 }], + ["rsc:/posts/post", { revalidate: 60, expire: 31_536_000 }], + ]); + }); + + it("lowers the stored lifetime to a cacheLife resolved after headers", async () => { + const common = createCommonOptions(); + let requestCacheLife: { revalidate: number; expire: number } | null = null; + + const response = await renderAppPageLifecycle({ + ...common.options, + getRequestCacheLife() { + return requestCacheLife; + }, + isProduction: true, + renderToReadableStream() { + let sent = false; + return new ReadableStream({ + pull(controller) { + if (sent) { + // Resolves below a Suspense boundary, after headers were sent. + requestCacheLife = { revalidate: 60, expire: 600 }; + controller.close(); + return; + } + sent = true; + controller.enqueue(new TextEncoder().encode("flight")); + }, + }); + }, + revalidateSeconds: Infinity, + }); + + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + await response.text(); + await Promise.all(common.waitUntilPromises); + expect(common.isrSet.mock.calls.map(([key, , policy]) => [key, policy.cacheControl])).toEqual([ + ["html:/posts/post", { revalidate: 60, expire: 600 }], + ["rsc:/posts/post", { revalidate: 60, expire: 600 }], + ]); + }); + + it("stores an RSC-only miss with the cacheLife lifetime", async () => { + const common = createCommonOptions(); + + const response = await renderAppPageLifecycle({ + ...common.options, + getRequestCacheLife() { + return { revalidate: 60 }; + }, + isProduction: true, + isRscRequest: true, + revalidateSeconds: Infinity, + }); + + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + await response.arrayBuffer(); + await Promise.all(common.waitUntilPromises); + expect(common.isrSet.mock.calls.map(([key, , policy]) => [key, policy.cacheControl])).toEqual([ + ["rsc:/posts/post", { revalidate: 60 }], + ]); + }); + + it("does not send static headers on an RSC miss that can still turn dynamic", async () => { + const common = createCommonOptions(); + + const response = await renderAppPageLifecycle({ + ...common.options, + isProduction: true, + isRscRequest: true, + revalidateSeconds: Infinity, + }); + + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + expect(response.headers.get("cache-control")).not.toBe(STATIC_CACHE_CONTROL); + expect(response.headers.get("cache-control")).toContain("no-store"); + await response.arrayBuffer(); + await Promise.all(common.waitUntilPromises); + }); + + it("keeps STATIC RSC headers for force-static routes", async () => { + const common = createCommonOptions(); + + const response = await renderAppPageLifecycle({ + ...common.options, + isForceStatic: true, + isProduction: true, + isRscRequest: true, + revalidateSeconds: Infinity, + }); + + expect(response.headers.get("cache-control")).toBe(STATIC_CACHE_CONTROL); + expect(response.headers.get("x-vinext-cache")).toBe("STATIC"); + await response.arrayBuffer(); + }); +}); + describe("layoutFlags injection into RSC payload", () => { function createRscOptions(overrides: { cleanPathname?: string; @@ -1883,6 +2788,7 @@ describe("layoutFlags injection into RSC payload", () => { isDraftMode: false, isForceDynamic: false, isForceStatic: false, + isStaticEligible: true, isProduction: true, isRscRequest: true, isrHtmlKey: (p: string) => `html:${p}`, diff --git a/tests/app-page-request.test.ts b/tests/app-page-request.test.ts index 036f972277..30b5a159ae 100644 --- a/tests/app-page-request.test.ts +++ b/tests/app-page-request.test.ts @@ -2,12 +2,15 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { resolveAppPageSpecialError } from "../packages/vinext/src/server/app-page-execution.js"; import { buildAppPageElement, + loadAppPageCurrentRouteIntercept, + matchAppPageCurrentRouteIntercept, resolveAppPageActionRerenderTarget, resolveAppPageIntercept, resolveAppPageInterceptionRerenderTarget, resolveAppPageGenerateStaticParamsSources, validateAppPageDynamicParams, } from "../packages/vinext/src/server/app-page-request.js"; +import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "../packages/vinext/src/server/app-rsc-route-matching.js"; import { cookies, headersContextFromRequest } from "../packages/vinext/src/shims/headers.js"; import { createRequestContext, @@ -85,6 +88,47 @@ describe("app page request helpers", () => { expect(response?.status).toBe(404); }); + it.each([ + ["an independent", () => [{ path: ["known"] }]], + [ + "a chained", + [ + { + chained: true as const, + generateStaticParams: () => [{ path: ["known"] }], + parentParamNames: [], + }, + ], + ], + ])( + "compares an omitted optional catch-all against %s generator's values", + async (_name, source) => { + const validate = (params: Record) => + validateAppPageDynamicParams({ + enforceStaticParamsOnly: true, + generateStaticParams: source, + isDynamicRoute: true, + optionalCatchAllParamNames: ["path"], + params, + }); + + expect((await validate({}))?.status).toBe(404); + expect(await validate({ path: ["known"] })).toBeNull(); + }, + ); + + it("allows an omitted optional catch-all that a generator leaves explicitly empty", async () => { + const response = await validateAppPageDynamicParams({ + enforceStaticParamsOnly: true, + generateStaticParams: () => [{ path: [] }], + isDynamicRoute: true, + optionalCatchAllParamNames: ["path"], + params: {}, + }); + + expect(response).toBeNull(); + }); + it("allows matching static params, including nested parent params", async () => { const response = await validateAppPageDynamicParams({ enforceStaticParamsOnly: true, @@ -230,6 +274,36 @@ describe("app page request helpers", () => { ).resolves.toMatchObject({ status: 404 }); }); + it("names the param of an intercepting route's marked segment", async () => { + // app/feed/[slug]/@modal/(..)[slug]/page.tsx generates the slug its own + // segment names, so it is no parent param of that generator. + const interceptGenerateStaticParams = vi.fn(() => [{ slug: "known" }]); + const generateStaticParams = resolveAppPageGenerateStaticParamsSources({ + layouts: [null], + layoutTreePositions: [0], + parallelBranches: [ + { + page: { generateStaticParams: interceptGenerateStaticParams }, + routeSegments: ["(..)[slug]"], + }, + ], + routeSegments: ["feed", "[slug]"], + }); + const validate = (slug: string) => + validateAppPageDynamicParams({ + enforceStaticParamsOnly: true, + generateStaticParams, + isDynamicRoute: true, + params: { slug }, + }); + + await expect(validate("known")).resolves.toBeNull(); + await expect(validate("unknown")).resolves.toMatchObject({ status: 404 }); + expect(interceptGenerateStaticParams).toHaveBeenCalledTimes(2); + expect(interceptGenerateStaticParams).toHaveBeenNthCalledWith(1, { params: {} }); + expect(interceptGenerateStaticParams).toHaveBeenNthCalledWith(2, { params: {} }); + }); + it("validates chained and parallel branch params independently", async () => { const localeGenerateStaticParams = vi.fn(() => [{ locale: "en" }]); const slugGenerateStaticParams = vi.fn(() => [{ slug: "static-123" }]); @@ -564,6 +638,7 @@ describe("app page request helpers", () => { return { ...params, catchAll: ["photos", "123"] }; }, renderInterceptResponse, + routeHasSlot: () => true, searchParams: new URLSearchParams("from=feed"), setNavigationContext, toInterceptOpts(intercept) { @@ -631,6 +706,7 @@ describe("app page request helpers", () => { async renderInterceptResponse() { throw new Error("should not render a separate intercept response"); }, + routeHasSlot: () => true, searchParams: new URLSearchParams(), setNavigationContext() {}, toInterceptOpts(intercept) { @@ -650,6 +726,247 @@ describe("app page request helpers", () => { }); }); + describe("owner default of a slot intercept", () => { + const currentRoute = { params: ["id"], pattern: "/photos/[id]", slots: {} }; + const feedDefault = { default: "feed-default", dynamic: "force-static" }; + + async function resolveWithSource( + sourceRoute: { params: string[]; pattern: string; slots: Record }, + slotKey: string, + routeHasSlot = (route: typeof sourceRoute, key: string) => Object.hasOwn(route.slots, key), + ) { + const __loadOwnerDefault = vi.fn(async () => feedDefault); + const loadInterceptLayout = vi.fn(async () => ({ default: "modal-layout" })); + const renderInterceptResponse = vi.fn( + (_route: unknown, _element: unknown, _opts: { interceptOwnerDefault: unknown }) => + new Response("intercepted"), + ); + const result = await resolveAppPageIntercept({ + async buildPageElement(route) { + return route.pattern; + }, + cleanPathname: "/photos/123", + currentRoute, + findIntercept() { + return { + __loadInterceptLayouts: [loadInterceptLayout], + __loadOwnerDefault, + interceptLayouts: [null], + matchedParams: { id: "123" }, + ownerDefault: null, + page: { default: "modal-page" }, + slotKey, + sourceRouteIndex: 0, + }; + }, + getRouteParamNames(route) { + return route.params; + }, + getSourceRoute() { + return sourceRoute; + }, + isRscRequest: true, + resolveNavigationParams(_route, params) { + return params; + }, + renderInterceptResponse, + routeHasSlot, + searchParams: new URLSearchParams(), + setNavigationContext() {}, + toInterceptOpts(intercept) { + return { interceptOwnerDefault: intercept.ownerDefault }; + }, + }); + return { __loadOwnerDefault, loadInterceptLayout, renderInterceptResponse, result }; + } + + it("loads it once the concrete source has the intercepted slot", async () => { + const sourceRoute = { + params: [], + pattern: "/feed", + slots: { "modal@app/feed/@modal": {} }, + }; + const { __loadOwnerDefault, loadInterceptLayout, renderInterceptResponse } = + await resolveWithSource(sourceRoute, "modal@app/feed/@modal"); + + expect(__loadOwnerDefault).toHaveBeenCalledTimes(1); + expect(loadInterceptLayout).toHaveBeenCalledTimes(1); + expect(renderInterceptResponse).toHaveBeenCalledWith(sourceRoute, "/feed", { + interceptOwnerDefault: feedDefault, + }); + }); + + it("skips it when the concrete source variant lacks the intercepted slot", async () => { + // A route-group variant of app/feed without @modal renders its own page, + // so a throwing or slow app/feed/default.tsx, or any module of the + // unused intercept, must not run. + const sourceRoute = { params: [], pattern: "/feed", slots: {} }; + const { __loadOwnerDefault, loadInterceptLayout, renderInterceptResponse } = + await resolveWithSource(sourceRoute, "modal@app/feed/@modal"); + + expect(__loadOwnerDefault).not.toHaveBeenCalled(); + expect(loadInterceptLayout).not.toHaveBeenCalled(); + expect(renderInterceptResponse).toHaveBeenCalledWith(sourceRoute, "/feed", { + interceptOwnerDefault: null, + }); + }); + + it("skips it for a sibling-page intercept", async () => { + // The intercepting page replaces the source's page, not the children of + // a slot's owner, whatever slots the source has. + const sourceRoute = { + params: [], + pattern: "/feed", + slots: { "modal@app/feed/@modal": {} }, + }; + const { __loadOwnerDefault, renderInterceptResponse } = await resolveWithSource( + sourceRoute, + SIBLING_PAGE_INTERCEPT_SLOT_KEY, + () => true, + ); + + expect(__loadOwnerDefault).not.toHaveBeenCalled(); + expect(renderInterceptResponse).toHaveBeenCalledWith(sourceRoute, "/feed", { + interceptOwnerDefault: null, + }); + }); + + it("loads it for a current-route interception, which renders the intercepting tree", async () => { + const __loadOwnerDefault = vi.fn(async () => feedDefault); + const feedRoute = { params: [], pattern: "/feed", slots: { "modal@app/feed/@modal": {} } }; + + const result = await resolveAppPageIntercept({ + async buildPageElement() { + throw new Error("should not build a separate intercept element"); + }, + cleanPathname: "/feed", + currentRoute: feedRoute, + findIntercept: () => ({ + __loadOwnerDefault, + matchedParams: {}, + ownerDefault: null, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 0, + }), + getRouteParamNames: (route) => route.params, + getSourceRoute: () => feedRoute, + isRscRequest: true, + resolveNavigationParams: (_route, params) => params, + async renderInterceptResponse() { + throw new Error("should not render a separate intercept response"); + }, + routeHasSlot: (route, key) => Object.hasOwn(route.slots, key), + searchParams: new URLSearchParams(), + setNavigationContext() {}, + toInterceptOpts: (intercept) => ({ interceptOwnerDefault: intercept.ownerDefault }), + }); + + expect(result.response).toBeNull(); + expect(__loadOwnerDefault).toHaveBeenCalledTimes(1); + expect(result.interceptOpts).toEqual({ interceptOwnerDefault: feedDefault }); + }); + + it("leaves it unloaded on re-render targets, which never classify", async () => { + const __loadOwnerDefault = vi.fn(async () => feedDefault); + const sourceRoute = { + params: [], + pattern: "/feed", + slots: { "modal@app/feed/@modal": {} }, + }; + + const result = await resolveAppPageActionRerenderTarget({ + cleanPathname: "/photos/123", + currentParams: { id: "123" }, + currentRoute, + findIntercept: () => ({ + __loadOwnerDefault, + matchedParams: { id: "123" }, + ownerDefault: null, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 0, + }), + getRouteParamNames: (route) => route.params, + getSourceRoute: () => sourceRoute, + isRscRequest: true, + toInterceptOpts: (intercept) => ({ interceptPage: intercept.page }), + }); + + expect(result.route).toBe(sourceRoute); + expect(__loadOwnerDefault).not.toHaveBeenCalled(); + }); + }); + + describe("current-route interception", () => { + const feedRoute = { pattern: "/feed", slots: { "modal@app/feed/@modal": {} } }; + + function createIntercept(slotKey = "modal@app/feed/@modal") { + return { + __loadOwnerDefault: vi.fn(async () => ({ default: "feed-default" })), + __pageLoader: vi.fn(async () => ({ default: "modal-page" })), + matchedParams: {}, + ownerDefault: null, + page: null, + slotKey, + sourceRouteIndex: 0, + }; + } + + it("matches an interception whose source is the current route without loading it", async () => { + const intercept = createIntercept(); + + const match = await matchAppPageCurrentRouteIntercept({ + cleanPathname: "/feed", + currentRoute: feedRoute, + findIntercept: () => intercept, + getSourceRoute: () => feedRoute, + isRscRequest: true, + }); + + expect(match).toBe(intercept); + expect(intercept.__pageLoader).not.toHaveBeenCalled(); + expect(intercept.__loadOwnerDefault).not.toHaveBeenCalled(); + }); + + it("matches nothing for another source route or a non-RSC request", async () => { + const intercept = createIntercept(); + const options = { + cleanPathname: "/feed", + currentRoute: feedRoute, + findIntercept: () => intercept, + isRscRequest: true, + }; + + await expect( + matchAppPageCurrentRouteIntercept({ ...options, getSourceRoute: () => ({ ...feedRoute }) }), + ).resolves.toBeNull(); + await expect( + matchAppPageCurrentRouteIntercept({ + ...options, + getSourceRoute: () => feedRoute, + isRscRequest: false, + }), + ).resolves.toBeNull(); + }); + + it("loads the intercepting tree's page and the owner's default for a slot the route has", async () => { + const intercept = createIntercept(); + const routeHasSlot = (route: typeof feedRoute, key: string) => + Object.hasOwn(route.slots, key); + + await loadAppPageCurrentRouteIntercept(intercept, feedRoute, routeHasSlot); + + expect(intercept.page).toEqual({ default: "modal-page" }); + expect(intercept.ownerDefault).toEqual({ default: "feed-default" }); + + const siblingIntercept = createIntercept(SIBLING_PAGE_INTERCEPT_SLOT_KEY); + await loadAppPageCurrentRouteIntercept(siblingIntercept, feedRoute, () => true); + expect(siblingIntercept.page).toEqual({ default: "modal-page" }); + expect(siblingIntercept.__loadOwnerDefault).not.toHaveBeenCalled(); + }); + }); + it("returns special-error responses from page builds", async () => { const result = await buildAppPageElement({ async buildPageElement() { @@ -767,6 +1084,45 @@ describe("resolveAppPageInterceptionRerenderTarget intercept loading", () => { expect(result.interceptOpts).toEqual(toInterceptOpts(intercept)); }); + it("loads a slot intercept's owner default before building its intercept options", async () => { + // A stale variant regenerates with the intercepting route's tree, where + // app/feed/default.tsx replaces app/feed's children, so its config must be + // loaded before the options it classifies with are built. + const feedDefault = { default: "feed-default", dynamic: "force-dynamic" }; + const resolveWith = async (slots: Record) => { + const __loadOwnerDefault = vi.fn(async () => feedDefault); + const result = await resolveAppPageInterceptionRerenderTarget({ + cleanPathname: "/photos/123", + currentParams: { id: "123" }, + currentRoute, + findIntercept: () => ({ + __loadOwnerDefault, + matchedParams: { id: "123" }, + ownerDefault: null, + page: { default: "modal-page" }, + slotKey: "modal@app/feed/@modal", + sourceRouteIndex: 0, + }), + getRouteParamNames: (route) => route.params, + getSourceRoute: () => ({ ...sourceRoute, slots }), + isRscRequest: true, + routeHasSlot: (route, key) => + Object.hasOwn((route as { slots?: Record }).slots ?? {}, key), + toInterceptOpts: (intercept) => ({ interceptOwnerDefault: intercept.ownerDefault }), + }); + return { __loadOwnerDefault, result }; + }; + + const attached = await resolveWith({ "modal@app/feed/@modal": {} }); + expect(attached.__loadOwnerDefault).toHaveBeenCalledTimes(1); + expect(attached.result.interceptOpts).toEqual({ interceptOwnerDefault: feedDefault }); + + // A source variant without @modal renders unchanged and never loads it. + const unattached = await resolveWith({}); + expect(unattached.__loadOwnerDefault).not.toHaveBeenCalled(); + expect(unattached.result.interceptOpts).toEqual({ interceptOwnerDefault: null }); + }); + it("deduplicates concurrent intercept page and layout loads", async () => { const interceptPage = { default: "modal-page" }; const interceptLayout = { default: "modal-layout" }; diff --git a/tests/app-page-response.test.ts b/tests/app-page-response.test.ts index 71a10fbf79..4e8bda4e58 100644 --- a/tests/app-page-response.test.ts +++ b/tests/app-page-response.test.ts @@ -29,6 +29,7 @@ describe("app page response helpers", () => { it("resolves RSC response policy for static and ISR responses", () => { expect( resolveAppPageRscResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringBuild: false, isDraftMode: false, isDynamicError: false, @@ -44,6 +45,7 @@ describe("app page response helpers", () => { expect( resolveAppPageRscResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringBuild: false, expireSeconds: 300, isDraftMode: false, @@ -62,6 +64,7 @@ describe("app page response helpers", () => { it("resolves RSC response policy for force-dynamic, infinity, and default cases", () => { expect( resolveAppPageRscResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringBuild: false, isDraftMode: false, isDynamicError: false, @@ -71,11 +74,12 @@ describe("app page response helpers", () => { revalidateSeconds: 60, }), ).toEqual({ - cacheControl: "no-store, must-revalidate", + cacheControl: "private, no-cache, no-store, max-age=0, must-revalidate", }); expect( resolveAppPageRscResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringBuild: false, isDraftMode: false, isDynamicError: false, @@ -86,11 +90,12 @@ describe("app page response helpers", () => { }), ).toEqual({ cacheControl: "s-maxage=31536000, stale-while-revalidate", - cacheState: "STATIC", + cacheState: "MISS", }); expect( resolveAppPageRscResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringBuild: false, isDraftMode: false, isDynamicError: false, @@ -102,9 +107,44 @@ describe("app page response helpers", () => { ).toEqual({}); }); + it("keeps STATIC only for RSC renders that can't turn dynamic while streaming", () => { + const base = { + dynamicUsedDuringBuild: false, + isDraftMode: false, + isForceDynamic: false, + isProduction: true, + isStaticEligible: true, + }; + for (const config of [ + { isDynamicError: false, isForceStatic: true }, + { isDynamicError: true, isForceStatic: false }, + ]) { + for (const revalidateSeconds of [null, Infinity]) { + expect(resolveAppPageRscResponsePolicy({ ...base, ...config, revalidateSeconds })).toEqual({ + cacheControl: "s-maxage=31536000, stale-while-revalidate", + cacheState: "STATIC", + }); + } + } + + expect( + resolveAppPageRscResponsePolicy({ + ...base, + isDynamicError: false, + isForceStatic: false, + isProduction: false, + revalidateSeconds: Infinity, + }), + ).toEqual({ + cacheControl: "s-maxage=31536000, stale-while-revalidate", + cacheState: "STATIC", + }); + }); + it("resolves RSC response policy as no-store when dynamic usage is detected during build", () => { expect( resolveAppPageRscResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringBuild: true, isDraftMode: false, isDynamicError: false, @@ -114,13 +154,14 @@ describe("app page response helpers", () => { revalidateSeconds: 60, }), ).toEqual({ - cacheControl: "no-store, must-revalidate", + cacheControl: "private, no-cache, no-store, max-age=0, must-revalidate", }); }); it("resolves draft mode response policies as uncacheable", () => { expect( resolveAppPageRscResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringBuild: false, isDraftMode: true, isDynamicError: false, @@ -130,11 +171,12 @@ describe("app page response helpers", () => { revalidateSeconds: 60, }), ).toEqual({ - cacheControl: "no-store, must-revalidate", + cacheControl: "private, no-cache, no-store, max-age=0, must-revalidate", }); expect( resolveAppPageHtmlResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringRender: false, hasScriptNonce: false, isDraftMode: true, @@ -145,7 +187,7 @@ describe("app page response helpers", () => { revalidateSeconds: 60, }), ).toEqual({ - cacheControl: "no-store, must-revalidate", + cacheControl: "private, no-cache, no-store, max-age=0, must-revalidate", shouldWriteToCache: false, }); }); @@ -153,6 +195,7 @@ describe("app page response helpers", () => { it("resolves HTML response policy precedence", () => { expect( resolveAppPageHtmlResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringRender: true, hasScriptNonce: false, isDraftMode: false, @@ -163,12 +206,13 @@ describe("app page response helpers", () => { revalidateSeconds: 60, }), ).toEqual({ - cacheControl: "no-store, must-revalidate", + cacheControl: "private, no-cache, no-store, max-age=0, must-revalidate", shouldWriteToCache: false, }); expect( resolveAppPageHtmlResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringRender: false, hasScriptNonce: false, isDraftMode: false, @@ -186,6 +230,7 @@ describe("app page response helpers", () => { expect( resolveAppPageHtmlResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringRender: false, hasScriptNonce: false, isDraftMode: false, @@ -203,6 +248,7 @@ describe("app page response helpers", () => { expect( resolveAppPageHtmlResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringRender: false, hasScriptNonce: false, isDraftMode: false, @@ -222,6 +268,7 @@ describe("app page response helpers", () => { it("resolves HTML response policy when cache writes stay enabled", () => { expect( resolveAppPageHtmlResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringRender: false, hasScriptNonce: false, isDraftMode: false, @@ -241,6 +288,7 @@ describe("app page response helpers", () => { it("writes force-static HTML responses to cache in production", () => { expect( resolveAppPageHtmlResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringRender: false, hasScriptNonce: false, isDraftMode: false, @@ -258,6 +306,7 @@ describe("app page response helpers", () => { expect( resolveAppPageHtmlResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringRender: false, hasScriptNonce: false, isDraftMode: false, @@ -277,6 +326,7 @@ describe("app page response helpers", () => { it("treats progressive action HTML responses as no-store", () => { expect( resolveAppPageHtmlResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringRender: false, isProgressiveActionRender: true, hasScriptNonce: false, @@ -293,9 +343,44 @@ describe("app page response helpers", () => { }); }); + it("keeps dev's no-store header for known-dynamic responses", () => { + const base = { + isDraftMode: false, + isDynamicError: false, + isForceStatic: false, + isProduction: false, + isStaticEligible: true, + revalidateSeconds: 60, + }; + for (const overrides of [ + { isDraftMode: true }, + { isForceDynamic: true }, + { isStaticEligible: false }, + { dynamicUsedDuringBuild: true }, + ]) { + expect( + resolveAppPageRscResponsePolicy({ + dynamicUsedDuringBuild: false, + isForceDynamic: false, + ...base, + ...overrides, + }), + ).toEqual({ cacheControl: "no-store, must-revalidate" }); + } + expect( + resolveAppPageHtmlResponsePolicy({ + ...base, + dynamicUsedDuringRender: true, + hasScriptNonce: false, + isForceDynamic: false, + }), + ).toEqual({ cacheControl: "no-store, must-revalidate", shouldWriteToCache: false }); + }); + it("treats revalidate = 0 as no-store in RSC response policy", () => { expect( resolveAppPageRscResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringBuild: false, isDraftMode: false, isDynamicError: false, @@ -305,12 +390,13 @@ describe("app page response helpers", () => { revalidateSeconds: 0, }), ).toEqual({ - cacheControl: "no-store, must-revalidate", + cacheControl: "private, no-cache, no-store, max-age=0, must-revalidate", }); // revalidate = 0 takes priority over isForceStatic expect( resolveAppPageRscResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringBuild: false, isDraftMode: false, isDynamicError: false, @@ -320,13 +406,14 @@ describe("app page response helpers", () => { revalidateSeconds: 0, }), ).toEqual({ - cacheControl: "no-store, must-revalidate", + cacheControl: "private, no-cache, no-store, max-age=0, must-revalidate", }); }); it("treats revalidate = 0 as no-store in HTML response policy", () => { expect( resolveAppPageHtmlResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringRender: false, hasScriptNonce: false, isDraftMode: false, @@ -337,13 +424,14 @@ describe("app page response helpers", () => { revalidateSeconds: 0, }), ).toEqual({ - cacheControl: "no-store, must-revalidate", + cacheControl: "private, no-cache, no-store, max-age=0, must-revalidate", shouldWriteToCache: false, }); // revalidate = 0 takes priority over isForceStatic expect( resolveAppPageHtmlResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringRender: false, hasScriptNonce: false, isDraftMode: false, @@ -354,7 +442,7 @@ describe("app page response helpers", () => { revalidateSeconds: 0, }), ).toEqual({ - cacheControl: "no-store, must-revalidate", + cacheControl: "private, no-cache, no-store, max-age=0, must-revalidate", shouldWriteToCache: false, }); }); @@ -362,6 +450,7 @@ describe("app page response helpers", () => { it("treats force-static with explicit revalidate as ISR in both policy helpers", () => { expect( resolveAppPageRscResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringBuild: false, isDraftMode: false, isDynamicError: false, @@ -377,6 +466,7 @@ describe("app page response helpers", () => { expect( resolveAppPageHtmlResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringRender: false, hasScriptNonce: false, isDraftMode: false, @@ -396,6 +486,7 @@ describe("app page response helpers", () => { it("treats HTML responses with a script nonce as no-store", () => { expect( resolveAppPageHtmlResponsePolicy({ + isStaticEligible: true, dynamicUsedDuringRender: false, hasScriptNonce: true, isDraftMode: false, diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index 135c20422f..d7f11013f5 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -35,10 +35,8 @@ import { getRequestContext, runWithRequestContext, } from "../packages/vinext/src/shims/unified-request-context.js"; -import { - APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS, - buildPageElements as buildResolvedPageElements, -} from "../packages/vinext/src/server/app-page-element-builder.js"; +import { buildPageElements as buildResolvedPageElements } from "../packages/vinext/src/server/app-page-element-builder.js"; +import { APP_PAGE_INTERCEPTION_MARKER_TRAVERSALS } from "../packages/vinext/src/server/app-page-interception-markers.js"; import { createNextBfcacheIdMap } from "../packages/vinext/src/server/app-bfcache-identity.js"; import type { AppPageSemanticSegment } from "../packages/vinext/src/server/app-page-segment-state.js"; import { createAppPageRenderDependency } from "../packages/vinext/src/server/app-render-dependency.js"; diff --git a/tests/app-route-graph.test.ts b/tests/app-route-graph.test.ts index 48991d2cb9..6989dcd12c 100644 --- a/tests/app-route-graph.test.ts +++ b/tests/app-route-graph.test.ts @@ -1639,6 +1639,82 @@ describe("App Router route graph builder", () => { }); }); + it("starts a slot intercept's layout chain with the slot's layouts above the marker", async () => { + // Next.js builds the loader tree of app/@modal/gallery/(.)photo/page.tsx + // from every folder on its path, so app/@modal/gallery/layout.tsx wraps + // the (.)photo branch outside the marker's own layout. + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "gallery/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "gallery/photo/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "@modal/default.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "@modal/gallery/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "@modal/gallery/(.)photo/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "@modal/gallery/(.)photo/page.tsx", EMPTY_PAGE); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const intercept = findRoute(graph.routes, "/gallery").parallelSlots[0] + ?.interceptingRoutes[0]; + + expect(intercept?.layoutPaths).toEqual([ + canonical(appDir, "@modal/gallery/layout.tsx"), + canonical(appDir, "@modal/gallery/(.)photo/layout.tsx"), + ]); + expect(intercept?.layoutSegments).toEqual([["gallery"], ["gallery", "photo"]]); + expect(intercept?.branchSegments).toEqual(["gallery", "photo"]); + }); + }); + + it("starts a sibling intercept's layout chain with the folders between its source and the marker", async () => { + // app/feed/(shell)/(.)photo/page.tsx replaces app/feed/page.tsx, and + // Next.js's intercepting route tree continues below app/feed through + // (shell), so its layout and loading wrap the (.)photo branch. + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "feed/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "photo/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "feed/(shell)/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "feed/(shell)/loading.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "feed/(shell)/(.)photo/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "feed/(shell)/(.)photo/loading.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "feed/(shell)/(.)photo/page.tsx", EMPTY_PAGE); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const intercept = findRoute(graph.routes, "/feed").siblingIntercepts[0]; + + expect(intercept?.layoutPaths).toEqual([ + canonical(appDir, "feed/(shell)/layout.tsx"), + canonical(appDir, "feed/(shell)/(.)photo/layout.tsx"), + ]); + expect(intercept?.layoutSegments).toEqual([["(shell)"], ["(shell)", "photo"]]); + expect(intercept?.branchSegments).toEqual(["(shell)", "photo"]); + expect(intercept?.loadingPaths).toEqual([ + canonical(appDir, "feed/(shell)/loading.tsx"), + canonical(appDir, "feed/(shell)/(.)photo/loading.tsx"), + ]); + expect(intercept?.loadingTreePositions).toEqual([1, 2]); + // Its not-found position still counts from the marker. + expect(intercept?.notFoundBranchSegments).toEqual(["photo"]); + }); + }); + + it("keeps a sibling intercept in its source's folder rooted at the marker", async () => { + await withTempApp(async (appDir) => { + await writeAppFile(appDir, "layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "feed/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "photo/page.tsx", EMPTY_PAGE); + await writeAppFile(appDir, "feed/(.)photo/layout.tsx", EMPTY_LAYOUT); + await writeAppFile(appDir, "feed/(.)photo/page.tsx", EMPTY_PAGE); + + const graph = await buildAppRouteGraph(appDir, createValidFileMatcher()); + const intercept = findRoute(graph.routes, "/feed").siblingIntercepts[0]; + + expect(intercept?.layoutPaths).toEqual([canonical(appDir, "feed/(.)photo/layout.tsx")]); + expect(intercept?.layoutSegments).toEqual([["photo"]]); + expect(intercept?.branchSegments).toEqual(["photo"]); + }); + }); + it("includes dynamic ancestor params for (.) slot with a dynamic ancestor segment", async () => { // Regression for the double-conversion bug: raw filesystem segments must be // passed as baseParts so that [locale] is not converted to :locale before diff --git a/tests/app-route-module-loader.test.ts b/tests/app-route-module-loader.test.ts index f7aad85f3b..889eda4446 100644 --- a/tests/app-route-module-loader.test.ts +++ b/tests/app-route-module-loader.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { ensureAppRouteModulesLoaded, loadAppInterceptNotFound, + loadAppInterceptOwnerDefault, loadAppInterceptPage, loadAppInterceptLayouts, type LazyLoadableRoute, @@ -213,6 +214,33 @@ describe("ensureAppRouteModulesLoaded", () => { expect(route.slots?.["@modal"].configLayouts).toEqual([nestedSlotLayout]); expect(route.slots?.["@modal"].loadings).toEqual([nestedSlotLoading]); }); + + it("leaves a slot intercept's owner default to the matched intercept", async () => { + // app/feed/default.tsx replaces app/feed's children only in the tree of + // app/feed/@modal/(.)photos/[id], so a direct /feed request never + // evaluates it. + const feedDefault = { default: () => null, dynamic: "force-static" }; + const __loadOwnerDefault = vi.fn(async () => feedDefault); + const intercept = { ownerDefault: null, __loadOwnerDefault }; + const route: LazyLoadableRoute = { + slots: { + "@modal": { + page: null, + __loadPage: async () => ({ default: () => null }), + intercepts: [intercept], + }, + }, + }; + + await ensureAppRouteModulesLoaded(route); + + expect(__loadOwnerDefault).not.toHaveBeenCalled(); + expect(intercept.ownerDefault).toBeNull(); + + await loadAppInterceptOwnerDefault(intercept); + + expect(intercept.ownerDefault).toBe(feedDefault); + }); }); describe("loadAppInterceptLayouts", () => { @@ -245,6 +273,7 @@ describe("loadAppInterceptLayouts", () => { describe.each([ ["page", "__pageLoader", "pageLoading", loadAppInterceptPage], ["notFound", "__loadNotFound", "notFoundLoading", loadAppInterceptNotFound], + ["ownerDefault", "__loadOwnerDefault", "ownerDefaultLoading", loadAppInterceptOwnerDefault], ] as const)("loadAppIntercept%s", (field, loaderField, loadingField, load) => { it("publishes a shared concurrent load onto every request-local intercept clone", async () => { let release!: () => void; @@ -261,6 +290,8 @@ describe.each([ pageLoading: null, notFound: null, notFoundLoading: null, + ownerDefault: null, + ownerDefaultLoading: null, interceptLayoutsLoading: null, }; const first = { diff --git a/tests/app-router-dev-server.test.ts b/tests/app-router-dev-server.test.ts index 181a554cbd..47a4dfa23a 100644 --- a/tests/app-router-dev-server.test.ts +++ b/tests/app-router-dev-server.test.ts @@ -1,5 +1,6 @@ import http from "node:http"; import fsp from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { type ViteDevServer } from "vite"; import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; @@ -2584,3 +2585,87 @@ describe("App Router public files whose route starts with basePath in dev", () = expect(await res.text()).toBe("Method Not Allowed"); }); }); + +describe("App Router slot intercept owner default in dev", () => { + let server: ViteDevServer; + let baseUrl: string; + let root: string; + let evaluationKey: string; + + beforeAll(async () => { + root = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-owner-default-")); + evaluationKey = `vinext-owner-default:${root}`; + const write = async (file: string, code: string) => { + await fsp.mkdir(path.dirname(path.join(root, file)), { recursive: true }); + await fsp.writeFile(path.join(root, file), code); + }; + await fsp.symlink( + path.resolve(import.meta.dirname, "../node_modules"), + path.join(root, "node_modules"), + "junction", + ); + await write("package.json", JSON.stringify({ type: "module", private: true })); + await write( + "app/layout.tsx", + "export default function Layout({ children }) { return {children}; }", + ); + await write( + "app/feed/layout.tsx", + "export default function Layout({ children, modal }) { return <>{children}{modal}; }", + ); + await write( + "app/feed/page.tsx", + 'export const dynamic = "force-static";\nexport default function Page() { return
Feed page
; }', + ); + // Only the intercepting route's tree holds app/feed/default.tsx, in place + // of app/feed's children. + await write( + "app/feed/default.tsx", + `globalThis[${JSON.stringify(evaluationKey)}] = (globalThis[${JSON.stringify(evaluationKey)}] ?? 0) + 1; +export const dynamic = "force-static"; +export default function Default() { return null; }`, + ); + await write( + "app/feed/@modal/default.tsx", + "export default function Default() { return null; }", + ); + await write( + "app/feed/@modal/(..)photos/[id]/page.tsx", + "export default function Modal() { return
Photo modal
; }", + ); + await write( + "app/photos/[id]/page.tsx", + "export default function Photo() { return
Photo page
; }", + ); + ({ server, baseUrl } = await startFixtureServer(root, { appRouter: true })); + }, 30000); + + afterAll(async () => { + await server?.close(); + Reflect.deleteProperty(globalThis, evaluationKey); + if (root) await fsp.rm(root, { recursive: true, force: true }); + }); + + it("evaluates the owner's default only for an intercepted request", async () => { + const direct = await fetch(`${baseUrl}/feed`); + expect(direct.status).toBe(200); + expect(await direct.text()).toContain("Feed page"); + expect(Reflect.get(globalThis, evaluationKey)).toBeUndefined(); + + // The force-static default takes app/feed's children in the intercepting + // route's tree, so the auto modal without generateStaticParams is static. + // Without it, that tree's children would be the built-in default-null. + const intercepted = await fetch(`${baseUrl}/photos/1.rsc`, { + headers: { + Accept: "text/x-component", + "X-Vinext-Interception-Context": "/feed", + }, + }); + expect(intercepted.status).toBe(200); + expect(await intercepted.text()).toContain("Photo modal"); + expect(Reflect.get(globalThis, evaluationKey)).toBe(1); + expect(intercepted.headers.get("cache-control")).not.toBe( + "private, no-cache, no-store, max-age=0, must-revalidate", + ); + }); +}); diff --git a/tests/app-router-isr-codegen.test.ts b/tests/app-router-isr-codegen.test.ts index 302091e9ed..f28bf9e658 100644 --- a/tests/app-router-isr-codegen.test.ts +++ b/tests/app-router-isr-codegen.test.ts @@ -1,6 +1,21 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { toSlash } from "pathslash"; import { describe, expect, it } from "vite-plus/test"; +import vm from "node:vm"; import { generateRscEntry } from "../packages/vinext/src/entries/app-rsc-entry.js"; -import type { AppRoute } from "../packages/vinext/src/routing/app-router.js"; +import { SIBLING_PAGE_INTERCEPT_SLOT_KEY } from "../packages/vinext/src/server/app-rsc-route-matching.js"; +import * as appSegmentConfig from "../packages/vinext/src/server/app-segment-config.js"; +import { + resolveAppPageGenerateStaticParamsSources, + validateAppPageDynamicParams, +} from "../packages/vinext/src/server/app-page-request.js"; +import { + appRouter, + invalidateAppRouteCache, + type AppRoute, +} from "../packages/vinext/src/routing/app-router.js"; describe("generateRscEntry ISR code generation", () => { // Minimal route list — only the generated ISR guard logic matters here @@ -32,6 +47,494 @@ describe("generateRscEntry ISR code generation", () => { expect(code).toContain('process.env.NODE_ENV === "production"'); }); + it("classifies static generation from the route's own segments and slots", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + // The generateStaticParams walk reads the main tree and every slot branch, + // including each slot's owner position and whether it (or children) + // renders its default. + expect(code) + .toContain(`hasGenerateStaticParams: __hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + childrenSlot: route.childrenSlot, + layouts: route.layouts, + layoutTreePositions: route.layoutTreePositions, + page: route.page, + parallelBranches: segmentConfigBranches, + routeSegments: route.routeSegments, + }),`); + // An intercept swaps a sibling slot for its default. + expect(code).toContain("default: slot.default,"); + expect(code).toContain("isDefault: !slot.page,"); + expect(code).toContain("ownerTreePosition: slot.ownerTreePosition,"); + // Any segment's generator still sets the route's revalidate default. + expect(code).toContain("hasAnyGenerateStaticParams: __generateStaticParams.length > 0,"); + // ...and an intercepting tree's generators set its own, read from Next.js's + // tree for the intercepting route. + expect(code).toContain(`function __resolveRouteHasAnyGenerateStaticParams(route, intercept) { + const tree = __resolveRouteInterceptTree(route, intercept); + return __hasAppPageAnyGenerateStaticParams({ + childrenSlot: tree.route.childrenSlot, + layouts: tree.route.layouts, + layoutTreePositions: tree.route.layoutTreePositions, + page: tree.route.page, + parallelBranches: tree.branches, + routeSegments: tree.route.routeSegments, + }); +}`); + expect(code).toContain(`resolveRouteHasAnyGenerateStaticParams(targetRoute, intercept) { + return __resolveRouteHasAnyGenerateStaticParams(targetRoute, intercept); + },`); + // The runtime merges across the whole loader tree, slots included. + expect(code).toContain(`isStaticGenerationEdgeRuntime: __isEdgeRuntime( + __resolveAppPageStaticGenerationRuntime( + __collectAppPageStaticGenerationRuntimes({ + childrenSlot: route.childrenSlot, + layouts: route.layouts, + layoutTreePositions: route.layoutTreePositions, + page: route.page, + parallelBranches: segmentConfigBranches, + routeSegments: route.routeSegments, + }), + ), + ),`); + // The matched route and any other route dispatch renders, such as an + // intercept's source, are classified from the same inputs. + expect(code).toContain( + "const __staticGeneration = __resolveRouteStaticGeneration(route, __segmentConfigBranches);", + ); + expect(code).toContain("hasGenerateStaticParams: __staticGeneration.hasGenerateStaticParams,"); + expect(code).toContain( + "isStaticGenerationEdgeRuntime: __staticGeneration.isStaticGenerationEdgeRuntime,", + ); + expect(code).toContain(`resolveRouteStaticEligible(targetRoute, intercept) { + return __resolveRouteStaticEligible(targetRoute, intercept); + },`); + // A direct intercepted RSC response classifies the source's tree with the + // intercepting branch in the intercepted slot, and is dynamic when the + // intercepted route is. + expect(code).toContain( + `slotIndex: Object.keys(route.slots ?? {}).indexOf(intercept.interceptSlotKey),`, + ); + // ...and its own folder's children for that folder's default, which only + // the matched intercept carries. + expect(code).toContain("interceptOwnerDefault: intercept.interceptOwnerDefault,"); + // A sibling-page intercept is told apart from a slot the source lacks. + expect(code).toContain( + "isSiblingPageIntercept: intercept.interceptSlotKey === __SIBLING_PAGE_INTERCEPT_SLOT_KEY,", + ); + // Next.js's tree swaps the other slots for their defaults, but vinext + // renders their active pages, so both trees must be static. + expect(code).toContain(` return [false, true].every((keepActiveSiblings) => + __isRouteTreeStaticEligible(`); + expect(code).toContain("__resolveRouteInterceptTree(route, intercept, keepActiveSiblings),"); + expect(code).toContain(" keepActiveSiblings,\n"); + expect(code).toContain("const effectiveRoute = tree?.route ?? route;"); + // The direct intercept render's dynamic config, revalidate and fetchCache + // come from the same trees: Next.js's, and the active siblings vinext + // renders in place of its defaults. + expect(code).toContain(`resolveRouteFetchCacheMode(targetRoute, intercept) { + return __resolveRouteFetchCacheMode(targetRoute, intercept); + },`); + expect(code).toContain(`function __resolveRouteFetchCacheMode(route, intercept) { + if (intercept) { + return __resolveRouteInterceptSegmentConfig(route, intercept).fetchCache ?? null; + }`); + expect(code).toContain( + "return __resolveRouteInterceptSegmentConfig(route, intercept).dynamicConfig ?? null;", + ); + expect(code).toContain( + "return __resolveRouteInterceptSegmentConfig(route, intercept).revalidateSeconds;", + ); + expect(code) + .toContain(` const [interceptTree, renderedTree] = [false, true].map((keepActiveSiblings) => { + const tree = __resolveRouteInterceptTree(route, intercept, keepActiveSiblings);`); + expect(code).toContain( + "return __resolveAppPageInterceptSegmentConfig(interceptTree, renderedTree);", + ); + expect(code).toContain(`resolveRouteDynamicConfig(targetRoute, intercept) { + return __resolveRouteDynamicConfig(targetRoute, intercept); + },`); + expect(code).toContain(`resolveRouteRevalidateSeconds(targetRoute, intercept) { + return __resolveRouteRevalidateSeconds(targetRoute, intercept); + },`); + // A Server Action rerender of that response resolves the same trees, with + // the owner default a slot intercept's tree renders. + for (const name of ["FetchCacheMode", "RevalidateSeconds", "DynamicConfig"]) { + expect(code.split(`return __resolveRoute${name}(targetRoute, intercept);`)).toHaveLength(3); + } + expect(code).toContain("interceptOwnerDefault: intercept.ownerDefault,"); + // ...and so does the dynamic stale time its RSC metadata advertises. + expect(code).toContain(`function __resolveRouteDynamicStaleTimeSeconds(route, intercept) { + if (intercept) { + return __resolveRouteInterceptSegmentConfig(route, intercept).dynamicStaleTimeSeconds; + } + return __resolveRouteSegmentConfig(route, __resolveRouteSegmentConfigBranches(route)) + .dynamicStaleTimeSeconds; +}`); + expect(code).toContain(`resolveRouteDynamicStaleTimeSeconds(targetRoute, intercept) { + return __resolveRouteDynamicStaleTimeSeconds(targetRoute, intercept); + },`); + // A slot intercept whose slot the source lacks leaves the source's tree as + // rendered, so the unused intercepted route's dynamism doesn't apply. + expect(code).toContain( + "if (!__isAppPageInterceptAttached(__resolveRouteInterceptTreeOptions(route, intercept))) {", + ); + // Its headers precede its render, so the direct intercept probes run what + // that render includes for the request's mounted slots and render mode. + // A source without the intercept's slot loads none of its modules. + expect(code).toContain(`const __probeIntercept = __resolveAppPageProbeIntercept( + sourceRoute, + findIntercept(interceptionPathname, interceptionContext, interceptionId), + );`); + expect(code).toContain(`return Promise.all(__buildAppPageInterceptSourceProbes({ + route: sourceRoute, + pageComponent: sourceRoute.page?.default, + intercept: __probeIntercept, + sourceParams, + // The intercepted render matches inherited slots' params against + // the request path, as buildPageElements does. + slotParamOverrides: __resolveSlotParamOverrides(sourceRoute, cleanPathname), + searchParams: sourceSearchParams, + mountedSlotsHeader, + renderMode, + makeThenableParams, + }));`); + expect(code).toContain(` return __isAppPageStaticEligible({ + ...__resolveRouteStaticGeneration(effectiveRoute, segmentConfigBranches), + dynamicConfig: segmentConfig.dynamicConfig, + isDynamicRoute: intercept + ? __isAppPageInterceptTargetDynamic(intercept.interceptTargetPatternParts) + : route.isDynamic, + revalidateSeconds: segmentConfig.revalidateSeconds, + });`); + }); + + it("classifies a slot intercept the source can't take by the source alone", () => { + // Run the generated classification against the real segment-config helpers. + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + const start = code.indexOf("function __resolveRouteSegmentConfigBranches(route) {"); + const end = code.indexOf("\n}\n", code.indexOf("function __isRouteTreeStaticEligible(")) + 3; + const resolveRouteStaticEligible = vm.runInNewContext( + `${code.slice(start, end)}\n__resolveRouteStaticEligible;`, + { + __collectAppPageStaticGenerationRuntimes: + appSegmentConfig.collectAppPageStaticGenerationRuntimes, + __hasAppPageGenerateStaticParamsAtLastDynamicSegment: + appSegmentConfig.hasAppPageGenerateStaticParamsAtLastDynamicSegment, + __isAppPageInterceptTargetDynamic: appSegmentConfig.isAppPageInterceptTargetDynamic, + __isAppPageInterceptAttached: appSegmentConfig.isAppPageInterceptAttached, + __isAppPageStaticEligible: appSegmentConfig.isAppPageStaticEligible, + __isEdgeRuntime: appSegmentConfig.isEdgeRuntime, + __resolveAppPageInterceptTree: appSegmentConfig.resolveAppPageInterceptTree, + __resolveAppPageSegmentConfig: appSegmentConfig.resolveAppPageSegmentConfig, + __resolveAppPageStaticGenerationRuntime: + appSegmentConfig.resolveAppPageStaticGenerationRuntime, + __SIBLING_PAGE_INTERCEPT_SLOT_KEY: SIBLING_PAGE_INTERCEPT_SLOT_KEY, + }, + ) as (route: unknown, intercept?: unknown) => boolean; + // A static app/feed intercepting the dynamic app/photos/[id] through + // app/feed/@modal/(..)photos/[id], which has no generateStaticParams. + const feed = { + childrenSlot: { ownerTreePath: "/feed", state: "active" }, + isDynamic: false, + layouts: [{}, {}], + layoutTreePositions: [0, 1], + page: {}, + routeSegments: ["feed"], + slots: { + "modal@app/feed/@modal": { + default: {}, + layout: null, + name: "modal", + ownerTreePosition: 1, + page: null, + routeSegments: [], + }, + }, + }; + const intercept = { + interceptBranchSegments: ["(..)photos", "[id]"], + interceptPage: {}, + interceptSlotKey: "modal@app/feed/@modal", + interceptTargetPatternParts: ["photos", ":id"], + }; + + expect(resolveRouteStaticEligible(feed)).toBe(true); + expect(resolveRouteStaticEligible(feed, intercept)).toBe(false); + // Next.js's isDynamicRoute classifies the intercepting route by the route + // its folders intercept: app/feed/@modal/(..)about intercepts /about, + // even where app/[slug] is the route that matches it... + expect( + resolveRouteStaticEligible(feed, { + ...intercept, + interceptBranchSegments: ["(..)about"], + interceptTargetPatternParts: ["about"], + }), + ).toBe(true); + // ...and app/[lang]/feed/@modal/(...)photos intercepts the static /photos. + const langFeed = { + ...feed, + childrenSlot: { ownerTreePath: "/[lang]/feed", state: "active" }, + isDynamic: true, + layouts: [{}, {}, {}], + layoutTreePositions: [0, 1, 2], + routeSegments: ["[lang]", "feed"], + slots: { + "modal@app/feed/@modal": { ...feed.slots["modal@app/feed/@modal"], ownerTreePosition: 2 }, + }, + }; + expect(resolveRouteStaticEligible(langFeed)).toBe(false); + expect( + resolveRouteStaticEligible(langFeed, { + ...intercept, + interceptBranchSegments: ["(...)photos"], + interceptTargetPatternParts: ["photos"], + }), + ).toBe(true); + // A route-group variant of app/feed without @modal renders its own page. + expect(resolveRouteStaticEligible({ ...feed, slots: {} }, intercept)).toBe(true); + expect( + resolveRouteStaticEligible( + { ...feed, slots: {} }, + { ...intercept, interceptSlotKey: SIBLING_PAGE_INTERCEPT_SLOT_KEY }, + ), + ).toBe(false); + }); + + it("gates a current-route intercept's params by the intercepting tree's own dynamicParams and generators", async () => { + // Run the generated resolvers against the real segment-config helpers. + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + expect(code).toContain(`resolveRouteDynamicParamsConfig(targetRoute, intercept) { + return __resolveRouteDynamicParamsConfig(targetRoute, intercept); + },`); + expect(code).toContain(`resolveRouteGenerateStaticParams(targetRoute, intercept) { + return __resolveRouteGenerateStaticParams(targetRoute, intercept); + },`); + // The matched route's own gate reads the same generator walk. + expect(code).toContain( + "const __generateStaticParams = __resolveRouteGenerateStaticParams(route);", + ); + const start = code.indexOf("function __resolveRouteSegmentConfigBranches(route) {"); + const end = code.indexOf("\n}\n", code.indexOf("function __isRouteTreeStaticEligible(")) + 3; + const [resolveDynamicParamsConfig, resolveGenerateStaticParams] = vm.runInNewContext( + `${code.slice(start, end)}\n[__resolveRouteDynamicParamsConfig, __resolveRouteGenerateStaticParams];`, + { + __isAppPageInterceptTargetDynamic: appSegmentConfig.isAppPageInterceptTargetDynamic, + __isAppPageInterceptAttached: appSegmentConfig.isAppPageInterceptAttached, + __resolveAppPageGenerateStaticParamsSources: resolveAppPageGenerateStaticParamsSources, + __resolveAppPageInterceptTree: appSegmentConfig.resolveAppPageInterceptTree, + __resolveAppPageSegmentConfig: appSegmentConfig.resolveAppPageSegmentConfig, + __SIBLING_PAGE_INTERCEPT_SLOT_KEY: SIBLING_PAGE_INTERCEPT_SLOT_KEY, + }, + ) as [ + (route: unknown, intercept: unknown) => boolean | undefined, + ( + route: unknown, + intercept?: unknown, + ) => ReturnType, + ]; + // app/feed/[slug]/page.tsx and app/feed/[slug]/@modal/(..)[slug]/page.tsx, + // which intercepts /feed/[slug] from itself. The intercepting tree drops + // the route's page for app/feed/[slug]/default.tsx. + function resolveGate(page: object, interceptPage: object, hasModalSlot = true) { + const route = { + childrenSlot: { ownerTreePath: "/feed/[slug]", state: "active" }, + isDynamic: true, + layouts: [{}], + layoutTreePositions: [0], + page, + patternParts: ["feed", ":slug"], + routeSegments: ["feed", "[slug]"], + slots: hasModalSlot + ? { + "modal@app/feed/[slug]/@modal": { + default: {}, + layout: null, + name: "modal", + ownerTreePosition: 2, + page: null, + routeSegments: [], + }, + } + : {}, + }; + const intercept = { + interceptBranchSegments: ["(..)[slug]"], + interceptOwnerDefault: {}, + interceptPage, + interceptSlotKey: "modal@app/feed/[slug]/@modal", + }; + const dynamicParamsConfig = resolveDynamicParamsConfig(route, intercept); + const generateStaticParams = resolveGenerateStaticParams(route, intercept); + return (slug: string) => + validateAppPageDynamicParams({ + enforceStaticParamsOnly: dynamicParamsConfig === false, + generateStaticParams, + isDynamicRoute: true, + params: { slug }, + }); + } + const generate = (slug: string) => () => [{ slug }]; + const allowsKnown = { dynamicParams: false, generateStaticParams: generate("known") }; + const allowsOther = { dynamicParams: false, generateStaticParams: generate("other") }; + + // The intercepting tree's dynamicParams = false over a route that allows + // fallback params. + const interceptForbids = resolveGate({}, allowsKnown); + await expect(interceptForbids("known")).resolves.toBeNull(); + await expect(interceptForbids("unknown")).resolves.toMatchObject({ status: 404 }); + // An intercepting tree that allows fallback params over a route that + // doesn't. + await expect(resolveGate(allowsKnown, {})("unknown")).resolves.toBeNull(); + // Both forbid them, but only the intercepting tree's generators apply. + const bothForbid = resolveGate(allowsKnown, allowsOther); + await expect(bothForbid("other")).resolves.toBeNull(); + await expect(bothForbid("known")).resolves.toMatchObject({ status: 404 }); + // A route-group variant without @modal renders its own page. + const unattached = resolveGate(allowsKnown, allowsOther, false); + await expect(unattached("known")).resolves.toBeNull(); + await expect(unattached("other")).resolves.toMatchObject({ status: 404 }); + }); + + it("gates a renamed intercepting param by every param of the intercepting route's path", async () => { + // Run the generated resolvers against the real segment-config helpers. + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + const start = code.indexOf("function __resolveRouteSegmentConfigBranches(route) {"); + const end = code.indexOf("\n}\n", code.indexOf("function __isRouteTreeStaticEligible(")) + 3; + const [resolveDynamicParamsConfig, resolveGenerateStaticParams] = vm.runInNewContext( + `${code.slice(start, end)}\n[__resolveRouteDynamicParamsConfig, __resolveRouteGenerateStaticParams];`, + { + __isAppPageInterceptTargetDynamic: appSegmentConfig.isAppPageInterceptTargetDynamic, + __isAppPageInterceptAttached: appSegmentConfig.isAppPageInterceptAttached, + __resolveAppPageGenerateStaticParamsSources: resolveAppPageGenerateStaticParamsSources, + __resolveAppPageInterceptTree: appSegmentConfig.resolveAppPageInterceptTree, + __resolveAppPageSegmentConfig: appSegmentConfig.resolveAppPageSegmentConfig, + __SIBLING_PAGE_INTERCEPT_SLOT_KEY: SIBLING_PAGE_INTERCEPT_SLOT_KEY, + }, + ) as [ + (route: unknown, intercept: unknown) => boolean | undefined, + ( + route: unknown, + intercept: unknown, + ) => ReturnType, + ]; + // app/feed/[slug]/layout.tsx generates slug "a", and + // app/feed/[slug]/@modal/(..)[photo]/page.tsx generates photo "known". + // Next.js serves /feed/known from /feed/a as /feed/[slug]/(..)[photo], + // whose path takes slug from the source and photo from the URL, and + // prerenders /feed/a/(..)known. + const route = { + childrenSlot: { ownerTreePath: "/feed/[slug]", state: "active" }, + isDynamic: true, + layouts: [{ generateStaticParams: () => [{ slug: "a" }] }], + layoutTreePositions: [2], + page: {}, + patternParts: ["feed", ":slug"], + routeSegments: ["feed", "[slug]"], + slots: { + "modal@app/feed/[slug]/@modal": { + default: {}, + layout: null, + name: "modal", + ownerTreePosition: 2, + page: null, + routeSegments: [], + }, + }, + }; + const intercept = { + interceptBranchSegments: ["(..)[photo]"], + interceptOwnerDefault: {}, + interceptPage: { dynamicParams: false, generateStaticParams: () => [{ photo: "known" }] }, + interceptSlotKey: "modal@app/feed/[slug]/@modal", + }; + const dynamicParamsConfig = resolveDynamicParamsConfig(route, intercept); + const generateStaticParams = resolveGenerateStaticParams(route, intercept); + const validate = (params: Record) => + validateAppPageDynamicParams({ + enforceStaticParamsOnly: dynamicParamsConfig === false, + generateStaticParams, + isDynamicRoute: true, + params, + }); + + await expect(validate({ photo: "known", slug: "a" })).resolves.toBeNull(); + await expect(validate({ photo: "unknown", slug: "a" })).resolves.toMatchObject({ + status: 404, + }); + await expect(validate({ photo: "known", slug: "b" })).resolves.toMatchObject({ + status: 404, + }); + }); + + it("exempts a current-route intercept's params gate only by the intercepting tree's own force-dynamic", () => { + // Run the generated resolvers against the real segment-config helpers. + const code = generateRscEntry("/tmp/test/app", minimalRoutes); + expect(code).toContain(`resolveRouteInterceptTreeDynamicConfig(targetRoute, intercept) { + return __resolveRouteInterceptTreeDynamicConfig(targetRoute, intercept); + },`); + const start = code.indexOf("function __resolveRouteSegmentConfigBranches(route) {"); + const end = code.indexOf("\n}\n", code.indexOf("function __isRouteTreeStaticEligible(")) + 3; + const [resolveInterceptSegmentConfig, resolveInterceptTreeDynamicConfig] = vm.runInNewContext( + `${code.slice(start, end)}\n[__resolveRouteInterceptSegmentConfig, __resolveRouteInterceptTreeDynamicConfig];`, + { + __resolveAppPageInterceptSegmentConfig: + appSegmentConfig.resolveAppPageInterceptSegmentConfig, + __resolveAppPageInterceptTree: appSegmentConfig.resolveAppPageInterceptTree, + __resolveAppPageSegmentConfig: appSegmentConfig.resolveAppPageSegmentConfig, + __SIBLING_PAGE_INTERCEPT_SLOT_KEY: SIBLING_PAGE_INTERCEPT_SLOT_KEY, + }, + ) as [ + (route: unknown, intercept: unknown) => { dynamicConfig?: string }, + (route: unknown, intercept: unknown) => string | null, + ]; + // app/feed/[slug]/page.tsx and app/feed/[slug]/@modal/(..)[slug]/page.tsx, + // which intercepts /feed/[slug] from itself. Next.js's intercepting tree + // has app/feed/[slug]/default.tsx where vinext renders the route's page. + function resolveDynamicConfigs(page: object, interceptPage: object) { + const route = { + childrenSlot: { ownerTreePath: "/feed/[slug]", state: "active" }, + isDynamic: true, + layouts: [{}], + layoutTreePositions: [0], + page, + patternParts: ["feed", ":slug"], + routeSegments: ["feed", "[slug]"], + slots: { + "modal@app/feed/[slug]/@modal": { + default: {}, + layout: null, + name: "modal", + ownerTreePosition: 2, + page: null, + routeSegments: [], + }, + }, + }; + const intercept = { + interceptBranchSegments: ["(..)[slug]"], + interceptOwnerDefault: {}, + interceptPage, + interceptSlotKey: "modal@app/feed/[slug]/@modal", + }; + return { + rendered: resolveInterceptSegmentConfig(route, intercept).dynamicConfig, + route: resolveInterceptTreeDynamicConfig(route, intercept), + }; + } + const forceDynamic = { dynamic: "force-dynamic" }; + + // The route's force-dynamic page makes the render dynamic, not the + // intercepting route, whose manifest entry keeps its params gate. + expect(resolveDynamicConfigs(forceDynamic, { dynamicParams: false })).toEqual({ + rendered: "force-dynamic", + route: null, + }); + expect(resolveDynamicConfigs({}, forceDynamic)).toEqual({ + rendered: "force-dynamic", + route: "force-dynamic", + }); + }); + it("generated handler delegates request and ctx handling to createAppRscHandler", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes); expect(code).toContain("createAppRscHandler"); @@ -158,6 +661,145 @@ describe("generateRscEntry ISR code generation", () => { expect(code).not.toMatch(/__loadInterceptLayouts:\s*\[\s*"\/tmp\/test\/app/); }); + it("generated code loads the owner's default only with a slot intercept", async () => { + const appDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-owner-default-")); + try { + for (const file of [ + "layout.tsx", + "feed/page.tsx", + "feed/default.tsx", + "feed/@modal/default.tsx", + "feed/@modal/(.)photos/[id]/page.tsx", + "feed/@sidebar/page.tsx", + "feed/@sidebar/default.tsx", + "photos/[id]/page.tsx", + ]) { + fs.mkdirSync(path.dirname(path.join(appDir, file)), { recursive: true }); + fs.writeFileSync( + path.join(appDir, file), + "export default function Page() { return null; }\n", + ); + } + invalidateAppRouteCache(); + const routes = await appRouter(appDir); + const code = generateRscEntry(appDir, routes); + + const feedEntry = code.slice(code.indexOf('pattern: "/feed"')); + const slotEntry = (slotKey: string) => { + const start = feedEntry.indexOf(`${JSON.stringify(slotKey)}: {`); + return feedEntry + .slice(start, feedEntry.indexOf("\n ],", start)) + .split("intercepts: ["); + }; + // @modal intercepts, so app/feed/default.tsx replaces its children in + // the intercepting route's tree. Its loader sits on the intercept, which + // hydrates only once matched, not with the slot's modules on every /feed + // request. @sidebar doesn't intercept, so nothing loads it at all. + const [modalFields, modalIntercepts] = slotEntry("modal@feed/@modal"); + expect(modalFields).not.toContain("__loadOwnerDefault"); + const ownerDefaultLoader = modalIntercepts.match(/__loadOwnerDefault: (\w+),/)?.[1]; + expect(code).toContain( + `const ${ownerDefaultLoader} = () => import(${JSON.stringify(path.join(appDir, "feed/default.tsx"))});`, + ); + expect(slotEntry("sidebar@feed/@sidebar").join("")).not.toContain("__loadOwnerDefault"); + } finally { + invalidateAppRouteCache(); + fs.rmSync(appDir, { recursive: true, force: true }); + } + }); + + it("generated code carries a slot intercept's layouts above the marker", async () => { + const appDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-intercept-ancestors-")); + try { + for (const file of [ + "layout.tsx", + "gallery/page.tsx", + "gallery/photo/page.tsx", + "@modal/default.tsx", + "@modal/gallery/layout.tsx", + "@modal/gallery/(.)photo/layout.tsx", + "@modal/gallery/(.)photo/page.tsx", + ]) { + fs.mkdirSync(path.dirname(path.join(appDir, file)), { recursive: true }); + fs.writeFileSync( + path.join(appDir, file), + "export default function Page() { return null; }\n", + ); + } + invalidateAppRouteCache(); + const routes = await appRouter(appDir); + const code = generateRscEntry(appDir, routes); + + // app/@modal/gallery/layout.tsx sits above the (.)photo marker: Next.js's + // intercepting route tree holds it at the slot's first folder, outside + // the marker's own layout, so the intercept's layout chain starts with it. + const galleryEntry = code.slice(code.indexOf('pattern: "/gallery"')); + const intercept = galleryEntry.slice(galleryEntry.indexOf("interceptLayouts:")); + expect(intercept).toMatch(/^interceptLayouts: \[null, null\],/); + expect(intercept).toContain('interceptLayoutSegments: [["gallery"],["gallery","photo"]],'); + const layoutLoaders = intercept.match(/__loadInterceptLayouts: \[(\w+), (\w+)\],/); + const lazyImports = new Map( + Array.from(code.matchAll(/^const (\w+) = \(\) => import\(("[^"\n]*")\);$/gm), (match) => [ + match[1], + JSON.parse(match[2]) as string, + ]), + ); + expect(lazyImports.get(layoutLoaders?.[1] ?? "")).toBe( + toSlash(path.join(appDir, "@modal/gallery/layout.tsx")), + ); + expect(lazyImports.get(layoutLoaders?.[2] ?? "")).toBe( + toSlash(path.join(appDir, "@modal/gallery/(.)photo/layout.tsx")), + ); + } finally { + invalidateAppRouteCache(); + fs.rmSync(appDir, { recursive: true, force: true }); + } + }); + + it("generated code carries a sibling intercept's layouts between its source and the marker", async () => { + const appDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-sibling-intercept-ancestors-")); + try { + for (const file of [ + "layout.tsx", + "feed/page.tsx", + "photo/page.tsx", + "feed/(shell)/layout.tsx", + "feed/(shell)/(.)photo/page.tsx", + ]) { + fs.mkdirSync(path.dirname(path.join(appDir, file)), { recursive: true }); + fs.writeFileSync( + path.join(appDir, file), + "export default function Page() { return null; }\n", + ); + } + invalidateAppRouteCache(); + const routes = await appRouter(appDir); + const code = generateRscEntry(appDir, routes); + + // app/feed/(shell)/layout.tsx sits between the source page's folder and + // the (.)photo marker, so Next.js's intercepting route tree holds it and + // a force-dynamic export there makes the intercepted response dynamic. + const feedEntry = code.slice(code.indexOf('pattern: "/feed"')); + const intercept = feedEntry.slice(feedEntry.indexOf("interceptLayouts:")); + expect(intercept).toMatch(/^interceptLayouts: \[null\],/); + expect(intercept).toContain('interceptLayoutSegments: [["(shell)"]],'); + expect(intercept).toContain('interceptBranchSegments: ["(shell)","photo"],'); + const layoutLoader = intercept.match(/__loadInterceptLayouts: \[(\w+)\],/); + const lazyImports = new Map( + Array.from(code.matchAll(/^const (\w+) = \(\) => import\(("[^"\n]*")\);$/gm), (match) => [ + match[1], + JSON.parse(match[2]) as string, + ]), + ); + expect(lazyImports.get(layoutLoader?.[1] ?? "")).toBe( + toSlash(path.join(appDir, "feed/(shell)/layout.tsx")), + ); + } finally { + invalidateAppRouteCache(); + fs.rmSync(appDir, { recursive: true, force: true }); + } + }); + it("generated code seeds root params around prerender generateStaticParams", () => { const routeWithRootParams: AppRoute = { errorPath: null, diff --git a/tests/app-rsc-cache-busting.test.ts b/tests/app-rsc-cache-busting.test.ts index d18121b65b..e83c3ea794 100644 --- a/tests/app-rsc-cache-busting.test.ts +++ b/tests/app-rsc-cache-busting.test.ts @@ -15,6 +15,7 @@ import { isRscCompatibilityIdCompatible, resolveInvalidRscCacheBustingRequest, setRscCacheBustingSearchParam, + keepOnlyValidatedRscCacheBustingSearchParam, stripRscCacheBustingSearchParam, VINEXT_RSC_BUILD_ID_HEADER, VINEXT_RSC_COMPATIBILITY_ID_HEADER, @@ -652,3 +653,17 @@ describe("App Router RSC cache-busting", () => { expect(isRscCompatibilityIdCompatible("compat-a", null)).toBe(true); }); }); + +describe("keepOnlyValidatedRscCacheBustingSearchParam", () => { + it.each([ + ["?tab=1&_rsc=abc&_rsc=ignored&%5Frsc=encoded", true, "?_rsc=abc"], + ["?%5Frsc=abc&tab=1", true, "?_rsc=abc"], + ["?tab=1&_rsc=", true, "?_rsc"], + ["?tab=1", true, ""], + ["?tab=1&_rsc=abc", false, ""], + ])("reduces %s (RSC: %s) to %j", (search, isRscRequest, expected) => { + const url = new URL(`https://example.test/page${search}`); + keepOnlyValidatedRscCacheBustingSearchParam(url, isRscRequest); + expect(url.search).toBe(expected); + }); +}); diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 5a561d8111..fc8c59bc42 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -71,6 +71,15 @@ import { markFrameworkLinkHeaders, serializeResponseStageLinkProvenance, } from "../packages/vinext/src/server/app-response-header-provenance.js"; +import { + cacheabilityManifestPageState, + cacheabilityManifestRouteKey, + projectCacheabilityManifestForRequestStage, + type CacheabilityManifest, + type CacheabilityManifestRoute, + type CacheabilityRepresentation, +} from "../packages/vinext/src/server/cacheability-manifest.js"; +import { createWorkerCacheabilityAdmissionContext } from "../packages/vinext/src/server/cacheability-request.js"; import { registerFrameworkTracingIntegration } from "../packages/vinext/src/server/tracer.js"; import type { ResolvedFrameworkSpanDescriptor } from "../packages/vinext/src/server/framework-tracer.js"; import { workUnitAsyncStorage } from "../packages/vinext/src/shims/internal/work-unit-async-storage.js"; @@ -153,6 +162,7 @@ function createHandler(overrides: Partial = {}) { return createAppRscHandler({ basePath: "/docs", buildId: overrides.buildId ?? "build-id", + cacheabilityRequestProjection: overrides.cacheabilityRequestProjection, clearRequestContext: overrides.clearRequestContext ?? (() => {}), configHeaders: overrides.configHeaders ?? [ { @@ -587,6 +597,866 @@ describe("createAppRscHandler", () => { expect(new URL(mountedRequest.url).searchParams.get("_rsc")).not.toBe(""); }); + describe("query-free cache identity", () => { + function useQueryFreeIdentityAdapter( + overrides: Partial> = {}, + ): void { + setCdnCacheAdapter({ + buildResponseHeaders: ({ cacheControl }) => ({ "Cache-Control": cacheControl }), + ownsBackgroundRevalidation: false, + requiresCompletedResponseAdmission: true, + responseStageCacheIdentity: "query-free", + async get() { + return null; + }, + async revalidateTag() {}, + async set() {}, + ...overrides, + }); + } + + function pathAndSearch(url: string): string { + const parsed = new URL(url); + return `${parsed.pathname}${parsed.search}`; + } + + it("strips the user query from shared HTML identities while dispatching the real query", async () => { + useQueryFreeIdentityAdapter(); + const dispatchResponseStage = vi.fn( + async () => new Response("page"), + ); + const handler = createHandler({ configHeaders: [] }); + + for (const method of ["GET", "HEAD"]) { + dispatchResponseStage.mockClear(); + await handler( + new Request("https://example.test/docs/about?tab=latest&utm_source=x", { method }), + null, + false, + dispatchResponseStage, + ); + + const [request, props, options] = dispatchResponseStage.mock.calls[0]!; + expect(pathAndSearch(request.url)).toBe("/docs/about?tab=latest&utm_source=x"); + expect(props).toMatchObject({ + kind: "app-page", + resolvedUrl: "/about?tab=latest&utm_source=x", + }); + expect(options.cache).toBe("shared"); + expect(options.cacheIdentity?.request.url).toBe("https://example.test/docs/about"); + expect(options.cacheIdentity?.request.method).toBe("GET"); + expect(options.cacheIdentity?.props).toEqual({ ...props, resolvedUrl: "/about" }); + } + }); + + it("drops _rsc from HTML identities, where it selects no representation", async () => { + useQueryFreeIdentityAdapter(); + const dispatchResponseStage = vi.fn( + async () => new Response("page"), + ); + const handler = createHandler({ configHeaders: [] }); + + await handler( + new Request(`https://example.test/docs/about?_rsc=${crypto.randomUUID()}&tab=latest`), + null, + false, + dispatchResponseStage, + ); + + const [request, props, options] = dispatchResponseStage.mock.calls[0]!; + expect(props).toMatchObject({ isRscRequest: false, kind: "app-page" }); + expect(new URL(request.url).searchParams.has("_rsc")).toBe(true); + expect(options.cacheIdentity?.request.url).toBe("https://example.test/docs/about"); + }); + + it("drops RSC selector headers from HTML identities, which never read them", async () => { + useQueryFreeIdentityAdapter(); + const dispatchResponseStage = vi.fn( + async () => new Response("page"), + ); + const handler = createHandler({ configHeaders: [] }); + const rscSelectors = { + "Next-Router-Prefetch": "1", + "Next-Router-Segment-Prefetch": "/__PAGE__", + "Next-Router-State-Tree": crypto.randomUUID(), + "Next-Url": `/${crypto.randomUUID()}`, + RSC: "0", + "X-Vinext-Interception-Context": "/feed", + "X-Vinext-Mounted-Slots": "!!!", + "X-Vinext-Rsc-Render-Mode": "prefetch-loading-shell", + "X-Vinext-Rsc-State-Fingerprint": crypto.randomUUID(), + }; + + await handler( + new Request("https://example.test/docs/about", { + headers: { ...rscSelectors, Accept: "text/html", "X-Custom": "kept" }, + }), + null, + false, + dispatchResponseStage, + ); + + const [request, props, options] = dispatchResponseStage.mock.calls[0]!; + expect(props).toMatchObject({ + interceptionContext: null, + isRscRequest: false, + mountedSlotsHeader: null, + renderMode: "navigation", + }); + const identityHeaders = options.cacheIdentity!.request.headers; + for (const name of Object.keys(rscSelectors)) { + expect(request.headers.has(name)).toBe(true); + expect(identityHeaders.has(name)).toBe(false); + } + expect(identityHeaders.get("Accept")).toBe("text/html"); + expect(identityHeaders.get("X-Custom")).toBe("kept"); + }); + + it("keeps only the validated _rsc value in contextual RSC identities", async () => { + useQueryFreeIdentityAdapter(); + const route = createPageRoute(); + const matchRoute = (pathname: string) => + pathname === "/about" ? { params: {}, route } : null; + const dispatchResponseStage = vi.fn( + async () => new Response("rsc"), + ); + const handler = createHandler({ + configHeaders: [], + configRewrites: { + afterFiles: [], + beforeFiles: [{ source: "/source", destination: "/about" }], + fallback: [], + }, + matchRequestRoute: matchRoute, + matchRoute, + }); + const headers = createRscRequestHeaders({ nextUrl: "/source" }); + const rscUrl = await createRscRequestUrl("/docs/source?tab=latest", headers); + const hash = new URL(rscUrl, "https://example.test").searchParams.get("_rsc"); + expect(hash).toBeTruthy(); + + await handler( + new Request(new URL(`${rscUrl}&_rsc=ignored&%5Frsc=encoded`, "https://example.test"), { + headers, + }), + null, + false, + dispatchResponseStage, + ); + + const [request, props, options] = dispatchResponseStage.mock.calls[0]!; + // Rewritten RSC requests stay contextual, so the raw URL reaches dispatch. + expect(props).toMatchObject({ isRscRequest: true, matchKind: "resolved" }); + expect(new URL(request.url).searchParams.getAll("_rsc")).toEqual([ + hash, + "ignored", + "encoded", + ]); + expect(pathAndSearch(options.cacheIdentity!.request.url)).toBe(`/docs/source?_rsc=${hash}`); + }); + + it("keeps _rsc, the .rsc suffix and the render mode in shared RSC identities", async () => { + useQueryFreeIdentityAdapter(); + const route = createPageRoute({ canUseCanonicalLoadingShell: true }); + const matchRoute = (pathname: string) => + pathname === "/about" ? { params: {}, route } : null; + const dispatchResponseStage = vi.fn( + async () => new Response("rsc"), + ); + const handler = createHandler({ + configHeaders: [], + matchRequestRoute: matchRoute, + matchRoute, + }); + + const navigationHeaders = createRscRequestHeaders({ + nextUrl: "/source", + routerState: { pathAndSearch: "/source", routeId: "route:/source" }, + }); + await handler( + new Request( + new URL( + await createRscRequestUrl("/docs/about?tab=latest", navigationHeaders), + "https://example.test", + ), + { headers: navigationHeaders }, + ), + null, + false, + dispatchResponseStage, + ); + let [request, props, options] = dispatchResponseStage.mock.calls[0]!; + expect(pathAndSearch(request.url)).toBe("/docs/about?tab=latest&_rsc"); + expect(props).toMatchObject({ renderMode: "navigation", resolvedUrl: "/about?tab=latest" }); + expect(pathAndSearch(options.cacheIdentity!.request.url)).toBe("/docs/about?_rsc"); + expect(options.cacheIdentity!.request.headers.get(RSC_HEADER)).toBe("1"); + expect(options.cacheIdentity!.props).toEqual({ ...props, resolvedUrl: "/about" }); + + dispatchResponseStage.mockClear(); + const shellHeaders = createRscRequestHeaders({ + prefetchRouterState: { pathAndSearch: "/source", routeId: "route:/source" }, + renderMode: "prefetch-loading-shell", + }); + await handler( + new Request( + new URL( + await createRscRequestUrl("/docs/about?tab=latest", shellHeaders), + "https://example.test", + ), + { headers: shellHeaders }, + ), + null, + false, + dispatchResponseStage, + ); + [request, props, options] = dispatchResponseStage.mock.calls[0]!; + const shellHash = new URL(request.url).searchParams.get("_rsc"); + expect(shellHash).toBeTruthy(); + expect(new URL(request.url).searchParams.get("tab")).toBe("latest"); + expect(props).toMatchObject({ renderMode: "prefetch-loading-shell" }); + expect(pathAndSearch(options.cacheIdentity!.request.url)).toBe( + `/docs/about?_rsc=${shellHash}`, + ); + expect(options.cacheIdentity!.request.headers.get("x-vinext-rsc-render-mode")).toBe( + "prefetch-loading-shell", + ); + expect(options.cacheIdentity!.props).toEqual({ ...props, resolvedUrl: "/about" }); + + dispatchResponseStage.mockClear(); + const suffixHeaders = createRscRequestHeaders(); + await handler( + new Request("https://example.test/docs/about.rsc?tab=latest", { headers: suffixHeaders }), + null, + false, + dispatchResponseStage, + ); + [request, props, options] = dispatchResponseStage.mock.calls[0]!; + expect(pathAndSearch(request.url)).toBe("/docs/about.rsc?tab=latest&_rsc"); + expect(pathAndSearch(options.cacheIdentity!.request.url)).toBe("/docs/about.rsc?_rsc"); + expect(options.cacheIdentity!.props).toEqual({ ...props, resolvedUrl: "/about" }); + }); + + it("is absent unless the adapter declares it behind completed-response admission", async () => { + const dispatchResponseStage = vi.fn( + async () => new Response("page"), + ); + const handler = createHandler({ configHeaders: [] }); + const request = () => new Request("https://example.test/docs/about?tab=latest"); + + await handler(request(), null, false, dispatchResponseStage); + useQueryFreeIdentityAdapter({ requiresCompletedResponseAdmission: false }); + await handler(request(), null, false, dispatchResponseStage); + + expect(dispatchResponseStage.mock.calls.map((call) => call[2])).toEqual([ + { cache: "shared" }, + { cache: "shared" }, + ]); + }); + + it("is absent for bypassed, probe and non-GET dispatches", async () => { + useQueryFreeIdentityAdapter(); + const dispatchResponseStage = vi.fn( + async () => new Response("page"), + ); + const handler = createHandler({ configHeaders: [] }); + + await handler( + new Request("https://example.test/docs/about?tab=latest", { + headers: { Cookie: "__prerender_bypass=test-draft-secret" }, + }), + null, + false, + dispatchResponseStage, + ); + await handler( + new Request("https://example.test/docs/about?tab=latest"), + null, + false, + dispatchResponseStage, + "probe", + ); + await handler( + new Request("https://example.test/docs/about?tab=latest", { method: "POST" }), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage.mock.calls.map((call) => call[2])).toEqual([ + { cache: "bypass" }, + { cache: "bypass" }, + { cache: "bypass" }, + ]); + }); + + it("is absent when a next.config public cache policy applies", async () => { + useQueryFreeIdentityAdapter(); + const dispatchResponseStage = vi.fn( + async () => new Response("page"), + ); + const handler = createHandler({ + configHeaders: [ + { source: "/about", headers: [{ key: "Cache-Control", value: "public, s-maxage=60" }] }, + ], + }); + + await handler( + new Request("https://example.test/docs/about?tab=latest"), + null, + false, + dispatchResponseStage, + ); + + expect(dispatchResponseStage.mock.calls[0]?.[1].cacheability.policyHeaders).toEqual([ + ["Cache-Control", "public, s-maxage=60"], + ]); + expect(dispatchResponseStage.mock.calls[0]?.[2]).toEqual({ cache: "shared" }); + }); + + it("is absent for mounted-slot, interception and route-handler dispatches", async () => { + useQueryFreeIdentityAdapter(); + const pageRoute = createPageRoute(); + const sourceRoute = createPageRoute({ pattern: "/feed", routeSegments: ["feed"] }); + const handlerRoute = createPageRoute({ + __loadPage: undefined, + __loadRouteHandler() {}, + page: null, + pattern: "/route", + routeHandler: { GET: () => new Response("get") }, + routeSegments: ["route"], + }); + const matchRoute = (pathname: string) => { + if (pathname === "/about") return { params: {}, route: pageRoute }; + if (pathname === "/feed") return { params: {}, route: sourceRoute }; + if (pathname === "/route") return { params: {}, route: handlerRoute }; + return null; + }; + const dispatchResponseStage = vi.fn( + async () => new Response("payload"), + ); + const handler = createHandler({ + configHeaders: [], + matchInterceptRoute: (pathname, sourcePathname) => + pathname === "/photo" && sourcePathname === "/feed" + ? { params: {}, route: sourceRoute } + : null, + matchRequestRoute: matchRoute, + matchRoute, + }); + + const mountedHeaders = createRscRequestHeaders({ mountedSlotsHeader: "slot:modal:/" }); + await handler( + new Request( + new URL( + await createRscRequestUrl("/docs/about?tab=latest", mountedHeaders), + "https://example.test", + ), + { headers: mountedHeaders }, + ), + null, + false, + dispatchResponseStage, + ); + const interceptionHeaders = createRscRequestHeaders({ interceptionContext: "/feed" }); + await handler( + new Request( + new URL( + await createRscRequestUrl("/docs/photo?tab=latest", interceptionHeaders), + "https://example.test", + ), + { headers: interceptionHeaders }, + ), + null, + false, + dispatchResponseStage, + ); + await handler( + new Request("https://example.test/docs/route?tab=latest"), + null, + false, + dispatchResponseStage, + ); + + expect( + dispatchResponseStage.mock.calls.map(([, props, options]) => [ + props.kind, + "matchKind" in props ? props.matchKind : null, + options, + ]), + ).toEqual([ + ["app-page", "request", { cache: "shared" }], + ["app-page", "interception", { cache: "shared" }], + ["app-route-handler", "request", { cache: "shared" }], + ]); + }); + }); + + describe("Workers Cache query-free dispatch", () => { + const aboutRoute: CacheabilityManifestRoute = { + kind: "app-page", + pattern: "/about", + state: "runtime-check", + staticPaths: { html: ["/about"], "rsc-full": ["/about"], "rsc-loading-shell": ["/about"] }, + }; + const productsRoute: CacheabilityManifestRoute = { + kind: "app-page", + pattern: "/products/:id", + pathPrefix: "/products/", + runtimePaths: ["dynamic"], + state: "runtime-check", + staticPaths: { html: ["static"] }, + }; + + function manifest(...routes: CacheabilityManifestRoute[]): CacheabilityManifest { + return { + buildId: "build-id", + routes: Object.fromEntries( + routes.map((route) => [cacheabilityManifestRouteKey(route.kind, route.pattern), route]), + ), + version: 1, + }; + } + + function projection(...routes: CacheabilityManifestRoute[]): string { + return JSON.stringify(manifest(...routes)); + } + + function pathAndSearch(url: string): string { + const parsed = new URL(url); + return `${parsed.pathname}${parsed.search}`; + } + + function createProductsHandler(overrides: Partial = {}) { + const about = createPageRoute({ canUseCanonicalLoadingShell: true }); + const products = createPageRoute({ + isDynamic: true, + params: ["id"], + pattern: "/products/:id", + routeSegments: ["products", "[id]"], + }); + const matchRoute = ( + pathname: string, + ): { params: Record; route: TestRoute } | null => { + if (pathname === "/about") return { params: {}, route: about }; + if (pathname.startsWith("/products/")) { + return { params: { id: pathname.slice("/products/".length) }, route: products }; + } + return null; + }; + return createHandler({ + configHeaders: [], + configRewrites: { + afterFiles: [], + beforeFiles: [{ source: "/promo", destination: "/products/static" }], + fallback: [], + }, + matchRequestRoute: matchRoute, + matchRoute, + ...overrides, + }); + } + + async function navigationRequest(pathAndQuery: string): Promise { + const headers = createRscRequestHeaders({ + routerState: { pathAndSearch: "/source", routeId: "route:/source" }, + }); + return new Request( + new URL(await createRscRequestUrl(pathAndQuery, headers), "https://example.test"), + { headers }, + ); + } + + async function loadingShellRequest(pathAndQuery: string): Promise { + const headers = createRscRequestHeaders({ + prefetchRouterState: { pathAndSearch: "/source", routeId: "route:/source" }, + renderMode: "prefetch-loading-shell", + }); + return new Request( + new URL(await createRscRequestUrl(pathAndQuery, headers), "https://example.test"), + { headers }, + ); + } + + it("drops the user query from shared dispatches of static-candidate App page paths", async () => { + const dispatchResponseStage = vi.fn( + async () => new Response("page"), + ); + const handler = createProductsHandler({ + cacheabilityRequestProjection: projection(aboutRoute), + }); + + for (const init of [ + { headers: { Accept: "text/html" } }, + { headers: { Accept: "text/html" }, method: "HEAD" }, + // A curl-style request has no text/html Accept. Admission maps it to + // html once the route resolves to an App page, and so does the strip. + {}, + ]) { + dispatchResponseStage.mockClear(); + await handler( + new Request("https://example.test/docs/about?tab=latest&utm_source=x", init), + null, + false, + dispatchResponseStage, + ); + + const [request, props, options] = dispatchResponseStage.mock.calls[0]!; + expect(request.url).toBe("https://example.test/docs/about"); + expect(request.method).toBe("GET"); + expect(props).toMatchObject({ + cacheability: { resolvedRoutePathname: "/about" }, + kind: "app-page", + resolvedUrl: "/about", + }); + expect(options).toEqual({ cache: "shared" }); + } + }); + + it("builds the canonical RSC and loading-shell URLs from the query-free dispatch", async () => { + const dispatchResponseStage = vi.fn( + async () => new Response("rsc"), + ); + const handler = createProductsHandler({ + cacheabilityRequestProjection: projection(aboutRoute), + }); + + const navigation = await handler( + await navigationRequest("/docs/about?tab=latest"), + null, + false, + dispatchResponseStage, + ); + let [request, props] = dispatchResponseStage.mock.calls[0]!; + expect(pathAndSearch(request.url)).toBe("/docs/about?_rsc"); + expect(props).toMatchObject({ renderMode: "navigation", resolvedUrl: "/about" }); + // The request stage still describes the routed request, query included. + expect(navigation.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBe( + encodeURIComponent("/about?tab=latest"), + ); + + dispatchResponseStage.mockClear(); + await handler( + await loadingShellRequest("/docs/about?tab=latest"), + null, + false, + dispatchResponseStage, + ); + [request, props] = dispatchResponseStage.mock.calls[0]!; + const shellUrl = new URL(request.url); + expect([...shellUrl.searchParams.keys()]).toEqual([VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM]); + expect(shellUrl.searchParams.get(VINEXT_RSC_CACHE_BUSTING_SEARCH_PARAM)).toBe( + await computeRscCacheBustingSearchParam(request.headers), + ); + expect(props).toMatchObject({ renderMode: "prefetch-loading-shell", resolvedUrl: "/about" }); + + dispatchResponseStage.mockClear(); + await handler( + new Request("https://example.test/docs/about.rsc?tab=latest", { + headers: createRscRequestHeaders(), + }), + null, + false, + dispatchResponseStage, + ); + [request, props] = dispatchResponseStage.mock.calls[0]!; + expect(pathAndSearch(request.url)).toBe("/docs/about.rsc?_rsc"); + expect(props).toMatchObject({ resolvedUrl: "/about" }); + }); + + it("judges a rewritten request by its resolved route pathname", async () => { + const dispatchResponseStage = vi.fn( + async () => new Response("page"), + ); + const handler = createProductsHandler({ + cacheabilityRequestProjection: projection(productsRoute), + }); + + for (const path of ["/docs/promo", "/docs/products/static", "/docs/products/dynamic"]) { + await handler( + new Request(`https://example.test${path}?tab=latest`, { + headers: { Accept: "text/html" }, + }), + null, + false, + dispatchResponseStage, + ); + } + + expect( + dispatchResponseStage.mock.calls.map(([request, props]) => [ + pathAndSearch(request.url), + "resolvedUrl" in props ? props.resolvedUrl : null, + ]), + ).toEqual([ + ["/docs/promo", "/products/static"], + ["/docs/products/static", "/products/static"], + ["/docs/products/dynamic?tab=latest", "/products/dynamic?tab=latest"], + ]); + }); + + it("keeps the query where the projection gives no static-candidate state", async () => { + const handlerRoute = createPageRoute({ + __loadPage: undefined, + __loadRouteHandler() {}, + page: null, + pattern: "/route", + routeHandler: { GET: () => new Response("get") }, + routeSegments: ["route"], + }); + const fallbackAbout = createPageRoute({ canUseCanonicalLoadingShell: true }); + const matchRoute = (pathname: string) => { + if (pathname === "/about") return { params: {}, route: fallbackAbout }; + if (pathname === "/route") return { params: {}, route: handlerRoute }; + return null; + }; + const dispatchResponseStage = vi.fn( + async () => new Response("payload"), + ); + const htmlOnly = createProductsHandler({ + cacheabilityRequestProjection: projection({ + ...aboutRoute, + staticPaths: { html: ["/about"] }, + }), + }); + // A fallback-only record answers static-candidate for any representation. + const fallbackOnly = createHandler({ + cacheabilityRequestProjection: projection( + { kind: "app-page", pattern: "/about", state: "static-candidate" }, + { kind: "app-page", pattern: "/route", state: "static-candidate" }, + ), + configHeaders: [], + matchRequestRoute: matchRoute, + matchRoute, + }); + + // Only the HTML version of the path is certified. + await htmlOnly( + await navigationRequest("/docs/about?tab=latest"), + null, + false, + dispatchResponseStage, + ); + // A mounted-slot request has no representation, so admission refuses it. + const mountedHeaders = createRscRequestHeaders({ mountedSlotsHeader: "slot:modal:/" }); + await fallbackOnly( + new Request( + new URL( + await createRscRequestUrl("/docs/about?tab=latest", mountedHeaders), + "https://example.test", + ), + { headers: mountedHeaders }, + ), + null, + false, + dispatchResponseStage, + ); + // Route handlers read the query without tracking it. + await fallbackOnly( + new Request("https://example.test/docs/route?tab=latest"), + null, + false, + dispatchResponseStage, + ); + // The route has no record in the projection. + await createProductsHandler({ cacheabilityRequestProjection: projection(productsRoute) })( + new Request("https://example.test/docs/about?tab=latest", { + headers: { Accept: "text/html" }, + }), + null, + false, + dispatchResponseStage, + ); + + expect( + dispatchResponseStage.mock.calls.map(([request]) => + new URL(request.url).searchParams.get("tab"), + ), + ).toEqual(["latest", "latest", "latest", "latest"]); + }); + + it("keeps the query of a Pages data URL that a root catch-all App page matches", async () => { + // app/[...path]/page.tsx with generateStaticParams returning [] gets a + // fallback-only record, which answers static-candidate for any + // representation. A pages-data request isn't an App page representation. + const catchAll = createPageRoute({ + isDynamic: true, + params: ["path"], + pattern: "/:path+", + routeSegments: ["[...path]"], + }); + const matchRoute = (pathname: string) => ({ + params: { path: pathname.slice(1).split("/") }, + route: catchAll, + }); + const dispatchResponseStage = vi.fn( + async () => new Response("page"), + ); + const handler = createHandler({ + cacheabilityRequestProjection: projection({ + kind: "app-page", + pattern: "/:path+", + state: "static-candidate", + }), + configHeaders: [], + matchRequestRoute: matchRoute, + matchRoute, + }); + + for (const headers of [{}, { Accept: "application/json" }] as Record[]) { + await handler( + new Request("https://example.test/docs/_next/data/build-id/foo.json?q=1", { headers }), + null, + false, + dispatchResponseStage, + ); + } + // The same route's HTML is stripped. + await handler( + new Request("https://example.test/docs/foo?q=1", { headers: { Accept: "text/html" } }), + null, + false, + dispatchResponseStage, + ); + + expect( + dispatchResponseStage.mock.calls.map(([request]) => pathAndSearch(request.url)), + ).toEqual([ + "/docs/_next/data/build-id/foo.json?q=1", + "/docs/_next/data/build-id/foo.json?q=1", + "/docs/foo", + ]); + }); + + it("keeps the full URL under a next.config policy, for bypassed dispatches and without a projection", async () => { + const dispatchResponseStage = vi.fn( + async () => new Response("page"), + ); + const html = (headers: Record = {}) => + new Request("https://example.test/docs/about?tab=latest", { + headers: { Accept: "text/html", ...headers }, + }); + + await createProductsHandler({ + cacheabilityRequestProjection: projection(aboutRoute), + configHeaders: [ + { source: "/about", headers: [{ key: "Cache-Control", value: "public, s-maxage=60" }] }, + ], + })(html(), null, false, dispatchResponseStage); + const handler = createProductsHandler({ + cacheabilityRequestProjection: projection(aboutRoute), + }); + await handler( + html({ Cookie: "__prerender_bypass=test-draft-secret" }), + null, + false, + dispatchResponseStage, + ); + // A request carrying a CSP nonce renders per request. + await handler( + html({ "Content-Security-Policy": "script-src 'nonce-request-nonce'" }), + null, + false, + dispatchResponseStage, + ); + await handler(html(), null, false, dispatchResponseStage, "probe"); + await createProductsHandler()(html(), null, false, dispatchResponseStage); + await createProductsHandler({ + buildId: "other-build", + cacheabilityRequestProjection: projection(aboutRoute), + })(html(), null, false, dispatchResponseStage); + + expect( + dispatchResponseStage.mock.calls.map(([request, props, options]) => [ + pathAndSearch(request.url), + "resolvedUrl" in props ? props.resolvedUrl : null, + options.cache, + ]), + ).toEqual([ + ["/docs/about?tab=latest", "/about?tab=latest", "shared"], + ["/docs/about?tab=latest", "/about?tab=latest", "bypass"], + ["/docs/about?tab=latest", "/about?tab=latest", "bypass"], + ["/docs/about?tab=latest", "/about?tab=latest", "bypass"], + ["/docs/about?tab=latest", "/about?tab=latest", "shared"], + ["/docs/about?tab=latest", "/about?tab=latest", "shared"], + ]); + }); + + it("strips exactly the dispatches completed-response admission gives static-candidate", async () => { + const full = manifest( + { ...aboutRoute, staticPaths: { html: ["/about"], "rsc-full": ["/about"] } }, + productsRoute, + { kind: "app-route", pattern: "/about", state: "static-candidate" }, + ); + const dispatchResponseStage = vi.fn( + async () => new Response("payload"), + ); + const handler = createProductsHandler({ + cacheabilityRequestProjection: JSON.stringify( + projectCacheabilityManifestForRequestStage(full), + ), + }); + const html = (path: string) => + new Request(`https://example.test${path}?tab=latest`, { + headers: { Accept: "text/html" }, + }); + const requests: Array<[string, Request]> = [ + ["html", html("/docs/about")], + ["curl", new Request("https://example.test/docs/about?tab=latest")], + ["rsc", await navigationRequest("/docs/about?tab=latest")], + ["loading shell", await loadingShellRequest("/docs/about?tab=latest")], + ["listed static", html("/docs/products/static")], + ["rewritten", html("/docs/promo")], + ["listed static rsc", await navigationRequest("/docs/products/static?tab=latest")], + ["listed dynamic", new Request("https://example.test/docs/products/dynamic?tab=latest")], + ["unlisted", html("/docs/products/unlisted")], + ]; + + const results: Array<[string, boolean, string | null]> = []; + for (const [label, request] of requests) { + dispatchResponseStage.mockClear(); + await handler(request, null, false, dispatchResponseStage); + const [dispatched, props] = dispatchResponseStage.mock.calls[0]!; + if (props.kind !== "app-page") throw new Error(`${label} did not dispatch a page`); + const admission = Reflect.get( + createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + dispatched, + JSON.stringify(full), + "build-id", + true, + undefined, + props.cacheability.resolvedRoutePathname, + props.cacheability.representation, + ), + CACHEABILITY_REQUEST_STATE, + ).admission as NonNullable; + const admissionState = + admission.policy === "manifest" && admission.routePathname + ? cacheabilityManifestPageState( + admission.manifest as CacheabilityManifest, + { kind: "app-page", pattern: props.routePattern }, + admission.representation as CacheabilityRepresentation, + admission.routePathname, + ) + : null; + const stripped = !new URL(dispatched.url).searchParams.has("tab"); + expect(stripped, label).toBe(admissionState === "static-candidate"); + results.push([label, stripped, admissionState]); + } + expect(results).toEqual([ + ["html", true, "static-candidate"], + ["curl", true, "static-candidate"], + ["rsc", true, "static-candidate"], + ["loading shell", false, "runtime-check"], + ["listed static", true, "static-candidate"], + ["rewritten", true, "static-candidate"], + ["listed static rsc", false, "runtime-check"], + ["listed dynamic", false, "runtime-check"], + ["unlisted", false, null], + ]); + }); + }); + it("dispatches a matched GET through the App response stage and composes request-stage headers", async () => { const dispatchResponseStage = vi.fn(async (_request, props) => { expect(props).toMatchObject({ diff --git a/tests/app-segment-config.test.ts b/tests/app-segment-config.test.ts index a0bf837ce0..f241991859 100644 --- a/tests/app-segment-config.test.ts +++ b/tests/app-segment-config.test.ts @@ -1,9 +1,19 @@ import { describe, expect, it } from "vite-plus/test"; import { + collectAppPageStaticGenerationRuntimes, + collectAppPageStaticParamsWalkSegments, + hasAppPageAnyGenerateStaticParams, + hasAppPageGenerateStaticParamsAtLastDynamicSegment, + isAppPageInterceptAttached, + isAppPageStaticEligible, isEdgeRuntime, + lastDynamicSegmentHasGenerateStaticParams, resolveAppPageDynamicConfig, resolveAppPageFetchCacheMode, + resolveAppPageInterceptSegmentConfig, + resolveAppPageInterceptTree, resolveAppPageSegmentConfig, + resolveAppPageStaticGenerationRuntime, resolveAppRouteHandlerFetchCacheMode, } from "../packages/vinext/src/server/app-segment-config.js"; @@ -529,3 +539,1251 @@ describe("isEdgeRuntime", () => { expect(isEdgeRuntime(undefined)).toBe(false); }); }); + +describe("resolveAppPageStaticGenerationRuntime", () => { + // Next.js reads runtime from the page and its parent layouts, the page + // winning, then the nearest layout. + // https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/build/get-static-info-including-layouts.ts + it("lets the page win, then the nearest layout", () => { + expect(resolveAppPageStaticGenerationRuntime(["nodejs", "edge", undefined])).toBe("edge"); + expect(resolveAppPageStaticGenerationRuntime(["edge", undefined, "nodejs"])).toBe("nodejs"); + expect(resolveAppPageStaticGenerationRuntime(["edge", "bogus"])).toBe("edge"); + expect(resolveAppPageStaticGenerationRuntime([undefined, undefined])).toBeUndefined(); + }); +}); + +describe("collectAppPageStaticGenerationRuntimes", () => { + const resolve = (options: Parameters[0]) => + resolveAppPageStaticGenerationRuntime(collectAppPageStaticGenerationRuntimes(options)); + + it("reads the page and its layouts", () => { + expect( + resolve({ + layouts: [{ runtime: "edge" }, {}], + layoutTreePositions: [0, 1], + page: {}, + routeSegments: ["blog"], + }), + ).toBe("edge"); + expect( + resolve({ + layouts: [{ runtime: "edge" }, {}], + layoutTreePositions: [0, 1], + page: { runtime: "nodejs" }, + routeSegments: ["blog"], + }), + ).toBe("nodejs"); + }); + + it("merges a slot page's runtime into a route with its own page", () => { + // app/page.tsx and app/@panel/page.tsx exporting runtime = "edge": Next.js + // merges every parallel branch, so / is edge. + expect( + resolve({ + childrenSlot: { ownerTreePath: "/", state: "active" }, + layouts: [{}], + layoutTreePositions: [0], + page: {}, + parallelBranches: [ + { name: "panel", ownerTreePosition: 0, page: { runtime: "edge" }, routeSegments: [] }, + ], + routeSegments: [], + }), + ).toBe("edge"); + }); + + it("merges a slot's default module runtime", () => { + // app/@panel/default.tsx exports runtime = "edge". + expect( + resolve({ + layouts: [{}], + layoutTreePositions: [0], + page: {}, + parallelBranches: [ + { isDefault: true, name: "panel", ownerTreePosition: 0, page: { runtime: "edge" } }, + ], + routeSegments: [], + }), + ).toBe("edge"); + }); + + it("reads the slot page of a route that only a slot page materializes", () => { + // app/@feed/foo/page.tsx with no app/foo/page.tsx: children renders the + // root default, and the slot page supplies the runtime. + expect( + resolve({ + childrenSlot: { ownerTreePath: "/", state: "default" }, + layouts: [{ runtime: "nodejs" }], + layoutTreePositions: [0], + page: {}, + parallelBranches: [ + { + configLayouts: [{ runtime: "nodejs" }], + configLayoutTreePositions: [1], + layout: {}, + name: "feed", + ownerTreePosition: 0, + page: { runtime: "edge" }, + routeSegments: ["foo"], + }, + ], + routeSegments: ["foo"], + }), + ).toBe("edge"); + }); + + it("makes the route edge when any sibling slot page is edge", () => { + // app/@alpha/page.tsx (Node) and app/@zeta/page.tsx (edge) with no + // app/page.tsx: / is edge even though @alpha sorts first. + expect( + resolve({ + childrenSlot: { ownerTreePath: "/", state: "default" }, + layouts: [{}], + layoutTreePositions: [0], + page: null, + parallelBranches: [ + { name: "alpha", ownerTreePosition: 0, page: {}, routeSegments: [] }, + { name: "zeta", ownerTreePosition: 0, page: { runtime: "edge" }, routeSegments: [] }, + ], + routeSegments: [], + }), + ).toBe("edge"); + }); + + it("lets a branch's runtime win over an enclosing layout's", () => { + // app/layout.tsx sets runtime = "edge" and app/@alpha/page.tsx sets + // "nodejs": the merged branch value is set, so the root layout doesn't + // override it. + expect( + resolve({ + childrenSlot: { ownerTreePath: "/", state: "default" }, + layouts: [{ runtime: "edge" }], + layoutTreePositions: [0], + page: null, + parallelBranches: [ + { name: "alpha", ownerTreePosition: 0, page: { runtime: "nodejs" }, routeSegments: [] }, + { name: "zeta", ownerTreePosition: 0, page: {}, routeSegments: [] }, + ], + routeSegments: [], + }), + ).toBe("nodejs"); + }); + + it("reads the main-branch layouts of a page-less route", () => { + // app/layout.tsx sets runtime = "edge", app/dashboard/layout.tsx sets + // "nodejs", app/dashboard/@panel/default.tsx makes /dashboard a route, and + // app/@feed/dashboard/page.tsx matches it. The dashboard layout is in the + // children branch, whose value the root layout doesn't override. + expect( + resolve({ + layouts: [{ runtime: "edge" }, { runtime: "nodejs" }], + layoutTreePositions: [0, 1], + page: null, + parallelBranches: [ + { + layout: {}, + name: "feed", + ownerTreePosition: 0, + page: {}, + routeSegments: ["dashboard"], + }, + { isDefault: true, name: "panel", ownerTreePosition: 1, page: {} }, + ], + routeSegments: ["dashboard"], + }), + ).toBe("nodejs"); + }); + + it("stops the main branch at the folder whose default children renders", () => { + // app/dashboard/layout.tsx sets runtime = "edge", app/dashboard/settings/ + // layout.tsx sets "nodejs", and app/dashboard/@feed/settings/page.tsx + // materializes /dashboard/settings. Children renders the dashboard default, + // so the settings layout isn't in the tree. + expect( + resolve({ + childrenSlot: { ownerTreePath: "/dashboard", state: "default" }, + layouts: [{}, { runtime: "edge" }, { runtime: "nodejs" }], + layoutTreePositions: [0, 1, 2], + page: {}, + parallelBranches: [ + { name: "feed", ownerTreePosition: 1, page: {}, routeSegments: ["settings"] }, + ], + routeSegments: ["dashboard", "settings"], + }), + ).toBe("edge"); + }); +}); + +describe("hasAppPageGenerateStaticParamsAtLastDynamicSegment", () => { + const generateStaticParams = () => []; + + it("counts generateStaticParams on the page below the last dynamic segment", () => { + // app/[slug]/page.tsx + expect( + hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + layouts: [{}], + layoutTreePositions: [0], + page: { generateStaticParams }, + routeSegments: ["[slug]"], + }), + ).toBe(true); + // app/[locale]/about/page.tsx + expect( + hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + layouts: [{}], + layoutTreePositions: [0], + page: { generateStaticParams }, + routeSegments: ["[locale]", "about"], + }), + ).toBe(true); + }); + + it("counts the last dynamic segment's layout and deeper layouts", () => { + // app/[slug]/layout.tsx exports it, app/[slug]/details/page.tsx does not. + expect( + hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + layouts: [{}, { generateStaticParams }], + layoutTreePositions: [0, 1], + page: {}, + routeSegments: ["[slug]", "details"], + }), + ).toBe(true); + // app/[slug]/(group)/layout.tsx + expect( + hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + layouts: [{}, { generateStaticParams }], + layoutTreePositions: [0, 2], + page: {}, + routeSegments: ["[slug]", "(group)"], + }), + ).toBe(true); + }); + + it("does not count generateStaticParams above the last dynamic segment", () => { + // app/[a]/layout.tsx exports it; app/[a]/[b]/page.tsx does not (Next.js: ƒ). + expect( + hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + layouts: [{}, { generateStaticParams }], + layoutTreePositions: [0, 1], + page: {}, + routeSegments: ["[a]", "[b]"], + }), + ).toBe(false); + // The root layout's generateStaticParams sits above every dynamic segment. + expect( + hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + layouts: [{ generateStaticParams }], + layoutTreePositions: [0], + page: {}, + routeSegments: ["[slug]"], + }), + ).toBe(false); + }); + + it("does not use a sibling page's generateStaticParams", () => { + // app/[slug]/page.tsx exports it, but /[slug]/details has its own page. + expect( + hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + layouts: [{}], + layoutTreePositions: [0], + page: {}, + routeSegments: ["[slug]", "details"], + }), + ).toBe(false); + }); + + it("counts parallel slot pages, and visits a layout-less slot folder that repeats the main tree once", () => { + // app/[id]/page.tsx has no generateStaticParams, app/@modal/[id]/page.tsx does. + expect( + hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + layouts: [{}], + layoutTreePositions: [0], + page: {}, + parallelBranches: [{ page: { generateStaticParams }, routeSegments: ["[id]"] }], + routeSegments: ["[id]"], + }), + ).toBe(true); + // app/[id]/page.tsx exports it. The slot's layout-less [id] folder is the + // same segment to Next.js, so it does not clear the flag again. + expect( + hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + layouts: [{}], + layoutTreePositions: [0], + page: { generateStaticParams }, + parallelBranches: [{ page: {}, routeSegments: ["[id]"] }], + routeSegments: ["[id]"], + }), + ).toBe(true); + }); + + // Next.js's default build (Turbopack) puts `children` first at each level, + // so the main-tree segment is visited before a slot segment at the same + // depth. + // https://github.com/vercel/next.js/blob/v16.2.7/crates/next-core/src/app_structure.rs#L1504-L1511 + it("visits the main tree before a matched slot at the same depth", () => { + // app/[id]/page.tsx exports it; app/@modal/[id]/layout.tsx does not. The + // slot's [id] is a separate segment, visited after the main page. + expect( + hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + layouts: [{}], + layoutTreePositions: [0], + page: { generateStaticParams }, + parallelBranches: [ + { + configLayouts: [{}], + configLayoutTreePositions: [1], + name: "modal", + ownerTreePosition: 0, + page: {}, + routeSegments: ["[id]"], + }, + ], + routeSegments: ["[id]"], + }), + ).toBe(false); + }); + + it("orders slot folder names by UTF-8 bytes", () => { + // @豈 (U+F900) sorts before @𐀀 (U+10000) by UTF-8 bytes, but after it by + // UTF-16 code units. + const segments = collectAppPageStaticParamsWalkSegments({ + layouts: [{}], + layoutTreePositions: [0], + page: {}, + parallelBranches: [ + { name: "\u{10000}", ownerTreePosition: 0, page: {}, routeSegments: [] }, + { name: "\u{F900}", ownerTreePosition: 0, page: {}, routeSegments: [] }, + ], + routeSegments: [], + }); + expect( + segments + .filter((segment) => segment.treePath.length === 1 && segment.treePath[0] > 0) + .map((segment) => segment.identity[0]), + ).toEqual(["@\u{F900}", "@\u{10000}"]); + }); + + it("orders slots by folder name, whether they matched a page or render default", () => { + const segments = collectAppPageStaticParamsWalkSegments({ + layouts: [{}], + layoutTreePositions: [0], + page: {}, + parallelBranches: [ + { name: "zeta", ownerTreePosition: 0, page: {}, routeSegments: [] }, + { isDefault: true, name: "alpha", ownerTreePosition: 0, page: {} }, + ], + routeSegments: [], + }); + expect(segments.map((segment) => [segment.identity[0], segment.treePath])).toEqual([ + ["", []], + ["__DEFAULT__", [1]], + ["@zeta", [2]], + ["__PAGE__", [2, 0]], + ["__PAGE__", [0]], + ]); + }); + + it("places a slot under the folder that owns it, not by its segment count", () => { + // app/(main)/[id]/page.tsx exports it; the root slot app/@panel/[id]/page.tsx + // does not. The slot's [id] sits one level above the main page. + expect( + hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + layouts: [{}], + layoutTreePositions: [0], + page: { generateStaticParams }, + parallelBranches: [ + { name: "panel", ownerTreePosition: 0, page: {}, routeSegments: ["[id]"] }, + ], + routeSegments: ["(main)", "[id]"], + }), + ).toBe(true); + }); + + it("reads only the default module of a slot that renders its default", () => { + // A default slot is a single `__DEFAULT__` segment; the slot's own layout + // is not part of the loader tree. + expect( + hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + layouts: [{}], + layoutTreePositions: [0], + page: {}, + parallelBranches: [ + { + isDefault: true, + layout: { generateStaticParams }, + name: "modal", + ownerTreePosition: 0, + page: {}, + routeSegments: [], + }, + ], + routeSegments: ["[id]"], + }), + ).toBe(false); + }); + + it("places the children default of a route that only a slot page materializes under its owner", () => { + // app/default.tsx exports it; app/@feed/[id]/page.tsx does not. Next.js's + // loader tree puts `__DEFAULT__` directly under the root, so the slot's + // deeper [id] is visited last and clears the flag. + expect( + hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + childrenSlot: { ownerTreePath: "/", state: "default" }, + layouts: [{}], + layoutTreePositions: [0], + page: { generateStaticParams }, + parallelBranches: [ + { name: "feed", ownerTreePosition: 0, page: {}, routeSegments: ["[id]"] }, + ], + routeSegments: ["[id]"], + }), + ).toBe(false); + }); + + it("reads a route-group layout of a slot page that has no URL segments", () => { + // app/[id]/@details/(variant)/layout.tsx exports it, below the [id] + // segment it follows. + expect( + hasAppPageGenerateStaticParamsAtLastDynamicSegment({ + layouts: [{}], + layoutTreePositions: [0], + page: {}, + parallelBranches: [ + { + configLayouts: [{ generateStaticParams }], + configLayoutTreePositions: [1], + name: "details", + ownerTreePosition: 1, + page: {}, + routeSegments: [], + }, + ], + routeSegments: ["[id]"], + }), + ).toBe(true); + }); + + it("walks segments breadth-first in loader tree order", () => { + expect( + lastDynamicSegmentHasGenerateStaticParams([ + { + dynamic: false, + generateStaticParams: true, + identity: ["__PAGE__", "page"], + treePath: [1, 0], + }, + { + dynamic: true, + generateStaticParams: false, + identity: ["[slug]", undefined], + treePath: [1], + }, + { + dynamic: true, + generateStaticParams: false, + identity: ["[id]", "slot"], + treePath: [0, 0], + }, + ]), + ).toBe(true); + expect(lastDynamicSegmentHasGenerateStaticParams([])).toBe(false); + }); +}); + +describe("isAppPageStaticEligible", () => { + const base = { + hasGenerateStaticParams: false, + isDynamicRoute: false, + isStaticGenerationEdgeRuntime: false, + revalidateSeconds: null, + }; + + it("treats routes without dynamic segments as static", () => { + expect(isAppPageStaticEligible(base)).toBe(true); + expect(isAppPageStaticEligible({ ...base, revalidateSeconds: 60 })).toBe(true); + }); + + it("treats dynamic-segment routes as SSG only with generateStaticParams at the last dynamic segment", () => { + expect(isAppPageStaticEligible({ ...base, isDynamicRoute: true })).toBe(false); + // revalidate never makes a dynamic route static in Next.js. + expect(isAppPageStaticEligible({ ...base, isDynamicRoute: true, revalidateSeconds: 60 })).toBe( + false, + ); + expect( + isAppPageStaticEligible({ ...base, hasGenerateStaticParams: true, isDynamicRoute: true }), + ).toBe(true); + }); + + it("treats force-static and dynamic = error as static", () => { + expect( + isAppPageStaticEligible({ ...base, dynamicConfig: "force-static", isDynamicRoute: true }), + ).toBe(true); + expect(isAppPageStaticEligible({ ...base, dynamicConfig: "error", isDynamicRoute: true })).toBe( + true, + ); + }); + + it("excludes force-dynamic, revalidate = 0 and the edge runtime", () => { + expect(isAppPageStaticEligible({ ...base, dynamicConfig: "force-dynamic" })).toBe(false); + expect(isAppPageStaticEligible({ ...base, revalidateSeconds: 0 })).toBe(false); + for (const config of [ + {}, + { revalidateSeconds: 60 }, + { hasGenerateStaticParams: true, isDynamicRoute: true }, + { dynamicConfig: "force-static" }, + ]) { + expect( + isAppPageStaticEligible({ ...base, ...config, isStaticGenerationEdgeRuntime: true }), + ).toBe(false); + } + }); +}); + +describe("resolveAppPageInterceptTree", () => { + // app/layout.tsx, app/feed/layout.tsx, app/feed/page.tsx and + // app/feed/@modal/default.tsx. + const layouts = [{}, {}]; + const layoutTreePositions = [0, 1]; + const routeSegments = ["feed"]; + const modalDefault = { + isDefault: true, + layout: null, + name: "modal", + ownerTreePosition: 1, + page: {}, + }; + + function classify( + interceptPage: Record, + options: { + isDynamicRoute?: boolean; + interceptOwnerDefault?: Record; + siblingBranches?: Parameters[0]["parallelBranches"]; + sourcePage?: Record; + slotIndex?: number; + } = {}, + ) { + // app/feed/@modal/(.)photos/[id]/page.tsx, or app/feed/(.)photos/[id]/ + // page.tsx for a sibling-page intercept. Like the generated entry, both + // Next.js's tree and the one vinext renders must be static. + return [false, true].every((keepActiveSiblings) => { + const tree = resolveAppPageInterceptTree({ + childrenSlot: { ownerTreePath: "/feed", state: "active" }, + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayouts: [], + interceptLayoutSegments: [], + interceptOwnerDefault: options.interceptOwnerDefault, + interceptPage, + keepActiveSiblings, + layouts, + layoutTreePositions, + page: options.sourcePage ?? {}, + parallelBranches: [modalDefault, ...(options.siblingBranches ?? [])], + routeSegments, + isSiblingPageIntercept: options.slotIndex === -1, + slotIndex: options.slotIndex ?? 0, + }); + const config = resolveAppPageSegmentConfig(tree); + return isAppPageStaticEligible({ + dynamicConfig: config.dynamicConfig, + hasGenerateStaticParams: hasAppPageGenerateStaticParamsAtLastDynamicSegment(tree), + isDynamicRoute: options.isDynamicRoute ?? false, + isStaticGenerationEdgeRuntime: isEdgeRuntime( + resolveAppPageStaticGenerationRuntime(collectAppPageStaticGenerationRuntimes(tree)) as + | string + | undefined, + ), + revalidateSeconds: config.revalidateSeconds, + }); + }); + } + + it("puts the intercepting branch in place of the intercepted slot's", () => { + const interceptPage = { dynamic: "force-dynamic" }; + const tree = resolveAppPageInterceptTree({ + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayouts: [{ revalidate: 60 }], + interceptLayoutSegments: [["(.)photos"]], + interceptPage, + layouts, + layoutTreePositions, + page: {}, + parallelBranches: [modalDefault], + routeSegments, + isSiblingPageIntercept: false, + slotIndex: 0, + }); + expect(tree.parallelBranches).toEqual([ + { + configLayouts: [{ revalidate: 60 }], + configLayoutTreePositions: [1], + isDefault: false, + layout: null, + name: "modal", + ownerTreePosition: 1, + page: interceptPage, + routeSegments: ["(.)photos", "[id]"], + }, + ]); + }); + + it("keeps the slot's layouts above the intercept marker in its branch", () => { + // app/layout.tsx, app/gallery/page.tsx, app/@modal/default.tsx, + // app/@modal/gallery/layout.tsx (force-dynamic), app/@modal/gallery/ + // (.)photo/layout.tsx and app/@modal/gallery/(.)photo/page.tsx. Next.js's + // intercepting route tree holds the @modal/gallery folder, and its layout, + // above the (.)photo branch. + const galleryLayout = { dynamic: "force-dynamic" }; + const photoLayout = {}; + const interceptPage = {}; + const source = { + childrenSlot: { ownerTreePath: "/gallery", state: "active" }, + interceptBranchSegments: ["gallery", "(.)photo"], + interceptLayouts: [galleryLayout, photoLayout], + interceptLayoutSegments: [["gallery"], ["gallery", "(.)photo"]], + interceptPage, + isSiblingPageIntercept: false, + layouts: [{}], + layoutTreePositions: [0], + page: {}, + parallelBranches: [{ ...modalDefault, ownerTreePosition: 0 }], + routeSegments: ["gallery"], + slotIndex: 0, + } as const; + + for (const keepActiveSiblings of [false, true]) { + const tree = resolveAppPageInterceptTree({ ...source, keepActiveSiblings }); + expect(tree.parallelBranches?.[0]).toEqual({ + configLayouts: [galleryLayout, photoLayout], + configLayoutTreePositions: [1, 2], + isDefault: false, + layout: null, + name: "modal", + ownerTreePosition: 0, + page: interceptPage, + routeSegments: ["gallery", "(.)photo"], + }); + expect(collectAppPageStaticParamsWalkSegments(tree)).toContainEqual({ + dynamic: false, + generateStaticParams: false, + identity: ["gallery", galleryLayout], + treePath: [1, 0], + }); + expect(resolveAppPageSegmentConfig(tree).dynamicConfig).toBe("force-dynamic"); + } + }); + + it("puts a sibling-page intercept in place of the source's page", () => { + const interceptLayout = {}; + const interceptPage = {}; + const tree = resolveAppPageInterceptTree({ + childrenSlot: { ownerTreePath: "/feed", state: "active" }, + interceptBranchSegments: ["(.)photos", "[id]"], + interceptLayouts: [interceptLayout], + interceptLayoutSegments: [["(.)photos"]], + interceptPage, + layouts, + layoutTreePositions, + page: { dynamic: "force-static" }, + parallelBranches: [modalDefault], + routeSegments, + isSiblingPageIntercept: true, + slotIndex: -1, + }); + expect(tree).toEqual({ + childrenSlot: null, + layoutTreePositions: [0, 1, 2], + layouts: [{}, {}, interceptLayout], + page: interceptPage, + parallelBranches: [ + { + configLayouts: [], + configLayoutTreePositions: [], + isDefault: true, + layout: null, + name: "modal", + ownerTreePosition: 1, + page: modalDefault.page, + routeSegments: [], + }, + ], + routeSegments: ["feed", "(.)photos", "[id]"], + }); + }); + + it("replaces active sibling slots at or above the intercept with their defaults", () => { + // For the source app/feed/nested/page.tsx, app/@global/feed/nested/page.tsx + // and app/feed/@sidebar/nested/page.tsx sit on the intercepting branch's + // path; app/feed/nested/@aside/page.tsx sits inside app/feed's children, + // which Next.js replaces with app/feed's default. + const globalDefault = { revalidate: 30 }; + const sidebarDefault = { runtime: "edge" }; + const aside = { + default: {}, + isDefault: false, + layout: {}, + name: "aside", + ownerTreePosition: 2, + page: { runtime: "nodejs" }, + routeSegments: [], + }; + const tree = resolveAppPageInterceptTree({ + interceptBranchSegments: ["(.)photos", "[id]"], + interceptPage: {}, + isSiblingPageIntercept: false, + layouts: [{}, {}, {}], + layoutTreePositions: [0, 1, 2], + page: {}, + parallelBranches: [ + { + configLayouts: [{}], + configLayoutTreePositions: [1], + default: globalDefault, + isDefault: false, + layout: { dynamic: "force-dynamic" }, + name: "global", + ownerTreePosition: 0, + page: {}, + routeSegments: ["feed", "nested"], + }, + { ...modalDefault, default: modalDefault.page }, + { + default: sidebarDefault, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: { runtime: "nodejs" }, + routeSegments: ["nested"], + }, + aside, + ], + routeSegments: ["feed", "nested"], + slotIndex: 1, + }); + const leaf = { + configLayouts: [], + configLayoutTreePositions: [], + isDefault: true, + layout: null, + }; + expect(tree.parallelBranches).toEqual([ + { ...leaf, name: "global", ownerTreePosition: 0, page: globalDefault, routeSegments: [] }, + expect.objectContaining({ isDefault: false, name: "modal" }), + { ...leaf, name: "sidebar", ownerTreePosition: 1, page: sidebarDefault, routeSegments: [] }, + null, + ]); + }); + + it("replaces a slot intercept's children with the owner's default, but renders the source", () => { + // app/feed/nested/layout.tsx, app/feed/nested/page.tsx and + // app/feed/nested/@aside/page.tsx sit below app/feed, whose children + // Next.js replaces with app/feed/default.tsx as a __DEFAULT__ leaf. + const feedDefault = { revalidate: 30 }; + const sourcePage = { dynamic: "force-static" }; + const nestedLayout = { dynamic: "force-static" }; + const aside = { + default: {}, + isDefault: false, + layout: null, + name: "aside", + ownerTreePosition: 2, + page: {}, + routeSegments: [], + }; + const source = { + childrenSlot: { ownerTreePath: "/feed/nested", state: "active" }, + interceptBranchSegments: ["(.)photos", "[id]"], + interceptOwnerDefault: feedDefault, + interceptPage: {}, + isSiblingPageIntercept: false, + layouts: [layouts[0], layouts[1], nestedLayout], + layoutTreePositions: [0, 1, 2], + page: sourcePage, + parallelBranches: [modalDefault, aside], + routeSegments: ["feed", "nested"], + slotIndex: 0, + } as const; + const intercept = expect.objectContaining({ isDefault: false, name: "modal" }); + + const nextTree = resolveAppPageInterceptTree(source); + expect(nextTree).toEqual({ + childrenSlot: { ownerTreePath: "/feed", state: "default" }, + layoutTreePositions: [0, 1], + layouts: [layouts[0], layouts[1]], + page: feedDefault, + parallelBranches: [intercept, null], + routeSegments: ["feed"], + }); + expect(collectAppPageStaticParamsWalkSegments(nextTree)).toContainEqual({ + dynamic: false, + generateStaticParams: false, + identity: ["__DEFAULT__", feedDefault], + treePath: [0, 0], + }); + + const renderedTree = resolveAppPageInterceptTree({ ...source, keepActiveSiblings: true }); + expect(renderedTree).toEqual({ + childrenSlot: source.childrenSlot, + layoutTreePositions: source.layoutTreePositions, + layouts: source.layouts, + page: sourcePage, + parallelBranches: [intercept, aside], + routeSegments: source.routeSegments, + }); + }); + + it("drops a force-static source page from a slot intercept's tree", () => { + // app/feed/page.tsx sets dynamic = "force-static"; the intercepting + // app/feed/@modal/(.)photos/[id]/page.tsx has no generateStaticParams, + // and app/feed has no default.tsx, so its children are default-null. + const sourcePage = { dynamic: "force-static" }; + expect(classify({}, { isDynamicRoute: true, sourcePage })).toBe(false); + // A force-static app/feed/default.tsx takes the source page's place. + expect( + classify( + {}, + { interceptOwnerDefault: { dynamic: "force-static" }, isDynamicRoute: true, sourcePage }, + ), + ).toBe(true); + }); + + it("classifies a slot intercept with an active sibling's edge default as edge", () => { + // app/feed/@sidebar/page.tsx is Node; app/feed/@sidebar/default.tsx is edge. + const sidebar = { + default: { runtime: "edge" }, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: { runtime: "nodejs" }, + routeSegments: [], + }; + expect(classify({}, { siblingBranches: [sidebar] })).toBe(false); + expect(classify({}, { siblingBranches: [{ ...sidebar, default: {} }] })).toBe(true); + }); + + it("keeps a slot intercept's rendered force-dynamic sibling page dynamic", () => { + // app/feed/@sidebar/page.tsx is force-dynamic and still renders beside + // the intercept; app/feed/@sidebar/default.tsx is static. + const sidebar = { + default: {}, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: { dynamic: "force-dynamic" }, + routeSegments: [], + }; + expect(classify({}, { siblingBranches: [sidebar] })).toBe(false); + expect(classify({}, { siblingBranches: [sidebar], slotIndex: -1 })).toBe(false); + }); + + it("keeps an active sibling slot's page when resolving the tree vinext renders", () => { + const sidebar = { + default: {}, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: { dynamic: "force-dynamic" }, + routeSegments: [], + }; + const tree = resolveAppPageInterceptTree({ + interceptBranchSegments: ["(.)photos", "[id]"], + interceptPage: {}, + isSiblingPageIntercept: false, + keepActiveSiblings: true, + layouts, + layoutTreePositions, + page: {}, + parallelBranches: [modalDefault, sidebar], + routeSegments, + slotIndex: 0, + }); + expect(tree.parallelBranches?.[1]).toBe(sidebar); + }); + + it("drops an active sibling slot's config from a sibling-page intercept's tree", () => { + // app/feed/@sidebar/page.tsx sets dynamic = "force-static"; the + // intercepting app/feed/(.)photos/[id]/page.tsx has no generateStaticParams. + const sidebar = { + default: {}, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: { dynamic: "force-static" }, + routeSegments: [], + }; + expect(classify({}, { isDynamicRoute: true, siblingBranches: [sidebar], slotIndex: -1 })).toBe( + false, + ); + }); + + it("keeps the source's tree when the source route lacks the intercepted slot", () => { + // A route-group variant of app/feed matched as the source has no @modal, + // so the intercepting page doesn't render and the dynamic source page does. + const sourcePage = { dynamic: "force-dynamic" }; + const tree = resolveAppPageInterceptTree({ + interceptBranchSegments: ["(.)photos", "[id]"], + interceptPage: {}, + isSiblingPageIntercept: false, + layouts, + layoutTreePositions, + page: sourcePage, + parallelBranches: [], + routeSegments, + slotIndex: -1, + }); + expect(tree.page).toBe(sourcePage); + expect(tree.routeSegments).toEqual(routeSegments); + expect(resolveAppPageSegmentConfig(tree).dynamicConfig).toBe("force-dynamic"); + }); + + it("attaches a slot intercept only to a source that has the slot", () => { + const source = { layouts, layoutTreePositions, page: {}, routeSegments }; + expect( + isAppPageInterceptAttached({ + ...source, + isSiblingPageIntercept: false, + parallelBranches: [modalDefault], + slotIndex: 0, + }), + ).toBe(true); + expect( + isAppPageInterceptAttached({ + ...source, + isSiblingPageIntercept: false, + parallelBranches: [], + slotIndex: -1, + }), + ).toBe(false); + // A sibling-page intercept replaces the source's page, whatever its slots. + expect( + isAppPageInterceptAttached({ + ...source, + isSiblingPageIntercept: true, + parallelBranches: [], + slotIndex: -1, + }), + ).toBe(true); + }); + + it("keeps a static intercepting branch static", () => { + expect(classify({})).toBe(true); + }); + + it("makes the tree dynamic when the intercepting page is force-dynamic", () => { + expect(classify({ dynamic: "force-dynamic" })).toBe(false); + }); + + it("makes the tree dynamic when the intercepting page sets revalidate = 0", () => { + expect(classify({ revalidate: 0 })).toBe(false); + }); + + it("disables static generation when the intercepting page is edge", () => { + expect(classify({ runtime: "edge" })).toBe(false); + }); + + it("needs generateStaticParams on the intercepting branch of a dynamic intercepted route", () => { + expect(classify({}, { isDynamicRoute: true })).toBe(false); + expect(classify({ generateStaticParams: () => [] }, { isDynamicRoute: true })).toBe(true); + }); + + it("drops a force-static source page from a sibling-page intercept's tree", () => { + // app/feed/page.tsx sets dynamic = "force-static"; the intercepting + // app/feed/(.)photos/[id]/page.tsx has no generateStaticParams. + expect( + classify( + {}, + { + isDynamicRoute: true, + slotIndex: -1, + sourcePage: { dynamic: "force-static" }, + }, + ), + ).toBe(false); + }); + + it("drops a source page's generateStaticParams from a sibling-page intercept's tree", () => { + // app/u/[user]/page.tsx exports generateStaticParams; the intercepting + // app/u/[user]/(.)settings/page.tsx doesn't, so [user] has none left. + const tree = resolveAppPageInterceptTree({ + childrenSlot: { ownerTreePath: "/u/[user]", state: "active" }, + interceptBranchSegments: ["(.)settings"], + interceptPage: {}, + layouts: [{}], + layoutTreePositions: [0], + page: { generateStaticParams: () => [] }, + parallelBranches: [], + routeSegments: ["u", "[user]"], + isSiblingPageIntercept: true, + slotIndex: -1, + }); + expect(hasAppPageGenerateStaticParamsAtLastDynamicSegment(tree)).toBe(false); + }); + + it("counts a marker-prefixed intercepting folder as a dynamic segment", () => { + // app/[user]/layout.tsx exports generateStaticParams; the intercepting + // app/[user]/feed/@modal/(.)[photo]/page.tsx, or the sibling-page + // app/[user]/feed/(..)(..)[photo]/page.tsx, doesn't, so [photo] has none. + const source = { + childrenSlot: { ownerTreePath: "/[user]/feed", state: "active" }, + interceptPage: {}, + layouts: [{}, { generateStaticParams: () => [] }], + layoutTreePositions: [0, 1], + page: {}, + routeSegments: ["[user]", "feed"], + } as const; + const slotTree = resolveAppPageInterceptTree({ + ...source, + interceptBranchSegments: ["(.)[photo]"], + isSiblingPageIntercept: false, + parallelBranches: [{ ...modalDefault, ownerTreePosition: 2 }], + slotIndex: 0, + }); + const siblingTree = resolveAppPageInterceptTree({ + ...source, + interceptBranchSegments: ["(..)(..)[photo]"], + isSiblingPageIntercept: true, + parallelBranches: [], + slotIndex: -1, + }); + expect(hasAppPageGenerateStaticParamsAtLastDynamicSegment(slotTree)).toBe(false); + expect(hasAppPageGenerateStaticParamsAtLastDynamicSegment(siblingTree)).toBe(false); + }); +}); + +describe("hasAppPageAnyGenerateStaticParams", () => { + const generator = () => []; + + it("reads a generator from any segment, not only at or below the last dynamic one", () => { + // app/[lang]/layout.tsx exports generateStaticParams; app/[lang]/[slug] + // has none. + const route = { + layouts: [{}, { generateStaticParams: generator }], + layoutTreePositions: [0, 1], + page: {}, + routeSegments: ["[lang]", "[slug]"], + }; + expect(hasAppPageAnyGenerateStaticParams(route)).toBe(true); + expect(hasAppPageGenerateStaticParamsAtLastDynamicSegment(route)).toBe(false); + expect(hasAppPageAnyGenerateStaticParams({ ...route, layouts: [{}, {}] })).toBe(false); + }); + + // app/layout.tsx, app/feed/layout.tsx, app/feed/page.tsx, + // app/feed/@modal/default.tsx, app/feed/@modal/gallery/layout.tsx, + // app/feed/@modal/gallery/(.)photo/page.tsx and app/feed/@sidebar/page.tsx. + function resolveInterceptTree( + modules: { + galleryLayout?: object; + interceptPage?: object; + rootLayout?: object; + sidebarPage?: object; + sourcePage?: object; + }, + keepActiveSiblings = false, + ) { + return resolveAppPageInterceptTree({ + childrenSlot: { ownerTreePath: "/feed", state: "active" }, + interceptBranchSegments: ["gallery", "(.)photo"], + interceptLayouts: [modules.galleryLayout ?? {}], + interceptLayoutSegments: [["gallery"]], + interceptPage: modules.interceptPage ?? {}, + isSiblingPageIntercept: false, + keepActiveSiblings, + layouts: [modules.rootLayout ?? {}, {}], + layoutTreePositions: [0, 1], + page: modules.sourcePage ?? {}, + parallelBranches: [ + { isDefault: true, layout: null, name: "modal", ownerTreePosition: 1, page: {} }, + { + default: {}, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: modules.sidebarPage ?? {}, + routeSegments: [], + }, + ], + routeSegments: ["feed"], + slotIndex: 0, + }); + } + + it.each([ + ["a shared ancestor layout", { rootLayout: { generateStaticParams: generator } }], + ["the intercepting page", { interceptPage: { generateStaticParams: generator } }], + [ + "a slot layout above the intercept marker", + { galleryLayout: { generateStaticParams: generator } }, + ], + ])("reads a generator from %s of an intercepting route's tree", (_name, modules) => { + expect(hasAppPageAnyGenerateStaticParams(resolveInterceptTree(modules))).toBe(true); + expect(hasAppPageAnyGenerateStaticParams(resolveInterceptTree({}))).toBe(false); + }); + + it.each([ + ["the source page the intercept's children default replaces", "sourcePage"], + ["an active sibling page Next.js's tree has the default of", "sidebarPage"], + ])("does not read a generator from %s", (_name, module) => { + const modules = { [module]: { generateStaticParams: generator } }; + expect(hasAppPageAnyGenerateStaticParams(resolveInterceptTree(modules))).toBe(false); + // vinext still renders it, but the intercepting route's tree doesn't. + expect(hasAppPageAnyGenerateStaticParams(resolveInterceptTree(modules, true))).toBe(true); + }); +}); + +describe("resolveAppPageInterceptSegmentConfig", () => { + // app/layout.tsx, app/feed/layout.tsx, app/feed/page.tsx, the intercepting + // app/feed/@modal/(.)photos/[id]/page.tsx, and app/feed/@sidebar, whose + // page vinext renders beside the intercept where Next.js renders its default. + const modalDefault = { + isDefault: true, + layout: null, + name: "modal", + ownerTreePosition: 1, + page: {}, + }; + + function resolve( + interceptPage: Record, + sidebar: { default?: Record; page: Record }, + options: { isSiblingPageIntercept?: boolean; page?: Record } = {}, + ) { + const [interceptTree, renderedTree] = [false, true].map((keepActiveSiblings) => + resolveAppPageInterceptTree({ + interceptBranchSegments: ["(.)photos", "[id]"], + interceptPage, + isSiblingPageIntercept: options.isSiblingPageIntercept ?? false, + keepActiveSiblings, + layouts: [{}, {}], + layoutTreePositions: [0, 1], + page: options.page ?? {}, + parallelBranches: [ + modalDefault, + { + default: sidebar.default ?? {}, + isDefault: false, + layout: null, + name: "sidebar", + ownerTreePosition: 1, + page: sidebar.page, + routeSegments: [], + }, + ], + routeSegments: ["feed"], + slotIndex: options.isSiblingPageIntercept ? -1 : 0, + }), + ); + return resolveAppPageInterceptSegmentConfig(interceptTree, renderedTree); + } + + it("takes the shorter revalidate of an active sibling page", () => { + // app/feed/@sidebar/page.tsx sets revalidate = 10 beside an intercepting + // page at 30. + expect(resolve({ revalidate: 30 }, { page: { revalidate: 10 } }).revalidateSeconds).toBe(10); + expect( + resolve({ revalidate: 30 }, { page: { revalidate: 10 } }, { isSiblingPageIntercept: true }) + .revalidateSeconds, + ).toBe(10); + // A longer active sibling revalidate leaves the intercepting page's. + expect(resolve({ revalidate: 30 }, { page: { revalidate: 60 } }).revalidateSeconds).toBe(30); + }); + + it("keeps the shorter revalidate of the default Next.js renders instead", () => { + // app/feed/@sidebar/default.tsx sets revalidate = 5. + expect( + resolve({ revalidate: 30 }, { default: { revalidate: 5 }, page: {} }).revalidateSeconds, + ).toBe(5); + }); + + it("makes the render force-dynamic for a force-dynamic active sibling page", () => { + expect(resolve({ dynamic: "force-static" }, { page: { dynamic: "force-dynamic" } })).toEqual( + expect.objectContaining({ dynamicConfig: "force-dynamic", revalidateSeconds: 0 }), + ); + }); + + it("keeps the intercepting branch's dynamic mode over an active sibling's", () => { + expect(resolve({ dynamic: "force-static" }, { page: { dynamic: "error" } }).dynamicConfig).toBe( + "force-static", + ); + // With none of its own, the active sibling's applies. + expect(resolve({}, { page: { dynamic: "force-static" } }).dynamicConfig).toBe("force-static"); + }); + + it("takes an active sibling page's route-wide fetchCache mode", () => { + expect( + resolve({ fetchCache: "default-cache" }, { page: { fetchCache: "force-no-store" } }) + .fetchCache, + ).toBe("force-no-store"); + expect(resolve({}, { page: { fetchCache: "default-no-store" } }).fetchCache).toBe( + "default-no-store", + ); + expect(() => + resolve({ fetchCache: "force-cache" }, { page: { fetchCache: "force-no-store" } }), + ).toThrow(/incompatible fetchCache values/); + }); + + it("never reduces a slot's default together with the active page it replaces", () => { + // app/feed/@sidebar/default.tsx and app/feed/@sidebar/page.tsx never + // render together, so conflicting fetchCache modes don't throw. The + // no-store mode wins between the two trees. + expect( + resolve( + {}, + { default: { fetchCache: "force-cache" }, page: { fetchCache: "force-no-store" } }, + ).fetchCache, + ).toBe("force-no-store"); + expect( + resolve( + {}, + { default: { fetchCache: "only-no-store" }, page: { fetchCache: "only-cache" } }, + { isSiblingPageIntercept: true }, + ).fetchCache, + ).toBe("only-no-store"); + // A force mode of either tree overrides an only mode of the other. + expect( + resolve({}, { default: { fetchCache: "only-no-store" }, page: { fetchCache: "force-cache" } }) + .fetchCache, + ).toBe("force-cache"); + }); + + it("applies the dynamic = error fetchCache default to the merged dynamic mode only", () => { + // app/feed/page.tsx sets dynamic = "error", which only vinext's tree + // renders beside the force-static intercepting page, whose mode wins. + const merged = resolve( + { dynamic: "force-static" }, + { page: {} }, + { page: { dynamic: "error" } }, + ); + expect(merged.dynamicConfig).toBe("force-static"); + expect(merged).not.toHaveProperty("fetchCache"); + // With none of its own, app/feed/page.tsx's dynamic = "error" applies. + expect(resolve({}, { page: {} }, { page: { dynamic: "error" } }).fetchCache).toBe("only-cache"); + }); + + it("takes the shortest unstable_dynamicStaleTime of the pages either tree renders", () => { + const staleTime = (...args: Parameters) => + resolve(...args).dynamicStaleTimeSeconds; + // The intercepting page, in the modal slot or in place of app/feed/page.tsx. + expect(staleTime({ unstable_dynamicStaleTime: 30 }, { page: {} })).toBe(30); + expect( + staleTime({ unstable_dynamicStaleTime: 30 }, { page: {} }, { isSiblingPageIntercept: true }), + ).toBe(30); + // An active sibling page, or the default Next.js renders in its place. + expect( + staleTime({ unstable_dynamicStaleTime: 30 }, { page: { unstable_dynamicStaleTime: 10 } }), + ).toBe(10); + expect( + staleTime( + { unstable_dynamicStaleTime: 30 }, + { default: { unstable_dynamicStaleTime: 5 }, page: {} }, + { isSiblingPageIntercept: true }, + ), + ).toBe(5); + expect(staleTime({}, { page: {} })).toBeUndefined(); + }); +}); diff --git a/tests/app-server-action-execution.test.ts b/tests/app-server-action-execution.test.ts index 2729f46c3b..c5ecea2265 100644 --- a/tests/app-server-action-execution.test.ts +++ b/tests/app-server-action-execution.test.ts @@ -56,6 +56,7 @@ type TestRoute = { routeHandler?: unknown; routeSegments?: readonly string[]; runtime?: "edge" | "experimental-edge" | "nodejs" | null; + slots?: Readonly>; /** Manifest lazy-module thunk; set when the route's page is not yet hydrated. */ __loadPage?: unknown; }; @@ -1725,6 +1726,26 @@ describe("app server action execution helpers", () => { expect(response?.headers.get("x-action-revalidated")).toBe("1"); }); + it("sends the path and query an action re-render rendered with", async () => { + const response = await handleServerActionRscRequest( + createRscOptions({ + loadServerAction() { + return Promise.resolve(async () => { + await Promise.resolve(revalidatePath("/dashboard")); + return "revalidated"; + }); + }, + // The query a rewrite resolved, not the one in the page URL. + searchParams: new URLSearchParams("q=rewritten value"), + }), + ); + + expect(response?.status).toBe(200); + expect(response?.headers.get("X-Vinext-Rendered-Path-And-Search")).toBe( + encodeURIComponent("/dashboard?q=rewritten+value"), + ); + }); + it("renders same-origin action redirects as a single-pass Flight response", async () => { // Ported from Next.js: test/e2e/app-dir/actions/app-action.test.ts // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/actions/app-action.test.ts @@ -1757,7 +1778,11 @@ describe("app server action execution helpers", () => { }); it("passes empty request APIs to force-static action rerender targets", async () => { - const buildInputs: Array<{ query: string; header: string | null }> = []; + const buildInputs: Array<{ + query: string; + header: string | null; + isForceStatic: boolean | undefined; + }> = []; const targetRoute: TestRoute = { id: "dashboard", page: {}, @@ -1766,10 +1791,11 @@ describe("app server action execution helpers", () => { }; const response = await handleServerActionRscRequest( createRscOptions({ - buildPageElement({ searchParams }) { + buildPageElement({ isForceStatic, searchParams }) { buildInputs.push({ query: searchParams.toString(), header: getHeadersContext()?.headers.get("x-request-value") ?? null, + isForceStatic, }); return "force-static-target"; }, @@ -1791,7 +1817,8 @@ describe("app server action execution helpers", () => { ); expect(response?.status).toBe(200); - expect(buildInputs).toEqual([{ query: "", header: null }]); + // Client pages also read an empty query in the browser. + expect(buildInputs).toEqual([{ query: "", header: null, isForceStatic: true }]); }); it("observes searchParams access for dynamic-error action rerender targets", async () => { @@ -3474,6 +3501,100 @@ describe("app server action execution helpers", () => { revalidateSpy.mockRestore(); forceDynamicSpy.mockRestore(); }); + + it("resolves an intercepted rerender's config from the intercepting tree it renders", async () => { + const fetchCacheShims = await import("../packages/vinext/src/shims/fetch-cache.js"); + const modeSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchCacheMode"); + const revalidateSpy = vi.spyOn(fetchCacheShims, "setCurrentFetchRevalidate"); + const forceDynamicSpy = vi.spyOn(fetchCacheShims, "setCurrentForceDynamicFetchDefault"); + // app/feed/@modal/(..)photos/[id] intercepting /photos/42 from app/feed, + // whose tree renders app/feed/default.tsx in place of app/feed's children. + const feedRoute: TestRoute = { + id: "feed", + page: {}, + params: [], + pattern: "/feed", + slots: { modal: {} }, + }; + const photoRoute: TestRoute = { + id: "photo", + page: {}, + params: ["id"], + pattern: "/photos/:id", + }; + const modalPage = { default: "modal-photo" }; + const feedDefault = { default: "feed-default" }; + const isInterceptingTree = ( + route: TestRoute, + intercept?: { + interceptOwnerDefault?: unknown; + interceptPage?: unknown; + interceptSlotKey?: string; + interceptTargetPatternParts?: readonly string[] | null; + }, + ) => + route === feedRoute && + intercept?.interceptOwnerDefault === feedDefault && + intercept.interceptPage === modalPage && + intercept.interceptSlotKey === "modal" && + intercept.interceptTargetPatternParts?.join("/") === "photos/:id"; + + const response = await handleServerActionRscRequest( + createRscOptions({ + cleanPathname: "/photos/42", + currentRouteMatch: { params: { id: "42" }, route: photoRoute }, + currentRoutePathname: "/photos/42", + findIntercept() { + // The owner default loads with the rest of the intercepting tree. + return { + __loadOwnerDefault: async () => feedDefault, + matchedParams: { id: "42" }, + ownerDefault: null, + page: modalPage, + slotKey: "modal", + sourceMatchedParams: {}, + sourceRouteIndex: 0, + }; + }, + getSourceRoute() { + return feedRoute; + }, + loadServerAction() { + return Promise.resolve(async () => { + await Promise.resolve(revalidatePath("/photos/42")); + return "revalidated"; + }); + }, + resolveRouteFetchCacheMode(route, intercept) { + return isInterceptingTree(route, intercept) ? "force-no-store" : null; + }, + resolveRouteRevalidateSeconds(route, intercept) { + return isInterceptingTree(route, intercept) ? 45 : null; + }, + resolveRouteDynamicConfig(route, intercept) { + return isInterceptingTree(route, intercept) ? "force-dynamic" : null; + }, + toInterceptOpts(intercept) { + return { + interceptOwnerDefault: (intercept as { ownerDefault?: unknown }).ownerDefault, + interceptPage: intercept.page, + interceptSlotKey: intercept.slotKey, + interceptTargetPatternParts: ["photos", ":id"], + slot: intercept.slotKey, + } as TestInterceptOptions; + }, + }), + ); + + expect(response?.status).toBe(200); + expect(modeSpy).toHaveBeenCalledWith("force-no-store"); + expect(revalidateSpy).toHaveBeenCalledWith(45); + expect(forceDynamicSpy).toHaveBeenCalledWith(true); + + modeSpy.mockRestore(); + revalidateSpy.mockRestore(); + forceDynamicSpy.mockRestore(); + }); }); // The client-side counterpart of `createServerActionNotFoundResponse`: when the diff --git a/tests/app-worker-stages.test.ts b/tests/app-worker-stages.test.ts index fae88a1591..9ec22a3d8b 100644 --- a/tests/app-worker-stages.test.ts +++ b/tests/app-worker-stages.test.ts @@ -15,7 +15,9 @@ import { } from "../packages/vinext/src/shims/cdn-cache.js"; import { VINEXT_EXPECTED_WORKER_VERSION_HEADER, + VINEXT_PARAMS_HEADER, VINEXT_PRERENDER_READINESS_HEADER, + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, } from "../packages/vinext/src/server/headers.js"; import { markFrameworkLinkHeaders } from "../packages/vinext/src/server/app-response-header-provenance.js"; import { setFrameworkRequestRoute } from "../packages/vinext/src/server/request-tracing.js"; @@ -213,6 +215,57 @@ describe("App Worker response stage", () => { expect(response.headers.get("x-vinext-app-stage-post-config-link")).toBe("1"); }); + it("drops the request-scoped headers of a shared App page RSC response", async () => { + const pageStage = { + ...notFoundStage, + bypassInterceptionContextCache: false, + cachePathname: "/missing", + canUseCanonicalLoadingShell: false, + interceptionContext: null, + interceptionId: null, + kind: "app-page" as const, + matchKind: "request" as const, + params: {}, + routePattern: "/missing", + routePathname: "/missing", + } satisfies AppWorkerResponseStageProps; + const rendered = () => + new Response("rsc", { + headers: { + [VINEXT_PARAMS_HEADER]: encodeURIComponent("{}"), + [VINEXT_RENDERED_PATH_AND_SEARCH_HEADER]: encodeURIComponent("/missing?q=1"), + }, + }); + const render = (props: AppWorkerResponseStageProps, cache: "bypass" | "shared") => { + stages.renderResponse.mockImplementationOnce(async () => rendered()); + return handleResponseStage( + new Request("https://example.com/missing?q=1"), + undefined, + undefined, + props, + async () => new Response("request-stage"), + { cache }, + ); + }; + + const shared = await render({ ...pageStage, isRscRequest: true }, "shared"); + expect(shared.headers.has(VINEXT_PARAMS_HEADER)).toBe(false); + expect(shared.headers.has(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBe(false); + await expect(shared.text()).resolves.toBe("rsc"); + + // Bypassed responses reach no shared cache, and HTML responses and + // not-found renders are not recomposed by the request stage. + for (const response of [ + await render({ ...pageStage, isRscRequest: true }, "bypass"), + await render(pageStage, "shared"), + await render({ ...notFoundStage, isRscRequest: true }, "shared"), + ]) { + expect(response.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBe( + encodeURIComponent("/missing?q=1"), + ); + } + }); + it("rejects matched-stage payloads missing interception cache-safety fields", () => { const matchedStage = { ...notFoundStage, diff --git a/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index 82db2dc345..0ab529365c 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -3955,6 +3955,12 @@ describe("createMultiStageChunkFileNames", () => { expect(fileName({ name: "vinext-stage-runtime~virtual_vinext-response-stage" } as never)).toBe( "vinext-stage-runtime~virtual_vinext-response-stage-[hash].js", ); + expect(fileName({ name: "_virtual_vinext-request-stage" } as never)).toBe( + "_virtual_vinext-request-stage-[hash].js", + ); + expect(fileName({ name: "vinext-stage-runtime~virtual_vinext-request-stage" } as never)).toBe( + "vinext-stage-runtime~virtual_vinext-request-stage-[hash].js", + ); expect(fileName({ name: "request-runtime" } as never)).toBe( "_next/static/request-runtime-[hash].js", ); diff --git a/tests/cache-adapters-build.test.ts b/tests/cache-adapters-build.test.ts index c552c3a960..ddfede4ba3 100644 --- a/tests/cache-adapters-build.test.ts +++ b/tests/cache-adapters-build.test.ts @@ -16,6 +16,8 @@ import { pathToFileURL } from "node:url"; import { createBuilder } from "vite"; import { afterEach, describe, expect, it } from "vite-plus/test"; import { cdnAdapter } from "../packages/cloudflare/src/cache/cdn-adapter.js"; +import { writeCacheabilityManifestArtifact } from "../packages/cloudflare/src/cacheability-artifact.js"; +import { cacheabilityManifestRouteKey } from "../packages/vinext/src/server/cacheability-manifest.js"; import vinext from "../packages/vinext/src/index.js"; const tmpDirs: string[] = []; @@ -538,5 +540,38 @@ export default { expect(readStaticEntryClosure(root, "virtual:vinext-response-stage")).toContain( LOCAL_ADAPTER_MARKER, ); + // The request stage reads the deploy's manifest projection, never the full + // manifest. The build emits a null placeholder that `vinext deploy` fills. + expect(readStaticEntryClosure(root, "virtual:vinext-request-stage")).toContain( + "__vinext_cacheability_request_projection.js", + ); + expect(readStaticEntryClosure(root, "virtual:vinext-request-stage")).not.toContain( + "__vinext_cacheability_manifest.js", + ); + expect( + fs.readFileSync( + path.join(root, "dist/server/__vinext_cacheability_request_projection.js"), + "utf8", + ), + ).toBe("export default null;\n"); + // The deploy only fills the projection that the Worker graph statically + // imports, so the emitted graph must pass that check. + writeCacheabilityManifestArtifact(root, "dist/server/wrangler.json", { + buildId: "build-a", + routes: { + [cacheabilityManifestRouteKey("app-page", "/about")]: { + kind: "app-page", + pattern: "/about", + state: "static-candidate", + }, + }, + version: 1, + }); + expect( + fs.readFileSync( + path.join(root, "dist/server/__vinext_cacheability_request_projection.js"), + "utf8", + ), + ).toContain("/about"); }, 60_000); }); diff --git a/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index bf406e7aaa..65bb3548be 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { + applyResponseStageCachePolicy, captureCacheabilityAdmissionBody, createCacheabilityAdmissionCaptureBudget, createWorkerCacheabilityAdmissionContext, @@ -17,10 +18,15 @@ import { import { DefaultCdnCacheAdapter, setCdnCacheAdapter, + type CdnCacheAdapter, } from "../packages/vinext/src/shims/cdn-cache.js"; import { runWithExecutionContext } from "../packages/vinext/src/shims/request-context.js"; +import { finalizeAppPageCacheabilityEvaluationResponse } from "../packages/vinext/src/server/app-page-cache-finalizer.js"; +import type { AppPageRenderObservationState } from "../packages/vinext/src/server/app-page-render-observation.js"; import { applyCdnResponseHeaders } from "../packages/vinext/src/server/cache-control.js"; import { applyRouteHandlerRevalidateHeader } from "../packages/vinext/src/server/app-route-handler-response.js"; +import { finalizeAppRscResponse } from "../packages/vinext/src/server/app-rsc-response-finalizer.js"; +import { requestContextFromRequest } from "../packages/vinext/src/config/request-context.js"; import { CloudflareCdnCacheAdapter } from "../packages/cloudflare/src/cache/cdn-adapter.runtime.js"; import { markClientTraceMetadataBlock, @@ -243,6 +249,7 @@ describe("single-request cacheability admission", () => { state.route = { kind: "app-page", pattern: "/page" }; state.outcome = { cacheable: true, + searchParamsUnread: true, cacheControl: "s-maxage=60, stale-while-revalidate=540", }; state.frameworkResponseCachePolicy = new Headers({ "Cache-Control": "no-store" }); @@ -265,7 +272,7 @@ describe("single-request cacheability admission", () => { ); const state = cacheabilityState(context); state.route = { kind: "app-page", pattern: "/page" }; - state.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + state.outcome = { cacheable: true, searchParamsUnread: true, cacheControl: "s-maxage=60" }; const marker = "2d533650-6016-42c8-baf4-3f7e4e65e65c"; state.clientTraceMetadataMarker = marker; const authored = ''; @@ -301,7 +308,7 @@ describe("single-request cacheability admission", () => { ); const state = cacheabilityState(context); state.route = { kind: "app-page", pattern: "/page" }; - state.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + state.outcome = { cacheable: true, searchParamsUnread: true, cacheControl: "s-maxage=60" }; const authoredMarkedBlock = markClientTraceMetadataBlock( '', "2d533650-6016-42c8-baf4-3f7e4e65e65c", @@ -330,6 +337,7 @@ describe("single-request cacheability admission", () => { state.route = { kind: "app-page", pattern: "/page" }; state.outcome = { cacheable: true, + searchParamsUnread: true, cacheControl: "s-maxage=60, stale-while-revalidate=540", }; const body = new Uint8Array(4 * 1024 * 1024 + 1); @@ -353,6 +361,8 @@ describe("single-request cacheability admission", () => { ); const state = cacheabilityState(context); state.route = { kind: "app-page", pattern: "/page" }; + // A later config policy replaces the renderer's, so it needs no + // searchParams proof. state.outcome = { cacheable: true, cacheControl: "s-maxage=120, stale-while-revalidate=31535880", @@ -360,7 +370,11 @@ describe("single-request cacheability admission", () => { state.frameworkResponseCachePolicy = new Headers({ "Cache-Control": "no-store" }); const response = await finalizeWorkerCacheabilityResponse( - new Response("static", { headers: { "Cache-Control": "s-maxage=30" } }), + applyResponseStageCachePolicy( + new Response("static", { headers: { "Cache-Control": "no-store" } }), + context, + [["Cache-Control", "s-maxage=30"]], + ), context, ); @@ -414,9 +428,13 @@ describe("single-request cacheability admission", () => { ); const response = await finalizeWorkerCacheabilityResponse( - new Response(`${tracedHtml}
dynamic
`, { - headers: { "Cache-Control": "s-maxage=32" }, - }), + applyResponseStageCachePolicy( + new Response(`${tracedHtml}
dynamic
`, { + headers: { "Cache-Control": "no-store" }, + }), + context, + [["Cache-Control", "s-maxage=32"]], + ), context, ); expect(response.headers.get("Cache-Control")).toBe("s-maxage=32"); @@ -551,6 +569,7 @@ describe("single-request cacheability admission", () => { state.route = { kind: "app-page", pattern: "/page" }; state.outcome = { cacheable: true, + searchParamsUnread: true, cacheControl: "s-maxage=60, stale-while-revalidate=540", }; state.frameworkResponseCachePolicy = new Headers({ "Cache-Control": "no-store" }); @@ -937,6 +956,7 @@ describe("single-request cacheability admission", () => { state.route = { kind: "app-page", pattern: "/page" }; state.outcome = { cacheable: true, + searchParamsUnread: true, cacheControl: "s-maxage=60, stale-while-revalidate=540", }; @@ -962,6 +982,7 @@ describe("single-request cacheability admission", () => { state.route = { kind: "app-page", pattern: "/page" }; state.outcome = { cacheable: true, + searchParamsUnread: true, cacheControl: "s-maxage=60, stale-while-revalidate=540", }; @@ -987,6 +1008,7 @@ describe("single-request cacheability admission", () => { state.route = { kind: "app-page", pattern: "/page" }; state.outcome = { cacheable: true, + searchParamsUnread: true, cacheControl: "s-maxage=60, stale-while-revalidate=540", }; @@ -1045,6 +1067,369 @@ describe("single-request cacheability admission", () => { await expect(response.text()).resolves.toContain("changed from static to dynamic"); }); + it.each<{ name: string; observation?: AppPageRenderObservationState; admitted: boolean }>([ + { + name: "left searchParams unread", + observation: { dynamicFetches: [], requestApis: [] }, + admitted: true, + }, + { + name: "read searchParams", + observation: { dynamicFetches: [], requestApis: ["searchParams"] }, + admitted: false, + }, + { name: "has no observation", admitted: false }, + ])("admits a rendered App page only when its render $name", async ({ observation, admitted }) => { + const { raw } = staticManifestRoute(); + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + raw, + "build-a", + ); + cacheabilityState(context).route = { kind: "app-page", pattern: "/page" }; + const rendered = runWithExecutionContext(context, () => + finalizeAppPageCacheabilityEvaluationResponse(new Response("static"), { + consumeDynamicUsage: () => false, + ...(observation ? { consumeRenderObservationState: () => observation } : {}), + getPageTags: () => ["/page"], + isStaticEligible: true, + revalidateSeconds: 60, + }), + ); + + const response = await finalizeWorkerCacheabilityResponse(await rendered, context); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toContain(admitted ? "s-maxage=60" : "no-store"); + await expect(response.text()).resolves.toBe("static"); + }); + + it.each([true, false])( + "requires a searchParams proof when config adds only Vary (proof: %s)", + async (searchParamsUnread) => { + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + null, + "build-a", + true, + "verbatim", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: "/page" }; + const cacheControl = "s-maxage=60, stale-while-revalidate=540"; + state.outcome = { + cacheable: true, + cacheControl, + ...(searchParamsUnread ? { searchParamsUnread: true as const } : {}), + }; + state.frameworkResponseCachePolicy = new Headers({ "Cache-Control": cacheControl }); + const rendered = applyResponseStageCachePolicy( + new Response("static", { headers: { "Cache-Control": cacheControl } }), + context, + [["Vary", "Accept-Language"]], + ); + + const response = await finalizeWorkerCacheabilityResponse(rendered, context); + + expect(response.headers.get("Cache-Control")).toContain( + searchParamsUnread ? "s-maxage=60" : "no-store", + ); + expect(response.headers.get("Vary")).toBe("Accept-Language"); + }, + ); + + it("admits a config cache policy matching the renderer's without a searchParams proof", async () => { + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + null, + "build-a", + true, + "verbatim", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: "/page" }; + const cacheControl = "s-maxage=60"; + state.outcome = { cacheable: true, cacheControl }; + state.frameworkResponseCachePolicy = new Headers({ "Cache-Control": cacheControl }); + const rendered = applyResponseStageCachePolicy( + new Response("static", { headers: { "Cache-Control": cacheControl } }), + context, + [["Cache-Control", cacheControl]], + ); + + const response = await finalizeWorkerCacheabilityResponse(rendered, context); + + expect(response.headers.get("Cache-Control")).toBe(cacheControl); + await expect(response.text()).resolves.toBe("static"); + }); + + it.each([ + { + name: "a matching cache policy", + header: "Cache-Control", + value: "s-maxage=60", + proof: false, + admitted: true, + }, + { name: "only Vary", header: "Vary", value: "Accept-Language", proof: false, admitted: false }, + { name: "only Vary", header: "Vary", value: "Accept-Language", proof: true, admitted: true }, + ])( + "admits a single-stage render when config sets $name (proof: $proof)", + async ({ header, value, proof, admitted }) => { + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + null, + "build-a", + true, + "verbatim", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: "/page" }; + const cacheControl = "s-maxage=60"; + state.outcome = { + cacheable: true, + cacheControl, + ...(proof ? { searchParamsUnread: true as const } : {}), + }; + state.frameworkResponseCachePolicy = new Headers({ "Cache-Control": cacheControl }); + const rendered = runWithExecutionContext(context, () => + finalizeAppRscResponse( + new Response("static", { headers: { "Cache-Control": cacheControl } }), + request, + { + basePath: "", + configHeaders: [{ source: "/page", headers: [{ key: header, value }] }], + i18nConfig: null, + requestContext: requestContextFromRequest(request), + }, + ), + ); + + const response = await finalizeWorkerCacheabilityResponse(await rendered, context); + + expect(response.headers.get("Cache-Control")).toContain(admitted ? cacheControl : "no-store"); + await expect(response.text()).resolves.toBe("static"); + }, + ); + + it.each( + (["single-stage", "response-stage"] as const).flatMap((path) => [ + { path, header: "Cache-Control", value: "s-maxage=300", admitted: false }, + { path, header: "CDN-Cache-Control", value: "max-age=300", admitted: false }, + { path, header: "Cloudflare-CDN-Cache-Control", value: null, admitted: true }, + { + path, + header: "Cloudflare-CDN-Cache-Control", + value: "public, max-age=300", + admitted: true, + }, + ]), + )( + "requires a searchParams proof unless config $header wins Cloudflare precedence ($path, value: $value)", + async ({ path, header, value, admitted }) => { + setCdnCacheAdapter(new CloudflareCdnCacheAdapter()); + try { + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + null, + "build-a", + true, + "verbatim", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: "/page" }; + state.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + const rendererHeaders = new Headers(); + await runWithExecutionContext(context, () => + applyCdnResponseHeaders(rendererHeaders, { cacheControl: "s-maxage=60" }), + ); + state.frameworkResponseCachePolicy = new Headers(rendererHeaders); + const rendererPolicy = rendererHeaders.get("Cloudflare-CDN-Cache-Control"); + expect(rendererPolicy).not.toBeNull(); + // A null value repeats the renderer's own edge policy. + const configValue = value ?? rendererPolicy!; + const rendered = new Response("static", { headers: rendererHeaders }); + const composed = + path === "single-stage" + ? await runWithExecutionContext(context, () => + finalizeAppRscResponse(rendered, request, { + basePath: "", + configHeaders: [ + { source: "/page", headers: [{ key: header, value: configValue }] }, + ], + i18nConfig: null, + requestContext: requestContextFromRequest(request), + }), + ) + : applyResponseStageCachePolicy(rendered, context, [[header, configValue]]); + + const response = await finalizeWorkerCacheabilityResponse(composed, context); + + if (admitted) { + expect(response.headers.get("Cloudflare-CDN-Cache-Control")).not.toBeNull(); + } else { + expect(response.headers.get("Cache-Control")).toContain("no-store"); + expect(response.headers.get("Cloudflare-CDN-Cache-Control")).toBeNull(); + } + await expect(response.text()).resolves.toBe("static"); + } finally { + setCdnCacheAdapter(new DefaultCdnCacheAdapter()); + } + }, + ); + + it.each( + (["single-stage", "response-stage"] as const).flatMap((path) => [ + { path, namesHeader: true, header: "Cache-Control", value: "s-maxage=300", admitted: false }, + { path, namesHeader: true, header: "X-Example-Edge-Policy", value: "allow", admitted: true }, + { path, namesHeader: false, header: "Cache-Control", value: "s-maxage=300", admitted: false }, + { + path, + namesHeader: false, + header: "X-Example-Edge-Policy", + value: "allow", + admitted: false, + }, + // A differing opaque value changes the effective policy, but only the + // adapter can say which header produced it. + { path, namesHeader: true, header: "X-Example-Edge-Policy", value: "extend", admitted: true }, + { + path, + namesHeader: false, + header: "X-Example-Edge-Policy", + value: "extend", + admitted: false, + }, + ]), + )( + "attributes an opaque adapter policy only through the header it names ($path, names: $namesHeader, config: $header: $value)", + async ({ path, namesHeader, header, value, admitted }) => { + // The edge header's opaque values bypass Cache-Control, so a synthetic + // Cache-Control-syntax value would fall back to the config header. + const readEdgePolicy = (headers: Headers) => { + const edge = headers.get("X-Example-Edge-Policy"); + return edge === "allow" || edge === "extend" || edge === "deny" + ? "X-Example-Edge-Policy" + : null; + }; + const adapter: CdnCacheAdapter = { + ownsBackgroundRevalidation: false, + responsePolicy: { + isHeader: (name) => name.toLowerCase() === "x-example-edge-policy", + readCacheControl: (headers) => + headers.get("X-Example-Edge-Policy") === "allow" + ? "public, s-maxage=60" + : headers.get("X-Example-Edge-Policy") === "extend" + ? "public, s-maxage=300" + : headers.get("X-Example-Edge-Policy") === "deny" + ? "no-store" + : headers.get("Cache-Control"), + ...(namesHeader + ? { + readCacheControlHeaderName: (headers: Headers) => + readEdgePolicy(headers) ?? + (headers.has("Cache-Control") ? "Cache-Control" : null), + } + : {}), + hasExplicitNonCacheablePolicy: (headers) => + headers.get("X-Example-Edge-Policy") === "deny", + }, + async get() { + return null; + }, + async set() {}, + buildResponseHeaders({ cacheControl }) { + return cacheControl.includes("no-store") + ? { "Cache-Control": "no-store", "X-Example-Edge-Policy": null } + : { "Cache-Control": "max-age=0", "X-Example-Edge-Policy": "allow" }; + }, + async revalidateTag() {}, + }; + setCdnCacheAdapter(adapter); + try { + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + null, + "build-a", + true, + "verbatim", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: "/page" }; + state.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + const rendererHeaders = new Headers(); + await runWithExecutionContext(context, () => + applyCdnResponseHeaders(rendererHeaders, { cacheControl: "s-maxage=60" }), + ); + state.frameworkResponseCachePolicy = new Headers(rendererHeaders); + const rendered = new Response("static", { headers: rendererHeaders }); + const composed = + path === "single-stage" + ? await runWithExecutionContext(context, () => + finalizeAppRscResponse(rendered, request, { + basePath: "", + configHeaders: [{ source: "/page", headers: [{ key: header, value }] }], + i18nConfig: null, + requestContext: requestContextFromRequest(request), + }), + ) + : applyResponseStageCachePolicy(rendered, context, [[header, value]]); + + const response = await finalizeWorkerCacheabilityResponse(composed, context); + + expect(response.headers.get("X-Example-Edge-Policy")).toBe(admitted ? "allow" : null); + expect(response.headers.get("Cache-Control")).toBe(admitted ? "max-age=0" : "no-store"); + await expect(response.text()).resolves.toBe("static"); + } finally { + setCdnCacheAdapter(new DefaultCdnCacheAdapter()); + } + }, + ); + + it.each(["single-stage", "response-stage"] as const)( + "admits a whitespace-padded config policy matching the renderer's without a searchParams proof (%s)", + async (path) => { + const context = createWorkerCacheabilityAdmissionContext( + { waitUntil() {} }, + request, + null, + "build-a", + true, + "verbatim", + ); + const state = cacheabilityState(context); + state.route = { kind: "app-page", pattern: "/page" }; + const cacheControl = "s-maxage=60"; + state.outcome = { cacheable: true, cacheControl }; + state.frameworkResponseCachePolicy = new Headers({ "Cache-Control": cacheControl }); + const rendered = new Response("static", { headers: { "Cache-Control": cacheControl } }); + // Headers.set trims the padding, so the response carries config's value. + const value = ` ${cacheControl}\t`; + const composed = + path === "single-stage" + ? await runWithExecutionContext(context, () => + finalizeAppRscResponse(rendered, request, { + basePath: "", + configHeaders: [{ source: "/page", headers: [{ key: "Cache-Control", value }] }], + i18nConfig: null, + requestContext: requestContextFromRequest(request), + }), + ) + : applyResponseStageCachePolicy(rendered, context, [["Cache-Control", value]]); + + const response = await finalizeWorkerCacheabilityResponse(composed, context); + + expect(response.headers.get("Cache-Control")).toBe(cacheControl); + await expect(response.text()).resolves.toBe("static"); + }, + ); + it("checks every sibling render against the route-pattern classification", async () => { const route: CacheabilityManifestRoute = { kind: "app-page", @@ -1081,7 +1466,11 @@ describe("single-request cacheability admission", () => { ); const staticState = cacheabilityState(staticContext); staticState.route = { kind: "app-page", pattern: route.pattern }; - staticState.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + staticState.outcome = { + cacheable: true, + searchParamsUnread: true, + cacheControl: "s-maxage=60", + }; const staticResponse = await finalizeWorkerCacheabilityResponse( new Response("public sibling"), staticContext, @@ -1098,7 +1487,7 @@ describe("single-request cacheability admission", () => { ); const rscState = cacheabilityState(rscContext); rscState.route = { kind: "app-page", pattern: route.pattern }; - rscState.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + rscState.outcome = { cacheable: true, searchParamsUnread: true, cacheControl: "s-maxage=60" }; const rscResponse = await finalizeWorkerCacheabilityResponse( new Response("public RSC sibling"), rscContext, @@ -1189,7 +1578,7 @@ describe("single-request cacheability admission", () => { ); const state = cacheabilityState(context); state.route = { kind: "app-page", pattern: route.pattern }; - state.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + state.outcome = { cacheable: true, searchParamsUnread: true, cacheControl: "s-maxage=60" }; const response = await finalizeWorkerCacheabilityResponse( new Response("runtime fallback"), @@ -1295,6 +1684,7 @@ describe("single-request cacheability admission", () => { } state.outcome = { cacheable: true, + searchParamsUnread: true, cacheControl: "s-maxage=60, stale-while-revalidate=540", }; @@ -1347,7 +1737,7 @@ describe("single-request cacheability admission", () => { ); const state = cacheabilityState(context); state.route = { kind: "app-page", pattern: "/page" }; - state.outcome = { cacheable: true, cacheControl: "s-maxage=60" }; + state.outcome = { cacheable: true, searchParamsUnread: true, cacheControl: "s-maxage=60" }; const response = await finalizeWorkerCacheabilityResponse( new Response("still rendered"), @@ -1559,6 +1949,50 @@ describe("cacheability probe finalization", () => { }); }); + it("reports dynamic usage and an applied next.config policy", async () => { + const envelope = async (state: Omit) => + (await finalizeWorkerCacheabilityResponse( + new Response("body", { headers: { "Cache-Control": "s-maxage=60" } }), + contextWith({ captureDeadlineAt: Date.now() + 1_000, mode: "probe", ...state }), + ).then((response) => response.json())) as Record; + const route = { kind: "app-page" as const, pattern: "/posts/:slug" }; + + const staticEnvelope = await envelope({ + outcome: { cacheable: true, cacheControl: "s-maxage=60" }, + route, + }); + expect(staticEnvelope).toMatchObject({ rendererStatic: true, state: "static-candidate" }); + expect(staticEnvelope).not.toHaveProperty("dynamicUsage"); + expect(staticEnvelope).not.toHaveProperty("explicitConfigCachePolicy"); + + await expect( + envelope({ + explicitConfigCachePolicy: true, + frameworkResponseCachePolicy: new Headers({ "Cache-Control": "no-store" }), + outcome: { cacheable: false, dynamicUsage: true }, + route, + }), + ).resolves.toMatchObject({ + dynamicUsage: true, + explicitConfigCachePolicy: true, + rendererStatic: false, + state: "static-candidate", + }); + + const outcome = { + cacheable: false, + dynamicUsage: true, + reason: '"use cache: private" requires request-time execution', + }; + await expect( + envelope({ outcome, probeBailout: { kind: "private-cache", outcome }, route }), + ).resolves.toMatchObject({ dynamicUsage: true, state: "dynamic" }); + + await expect( + envelope({ patternDynamicReason: 'dynamic = "force-dynamic"', route }), + ).resolves.toMatchObject({ dynamicUsage: true, scope: "pattern", state: "dynamic" }); + }); + it("does not let ordinary dynamic usage hide a route 500", async () => { const state: RouteCacheabilityState = { captureDeadlineAt: Date.now() + 1_000, diff --git a/tests/cacheability-manifest.test.ts b/tests/cacheability-manifest.test.ts index 7dfa2ef971..8dc13b7185 100644 --- a/tests/cacheability-manifest.test.ts +++ b/tests/cacheability-manifest.test.ts @@ -163,6 +163,40 @@ describe("cacheability manifest", () => { ).toBeNull(); }); + it("certifies one path under each representation of its static render", () => { + const promotedRoute: CacheabilityManifestRoute = { + ...route, + pathPrefix: "/products/", + runtimePaths: ["dynamic"], + state: "runtime-check", + staticPaths: { html: ["static"], "rsc-full": ["static"], "rsc-loading-shell": ["static"] }, + }; + const manifest = parseCacheabilityManifest( + JSON.stringify({ buildId: "build-a", routes: { [key]: promotedRoute }, version: 1 }), + "build-a", + ); + expect(manifest).not.toBeNull(); + const parsed = findCacheabilityManifestRoute(manifest!, route.kind, route.pattern)!; + for (const representation of ["html", "rsc-full", "rsc-loading-shell"] as const) { + expect(cacheabilityManifestRouteState(parsed, "/products/static", representation)).toBe( + "static-candidate", + ); + } + + // A path is still listed once per list, and never both runtime-checked and static. + for (const malformedRoute of [ + { ...promotedRoute, staticPaths: { html: ["static"], "rsc-full": ["dynamic"] } }, + { ...promotedRoute, staticPaths: { html: ["static"], "rsc-full": ["static", "static"] } }, + ]) { + expect( + parseCacheabilityManifest( + JSON.stringify({ buildId: "build-a", routes: { [key]: malformedRoute }, version: 1 }), + "build-a", + ), + ).toBeNull(); + } + }); + it("maps Pages data and HTML requests to one concrete route pathname", () => { expect(cacheabilityRoutePathname("/docs/products/one?currency=gbp", "html")).toBe( "/docs/products/one", @@ -233,6 +267,49 @@ describe("cacheability manifest", () => { expect(cacheabilityManifestRouteState(shellRoute, "/posts/one", "html")).toBeNull(); }); + it("authorizes a runtime representation only at its listed paths", () => { + const mixedRoute: CacheabilityManifestRoute = { + kind: "app-page", + pathPrefix: "/posts/", + pattern: "/posts/:slug", + runtimeRepresentationPaths: { "rsc-loading-shell": ["two"] }, + state: "runtime-check", + staticPaths: { html: ["one"], "rsc-full": ["one"], "rsc-loading-shell": ["one"] }, + }; + const mixedKey = cacheabilityManifestRouteKey(mixedRoute.kind, mixedRoute.pattern); + const parse = (value: CacheabilityManifestRoute) => + parseCacheabilityManifest( + JSON.stringify({ buildId: "build-a", routes: { [mixedKey]: value }, version: 1 }), + "build-a", + ); + expect(parse(mixedRoute)?.routes[mixedKey]).toEqual(mixedRoute); + expect(cacheabilityManifestRouteState(mixedRoute, "/posts/two", "rsc-loading-shell")).toBe( + "runtime-check", + ); + expect(cacheabilityManifestRouteState(mixedRoute, "/posts/two", "rsc-full")).toBeNull(); + expect(cacheabilityManifestRouteState(mixedRoute, "/posts/two", "html")).toBeNull(); + expect(cacheabilityManifestRouteState(mixedRoute, "/posts/one", "rsc-loading-shell")).toBe( + "static-candidate", + ); + expect( + cacheabilityManifestRouteState(mixedRoute, "/posts/three", "rsc-loading-shell"), + ).toBeNull(); + // A path is never both representation-only and runtime-checked or static. + expect(parse({ ...mixedRoute, runtimePaths: ["two"] })).toBeNull(); + expect( + parse({ ...mixedRoute, runtimeRepresentationPaths: { "rsc-loading-shell": ["one"] } }), + ).toBeNull(); + expect( + parse({ + kind: "app-page", + pattern: "/posts/:slug", + runtimeRepresentation: "rsc-loading-shell", + runtimeRepresentationPaths: { "rsc-loading-shell": ["/posts/two"] }, + state: "runtime-check", + }), + ).toBeNull(); + }); + it("keeps HTML query variants and RSC representations distinct", () => { expect( cacheabilityRequestIdentity( diff --git a/tests/candidate-search-params-gate.test.ts b/tests/candidate-search-params-gate.test.ts new file mode 100644 index 0000000000..eb52eb2bba --- /dev/null +++ b/tests/candidate-search-params-gate.test.ts @@ -0,0 +1,243 @@ +import React from "react"; +import { renderToReadableStream } from "react-dom/server.edge"; +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { startCandidateSearchParamsGate } from "../packages/vinext/src/server/app-ssr-search-params-gate.js"; +import { + consumeDynamicUsage, + headersContextFromRequest, + markDynamicUsage, + runWithHeadersContext, + runWithIsolatedDynamicUsage, +} from "../packages/vinext/src/shims/headers.js"; +import { + isBailoutToCSRError, + setNavigationContext, + useSearchParams, +} from "../packages/vinext/src/shims/navigation.js"; + +const QUERY = "q=secret"; + +function SearchValue(props: { id: string }): React.ReactNode { + const searchParams = useSearchParams(); + return React.createElement("p", { id: props.id }, `value:${searchParams.get("q") ?? ""}`); +} + +function wrapped(id = "search"): React.ReactNode { + return React.createElement( + React.Suspense, + { fallback: React.createElement("p", null, `fallback:${id}`) }, + React.createElement(SearchValue, { id }), + ); +} + +type GatedRender = { + gate: ReturnType["gate"]; + /** Read the Flight stream to its end, which settles the render. */ + settle(): Promise; +}; + +/** Start a candidate render's gate, as handleSsr does, with a stub Flight stream. */ +function startGatedRender(): GatedRender { + const { gate, settleWhenConsumed } = startCandidateSearchParamsGate(); + let closeFlight!: () => void; + const flight = settleWhenConsumed( + new ReadableStream({ + start(controller) { + closeFlight = () => controller.close(); + }, + }), + ); + setNavigationContext({ + pathname: "/search", + searchParams: new URLSearchParams(QUERY), + params: {}, + searchParamsGate: gate, + }); + return { + gate, + async settle() { + closeFlight(); + const reader = flight.getReader(); + while (!(await reader.read()).done) { + // drain + } + }, + }; +} + +async function readHtml(stream: ReadableStream): Promise { + return new Response(stream).text(); +} + +function inRequest(fn: () => Promise): Promise { + return runWithHeadersContext( + headersContextFromRequest(new Request(`https://example.test/search?${QUERY}`)), + fn, + ); +} + +afterEach(() => { + setNavigationContext(null); +}); + +describe("candidate render useSearchParams() gate", () => { + it("bails out to the Suspense fallback when the render settles static", async () => { + await inRequest(async () => { + const render = startGatedRender(); + const stream = await renderToReadableStream(wrapped(), { onError: () => {} }); + await render.settle(); + await stream.allReady; + const html = await readHtml(stream); + + expect(render.gate.decision).toBe("bailout"); + expect(html).toContain("fallback:search"); + expect(html).not.toContain("secret"); + expect(consumeDynamicUsage()).toBe(false); + }); + }); + + it("fails the shell when an unwrapped call bails out", async () => { + await inRequest(async () => { + const render = startGatedRender(); + const shell = renderToReadableStream(React.createElement(SearchValue, { id: "search" }), { + onError: () => {}, + }); + await render.settle(); + + const error = await shell.then( + () => null, + (reason: unknown) => reason, + ); + expect(isBailoutToCSRError(error)).toBe(true); + }); + }); + + it("reads the real query when the render is already dynamic", async () => { + await inRequest(async () => { + markDynamicUsage(); + const render = startGatedRender(); + expect(render.gate.decision).toBe("real"); + + const stream = await renderToReadableStream(wrapped(), { onError: () => {} }); + await stream.allReady; + expect(await readHtml(stream)).toContain("value:secret"); + await render.settle(); + }); + }); + + it("reads the real query once the render turns dynamic while waiting", async () => { + await inRequest(async () => { + const render = startGatedRender(); + const stream = await renderToReadableStream(wrapped(), { onError: () => {} }); + // A server component below the shell reads cookies(). + markDynamicUsage(); + await render.settle(); + await stream.allReady; + + expect(render.gate.decision).toBe("real"); + expect(await readHtml(stream)).toContain("value:secret"); + }); + }); + + it("sees dynamic usage consumed at the shell before the gate starts", async () => { + await inRequest(async () => { + markDynamicUsage(); + expect(consumeDynamicUsage()).toBe(true); + const render = startGatedRender(); + expect(render.gate.decision).toBe("real"); + await render.settle(); + }); + }); + + it("sees dynamic usage inside an isolated scope", async () => { + await inRequest(async () => { + const render = startGatedRender(); + await runWithIsolatedDynamicUsage(async () => { + markDynamicUsage(); + }); + expect(render.gate.decision).toBe("real"); + await render.settle(); + }); + }); + + it("marks the render dynamic when it opens the gate", async () => { + await inRequest(async () => { + markDynamicUsage(); + consumeDynamicUsage(); + const render = startGatedRender(); + // The consumed flag is set again, so the render is never stored. + expect(consumeDynamicUsage()).toBe(true); + await render.settle(); + }); + }); + + it("keeps the fallback when the render turns dynamic after it settles", async () => { + await inRequest(async () => { + const render = startGatedRender(); + const stream = await renderToReadableStream(wrapped(), { onError: () => {} }); + await render.settle(); + markDynamicUsage(); + await stream.allReady; + + expect(render.gate.decision).toBe("bailout"); + expect(await readHtml(stream)).toContain("fallback:search"); + expect(consumeDynamicUsage()).toBe(true); + }); + }); + + it("follows a decision made before the call", async () => { + await inRequest(async () => { + const render = startGatedRender(); + await render.settle(); + const stream = await renderToReadableStream(wrapped(), { onError: () => {} }); + await stream.allReady; + expect(await readHtml(stream)).toContain("fallback:search"); + }); + }); + + it("bails out every call, including nested boundaries", async () => { + await inRequest(async () => { + const render = startGatedRender(); + const tree = React.createElement( + React.Suspense, + { fallback: React.createElement("p", null, "fallback:outer") }, + React.createElement(SearchValue, { id: "outer" }), + wrapped("inner"), + ); + const stream = await renderToReadableStream(tree, { onError: () => {} }); + await render.settle(); + await stream.allReady; + const html = await readHtml(stream); + + expect(html).toContain("fallback:outer"); + expect(html).not.toContain("secret"); + }); + }); + + it("opens, rather than bails out, when SSR cancels the Flight stream", async () => { + await inRequest(async () => { + const { gate, settleWhenConsumed } = startCandidateSearchParamsGate(); + const flight = settleWhenConsumed(new ReadableStream()); + await flight.cancel(); + expect(gate.decision).toBe("real"); + expect(consumeDynamicUsage()).toBe(true); + }); + }); + + it("keeps a Flight failure instead of turning it into a bailout", async () => { + await inRequest(async () => { + const { gate, settleWhenConsumed } = startCandidateSearchParamsGate(); + const failure = new Error("flight failed"); + const flight = settleWhenConsumed( + new ReadableStream({ + pull(controller) { + controller.error(failure); + }, + }), + ); + await expect(flight.getReader().read()).rejects.toBe(failure); + expect(gate.decision).toBe("real"); + expect(consumeDynamicUsage()).toBe(true); + }); + }); +}); diff --git a/tests/client-page-root.test.ts b/tests/client-page-root.test.ts new file mode 100644 index 0000000000..a0bb8f96a3 --- /dev/null +++ b/tests/client-page-root.test.ts @@ -0,0 +1,745 @@ +import React from "react"; +import { renderToReadableStream } from "react-dom/server.edge"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + createClientPageSsrSearchParamsSource, + makeClientPageSsrSearchParamsThenable, +} from "../packages/vinext/src/server/app-page-search-params-observation.js"; +import { startCandidateSearchParamsGate } from "../packages/vinext/src/server/app-ssr-search-params-gate.js"; +import { + ClientPageRoot, + createClientPageSearchParams, +} from "../packages/vinext/src/shims/client-page-root.js"; +import { + consumeDynamicUsage, + consumeRenderRequestApiUsage, + headersContextFromRequest, + isRenderDynamicLatched, + runWithHeadersContext, +} from "../packages/vinext/src/shims/headers.js"; +import { setNavigationContext, useSearchParams } from "../packages/vinext/src/shims/navigation.js"; + +type SearchParamsProps = { searchParams: Promise> }; + +function ReadingPage({ searchParams }: SearchParamsProps): React.ReactNode { + const { q } = React.use(searchParams); + return React.createElement("p", null, `page:${String(q)}`); +} + +function IgnoringPage(): React.ReactNode { + return React.createElement("p", null, "page:static"); +} + +function SyncReadingPage({ searchParams }: SearchParamsProps): React.ReactNode { + return React.createElement("p", null, `page:${String(Reflect.get(searchParams, "q"))}`); +} + +/** Reads React's promise fields directly. `status` and `value` are reserved. */ +function FieldsPage({ searchParams }: SearchParamsProps): React.ReactNode { + const status = String(Reflect.get(searchParams, "status")); + const value = String(Reflect.get(searchParams, "value")); + return React.createElement("p", null, `status:${status} value:${value}`); +} + +function SearchValue(): React.ReactNode { + return React.createElement("p", null, `hook:${useSearchParams().get("q") ?? ""}`); +} + +function inRequest(fn: () => Promise): Promise { + return runWithHeadersContext( + headersContextFromRequest(new Request("https://example.test/client?q=secret")), + fn, + ); +} + +/** Set up SSR navigation state as handleSsr does for a candidate render. */ +function startCandidateSsr(options?: { isPprFallbackShell?: boolean }) { + const searchParams = new URLSearchParams("q=secret"); + const gate = startCandidateSearchParamsGate(); + setNavigationContext({ + pathname: "/client", + searchParams, + params: {}, + searchParamsGate: gate.gate, + getClientPageSearchParams: createClientPageSsrSearchParamsSource(searchParams, { + isPprFallbackShell: options?.isPprFallbackShell, + }), + }); + return gate; +} + +async function renderPage( + Component: React.ComponentType, + extra?: React.ReactNode, + rootProps?: { emptySearchParams?: boolean; pageProps?: Record }, +): Promise { + const stream = await renderToReadableStream( + React.createElement( + React.Fragment, + null, + React.createElement(ClientPageRoot, { + Component: Component as React.ComponentType>, + pageProps: { params: Promise.resolve({}) }, + ...rootProps, + }), + extra, + ), + { onError: () => {} }, + ); + await stream.allReady; + return new Response(stream).text(); +} + +/** + * Render to HTML once everything settles. A page suspends the first time it + * uses a searchParams promise React hasn't tracked yet. + */ +async function renderMarkup(element: React.ReactNode): Promise { + const stream = await renderToReadableStream(element); + await stream.allReady; + return new Response(stream).text(); +} + +afterEach(() => { + setNavigationContext(null); +}); + +describe("ClientPageRoot in SSR", () => { + it("marks a candidate render dynamic when the page reads searchParams", async () => { + await inRequest(async () => { + const gate = startCandidateSsr(); + const html = await renderPage( + ReadingPage, + React.createElement( + React.Suspense, + { fallback: React.createElement("p", null, "fallback") }, + React.createElement(SearchValue), + ), + ); + + // The read is a dynamic API, so the render won't be stored and the + // page and useSearchParams() both render the real query. + expect(html).toContain("page:secret"); + expect(html).toContain("hook:secret"); + expect(gate.gate.decision).toBe("real"); + expect(isRenderDynamicLatched()).toBe(true); + expect(consumeDynamicUsage()).toBe(true); + expect(consumeRenderRequestApiUsage()).toContain("searchParams"); + }); + }); + + it("marks a synchronous property read dynamic and returns the real value", async () => { + await inRequest(async () => { + startCandidateSsr(); + const html = await renderPage(SyncReadingPage); + + expect(html).toContain("page:secret"); + expect(isRenderDynamicLatched()).toBe(true); + }); + }); + + it("leaves a candidate render static when the page never reads searchParams", async () => { + await inRequest(async () => { + const gate = startCandidateSsr(); + const html = await renderPage(IgnoringPage); + + expect(html).toContain("page:static"); + expect(html).not.toContain("secret"); + expect(gate.gate.decision).toBeNull(); + expect(isRenderDynamicLatched()).toBe(false); + expect(consumeDynamicUsage()).toBe(false); + expect(consumeRenderRequestApiUsage()).not.toContain("searchParams"); + }); + }); + + it("hands a force-static page an empty, untracked query", async () => { + await inRequest(async () => { + startCandidateSsr(); + const html = await renderPage(ReadingPage, null, { emptySearchParams: true }); + + expect(html).toContain("page:undefined"); + expect(html).not.toContain("secret"); + expect(isRenderDynamicLatched()).toBe(false); + expect(consumeRenderRequestApiUsage()).not.toContain("searchParams"); + }); + }); + + it("hands a PPR fallback shell its query untracked", async () => { + await inRequest(async () => { + startCandidateSsr({ isPprFallbackShell: true }); + const html = await renderPage(ReadingPage); + + expect(html).toContain("page:secret"); + expect(isRenderDynamicLatched()).toBe(false); + expect(consumeRenderRequestApiUsage()).not.toContain("searchParams"); + }); + }); + + it("observes a force-static navigation context's query untracked", async () => { + // handleSsr's own force-static guard, behind the page's emptySearchParams. + await inRequest(async () => { + const searchParams = makeClientPageSsrSearchParamsThenable(new URLSearchParams(), { + isForceStatic: true, + }); + + expect({ ...(await searchParams) }).toEqual({}); + expect(isRenderDynamicLatched()).toBe(false); + expect(consumeRenderRequestApiUsage()).not.toContain("searchParams"); + }); + }); + + it("marks a direct own-property check dynamic and answers it like the browser", async () => { + await inRequest(async () => { + startCandidateSsr(); + function OwnPropertyPage({ searchParams }: SearchParamsProps): React.ReactNode { + return React.createElement("p", null, `own:${String(searchParams.hasOwnProperty("q"))}`); + } + const html = await renderPage(OwnPropertyPage); + + expect(html).toContain("own:true"); + expect(isRenderDynamicLatched()).toBe(true); + }); + }); + + it("keeps handing the page the same promise across renders", async () => { + await inRequest(async () => { + startCandidateSsr(); + const received: unknown[] = []; + function RecordingPage({ searchParams }: SearchParamsProps): React.ReactNode { + received.push(searchParams); + return null; + } + const pageProps = { params: Promise.resolve({}) }; + await renderPage(RecordingPage, null, { pageProps }); + await renderPage(RecordingPage, null, { pageProps }); + // Another page, such as a parallel slot's, gets its own. + await renderPage(RecordingPage); + + expect(received).toHaveLength(3); + expect(received[0]).toBe(received[1]); + expect(received[2]).not.toBe(received[0]); + }); + }); +}); + +describe("createClientPageSearchParams", () => { + it("builds a settled promise with the query readable synchronously", async () => { + const searchParams = createClientPageSearchParams( + new URLSearchParams("q=one&tag=a&tag=b&status=y&value=z&then=x"), + ); + const record = await searchParams; + + // Reserved names aren't query keys, and React adds its bookkeeping only + // once it tracks the promise, as it does with the SSR thenable. + expect(Reflect.get(searchParams, "status")).toBeUndefined(); + expect(Reflect.get(searchParams, "value")).toBeUndefined(); + expect(Reflect.get(searchParams, "q")).toBe("one"); + expect(Reflect.get(searchParams, "tag")).toEqual(["a", "b"]); + // Names Promise and React rely on keep their meaning, and stay readable + // once awaited. + expect(typeof searchParams.then).toBe("function"); + expect(Reflect.get(record, "then")).toBe("x"); + expect(Reflect.get(record, "status")).toBe("y"); + expect(Reflect.get(record, "value")).toBe("z"); + expect(Object.keys(record)).toEqual(["q", "tag", "status", "value", "then"]); + }); + + it("builds an empty record without a query", async () => { + expect({ ...(await createClientPageSearchParams(null)) }).toEqual({}); + }); + + it("enumerates like the SSR thenable, so hydration sees the same keys", async () => { + const query = "q=one&status=y&value=z&then=x&constructor=c&tag=a&tag=b"; + const browser = createClientPageSearchParams(new URLSearchParams(query)); + const ssr = makeClientPageSsrSearchParamsThenable(new URLSearchParams(query), { + isPprFallbackShell: true, + }); + + // React's bookkeeping and the reserved names aren't query keys. + expect(Object.keys(browser)).toEqual(["q", "constructor", "tag"]); + expect(Object.keys(browser)).toEqual(Object.keys(ssr)); + // What `{ ...searchParams }` copies. + expect(Object.entries(browser)).toEqual(Object.entries(ssr)); + expect({ ...(await browser) }).toEqual({ ...(await ssr) }); + }); + + it("answers direct own-property checks like the SSR thenable", async () => { + const query = "q=one&then=x"; + const browser = createClientPageSearchParams(new URLSearchParams(query)); + const ssr = makeClientPageSsrSearchParamsThenable(new URLSearchParams(query), { + isPprFallbackShell: true, + }); + + for (const searchParams of [browser, ssr]) { + expect(searchParams.hasOwnProperty("q")).toBe(true); + expect(searchParams.propertyIsEnumerable("q")).toBe(true); + expect(searchParams.hasOwnProperty("missing")).toBe(false); + // A reserved name isn't a query key. + expect(searchParams.hasOwnProperty("then")).toBe(false); + } + }); + + it("resolves to a plain object, like the SSR thenable", async () => { + const record = await createClientPageSearchParams(new URLSearchParams("q=one&__proto__=x")); + const ssrRecord = await makeClientPageSsrSearchParamsThenable( + new URLSearchParams("q=one&__proto__=x"), + { isPprFallbackShell: true }, + ); + + expect(Object.getPrototypeOf(record)).toBe(Object.prototype); + expect(Object.getPrototypeOf(record)).toBe(Object.getPrototypeOf(ssrRecord)); + expect(record.hasOwnProperty("q")).toBe(true); + // A `__proto__` key stays an ordinary entry. + expect(Object.keys(record)).toEqual(["q", "__proto__"]); + expect(Object.getOwnPropertyDescriptor(record, "__proto__")?.value).toBe("x"); + }); + + it("reads a query key named constructor like the SSR thenable, and stays awaitable", async () => { + const query = "constructor=c&q=one"; + const browser = createClientPageSearchParams(new URLSearchParams(query)); + const ssr = makeClientPageSsrSearchParamsThenable(new URLSearchParams(query), { + isPprFallbackShell: true, + }); + + for (const searchParams of [browser, ssr]) { + expect(Reflect.get(searchParams, "constructor")).toBe("c"); + const record = await searchParams; + expect(Reflect.get(record, "constructor")).toBe("c"); + expect(Reflect.get(record, "q")).toBe("one"); + await expect(searchParams.finally(() => {})).resolves.toBe(record); + } + }); +}); + +type BrowserModules = { + ClientPageRoot: typeof ClientPageRoot; + navigation: typeof import("../packages/vinext/src/shims/navigation.js"); + slot: typeof import("../packages/vinext/src/shims/slot.js"); +}; + +/** Load the shims as the browser does: with a `window`. */ +async function withBrowserModules(run: (modules: BrowserModules) => Promise): Promise { + const previousWindow = Reflect.get(globalThis, "window"); + Reflect.set(globalThis, "window", { + location: { + hash: "", + href: "http://localhost/feed/bar", + origin: "http://localhost", + pathname: "/feed/bar", + search: "", + }, + history: { state: null, pushState() {}, replaceState() {} }, + addEventListener() {}, + removeEventListener() {}, + }); + try { + vi.resetModules(); + const { ClientPageRoot: BrowserClientPageRoot } = + await import("../packages/vinext/src/shims/client-page-root.js"); + const navigation = await import("../packages/vinext/src/shims/navigation.js"); + const slot = await import("../packages/vinext/src/shims/slot.js"); + await run({ ClientPageRoot: BrowserClientPageRoot, navigation, slot }); + } finally { + vi.resetModules(); + if (previousWindow === undefined) { + Reflect.deleteProperty(globalThis, "window"); + } else { + Reflect.set(globalThis, "window", previousWindow); + } + } +} + +function QueryPage({ searchParams }: SearchParamsProps): React.ReactNode { + const record = React.use(searchParams); + return React.createElement("p", null, `query:${JSON.stringify(record)}`); +} + +function renderInBrowser( + modules: BrowserModules, + snapshot: ReturnType, + pageProps: Record, + rootProps?: { emptySearchParams?: boolean }, +): Promise { + const Context = modules.navigation.getClientNavigationRenderContext(); + if (!Context) throw new Error("Expected client navigation render context"); + return renderMarkup( + React.createElement( + Context.Provider, + { value: snapshot }, + React.createElement(modules.ClientPageRoot, { + Component: QueryPage as React.ComponentType>, + pageProps, + ...rootProps, + }), + ), + ); +} + +describe("ClientPageRoot in the browser", () => { + it("reads the query the server rendered, which a rewrite may change", async () => { + // A rewrite from /feed/:tab to /feed?tab=:tab: the browser URL has no + // query, and the navigation response names the rendered one. + await withBrowserModules(async (modules) => { + const snapshot = modules.navigation.createClientNavigationRenderSnapshot( + "http://localhost/feed/bar", + {}, + "/feed?tab=bar", + ); + + expect(snapshot.renderedSearch).toBe("?tab=bar"); + expect(await renderInBrowser(modules, snapshot, { params: {} })).toContain( + "query:{"tab":"bar"}", + ); + }); + }); + + it("reads the browser URL's query when the rendered one is unknown", async () => { + await withBrowserModules(async (modules) => { + const snapshot = modules.navigation.createClientNavigationRenderSnapshot( + "http://localhost/feed?tab=hot", + {}, + ); + + expect(snapshot.renderedSearch).toBeUndefined(); + expect(await renderInBrowser(modules, snapshot, { params: {} })).toContain( + "query:{"tab":"hot"}", + ); + }); + }); + + it("keeps a mounted page's query when a later navigation changes the URL", async () => { + // /feed?tab=hot, then an intercepted /photo/1 opens in @modal. The router + // keeps the feed page's server output, so its props object is the same. + await withBrowserModules(async (modules) => { + const feedProps = { params: {} }; + const feed = modules.navigation.createClientNavigationRenderSnapshot( + "http://localhost/feed?tab=hot", + {}, + "/feed?tab=hot", + ); + const photo = modules.navigation.createClientNavigationRenderSnapshot( + "http://localhost/photo/1", + { id: "1" }, + "/photo/1", + ); + + expect(await renderInBrowser(modules, feed, feedProps)).toContain("tab":"hot"); + expect(await renderInBrowser(modules, photo, feedProps)).toContain("tab":"hot"); + // A new server render of the page reads the navigation that sent it. + expect(await renderInBrowser(modules, photo, { params: {} })).toContain("query:{}"); + }); + }); + + it("reads the query a refreshed kept branch rendered with", async () => { + // A refresh under an intercepted /photo/1 fetches the kept /feed?tab=hot + // source page from its own URL and merges it into the navigation's tree. + await withBrowserModules(async (modules) => { + const Context = modules.navigation.getClientNavigationRenderContext(); + if (!Context) throw new Error("Expected client navigation render context"); + const photo = modules.navigation.createClientNavigationRenderSnapshot( + "http://localhost/photo/1", + { id: "1" }, + "/photo/1", + ); + const renderSlot = (elements: Record) => + renderMarkup( + React.createElement( + Context.Provider, + { value: photo }, + React.createElement( + modules.slot.ElementsContext.Provider, + { value: elements }, + React.createElement(modules.slot.Slot, { id: "page:/feed" }), + ), + ), + ); + const feedPage = () => + React.createElement(modules.ClientPageRoot, { + Component: QueryPage as React.ComponentType>, + pageProps: { params: {} }, + }); + + const refreshed = { "page:/feed": feedPage() }; + modules.slot.setAppElementsRenderedSearch(refreshed, "?tab=hot"); + expect(await renderSlot(refreshed)).toContain("query:{"tab":"hot"}"); + // The navigation's own output reads the navigation's query. + expect(await renderSlot({ "page:/feed": feedPage() })).toContain("query:{}"); + }); + }); + + it("reads its own navigation's query when a kept branch first renders under a later one", async () => { + // /feed?tab=hot commits its loading shell while the page is still + // streaming. An intercepted /photo/1 then keeps that branch, and the page + // first renders under /photo/1. + await withBrowserModules(async (modules) => { + const { AppElementsWire, normalizeAppElements } = + await import("../packages/vinext/src/server/app-elements.js"); + const { FRESH_APP_NAVIGATION_PAYLOAD_ORIGIN, createPendingNavigationCommitFromElements } = + await import("../packages/vinext/src/server/app-browser-state.js"); + const Context = modules.navigation.getClientNavigationRenderContext(); + if (!Context) throw new Error("Expected client navigation render context"); + const feed = modules.navigation.createClientNavigationRenderSnapshot( + "http://localhost/feed?tab=hot", + {}, + "/feed?tab=hot", + ); + const photo = modules.navigation.createClientNavigationRenderSnapshot( + "http://localhost/photo/1", + { id: "1" }, + "/photo/1", + ); + const createElements = (routeId: string, entries: Record) => + normalizeAppElements({ + ...AppElementsWire.createMetadataEntries({ + interception: null, + interceptionContext: null, + layoutIds: [], + rootLayoutTreePath: null, + routeId, + slotBindings: [], + sourcePage: null, + }), + ...entries, + }); + const feedPage = () => + React.createElement(modules.ClientPageRoot, { + Component: QueryPage as React.ComponentType>, + pageProps: { params: {} }, + }); + // A kept branch a refresh fetched from its own URL, merged in first. + const refreshed = { "page:/refreshed": feedPage() }; + modules.slot.setAppElementsRenderedSearch(refreshed, "?tab=new"); + const feedElements = createElements("route:/feed", { + "page:/feed": feedPage(), + ...refreshed, + }); + + createPendingNavigationCommitFromElements({ + currentState: { + activeOperation: null, + bfcacheIds: {}, + elements: createElements("route:/", {}), + interception: null, + interceptionContext: null, + layoutFlags: {}, + layoutIds: [], + navigationSnapshot: modules.navigation.createClientNavigationRenderSnapshot( + "http://localhost/", + {}, + ), + previousNextUrl: null, + renderId: 0, + rootLayoutTreePath: null, + routeId: "route:/", + slotBindings: [], + visibleCommitVersion: 0, + }, + navigationSnapshot: feed, + nextElements: feedElements, + operationLane: "navigation", + payloadOrigin: FRESH_APP_NAVIGATION_PAYLOAD_ORIGIN, + renderId: 1, + type: "navigate", + }); + + const renderSlot = (id: string) => + renderMarkup( + React.createElement( + Context.Provider, + { value: photo }, + React.createElement( + modules.slot.ElementsContext.Provider, + { value: feedElements }, + React.createElement(modules.slot.Slot, { id }), + ), + ), + ); + expect(await renderSlot("page:/feed")).toContain("query:{"tab":"hot"}"); + expect(await renderSlot("page:/refreshed")).toContain( + "query:{"tab":"new"}", + ); + }); + }); + + it("keeps handing a kept page the same promise", async () => { + await withBrowserModules(async (modules) => { + const received: unknown[] = []; + function RecordingPage({ searchParams }: SearchParamsProps): React.ReactNode { + received.push(searchParams); + return null; + } + const Context = modules.navigation.getClientNavigationRenderContext(); + if (!Context) throw new Error("Expected client navigation render context"); + const pageProps = { params: {} }; + for (const href of ["http://localhost/feed?tab=hot", "http://localhost/feed?tab=new"]) { + await renderMarkup( + React.createElement( + Context.Provider, + { value: modules.navigation.createClientNavigationRenderSnapshot(href, {}) }, + React.createElement(modules.ClientPageRoot, { + Component: RecordingPage as React.ComponentType>, + pageProps, + }), + ), + ); + } + + expect(received).toHaveLength(2); + expect(received[0]).toBe(received[1]); + }); + }); + + it("hydrates a direct read of React's promise fields as SSR rendered it", async () => { + // `status` and `value` are reserved on both sides, so these query keys + // don't shadow them, and neither promise carries React's bookkeeping yet. + const query = "status=y&value=z"; + const ssrHtml = await inRequest(async () => { + const searchParams = new URLSearchParams(query); + setNavigationContext({ + pathname: "/client", + searchParams, + params: {}, + getClientPageSearchParams: createClientPageSsrSearchParamsSource(searchParams, {}), + }); + return renderPage(FieldsPage); + }); + + await withBrowserModules(async (modules) => { + const Context = modules.navigation.getClientNavigationRenderContext(); + if (!Context) throw new Error("Expected client navigation render context"); + const browserHtml = await renderMarkup( + React.createElement( + Context.Provider, + { + value: modules.navigation.createClientNavigationRenderSnapshot( + `http://localhost/client?${query}`, + {}, + ), + }, + React.createElement(modules.ClientPageRoot, { + Component: FieldsPage as React.ComponentType>, + pageProps: { params: {} }, + }), + ), + ); + + expect(ssrHtml).toContain("status:undefined value:undefined"); + expect(browserHtml).toBe(ssrHtml); + }); + }); + + it("hydrates a sibling's promise fields as SSR rendered them when another page uses its query", async () => { + // Parallel client pages: one unwraps searchParams with use(), which makes + // React write its bookkeeping onto that page's promise. The browser builds + // one promise per page, so SSR must too, or the sibling sees those fields. + const query = "q=one"; + const pages = (Root: typeof ClientPageRoot) => + React.createElement( + React.Fragment, + null, + React.createElement(Root, { + Component: ReadingPage as React.ComponentType>, + pageProps: { params: {} }, + }), + React.createElement(Root, { + Component: FieldsPage as React.ComponentType>, + pageProps: { params: {} }, + }), + ); + const ssrHtml = await inRequest(async () => { + const searchParams = new URLSearchParams(query); + setNavigationContext({ + pathname: "/client", + searchParams, + params: {}, + getClientPageSearchParams: createClientPageSsrSearchParamsSource(searchParams, {}), + }); + return renderMarkup(pages(ClientPageRoot)); + }); + + await withBrowserModules(async (modules) => { + const Context = modules.navigation.getClientNavigationRenderContext(); + if (!Context) throw new Error("Expected client navigation render context"); + const browserHtml = await renderMarkup( + React.createElement( + Context.Provider, + { + value: modules.navigation.createClientNavigationRenderSnapshot( + `http://localhost/client?${query}`, + {}, + ), + }, + pages(modules.ClientPageRoot), + ), + ); + + expect(ssrHtml).toContain("page:one"); + expect(ssrHtml).toContain("status:undefined value:undefined"); + expect(browserHtml).toBe(ssrHtml); + }); + }); + + it("reads the public URL's query with Cache Components, not a rewritten one", async () => { + // Next.js builds the prop from SearchParamsContext in this mode, the query + // useSearchParams() reads. A rewrite from /feed/:tab to /feed?tab=:tab + // stays internal. + vi.stubEnv("__NEXT_CACHE_COMPONENTS", "true"); + try { + await withBrowserModules(async (modules) => { + const snapshot = modules.navigation.createClientNavigationRenderSnapshot( + "http://localhost/feed/bar", + {}, + "/feed?tab=bar", + ); + modules.navigation.activateNavigationSnapshot(); + + expect(snapshot.renderedSearch).toBe("?tab=bar"); + expect(await renderInBrowser(modules, snapshot, { params: {} })).toContain("query:{}"); + }); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("follows the URL with Cache Components when a navigation keeps the page", async () => { + vi.stubEnv("__NEXT_CACHE_COMPONENTS", "true"); + try { + await withBrowserModules(async (modules) => { + const feedProps = { params: {} }; + const feed = modules.navigation.createClientNavigationRenderSnapshot( + "http://localhost/feed?tab=hot", + {}, + "/feed?tab=hot", + ); + const photo = modules.navigation.createClientNavigationRenderSnapshot( + "http://localhost/photo/1?tab=new", + { id: "1" }, + "/photo/1?tab=new", + ); + modules.navigation.activateNavigationSnapshot(); + + expect(await renderInBrowser(modules, feed, feedProps)).toContain("tab":"hot"); + expect(await renderInBrowser(modules, photo, feedProps)).toContain("tab":"new"); + }); + } finally { + vi.unstubAllEnvs(); + } + }); + + it("keeps a force-static page's query empty during a navigation", async () => { + // SSR renders force-static pages with an empty query, and so does Next.js + // in the browser, whatever the destination URL. + await withBrowserModules(async (modules) => { + const snapshot = modules.navigation.createClientNavigationRenderSnapshot( + "http://localhost/static?value=hidden", + {}, + "/static?value=hidden", + ); + modules.navigation.activateNavigationSnapshot(); + + expect( + await renderInBrowser(modules, snapshot, { params: {} }, { emptySearchParams: true }), + ).toContain("query:{}"); + }); + }); +}); diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index da872f804e..474fc73552 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -5,9 +5,11 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { probeStagedWorkerCacheability } from "../packages/cloudflare/src/cacheability-probe.js"; import { VINEXT_CDN_BUILD_ID_HEADER } from "../packages/cloudflare/src/cache/cdn-build-id.js"; +import type { CdnWarmTarget } from "../packages/cloudflare/src/cdn-warm.js"; import { cacheabilityManifestRouteState, cacheabilityManifestRouteKey, + parseCacheabilityManifest, type CacheabilityManifestRoute, } from "../packages/vinext/src/server/cacheability-manifest.js"; import { @@ -173,7 +175,7 @@ describe("staged Worker cacheability probes", () => { unknownState: "static-candidate", pattern: "/cached/:slug", state: "runtime-check", - staticPaths: { html: ["/cached/intro"] }, + staticPaths: { html: ["/cached/intro"], "rsc-full": ["/cached/intro"] }, }); expect(result.cacheableTargets).toEqual([target]); }); @@ -244,6 +246,330 @@ describe("staged Worker cacheability probes", () => { expect(result.failures).toEqual(["/broken: probe returned HTTP 500"]); }); + it("drops an unlisted path whose render fails, but fails the deploy for a listed one", async () => { + const root = createProbeRoot(); + const route = optimizableRoute("/posts/:slug"); + const pickedRoute = { + ...route, + cacheabilityProbe: { ...route.cacheabilityProbe, unlisted: true }, + }; + const probe = (brokenPathname: string) => + probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + return pathname === brokenPathname + ? Response.json({ + kind: "app-page", + pattern: route.pattern, + reason: "route returned HTTP 500", + state: "probe-failed", + status: 500, + version: 1, + }) + : staticProbeResponse(route.pattern); + }, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [ + { ...target("/posts/listed"), route }, + { ...target("/posts/picked"), route: pickedRoute }, + ], + }); + + const dropped = await probe("/posts/picked"); + expect(dropped).toMatchObject({ failures: [], skipped: 0 }); + expect(dropped.cacheableTargets.map((warm) => warm.pathname)).toEqual(["/posts/listed"]); + const routeRecord = + dropped.manifest.routes[cacheabilityManifestRouteKey("app-page", route.pattern)]; + expect(routeRecord?.runtimePaths).toBeUndefined(); + expect(cacheabilityManifestRouteState(routeRecord!, "/posts/picked", "html")).not.toBe( + "runtime-check", + ); + + const failed = await probe("/posts/listed"); + expect(failed.failures).toEqual(["/posts/listed: route returned HTTP 500"]); + }); + + it("keeps discovery's unlisted state for a literal App page route's own path", async () => { + // A literal force-dynamic or edge-runtime page isn't build-rendered, so + // discovery marks its own path unlisted. + const literalRoute = (unlisted: boolean) => ({ + cacheabilityProbe: { canPrunePattern: true, ...(unlisted ? { unlisted: true } : {}) }, + kind: "app-page" as const, + pattern: "/dynamic", + }); + const failed500 = (pattern: string, routePathname?: string) => + Response.json({ + kind: "app-page", + pattern, + reason: "route returned HTTP 500", + ...(routePathname ? { routePathname } : {}), + state: "probe-failed", + status: 500, + version: 1, + }); + const probe = (targets: CdnWarmTarget[], fetchImpl: typeof fetch) => + probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 1, + fetchImpl, + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets, + }); + + const unlisted = await probe([{ ...target("/dynamic"), route: literalRoute(true) }], async () => + failed500("/dynamic"), + ); + expect(unlisted).toMatchObject({ cacheableTargets: [], failures: [] }); + expect(unlisted.manifest.routes).toEqual({}); + + const listed = await probe([{ ...target("/dynamic"), route: literalRoute(false) }], async () => + failed500("/dynamic"), + ); + expect(listed.failures).toEqual(["/dynamic: route returned HTTP 500"]); + + // A failure the request stage moves to the literal route is judged by the + // same discovery fact, whichever probe completes first. + const alias = { + ...target("/rewrite-me"), + route: { + cacheabilityProbe: { canPrunePattern: true, routeMayResolve: true, unlisted: true }, + kind: "app-page" as const, + pattern: "/rewrite-me", + }, + }; + const destination = { ...target("/dynamic"), route: literalRoute(true) }; + for (const targets of [ + [alias, destination], + [destination, alias], + ]) { + const moved = await probe(targets, async () => failed500("/dynamic", "/dynamic")); + expect(moved.failures).toEqual([]); + } + }); + + it("judges an unlisted render failure under the route the request stage resolved", async () => { + const sourceRoute = { + cacheabilityProbe: { canPrunePattern: true, routeMayResolve: true, unlisted: true }, + kind: "app-page" as const, + pattern: "/rewrite-me/:slug", + }; + const probe = ( + resolved: { kind: string; pattern: string; routePathname: string }, + extraTargets: CdnWarmTarget[] = [], + ) => + probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + return pathname === "/rewrite-me/a" + ? Response.json({ + ...resolved, + reason: "route returned HTTP 500", + state: "probe-failed", + status: 500, + version: 1, + }) + : staticProbeResponse("/posts/:slug"); + }, + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets: [{ ...target("/rewrite-me/a"), route: sourceRoute }, ...extraTargets], + }); + const failure = ["/rewrite-me/a: route returned HTTP 500"]; + + // Rewritten to a Pages page or Route Handler, the failure fails the deploy. + for (const kind of ["pages-page", "app-route"]) { + const result = await probe({ kind, pattern: "/legacy/:slug", routePathname: "/legacy/a" }); + expect(result.failures).toEqual(failure); + } + // Rewritten to a path the destination App page route lists, it does too. + const listed = await probe( + { kind: "app-page", pattern: "/posts/:slug", routePathname: "/posts/a" }, + [{ ...target("/posts/a"), route: optimizableRoute("/posts/:slug") }], + ); + expect(listed.failures).toEqual(failure); + // Rewritten to a path the destination doesn't list, it's dropped. + const unlisted = await probe( + { kind: "app-page", pattern: "/posts/:slug", routePathname: "/posts/b" }, + [{ ...target("/posts/a"), route: optimizableRoute("/posts/:slug") }], + ); + expect(unlisted.failures).toEqual([]); + }); + + it("judges a failure moved to another pathname of its own route by that pathname's listing", async () => { + // The request stage rewrites the listed /posts/a to /posts/b, which the same + // route renders. Only /posts/b's own listing says whether Next.js's build + // rendered it. + const movedRoute = { + cacheabilityProbe: { canPrunePattern: true, routeMayResolve: true }, + kind: "app-page" as const, + pattern: "/posts/:slug", + }; + const probe = (extraTargets: CdnWarmTarget[]) => + probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 1, + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + return pathname === "/posts/a" + ? Response.json({ + kind: "app-page", + pattern: "/posts/:slug", + reason: "route returned HTTP 500", + routePathname: "/posts/b", + state: "probe-failed", + status: 500, + version: 1, + }) + : staticProbeResponse("/posts/:slug"); + }, + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets: [{ ...target("/posts/a"), route: movedRoute }, ...extraTargets], + }); + + expect((await probe([])).failures).toEqual([]); + const unlistedDestination = { + ...movedRoute, + cacheabilityProbe: { canPrunePattern: true, unlisted: true }, + }; + expect((await probe([{ ...target("/posts/b"), route: unlistedDestination }])).failures).toEqual( + [], + ); + expect( + (await probe([{ ...target("/posts/b"), route: optimizableRoute("/posts/:slug") }])).failures, + ).toEqual(["/posts/a: route returned HTTP 500"]); + }); + + it("judges a moved failure against the destination's listing whichever probe completes first", async () => { + const sourceRoute = { + cacheabilityProbe: { canPrunePattern: true, routeMayResolve: true, unlisted: true }, + kind: "app-page" as const, + pattern: "/rewrite-me/:slug", + }; + // The listed /posts/a is itself moved elsewhere by the request stage, which + // takes its group off the /posts/:slug pattern once its probe completes. + const listedRoute = { + cacheabilityProbe: { canPrunePattern: true, routeMayResolve: true }, + kind: "app-page" as const, + pattern: "/posts/:slug", + }; + const probe = (targets: CdnWarmTarget[]) => + probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 1, + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + return pathname === "/rewrite-me/a" + ? Response.json({ + kind: "app-page", + pattern: "/posts/:slug", + reason: "route returned HTTP 500", + routePathname: "/posts/a", + state: "probe-failed", + status: 500, + version: 1, + }) + : Response.json({ + kind: "app-page", + pattern: "/other/:slug", + rendererStatic: true, + routePathname: "/other/a", + state: "static-candidate", + status: 200, + version: 1, + }); + }, + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets, + }); + const alias = { ...target("/rewrite-me/a"), route: sourceRoute }; + const listed = { ...target("/posts/a"), route: listedRoute }; + const failure = ["/rewrite-me/a: route returned HTTP 500"]; + + expect((await probe([alias, listed])).failures).toEqual(failure); + expect((await probe([listed, alias])).failures).toEqual(failure); + }); + + it("keeps paired representations at the original route when a moved unlisted failure is dropped", async () => { + const { html, route, rsc } = pairedRouteTargets(); + // Header-sensitive routing sends only the HTML request to an unlisted, + // failing App page; the RSC request stays on the static source route. + const fetchImpl = vi.fn(async () => + Response.json({ + kind: "app-page", + pattern: "/posts/:slug", + reason: "route returned HTTP 500", + routePathname: "/posts/unlisted", + state: "probe-failed", + status: 500, + version: 1, + }), + ); + + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl, + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets: [rsc, html], + }); + + expect(fetchImpl).toHaveBeenCalledOnce(); + expect(result).toMatchObject({ + cacheableTargets: [rsc], + failures: [], + speculativeTargets: [rsc], + }); + expect(Object.keys(result.manifest.routes)).toEqual([ + cacheabilityManifestRouteKey(route.kind, route.pattern), + ]); + const sourceManifestRoute = + result.manifest.routes[cacheabilityManifestRouteKey(route.kind, route.pattern)]; + expect(cacheabilityManifestRouteState(sourceManifestRoute!, "/source", "rsc-full")).toBe( + "runtime-check", + ); + }); + + it("fails the deploy for an unlisted Pages or Route Handler path whose render fails", async () => { + for (const kind of ["pages-page", "app-route"] as const) { + const route = { ...optimizableRoute("/posts/:slug"), kind }; + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async () => + Response.json({ + kind, + pattern: route.pattern, + reason: "route returned HTTP 500", + state: "probe-failed", + status: 500, + version: 1, + }), + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets: [ + { + ...target("/posts/picked"), + route: { ...route, cacheabilityProbe: { ...route.cacheabilityProbe, unlisted: true } }, + }, + ], + }); + + expect(result.failures).toEqual(["/posts/picked: route returned HTTP 500"]); + } + }); + it("retries a malformed successful probe envelope", async () => { const root = createProbeRoot(); const fetchImpl = vi @@ -764,7 +1090,7 @@ describe("staged Worker cacheability probes", () => { unknownState: "static-candidate", pattern: route.pattern, state: "runtime-check", - staticPaths: { html: ["/posts/one"] }, + staticPaths: { html: ["/posts/one"], "rsc-full": ["/posts/one"] }, }), ]); }); @@ -1138,7 +1464,7 @@ describe("staged Worker cacheability probes", () => { expect.objectContaining({ pattern: route.pattern, state: "runtime-check", - staticRepresentation: "html", + staticPaths: { html: ["/missing"], "rsc-full": ["/missing"] }, }), ]); }); @@ -1306,11 +1632,446 @@ describe("staged Worker cacheability probes", () => { pattern: route.pattern, runtimePaths: ["/posts/conditionally-dynamic"], state: "runtime-check", - staticPaths: { html: ["/posts/static"] }, + staticPaths: { html: ["/posts/static"], "rsc-full": ["/posts/static"] }, }), ]); }); + describe("App page classification per route", () => { + const listedRoute = optimizableRoute("/posts/:slug"); + const pickedRoute = { + ...listedRoute, + cacheabilityProbe: { ...listedRoute.cacheabilityProbe, unlisted: true }, + }; + const dynamicApi = { + dynamicUsage: true, + rendererStatic: false, + scope: "identity", + state: "dynamic", + }; + const veto = { rendererStatic: false, scope: "identity", state: "dynamic" }; + const configOnly = { + dynamicUsage: true, + explicitConfigCachePolicy: true, + rendererStatic: false, + }; + + const pageTargets = (pathname: string, route: typeof listedRoute) => [ + { ...target(pathname), route }, + { + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-full" as const, + label: `${pathname} (RSC full)`, + pathname: `${pathname}?_rsc`, + route, + sourcePathname: pathname, + }, + ]; + + const probe = ( + targets: readonly CdnWarmTarget[], + fieldsByPathname: Record>, + options: Partial[0]> = {}, + ) => + probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + const route = targets.find( + (candidate) => candidate.pathname.split("?")[0] === pathname, + )!.route!; + return Response.json({ + kind: route.kind, + pattern: route.pattern, + rendererStatic: true, + state: "static-candidate", + status: 200, + version: 1, + ...fieldsByPathname[pathname], + }); + }, + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets, + ...options, + }); + + const warmed = (result: Awaited>) => + result.cacheableTargets.map((warm) => `${warm.kind} ${warm.sourcePathname}`).sort(); + + it("gives unknown paths on-demand ISR when no listed path used a dynamic API", async () => { + const result = await probe( + [ + ...pageTargets("/posts/a", listedRoute), + ...pageTargets("/posts/vetoed", listedRoute), + ...pageTargets("/posts/picked-static", pickedRoute), + ...pageTargets("/posts/picked-dynamic", pickedRoute), + ...pageTargets("/posts/picked-vetoed", pickedRoute), + ], + { + "/posts/picked-dynamic": dynamicApi, + "/posts/picked-vetoed": veto, + "/posts/vetoed": veto, + }, + ); + + expect(result.failures).toEqual([]); + const route = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]!; + const state = (pathname: string) => cacheabilityManifestRouteState(route, pathname, "html"); + expect(state("/posts/a")).toBe("static-candidate"); + expect(state("/posts/picked-static")).toBe("static-candidate"); + // Left out of both lists, so it gets the static-to-dynamic 500, as in Next.js. + expect(state("/posts/picked-dynamic")).toBe("static-candidate"); + expect(route.runtimePaths?.map((token) => `${route.pathPrefix ?? ""}${token}`)).toEqual([ + "/posts/picked-vetoed", + "/posts/vetoed", + ]); + expect(state("/posts/unprobed")).toBe("static-candidate"); + expect(warmed(result)).toEqual([ + "html /posts/a", + "html /posts/picked-static", + "rsc-full /posts/a", + "rsc-full /posts/picked-static", + "rsc-full /posts/picked-vetoed", + "rsc-full /posts/vetoed", + ]); + }); + + it("never admits dynamic listed paths or the unlisted paths of their route", async () => { + const result = await probe( + [ + ...pageTargets("/posts/a", listedRoute), + ...pageTargets("/posts/dynamic", listedRoute), + ...pageTargets("/posts/vetoed", listedRoute), + ...pageTargets("/posts/picked-static", pickedRoute), + ...pageTargets("/posts/picked-vetoed", pickedRoute), + ...pageTargets("/posts/picked-config", pickedRoute), + ], + { + "/posts/dynamic": dynamicApi, + "/posts/picked-config": configOnly, + "/posts/picked-vetoed": veto, + "/posts/vetoed": veto, + }, + ); + + expect(result.failures).toEqual([]); + const route = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]!; + const state = (pathname: string) => cacheabilityManifestRouteState(route, pathname, "html"); + expect(state("/posts/a")).toBe("static-candidate"); + expect(state("/posts/dynamic")).toBeNull(); + expect(cacheabilityManifestRouteState(route, "/posts/dynamic", "rsc-full")).toBeNull(); + expect(state("/posts/vetoed")).toBe("runtime-check"); + expect(state("/posts/picked-static")).toBeNull(); + expect(state("/posts/picked-vetoed")).toBeNull(); + // Cacheable only through a next.config policy: runtime-checked from any source. + expect(state("/posts/picked-config")).toBe("runtime-check"); + expect(state("/posts/unprobed")).toBeNull(); + expect(warmed(result)).toEqual([ + "html /posts/a", + "html /posts/picked-config", + "rsc-full /posts/a", + "rsc-full /posts/picked-config", + "rsc-full /posts/vetoed", + ]); + }); + + it("counts a config-policy listed path toward on-demand ISR by its dynamic usage", async () => { + for (const [config, onDemand] of [ + [configOnly, false], + [{ ...configOnly, dynamicUsage: undefined }, true], + ] as const) { + const result = await probe( + [ + ...pageTargets("/posts/a", listedRoute), + ...pageTargets("/posts/config", listedRoute), + ...pageTargets("/posts/picked", pickedRoute), + ], + { "/posts/config": config }, + ); + + const route = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]!; + const state = (pathname: string) => cacheabilityManifestRouteState(route, pathname, "html"); + expect(state("/posts/config")).toBe("runtime-check"); + expect(state("/posts/picked")).toBe(onDemand ? "static-candidate" : null); + expect(state("/posts/unprobed")).toBe(onDemand ? "static-candidate" : null); + } + }); + + it("gives no entry to routes whose paths all used a dynamic API", async () => { + const aboutRoute = optimizableRoute("/about"); + const fallbackRoute = { + ...optimizableRoute("/fallback/:id"), + cacheabilityProbe: { + canPrunePattern: true, + unlisted: true, + }, + }; + const result = await probe( + [ + ...pageTargets("/posts/dynamic", listedRoute), + ...pageTargets("/about", aboutRoute), + ...pageTargets("/fallback/broken", fallbackRoute), + ], + { + "/about": dynamicApi, + "/fallback/broken": { + reason: "route returned HTTP 500", + rendererStatic: undefined, + state: "probe-failed", + status: 500, + }, + "/posts/dynamic": dynamicApi, + }, + { fallbackRoutePatterns: [{ kind: "app-page", pattern: "/fallback/:id" }] }, + ); + + expect(result.failures).toEqual([]); + expect(result.cacheableTargets).toEqual([]); + // The fallback route keeps its fallback-only entry once its probed path drops out. + expect(result.manifest.routes).toEqual({ + [cacheabilityManifestRouteKey("app-page", "/fallback/:id")]: { + kind: "app-page", + pattern: "/fallback/:id", + state: "static-candidate", + }, + }); + }); + + it("OR-merges dynamic usage across probes of one path", async () => { + const source = { + ...target("/rewrite-me"), + route: { + cacheabilityProbe: { canPrunePattern: true, routeMayResolve: true }, + kind: "app-page" as const, + pattern: "/rewrite-me", + }, + }; + for (const delayedPathname of ["/rewrite-me", "/posts/a"]) { + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + concurrency: 2, + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + if (pathname === delayedPathname) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return Response.json({ + kind: "app-page", + pattern: "/posts/:slug", + routePathname: "/posts/a", + status: 200, + version: 1, + ...(pathname === "/rewrite-me" ? dynamicApi : veto), + }); + }, + retries: 0, + root: createProbeRoot(), + targetUrl: "https://example.com", + targets: [source, { ...target("/posts/a"), route: listedRoute }], + }); + + expect(result.failures).toEqual([]); + expect(result.cacheableTargets).toEqual([]); + expect( + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")], + ).toBeUndefined(); + } + }); + + it("gives no entry to a route whose only generateStaticParams is above its last dynamic segment, even when discovery lists its paths", async () => { + // app/[category]/page.tsx lists `news` for app/[category]/details too. + const detailsRoute = optimizableRoute("/:category/details"); + const unlistedDetailsRoute = { + ...detailsRoute, + cacheabilityProbe: { ...detailsRoute.cacheabilityProbe, unlisted: true }, + }; + const notStaticallyGenerated = { + reason: "route is not statically generated", + rendererStatic: false, + state: "dynamic", + }; + const cases: [typeof unlistedDetailsRoute | typeof detailsRoute, Record][] = + [ + // The runtime reports the whole pattern dynamic. + [detailsRoute, { ...notStaticallyGenerated, dynamicUsage: true, scope: "pattern" }], + // Discovery marks the path unlisted. + [unlistedDetailsRoute, { ...notStaticallyGenerated, scope: "identity" }], + ]; + for (const [route, fields] of cases) { + const result = await probe([...pageTargets("/news/details", route)], { + "/news/details": fields, + }); + + expect(result.failures).toEqual([]); + expect(result.manifest.routes).toEqual({}); + expect(result.cacheableTargets).toEqual([]); + } + }); + + it("gives no entry to a route without generateStaticParams whose only paths are traffic-picked", async () => { + const itemsRoute = optimizableRoute("/items/:id"); + const pickedItemsRoute = { + ...itemsRoute, + cacheabilityProbe: { ...itemsRoute.cacheabilityProbe, unlisted: true }, + }; + const result = await probe( + [ + ...pageTargets("/items/a", pickedItemsRoute), + ...pageTargets("/items/b", pickedItemsRoute), + ], + {}, + ); + + expect(result.failures).toEqual([]); + expect(result.manifest.routes).toEqual({}); + expect(result.cacheableTargets).toEqual([]); + }); + + it("doesn't treat a static-candidate result without rendererStatic as a config-policy path", async () => { + // The listed path used a dynamic API, so the route has no on-demand ISR. + const targets = [ + ...pageTargets("/posts/dynamic", listedRoute), + ...pageTargets("/posts/picked", pickedRoute), + ]; + const state = async (fields: Record) => { + const result = await probe(targets, { + "/posts/dynamic": dynamicApi, + "/posts/picked": fields, + }); + const route = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]; + return route ? cacheabilityManifestRouteState(route, "/posts/picked", "html") : null; + }; + + expect(await state({ rendererStatic: false, state: "static-candidate" })).toBeNull(); + expect(await state(configOnly)).toBe("runtime-check"); + }); + + it("counts a listed path that middleware moves toward the destination's listed set only", async () => { + const movedRoute = { + ...listedRoute, + cacheabilityProbe: { ...listedRoute.cacheabilityProbe, routeMayResolve: true }, + }; + const otherRoute = optimizableRoute("/other/:id"); + const moved = { pattern: "/other/:id", routePathname: "/other/x" }; + for (const destinationListsPath of [false, true]) { + const result = await probe( + [ + ...pageTargets("/posts/moved", movedRoute), + ...pageTargets("/posts/picked", pickedRoute), + ...(destinationListsPath ? pageTargets("/other/x", otherRoute) : []), + ], + { "/posts/moved": moved }, + ); + + expect(result.failures).toEqual([]); + // The moved path never gives its origin route on-demand ISR. + const origin = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]; + expect(origin?.allowUnknown).toBeUndefined(); + for (const pathname of ["/posts/picked", "/posts/unprobed"]) { + expect( + origin ? cacheabilityManifestRouteState(origin, pathname, "html") : null, + ).toBeNull(); + } + const other = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/other/:id")]; + if (destinationListsPath) { + expect(cacheabilityManifestRouteState(other!, "/other/x", "html")).toBe( + "static-candidate", + ); + expect(cacheabilityManifestRouteState(other!, "/other/unprobed", "html")).toBe( + "static-candidate", + ); + } else { + expect(other).toBeUndefined(); + } + } + }); + + it("certifies the full RSC representation of a static HTML render", async () => { + const aboutRoute = optimizableRoute("/about"); + const rscOnly = pageTargets("/posts/rsc-only", listedRoute)[1]!; + const loadingShell = (pathname: string, route: typeof listedRoute) => ({ + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-loading-shell" as const, + label: `${pathname} (RSC loading shell)`, + pathname: `${pathname}?_rsc=loading`, + route, + sourcePathname: pathname, + }); + const postShell = loadingShell("/posts/a", listedRoute); + const aboutShell = loadingShell("/about", aboutRoute); + const result = await probe( + [ + ...pageTargets("/posts/a", listedRoute), + postShell, + rscOnly, + ...pageTargets("/about", aboutRoute), + aboutShell, + ], + {}, + ); + + expect(result.failures).toEqual([]); + const posts = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]!; + for (const representation of ["html", "rsc-full"] as const) { + expect(cacheabilityManifestRouteState(posts, "/posts/a", representation)).toBe( + "static-candidate", + ); + } + // Probed only through RSC, so only its RSC render is certified. + expect(cacheabilityManifestRouteState(posts, "/posts/rsc-only", "rsc-full")).toBe( + "static-candidate", + ); + + const about = result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/about")]!; + expect(about.staticRepresentation).toBeUndefined(); + expect(cacheabilityManifestRouteState(about, "/about", "html")).toBe("static-candidate"); + expect(cacheabilityManifestRouteState(about, "/about", "rsc-full")).toBe("static-candidate"); + + // A static page's render may never reach its loading boundary, so the + // HTML probe proves nothing about the loading shell: its own completed + // render decides admission, and a dynamic API there isn't a 500. + for (const [entry, pathname] of [ + [posts, "/posts/a"], + [about, "/about"], + ] as const) { + expect(cacheabilityManifestRouteState(entry, pathname, "rsc-loading-shell")).toBe( + "runtime-check", + ); + } + expect(posts.staticPaths?.["rsc-loading-shell"]).toBeUndefined(); + expect(about.staticPaths?.["rsc-loading-shell"]).toBeUndefined(); + expect(result.speculativeTargets).toEqual(expect.arrayContaining([postShell, aboutShell])); + expect( + parseCacheabilityManifest(JSON.stringify(result.manifest), "application-build"), + ).toEqual(result.manifest); + }); + + it("keeps Pages Router classification unchanged", async () => { + const pagesRoute = { ...listedRoute, kind: "pages-page" as const }; + const result = await probe( + [ + { ...target("/posts/a"), route: pagesRoute }, + { ...target("/posts/dynamic"), route: pagesRoute }, + ], + { "/posts/dynamic": dynamicApi }, + ); + + const route = + result.manifest.routes[cacheabilityManifestRouteKey("pages-page", "/posts/:slug")]!; + expect(cacheabilityManifestRouteState(route, "/posts/dynamic", "html")).toBe("runtime-check"); + expect(cacheabilityManifestRouteState(route, "/posts/a", "html")).toBe("static-candidate"); + }); + }); + it("records a rewrite source under the concrete route resolved by the request stage", async () => { const root = createProbeRoot(); const source = { @@ -1346,13 +2107,12 @@ describe("staged Worker cacheability probes", () => { expect(result.failures).toEqual([]); expect(result).toMatchObject({ classified: 1, probed: 2 }); expect(result.cacheableTargets).toEqual([source, direct]); + // A route without dynamic segments has no unknown paths to admit. expect(result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/safe")]).toEqual({ - allowUnknown: true, kind: "app-page", pattern: "/safe", state: "runtime-check", - staticPaths: { html: ["/safe"] }, - unknownState: "static-candidate", + staticPaths: { html: ["/safe"], "rsc-full": ["/safe"] }, }); }); @@ -1407,7 +2167,7 @@ describe("staged Worker cacheability probes", () => { kind: "app-page", pattern: "/html-target", state: "runtime-check", - staticRepresentation: "html", + staticPaths: { html: ["/html-target"], "rsc-full": ["/html-target"] }, }); expect(cacheabilityManifestRouteState(sourceManifestRoute, "/source", "rsc-full")).toBe( "runtime-check", @@ -1419,22 +2179,34 @@ describe("staged Worker cacheability probes", () => { it.each([ { + // A literal route lists only its own path, so the resolved pathname is + // unlisted there and, without on-demand ISR, gets no state. change: "route pathname", expectedPattern: "/source", + expectedResolvedState: null, expectedRoutePathname: "/resolved", pattern: "/source", routePathname: "/resolved", }, { + // The destination route lists no paths, so the moved path is unlisted + // there and, without on-demand ISR, gets no state and no warm request. change: "route pattern", expectedPattern: "/destination/:slug", + expectedResolvedState: null, expectedRoutePathname: "/source", pattern: "/destination/:slug", routePathname: "/source", }, ])( "retains deferred representation ownership when only the $change changes", - async ({ expectedPattern, expectedRoutePathname, pattern, routePathname }) => { + async ({ + expectedPattern, + expectedResolvedState, + expectedRoutePathname, + pattern, + routePathname, + }) => { const root = createProbeRoot(); const { html, route, rsc } = pairedRouteTargets(); const result = await probeStagedWorkerCacheability({ @@ -1456,7 +2228,7 @@ describe("staged Worker cacheability probes", () => { }); expect(result).toMatchObject({ - cacheableTargets: [html, rsc], + cacheableTargets: expectedResolvedState ? [html, rsc] : [rsc], failures: [], probed: 1, speculativeTargets: [rsc], @@ -1469,8 +2241,10 @@ describe("staged Worker cacheability probes", () => { const resolvedManifestRoute = result.manifest.routes[cacheabilityManifestRouteKey(route.kind, expectedPattern)]; expect( - cacheabilityManifestRouteState(resolvedManifestRoute, expectedRoutePathname, "html"), - ).toBe("static-candidate"); + resolvedManifestRoute + ? cacheabilityManifestRouteState(resolvedManifestRoute, expectedRoutePathname, "html") + : null, + ).toBe(expectedResolvedState); }, ); @@ -1521,6 +2295,7 @@ describe("staged Worker cacheability probes", () => { buildId: "application-build", fetchImpl: async () => Response.json({ + explicitConfigCachePolicy: true, kind: "app-page", pattern: "/posts/:slug", rendererStatic: false, @@ -1755,7 +2530,7 @@ describe("staged Worker cacheability probes", () => { pattern: route.pattern, runtimePaths: ["/posts/z-ordinary"], state: "runtime-check", - staticPaths: { html: ["/posts/a-special"] }, + staticPaths: { html: ["/posts/a-special"], "rsc-full": ["/posts/a-special"] }, }), ]); }); @@ -1807,7 +2582,7 @@ describe("staged Worker cacheability probes", () => { expect.objectContaining({ pattern: route.pattern, state: "runtime-check", - staticRepresentation: "html", + staticPaths: { html: ["/conditional"], "rsc-full": ["/conditional"] }, }), ]); }); @@ -1908,6 +2683,88 @@ describe("staged Worker cacheability probes", () => { ]); }); + it("keeps the loading shell of a path whose full page used a dynamic API", async () => { + const root = createProbeRoot(); + const representations = (route: ReturnType, pathname: string) => ({ + html: { ...target(pathname), route }, + fullRsc: { + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-full" as const, + label: `${pathname} (RSC full)`, + pathname: `${pathname}?_rsc`, + route, + sourcePathname: pathname, + }, + loadingShell: { + headers: { Accept: "text/x-component", RSC: "1" }, + kind: "rsc-loading-shell" as const, + label: `${pathname} (RSC loading shell)`, + pathname: `${pathname}?_rsc=loading`, + route, + sourcePathname: pathname, + }, + }); + const postsRoute = optimizableRoute("/posts/:slug"); + const dashboardRoute = optimizableRoute("/dashboard"); + const post = representations(postsRoute, "/posts/one"); + const dashboard = representations(dashboardRoute, "/dashboard"); + + const result = await probeStagedWorkerCacheability({ + buildId: "application-build", + fetchImpl: async (input) => { + const pathname = new URL(input instanceof Request ? input.url : String(input)).pathname; + // headers() below loading.tsx makes the full page dynamic. + return Response.json({ + dynamicUsage: true, + kind: "app-page", + pattern: pathname === "/dashboard" ? "/dashboard" : "/posts/:slug", + scope: "identity", + state: "dynamic", + status: 200, + version: 1, + }); + }, + retries: 0, + root, + targetUrl: "https://example.com", + targets: [ + post.loadingShell, + post.fullRsc, + post.html, + dashboard.loadingShell, + dashboard.fullRsc, + dashboard.html, + ], + }); + + expect(result.probed).toBe(2); + expect(result.cacheableTargets).toEqual([dashboard.loadingShell, post.loadingShell]); + expect(result.speculativeTargets).toEqual([dashboard.loadingShell, post.loadingShell]); + const postsEntry = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]; + const dashboardEntry = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/dashboard")]; + expect(postsEntry).toBeDefined(); + expect(dashboardEntry).toBeDefined(); + expect(parseCacheabilityManifest(JSON.stringify(result.manifest), "application-build")).toEqual( + result.manifest, + ); + for (const [entry, pathname] of [ + [postsEntry!, "/posts/one"], + [dashboardEntry!, "/dashboard"], + ] as const) { + expect(cacheabilityManifestRouteState(entry, pathname, "rsc-loading-shell")).toBe( + "runtime-check", + ); + expect(cacheabilityManifestRouteState(entry, pathname, "html")).toBeNull(); + expect(cacheabilityManifestRouteState(entry, pathname, "rsc-full")).toBeNull(); + } + // Only the listed path's loading shell is authorized. + expect( + cacheabilityManifestRouteState(postsEntry!, "/posts/two", "rsc-loading-shell"), + ).toBeNull(); + }); + it("classifies every nodejs.org path while storing one compact exact-path record", async () => { const root = createProbeRoot(); const pathCount = 2_272; @@ -1970,12 +2827,13 @@ describe("staged Worker cacheability probes", () => { state: "runtime-check", staticPaths: { html: Array.from({ length: pathCount - 1 }, (_, index) => `${index}`).sort(), + "rsc-full": Array.from({ length: pathCount - 1 }, (_, index) => `${index}`).sort(), }, }), ]); - // One exact path string per cacheable render is the irreducible safety - // information. It is still far smaller than per-HTML/RSC route records. - expect(Buffer.byteLength(JSON.stringify(result.manifest))).toBeLessThan(20 * 1024); + // One exact path string per certified representation is the irreducible + // safety information. It is still far smaller than per-path route records. + expect(Buffer.byteLength(JSON.stringify(result.manifest))).toBeLessThan(40 * 1024); expect(progress.at(-1)).toBe(pathCount); }); @@ -2059,7 +2917,7 @@ describe("staged Worker cacheability probes", () => { expect.objectContaining({ pattern: "/static", state: "runtime-check", - staticRepresentation: "html", + staticPaths: { html: ["/static"], "rsc-full": ["/static"] }, }), ]); }); @@ -2071,7 +2929,7 @@ describe("staged Worker cacheability probes", () => { kind: "app-page", pattern: firstTarget.pathname, state: "runtime-check", - staticRepresentation: "html", + staticPaths: { html: [firstTarget.pathname], "rsc-full": [firstTarget.pathname] }, }; const key = cacheabilityManifestRouteKey(route.kind, route.pattern); const exactBytes = Buffer.byteLength( diff --git a/tests/cloudflare-cdn-cache.test.ts b/tests/cloudflare-cdn-cache.test.ts index f800bef623..15bdf1df9f 100644 --- a/tests/cloudflare-cdn-cache.test.ts +++ b/tests/cloudflare-cdn-cache.test.ts @@ -24,6 +24,7 @@ import { finalizeAppPageRscCacheResponse, } from "../packages/vinext/src/server/app-page-cache-finalizer.js"; import { finalizeAppRscResponse } from "../packages/vinext/src/server/app-rsc-response-finalizer.js"; +import { queryInvariantObservationBuilders } from "./render-observation-test-helpers.js"; import { applyCdnResponseHeaders, applyCdnResponseIdentityHeaders, @@ -65,6 +66,8 @@ function finalizePendingDynamicRscResponse(): Response { }, }), { + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: null, cleanPathname: "/dashboard", consumeDynamicUsage() { @@ -463,6 +466,8 @@ describe("CloudflareCdnCacheAdapter", () => { }, }), { + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer), cleanPathname: "/dynamic-html", consumeDynamicUsage() { @@ -511,6 +516,8 @@ describe("CloudflareCdnCacheAdapter", () => { }, }), { + ...queryInvariantObservationBuilders, + isStaticEligible: true, bypassInterceptionContextCache: true, capturedRscDataPromise: Promise.resolve(new TextEncoder().encode("flight").buffer), cleanPathname: "/about", @@ -551,6 +558,8 @@ describe("CloudflareCdnCacheAdapter", () => { }, }), { + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: Promise.resolve( new TextEncoder().encode("slot-specific-flight").buffer, ), @@ -593,6 +602,8 @@ describe("CloudflareCdnCacheAdapter", () => { }, }), { + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: Promise.resolve( new TextEncoder().encode("slot-specific-flight").buffer, ), @@ -633,6 +644,8 @@ describe("CloudflareCdnCacheAdapter", () => { }, }), { + ...queryInvariantObservationBuilders, + isStaticEligible: true, capturedRscDataPromise: null, cleanPathname: "/dashboard", consumeDynamicUsage() { diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 98b9aff193..5e3702066d 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -12,8 +12,10 @@ import { VINEXT_EXPECTED_WORKER_VERSION_HEADER } from "../packages/cloudflare/sr import { writeCacheabilityManifestArtifact } from "../packages/cloudflare/src/cacheability-artifact.js"; import { CACHEABILITY_MANIFEST_MODULE, + CACHEABILITY_REQUEST_PROJECTION_MODULE, cacheabilityManifestRouteKey, type CacheabilityManifest, + type CacheabilityManifestRoute, } from "../packages/vinext/src/server/cacheability-manifest.js"; import { VINEXT_CACHEABILITY_PROBE_HEADER, @@ -110,24 +112,40 @@ const OLD_VERSION = "11111111-1111-4111-8111-111111111111"; const PROBE_VERSION = "22222222-2222-4222-8222-222222222222"; const FINAL_VERSION = "33333333-3333-4333-8333-333333333333"; -function writeTwoStageWorkerArtifact(): void { +// An App Router build also emits the request stage's projection module, which +// its request stage imports. +function writeTwoStageWorkerArtifact({ + appRouter = true, + importsProjection = appRouter, +}: { appRouter?: boolean; importsProjection?: boolean } = {}): void { writeFile( "dist/server/wrangler.json", JSON.stringify({ main: "index.js", name: "my-worker", workers_dev: true }), ); - writeFile("dist/server/index.js", 'void import("./response-stage.js");\n'); + writeFile( + "dist/server/index.js", + 'void import("./request-stage.js");\nvoid import("./response-stage.js");\n', + ); + writeFile( + "dist/server/request-stage.js", + importsProjection ? `import "./${CACHEABILITY_REQUEST_PROJECTION_MODULE}";\n` : "", + ); writeFile("dist/server/response-stage.js", `import "./${CACHEABILITY_MANIFEST_MODULE}";\n`); writeFile( "dist/server/.vite/manifest.json", JSON.stringify({ "virtual:cloudflare/worker-entry": { - dynamicImports: ["virtual:vinext-response-stage"], + dynamicImports: ["virtual:vinext-request-stage", "virtual:vinext-response-stage"], file: "index.js", }, + "virtual:vinext-request-stage": { file: "request-stage.js" }, "virtual:vinext-response-stage": { file: "response-stage.js" }, }), ); writeFile(`dist/server/${CACHEABILITY_MANIFEST_MODULE}`, "export default null;\n"); + if (appRouter) { + writeFile(`dist/server/${CACHEABILITY_REQUEST_PROJECTION_MODULE}`, "export default null;\n"); + } writeFile( "dist/server/vinext-server.json", JSON.stringify({ prerenderSecret: "test-prerender-secret" }), @@ -463,6 +481,144 @@ describe("Cloudflare CDN warmup deploy flow", () => { ).toBe('export default "{\\"buildId\\":\\"build-a\\",\\"routes\\":{},\\"version\\":1}";\n'); }); + it("writes the request stage's projection of the App page routes that admit query-free entries", () => { + writeTwoStageWorkerArtifact(); + const routes: CacheabilityManifest["routes"] = {}; + const records: CacheabilityManifestRoute[] = [ + { + kind: "app-page", + pattern: "/blog/:slug", + state: "runtime-check", + allowUnknown: true, + unknownState: "static-candidate", + runtimePaths: ["/blog/vetoed"], + }, + { + kind: "app-page", + pattern: "/about", + state: "runtime-check", + staticPaths: { html: ["/about"], "rsc-full": ["/about"] }, + }, + { kind: "app-page", pattern: "/fallback/:id", state: "static-candidate" }, + { + kind: "app-page", + pattern: "/dynamic/:id", + state: "runtime-check", + runtimePaths: ["/dynamic/a"], + }, + { + kind: "app-page", + pattern: "/pruned/:id", + state: "runtime-check", + runtimeRepresentation: "rsc-loading-shell", + }, + { kind: "app-route", pattern: "/api/static", state: "static-candidate" }, + { + kind: "pages-page", + pattern: "/legacy", + state: "runtime-check", + staticRepresentation: "html", + }, + ]; + for (const route of records) { + routes[cacheabilityManifestRouteKey(route.kind, route.pattern)] = route; + } + + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", { + buildId: "build-a", + routes, + version: 1, + }); + + const source = fs.readFileSync( + path.join(tmpDir, "dist/server", CACHEABILITY_REQUEST_PROJECTION_MODULE), + "utf8", + ); + const projection = JSON.parse( + JSON.parse(source.slice("export default ".length, -";\n".length)), + ) as CacheabilityManifest; + // Route records stay unchanged, so a lookup agrees with the full manifest. + expect(projection).toEqual({ + buildId: "build-a", + routes: { + [cacheabilityManifestRouteKey("app-page", "/blog/:slug")]: + routes[cacheabilityManifestRouteKey("app-page", "/blog/:slug")], + [cacheabilityManifestRouteKey("app-page", "/about")]: + routes[cacheabilityManifestRouteKey("app-page", "/about")], + [cacheabilityManifestRouteKey("app-page", "/fallback/:id")]: + routes[cacheabilityManifestRouteKey("app-page", "/fallback/:id")], + }, + version: 1, + }); + }); + + it("writes no projection for a Pages Router build", () => { + writeTwoStageWorkerArtifact({ appRouter: false }); + const route: CacheabilityManifestRoute = { + kind: "pages-page", + pattern: "/legacy", + state: "static-candidate", + }; + + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", { + buildId: "build-a", + routes: { [cacheabilityManifestRouteKey(route.kind, route.pattern)]: route }, + version: 1, + }); + + expect( + fs.existsSync(path.join(tmpDir, "dist/server", CACHEABILITY_REQUEST_PROJECTION_MODULE)), + ).toBe(false); + }); + + it("rejects an App page manifest for an artifact without the request stage's projection module", () => { + // A stale or pre-built artifact would otherwise deploy RSC listings its + // request stage never strips the query for. + writeTwoStageWorkerArtifact({ appRouter: false }); + const route: CacheabilityManifestRoute = { + kind: "app-page", + pattern: "/about", + state: "static-candidate", + }; + + expect(() => + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", { + buildId: "build-a", + routes: { [cacheabilityManifestRouteKey(route.kind, route.pattern)]: route }, + version: 1, + }), + ).toThrow( + `requires ${CACHEABILITY_REQUEST_PROJECTION_MODULE} in the generated Worker artifact`, + ); + expect( + fs.readFileSync(path.join(tmpDir, "dist/server", CACHEABILITY_MANIFEST_MODULE), "utf8"), + ).toBe("export default null;\n"); + }); + + it("rejects an App page manifest when the Worker graph doesn't import the projection", () => { + // The file exists, but no request-stage module reads it, so the request + // stage would keep full-query dispatches the response stage admits. + writeTwoStageWorkerArtifact({ importsProjection: false }); + const route: CacheabilityManifestRoute = { + kind: "app-page", + pattern: "/about", + state: "static-candidate", + }; + + expect(() => + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", { + buildId: "build-a", + routes: { [cacheabilityManifestRouteKey(route.kind, route.pattern)]: route }, + version: 1, + }), + ).toThrow( + `requires the generated Worker graph to statically import ${CACHEABILITY_REQUEST_PROJECTION_MODULE}`, + ); + expect( + fs.readFileSync(path.join(tmpDir, "dist/server", CACHEABILITY_MANIFEST_MODULE), "utf8"), + ).toBe("export default null;\n"); + }); + it("accepts a manifest over one MiB with more than 10,000 route patterns", () => { writeTwoStageWorkerArtifact(); const routes = Object.fromEntries( @@ -753,7 +909,10 @@ describe("Cloudflare CDN warmup deploy flow", () => { kind: "app-page", pattern: "/:slug", runtimePaths: ["/dynamic"], - staticPaths: { html: ["/about"] }, + staticPaths: { + html: ["/about"], + "rsc-full": ["/about"], + }, state: "runtime-check", }), expect.objectContaining({ diff --git a/tests/cloudflare-response-store-worker.test.ts b/tests/cloudflare-response-store-worker.test.ts index 0213d31709..9084487536 100644 --- a/tests/cloudflare-response-store-worker.test.ts +++ b/tests/cloudflare-response-store-worker.test.ts @@ -1,5 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { createVinextResponseStoreHandler } from "../packages/cloudflare/src/cache/response-store-adapter.worker.js"; +import { + createVinextResponseStoreHandler, + createVinextResponseStoreOptions, +} from "../packages/cloudflare/src/cache/response-store-adapter.worker.js"; +import createResponseStoreDataCacheAdapter, { + captureResponseStoreRscData, + deferResponseStoreAdmission, +} from "../packages/cloudflare/src/cache/response-store-data.runtime.js"; +import { createCanonicalRscRequestHeaders } from "../packages/vinext/src/server/app-rsc-cache-busting.js"; import { VINEXT_RSC_VARY_HEADER } from "../packages/vinext/src/server/headers.js"; const stages = vi.hoisted(() => ({ request: vi.fn(), response: vi.fn() })); @@ -180,3 +188,321 @@ describe("Cloudflare Response Store Worker", () => { expect(store.fetch).not.toHaveBeenCalled(); }); }); + +describe("Cloudflare Response Store Worker query-free cache identity", () => { + type PutOptions = { revalidator?: { id: string; args: unknown[] } }; + type StoredEntry = { body: string; headers: Headers; options?: PutOptions }; + + beforeEach(() => { + stages.request.mockReset(); + stages.response.mockReset(); + }); + + function createMemoryStore() { + const entries = new Map(); + const mutationResult = { backingStoreUpdated: true, edgePurgeAccepted: true }; + const fetch = vi.fn(async (request: Request) => { + const entry = entries.get(request.url); + return entry + ? new Response(entry.body, { headers: entry.headers }) + : new Response(null, { headers: { "X-Workers-Response-Store": "MISS" }, status: 404 }); + }); + const put = vi.fn(async (request: Request, response: Response, options?: PutOptions) => { + entries.set(request.url, { + body: await response.text(), + headers: new Headers(response.headers), + options, + }); + return mutationResult; + }); + return { + entries, + store: { + fetch, + getTagExpiration: vi.fn(async () => 0), + purge: vi.fn(async () => mutationResult), + put, + refresh: vi.fn(async () => mutationResult), + }, + }; + } + + function pageProps(resolvedUrl: string) { + return { + interceptionContext: null, + interceptionId: null, + isRscRequest: false, + kind: "app-page", + matchKind: "request", + mountedSlotsHeader: null, + renderMode: "navigation", + resolvedUrl, + }; + } + + function dispatchWithIdentity(withIdentity = true, overrides: Record = {}) { + stages.request.mockImplementation((request: Request, _env, _context, dispatchResponseStage) => { + const url = new URL(request.url); + const identityUrl = new URL(url); + identityUrl.search = ""; + const props = { ...pageProps(`${url.pathname}${url.search}`), ...overrides }; + return dispatchResponseStage( + request, + props, + withIdentity + ? { + cache: "shared", + cacheIdentity: { + props: { ...props, resolvedUrl: url.pathname }, + request: new Request(identityUrl, { headers: request.headers }), + }, + } + : { cache: "shared" }, + ); + }); + } + + function context() { + return { passThroughOnException: vi.fn(), waitUntil: vi.fn() }; + } + + function revalidatorInvocation(entry: StoredEntry | undefined) { + return JSON.parse(String(entry?.options?.revalidator?.args[0])) as { + props: { resolvedUrl: string; isRscRequest: boolean }; + request: { url: string }; + }; + } + + it("serves two queries from one entry keyed and replayed by the identity", async () => { + dispatchWithIdentity(); + const renderedUrls: string[] = []; + stages.response.mockImplementation(async (request: Request, _env, _context, props) => { + renderedUrls.push(`${request.url} ${(props as { resolvedUrl: string }).resolvedUrl}`); + // Data-cache writes replay the render that produced them. + await createResponseStoreDataCacheAdapter().set("data-key", null, { revalidate: 60 }); + return new Response("rendered", { headers: { "Cache-Control": "public, max-age=60" } }); + }); + const { entries, store } = createMemoryStore(); + const handler = createVinextResponseStoreHandler(store); + + const first = await handler.fetch( + new Request("https://example.com/page?q=a"), + {} as never, + context(), + ); + const second = await handler.fetch( + new Request("https://example.com/page?q=b"), + {} as never, + context(), + ); + + expect(first.headers.get("X-Vinext-Cache")).toBe("MISS"); + expect(await first.text()).toBe("rendered"); + expect(second.headers.get("X-Vinext-Cache")).toBe("HIT"); + expect(await second.text()).toBe("rendered"); + expect(renderedUrls).toEqual(["https://example.com/page?q=a /page?q=a"]); + const [firstKey, secondKey] = store.fetch.mock.calls.map(([request]) => request.url); + expect(secondKey).toBe(firstKey); + expect(new URL(firstKey!).pathname).toBe("/page"); + expect(new URL(firstKey!).searchParams.has("q")).toBe(false); + + const routeEntry = entries.get(firstKey!); + expect(routeEntry?.options?.revalidator?.id).toBe("vinext:response"); + expect(revalidatorInvocation(routeEntry)).toMatchObject({ + props: { resolvedUrl: "/page" }, + request: { url: "https://example.com/page" }, + }); + const dataEntry = [...entries.values()].find( + (entry) => entry.options?.revalidator?.id === "vinext:data", + ); + expect(JSON.parse(String(dataEntry?.options?.revalidator?.args[1]))).toMatchObject({ + props: { resolvedUrl: "/page?q=a" }, + request: { url: "https://example.com/page?q=a" }, + }); + }); + + it("keys the full request when no identity is given", async () => { + dispatchWithIdentity(false); + stages.response.mockImplementation( + async () => new Response("rendered", { headers: { "Cache-Control": "public, max-age=60" } }), + ); + const { entries, store } = createMemoryStore(); + const handler = createVinextResponseStoreHandler(store); + + for (const query of ["a", "b"]) { + const response = await handler.fetch( + new Request(`https://example.com/page?q=${query}`), + {} as never, + context(), + ); + expect(response.headers.get("X-Vinext-Cache")).toBe("MISS"); + await response.text(); + } + + expect(stages.response).toHaveBeenCalledTimes(2); + expect(entries.size).toBe(2); + expect([...entries.values()].map((entry) => revalidatorInvocation(entry).request.url)).toEqual([ + "https://example.com/page?q=a", + "https://example.com/page?q=b", + ]); + }); + + it("stores and regenerates App page RSC without the request's params and path headers", async () => { + dispatchWithIdentity(true, { isRscRequest: true }); + const rscHeaders = { + "Cache-Control": "public, max-age=60", + "X-Vinext-Params": encodeURIComponent(JSON.stringify({ slug: "page" })), + "X-Vinext-Rendered-Path-And-Search": encodeURIComponent("/page?q=a"), + }; + stages.response.mockImplementation(async () => new Response("rsc", { headers: rscHeaders })); + const { entries, store } = createMemoryStore(); + const handler = createVinextResponseStoreHandler(store); + + const miss = await handler.fetch( + new Request("https://example.com/page?q=a"), + {} as never, + context(), + ); + // The request stage recomposes both headers for every App page RSC + // response, HITs included. + expect(miss.headers.get("X-Vinext-Rendered-Path-And-Search")).toBe( + encodeURIComponent("/page?q=a"), + ); + await miss.text(); + const [entry] = [...entries.values()]; + expect(entry?.headers.get("Cache-Control")).toBe("public, max-age=60"); + expect(entry?.headers.has("X-Vinext-Params")).toBe(false); + expect(entry?.headers.has("X-Vinext-Rendered-Path-And-Search")).toBe(false); + + stages.response.mockClear(); + const regenerated = await createVinextResponseStoreOptions().regenerate( + { + args: entry!.options!.revalidator!.args as string[], + id: "vinext:response", + reason: "stale", + request: new Request("https://example.com/page"), + } as never, + { ctx: context(), env: {} } as never, + ); + const [replayedRequest, , , replayedProps] = stages.response.mock.calls[0]!; + expect((replayedRequest as Request).url).toBe("https://example.com/page"); + expect(replayedProps).toMatchObject({ resolvedUrl: "/page" }); + expect(regenerated.headers.has("X-Vinext-Params")).toBe(false); + expect(regenerated.headers.has("X-Vinext-Rendered-Path-And-Search")).toBe(false); + }); + + it.each([ + ["an App route handler", "stored", { isRscRequest: false, kind: "app-route-handler" }], + ["an App route handler", "admitted", { isRscRequest: false, kind: "app-route-handler" }], + ["an App page HTML response", "stored", { isRscRequest: false, kind: "app-page" }], + ])("keeps params and path headers that %s returns when %s", async (_name, path, overrides) => { + // The request stage recomposes these headers only for App page RSC + // responses, so every other entry must replay them as rendered. + dispatchWithIdentity(overrides.kind === "app-page", overrides); + const routeHeaders = { + "Cache-Control": "public, max-age=60", + "X-Vinext-Params": encodeURIComponent(JSON.stringify({ slug: "route" })), + "X-Vinext-Rendered-Path-And-Search": encodeURIComponent("/route"), + }; + stages.response.mockImplementation(async () => { + const response = new Response("route", { headers: routeHeaders }); + return path === "admitted" + ? (deferResponseStoreAdmission(response, async (admitted) => admitted) ?? response) + : response; + }); + const { entries, store } = createMemoryStore(); + const handler = createVinextResponseStoreHandler(store); + + for (const expectedStatus of ["MISS", "HIT"]) { + const ctx = context(); + const response = await handler.fetch( + new Request("https://example.com/route"), + {} as never, + ctx, + ); + expect(response.headers.get("X-Vinext-Cache")).toBe(expectedStatus); + expect(response.headers.get("X-Vinext-Params")).toBe(routeHeaders["X-Vinext-Params"]); + expect(response.headers.get("X-Vinext-Rendered-Path-And-Search")).toBe( + routeHeaders["X-Vinext-Rendered-Path-And-Search"], + ); + await response.text(); + await Promise.all(ctx.waitUntil.mock.calls.map(([promise]) => promise)); + } + expect(stages.response).toHaveBeenCalledTimes(1); + + const [entry] = [...entries.values()]; + const regenerated = await createVinextResponseStoreOptions().regenerate( + { + args: entry!.options!.revalidator!.args as string[], + id: "vinext:response", + reason: "stale", + request: new Request("https://example.com/route"), + } as never, + { ctx: context(), env: {} } as never, + ); + expect(regenerated.headers.get("X-Vinext-Params")).toBe(routeHeaders["X-Vinext-Params"]); + expect(regenerated.headers.get("X-Vinext-Rendered-Path-And-Search")).toBe( + routeHeaders["X-Vinext-Rendered-Path-And-Search"], + ); + }); + + it("seeds the canonical RSC entry under the identity during warmup", async () => { + dispatchWithIdentity(); + stages.response.mockImplementation(async () => { + captureResponseStoreRscData(Promise.resolve(new TextEncoder().encode("rsc").buffer)); + return new Response("html", { headers: { "Cache-Control": "public, max-age=60" } }); + }); + const { entries, store } = createMemoryStore(); + const handler = createVinextResponseStoreHandler(store); + + const warmup = await handler.fetch( + new Request("https://example.com/page?q=a", { + headers: { "user-agent": "vinext-cloudflare-cdn-warm" }, + }), + {} as never, + context(), + ); + await warmup.text(); + + const invocations = [...entries.values()].map(revalidatorInvocation); + expect(invocations).toEqual([ + expect.objectContaining({ + props: expect.objectContaining({ isRscRequest: false, resolvedUrl: "/page" }), + request: expect.objectContaining({ url: "https://example.com/page" }), + }), + expect.objectContaining({ + props: expect.objectContaining({ isRscRequest: true, resolvedUrl: "/page" }), + request: expect.objectContaining({ url: "https://example.com/page?_rsc" }), + }), + ]); + + // A later canonical RSC navigation with any query reaches the seeded entry. + stages.response.mockClear(); + stages.request.mockImplementation((_request, _env, _context, dispatchResponseStage) => { + const props = { ...pageProps("/page"), isRscRequest: true }; + return dispatchResponseStage( + new Request("https://example.com/page?q=b&_rsc", { + headers: createCanonicalRscRequestHeaders(), + }), + { ...props, resolvedUrl: "/page?q=b" }, + { + cache: "shared", + cacheIdentity: { + props, + request: new Request("https://example.com/page?_rsc", { + headers: createCanonicalRscRequestHeaders(), + }), + }, + }, + ); + }); + const rsc = await handler.fetch( + new Request("https://example.com/page?q=b&_rsc"), + {} as never, + context(), + ); + expect(rsc.headers.get("X-Vinext-Cache")).toBe("HIT"); + expect(await rsc.text()).toBe("rsc"); + expect(stages.response).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/e2e/app-router-prod/static-hydration.spec.ts b/tests/e2e/app-router-prod/static-hydration.spec.ts index 965deddcf3..ec1262f937 100644 --- a/tests/e2e/app-router-prod/static-hydration.spec.ts +++ b/tests/e2e/app-router-prod/static-hydration.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; import { waitForAppRouterHydration } from "../helpers"; // Ported from Next.js: test/e2e/app-dir/app-static/app-static.test.ts @@ -21,3 +21,147 @@ test("force-static hydration keeps search params empty", async ({ page }) => { await waitForAppRouterHydration(page); await expect(page.getByTestId("force-static-search-params")).toHaveText("N/A"); }); + +test("a client page reads its searchParams prop from the URL in the browser", async ({ page }) => { + const errors = collectPageErrors(page); + + await page.goto("/client-page-search-params?q=hello"); + await waitForAppRouterHydration(page); + await expect(page.getByTestId("client-page-search-params-q")).toHaveText("hello"); + + await page.getByTestId("client-page-search-params-link").click(); + await expect(page).toHaveURL(/\?q=world$/); + await expect(page.getByTestId("client-page-search-params-q")).toHaveText("world"); + expect(errors).toEqual([]); +}); + +function collectPageErrors(page: Page): string[] { + const errors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("pageerror", (error) => errors.push(error.message)); + return errors; +} + +test("a client page reads the rewritten query in SSR, hydration and navigation", async ({ + page, +}) => { + // /client-page-search-params/rewritten/:q rewrites to ?q=:q. Next.js hands a + // client page the rewritten query from its segment payload. + const errors = collectPageErrors(page); + + await page.goto("/client-page-search-params/rewritten/bar"); + await waitForAppRouterHydration(page); + await expect(page.getByTestId("client-page-search-params-q")).toHaveText("bar"); + + await page.goto("/client-page-search-params?q=hello"); + await waitForAppRouterHydration(page); + await page.getByTestId("client-page-search-params-rewrite-link").click(); + await expect(page).toHaveURL(/\/client-page-search-params\/rewritten\/bar$/); + await expect(page.getByTestId("client-page-search-params-q")).toHaveText("bar"); + expect(errors).toEqual([]); +}); + +test("a client page behind a delayed boundary hydrates with the rewritten query", async ({ + page, +}) => { + // The page reads its searchParams only after the document head is out, so + // the head can't carry the rewritten query /delayed-rewritten/:q gives it. + const errors = collectPageErrors(page); + + await page.goto("/client-page-search-params/delayed-rewritten/bar"); + await waitForAppRouterHydration(page); + const query = page.getByTestId("delayed-client-page-q"); + await expect(query).toHaveAttribute("data-hydrated", "true"); + await expect(query).toHaveText("bar"); + expect(errors).toEqual([]); +}); + +test("a client page hydrates with the rewritten query when it fetches its Flight payload", async ({ + page, +}) => { + // Without the payload embedded in the document, hydration fetches it, and + // the query comes from that response rather than the browser URL. + const errors = collectPageErrors(page); + await page.route("**/client-page-search-params/delayed-rewritten/bar", async (route) => { + if (route.request().resourceType() !== "document") return route.continue(); + const response = await route.fetch(); + const html = (await response.text()).replace( + /