From 02f78c97d19841b65c37381a9d8e57322ae40edd Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 04:22:11 +0100 Subject: [PATCH 01/36] feat(app-router): let a cacheability manifest path appear in several static lists A static App page render certifies its HTML and its RSC versions together, so the Workers Cache manifest can list one path under each representation. A path still appears once per list and never in both runtimePaths and a staticPaths list. --- .../src/server/cacheability-manifest.ts | 15 ++++---- tests/cacheability-manifest.test.ts | 34 +++++++++++++++++++ 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/server/cacheability-manifest.ts b/packages/vinext/src/server/cacheability-manifest.ts index 039775e826..ced9a7f32b 100644 --- a/packages/vinext/src/server/cacheability-manifest.ts +++ b/packages/vinext/src/server/cacheability-manifest.ts @@ -42,7 +42,7 @@ 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 statically certified per representation. A path may appear in several lists. */ staticPaths?: Partial>; }; @@ -186,12 +186,15 @@ function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | nu ...(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 ?? []) { - const pathname = expandPathToken(pathPrefix, token)!; - if (observedPaths.has(pathname)) return null; - observedPaths.add(pathname); + if (runtimePathSet.has(expandPathToken(pathPrefix, token)!)) return null; } } return key === cacheabilityManifestRouteKey(parsed.kind, parsed.pattern) ? parsed : null; diff --git a/tests/cacheability-manifest.test.ts b/tests/cacheability-manifest.test.ts index 7dfa2ef971..e35162947d 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", From d66f713eede83b84fa0d191b64050c5001be3780 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 04:25:41 +0100 Subject: [PATCH 02/36] refactor(app-router): share completed-response admission's page manifest lookup Move the representation mapping and page-route manifest lookup that completed-response admission uses into cacheability-manifest, so the Workers Cache request stage and App page dispatch can reach the same manifest state admission does. --- .../src/server/cacheability-manifest.ts | 49 +++++++++++++++++++ .../vinext/src/server/cacheability-request.ts | 47 ++++++------------ 2 files changed, 65 insertions(+), 31 deletions(-) diff --git a/packages/vinext/src/server/cacheability-manifest.ts b/packages/vinext/src/server/cacheability-manifest.ts index ced9a7f32b..6c06ac0407 100644 --- a/packages/vinext/src/server/cacheability-manifest.ts +++ b/packages/vinext/src/server/cacheability-manifest.ts @@ -388,3 +388,52 @@ 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; +} diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index 90c86ab430..ead244b7f5 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -28,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, @@ -248,21 +251,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, @@ -830,17 +818,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); } @@ -848,11 +829,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); } From 3240ad5f822942d71178c14515327e8ab2d1f244 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 04:25:42 +0100 Subject: [PATCH 03/36] feat(app-router): render Workers Cache probes in cache-candidate mode The deploy probe now renders App pages in cache-candidate mode, without reading the cache, so the manifest it produces matches what the runtime serves. A path the Workers Cache manifest gives no state is never admitted, so it renders normally with real values, as Next.js serves it per request. Without a manifest every path keeps candidate mode. --- .../vinext/src/server/app-page-dispatch.ts | 43 ++++++- tests/app-page-dispatch.test.ts | 110 ++++++++++++++++++ 2 files changed, 148 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 233be66762..3173a7ba68 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -100,7 +100,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"; @@ -652,6 +658,28 @@ function toInterceptOptions( }; } +/** + * 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 { @@ -797,8 +825,7 @@ async function dispatchAppPageInner( }); } - const shouldReadCache = - !isRouteCacheabilityProbe() && + const isCacheEligibleRender = options.bypassInterceptionContextCache !== true && shouldReadAppPageCache({ isDraftMode, @@ -809,10 +836,16 @@ async function dispatchAppPageInner( 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. PPR fallback shells follow - // cacheComponents' model instead. - const isCacheCandidate = shouldReadCache && isStaticEligible && options.pprRuntime === undefined; + // 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, diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index 98d3a9d910..70505c64ff 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -47,6 +47,10 @@ 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, runWithRequestContext, @@ -992,6 +996,112 @@ describe("app page dispatch", () => { } }); + // 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(); + }); + + // 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[] = []; From 58800410b6c4fde83378a6775b8f509c5624dc96 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 04:31:04 +0100 Subject: [PATCH 04/36] feat(cloudflare): emit the Workers Cache request-stage manifest projection at deploy --- .../cloudflare/src/cacheability-artifact.ts | 32 +++++-- packages/vinext/src/entries/app-rsc-entry.ts | 2 + packages/vinext/src/index.ts | 28 +++++- packages/vinext/src/server/app-rsc-handler.ts | 6 ++ .../src/server/cacheability-manifest.ts | 33 +++++++ tests/cache-adapters-build.test.ts | 14 +++ tests/cloudflare-cdn-warm-deploy.test.ts | 88 +++++++++++++++++++ tests/entry-templates.test.ts | 6 ++ 8 files changed, 200 insertions(+), 9 deletions(-) diff --git a/packages/cloudflare/src/cacheability-artifact.ts b/packages/cloudflare/src/cacheability-artifact.ts index 50ab4820df..e88fdb35ca 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 { @@ -278,10 +280,26 @@ function assertManifestModuleReachable(artifact: GeneratedWorkerArtifact): void } } +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, @@ -307,13 +325,13 @@ 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); + writeStringModule(manifestPath, serializedManifest); + const projectionPath = path.join(serverDirectory, CACHEABILITY_REQUEST_PROJECTION_MODULE); + if (fs.existsSync(projectionPath) && fs.lstatSync(projectionPath).isFile()) { + writeStringModule( + projectionPath, + JSON.stringify(projectCacheabilityManifestForRequestStage(manifest)), + ); } return path.relative(root, configPath); } diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 0427b65af4..c3df056e14 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 ?? [])}, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 2cda7a3c81..a3703fc10d 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -55,7 +55,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"; @@ -1203,6 +1206,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"; @@ -4510,6 +4516,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" @@ -4675,7 +4691,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) { @@ -5217,6 +5236,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/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index c99127744d..c52e531bfb 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -479,6 +479,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[]; diff --git a/packages/vinext/src/server/cacheability-manifest.ts b/packages/vinext/src/server/cacheability-manifest.ts index 6c06ac0407..7ee684f61f 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 = @@ -437,3 +439,34 @@ export function cacheabilityManifestPageState( ? 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/tests/cache-adapters-build.test.ts b/tests/cache-adapters-build.test.ts index 84204d3285..4b5eddbee0 100644 --- a/tests/cache-adapters-build.test.ts +++ b/tests/cache-adapters-build.test.ts @@ -538,5 +538,19 @@ 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"); }, 60_000); }); diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index df0ba6b4ec..95021eaf44 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, @@ -578,6 +580,92 @@ 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(); + writeFile(`dist/server/${CACHEABILITY_REQUEST_PROJECTION_MODULE}`, "export default null;\n"); + 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 build without the request stage's projection module", () => { + writeTwoStageWorkerArtifact(); + + writeCacheabilityManifestArtifact(tmpDir, "dist/server/wrangler.json", { + buildId: "build-a", + routes: {}, + version: 1, + }); + + expect( + fs.existsSync(path.join(tmpDir, "dist/server", CACHEABILITY_REQUEST_PROJECTION_MODULE)), + ).toBe(false); + }); + it("accepts a manifest over one MiB with more than 10,000 route patterns", () => { writeTwoStageWorkerArtifact(); const routes = Object.fromEntries( diff --git a/tests/entry-templates.test.ts b/tests/entry-templates.test.ts index 1226332d2b..021c20be6e 100644 --- a/tests/entry-templates.test.ts +++ b/tests/entry-templates.test.ts @@ -1141,6 +1141,12 @@ describe("App Router entry templates", () => { ); expect(code).toContain('from "virtual:vinext-cdn-cache-adapter"'); expect(code).not.toContain('from "virtual:vinext-cache-adapters"'); + // The request stage reads only the Workers Cache manifest's projection. + expect(code).toContain( + 'import __cacheabilityRequestProjection from "virtual:vinext-cacheability-request-projection"', + ); + expect(code).toContain("cacheabilityRequestProjection: __cacheabilityRequestProjection,"); + expect(code).not.toContain("virtual:vinext-cacheability-manifest"); expect(code).toContain('dispatchPagesResponseStage(stageRequest, "api")'); expect(code).toContain( 'dispatchPagesResponseStage(stageRequest, "page", dataKind, __pagesRequestEntry.hasRequestAwareDocument)', From 1440f99465e4fb1522ba01c215a07ea104186783 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 04:41:00 +0100 Subject: [PATCH 05/36] feat(app-router): drop request-scoped RSC headers from shared responses before admission --- .../src/server/app-response-stage-entry.ts | 1 + .../src/server/response-stage-cacheability.ts | 42 +++++++++- tests/app-worker-stages.test.ts | 53 ++++++++++++ tests/response-stage-cacheability.test.ts | 84 ++++++++++++++++++- 4 files changed, 177 insertions(+), 3 deletions(-) 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/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/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/response-stage-cacheability.test.ts b/tests/response-stage-cacheability.test.ts index 9cc1e09e77..d8683ee7a1 100644 --- a/tests/response-stage-cacheability.test.ts +++ b/tests/response-stage-cacheability.test.ts @@ -6,7 +6,11 @@ import { serializeWorkerCacheabilityProbeRoute, type WorkerCacheabilityProbeMode, } from "../packages/vinext/src/server/cacheability-request.js"; -import { VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER } from "../packages/vinext/src/server/headers.js"; +import { + VINEXT_CACHEABILITY_PROBE_ROUTE_HEADER, + VINEXT_PARAMS_HEADER, + VINEXT_RENDERED_PATH_AND_SEARCH_HEADER, +} from "../packages/vinext/src/server/headers.js"; import { cacheabilityManifestRouteKey } from "../packages/vinext/src/server/cacheability-manifest.js"; import { withResponseStageCacheability } from "../packages/vinext/src/server/response-stage-cacheability.js"; import { @@ -233,6 +237,84 @@ describe("response-stage cacheability", () => { await expect(response.text()).resolves.toContain("changed from static to dynamic"); }); + it("drops request-scoped headers before admitting a shared response", async () => { + const route = { kind: "app-page" as const, pattern: "/page", state: "static-candidate" }; + const rawManifest = JSON.stringify({ + buildId: "build-a", + routes: { [cacheabilityManifestRouteKey(route.kind, route.pattern)]: route }, + version: 1, + }); + const rscRequest = () => + new Request("https://example.com/page?_rsc", { + headers: { Accept: "text/x-component", RSC: "1" }, + }); + const admitted: Response[] = []; + const render = (options: { + cache: "bypass" | "shared"; + rawManifest: string | null; + recomposesRequestScopedHeaders: boolean; + }) => + withResponseStageCacheability( + { + ...options, + buildId: "build-a", + context: baseContext(), + registerCacheAdapters() {}, + request: rscRequest(), + }, + async (context) => { + const state = contextState(context); + if (state) { + state.route = { kind: "app-page", pattern: "/page" }; + state.outcome = { + cacheable: true, + cacheControl: "s-maxage=60", + searchParamsUnread: true, + }; + } + return new Response("rsc", { + headers: { + [VINEXT_PARAMS_HEADER]: encodeURIComponent('{"id":"one"}'), + [VINEXT_RENDERED_PATH_AND_SEARCH_HEADER]: encodeURIComponent("/page?q=1"), + }, + }); + }, + ); + setCdnCacheAdapter({ + ...admissionAdapter(), + deferCompletedPageResponseAdmission(response) { + admitted.push(response); + return null; + }, + }); + + // Manifest admission stores the response as returned. + const manifestAdmitted = await render({ + cache: "shared", + rawManifest, + recomposesRequestScopedHeaders: true, + }); + expect(manifestAdmitted.headers.get("Cache-Control")).toBe("s-maxage=60"); + expect(manifestAdmitted.headers.has(VINEXT_PARAMS_HEADER)).toBe(false); + expect(manifestAdmitted.headers.has(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBe(false); + await expect(manifestAdmitted.text()).resolves.toBe("rsc"); + + // Deferred runtime admission sees the response without them too. + await render({ cache: "shared", rawManifest: null, recomposesRequestScopedHeaders: true }); + expect(admitted).toHaveLength(1); + expect(admitted[0]!.headers.has(VINEXT_PARAMS_HEADER)).toBe(false); + expect(admitted[0]!.headers.has(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBe(false); + + for (const kept of [ + await render({ cache: "bypass", rawManifest, recomposesRequestScopedHeaders: true }), + await render({ cache: "shared", rawManifest, recomposesRequestScopedHeaders: false }), + ]) { + expect(kept.headers.get(VINEXT_RENDERED_PATH_AND_SEARCH_HEADER)).toBe( + encodeURIComponent("/page?q=1"), + ); + } + }); + it("runs authenticated probes even when the response transport bypasses caching", async () => { let closeBody!: () => void; let markRenderStarted!: () => void; From d5f0e1a35ed7fd66b6035d4575211f41b575c377 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 04:41:00 +0100 Subject: [PATCH 06/36] feat(app-router): drop the query from Workers Cache dispatches of static-candidate App pages --- packages/vinext/src/server/app-rsc-handler.ts | 70 ++- .../vinext/src/server/app-worker-stages.ts | 52 ++- tests/app-rsc-handler.test.ts | 415 ++++++++++++++++++ 3 files changed, 525 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index c52e531bfb..3c64c19b5c 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -145,13 +145,16 @@ 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, @@ -250,6 +253,31 @@ 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 @@ -1149,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" && @@ -1166,23 +1195,46 @@ 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); } } - const stageProps: AppWorkerResponseStageProps = { + 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 diff --git a/packages/vinext/src/server/app-worker-stages.ts b/packages/vinext/src/server/app-worker-stages.ts index a79e1ab5ce..f63b44c664 100644 --- a/packages/vinext/src/server/app-worker-stages.ts +++ b/packages/vinext/src/server/app-worker-stages.ts @@ -1,5 +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, @@ -129,8 +135,7 @@ export function createSharedAppPageCacheIdentity( request: Request, props: AppMatchedWorkerResponseStageProps, ): NonNullable { - const url = new URL(request.url); - keepOnlyValidatedRscCacheBustingSearchParam(url, props.isRscRequest); + 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 @@ -138,16 +143,57 @@ export function createSharedAppPageCacheIdentity( // 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), }, - request: new Request(url, { headers, method: request.method }), + 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/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index 1eac7c76ed..ab59e7b069 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 ?? [ { @@ -981,6 +991,411 @@ describe("createAppRscHandler", () => { }); }); + 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 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, + ); + 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", "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({ From 34162b94396055be492ba6088bd295bfd9abbab5 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 04:45:33 +0100 Subject: [PATCH 07/36] feat(app-router): report dynamic usage and config policy in cacheability probe envelopes --- .../vinext/src/server/cacheability-request.ts | 21 +++++++-- tests/cacheability-admission.test.ts | 44 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/server/cacheability-request.ts b/packages/vinext/src/server/cacheability-request.ts index ead244b7f5..7503f79d10 100644 --- a/packages/vinext/src/server/cacheability-request.ts +++ b/packages/vinext/src/server/cacheability-request.ts @@ -51,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; @@ -300,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" @@ -1013,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/tests/cacheability-admission.test.ts b/tests/cacheability-admission.test.ts index 79f00a899b..65bb3548be 100644 --- a/tests/cacheability-admission.test.ts +++ b/tests/cacheability-admission.test.ts @@ -1949,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, From 8f5024d9ce48ac8f3aae4492a3d6df174c70c39c Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 04:48:09 +0100 Subject: [PATCH 08/36] feat(cloudflare): mark traffic-picked probe paths and drop their render failures --- packages/cloudflare/src/cacheability-probe.ts | 41 +++++++++++++++++ packages/cloudflare/src/cdn-warm.ts | 4 +- packages/vinext/src/build/prerender-paths.ts | 11 +++++ tests/cloudflare-cacheability-probe.test.ts | 46 +++++++++++++++++++ tests/prerender-paths.test.ts | 30 ++++++++++++ 5 files changed, 131 insertions(+), 1 deletion(-) diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index d8722c746e..9ac97642b7 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -400,6 +400,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,6 +474,23 @@ export async function probeStagedWorkerCacheability(options: { group.targets.push(target); targetGroups.set(concreteKey, group); } + // Paths listed by each route's own static generation. Traffic-picked paths + // are unlisted; a path moved to another route counts as listed there only + // when that route lists its resolved pathname. + const listedPathnamesByRoute = new Map>(); + for (const target of routableTargets) { + const route = target.route!; + if (route.cacheabilityProbe?.trafficPicked === true) continue; + const key = cacheabilityManifestRouteKey(route.kind, route.pattern); + const listed = listedPathnamesByRoute.get(key) ?? new Set(); + listed.add( + route.cacheabilityProbe?.concretePathname ?? + cacheabilityRoutePathname(target.pathname, target.kind), + ); + listedPathnamesByRoute.set(key, listed); + } + const isListedGroup = (group: ConcretePathGroup): boolean => + listedPathnamesByRoute.get(group.pattern.key)?.has(group.routePathname) === true; const groups: ConcretePathGroup[] = Array.from(targetGroups.values(), (targetGroup) => { targetGroup.targets.sort((first, second) => { const preference = targetPreference(first) - targetPreference(second); @@ -665,6 +684,27 @@ export async function probeStagedWorkerCacheability(options: { reportProgress(); return "done"; } + if ( + result.state === "probe-failed" && + result.status! >= 500 && + result.reason === `route returned HTTP ${result.status}` && + !isListedGroup(group) + ) { + // Next.js's build never renders an unlisted path, so its render error + // doesn't fail the deploy. The path is neither classified nor warmed. + 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; @@ -905,6 +945,7 @@ export async function probeStagedWorkerCacheability(options: { const rendererStaticTargets = new Map(); const runtimePathSet = new Set(); for (const group of pattern.groups) { + if (group.dropped) continue; if (group.deferred) { runtimePathSet.add(group.routePathname); cacheableTargets.push(...group.targets); diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 8a98f83d56..11b851ebed 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.trafficPicked === undefined || + typeof route.cacheabilityProbe.trafficPicked === "boolean"))), ))) || (manifest.loadingShellPaths !== undefined && (!Array.isArray(manifest.loadingShellPaths) || diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 5d1e6298ad..cad46beee2 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -60,6 +60,12 @@ export type PrerenderRoutePattern = { routeMayResolve?: boolean; /** A request representation may terminate before reaching the response stage. */ requestStageMayTerminate?: boolean; + /** + * Picked from traffic, not listed by the route's own static generation + * (`generateStaticParams`, `getStaticPaths` or a route without dynamic + * segments). A path both listed and picked counts as listed. + */ + trafficPicked?: boolean; }; }; export type PrerenderPathManifest = { @@ -1120,6 +1126,7 @@ function annotateCacheabilityProbeSafety( config: Pick, routeMayResolve: ReadonlySet, requestStageMayTerminate: ReadonlySet, + trafficPicked: ReadonlySet, isResponsePolicyHeader: (name: string) => boolean, ): Record { const cachePolicyRules = config.headers.filter((rule) => @@ -1171,6 +1178,7 @@ function annotateCacheabilityProbeSafety( canPrunePattern, ...(routeMayResolve.has(pathname) ? { routeMayResolve: true } : {}), ...(requestStageMayTerminate.has(pathname) ? { requestStageMayTerminate: true } : {}), + ...(trafficPicked.has(pathname) ? { trafficPicked: true } : {}), }, }, ]; @@ -1440,6 +1448,7 @@ export async function discoverPrerenderPathManifest( } }); + const trafficPickedPathSet = new Set(); for (const publicPathname of options.candidatePaths ?? []) { let pathname = normalizePathTrailingSlash( new URL(publicPathname, "http://vinext.local").pathname, @@ -1451,6 +1460,7 @@ export async function discoverPrerenderPathManifest( pathname = pathname.slice(config.basePath.length); else continue; } + if (!seen.has(pathname)) trafficPickedPathSet.add(pathname); addPath(paths, seen, pathname); if (pagesDir) addPath(discoveredPagesPaths, seenPagesPaths, pathname); } @@ -1596,6 +1606,7 @@ export async function discoverPrerenderPathManifest( config, routeMayResolveWarmPathSet, requestStageMayTerminateWarmPathSet, + trafficPickedPathSet, (name) => name.trim().toLowerCase() === "cache-control" || options.isResponsePolicyHeader?.(name) === true, diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index da872f804e..78f3299725 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -244,6 +244,52 @@ 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, trafficPicked: 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("retries a malformed successful probe envelope", async () => { const root = createProbeRoot(); const fetchImpl = vi diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 442822958c..7ccd7f5524 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -210,6 +210,36 @@ describe("prerender path manifest", () => { }); }); + it("marks traffic-picked paths that the route's static generation doesn't list", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile("app/page.tsx", "export default function Page() { return null; }\n"); + writeFile( + "app/cached/[slug]/page.tsx", + [ + "export function generateStaticParams() { return [{ slug: 'intro' }, { slug: 'featured' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + + const { discoverPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await discoverPrerenderPathManifest({ + root: tmpDir, + candidatePaths: ["/cached/from-traffic", "/cached/intro", "/"], + responseVary: "verbatim", + }); + + const trafficPicked = (pathname: string) => + manifest?.routePatterns?.[pathname]?.cacheabilityProbe?.trafficPicked; + expect(trafficPicked("/cached/from-traffic")).toBe(true); + expect(trafficPicked("/cached/intro")).toBeUndefined(); + expect(trafficPicked("/cached/featured")).toBeUndefined(); + expect(trafficPicked("/")).toBeUndefined(); + }); + it("keeps traffic paths that an uncached request stage rewrites", async () => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); From bb18bbe3cece0bb33ed01fb0bca5aa76f9bda687 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 04:54:07 +0100 Subject: [PATCH 09/36] feat(cloudflare): classify probed App page routes as Next.js's build does --- packages/cloudflare/src/cacheability-probe.ts | 140 +++++++-- tests/cloudflare-cacheability-probe.test.ts | 286 +++++++++++++++++- 2 files changed, 395 insertions(+), 31 deletions(-) diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index 9ac97642b7..41b559e26c 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -32,6 +32,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; @@ -383,6 +385,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; @@ -402,6 +408,10 @@ export async function probeStagedWorkerCacheability(options: { deferred: boolean; /** An unlisted path whose render failed; it is left to the route rule. */ dropped?: boolean; + /** Discovery listed the path for the route it was grouped under. */ + listedAtOrigin: boolean; + /** The route key the path was grouped under before any request-stage move. */ + originKey: string; pattern: PatternClassification; primary: CdnWarmTarget; result?: ConcretePathResult; @@ -474,29 +484,38 @@ export async function probeStagedWorkerCacheability(options: { group.targets.push(target); targetGroups.set(concreteKey, group); } - // Paths listed by each route's own static generation. Traffic-picked paths - // are unlisted; a path moved to another route counts as listed there only - // when that route lists its resolved pathname. - const listedPathnamesByRoute = new Map>(); - for (const target of routableTargets) { - const route = target.route!; - if (route.cacheabilityProbe?.trafficPicked === true) continue; - const key = cacheabilityManifestRouteKey(route.kind, route.pattern); - const listed = listedPathnamesByRoute.get(key) ?? new Set(); - listed.add( - route.cacheabilityProbe?.concretePathname ?? - cacheabilityRoutePathname(target.pathname, target.kind), + // A path is listed when its route's own static generation lists it; + // traffic-picked paths are unlisted. A path the request stage moves to + // another route counts as listed there only when that route lists its + // resolved pathname. + const isListedGroup = (group: ConcretePathGroup): boolean => { + if ( + !/(^|\/):/.test(group.pattern.route.pattern) && + normalizeCacheabilityRoutePathname(group.pattern.route.pattern) === group.routePathname + ) { + return true; + } + if (group.originKey === group.pattern.key) return group.listedAtOrigin; + return group.pattern.groups.some( + (candidate) => + candidate.originKey === group.pattern.key && + candidate.listedAtOrigin && + !candidate.deferred && + candidate.routePathname === group.routePathname, ); - listedPathnamesByRoute.set(key, listed); - } - const isListedGroup = (group: ConcretePathGroup): boolean => - listedPathnamesByRoute.get(group.pattern.key)?.has(group.routePathname) === true; + }; 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] }; + const group = { + ...targetGroup, + deferred: false, + listedAtOrigin: targetGroup.targets[0].route!.cacheabilityProbe?.trafficPicked !== true, + originKey: targetGroup.pattern.key, + primary: targetGroup.targets[0], + }; targetGroup.pattern.groups.push(group); return group; }); @@ -597,6 +616,8 @@ export async function probeStagedWorkerCacheability(options: { cacheabilityRoutePathname(target.pathname, target.kind); const deferredGroup: ConcretePathGroup = { deferred: true, + listedAtOrigin: group.listedAtOrigin, + originKey: group.pattern.key, pattern: group.pattern, primary: target, resultKey: routePathname, @@ -673,6 +694,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) || @@ -755,6 +779,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, @@ -762,15 +788,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) { @@ -936,16 +968,63 @@ 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(); for (const group of pattern.groups) { if (group.dropped) continue; + if (!group.deferred && hasNoState(group)) continue; if (group.deferred) { runtimePathSet.add(group.routePathname); cacheableTargets.push(...group.targets); @@ -995,14 +1074,16 @@ export async function probeStagedWorkerCacheability(options: { 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)) { + continue; + } else if (literalPatternNamesSolePath) { const result = pattern.results.get(soleGroup.resultKey); route = result?.state === "static-candidate" @@ -1029,12 +1110,21 @@ export async function probeStagedWorkerCacheability(options: { pattern: pattern.route.pattern, state: "runtime-check", }; + } else if ( + isAppPage && + !hasOnDemandIsr && + runtimePathSet.size === 0 && + 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() } : {}), diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index 78f3299725..d577fa97c6 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -5,6 +5,7 @@ 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, @@ -1357,6 +1358,267 @@ describe("staged Worker cacheability probes", () => { ]); }); + describe("App page classification per route", () => { + const listedRoute = optimizableRoute("/posts/:slug"); + const pickedRoute = { + ...listedRoute, + cacheabilityProbe: { ...listedRoute.cacheabilityProbe, trafficPicked: 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 === 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, + trafficPicked: 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("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 = { @@ -1392,13 +1654,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", }); }); @@ -1467,20 +1728,30 @@ describe("staged Worker cacheability probes", () => { { change: "route pathname", expectedPattern: "/source", + expectedResolvedState: "static-candidate", 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({ @@ -1502,7 +1773,7 @@ describe("staged Worker cacheability probes", () => { }); expect(result).toMatchObject({ - cacheableTargets: [html, rsc], + cacheableTargets: expectedResolvedState ? [html, rsc] : [rsc], failures: [], probed: 1, speculativeTargets: [rsc], @@ -1515,8 +1786,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); }, ); @@ -1567,6 +1840,7 @@ describe("staged Worker cacheability probes", () => { buildId: "application-build", fetchImpl: async () => Response.json({ + explicitConfigCachePolicy: true, kind: "app-page", pattern: "/posts/:slug", rendererStatic: false, From 74132549d1ace2971da3fc558bebee2f892d0b61 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 05:01:07 +0100 Subject: [PATCH 10/36] feat(cloudflare): certify the RSC representations of static App pages A static App page's HTML probe runs SSR, so it sees every read its RSC renders can make. List a certified path under html, rsc-full and, when the route has a main-tree loading boundary, rsc-loading-shell. A literal App page now gets the same per-representation lists instead of an html-only staticRepresentation. Discovery records every App page route pattern with a loading boundary, including routes without warm paths, and deploy passes it to the probe. --- packages/cloudflare/src/cacheability-probe.ts | 65 ++++++++++++++----- packages/cloudflare/src/cdn-warm.ts | 9 +++ packages/cloudflare/src/deploy.ts | 1 + packages/vinext/src/build/prerender-paths.ts | 17 +++++ tests/cloudflare-cacheability-probe.test.ts | 65 +++++++++++++++---- tests/cloudflare-cdn-warm-deploy.test.ts | 7 +- tests/cloudflare-cdn-warm.test.ts | 17 +++++ tests/prerender-paths.test.ts | 22 +++++++ 8 files changed, 172 insertions(+), 31 deletions(-) diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index 41b559e26c..9bf4ef788f 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -335,6 +335,8 @@ export async function probeStagedWorkerCacheability(options: { expectedResponseBuildId?: string; fallbackRoutePatterns?: readonly PrerenderRoutePattern[]; fetchImpl?: typeof fetch; + /** App page route patterns with a main-tree loading boundary. */ + loadingBoundaryRoutePatterns?: readonly string[]; headers?: HeadersInit; retries?: number; retryDelayMs?: number; @@ -943,10 +945,13 @@ 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 RSC representations, 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. + const loadingBoundaryRoutePatterns = new Set(options.loadingBoundaryRoutePatterns); for (const pattern of patterns.values()) { if (pattern.pruned) { classified += 1; @@ -1058,14 +1063,30 @@ export async function probeStagedWorkerCacheability(options: { speculativeTargets.push(...pairedTargets); } } + // The HTML probe ran SSR, so it saw every read the RSC renders can make. + // A path probed only through RSC keeps its single listing. + const staticRepresentations = ( + representation: CdnWarmTarget["kind"], + ): CdnWarmTarget["kind"][] => + isAppPage && representation === "html" + ? [ + "html", + "rsc-full", + ...(loadingBoundaryRoutePatterns.has(pattern.route.pattern) + ? (["rsc-loading-shell"] as const) + : []), + ] + : [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 allObservedPathsStatic = @@ -1093,18 +1114,30 @@ 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, diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 11b851ebed..f3d313365f 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -112,6 +112,7 @@ export type PrerenderWarmPlan = { buildIdentity?: string; deploymentId?: string; fallbackRoutePatterns?: PrerenderRoutePattern[]; + loadingBoundaryRoutePatterns?: string[]; loadingShellPaths: string[]; pagesDataPaths?: string[]; pagesPaths?: string[]; @@ -220,6 +221,11 @@ function readPrerenderPathManifest(manifestPath: string): PrerenderPathManifest (manifest.loadingShellPaths !== undefined && (!Array.isArray(manifest.loadingShellPaths) || !manifest.loadingShellPaths.every((pathname) => typeof pathname === "string"))) || + (manifest.loadingBoundaryRoutePatterns !== undefined && + (!Array.isArray(manifest.loadingBoundaryRoutePatterns) || + !manifest.loadingBoundaryRoutePatterns.every( + (pattern) => typeof pattern === "string" && pattern.startsWith("/"), + ))) || (manifest.basePath !== undefined && typeof manifest.basePath !== "string") || (manifest.buildIdentity !== undefined && typeof manifest.buildIdentity !== "string") || (manifest.deploymentId !== undefined && typeof manifest.deploymentId !== "string") || @@ -326,6 +332,9 @@ export function createPrerenderWarmPlan( ...(manifest.fallbackRoutePatterns ? { fallbackRoutePatterns: manifest.fallbackRoutePatterns } : {}), + ...(manifest.loadingBoundaryRoutePatterns + ? { loadingBoundaryRoutePatterns: manifest.loadingBoundaryRoutePatterns } + : {}), loadingShellPaths: supportsCanonicalRsc ? (manifest.loadingShellPaths ?? []).map(applyConfig) : [], diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 5711b561d9..61433fe962 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -1885,6 +1885,7 @@ async function deployWithCacheabilityProbe( concurrency: options.warmCdnConcurrency, expectedResponseBuildId: plan.buildIdentity, fallbackRoutePatterns: plan.fallbackRoutePatterns, + loadingBoundaryRoutePatterns: plan.loadingBoundaryRoutePatterns, phaseTimeoutMs: options.warmCdnProbeTimeout ?? DEFAULT_CACHEABILITY_PROBE_PHASE_TIMEOUT_MS, retries: options.warmCdnProbeRetries ?? diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index cad46beee2..b0f4facae3 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -85,6 +85,8 @@ export type PrerenderPathManifest = { routeHandlerPaths?: string[]; /** App Router paths with an ordinary main-tree loading boundary. */ loadingShellPaths?: string[]; + /** Every App page route pattern with an ordinary main-tree loading boundary. */ + loadingBoundaryRoutePatterns?: string[]; /** Pages Router paths selected by the existing HTML warm discovery pass. */ pagesPaths?: string[]; /** Pages Router JSON data identities corresponding to discovered static paths. */ @@ -948,12 +950,22 @@ async function resolveAppWarmPaths(options: { appPaths: string[]; appRoutePaths: string[]; htmlPaths: string[]; + loadingBoundaryRoutePatterns: string[]; loadingShellPaths: string[]; pagesPaths: string[]; rscPaths: string[]; routePatterns: Record; }> { const appRoutes = await appRouter(options.appDir, options.pageExtensions); + // Read from the route graph for every App page route. A configured + // candidate path can be recorded without a matched route, so per-path + // metadata can't carry it. + const loadingBoundaryRoutePatterns = appRoutes + .filter( + (route) => !(route.routePath && !route.pagePath) && appRouteHasMainTreeLoadingBoundary(route), + ) + .map((route) => route.pattern) + .sort(); const routeHandlerClassifications = new Map( appRoutes.flatMap((route) => route.routePath && !route.pagePath @@ -1036,6 +1048,7 @@ async function resolveAppWarmPaths(options: { appPaths, appRoutePaths, htmlPaths, + loadingBoundaryRoutePatterns, loadingShellPaths, pagesPaths, routePatterns, @@ -1565,6 +1578,7 @@ export async function discoverPrerenderPathManifest( appPaths: [], appRoutePaths: [], htmlPaths: pagesOnlyWarmPaths, + loadingBoundaryRoutePatterns: [], loadingShellPaths: [], pagesPaths: pagesOnlyWarmPaths, routePatterns: pagesWarmMetadata.routePatterns, @@ -1646,6 +1660,9 @@ export async function discoverPrerenderPathManifest( : {}), ...(excludedWarmPathSet.size > 0 ? { excludedWarmPaths: Array.from(excludedWarmPathSet) } : {}), ...(fallbackRoutePatterns.length > 0 ? { fallbackRoutePatterns } : {}), + ...(appOwnedWarmPaths.loadingBoundaryRoutePatterns.length > 0 + ? { loadingBoundaryRoutePatterns: appOwnedWarmPaths.loadingBoundaryRoutePatterns } + : {}), ...(rscBuildId ? { rscBuildId } : {}), ...(options.responseVary ? { responseVary: options.responseVary } : {}), ...(includeCanonicalRsc ? { rscPaths: appOwnedWarmPaths.rscPaths } : {}), diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index d577fa97c6..781ba7fcc4 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -174,7 +174,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]); }); @@ -811,7 +811,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"] }, }), ]); }); @@ -1185,7 +1185,7 @@ describe("staged Worker cacheability probes", () => { expect.objectContaining({ pattern: route.pattern, state: "runtime-check", - staticRepresentation: "html", + staticPaths: { html: ["/missing"], "rsc-full": ["/missing"] }, }), ]); }); @@ -1353,7 +1353,7 @@ 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"] }, }), ]); }); @@ -1398,7 +1398,9 @@ describe("staged Worker cacheability probes", () => { 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 === pathname)!.route!; + const route = targets.find( + (candidate) => candidate.pathname.split("?")[0] === pathname, + )!.route!; return Response.json({ kind: route.kind, pattern: route.pattern, @@ -1602,6 +1604,40 @@ describe("staged Worker cacheability probes", () => { } }); + it("certifies the RSC representations of a static HTML render", async () => { + const aboutRoute = optimizableRoute("/about"); + const rscOnly = pageTargets("/posts/rsc-only", listedRoute)[1]!; + const result = await probe( + [...pageTargets("/posts/a", listedRoute), rscOnly, ...pageTargets("/about", aboutRoute)], + {}, + { loadingBoundaryRoutePatterns: ["/posts/:slug"] }, + ); + + expect(result.failures).toEqual([]); + const posts = + result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]!; + for (const representation of ["html", "rsc-full", "rsc-loading-shell"] 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", + ); + expect(posts.staticPaths?.html).toEqual(["a"]); + expect(posts.staticPaths?.["rsc-loading-shell"]).toEqual(["a"]); + + 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"); + // No loading boundary, so no loading-shell render to certify. + expect(cacheabilityManifestRouteState(about, "/about", "rsc-loading-shell")).not.toBe( + "static-candidate", + ); + }); + it("keeps Pages Router classification unchanged", async () => { const pagesRoute = { ...listedRoute, kind: "pages-page" as const }; const result = await probe( @@ -1659,7 +1695,7 @@ describe("staged Worker cacheability probes", () => { kind: "app-page", pattern: "/safe", state: "runtime-check", - staticPaths: { html: ["/safe"] }, + staticPaths: { html: ["/safe"], "rsc-full": ["/safe"] }, }); }); @@ -1714,7 +1750,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", @@ -2075,7 +2111,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"] }, }), ]); }); @@ -2127,7 +2163,7 @@ describe("staged Worker cacheability probes", () => { expect.objectContaining({ pattern: route.pattern, state: "runtime-check", - staticRepresentation: "html", + staticPaths: { html: ["/conditional"], "rsc-full": ["/conditional"] }, }), ]); }); @@ -2290,12 +2326,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); }); @@ -2379,7 +2416,7 @@ describe("staged Worker cacheability probes", () => { expect.objectContaining({ pattern: "/static", state: "runtime-check", - staticRepresentation: "html", + staticPaths: { html: ["/static"], "rsc-full": ["/static"] }, }), ]); }); @@ -2391,7 +2428,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-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 95021eaf44..25f818e5eb 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -846,6 +846,7 @@ describe("Cloudflare CDN warmup deploy flow", () => { appPaths: ["/about", "/dynamic"], buildId: "app-build-a", buildIdentity: "app-build-a", + loadingBoundaryRoutePatterns: ["/:slug"], loadingShellPaths: [], pagesDataPaths: ["/_next/data/app-build-a/pages-about.json"], pagesPaths: ["/pages-about"], @@ -956,7 +957,11 @@ describe("Cloudflare CDN warmup deploy flow", () => { kind: "app-page", pattern: "/:slug", runtimePaths: ["/dynamic"], - staticPaths: { html: ["/about"] }, + staticPaths: { + html: ["/about"], + "rsc-full": ["/about"], + "rsc-loading-shell": ["/about"], + }, state: "runtime-check", }), expect.objectContaining({ diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index 388bf33a1d..3a00dc9776 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -190,6 +190,7 @@ describe("Cloudflare CDN warmup", () => { { kind: "app-route", pattern: "/api/posts/:slug" }, { kind: "pages-page", pattern: "/legacy/:slug" }, ], + loadingBoundaryRoutePatterns: ["/dashboard", "/posts/:slug"], loadingShellPaths: ["/dashboard"], pagesDataPaths: ["/docs/_next/data/build-a/pages.json"], paths: ["/dashboard", "/dynamic", "/pages"], @@ -214,6 +215,8 @@ describe("Cloudflare CDN warmup", () => { { kind: "app-route", pattern: "/api/posts/:slug" }, { kind: "pages-page", pattern: "/legacy/:slug" }, ], + // Route patterns, so neither the basePath nor trailingSlash applies. + loadingBoundaryRoutePatterns: ["/dashboard", "/posts/:slug"], loadingShellPaths: ["/docs/dashboard/"], pagesDataPaths: ["/docs/_next/data/build-a/pages.json"], paths: ["/docs/dashboard/", "/docs/dynamic/", "/docs/pages/"], @@ -349,6 +352,20 @@ describe("Cloudflare CDN warmup", () => { ); }); + it("rejects malformed loading-boundary route patterns", () => { + writeFile("dist/server/BUILD_ID", "build-a\n"); + for (const loadingBoundaryRoutePatterns of ["/posts/:slug", ["posts/:slug"], [1]]) { + writeFile( + "dist/server/vinext-prerender-paths.json", + JSON.stringify({ buildId: "build-a", loadingBoundaryRoutePatterns, paths: ["/"] }), + ); + + expect(() => readPrerenderWarmPlan(tmpDir, { strict: true })).toThrow( + "prerender path manifest not found", + ); + } + }); + it("warms canonical RSC, HTML, and Pages data with browser-identical requests", async () => { const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 7ccd7f5524..f16bd8efab 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -130,6 +130,7 @@ describe("prerender path manifest", () => { appPaths: ["/", "/dynamic", "/cached/intro", "/cached/featured"], buildId: "build-a", buildIdentity: "rsc-build-a", + loadingBoundaryRoutePatterns: ["/cached/:slug"], loadingShellPaths: ["/cached/intro", "/cached/featured"], rscBuildId: "rsc-build-a", responseVary: "verbatim", @@ -886,6 +887,27 @@ describe("prerender path manifest", () => { expect(manifest?.loadingShellPaths).toEqual(["/safe"]); }); + it("lists every App page route with a loading boundary, including routes with no warm paths", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile("app/about/page.tsx", "export default function Page() {}\n"); + writeFile( + "app/posts/[slug]/page.tsx", + "export const revalidate = 60; export default function Page() {}\n", + ); + writeFile("app/posts/loading.tsx", "export default function Loading() { return null; }\n"); + writeFile("app/posts/feed/route.ts", "export function GET() {}\n"); + + const { emitPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await emitPrerenderPathManifest({ root: tmpDir }); + + expect(manifest?.paths).toEqual(["/about"]); + expect(manifest?.loadingBoundaryRoutePatterns).toEqual(["/posts/:slug"]); + }); + it("warms rewrite source paths when routing runs in an uncached stage", async () => { // Rewrite-aware prefetches can resolve a public URL to a different route: // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/concurrent-navigations/mismatching-prefetch.test.ts From 120bd99d8091e87280a7c1ed3538c5013dfed4ce Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 05:02:05 +0100 Subject: [PATCH 11/36] test(cloudflare): assert that Workers Cache shares one entry across a static page's queries --- .../cloudflare-workers/cache-prewarm.spec.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts b/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts index df65dfb1c9..8e2a8d5686 100644 --- a/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts +++ b/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts @@ -202,3 +202,35 @@ test("useSearchParams() server-renders the real query once the page is dynamic", expect(headers["x-vinext-cache"]).not.toBe("HIT"); expect(headers["cf-cache-status"]).not.toBe("HIT"); }); + +test("Workers Cache serves every query of a static page from one entry", async ({ + baseURL, + request, +}) => { + test.skip(!baseURL?.startsWith("https://"), "requires a deployed Cloudflare Worker"); + test.skip(backend !== "workers-cache", "the query-free dispatch is specific to Workers Cache"); + if (!baseURL) throw new Error("deployed test requires a base URL"); + test.setTimeout(60_000); + + // Next.js serves a static page's one render for any query. Each request + // carries a query no earlier request used, so only an entry shared across + // queries can report a HIT with the previous response's render. + let previousRenderId: string | undefined; + await expect + .poll( + async () => { + const response = await request.get(`${baseURL}/cached/featured?q=${randomUUID()}`, { + headers: { accept: "text/html" }, + }); + const headers = response.headers(); + expect(response.ok(), JSON.stringify(headers)).toBe(true); + const renderId = /data-render-id-tag[^>]*>([^<]+) Date: Fri, 25 Sep 2026 05:23:54 +0100 Subject: [PATCH 12/36] fix(app-router): report a route that isn't statically generated as pattern-dynamic to the deploy probe Discovery can list paths of a route Next.js classifies as dynamic, and the probe then classified its non-cacheable renders as cacheable for another reason, giving the route on-demand ISR. The probe now marks the whole pattern dynamic, as it does for force-dynamic and revalidate = 0, so its envelopes report dynamic usage and the route gets no entry. --- .../vinext/src/server/app-page-dispatch.ts | 24 ++++++++++-- tests/app-page-dispatch.test.ts | 39 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 3173a7ba68..f3f4f25019 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -734,10 +734,26 @@ async function dispatchAppPageInner( const currentRevalidateSeconds = options.revalidateSeconds ?? (hasStaticRevalidateDefault || options.hasAnyGenerateStaticParams ? Infinity : null); - if (isRouteCacheabilityProbe() && (isForceDynamic || currentRevalidateSeconds === 0)) { - markRouteCacheabilityPatternDynamic( - isForceDynamic ? 'dynamic = "force-dynamic"' : "revalidate = 0", - ); + 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. + 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 = isForceDynamic + ? 'dynamic = "force-dynamic"' + : currentRevalidateSeconds === 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"; diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index 70505c64ff..bfcb2f6c24 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -1026,6 +1026,45 @@ describe("app page dispatch", () => { 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 From 1dc0bd45a3d33297f9ebaf592208e7924b4bc4ce Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 05:27:36 +0100 Subject: [PATCH 13/36] fix(cloudflare): list discovered paths only for static or SSG App page routes Discovery can find paths for a route Next.js classifies as dynamic, for example through a sibling page's generateStaticParams above the route's last dynamic segment, or under an edge-runtime layout. Those paths counted as listed, so the probe gave the route on-demand ISR and Workers Cache stripped its query. Discovery now reads each App page route's segment config from source and applies the same eligibility helpers dispatch uses. Paths of a route that isn't static or SSG stay warm paths but are marked unlisted, and the route gets no static fallback. The probe-metadata flag is renamed from trafficPicked to unlisted, since it now also covers these paths. --- packages/cloudflare/src/cacheability-probe.ts | 4 +- packages/cloudflare/src/cdn-warm.ts | 4 +- packages/vinext/src/build/prerender-paths.ts | 112 ++++++++++++++++-- tests/cloudflare-cacheability-probe.test.ts | 36 +++++- tests/prerender-paths.test.ts | 95 +++++++++++++-- 5 files changed, 226 insertions(+), 25 deletions(-) diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index 9bf4ef788f..922cff673f 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -487,7 +487,7 @@ export async function probeStagedWorkerCacheability(options: { targetGroups.set(concreteKey, group); } // A path is listed when its route's own static generation lists it; - // traffic-picked paths are unlisted. A path the request stage moves to + // discovery marks every other path unlisted. A path the request stage moves to // another route counts as listed there only when that route lists its // resolved pathname. const isListedGroup = (group: ConcretePathGroup): boolean => { @@ -514,7 +514,7 @@ export async function probeStagedWorkerCacheability(options: { const group = { ...targetGroup, deferred: false, - listedAtOrigin: targetGroup.targets[0].route!.cacheabilityProbe?.trafficPicked !== true, + listedAtOrigin: targetGroup.targets[0].route!.cacheabilityProbe?.unlisted !== true, originKey: targetGroup.pattern.key, primary: targetGroup.targets[0], }; diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index f3d313365f..0a112b2ab0 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -215,8 +215,8 @@ function readPrerenderPathManifest(manifestPath: string): PrerenderPathManifest typeof route.cacheabilityProbe.routeMayResolve === "boolean") && (route.cacheabilityProbe.requestStageMayTerminate === undefined || typeof route.cacheabilityProbe.requestStageMayTerminate === "boolean") && - (route.cacheabilityProbe.trafficPicked === undefined || - typeof route.cacheabilityProbe.trafficPicked === "boolean"))), + (route.cacheabilityProbe.unlisted === undefined || + typeof route.cacheabilityProbe.unlisted === "boolean"))), ))) || (manifest.loadingShellPaths !== undefined && (!Array.isArray(manifest.loadingShellPaths) || diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index b0f4facae3..96850c57f4 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,8 +24,10 @@ import { classifyAppRoute, classifyAppRouteHandler, classifyPagesRoute, + extractExportConstNumber, extractExportConstString, extractMiddlewareMatcherConfig, + hasNamedExport, } from "./report.js"; import { buildUrlFromParams, resolveParentParams, type StaticParamsMap } from "./prerender.js"; import { readPrerenderSecret } from "./server-manifest.js"; @@ -37,7 +40,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"; @@ -61,11 +72,12 @@ export type PrerenderRoutePattern = { /** A request representation may terminate before reaching the response stage. */ requestStageMayTerminate?: boolean; /** - * Picked from traffic, not listed by the route's own static generation - * (`generateStaticParams`, `getStaticPaths` or a route without dynamic - * segments). A path both listed and picked counts as listed. + * Not listed by the route's own static generation (`generateStaticParams`, + * `getStaticPaths` or a route without dynamic segments): picked from + * traffic, or discovered for an App page route that isn't static or SSG. + * A path both listed and picked counts as listed. */ - trafficPicked?: boolean; + unlisted?: boolean; }; }; export type PrerenderPathManifest = { @@ -732,6 +744,71 @@ 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. + */ +function isAppPageRouteStaticEligible(route: AppRoute): boolean { + const readSegmentConfig = (filePath: string | null | undefined) => { + if (!filePath) return null; + const code = fs.readFileSync(filePath, "utf8"); + const dynamic = extractExportConstString(code, "dynamic"); + const revalidate = extractExportConstNumber(code, "revalidate"); + const runtime = extractExportConstString(code, "runtime"); + return { + ...(dynamic === null ? {} : { dynamic }), + ...(hasNamedExport(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, + })); + const segmentConfig = resolveAppPageSegmentConfig({ + layouts, + layoutTreePositions: route.layoutTreePositions, + page, + parallelBranches, + routeSegments: route.routeSegments, + }); + return 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, + materializedBySlot: route.materializedBySlot, + page, + parallelBranches, + }), + ), + ), + revalidateSeconds: segmentConfig.revalidateSeconds, + }); +} + async function collectAppPaths(options: { appDir: string; baseUrl: string | null; @@ -745,6 +822,8 @@ async function collectAppPaths(options: { nonDynamicPaths: string[]; paths: string[]; routeHandlerPaths: string[]; + /** App page paths discovered only for routes that aren't static or SSG. */ + unlistedPaths: string[]; }> { const routes = await appRouter(options.appDir, options.pageExtensions); const paths: string[] = []; @@ -754,6 +833,8 @@ async function collectAppPaths(options: { const fallbackRoutePatterns: PrerenderRoutePattern[] = []; const nonDynamicPaths: string[] = []; const seenNonDynamicPaths = new Set(); + const listedPaths = new Set(); + const ineligiblePaths = new Set(); const staticParamsCache = new Map[] | null>>(); let requireNonEmptyStaticParams = false; const staticParamsMap = new Proxy({} as StaticParamsMap, { @@ -821,6 +902,12 @@ 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. 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 isStaticEligible = + isRouteHandler || options.cacheComponents || isAppPageRouteStaticEligible(route); const addDiscoveredPath = (pathname: string): void => { if (isRouteHandler) { @@ -828,6 +915,7 @@ async function collectAppPaths(options: { return; } addPath(paths, seen, pathname); + (isStaticEligible ? listedPaths : ineligiblePaths).add(pathname); }; if (!route.isDynamic) { @@ -917,7 +1005,7 @@ async function collectAppPaths(options: { }); const hasStaticFallback = paramSets !== null || dynamicConfig === "force-static" || dynamicConfig === "error"; - if (hasStaticFallback && !hasDynamicSegment) { + if (hasStaticFallback && !hasDynamicSegment && isStaticEligible) { fallbackRoutePatterns.push({ kind: "app-page", pattern: route.pattern }); } continue; @@ -937,6 +1025,7 @@ async function collectAppPaths(options: { nonDynamicPaths, paths, routeHandlerPaths, + unlistedPaths: Array.from(ineligiblePaths).filter((pathname) => !listedPaths.has(pathname)), }; } @@ -1139,7 +1228,7 @@ function annotateCacheabilityProbeSafety( config: Pick, routeMayResolve: ReadonlySet, requestStageMayTerminate: ReadonlySet, - trafficPicked: ReadonlySet, + unlisted: ReadonlySet, isResponsePolicyHeader: (name: string) => boolean, ): Record { const cachePolicyRules = config.headers.filter((rule) => @@ -1191,7 +1280,7 @@ function annotateCacheabilityProbeSafety( canPrunePattern, ...(routeMayResolve.has(pathname) ? { routeMayResolve: true } : {}), ...(requestStageMayTerminate.has(pathname) ? { requestStageMayTerminate: true } : {}), - ...(trafficPicked.has(pathname) ? { trafficPicked: true } : {}), + ...(unlisted.has(pathname) ? { unlisted: true } : {}), }, }, ]; @@ -1353,6 +1442,7 @@ export async function discoverPrerenderPathManifest( const discoveredRouteHandlerPaths: string[] = []; const seenRouteHandlerPaths = new Set(); const discoveredNonDynamicPathSet = new Set(); + const unlistedPathSet = new Set(); const fallbackRoutePatterns: PrerenderRoutePattern[] = []; await withPrerenderEndpoints(async () => { let prodServer: { server: HttpServer; port: number } | null = null; @@ -1430,6 +1520,7 @@ export async function discoverPrerenderPathManifest( discoveredNonDynamicPathSet.add(pathname); } fallbackRoutePatterns.push(...appPathResult.fallbackRoutePatterns); + for (const pathname of appPathResult.unlistedPaths) unlistedPathSet.add(pathname); } if (pagesDir) { @@ -1461,7 +1552,6 @@ export async function discoverPrerenderPathManifest( } }); - const trafficPickedPathSet = new Set(); for (const publicPathname of options.candidatePaths ?? []) { let pathname = normalizePathTrailingSlash( new URL(publicPathname, "http://vinext.local").pathname, @@ -1473,7 +1563,7 @@ export async function discoverPrerenderPathManifest( pathname = pathname.slice(config.basePath.length); else continue; } - if (!seen.has(pathname)) trafficPickedPathSet.add(pathname); + if (!seen.has(pathname)) unlistedPathSet.add(pathname); addPath(paths, seen, pathname); if (pagesDir) addPath(discoveredPagesPaths, seenPagesPaths, pathname); } @@ -1620,7 +1710,7 @@ export async function discoverPrerenderPathManifest( config, routeMayResolveWarmPathSet, requestStageMayTerminateWarmPathSet, - trafficPickedPathSet, + unlistedPathSet, (name) => name.trim().toLowerCase() === "cache-control" || options.isResponsePolicyHeader?.(name) === true, diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index 781ba7fcc4..4b4585ce04 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -250,7 +250,7 @@ describe("staged Worker cacheability probes", () => { const route = optimizableRoute("/posts/:slug"); const pickedRoute = { ...route, - cacheabilityProbe: { ...route.cacheabilityProbe, trafficPicked: true }, + cacheabilityProbe: { ...route.cacheabilityProbe, unlisted: true }, }; const probe = (brokenPathname: string) => probeStagedWorkerCacheability({ @@ -1362,7 +1362,7 @@ describe("staged Worker cacheability probes", () => { const listedRoute = optimizableRoute("/posts/:slug"); const pickedRoute = { ...listedRoute, - cacheabilityProbe: { ...listedRoute.cacheabilityProbe, trafficPicked: true }, + cacheabilityProbe: { ...listedRoute.cacheabilityProbe, unlisted: true }, }; const dynamicApi = { dynamicUsage: true, @@ -1529,7 +1529,7 @@ describe("staged Worker cacheability probes", () => { ...optimizableRoute("/fallback/:id"), cacheabilityProbe: { canPrunePattern: true, - trafficPicked: true, + unlisted: true, }, }; const result = await probe( @@ -1604,6 +1604,36 @@ describe("staged Worker cacheability probes", () => { } }); + 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("certifies the RSC representations of a static HTML render", async () => { const aboutRoute = optimizableRoute("/about"); const rscOnly = pageTargets("/posts/rsc-only", listedRoute)[1]!; diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index f16bd8efab..c205be0a55 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -151,7 +151,8 @@ describe("prerender path manifest", () => { pattern: "/cached/:slug", }, "/dynamic": { - cacheabilityProbe: { canPrunePattern: true }, + // force-dynamic, so Next.js's build never lists it. + cacheabilityProbe: { canPrunePattern: true, unlisted: true }, kind: "app-page", pattern: "/dynamic", }, @@ -233,12 +234,92 @@ describe("prerender path manifest", () => { responseVary: "verbatim", }); - const trafficPicked = (pathname: string) => - manifest?.routePatterns?.[pathname]?.cacheabilityProbe?.trafficPicked; - expect(trafficPicked("/cached/from-traffic")).toBe(true); - expect(trafficPicked("/cached/intro")).toBeUndefined(); - expect(trafficPicked("/cached/featured")).toBeUndefined(); - expect(trafficPicked("/")).toBeUndefined(); + const unlisted = (pathname: string) => + manifest?.routePatterns?.[pathname]?.cacheabilityProbe?.unlisted; + expect(unlisted("/cached/from-traffic")).toBe(true); + expect(unlisted("/cached/intro")).toBeUndefined(); + expect(unlisted("/cached/featured")).toBeUndefined(); + expect(unlisted("/")).toBeUndefined(); + }); + + it("lists paths only for App page routes that are static or SSG", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + // The route's only generateStaticParams is a sibling page's, above its + // last dynamic segment, so Next.js classifies /[category]/details as ƒ. + writeFile( + "app/[category]/page.tsx", + [ + "export function generateStaticParams() { return [{ category: 'news' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile( + "app/[category]/details/page.tsx", + "export const revalidate = 60; export default function Page() { return null; }\n", + ); + // A layout's edge runtime disables static generation for its pages. + writeFile("app/edge/layout.tsx", "export const runtime = 'edge';\n"); + writeFile("app/edge/page.tsx", "export default function Page() { return null; }\n"); + writeFile( + "app/edge/[id]/page.tsx", + [ + "export function generateStaticParams() { return []; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + const defaultFetch = vi.mocked(fetch).getMockImplementation()!; + vi.mocked(fetch).mockImplementation(async (input, init) => + new URL(input instanceof Request ? input.url : String(input)).searchParams.get("pattern") === + "/edge/:id" + ? Response.json([]) + : defaultFetch(input, init), + ); + + const { discoverPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await discoverPrerenderPathManifest({ + root: tmpDir, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual(expect.arrayContaining(["/news", "/news/details", "/edge"])); + const unlisted = (pathname: string) => + manifest?.routePatterns?.[pathname]?.cacheabilityProbe?.unlisted; + expect(unlisted("/news")).toBeUndefined(); + expect(unlisted("/news/details")).toBe(true); + expect(unlisted("/edge")).toBe(true); + expect(manifest?.fallbackRoutePatterns).toBeUndefined(); + }); + + it("lists the paths of a route whose last dynamic segment's layout has generateStaticParams", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/[category]/layout.tsx", + [ + "export function generateStaticParams() { return [{ category: 'news' }]; }", + "export default function Layout({ children }) { return children; }", + ].join("\n"), + ); + writeFile( + "app/[category]/details/page.tsx", + "export default function Page() { return null; }\n", + ); + + const { discoverPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await discoverPrerenderPathManifest({ + root: tmpDir, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toContain("/news/details"); + expect(manifest?.routePatterns?.["/news/details"]?.cacheabilityProbe?.unlisted).toBeUndefined(); }); it("keeps traffic paths that an uncached request stage rewrites", async () => { From 5ef6b746352bde4b731c0ebc8e91871afc31aefb Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 05:28:13 +0100 Subject: [PATCH 14/36] fix(cloudflare): drop only unlisted App page render failures from the probe The unlisted-path 5xx drop applied to every route kind, so a traffic-picked Pages Router or Route Handler path that failed to render no longer failed the deploy. Limit it to App pages, the scope of the build-listing rules. --- packages/cloudflare/src/cacheability-probe.ts | 6 ++-- tests/cloudflare-cacheability-probe.test.ts | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index 922cff673f..a75062eeb8 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -711,13 +711,15 @@ export async function probeStagedWorkerCacheability(options: { return "done"; } if ( + group.pattern.route.kind === "app-page" && result.state === "probe-failed" && result.status! >= 500 && result.reason === `route returned HTTP ${result.status}` && !isListedGroup(group) ) { - // Next.js's build never renders an unlisted path, so its render error - // doesn't fail the deploy. The path is neither classified nor warmed. + // 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. group.dropped = true; if ( !group.pattern.groups.some( diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index 4b4585ce04..7e9005bea1 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -291,6 +291,35 @@ describe("staged Worker cacheability probes", () => { expect(failed.failures).toEqual(["/posts/listed: route returned HTTP 500"]); }); + 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 From 46467af26801a526b92e3b28b03b331f9443eefa Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 05:30:00 +0100 Subject: [PATCH 15/36] fix(cloudflare): require the request-stage projection module for an App page manifest A stale or pre-built artifact without the projection module silently skipped the projection, so it deployed a manifest with RSC listings whose request stage never drops the query. Fail the deploy instead, as a missing manifest module does. --- .../cloudflare/src/cacheability-artifact.ts | 17 +++++++- tests/cloudflare-cdn-warm-deploy.test.ts | 42 ++++++++++++++++--- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/packages/cloudflare/src/cacheability-artifact.ts b/packages/cloudflare/src/cacheability-artifact.ts index e88fdb35ca..bb6f20e66b 100644 --- a/packages/cloudflare/src/cacheability-artifact.ts +++ b/packages/cloudflare/src/cacheability-artifact.ts @@ -325,9 +325,22 @@ export function writeCacheabilityManifestArtifact( throw cacheabilityManifestByteLimitError(manifestBytes); } - writeStringModule(manifestPath, serializedManifest); const projectionPath = path.join(serverDirectory, CACHEABILITY_REQUEST_PROJECTION_MODULE); - if (fs.existsSync(projectionPath) && fs.lstatSync(projectionPath).isFile()) { + 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 ( + !hasProjectionModule && + Object.values(manifest.routes).some((route) => route.kind === "app-page") + ) { + throw new Error( + `Two-stage CDN warming requires ${CACHEABILITY_REQUEST_PROJECTION_MODULE} in the generated Worker artifact. Rebuild the app before deploying.`, + ); + } + + writeStringModule(manifestPath, serializedManifest); + if (hasProjectionModule) { writeStringModule( projectionPath, JSON.stringify(projectCacheabilityManifestForRequestStage(manifest)), diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 25f818e5eb..9c3bee1b68 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -112,7 +112,8 @@ 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. +function writeTwoStageWorkerArtifact({ appRouter = true } = {}): void { writeFile( "dist/server/wrangler.json", JSON.stringify({ main: "index.js", name: "my-worker", workers_dev: true }), @@ -130,6 +131,9 @@ function writeTwoStageWorkerArtifact(): void { }), ); 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" }), @@ -582,7 +586,6 @@ describe("Cloudflare CDN warmup deploy flow", () => { it("writes the request stage's projection of the App page routes that admit query-free entries", () => { writeTwoStageWorkerArtifact(); - writeFile(`dist/server/${CACHEABILITY_REQUEST_PROJECTION_MODULE}`, "export default null;\n"); const routes: CacheabilityManifest["routes"] = {}; const records: CacheabilityManifestRoute[] = [ { @@ -652,12 +655,17 @@ describe("Cloudflare CDN warmup deploy flow", () => { }); }); - it("writes no projection for a build without the request stage's projection module", () => { - writeTwoStageWorkerArtifact(); + 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: {}, + routes: { [cacheabilityManifestRouteKey(route.kind, route.pattern)]: route }, version: 1, }); @@ -666,6 +674,30 @@ describe("Cloudflare CDN warmup deploy flow", () => { ).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("accepts a manifest over one MiB with more than 10,000 route patterns", () => { writeTwoStageWorkerArtifact(); const routes = Object.fromEntries( From 62513d462a58482db0fdfd787717234343a42c23 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 05:34:22 +0100 Subject: [PATCH 16/36] test(app-router): keep the query of nonce and Pages data dispatches under Workers Cache --- tests/app-rsc-handler.test.ts | 61 +++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index ab59e7b069..fc8c59bc42 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -1274,6 +1274,59 @@ describe("createAppRscHandler", () => { ).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"), @@ -1298,6 +1351,13 @@ describe("createAppRscHandler", () => { 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({ @@ -1315,6 +1375,7 @@ describe("createAppRscHandler", () => { ["/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"], ]); From 6baacd62a05533cc288889c9530c9958d1919bb8 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 05:34:22 +0100 Subject: [PATCH 17/36] test(cloudflare): cover traffic-picked-only, moved and non-renderer static-candidate probe paths --- tests/cloudflare-cacheability-probe.test.ts | 81 +++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index 7e9005bea1..e4c6071635 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -1663,6 +1663,87 @@ describe("staged Worker cacheability probes", () => { } }); + 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 RSC representations of a static HTML render", async () => { const aboutRoute = optimizableRoute("/about"); const rscOnly = pageTargets("/posts/rsc-only", listedRoute)[1]!; From f806dbf87586cc75ed18b710b45b23208eebc4be Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 05:34:22 +0100 Subject: [PATCH 18/36] test(cloudflare): assert the shared Workers Cache entry carries no query, for HTML and RSC --- .../cloudflare-workers/cache-prewarm.spec.ts | 67 +++++++++++++------ 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts b/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts index 8e2a8d5686..264f276cec 100644 --- a/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts +++ b/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts @@ -210,27 +210,54 @@ test("Workers Cache serves every query of a static page from one entry", async ( test.skip(!baseURL?.startsWith("https://"), "requires a deployed Cloudflare Worker"); test.skip(backend !== "workers-cache", "the query-free dispatch is specific to Workers Cache"); if (!baseURL) throw new Error("deployed test requires a base URL"); - test.setTimeout(60_000); + test.setTimeout(120_000); // Next.js serves a static page's one render for any query. Each request // carries a query no earlier request used, so only an entry shared across - // queries can report a HIT with the previous response's render. - let previousRenderId: string | undefined; - await expect - .poll( - async () => { - const response = await request.get(`${baseURL}/cached/featured?q=${randomUUID()}`, { - headers: { accept: "text/html" }, - }); - const headers = response.headers(); - expect(response.ok(), JSON.stringify(headers)).toBe(true); - const renderId = /data-render-id-tag[^>]*>([^<]+) string, + headers: Record, + renderOf: (body: string) => string | undefined, + ) => { + let previousRender: string | undefined; + await expect + .poll( + async () => { + const query = randomUUID(); + const response = await request.get(urlFor(query), { headers }); + const responseHeaders = response.headers(); + const trace = JSON.stringify({ label, headers: responseHeaders }); + expect(response.ok(), trace).toBe(true); + const body = await response.text(); + expect(body, trace).toContain("Post: featured"); + expect(body, trace).not.toContain(query); + const render = renderOf(body); + expect(render, trace).toBeTruthy(); + const shared = responseHeaders["cf-cache-status"] === "HIT" && render === previousRender; + previousRender = render; + return shared; + }, + { intervals: [1_000], timeout: 45_000 }, + ) + .toBe(true); + }; + + await expectSharedAcrossQueries( + "HTML", + (query) => `${baseURL}/cached/featured?q=${query}`, + { accept: "text/html" }, + (body) => /data-render-id-tag[^>]*>([^<]+) `${baseURL}/cached/featured?q=${query}&_rsc`, + { accept: "text/x-component", rsc: "1" }, + // A HIT returns the stored payload byte for byte. + (body) => body, + ); }); From cf6096c50b9e4b4e8ef66ce10d851771a13d1581 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 05:43:44 +0100 Subject: [PATCH 19/36] fix(cloudflare): read discovered routes' runtime from their whole loader tree Follows the loader-tree runtime merge below this PR, which needs the route's segments and no longer takes a materializing-slot flag. --- packages/vinext/src/build/prerender-paths.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 96850c57f4..65ef03320d 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -799,9 +799,9 @@ function isAppPageRouteStaticEligible(route: AppRoute): boolean { childrenSlot: route.childrenSlot ?? null, layouts, layoutTreePositions: route.layoutTreePositions, - materializedBySlot: route.materializedBySlot, page, parallelBranches, + routeSegments: route.routeSegments, }), ), ), From c9a3376123c7fea3a41e25123462aa747f6fabf9 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 06:02:22 +0100 Subject: [PATCH 20/36] fix(cloudflare): read an MDX page's segment config from its ESM blocks Discovery parsed raw MDX with the TypeScript parser, which fails once the file has Markdown, so a real generateStaticParams export was missed and the route's discovered paths were marked unlisted. Keep only the MDX ESM blocks, as MDX delimits them, before reading exports. --- packages/vinext/src/build/prerender-paths.ts | 4 ++- packages/vinext/src/utils/mdx-scan.ts | 36 ++++++++++++++++++++ tests/prerender-paths.test.ts | 13 ++++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 65ef03320d..919a4e8cb7 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -32,6 +32,7 @@ import { import { buildUrlFromParams, resolveParentParams, type StaticParamsMap } from "./prerender.js"; import { readPrerenderSecret } from "./server-manifest.js"; import { startProdServer } from "../server/prod-server.js"; +import { extractMdxEsm } 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"; @@ -752,7 +753,8 @@ function extractPagesStaticPathLocale( function isAppPageRouteStaticEligible(route: AppRoute): boolean { const readSegmentConfig = (filePath: string | null | undefined) => { if (!filePath) return null; - const code = fs.readFileSync(filePath, "utf8"); + const source = fs.readFileSync(filePath, "utf8"); + const code = filePath.toLowerCase().endsWith(".mdx") ? extractMdxEsm(source) : source; const dynamic = extractExportConstString(code, "dynamic"); const revalidate = extractExportConstNumber(code, "revalidate"); const runtime = extractExportConstString(code, "runtime"); diff --git a/packages/vinext/src/utils/mdx-scan.ts b/packages/vinext/src/utils/mdx-scan.ts index 03d2cce8d3..0e1838c252 100644 --- a/packages/vinext/src/utils/mdx-scan.ts +++ b/packages/vinext/src/utils/mdx-scan.ts @@ -38,3 +38,39 @@ function scanDirForMdx(dir: string): boolean { } return false; } + +/** + * Keep only the ESM of an MDX module, so the JavaScript export helpers can + * read it. MDX takes a block as ESM when an unindented line starts with + * `import` or `export` outside a code fence, and the block runs until the next + * blank line. + * https://github.com/micromark/micromark-extension-mdxjs-esm + */ +export function extractMdxEsm(source: string): string { + const blocks: string[] = []; + let block: string[] | null = null; + let fence: string | null = null; + for (const line of source.split(/\r?\n/)) { + if (block) { + if (line.trim() === "") { + blocks.push(block.join("\n")); + block = null; + } else { + block.push(line); + } + continue; + } + const fenceMarker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]; + if (fence) { + if (fenceMarker?.[0] === fence[0] && fenceMarker.length >= fence.length) fence = null; + continue; + } + if (fenceMarker) { + fence = fenceMarker; + continue; + } + if (/^(?:import|export)\s/.test(line)) block = [line]; + } + if (block) blocks.push(block.join("\n")); + return blocks.join("\n\n"); +} diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index c205be0a55..c3379eb66d 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -1555,7 +1555,16 @@ describe("prerender path manifest", () => { writeFile("dist/server/index.js", "export default {};\n"); writeFile( "app/[slug]/page.mdx", - 'export function generateStaticParams() { return [{ slug: "hello" }] }\n\n# Hello\n', + [ + "```js", + "export const dynamic = 'force-dynamic'", + "```", + "", + 'export function generateStaticParams() { return [{ slug: "hello" }] }', + "", + "# Hello", + "", + ].join("\n"), ); vi.mocked(fetch).mockResolvedValue(Response.json([{ slug: "hello" }])); @@ -1575,6 +1584,8 @@ describe("prerender path manifest", () => { expect(manifest?.paths).toEqual(["/hello"]); expect(manifest?.rscPaths).toEqual(["/hello"]); + // The page's generateStaticParams lists it. The fenced code isn't ESM. + expect(manifest?.routePatterns?.["/hello"]?.cacheabilityProbe?.unlisted).toBeUndefined(); }); it("discovers dynamic Pages MDX paths from the built runtime", async () => { From a2594a1e7e1c1660d17dd6bd0be88ef1a60bca02 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 06:02:56 +0100 Subject: [PATCH 21/36] fix(cloudflare): ignore type-only generateStaticParams exports in discovery The eligibility read counted `export type { generateStaticParams }` as a generator, while the runtime helpers need a function value, so a route could be classified SSG and its discovered paths listed. Check runtime value exports only, as Route Handler classification does. --- packages/vinext/src/build/prerender-paths.ts | 6 ++-- packages/vinext/src/build/report.ts | 9 ++++++ tests/prerender-paths.test.ts | 32 ++++++++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 919a4e8cb7..cdb03b0fbf 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -27,7 +27,7 @@ import { extractExportConstNumber, extractExportConstString, extractMiddlewareMatcherConfig, - hasNamedExport, + hasRuntimeExportedName, } from "./report.js"; import { buildUrlFromParams, resolveParentParams, type StaticParamsMap } from "./prerender.js"; import { readPrerenderSecret } from "./server-manifest.js"; @@ -760,7 +760,9 @@ function isAppPageRouteStaticEligible(route: AppRoute): boolean { const runtime = extractExportConstString(code, "runtime"); return { ...(dynamic === null ? {} : { dynamic }), - ...(hasNamedExport(code, "generateStaticParams") ? { generateStaticParams() {} } : {}), + ...(hasRuntimeExportedName(code, "generateStaticParams") + ? { generateStaticParams() {} } + : {}), ...(revalidate === null ? {} : { revalidate }), ...(runtime === null ? {} : { runtime }), }; diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 4b308079de..92d42d0a48 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/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index c3379eb66d..ad0d692bf0 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -294,6 +294,38 @@ describe("prerender path manifest", () => { expect(manifest?.fallbackRoutePatterns).toBeUndefined(); }); + it("doesn't count a type-only generateStaticParams export toward static generation", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/[category]/page.tsx", + [ + "export function generateStaticParams() { return [{ category: 'news' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile( + "app/[category]/details/page.tsx", + [ + "type generateStaticParams = () => unknown[];", + "export type { generateStaticParams };", + "export default function Page() { return null; }", + ].join("\n"), + ); + + const { discoverPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await discoverPrerenderPathManifest({ + root: tmpDir, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toContain("/news/details"); + expect(manifest?.routePatterns?.["/news/details"]?.cacheabilityProbe?.unlisted).toBe(true); + }); + it("lists the paths of a route whose last dynamic segment's layout has generateStaticParams", async () => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); From 509c38dd338d04362f412a270fe94fe9105ac52a Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 06:04:39 +0100 Subject: [PATCH 22/36] fix(cloudflare): judge an unlisted render failure under the route the request resolved The unlisted App page 5xx drop read the grouped route before route resolution, so an unlisted App page source rewritten to a Pages page or Route Handler that failed was dropped and the deploy succeeded. Drop only a failure the envelope resolves to an App page, through an allowed move, and judge its listing under that resolved route and pathname. --- packages/cloudflare/src/cacheability-probe.ts | 49 +++++++++++++----- tests/cloudflare-cacheability-probe.test.ts | 50 +++++++++++++++++++ 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index a75062eeb8..8b32866aaa 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -490,22 +490,33 @@ export async function probeStagedWorkerCacheability(options: { // discovery marks every other path unlisted. A path the request stage moves to // another route counts as listed there only when that route lists its // resolved pathname. - const isListedGroup = (group: ConcretePathGroup): boolean => { + const isListedAt = ( + group: ConcretePathGroup, + route: Pick, + routePathname: string, + ): boolean => { if ( - !/(^|\/):/.test(group.pattern.route.pattern) && - normalizeCacheabilityRoutePathname(group.pattern.route.pattern) === group.routePathname + !/(^|\/):/.test(route.pattern) && + normalizeCacheabilityRoutePathname(route.pattern) === routePathname ) { return true; } - if (group.originKey === group.pattern.key) return group.listedAtOrigin; - return group.pattern.groups.some( - (candidate) => - candidate.originKey === group.pattern.key && - candidate.listedAtOrigin && - !candidate.deferred && - candidate.routePathname === group.routePathname, + const key = cacheabilityManifestRouteKey(route.kind, route.pattern); + if (group.originKey === key) return group.listedAtOrigin; + return ( + patterns + .get(key) + ?.groups.some( + (candidate) => + candidate.originKey === key && + candidate.listedAtOrigin && + !candidate.deferred && + candidate.routePathname === routePathname, + ) === true ); }; + const isListedGroup = (group: ConcretePathGroup): boolean => + isListedAt(group, group.pattern.route, group.routePathname); const groups: ConcretePathGroup[] = Array.from(targetGroups.values(), (targetGroup) => { targetGroup.targets.sort((first, second) => { const preference = targetPreference(first) - targetPreference(second); @@ -711,15 +722,27 @@ export async function probeStagedWorkerCacheability(options: { return "done"; } if ( - group.pattern.route.kind === "app-page" && result.state === "probe-failed" && + result.kind === "app-page" && result.status! >= 500 && result.reason === `route returned HTTP ${result.status}` && - !isListedGroup(group) + 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( + group, + { 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. + // nor warmed. Listing is judged under the route the request resolved. group.dropped = true; if ( !group.pattern.groups.some( diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index e4c6071635..dfe3bf0284 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -291,6 +291,56 @@ describe("staged Worker cacheability probes", () => { expect(failed.failures).toEqual(["/posts/listed: route returned HTTP 500"]); }); + 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("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 }; From b0cef7081cbc36ac3bc7ef12fb182fdb0c483adb Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 06:10:05 +0100 Subject: [PATCH 23/36] fix(build): keep the App request stage's chunks beside the server entry The request stage imports the deploy-filled projection through a `./__vinext_cacheability_request_projection.js` external. Its chunks fell through to the assets directory, so that specifier resolved to a module the build never emits. --- packages/vinext/src/build/client-build-config.ts | 4 +++- tests/build-optimization.test.ts | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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/tests/build-optimization.test.ts b/tests/build-optimization.test.ts index e84de3bac2..bee1865959 100644 --- a/tests/build-optimization.test.ts +++ b/tests/build-optimization.test.ts @@ -4157,6 +4157,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", ); From 7fcd06df770c51f034ef8a4c33cf10cbf721c918 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 06:10:05 +0100 Subject: [PATCH 24/36] fix(cloudflare): require the Worker graph to import the request-stage projection A manifest with App page routes is only safe to publish when the request stage reads the projection filled beside it. Check the projection's static reachability from the Wrangler main module, as for the manifest itself. --- .../cloudflare/src/cacheability-artifact.ts | 31 ++++++------- tests/cache-adapters-build.test.ts | 21 +++++++++ tests/cloudflare-cdn-warm-deploy.test.ts | 44 +++++++++++++++++-- 3 files changed, 74 insertions(+), 22 deletions(-) diff --git a/packages/cloudflare/src/cacheability-artifact.ts b/packages/cloudflare/src/cacheability-artifact.ts index bb6f20e66b..4865d23f44 100644 --- a/packages/cloudflare/src/cacheability-artifact.ts +++ b/packages/cloudflare/src/cacheability-artifact.ts @@ -225,7 +225,7 @@ function resolveBuildOutputArtifact(root: string): GeneratedWorkerArtifact { return { configPath, main, serverDirectory: path.join(path.dirname(configPath), "bundle") }; } -function assertManifestModuleReachable(artifact: GeneratedWorkerArtifact): void { +function assertModuleReachable(artifact: GeneratedWorkerArtifact, moduleName: string): void { const { main, serverDirectory } = artifact; const mainPath = path.resolve(serverDirectory, main); if (!fs.existsSync(mainPath) || !fs.lstatSync(mainPath).isFile()) { @@ -255,16 +255,11 @@ function assertManifestModuleReachable(artifact: GeneratedWorkerArtifact): 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]) { @@ -275,7 +270,7 @@ function assertManifestModuleReachable(artifact: GeneratedWorkerArtifact): 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}.`, ); } } @@ -311,7 +306,7 @@ export function writeCacheabilityManifestArtifact( deploymentTool === "cf" ? resolveBuildOutputArtifact(root) : resolveGeneratedWranglerArtifact(root, configuredPath); - assertManifestModuleReachable(artifact); + assertModuleReachable(artifact, CACHEABILITY_MANIFEST_MODULE); const { configPath, serverDirectory } = artifact; const manifestPath = path.join(serverDirectory, CACHEABILITY_MANIFEST_MODULE); if (!fs.existsSync(manifestPath) || !fs.lstatSync(manifestPath).isFile()) { @@ -330,13 +325,13 @@ export function writeCacheabilityManifestArtifact( 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 ( - !hasProjectionModule && - Object.values(manifest.routes).some((route) => route.kind === "app-page") - ) { - throw new Error( - `Two-stage CDN warming requires ${CACHEABILITY_REQUEST_PROJECTION_MODULE} in the generated Worker artifact. Rebuild the app before deploying.`, - ); + 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(artifact, CACHEABILITY_REQUEST_PROJECTION_MODULE); } writeStringModule(manifestPath, serializedManifest); diff --git a/tests/cache-adapters-build.test.ts b/tests/cache-adapters-build.test.ts index 4b5eddbee0..b68e6a8561 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 { workersCacheCdnAdapter } from "../packages/cloudflare/src/cache/workers-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[] = []; @@ -552,5 +554,24 @@ export default { "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/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 9c3bee1b68..6277be549a 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -112,21 +112,33 @@ const OLD_VERSION = "11111111-1111-4111-8111-111111111111"; const PROBE_VERSION = "22222222-2222-4222-8222-222222222222"; const FINAL_VERSION = "33333333-3333-4333-8333-333333333333"; -// An App Router build also emits the request stage's projection module. -function writeTwoStageWorkerArtifact({ appRouter = true } = {}): 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" }, }), ); @@ -698,6 +710,30 @@ describe("Cloudflare CDN warmup deploy flow", () => { ).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( From 42e60758f897fba27a548667f265d306678e4c53 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 06:52:58 +0100 Subject: [PATCH 25/36] fix(cloudflare): keep MDX ESM open across blank lines until it parses MDX only ends an ESM block at a blank line once the accumulated JavaScript parses. Ending it at the first blank line cut a multi-line generateStaticParams down to its signature, so discovery missed it. --- packages/vinext/src/utils/mdx-scan.ts | 16 +++++++++++++--- tests/prerender-paths.test.ts | 6 +++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/utils/mdx-scan.ts b/packages/vinext/src/utils/mdx-scan.ts index 0e1838c252..c88afa5fde 100644 --- a/packages/vinext/src/utils/mdx-scan.ts +++ b/packages/vinext/src/utils/mdx-scan.ts @@ -1,5 +1,6 @@ import fs from "node:fs"; import path from "pathslash"; +import { parseSync } from "vite"; /** Module-level cache for hasMdxFiles — avoids re-scanning per Vite environment. */ export const mdxScanCache = new Map(); @@ -42,8 +43,8 @@ function scanDirForMdx(dir: string): boolean { /** * Keep only the ESM of an MDX module, so the JavaScript export helpers can * read it. MDX takes a block as ESM when an unindented line starts with - * `import` or `export` outside a code fence, and the block runs until the next - * blank line. + * `import` or `export` outside a code fence, and the block runs until the + * first blank line at which its JavaScript parses. * https://github.com/micromark/micromark-extension-mdxjs-esm */ export function extractMdxEsm(source: string): string { @@ -52,7 +53,7 @@ export function extractMdxEsm(source: string): string { let fence: string | null = null; for (const line of source.split(/\r?\n/)) { if (block) { - if (line.trim() === "") { + if (line.trim() === "" && isCompleteEsm(block.join("\n"))) { blocks.push(block.join("\n")); block = null; } else { @@ -74,3 +75,12 @@ export function extractMdxEsm(source: string): string { if (block) blocks.push(block.join("\n")); return blocks.join("\n\n"); } + +function isCompleteEsm(code: string): boolean { + try { + const result = parseSync("vinext-mdx-esm.jsx", code, { lang: "jsx", sourceType: "module" }); + return !result.errors.some((error) => error.severity === "Error"); + } catch { + return false; + } +} diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index ad0d692bf0..62904e9bb1 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -1592,7 +1592,11 @@ describe("prerender path manifest", () => { "export const dynamic = 'force-dynamic'", "```", "", - 'export function generateStaticParams() { return [{ slug: "hello" }] }', + // MDX keeps ESM open across a blank line until the JavaScript parses. + "export function generateStaticParams() {", + "", + ' return [{ slug: "hello" }]', + "}", "", "# Hello", "", From 93c9eddd9fe23d5a9aab9f92cee816af6f32c88c Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 07:35:48 +0100 Subject: [PATCH 26/36] feat(app-router): authorize a runtime-checked representation at exact manifest paths A route record could only runtime-check one representation for every path of its pattern. runtimeRepresentationPaths lists the exact paths at which a single representation is runtime-checked, beside the route's other lists. --- .../src/server/cacheability-manifest.ts | 38 +++++++++++++--- tests/cacheability-manifest.test.ts | 43 +++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/server/cacheability-manifest.ts b/packages/vinext/src/server/cacheability-manifest.ts index 7ee684f61f..ea71f498f5 100644 --- a/packages/vinext/src/server/cacheability-manifest.ts +++ b/packages/vinext/src/server/cacheability-manifest.ts @@ -44,6 +44,8 @@ export type CacheabilityManifestRoute = { staticRepresentation?: CacheabilityRepresentation; /** Exact dynamic paths observed in a mixed or pattern-dynamic route. */ runtimePaths?: string[]; + /** 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>; }; @@ -140,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; @@ -159,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; @@ -185,6 +194,7 @@ function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | nu ...(runtimeRepresentation ? { runtimeRepresentation } : {}), ...(staticRepresentation ? { staticRepresentation } : {}), ...(runtimePaths ? { runtimePaths } : {}), + ...(runtimeRepresentationPaths ? { runtimeRepresentationPaths } : {}), ...(staticPaths ? { staticPaths } : {}), }; @@ -199,6 +209,16 @@ function parseRoute(key: string, value: unknown): CacheabilityManifestRoute | nu 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 ?? []) { + if (runtimePathSet.has(expandPathToken(pathPrefix, token)!) || staticTokens.has(token)) { + return null; + } + } + } return key === cacheabilityManifestRouteKey(parsed.kind, parsed.pattern) ? parsed : null; } @@ -370,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; diff --git a/tests/cacheability-manifest.test.ts b/tests/cacheability-manifest.test.ts index e35162947d..8dc13b7185 100644 --- a/tests/cacheability-manifest.test.ts +++ b/tests/cacheability-manifest.test.ts @@ -267,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( From f3dd187c7d4c3b3f2b16e539790081c3fdd9a225 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 07:35:48 +0100 Subject: [PATCH 27/36] fix(cloudflare): keep the loading shell warm when a dynamic API excludes the full page A dynamic API below loading.tsx leaves the loading shell deterministic. Dropping every target of a dynamic-usage path also dropped that shell, so keep it warmable and runtime-checked at its path. --- packages/cloudflare/src/cacheability-probe.ts | 64 +++++++++++--- tests/cloudflare-cacheability-probe.test.ts | 83 +++++++++++++++++++ 2 files changed, 136 insertions(+), 11 deletions(-) diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index 8b32866aaa..747eaae408 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 { @@ -100,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 @@ -1052,9 +1057,27 @@ export async function probeStagedWorkerCacheability(options: { 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)) 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); @@ -1114,6 +1137,17 @@ export async function probeStagedWorkerCacheability(options: { } } 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"); @@ -1128,7 +1162,13 @@ export async function probeStagedWorkerCacheability(options: { normalizeCacheabilityRoutePathname(pattern.route.pattern) === soleGroup.routePathname; let route: CacheabilityManifestRoute; if (literalPatternNamesSolePath && !soleGroup.deferred && hasNoState(soleGroup)) { - continue; + 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 = @@ -1172,6 +1212,7 @@ export async function probeStagedWorkerCacheability(options: { isAppPage && !hasOnDemandIsr && runtimePathSet.size === 0 && + !runtimeRepresentationPaths && Object.keys(staticPaths).length === 0 ) { // Without path lists or on-demand ISR, a runtime-check entry would admit @@ -1186,6 +1227,7 @@ export async function probeStagedWorkerCacheability(options: { ? { 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/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index dfe3bf0284..1489c5f786 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -9,6 +9,7 @@ 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 { @@ -2454,6 +2455,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; From 71d9dd655673a353deb616100d2ce50ea3b09dc8 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 07:39:22 +0100 Subject: [PATCH 28/36] fix(cloudflare): read an MDX page's ESM from the MDX parser Line prefixes took paragraph text such as "export function generateStaticParams() {}" as ESM, which MDX doesn't. Read the mdxjsEsm nodes from the @mdx-js/mdx parser that @mdx-js/rollup compiles with, and never list an MDX route's paths when that parser isn't installed. --- packages/vinext/src/build/prerender-paths.ts | 30 ++++++-- packages/vinext/src/utils/mdx-scan.ts | 65 +++++++---------- tests/prerender-paths.test.ts | 74 ++++++++++++++++++++ 3 files changed, 125 insertions(+), 44 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index cdb03b0fbf..9cd518ed14 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -32,7 +32,7 @@ import { import { buildUrlFromParams, resolveParentParams, type StaticParamsMap } from "./prerender.js"; import { readPrerenderSecret } from "./server-manifest.js"; import { startProdServer } from "../server/prod-server.js"; -import { extractMdxEsm } from "../utils/mdx-scan.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"; @@ -750,11 +750,26 @@ function extractPagesStaticPathLocale( * layout, page and parallel-slot sources with the helpers dispatch applies to * the loaded modules. Only such a route has listed paths. */ -function isAppPageRouteStaticEligible(route: AppRoute): boolean { +function isAppPageRouteStaticEligible( + route: AppRoute, + readMdxEsm: ((source: string) => string) | null, +): boolean { + // An MDX source read without the MDX parser has unknown exports, so its + // route is never taken as static. + let unreadable = false; const readSegmentConfig = (filePath: string | null | undefined) => { if (!filePath) return null; - const source = fs.readFileSync(filePath, "utf8"); - const code = filePath.toLowerCase().endsWith(".mdx") ? extractMdxEsm(source) : source; + 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"); @@ -779,6 +794,7 @@ function isAppPageRouteStaticEligible(route: AppRoute): boolean { page: readSegmentConfig(slot.pagePath ?? slot.defaultPath), routeSegments: slot.routeSegments, })); + if (unreadable) return false; const segmentConfig = resolveAppPageSegmentConfig({ layouts, layoutTreePositions: route.layoutTreePositions, @@ -895,6 +911,10 @@ async function collectAppPaths(options: { }, }); + const readMdxEsm = + !options.cacheComponents && options.pageExtensions.includes("mdx") + ? await loadMdxEsmReader() + : null; for (const route of routes) { const isRouteHandler = route.routePath !== null && route.pagePath === null; const renderEntryPath = isRouteHandler ? route.routePath : getAppRouteRenderEntryPath(route); @@ -911,7 +931,7 @@ async function collectAppPaths(options: { // generateStaticParams, stay warm paths but aren't listed. A // cacheComponents build keeps every page route eligible, as dispatch does. const isStaticEligible = - isRouteHandler || options.cacheComponents || isAppPageRouteStaticEligible(route); + isRouteHandler || options.cacheComponents || isAppPageRouteStaticEligible(route, readMdxEsm); const addDiscoveredPath = (pathname: string): void => { if (isRouteHandler) { diff --git a/packages/vinext/src/utils/mdx-scan.ts b/packages/vinext/src/utils/mdx-scan.ts index c88afa5fde..39875d3f2e 100644 --- a/packages/vinext/src/utils/mdx-scan.ts +++ b/packages/vinext/src/utils/mdx-scan.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; import path from "pathslash"; -import { parseSync } from "vite"; /** Module-level cache for hasMdxFiles — avoids re-scanning per Vite environment. */ export const mdxScanCache = new Map(); @@ -40,47 +41,33 @@ function scanDirForMdx(dir: string): boolean { return false; } +type MdxProcessorModule = { + createProcessor(): { + parse(source: string): { children: { type: string; value?: unknown }[] }; + }; +}; + /** - * Keep only the ESM of an MDX module, so the JavaScript export helpers can - * read it. MDX takes a block as ESM when an unindented line starts with - * `import` or `export` outside a code fence, and the block runs until the - * first blank line at which its JavaScript parses. - * https://github.com/micromark/micromark-extension-mdxjs-esm + * 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 from the same place vinext loads + * that plugin. Returns null when that parser isn't installed. */ -export function extractMdxEsm(source: string): string { - const blocks: string[] = []; - let block: string[] | null = null; - let fence: string | null = null; - for (const line of source.split(/\r?\n/)) { - if (block) { - if (line.trim() === "" && isCompleteEsm(block.join("\n"))) { - blocks.push(block.join("\n")); - block = null; - } else { - block.push(line); - } - continue; - } - const fenceMarker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]; - if (fence) { - if (fenceMarker?.[0] === fence[0] && fenceMarker.length >= fence.length) fence = null; - continue; - } - if (fenceMarker) { - fence = fenceMarker; - continue; - } - if (/^(?:import|export)\s/.test(line)) block = [line]; - } - if (block) blocks.push(block.join("\n")); - return blocks.join("\n\n"); -} - -function isCompleteEsm(code: string): boolean { +export async function loadMdxEsmReader(): Promise<((source: string) => string) | null> { + let mdx: MdxProcessorModule; try { - const result = parseSync("vinext-mdx-esm.jsx", code, { lang: "jsx", sourceType: "module" }); - return !result.errors.some((error) => error.severity === "Error"); + const rollupEntry = createRequire(import.meta.url).resolve("@mdx-js/rollup"); + const mdxEntry = createRequire(rollupEntry).resolve("@mdx-js/mdx"); + mdx = (await import(pathToFileURL(mdxEntry).href)) as MdxProcessorModule; } catch { - return false; + 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/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 62904e9bb1..ff6e389596 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -1624,6 +1624,80 @@ describe("prerender path manifest", () => { expect(manifest?.routePatterns?.["/hello"]?.cacheabilityProbe?.unlisted).toBeUndefined(); }); + it("doesn't take MDX paragraph text that looks like an export as ESM", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/[slug]/page.mdx", + [ + "An export can't interrupt a paragraph, so this is text:", + 'export function generateStaticParams() { return [{ slug: "hello" }] }', + "", + "# Hello", + "", + ].join("\n"), + ); + vi.mocked(fetch).mockResolvedValue(Response.json([{ slug: "hello" }])); + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const nextConfig = await resolveNextConfig( + { pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"] }, + tmpDir, + ); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + nextConfig, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual(["/hello"]); + expect(manifest?.routePatterns?.["/hello"]?.cacheabilityProbe?.unlisted).toBe(true); + }); + + it("doesn't list an MDX route's paths without the MDX parser", async () => { + vi.resetModules(); + vi.doMock("../packages/vinext/src/utils/mdx-scan.js", async (importOriginal) => ({ + ...(await importOriginal()), + loadMdxEsmReader: async () => null, + })); + try { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "app/[slug]/page.mdx", + 'export function generateStaticParams() { return [{ slug: "hello" }] }\n\n# Hello\n', + ); + vi.mocked(fetch).mockResolvedValue(Response.json([{ slug: "hello" }])); + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const nextConfig = await resolveNextConfig( + { pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"] }, + tmpDir, + ); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + nextConfig, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual(["/hello"]); + expect(manifest?.routePatterns?.["/hello"]?.cacheabilityProbe?.unlisted).toBe(true); + } finally { + vi.doUnmock("../packages/vinext/src/utils/mdx-scan.js"); + vi.resetModules(); + } + }); + it("discovers dynamic Pages MDX paths from the built runtime", async () => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); From 8c2806a4fcc10c5d65028e113db50f9c1f8e0640 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 10:46:02 +0100 Subject: [PATCH 29/36] fix(cloudflare): find the MDX parser beside an app's own MDX plugin Discovery resolved @mdx-js/mdx only through vinext's optional @mdx-js/rollup peer, so an app building MDX with its own plugin had every MDX route unlisted. Also resolve it through the app's @mdx-js/rollup and from the app root. An MDX route still unreadable is taken as static: the built runtime reads its real exports and never stores a route that isn't static, while an unlisted path's render failure was dropped instead of failing the deploy. --- packages/vinext/src/build/prerender-paths.ts | 12 +- packages/vinext/src/utils/mdx-scan.ts | 31 +++-- tests/prerender-paths.test.ts | 115 ++++++++++++++++++- 3 files changed, 140 insertions(+), 18 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 9cd518ed14..643e3a304d 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -754,8 +754,10 @@ function isAppPageRouteStaticEligible( route: AppRoute, readMdxEsm: ((source: string) => string) | null, ): boolean { - // An MDX source read without the MDX parser has unknown exports, so its - // route is never taken as static. + // An MDX source read without the MDX parser has unknown exports. Its route + // is taken as static: listing only permits, since 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. let unreadable = false; const readSegmentConfig = (filePath: string | null | undefined) => { if (!filePath) return null; @@ -794,7 +796,7 @@ function isAppPageRouteStaticEligible( page: readSegmentConfig(slot.pagePath ?? slot.defaultPath), routeSegments: slot.routeSegments, })); - if (unreadable) return false; + if (unreadable) return true; const segmentConfig = resolveAppPageSegmentConfig({ layouts, layoutTreePositions: route.layoutTreePositions, @@ -836,6 +838,7 @@ async function collectAppPaths(options: { enumerateDynamicPaths: boolean; pageExtensions: readonly string[]; retryOptions?: PathDiscoveryRetryOptions; + root: string; secretHeaders: Record; }): Promise<{ fallbackRoutePatterns: PrerenderRoutePattern[]; @@ -913,7 +916,7 @@ async function collectAppPaths(options: { const readMdxEsm = !options.cacheComponents && options.pageExtensions.includes("mdx") - ? await loadMdxEsmReader() + ? await loadMdxEsmReader(options.root) : null; for (const route of routes) { const isRouteHandler = route.routePath !== null && route.pagePath === null; @@ -1532,6 +1535,7 @@ export async function discoverPrerenderPathManifest( enumerateDynamicPaths: options.candidatePathsOnly !== true, pageExtensions: config.pageExtensions, retryOptions: pathDiscoveryRetryOptions, + root, secretHeaders, }); for (const pathname of appPathResult.paths) { diff --git a/packages/vinext/src/utils/mdx-scan.ts b/packages/vinext/src/utils/mdx-scan.ts index 39875d3f2e..9bbdc2e714 100644 --- a/packages/vinext/src/utils/mdx-scan.ts +++ b/packages/vinext/src/utils/mdx-scan.ts @@ -50,18 +50,29 @@ type MdxProcessorModule = { /** * 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 from the same place vinext loads - * that plugin. Returns null when that parser isn't installed. + * `@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(): Promise<((source: string) => string) | null> { - let mdx: MdxProcessorModule; - try { - const rollupEntry = createRequire(import.meta.url).resolve("@mdx-js/rollup"); - const mdxEntry = createRequire(rollupEntry).resolve("@mdx-js/mdx"); - mdx = (await import(pathToFileURL(mdxEntry).href)) as MdxProcessorModule; - } catch { - return null; +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 diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index ff6e389596..8ffd38742c 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import fs from "node:fs"; import os from "node:os"; +import { createRequire } from "node:module"; import path from "node:path"; import { toSlash } from "pathslash"; import { resolveNextConfig } from "../packages/vinext/src/config/next-config.js"; @@ -1633,7 +1634,7 @@ describe("prerender path manifest", () => { "app/[slug]/page.mdx", [ "An export can't interrupt a paragraph, so this is text:", - 'export function generateStaticParams() { return [{ slug: "hello" }] }', + "export function generateStaticParams() {}", "", "# Hello", "", @@ -1659,7 +1660,76 @@ describe("prerender path manifest", () => { expect(manifest?.routePatterns?.["/hello"]?.cacheabilityProbe?.unlisted).toBe(true); }); - it("doesn't list an MDX route's paths without the MDX parser", async () => { + it("reads MDX with the parser beside an MDX plugin the app installs", async () => { + // vinext's own @mdx-js/rollup isn't installed; the app registers its own. + const rollupDir = path.dirname( + createRequire(new URL("../packages/vinext/package.json", import.meta.url)).resolve( + "@mdx-js/rollup", + ), + ); + vi.resetModules(); + vi.doMock("node:module", async (importOriginal) => { + const actual = await importOriginal(); + const createRequireWithoutVinextMdx = (base: string | URL) => { + const require = actual.createRequire(base); + if (!String(base).includes("/utils/mdx-scan.")) return require; + return Object.assign((id: string) => require(id), require, { + resolve: (id: string, options?: { paths?: string[] }) => { + if (id === "@mdx-js/rollup") throw new Error(`Cannot find module '${id}'`); + return require.resolve(id, options); + }, + }); + }; + return Object.assign({}, actual, { createRequire: createRequireWithoutVinextMdx }); + }); + try { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + fs.mkdirSync(path.join(tmpDir, "node_modules", "@mdx-js"), { recursive: true }); + fs.symlinkSync(rollupDir, path.join(tmpDir, "node_modules", "@mdx-js", "rollup"), "dir"); + writeFile( + "app/[slug]/page.mdx", + 'export function generateStaticParams() { return [{ slug: "hello" }] }\n\n# Hello\n', + ); + // Only a route read with the parser keeps its force-dynamic config. + writeFile( + "app/forced/[slug]/page.mdx", + [ + 'export const dynamic = "force-dynamic"', + 'export function generateStaticParams() { return [{ slug: "hello" }] }', + "", + "# Forced", + "", + ].join("\n"), + ); + vi.mocked(fetch).mockImplementation(async () => Response.json([{ slug: "hello" }])); + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const nextConfig = await resolveNextConfig( + { pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"] }, + tmpDir, + ); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + nextConfig, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual(["/forced/hello", "/hello"]); + expect(manifest?.routePatterns?.["/hello"]?.cacheabilityProbe?.unlisted).toBeUndefined(); + expect(manifest?.routePatterns?.["/forced/hello"]?.cacheabilityProbe?.unlisted).toBe(true); + } finally { + vi.doUnmock("node:module"); + vi.resetModules(); + } + }); + + it("lists an MDX route's paths without the MDX parser, so their render failures still fail", async () => { vi.resetModules(); vi.doMock("../packages/vinext/src/utils/mdx-scan.js", async (importOriginal) => ({ ...(await importOriginal()), @@ -1670,15 +1740,24 @@ describe("prerender path manifest", () => { writeFile("dist/server/BUILD_ID", "build-a\n"); writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); writeFile("dist/server/index.js", "export default {};\n"); + writeFile( + "dist/server/vinext-server.json", + JSON.stringify({ prerenderSecret: "probe-secret" }), + ); writeFile( "app/[slug]/page.mdx", 'export function generateStaticParams() { return [{ slug: "hello" }] }\n\n# Hello\n', ); vi.mocked(fetch).mockResolvedValue(Response.json([{ slug: "hello" }])); - const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + const [ + { emitPrerenderPathManifest }, + { resolveNextConfig }, + { probeStagedWorkerCacheability }, + ] = await Promise.all([ import("../packages/vinext/src/build/prerender-paths.js"), import("../packages/vinext/src/config/next-config.js"), + import("../packages/cloudflare/src/cacheability-probe.js"), ]); const nextConfig = await resolveNextConfig( { pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"] }, @@ -1691,7 +1770,35 @@ describe("prerender path manifest", () => { }); expect(manifest?.paths).toEqual(["/hello"]); - expect(manifest?.routePatterns?.["/hello"]?.cacheabilityProbe?.unlisted).toBe(true); + const route = manifest?.routePatterns?.["/hello"]; + expect(route?.cacheabilityProbe?.unlisted).toBeUndefined(); + + const result = await probeStagedWorkerCacheability({ + buildId: "build-a", + fetchImpl: async () => + Response.json({ + kind: "app-page", + pattern: route?.pattern, + reason: "route returned HTTP 500", + state: "probe-failed", + status: 500, + version: 1, + }), + retries: 0, + root: tmpDir, + targetUrl: "https://example.com", + targets: [ + { + headers: { Accept: "text/html" }, + kind: "html", + label: "/hello", + pathname: "/hello", + route, + sourcePathname: "/hello", + }, + ], + }); + expect(result.failures).toEqual(["/hello: route returned HTTP 500"]); } finally { vi.doUnmock("../packages/vinext/src/utils/mdx-scan.js"); vi.resetModules(); From 74453ea00b11f41d631834986f72a4aa83d9e79e Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 11:11:51 +0100 Subject: [PATCH 30/36] fix(cloudflare): don't certify an unreadable MDX route's static fallback An MDX route read without the MDX parser stays listed, so its paths' render failures still fail the deploy. Its exports are unknown, though, and no probe renders a fallback pattern, so an empty generateStaticParams result no longer certifies the route as a static-candidate fallback. --- packages/vinext/src/build/prerender-paths.ts | 28 +++++++----- tests/prerender-paths.test.ts | 48 ++++++++++++++++++++ 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 643e3a304d..2b5c07b620 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -749,15 +749,17 @@ function extractPagesStaticPathLocale( * 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 isAppPageRouteStaticEligible( +function classifyAppPageRouteStaticEligibility( route: AppRoute, readMdxEsm: ((source: string) => string) | null, -): boolean { - // An MDX source read without the MDX parser has unknown exports. Its route - // is taken as static: listing only permits, since 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. +): "eligible" | "ineligible" | "unreadable" { let unreadable = false; const readSegmentConfig = (filePath: string | null | undefined) => { if (!filePath) return null; @@ -796,7 +798,7 @@ function isAppPageRouteStaticEligible( page: readSegmentConfig(slot.pagePath ?? slot.defaultPath), routeSegments: slot.routeSegments, })); - if (unreadable) return true; + if (unreadable) return "unreadable"; const segmentConfig = resolveAppPageSegmentConfig({ layouts, layoutTreePositions: route.layoutTreePositions, @@ -804,7 +806,7 @@ function isAppPageRouteStaticEligible( parallelBranches, routeSegments: route.routeSegments, }); - return isAppPageStaticEligible({ + const eligible = isAppPageStaticEligible({ dynamicConfig: segmentConfig.dynamicConfig, hasGenerateStaticParams: hasAppPageGenerateStaticParamsAtLastDynamicSegment({ childrenSlot: route.childrenSlot ?? null, @@ -829,6 +831,7 @@ function isAppPageRouteStaticEligible( ), revalidateSeconds: segmentConfig.revalidateSeconds, }); + return eligible ? "eligible" : "ineligible"; } async function collectAppPaths(options: { @@ -933,8 +936,11 @@ async function collectAppPaths(options: { // 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 isStaticEligible = - isRouteHandler || options.cacheComponents || isAppPageRouteStaticEligible(route, readMdxEsm); + const staticEligibility = + isRouteHandler || options.cacheComponents + ? "eligible" + : classifyAppPageRouteStaticEligibility(route, readMdxEsm); + const isStaticEligible = staticEligibility !== "ineligible"; const addDiscoveredPath = (pathname: string): void => { if (isRouteHandler) { @@ -1032,7 +1038,7 @@ async function collectAppPaths(options: { }); const hasStaticFallback = paramSets !== null || dynamicConfig === "force-static" || dynamicConfig === "error"; - if (hasStaticFallback && !hasDynamicSegment && isStaticEligible) { + if (hasStaticFallback && !hasDynamicSegment && staticEligibility === "eligible") { fallbackRoutePatterns.push({ kind: "app-page", pattern: route.pattern }); } continue; diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 8ffd38742c..d1bb67a80c 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -1805,6 +1805,54 @@ describe("prerender path manifest", () => { } }); + it("doesn't certify an unreadable MDX route's empty static params fallback", async () => { + vi.resetModules(); + vi.doMock("../packages/vinext/src/utils/mdx-scan.js", async (importOriginal) => ({ + ...(await importOriginal()), + loadMdxEsmReader: async () => null, + })); + try { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + // Without the parser, the layout's edge runtime can't be read, so no + // probe proves the fallback static. + writeFile( + "app/posts/layout.mdx", + 'export const runtime = "edge"\n\n# Posts\n\n{props.children}\n', + ); + writeFile( + "app/posts/[slug]/page.tsx", + [ + "export function generateStaticParams() { return []; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + vi.mocked(fetch).mockResolvedValue(Response.json([])); + + const [{ emitPrerenderPathManifest }, { resolveNextConfig }] = await Promise.all([ + import("../packages/vinext/src/build/prerender-paths.js"), + import("../packages/vinext/src/config/next-config.js"), + ]); + const nextConfig = await resolveNextConfig( + { pageExtensions: ["tsx", "ts", "jsx", "js", "mdx"] }, + tmpDir, + ); + const manifest = await emitPrerenderPathManifest({ + root: tmpDir, + nextConfig, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual([]); + expect(manifest?.fallbackRoutePatterns).toBeUndefined(); + } finally { + vi.doUnmock("../packages/vinext/src/utils/mdx-scan.js"); + vi.resetModules(); + } + }); + it("discovers dynamic Pages MDX paths from the built runtime", async () => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); From 103d869afbc5e6f46e2735007f647bf2e7b3f1cb Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 11:13:28 +0100 Subject: [PATCH 31/36] fix(cloudflare): keep a destination's build-time listing fixed while probes move paths A moved failure was judged listed by scanning the destination pattern's groups, but a route-moving probe removes its group from that pattern when it completes. A listed path moved elsewhere before an alias resolving to it failed silently dropped that failure. Record every origin-listed route and pathname once, before any probe runs, and judge listing against that set. --- packages/cloudflare/src/cacheability-probe.ts | 18 +++---- tests/cloudflare-cacheability-probe.test.ts | 52 +++++++++++++++++++ 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index 747eaae408..b4cebcc1e7 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -494,7 +494,10 @@ export async function probeStagedWorkerCacheability(options: { // A path is listed when its route's own static generation lists it; // discovery marks every other path unlisted. A path the request stage moves to // another route counts as listed there only when that route lists its - // resolved pathname. + // resolved pathname. Those build-time facts are fixed before any probe runs: + // groups move between patterns as route-moving probes complete, so the + // listing is never read back from them. + const originListedPaths = new Set(); const isListedAt = ( group: ConcretePathGroup, route: Pick, @@ -508,17 +511,7 @@ export async function probeStagedWorkerCacheability(options: { } const key = cacheabilityManifestRouteKey(route.kind, route.pattern); if (group.originKey === key) return group.listedAtOrigin; - return ( - patterns - .get(key) - ?.groups.some( - (candidate) => - candidate.originKey === key && - candidate.listedAtOrigin && - !candidate.deferred && - candidate.routePathname === routePathname, - ) === true - ); + return originListedPaths.has(`${key}\0${routePathname}`); }; const isListedGroup = (group: ConcretePathGroup): boolean => isListedAt(group, group.pattern.route, group.routePathname); @@ -534,6 +527,7 @@ export async function probeStagedWorkerCacheability(options: { originKey: targetGroup.pattern.key, primary: targetGroup.targets[0], }; + if (group.listedAtOrigin) originListedPaths.add(`${group.originKey}\0${group.routePathname}`); targetGroup.pattern.groups.push(group); return group; }); diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index 1489c5f786..b8844c8dbe 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -342,6 +342,58 @@ describe("staged Worker cacheability probes", () => { expect(unlisted.failures).toEqual([]); }); + 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("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 }; From cc88a39a8e01790abeb3263060a05a7f8a4703a1 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 11:14:02 +0100 Subject: [PATCH 32/36] fix(cloudflare): keep paired representations when a moved unlisted failure is dropped An unlisted App page failure was dropped before its paired representations were split back to the original route, so header-sensitive routing that sent only the HTML probe to a failing page also dropped an RSC request that stays on a valid source route. Defer the paired targets to the original route first, as a moved success does, and drop only the failed primary. --- packages/cloudflare/src/cacheability-probe.ts | 12 ++++++ tests/cloudflare-cacheability-probe.test.ts | 41 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index b4cebcc1e7..f2e2d118c8 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -742,6 +742,18 @@ export async function probeStagedWorkerCacheability(options: { // 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( diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index b8844c8dbe..4480894b7c 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -394,6 +394,47 @@ describe("staged Worker cacheability probes", () => { 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 }; From 60e121d51a675a12060bba813a24dc44974b6590 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 11:17:28 +0100 Subject: [PATCH 33/36] fix(cloudflare): leave a static page's loading shell runtime-checked A static HTML render doesn't prove the loading-shell render static: a static page may never render its loading boundary, while that boundary can read a dynamic API when the loading-shell request renders it. Certifying the shell from the HTML probe turned such a render into a static-to-dynamic 500 at admission. Certify only html and rsc-full from the HTML probe; the shell stays runtime-checked, so its completed render decides admission. Drop the loading-boundary route list discovery passed only for that. --- packages/cloudflare/src/cacheability-probe.ts | 22 +++------ packages/cloudflare/src/cdn-warm.ts | 9 ---- packages/cloudflare/src/deploy.ts | 1 - packages/vinext/src/build/prerender-paths.ts | 17 ------- tests/cloudflare-cacheability-probe.test.ts | 47 +++++++++++++++---- tests/cloudflare-cdn-warm-deploy.test.ts | 2 - tests/cloudflare-cdn-warm.test.ts | 17 ------- tests/prerender-paths.test.ts | 22 --------- 8 files changed, 44 insertions(+), 93 deletions(-) diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index f2e2d118c8..234d006491 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -340,8 +340,6 @@ export async function probeStagedWorkerCacheability(options: { expectedResponseBuildId?: string; fallbackRoutePatterns?: readonly PrerenderRoutePattern[]; fetchImpl?: typeof fetch; - /** App page route patterns with a main-tree loading boundary. */ - loadingBoundaryRoutePatterns?: readonly string[]; headers?: HeadersInit; retries?: number; retryDelayMs?: number; @@ -984,10 +982,9 @@ export async function probeStagedWorkerCacheability(options: { // 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 RSC representations, which Next.js serves from one render. + // 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. - const loadingBoundaryRoutePatterns = new Set(options.loadingBoundaryRoutePatterns); for (const pattern of patterns.values()) { if (pattern.pruned) { classified += 1; @@ -1117,20 +1114,15 @@ export async function probeStagedWorkerCacheability(options: { speculativeTargets.push(...pairedTargets); } } - // The HTML probe ran SSR, so it saw every read the RSC renders can make. - // A path probed only through RSC keeps its single listing. + // 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", - ...(loadingBoundaryRoutePatterns.has(pattern.route.pattern) - ? (["rsc-loading-shell"] as const) - : []), - ] - : [representation]; + 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 diff --git a/packages/cloudflare/src/cdn-warm.ts b/packages/cloudflare/src/cdn-warm.ts index 0a112b2ab0..9565100488 100644 --- a/packages/cloudflare/src/cdn-warm.ts +++ b/packages/cloudflare/src/cdn-warm.ts @@ -112,7 +112,6 @@ export type PrerenderWarmPlan = { buildIdentity?: string; deploymentId?: string; fallbackRoutePatterns?: PrerenderRoutePattern[]; - loadingBoundaryRoutePatterns?: string[]; loadingShellPaths: string[]; pagesDataPaths?: string[]; pagesPaths?: string[]; @@ -221,11 +220,6 @@ function readPrerenderPathManifest(manifestPath: string): PrerenderPathManifest (manifest.loadingShellPaths !== undefined && (!Array.isArray(manifest.loadingShellPaths) || !manifest.loadingShellPaths.every((pathname) => typeof pathname === "string"))) || - (manifest.loadingBoundaryRoutePatterns !== undefined && - (!Array.isArray(manifest.loadingBoundaryRoutePatterns) || - !manifest.loadingBoundaryRoutePatterns.every( - (pattern) => typeof pattern === "string" && pattern.startsWith("/"), - ))) || (manifest.basePath !== undefined && typeof manifest.basePath !== "string") || (manifest.buildIdentity !== undefined && typeof manifest.buildIdentity !== "string") || (manifest.deploymentId !== undefined && typeof manifest.deploymentId !== "string") || @@ -332,9 +326,6 @@ export function createPrerenderWarmPlan( ...(manifest.fallbackRoutePatterns ? { fallbackRoutePatterns: manifest.fallbackRoutePatterns } : {}), - ...(manifest.loadingBoundaryRoutePatterns - ? { loadingBoundaryRoutePatterns: manifest.loadingBoundaryRoutePatterns } - : {}), loadingShellPaths: supportsCanonicalRsc ? (manifest.loadingShellPaths ?? []).map(applyConfig) : [], diff --git a/packages/cloudflare/src/deploy.ts b/packages/cloudflare/src/deploy.ts index 61433fe962..5711b561d9 100644 --- a/packages/cloudflare/src/deploy.ts +++ b/packages/cloudflare/src/deploy.ts @@ -1885,7 +1885,6 @@ async function deployWithCacheabilityProbe( concurrency: options.warmCdnConcurrency, expectedResponseBuildId: plan.buildIdentity, fallbackRoutePatterns: plan.fallbackRoutePatterns, - loadingBoundaryRoutePatterns: plan.loadingBoundaryRoutePatterns, phaseTimeoutMs: options.warmCdnProbeTimeout ?? DEFAULT_CACHEABILITY_PROBE_PHASE_TIMEOUT_MS, retries: options.warmCdnProbeRetries ?? diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 2b5c07b620..4ea114ed23 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -98,8 +98,6 @@ export type PrerenderPathManifest = { routeHandlerPaths?: string[]; /** App Router paths with an ordinary main-tree loading boundary. */ loadingShellPaths?: string[]; - /** Every App page route pattern with an ordinary main-tree loading boundary. */ - loadingBoundaryRoutePatterns?: string[]; /** Pages Router paths selected by the existing HTML warm discovery pass. */ pagesPaths?: string[]; /** Pages Router JSON data identities corresponding to discovered static paths. */ @@ -1072,22 +1070,12 @@ async function resolveAppWarmPaths(options: { appPaths: string[]; appRoutePaths: string[]; htmlPaths: string[]; - loadingBoundaryRoutePatterns: string[]; loadingShellPaths: string[]; pagesPaths: string[]; rscPaths: string[]; routePatterns: Record; }> { const appRoutes = await appRouter(options.appDir, options.pageExtensions); - // Read from the route graph for every App page route. A configured - // candidate path can be recorded without a matched route, so per-path - // metadata can't carry it. - const loadingBoundaryRoutePatterns = appRoutes - .filter( - (route) => !(route.routePath && !route.pagePath) && appRouteHasMainTreeLoadingBoundary(route), - ) - .map((route) => route.pattern) - .sort(); const routeHandlerClassifications = new Map( appRoutes.flatMap((route) => route.routePath && !route.pagePath @@ -1170,7 +1158,6 @@ async function resolveAppWarmPaths(options: { appPaths, appRoutePaths, htmlPaths, - loadingBoundaryRoutePatterns, loadingShellPaths, pagesPaths, routePatterns, @@ -1702,7 +1689,6 @@ export async function discoverPrerenderPathManifest( appPaths: [], appRoutePaths: [], htmlPaths: pagesOnlyWarmPaths, - loadingBoundaryRoutePatterns: [], loadingShellPaths: [], pagesPaths: pagesOnlyWarmPaths, routePatterns: pagesWarmMetadata.routePatterns, @@ -1784,9 +1770,6 @@ export async function discoverPrerenderPathManifest( : {}), ...(excludedWarmPathSet.size > 0 ? { excludedWarmPaths: Array.from(excludedWarmPathSet) } : {}), ...(fallbackRoutePatterns.length > 0 ? { fallbackRoutePatterns } : {}), - ...(appOwnedWarmPaths.loadingBoundaryRoutePatterns.length > 0 - ? { loadingBoundaryRoutePatterns: appOwnedWarmPaths.loadingBoundaryRoutePatterns } - : {}), ...(rscBuildId ? { rscBuildId } : {}), ...(options.responseVary ? { responseVary: options.responseVary } : {}), ...(includeCanonicalRsc ? { rscPaths: appOwnedWarmPaths.rscPaths } : {}), diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index 4480894b7c..c6186a0c5b 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -1888,19 +1888,34 @@ describe("staged Worker cacheability probes", () => { } }); - it("certifies the RSC representations of a static HTML render", async () => { + 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), rscOnly, ...pageTargets("/about", aboutRoute)], + [ + ...pageTargets("/posts/a", listedRoute), + postShell, + rscOnly, + ...pageTargets("/about", aboutRoute), + aboutShell, + ], {}, - { loadingBoundaryRoutePatterns: ["/posts/:slug"] }, ); expect(result.failures).toEqual([]); const posts = result.manifest.routes[cacheabilityManifestRouteKey("app-page", "/posts/:slug")]!; - for (const representation of ["html", "rsc-full", "rsc-loading-shell"] as const) { + for (const representation of ["html", "rsc-full"] as const) { expect(cacheabilityManifestRouteState(posts, "/posts/a", representation)).toBe( "static-candidate", ); @@ -1909,17 +1924,29 @@ describe("staged Worker cacheability probes", () => { expect(cacheabilityManifestRouteState(posts, "/posts/rsc-only", "rsc-full")).toBe( "static-candidate", ); - expect(posts.staticPaths?.html).toEqual(["a"]); - expect(posts.staticPaths?.["rsc-loading-shell"]).toEqual(["a"]); 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"); - // No loading boundary, so no loading-shell render to certify. - expect(cacheabilityManifestRouteState(about, "/about", "rsc-loading-shell")).not.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 () => { diff --git a/tests/cloudflare-cdn-warm-deploy.test.ts b/tests/cloudflare-cdn-warm-deploy.test.ts index 6277be549a..c46aea854f 100644 --- a/tests/cloudflare-cdn-warm-deploy.test.ts +++ b/tests/cloudflare-cdn-warm-deploy.test.ts @@ -914,7 +914,6 @@ describe("Cloudflare CDN warmup deploy flow", () => { appPaths: ["/about", "/dynamic"], buildId: "app-build-a", buildIdentity: "app-build-a", - loadingBoundaryRoutePatterns: ["/:slug"], loadingShellPaths: [], pagesDataPaths: ["/_next/data/app-build-a/pages-about.json"], pagesPaths: ["/pages-about"], @@ -1028,7 +1027,6 @@ describe("Cloudflare CDN warmup deploy flow", () => { staticPaths: { html: ["/about"], "rsc-full": ["/about"], - "rsc-loading-shell": ["/about"], }, state: "runtime-check", }), diff --git a/tests/cloudflare-cdn-warm.test.ts b/tests/cloudflare-cdn-warm.test.ts index 3a00dc9776..388bf33a1d 100644 --- a/tests/cloudflare-cdn-warm.test.ts +++ b/tests/cloudflare-cdn-warm.test.ts @@ -190,7 +190,6 @@ describe("Cloudflare CDN warmup", () => { { kind: "app-route", pattern: "/api/posts/:slug" }, { kind: "pages-page", pattern: "/legacy/:slug" }, ], - loadingBoundaryRoutePatterns: ["/dashboard", "/posts/:slug"], loadingShellPaths: ["/dashboard"], pagesDataPaths: ["/docs/_next/data/build-a/pages.json"], paths: ["/dashboard", "/dynamic", "/pages"], @@ -215,8 +214,6 @@ describe("Cloudflare CDN warmup", () => { { kind: "app-route", pattern: "/api/posts/:slug" }, { kind: "pages-page", pattern: "/legacy/:slug" }, ], - // Route patterns, so neither the basePath nor trailingSlash applies. - loadingBoundaryRoutePatterns: ["/dashboard", "/posts/:slug"], loadingShellPaths: ["/docs/dashboard/"], pagesDataPaths: ["/docs/_next/data/build-a/pages.json"], paths: ["/docs/dashboard/", "/docs/dynamic/", "/docs/pages/"], @@ -352,20 +349,6 @@ describe("Cloudflare CDN warmup", () => { ); }); - it("rejects malformed loading-boundary route patterns", () => { - writeFile("dist/server/BUILD_ID", "build-a\n"); - for (const loadingBoundaryRoutePatterns of ["/posts/:slug", ["posts/:slug"], [1]]) { - writeFile( - "dist/server/vinext-prerender-paths.json", - JSON.stringify({ buildId: "build-a", loadingBoundaryRoutePatterns, paths: ["/"] }), - ); - - expect(() => readPrerenderWarmPlan(tmpDir, { strict: true })).toThrow( - "prerender path manifest not found", - ); - } - }); - it("warms canonical RSC, HTML, and Pages data with browser-identical requests", async () => { const fetchImpl = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { const headers = new Headers(init?.headers); diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index d1bb67a80c..0cc60779a9 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -131,7 +131,6 @@ describe("prerender path manifest", () => { appPaths: ["/", "/dynamic", "/cached/intro", "/cached/featured"], buildId: "build-a", buildIdentity: "rsc-build-a", - loadingBoundaryRoutePatterns: ["/cached/:slug"], loadingShellPaths: ["/cached/intro", "/cached/featured"], rscBuildId: "rsc-build-a", responseVary: "verbatim", @@ -1001,27 +1000,6 @@ describe("prerender path manifest", () => { expect(manifest?.loadingShellPaths).toEqual(["/safe"]); }); - it("lists every App page route with a loading boundary, including routes with no warm paths", async () => { - writeFile("package.json", JSON.stringify({ type: "module" })); - writeFile("dist/server/BUILD_ID", "build-a\n"); - writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); - writeFile("dist/server/index.js", "export default {};\n"); - writeFile("app/about/page.tsx", "export default function Page() {}\n"); - writeFile( - "app/posts/[slug]/page.tsx", - "export const revalidate = 60; export default function Page() {}\n", - ); - writeFile("app/posts/loading.tsx", "export default function Loading() { return null; }\n"); - writeFile("app/posts/feed/route.ts", "export function GET() {}\n"); - - const { emitPrerenderPathManifest } = - await import("../packages/vinext/src/build/prerender-paths.js"); - const manifest = await emitPrerenderPathManifest({ root: tmpDir }); - - expect(manifest?.paths).toEqual(["/about"]); - expect(manifest?.loadingBoundaryRoutePatterns).toEqual(["/posts/:slug"]); - }); - it("warms rewrite source paths when routing runs in an uncached stage", async () => { // Rewrite-aware prefetches can resolve a public URL to a different route: // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/concurrent-navigations/mismatching-prefetch.test.ts From 738f57e486e01b1812d105b5e8f6cea5696370af Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 11:55:16 +0100 Subject: [PATCH 34/36] fix(cloudflare): keep discovery's unlisted state for a literal App page route's own path --- packages/cloudflare/src/cacheability-probe.ts | 21 +++---- tests/cloudflare-cacheability-probe.test.ts | 60 +++++++++++++++++++ 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index 234d006491..f51e6fda58 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -494,22 +494,23 @@ export async function probeStagedWorkerCacheability(options: { // another route counts as listed there only when that route lists its // resolved pathname. Those build-time facts are fixed before any probe runs: // groups move between patterns as route-moving probes complete, so the - // listing is never read back from them. - const originListedPaths = new Set(); + // 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 = ( group: ConcretePathGroup, route: Pick, routePathname: string, ): boolean => { - if ( - !/(^|\/):/.test(route.pattern) && - normalizeCacheabilityRoutePathname(route.pattern) === routePathname - ) { - return true; - } const key = cacheabilityManifestRouteKey(route.kind, route.pattern); if (group.originKey === key) return group.listedAtOrigin; - return originListedPaths.has(`${key}\0${routePathname}`); + 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, group.pattern.route, group.routePathname); @@ -525,7 +526,7 @@ export async function probeStagedWorkerCacheability(options: { originKey: targetGroup.pattern.key, primary: targetGroup.targets[0], }; - if (group.listedAtOrigin) originListedPaths.add(`${group.originKey}\0${group.routePathname}`); + buildTimeListing.set(`${group.originKey}\0${group.routePathname}`, group.listedAtOrigin); targetGroup.pattern.groups.push(group); return group; }); diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index c6186a0c5b..587c941205 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -292,6 +292,66 @@ describe("staged Worker cacheability probes", () => { 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 }, From fa5a1dc5d0e784dd6fd9a9ddb885c067fc965d3a Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 11:59:07 +0100 Subject: [PATCH 35/36] fix(cloudflare): key a path's listing by the route that owns it at runtime --- packages/cloudflare/src/cacheability-probe.ts | 42 +++++------- packages/vinext/src/build/prerender-paths.ts | 56 +++++++++++----- tests/cloudflare-cacheability-probe.test.ts | 50 +++++++++++++- tests/prerender-paths.test.ts | 66 +++++++++++++++++++ 4 files changed, 172 insertions(+), 42 deletions(-) diff --git a/packages/cloudflare/src/cacheability-probe.ts b/packages/cloudflare/src/cacheability-probe.ts index f51e6fda58..a41f1e2c32 100644 --- a/packages/cloudflare/src/cacheability-probe.ts +++ b/packages/cloudflare/src/cacheability-probe.ts @@ -413,10 +413,6 @@ export async function probeStagedWorkerCacheability(options: { deferred: boolean; /** An unlisted path whose render failed; it is left to the route rule. */ dropped?: boolean; - /** Discovery listed the path for the route it was grouped under. */ - listedAtOrigin: boolean; - /** The route key the path was grouped under before any request-stage move. */ - originKey: string; pattern: PatternClassification; primary: CdnWarmTarget; result?: ConcretePathResult; @@ -489,22 +485,22 @@ export async function probeStagedWorkerCacheability(options: { group.targets.push(target); targetGroups.set(concreteKey, group); } - // A path is listed when its route's own static generation lists it; - // discovery marks every other path unlisted. A path the request stage moves to - // another route counts as listed there only when that route lists its - // resolved pathname. Those build-time facts are 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. + // 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 = ( - group: ConcretePathGroup, route: Pick, routePathname: string, ): boolean => { const key = cacheabilityManifestRouteKey(route.kind, route.pattern); - if (group.originKey === key) return group.listedAtOrigin; const listed = buildTimeListing.get(`${key}\0${routePathname}`); if (listed !== undefined) return listed; return ( @@ -513,20 +509,17 @@ export async function probeStagedWorkerCacheability(options: { ); }; const isListedGroup = (group: ConcretePathGroup): boolean => - isListedAt(group, group.pattern.route, group.routePathname); + 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, - listedAtOrigin: targetGroup.targets[0].route!.cacheabilityProbe?.unlisted !== true, - originKey: targetGroup.pattern.key, - primary: targetGroup.targets[0], - }; - buildTimeListing.set(`${group.originKey}\0${group.routePathname}`, group.listedAtOrigin); + 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; }); @@ -627,8 +620,6 @@ export async function probeStagedWorkerCacheability(options: { cacheabilityRoutePathname(target.pathname, target.kind); const deferredGroup: ConcretePathGroup = { deferred: true, - listedAtOrigin: group.listedAtOrigin, - originKey: group.pattern.key, pattern: group.pattern, primary: target, resultKey: routePathname, @@ -731,7 +722,6 @@ export async function probeStagedWorkerCacheability(options: { (target.route.cacheabilityProbe?.routeMayResolve === true && result.routePathname !== undefined)) && !isListedAt( - group, { kind: result.kind, pattern: result.pattern }, result.routePathname === undefined ? group.routePathname diff --git a/packages/vinext/src/build/prerender-paths.ts b/packages/vinext/src/build/prerender-paths.ts index 4ea114ed23..68b3e8d58d 100644 --- a/packages/vinext/src/build/prerender-paths.ts +++ b/packages/vinext/src/build/prerender-paths.ts @@ -75,8 +75,9 @@ export type PrerenderRoutePattern = { /** * Not listed by the route's own static generation (`generateStaticParams`, * `getStaticPaths` or a route without dynamic segments): picked from - * traffic, or discovered for an App page route that isn't static or SSG. - * A path both listed and picked counts as listed. + * 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; }; @@ -832,6 +833,11 @@ function classifyAppPageRouteStaticEligibility( 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; @@ -846,8 +852,11 @@ async function collectAppPaths(options: { nonDynamicPaths: string[]; paths: string[]; routeHandlerPaths: string[]; - /** App page paths discovered only for routes that aren't static or SSG. */ - unlistedPaths: 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[] = []; @@ -857,8 +866,7 @@ async function collectAppPaths(options: { const fallbackRoutePatterns: PrerenderRoutePattern[] = []; const nonDynamicPaths: string[] = []; const seenNonDynamicPaths = new Set(); - const listedPaths = new Set(); - const ineligiblePaths = new Set(); + const listedRoutePaths = new Set(); const staticParamsCache = new Map[] | null>>(); let requireNonEmptyStaticParams = false; const staticParamsMap = new Proxy({} as StaticParamsMap, { @@ -930,10 +938,11 @@ 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. 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. + // 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" @@ -946,7 +955,7 @@ async function collectAppPaths(options: { return; } addPath(paths, seen, pathname); - (isStaticEligible ? listedPaths : ineligiblePaths).add(pathname); + if (isStaticEligible) listedRoutePaths.add(appPageListingKey(route.pattern, pathname)); }; if (!route.isDynamic) { @@ -1056,7 +1065,7 @@ async function collectAppPaths(options: { nonDynamicPaths, paths, routeHandlerPaths, - unlistedPaths: Array.from(ineligiblePaths).filter((pathname) => !listedPaths.has(pathname)), + listedRoutePaths, }; } @@ -1462,7 +1471,8 @@ export async function discoverPrerenderPathManifest( const discoveredRouteHandlerPaths: string[] = []; const seenRouteHandlerPaths = new Set(); const discoveredNonDynamicPathSet = new Set(); - const unlistedPathSet = new Set(); + const appListedRoutePaths = new Set(); + const candidateOnlyPathSet = new Set(); const fallbackRoutePatterns: PrerenderRoutePattern[] = []; await withPrerenderEndpoints(async () => { let prodServer: { server: HttpServer; port: number } | null = null; @@ -1541,7 +1551,7 @@ export async function discoverPrerenderPathManifest( discoveredNonDynamicPathSet.add(pathname); } fallbackRoutePatterns.push(...appPathResult.fallbackRoutePatterns); - for (const pathname of appPathResult.unlistedPaths) unlistedPathSet.add(pathname); + for (const key of appPathResult.listedRoutePaths) appListedRoutePaths.add(key); } if (pagesDir) { @@ -1584,7 +1594,7 @@ export async function discoverPrerenderPathManifest( pathname = pathname.slice(config.basePath.length); else continue; } - if (!seen.has(pathname)) unlistedPathSet.add(pathname); + if (!seen.has(pathname)) candidateOnlyPathSet.add(pathname); addPath(paths, seen, pathname); if (pagesDir) addPath(discoveredPagesPaths, seenPagesPaths, pathname); } @@ -1725,6 +1735,22 @@ 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, diff --git a/tests/cloudflare-cacheability-probe.test.ts b/tests/cloudflare-cacheability-probe.test.ts index 587c941205..474fc73552 100644 --- a/tests/cloudflare-cacheability-probe.test.ts +++ b/tests/cloudflare-cacheability-probe.test.ts @@ -402,6 +402,52 @@ describe("staged Worker cacheability probes", () => { 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 }, @@ -2133,9 +2179,11 @@ 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: "static-candidate", + expectedResolvedState: null, expectedRoutePathname: "/resolved", pattern: "/source", routePathname: "/resolved", diff --git a/tests/prerender-paths.test.ts b/tests/prerender-paths.test.ts index 0cc60779a9..cdf0939f48 100644 --- a/tests/prerender-paths.test.ts +++ b/tests/prerender-paths.test.ts @@ -294,6 +294,72 @@ describe("prerender path manifest", () => { expect(manifest?.fallbackRoutePatterns).toBeUndefined(); }); + it("marks a path unlisted when a route other than its runtime owner generates it", async () => { + writeFile("package.json", JSON.stringify({ type: "module" })); + writeFile("dist/server/BUILD_ID", "build-a\n"); + writeFile("dist/server/RSC_BUILD_ID", "rsc-build-a\n"); + writeFile("dist/server/index.js", "export default {};\n"); + // Next.js's build renders each generated path under the route that + // generated it, so the static catch-all's paths are never build-rendered + // by the more specific routes that own them at runtime. + writeFile( + "app/[...slug]/page.tsx", + [ + "export function generateStaticParams() { return []; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + writeFile( + "app/specific/[id]/page.tsx", + "export const dynamic = 'force-dynamic'; export default function Page() { return null; }\n", + ); + writeFile( + "app/static/[id]/page.tsx", + [ + "export function generateStaticParams() { return [{ id: 'own' }]; }", + "export default function Page() { return null; }", + ].join("\n"), + ); + const defaultFetch = vi.mocked(fetch).getMockImplementation()!; + vi.mocked(fetch).mockImplementation(async (input, init) => { + const pattern = new URL( + input instanceof Request ? input.url : String(input), + ).searchParams.get("pattern"); + if (pattern === "/:slug+") { + return Response.json([ + { slug: ["specific", "value"] }, + { slug: ["static", "foreign"] }, + { slug: ["other", "value"] }, + ]); + } + if (pattern === "/static/:id") return Response.json([{ id: "own" }]); + return defaultFetch(input, init); + }); + + const { discoverPrerenderPathManifest } = + await import("../packages/vinext/src/build/prerender-paths.js"); + const manifest = await discoverPrerenderPathManifest({ + root: tmpDir, + responseVary: "verbatim", + }); + + expect(manifest?.paths).toEqual( + expect.arrayContaining(["/specific/value", "/static/foreign", "/other/value", "/static/own"]), + ); + const route = (pathname: string) => manifest?.routePatterns?.[pathname]; + expect(route("/specific/value")).toMatchObject({ + cacheabilityProbe: { unlisted: true }, + pattern: "/specific/:id", + }); + expect(route("/static/foreign")).toMatchObject({ + cacheabilityProbe: { unlisted: true }, + pattern: "/static/:id", + }); + expect(route("/static/own")?.cacheabilityProbe?.unlisted).toBeUndefined(); + expect(route("/other/value")).toMatchObject({ pattern: "/:slug+" }); + expect(route("/other/value")?.cacheabilityProbe?.unlisted).toBeUndefined(); + }); + it("doesn't count a type-only generateStaticParams export toward static generation", async () => { writeFile("package.json", JSON.stringify({ type: "module" })); writeFile("dist/server/BUILD_ID", "build-a\n"); From 7801a78587b1790c114aed28c47319295b1e8147 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 25 Sep 2026 12:39:28 +0100 Subject: [PATCH 36/36] test(cloudflare): share the Suspense useSearchParams() page across queries in Workers Cache Next.js prerenders a static page whose useSearchParams() sits inside Suspense once, with the fallback, and serves it for every query. The deployed Workers Cache test now requests /search-params/suspense with fresh queries until one HITs with the previous response's render ID, and requires that no query sent reaches either body. A unit case renders that page shape as the deploy probe does, in cache-candidate mode with the SSR useSearchParams() gate, and requires a static-candidate, renderer-static envelope without dynamic usage, so the probe certifies it and the request stage drops its query. --- tests/app-page-render.test.ts | 57 ++++++++++++++++++- .../cloudflare-workers/cache-prewarm.spec.ts | 24 ++++++-- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/tests/app-page-render.test.ts b/tests/app-page-render.test.ts index 3fad6dd060..bfb701ccc7 100644 --- a/tests/app-page-render.test.ts +++ b/tests/app-page-render.test.ts @@ -27,8 +27,11 @@ import { createClientPageSsrSearchParamsSource } from "../packages/vinext/src/se 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, @@ -1342,7 +1345,10 @@ describe("app page render lifecycle", () => { async function renderClassifying( render: ClassifyingRender, tree: ReactNode, - { readsSearchParams = false }: { readsSearchParams?: boolean } = {}, + { + isCacheCandidate = false, + readsSearchParams = false, + }: { isCacheCandidate?: boolean; readsSearchParams?: boolean } = {}, ) { const common = createCommonOptions(); const state: RouteCacheabilityState = { @@ -1376,13 +1382,14 @@ describe("app page render lifecycle", () => { params: { slug: "post" }, }; }, + isCacheCandidate, isPrerender: render !== "probe", isSpeculativePrerender: render === "speculative prerender", isProduction: true, revalidateSeconds: Infinity, async loadSsrHandler() { return { - handleSsr(_rscStream, navContext, _fontData, options) { + handleSsr(rscStream, navContext, _fontData, options) { return runWithNavigationContext(async () => { if (options?.capturedRscDataRef) { options.capturedRscDataRef.value = Promise.resolve( @@ -1390,9 +1397,21 @@ describe("app page render lifecycle", () => { ); 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, {}, @@ -1413,7 +1432,11 @@ describe("app page render lifecycle", () => { }, }); }); - return { response, completion: render === "probe" ? state.completion : undefined }; + return { + response, + completion: render === "probe" ? state.completion : undefined, + executionContext, + }; } async function classify(render: ClassifyingRender, tree: ReactNode) { @@ -1474,6 +1497,34 @@ describe("app page render lifecycle", () => { 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. diff --git a/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts b/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts index 264f276cec..7709d269af 100644 --- a/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts +++ b/tests/e2e/cloudflare-workers/cache-prewarm.spec.ts @@ -210,30 +210,33 @@ test("Workers Cache serves every query of a static page from one entry", async ( test.skip(!baseURL?.startsWith("https://"), "requires a deployed Cloudflare Worker"); test.skip(backend !== "workers-cache", "the query-free dispatch is specific to Workers Cache"); if (!baseURL) throw new Error("deployed test requires a base URL"); - test.setTimeout(120_000); + test.setTimeout(180_000); // Next.js serves a static page's one render for any query. Each request // carries a query no earlier request used, so only an entry shared across - // queries can report a HIT with the previous response's render. The query - // never reaches the shared render. + // queries can report a HIT with the previous response's render. No query + // ever reaches the shared render. const expectSharedAcrossQueries = async ( label: string, urlFor: (query: string) => string, headers: Record, + content: string, renderOf: (body: string) => string | undefined, ) => { + const queries: string[] = []; let previousRender: string | undefined; await expect .poll( async () => { const query = randomUUID(); + queries.push(query); const response = await request.get(urlFor(query), { headers }); const responseHeaders = response.headers(); const trace = JSON.stringify({ label, headers: responseHeaders }); expect(response.ok(), trace).toBe(true); const body = await response.text(); - expect(body, trace).toContain("Post: featured"); - expect(body, trace).not.toContain(query); + expect(body, trace).toContain(content); + for (const sent of queries) expect(body, trace).not.toContain(sent); const render = renderOf(body); expect(render, trace).toBeTruthy(); const shared = responseHeaders["cf-cache-status"] === "HIT" && render === previousRender; @@ -249,6 +252,7 @@ test("Workers Cache serves every query of a static page from one entry", async ( "HTML", (query) => `${baseURL}/cached/featured?q=${query}`, { accept: "text/html" }, + "Post: featured", (body) => /data-render-id-tag[^>]*>([^<]+) `${baseURL}/cached/featured?q=${query}&_rsc`, { accept: "text/x-component", rsc: "1" }, + "Post: featured", // A HIT returns the stored payload byte for byte. (body) => body, ); + // A static page that reads useSearchParams() inside Suspense: Next.js + // prerenders it once with the fallback, and the browser reads the query. + await expectSharedAcrossQueries( + "HTML with useSearchParams() inside Suspense", + (query) => `${baseURL}/search-params/suspense?q=${query}`, + { accept: "text/html" }, + 'data-testid="search-fallback"', + (body) => /search-suspense-render-id[^>]*>([^<]+)