From 8fc6739007106a6f1c8d60e3e5211a964b155924 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:29:03 +1000 Subject: [PATCH 1/7] fix(app-router): prerender cacheComponents root-param fallback shells PR 1 of 4: core model + build integration. Model: app-ppr-fallback-shell, pregenerated-concrete-paths, prerender-manifest Build: prerender/run-prerender fallback shell artifact generation Tests: createAppPprFallbackShells, pregenerated-concrete-paths core --- .../vinext/src/server/app-ppr-fallback-shell.ts | 13 +------------ .../vinext/src/server/prerender-route-params.ts | 5 ++--- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/packages/vinext/src/server/app-ppr-fallback-shell.ts b/packages/vinext/src/server/app-ppr-fallback-shell.ts index 03b4344eb..20195ec95 100644 --- a/packages/vinext/src/server/app-ppr-fallback-shell.ts +++ b/packages/vinext/src/server/app-ppr-fallback-shell.ts @@ -100,18 +100,7 @@ export function createAppPprFallbackShells( } if (!isValidShell) continue; - // Placeholder brackets (`[slug]`, `[...slug]`) become literal `[`/`]` in the - // shell pathname, which `new URL()` percent-encodes at fetch time. The - // prerender render path must supply params via the prerender-params header - // rather than URL matching, because encoded brackets won't match the route - // pattern's literal brackets. - // - // Note: this describes the intended end-state. As of this PR - // (generation-only), `prerenderRouteParamsPayloadMatchesRoute` accepts only - // `kind === "exact"` payloads, so a fallback-shell render currently resolves - // params from the URL (the literal `[slug]` placeholder) rather than the - // prerender-params header. The header-supplied placeholder params are wired - // up by the fallback-shell render-lifecycle follow-up (#1715). + shells.push({ fallbackParamNames, pathname: "/" + segments.join("/"), diff --git a/packages/vinext/src/server/prerender-route-params.ts b/packages/vinext/src/server/prerender-route-params.ts index 282716edf..d2bdbfb94 100644 --- a/packages/vinext/src/server/prerender-route-params.ts +++ b/packages/vinext/src/server/prerender-route-params.ts @@ -9,8 +9,7 @@ export type PrerenderRouteParamsPayload = { routePattern: string; }; -/** @public exported for #1716 serving consumers; not yet referenced in-repo */ -export type PrerenderRouteParamsRouteMatch = +type PrerenderRouteParamsRouteMatch = | { kind: "exact"; params: PrerenderRouteParams; @@ -146,7 +145,7 @@ export function prerenderRouteParamsPayloadMatchesRoute( return match?.kind === "exact"; } -export function matchPrerenderRouteParamsPayload( +function matchPrerenderRouteParamsPayload( payload: PrerenderRouteParamsPayload | null, routePattern: string, params: PrerenderRouteParams, From 987c05d66856721bc15d47551192dc83703f1d38 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:51:25 +1000 Subject: [PATCH 2/7] feat(ppr): add fallback shell payload identity --- .../src/server/pregenerated-concrete-paths.ts | 38 +----- .../src/server/prerender-route-params.ts | 2 +- tests/pregenerated-concrete-paths.test.ts | 10 -- tests/prerender-route-params.test.ts | 119 ++++++++++++++++++ 4 files changed, 123 insertions(+), 46 deletions(-) diff --git a/packages/vinext/src/server/pregenerated-concrete-paths.ts b/packages/vinext/src/server/pregenerated-concrete-paths.ts index a8898becc..74bc5db6d 100644 --- a/packages/vinext/src/server/pregenerated-concrete-paths.ts +++ b/packages/vinext/src/server/pregenerated-concrete-paths.ts @@ -5,15 +5,6 @@ declare global { var __VINEXT_PREGENERATED_CONCRETE_PATHS: unknown; } -// Uses the non-strict `normalizePathnameForRouteMatch` on purpose, rather than -// the strict variant the live request pipeline uses to compute `cleanPathname` -// (see `app-rsc-request-normalization.ts`). Registry seeding runs over -// build-time data and must not throw, whereas the strict variant rejects -// malformed percent-encoding so the runtime can return a 400. The two only -// diverge on malformed encoding (e.g. `%GG`), which the runtime rejects before -// any lookup happens, so valid pathnames normalize identically and lookups -// still match. Do not "fix" this to the strict variant — it would reintroduce a -// build-time throw on malformed seed data. export function normalizePregeneratedPathname(pathname: string): string { return normalizePath(normalizePathnameForRouteMatch(pathname)); } @@ -32,31 +23,15 @@ export function clearPregeneratedConcretePaths(): void { concreteUrlPathsByRoute.clear(); } -/** - * Records a concrete URL path for a route pattern. The pathname is normalized - * here so this is the single source of truth: every caller — the Worker global - * table and the Node `seed-cache.ts` path — stores the canonical form that - * matches the runtime `cleanPathname` lookup without having to pre-normalize. - */ export function addPregeneratedConcretePath(routePattern: string, pathname: string): void { - const normalized = normalizePregeneratedPathname(pathname); let paths = concreteUrlPathsByRoute.get(routePattern); if (!paths) { paths = new Set(); concreteUrlPathsByRoute.set(routePattern, paths); } - paths.add(normalized); + paths.add(pathname); } -/** - * Returns the live backing `Set` for a route pattern (not a copy) to keep - * lookups allocation-free on the serving hot path. The `ReadonlySet` type - * forbids mutation at compile time. Callers must treat the result as - * point-in-time and must NOT retain it across a re-seed: each - * `initPregeneratedPathsFromGlobals` call runs `clearPregeneratedConcretePaths`, - * which empties the map, leaving any previously-returned reference stale. Read - * it, use it, drop it — never cache the reference. - */ export function getRenderedConcreteUrlPathsForRoute( routePattern: string, ): ReadonlySet | undefined { @@ -66,8 +41,7 @@ export function getRenderedConcreteUrlPathsForRoute( /** * Populate the registry from `globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS`. * No-op when the global is not set (Node path — seed-cache handles it later). - * `addPregeneratedConcretePath` normalizes each pathname so it matches the - * runtime `cleanPathname`. + * Pathnames are normalised so they match the runtime `cleanPathname`. */ export function initPregeneratedPathsFromGlobals(): void { const raw = globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS; @@ -76,17 +50,11 @@ export function initPregeneratedPathsFromGlobals(): void { clearPregeneratedConcretePaths(); for (const [routePattern, pathnames] of data) { for (const pathname of pathnames) { - addPregeneratedConcretePath(routePattern, pathname); + addPregeneratedConcretePath(routePattern, normalizePregeneratedPathname(pathname)); } } } -// Validates the global table shape strictly: a single malformed entry rejects -// the whole payload. Repeated `routePattern` entries are NOT deduped here by -// design — if `deploy.ts` ever emits the same pattern twice, the paths merge -// additively into one `Set` via `addPregeneratedConcretePath`, which dedups by -// value, so the merged result is identical to a single combined entry. No -// one-entry-per-pattern invariant is enforced because none is needed. function parsePregeneratedConcretePaths(value: unknown): Array<[string, string[]]> | undefined { if (!Array.isArray(value)) return undefined; const result: Array<[string, string[]]> = []; diff --git a/packages/vinext/src/server/prerender-route-params.ts b/packages/vinext/src/server/prerender-route-params.ts index d2bdbfb94..fdbe1f8ee 100644 --- a/packages/vinext/src/server/prerender-route-params.ts +++ b/packages/vinext/src/server/prerender-route-params.ts @@ -145,7 +145,7 @@ export function prerenderRouteParamsPayloadMatchesRoute( return match?.kind === "exact"; } -function matchPrerenderRouteParamsPayload( +export function matchPrerenderRouteParamsPayload( payload: PrerenderRouteParamsPayload | null, routePattern: string, params: PrerenderRouteParams, diff --git a/tests/pregenerated-concrete-paths.test.ts b/tests/pregenerated-concrete-paths.test.ts index b07b67101..f89cffe6d 100644 --- a/tests/pregenerated-concrete-paths.test.ts +++ b/tests/pregenerated-concrete-paths.test.ts @@ -65,16 +65,6 @@ describe("pregenerated concrete paths", () => { expect(normalizePregeneratedPathname("/en/blog/hello%20world")).toBe("/en/blog/hello world"); }); - it("normalizes un-normalized pathnames recorded directly", () => { - addPregeneratedConcretePath("/:locale/blog/:slug", "/en/blog/hello%20world"); - addPregeneratedConcretePath("/:locale/blog/:slug", "//en/./blog/world"); - - expect([...getRenderedConcreteUrlPathsForRoute("/:locale/blog/:slug")!]).toEqual([ - "/en/blog/hello world", - "/en/blog/world", - ]); - }); - it("initializes from the Worker global concrete-path table", () => { globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = [ ["/:locale/blog/:slug", ["/en/blog/hello%20world"]], diff --git a/tests/prerender-route-params.test.ts b/tests/prerender-route-params.test.ts index 37833bada..f7ba6283d 100644 --- a/tests/prerender-route-params.test.ts +++ b/tests/prerender-route-params.test.ts @@ -218,3 +218,122 @@ describe("encodePrerenderRouteParams", () => { }); }); }); + +describe("matchPrerenderRouteParamsPayload", () => { + it("returns kind exact when payload has no fallbackParamNames", () => { + const payload: PrerenderRouteParamsPayload = { + routePattern: "/:locale/blog/:slug", + params: { locale: "en", slug: "hello%20world" }, + }; + + expect( + matchPrerenderRouteParamsPayload(payload, "/:locale/blog/:slug", { + locale: "en", + slug: "hello world", + }), + ).toEqual({ kind: "exact", params: { locale: "en", slug: "hello%20world" } }); + }); + + it("returns kind fallback-shell when payload has fallbackParamNames", () => { + const payload: PrerenderRouteParamsPayload = { + routePattern: "/:locale/blog/:slug", + params: { locale: "en", slug: "%5Bslug%5D" }, + fallbackParamNames: ["slug"], + }; + + expect( + matchPrerenderRouteParamsPayload(payload, "/:locale/blog/:slug", { + locale: "en", + slug: "[slug]", + }), + ).toEqual({ + fallbackParamNames: ["slug"], + kind: "fallback-shell", + params: { locale: "en", slug: "%5Bslug%5D" }, + }); + }); + + it("rejects fallback-shell payloads that name params outside the route pattern", () => { + const payload: PrerenderRouteParamsPayload = { + routePattern: "/:locale/blog/:slug", + params: { locale: "en", slug: "%5Bslug%5D" }, + fallbackParamNames: ["missing"], + }; + + expect( + matchPrerenderRouteParamsPayload(payload, "/:locale/blog/:slug", { + locale: "en", + slug: "[slug]", + }), + ).toBeNull(); + }); + + it("matches fallback-shell catch-all placeholders as route param arrays", () => { + const payload: PrerenderRouteParamsPayload = { + routePattern: "/:locale/docs/:slug+", + params: { locale: "fr", slug: ["%5B...slug%5D"] }, + fallbackParamNames: ["slug"], + }; + + expect( + matchPrerenderRouteParamsPayload(payload, "/:locale/docs/:slug+", { + locale: "fr", + slug: ["[...slug]"], + }), + ).toEqual({ + fallbackParamNames: ["slug"], + kind: "fallback-shell", + params: { locale: "fr", slug: ["%5B...slug%5D"] }, + }); + }); +}); + +describe("encodePrerenderRouteParams", () => { + it("encodes exact params without fallbackParamNames", () => { + const result = encodePrerenderRouteParams("/product/:id", { id: "abc" }); + expect(result).toEqual({ + routePattern: "/product/:id", + params: { id: "abc" }, + }); + }); + + it("encodes fallback-shell params with fallbackParamNames", () => { + const result = encodePrerenderRouteParams( + "/:locale/blog/:slug", + { locale: "en", slug: "[slug]" }, + ["slug"], + ); + expect(result).toEqual({ + fallbackParamNames: ["slug"], + routePattern: "/:locale/blog/:slug", + params: { locale: "en", slug: "%5Bslug%5D" }, + }); + }); + + it("returns null for static patterns with no dynamic params", () => { + expect(encodePrerenderRouteParams("/about", {})).toBeNull(); + }); + + it("percent-encodes param values", () => { + const result = encodePrerenderRouteParams("/:locale/blog/:slug", { + locale: "en", + slug: "hello world & more", + }); + expect(result).toEqual({ + routePattern: "/:locale/blog/:slug", + params: { locale: "en", slug: "hello%20world%20%26%20more" }, + }); + }); + + it("omits fallbackParamNames when empty array is passed", () => { + const result = encodePrerenderRouteParams( + "/:locale/blog/:slug", + { locale: "en", slug: "post" }, + [], + ); + expect(result).toEqual({ + routePattern: "/:locale/blog/:slug", + params: { locale: "en", slug: "post" }, + }); + }); +}); From 5b8b2c11ccb9f58694d94299a9319d26885dc18c Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Mon, 1 Jun 2026 23:01:00 +1000 Subject: [PATCH 3/7] feat(ppr): safely serve fallback shell cache entries --- packages/vinext/src/deploy.ts | 56 +++ packages/vinext/src/entries/app-rsc-entry.ts | 22 + packages/vinext/src/index.ts | 68 +-- packages/vinext/src/server/app-page-cache.ts | 104 ++++ .../vinext/src/server/app-page-dispatch.ts | 379 +++++++++++++-- packages/vinext/src/server/app-page-render.ts | 2 +- packages/vinext/src/server/app-rsc-handler.ts | 54 ++- packages/vinext/src/server/app-ssr-entry.ts | 105 +++- .../vinext/src/server/prerender-manifest.ts | 74 +++ packages/vinext/src/server/seed-cache.ts | 62 +-- packages/vinext/src/shims/cache-runtime.ts | 292 +++++------ packages/vinext/src/shims/headers.ts | 35 ++ tests/app-page-cache.test.ts | 75 +++ tests/app-page-dispatch.test.ts | 456 +++++++++++++++++- tests/deploy.test.ts | 122 +++++ tests/seed-cache.test.ts | 118 +++++ 16 files changed, 1748 insertions(+), 276 deletions(-) create mode 100644 packages/vinext/src/server/prerender-manifest.ts diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 85280dd24..70a28e7e4 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -32,6 +32,10 @@ import { runPrerender } from "./build/run-prerender.js"; import { loadDotenv } from "./config/dotenv.js"; import { loadNextConfig, resolveNextConfig } from "./config/next-config.js"; import { parsePositiveIntegerArg } from "./cli-args.js"; +import { + readPrerenderManifest, + buildPregeneratedConcretePathTable, +} from "./server/prerender-manifest.js"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -1473,6 +1477,54 @@ function runWranglerDeploy(root: string, options: Pick 0) { + const injection = + `${VINEXT_PREGEN_START}\n` + + `globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = ${JSON.stringify(table)};\n` + + `${VINEXT_PREGEN_END}\n`; + code = injection + code; + } + + fs.writeFileSync(workerEntry, code); +} + // ─── Main Entry ────────────────────────────────────────────────────────────── export async function deploy(options: DeployOptions): Promise { @@ -1600,6 +1652,10 @@ export async function deploy(options: DeployOptions): Promise { } await runPrerender({ root: info.root, concurrency: options.prerenderConcurrency }); } + + // Inject pregenerated concrete paths into the Worker bundle so the PPR + // fallback-shell guard is populated without calling seedMemoryCacheFromPrerender. + injectPregeneratedConcretePaths(root); } // Step 6b: TPR — pre-render hot pages into KV cache (experimental, opt-in) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 030c2fbc0..3db3dbae5 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -90,6 +90,10 @@ const appPrerenderStaticParamsPath = resolveEntryPath( import.meta.url, ); const seedCachePath = resolveEntryPath("../server/seed-cache.js", import.meta.url); +const pregeneratedConcretePathsPath = resolveEntryPath( + "../server/pregenerated-concrete-paths.js", + import.meta.url, +); const appHookWarningSuppressionPath = resolveEntryPath( "../server/app-hook-warning-suppression.js", import.meta.url, @@ -206,6 +210,7 @@ export function generateRscEntry( const reactMaxHeadersLength = config?.reactMaxHeadersLength ?? DEFAULT_REACT_MAX_HEADERS_LENGTH; const cacheMaxMemorySize = config?.cacheMaxMemorySize; const inlineCss = config?.inlineCss === true; + const cacheComponents = config?.cacheComponents === true; const i18nConfig = config?.i18n ?? null; const hasPagesDir = config?.hasPagesDir ?? false; const publicFiles = config?.publicFiles ?? []; @@ -349,9 +354,15 @@ __configureMemoryCacheHandler({ cacheMaxMemorySize: ${JSON.stringify(cacheMaxMem import { createAppPrerenderStaticParamsResolver as __createAppPrerenderStaticParamsResolver } from ${JSON.stringify(appPrerenderStaticParamsPath)}; import { ensureAppRouteModulesLoaded as __ensureRouteLoaded } from ${JSON.stringify(appRouteModuleLoaderPath)}; import { seedMemoryCacheFromPrerender as __seedMemoryCacheFromPrerender } from ${JSON.stringify(seedCachePath)}; +import { + getRenderedConcreteUrlPathsForRoute as __getRenderedConcreteUrlPathsForRoute, + initPregeneratedPathsFromGlobals as __initPregeneratedPathsFromGlobals, +} from ${JSON.stringify(pregeneratedConcretePathsPath)}; const __draftModeSecret = ${JSON.stringify(draftModeSecret)}; +__initPregeneratedPathsFromGlobals(); + // Note: cache entries are written with \`headers: undefined\`. Next.js stores // response headers (e.g. set-cookie from cookies().set() during render) in the // cache entry so they can be replayed on HIT. We don't do this because: @@ -541,6 +552,8 @@ const __reactMaxHeadersLength = ${JSON.stringify(reactMaxHeadersLength)}; // \`vinextConfig\` export). Empty string when unset. export const __assetPrefix = ${JSON.stringify(assetPrefix)}; export const __inlineCss = ${JSON.stringify(inlineCss)}; +export const getRenderedConcreteUrlPathsForRoute = __getRenderedConcreteUrlPathsForRoute; +const __cacheComponents = ${JSON.stringify(cacheComponents)}; export function seedMemoryCacheFromPrerender(serverDir) { return __seedMemoryCacheFromPrerender(serverDir, { @@ -592,6 +605,7 @@ export default __createAppRscHandler({ }, registerCacheAdapters: __registerConfiguredCacheAdapters, configHeaders: __configHeaders, + cacheComponents: __cacheComponents, configRedirects: __configRedirects, configRewrites: __configRewrites, draftModeSecret: __draftModeSecret, @@ -609,6 +623,10 @@ export default __createAppRscHandler({ middlewareContext, mountedSlotsHeader, params, + pprFallbackCacheShells, + pprFallbackShell, + renderedConcreteUrlPaths, + skipStaticParamsValidation, staticParamsValidationParams, rootParams, request, @@ -695,6 +713,10 @@ export default __createAppRscHandler({ middlewareContext, mountedSlotsHeader, params, + pprFallbackCacheShells, + pprFallbackShell, + renderedConcreteUrlPaths, + skipStaticParamsValidation, staticParamsValidationParams, rootParams, probeLayoutAt(li, layoutParamAccess) { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 055404c01..adfba0d65 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -164,7 +164,6 @@ import { createImportMetaUrlPlugin } from "./plugins/import-meta-url.js"; import { createRequireContextPlugin } from "./plugins/require-context.js"; import { hasMdxFiles } from "./utils/mdx-scan.js"; import { scanPublicFileRoutes } from "./utils/public-routes.js"; -import { getViteMajorVersion } from "./utils/vite-version.js"; import tsconfigPaths from "vite-tsconfig-paths"; import type { Options as VitePluginReactOptions } from "@vitejs/plugin-react"; import MagicString from "magic-string"; @@ -196,29 +195,6 @@ function isInsideDirectory(dir: string, filePath: string): boolean { return relativePath !== "" && !relativePath.startsWith("..") && !path.isAbsolute(relativePath); } -// Detect a module-level `"use server"` directive (a Server Actions module). -// Directives form the leading prologue of string-literal ExpressionStatements, -// so we only scan until the first non-directive statement. -function hasModuleLevelUseServerDirective(body: ReturnType["body"]): boolean { - for (const node of body) { - if (node.type !== "ExpressionStatement") break; - const directive = (node as ASTNode & { directive?: unknown }).directive; - const expression = (node as ASTNode & { expression?: { type?: string; value?: unknown } }) - .expression; - const value = - typeof directive === "string" - ? directive - : expression?.type === "Literal" - ? expression.value - : undefined; - if (value === "use server") return true; - // Keep scanning through other directives (e.g. "use strict"); a - // non-string-literal statement ends the directive prologue. - if (typeof value !== "string") break; - } - return false; -} - function hasServerOnlyMarkerImport(code: string): boolean { if (!code.includes("server-only")) return false; @@ -229,15 +205,6 @@ function hasServerOnlyMarkerImport(code: string): boolean { return false; } - // Server Actions modules (`"use server"`) live in the server/action layer, - // not the client layer. Next.js allows them to `import "server-only"` even - // though the client bundle holds references used to invoke the actions - // (see test/e2e/app-dir/actions/app/client/actions.js, which does exactly - // this). Treating them as client-reachable here is a false positive that - // breaks the entire client build, so exempt them while still rejecting - // genuine client modules below. - if (hasModuleLevelUseServerDirective(ast.body)) return false; - function walk(node: ASTNode | ASTNode[] | null | undefined): boolean { if (!node) return false; if (Array.isArray(node)) return node.some((child) => walk(child)); @@ -464,6 +431,40 @@ function loadTsconfigPathAliases( }; } +/** + * Detect Vite major version at runtime by resolving from cwd. + * The plugin may be installed in a workspace root with Vite 7 but used + * by a project that has Vite 8 — so we resolve from cwd, not from + * the plugin's own location. + */ +function getViteMajorVersion(): number { + try { + const require = createRequire(path.join(process.cwd(), "package.json")); + const vitePkg = require("vite/package.json"); + + const viteMajor = parseInt(vitePkg?.version, 10); + if (vitePkg?.name === "vite" && Number.isFinite(viteMajor)) { + return viteMajor; + } + + const bundledViteMajor = parseInt(vitePkg?.bundledVersions?.vite, 10); + if (Number.isFinite(bundledViteMajor)) { + return bundledViteMajor; + } + + // npm aliases like `vite: npm:@voidzero-dev/vite-plus-core@...` expose the + // aliased package.json, whose own version is not Vite's version. + console.warn( + `[vinext] Could not determine Vite major version from ${vitePkg?.name ?? "vite/package.json"}; assuming Vite 7`, + ); + return 7; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[vinext] Failed to resolve vite/package.json (${message}); assuming Vite 7`); + return 7; + } +} + /** * Read the vinext package version once at plugin load. Surfaced via * `process.env.__NEXT_VERSION` define so `window.next.version` lands a @@ -2527,6 +2528,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { reactMaxHeadersLength: nextConfig?.reactMaxHeadersLength, cacheMaxMemorySize: nextConfig?.cacheMaxMemorySize, inlineCss: nextConfig?.inlineCss, + cacheComponents: nextConfig?.cacheComponents, i18n: nextConfig?.i18n, hasPagesDir, publicFiles: scanPublicFileRoutes(root), diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index b75953c26..4ad5b8354 100644 --- a/packages/vinext/src/server/app-page-cache.ts +++ b/packages/vinext/src/server/app-page-cache.ts @@ -72,6 +72,13 @@ type AppPageCacheRenderResult = { tags: string[]; }; +type AppPageFallbackShellCacheRenderResult = { + cacheControl?: CacheControlMetadata; + html: string; + htmlRenderObservation?: RenderObservation; + tags: string[]; +}; + type BuildAppPageCachedResponseOptions = { cacheControl?: CacheControlMetadata; cacheState: "HIT" | "STALE"; @@ -107,6 +114,23 @@ type ReadAppPageCacheResponseOptions = { scheduleBackgroundRegeneration: AppPageBackgroundRegenerator; }; +type ReadAppPageFallbackShellCacheResponseOptions = { + clearRequestContext: () => void; + expireSeconds?: number; + fallbackPathname: string; + isEdgeRuntime?: boolean; + isrDebug?: AppPageDebugLogger; + isrGet: AppPageCacheGetter; + isrHtmlKey: (pathname: string) => string; + isrSet: AppPageCacheSetter; + middlewareHeaders?: Headers | null; + middlewareStatus?: number | null; + revalidateSeconds: number; + renderFreshFallbackShellForCache: () => Promise; + rewriteHtml: (html: string) => string; + scheduleBackgroundRegeneration: AppPageBackgroundRegenerator; +}; + type FinalizeAppPageHtmlCacheResponseOptions = { capturedDynamicUsageBeforeContextCleanup?: () => boolean; capturedRscDataPromise: Promise | null; @@ -528,6 +552,86 @@ export async function readAppPageCacheResponse( return null; } +function scheduleAppPageFallbackShellRegeneration( + isrKey: string, + options: ReadAppPageFallbackShellCacheResponseOptions, +): void { + options.scheduleBackgroundRegeneration(isrKey, async () => { + const revalidatedShell = await options.renderFreshFallbackShellForCache(); + const revalidateSeconds = + revalidatedShell.cacheControl?.revalidate ?? options.revalidateSeconds; + const expireSeconds = revalidatedShell.cacheControl?.expire ?? options.expireSeconds; + await options.isrSet( + isrKey, + buildAppPageCacheValue( + revalidatedShell.html, + undefined, + 200, + revalidatedShell.htmlRenderObservation, + ), + revalidateSeconds, + revalidatedShell.tags, + expireSeconds, + ); + options.isrDebug?.("regen complete (fallback shell)", options.fallbackPathname); + }); +} + +export async function readAppPageFallbackShellCacheResponse( + options: ReadAppPageFallbackShellCacheResponseOptions, +): Promise { + const isrKey = options.isrHtmlKey(options.fallbackPathname); + + try { + const cached = await options.isrGet(isrKey); + const cachedValue = getCachedAppPageValue(cached); + if (!cachedValue) { + options.isrDebug?.("MISS (fallback shell)", options.fallbackPathname); + return null; + } + + if (typeof cachedValue.html !== "string" || cachedValue.html.length === 0) { + if (cached?.isStale) { + scheduleAppPageFallbackShellRegeneration(isrKey, options); + } + options.isrDebug?.("MISS (empty fallback shell)", options.fallbackPathname); + return null; + } + + const cacheState = cached?.isStale ? "STALE" : "HIT"; + if (cached?.isStale) { + scheduleAppPageFallbackShellRegeneration(isrKey, options); + } + + const response = buildAppPageCachedResponse( + { + ...cachedValue, + html: options.rewriteHtml(cachedValue.html), + }, + { + cacheState, + cacheControl: cached?.value.cacheControl, + expireSeconds: options.expireSeconds, + isEdgeRuntime: options.isEdgeRuntime, + isRscRequest: false, + middlewareHeaders: options.middlewareHeaders, + middlewareStatus: options.middlewareStatus, + revalidateSeconds: options.revalidateSeconds, + }, + ); + + if (!response) return null; + + options.isrDebug?.(`${cacheState} (fallback shell)`, options.fallbackPathname); + options.clearRequestContext(); + return response; + } catch (isrReadError) { + options.isrDebug?.("MISS (fallback shell read error)", options.fallbackPathname); + console.error("[vinext] ISR fallback shell cache read error:", isrReadError); + return null; + } +} + export function finalizeAppPageHtmlCacheResponse( response: Response, options: FinalizeAppPageHtmlCacheResponseOptions, diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 8d3dd3e45..1628ac760 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -21,6 +21,15 @@ import { } from "vinext/shims/headers"; import { getRequestExecutionContext } from "vinext/shims/request-context"; import { createRequestContext, runWithRequestContext } from "vinext/shims/unified-request-context"; +import { + createPprFallbackShellState, + getPprFallbackShellState, + isPprFallbackShellAbortError, + preparePprFallbackShellFinalRender, + runWithPprFallbackShellState, + waitForPprFallbackShellCacheReady, + type PprFallbackShellState, +} from "vinext/shims/ppr-fallback-shell"; import { ensureFetchPatch, consumeDynamicFetchObservations, @@ -32,7 +41,11 @@ import { setCurrentFetchSoftTags, } from "vinext/shims/fetch-cache"; import { AppElementsWire, type AppOutgoingElements } from "./app-elements.js"; -import { readAppPageCacheResponse } from "./app-page-cache.js"; +import { + readAppPageCacheResponse, + readAppPageFallbackShellCacheResponse, +} from "./app-page-cache.js"; +import { rewriteAppPprFallbackShellHtmlNavigation } from "./app-ppr-fallback-shell.js"; import { resolveAppPageParentHttpAccessBoundary, resolveAppPageParentHttpAccessBoundaryModule, @@ -40,6 +53,7 @@ import { import { readStreamAsText } from "../utils/text-stream.js"; import { buildAppPageSpecialErrorResponse, + readAppPageBinaryStream, resolveAppPageSpecialError, teeAppPageRscStreamForCapture, type AppPageFontPreload, @@ -170,6 +184,12 @@ type AppPageDispatchRoute = { unauthorizeds?: readonly (AppPageModule | null | undefined)[]; }; +type AppPagePprFallbackCacheShell = { + fallbackParamNames: readonly string[]; + params: AppPageParams; + pathname: string; +}; + function resolveAppPageRouteBoundaryModule( route: AppPageDispatchRoute, statusCode: number, @@ -252,6 +272,20 @@ type DispatchAppPageOptions = { middlewareContext: AppPageMiddlewareContext; mountedSlotsHeader?: string | null; params: AppPageParams; + pprFallbackCacheShells?: readonly AppPagePprFallbackCacheShell[] | null; + pprFallbackShell?: { + fallbackParamNames: readonly string[]; + routePattern: string; + }; + /** + * Set of concrete URL paths that were pre-rendered at build time for this + * route. When the exact cache entry for a known pregenerated path is absent + * (evicted, stale-empty, cold start, read error), the fallback shell must + * NOT be served — the route is a valid generated route whose cache merely + * has a transient gap. Falls through to a fresh render instead. + */ + renderedConcreteUrlPaths?: ReadonlySet; + skipStaticParamsValidation?: boolean; staticParamsValidationParams?: AppPageParams; rootParams?: RootParams; probeLayoutAt: (layoutIndex: number, layoutParamAccess?: AppLayoutParamAccessTracker) => unknown; @@ -406,7 +440,6 @@ function buildAppPageTags( async function runAppPageRevalidationContext< TResult extends { html: string; - rscData: ArrayBuffer; tags: string[]; }, >( @@ -474,10 +507,198 @@ function toInterceptOptions( }; } +async function warmPprFallbackShellCaches(options: { + element: AppPageRenderableElement; + onError: AppPageBoundaryOnError; + renderToReadableStream: DispatchAppPageOptions["renderToReadableStream"]; + state: PprFallbackShellState; +}): Promise { + let warmupError: unknown = null; + const warmupStream = options.renderToReadableStream(options.element, { + onError(error, requestInfo, errorContext) { + if (options.state.abortController.signal.aborted || isPprFallbackShellAbortError(error)) { + return undefined; + } + + return options.onError(error, requestInfo, errorContext); + }, + }); + const warmupDrain = readAppPageBinaryStream(warmupStream).catch((error: unknown) => { + if (options.state.abortController.signal.aborted || isPprFallbackShellAbortError(error)) { + return; + } + warmupError = error; + }); + + try { + await waitForPprFallbackShellCacheReady(options.state); + } finally { + options.state.abortController.abort(); + await warmupDrain; + preparePprFallbackShellFinalRender(options.state); + } + + if (warmupError) { + throw warmupError; + } +} + +async function renderFreshPprFallbackShellForCache( + options: DispatchAppPageOptions, + fallbackShell: AppPagePprFallbackCacheShell, +) { + const fallbackSearchParams = new URLSearchParams(); + return runAppPageRevalidationContext( + { + cleanPathname: fallbackShell.pathname, + currentFetchCacheMode: + options.resolveRouteFetchCacheMode?.(options.route) ?? options.fetchCache ?? null, + draftModeSecret: options.draftModeSecret, + dynamicConfig: options.dynamicConfig, + params: fallbackShell.params, + routePattern: options.route.pattern, + routeSegments: options.route.routeSegments, + setNavigationContext: options.setNavigationContext, + }, + async () => { + const fallbackShellState = createPprFallbackShellState({ + fallbackParamNames: fallbackShell.fallbackParamNames, + routePattern: options.route.pattern, + }); + + return await runWithPprFallbackShellState(fallbackShellState, async () => { + try { + const onError = options.createRscOnErrorHandler( + fallbackShell.pathname, + options.route.pattern, + ); + const warmupElement = await options.buildPageElement( + options.route, + fallbackShell.params, + undefined, + fallbackSearchParams, + ); + await warmPprFallbackShellCaches({ + element: warmupElement, + onError, + renderToReadableStream: options.renderToReadableStream, + state: fallbackShellState, + }); + _consumeRequestScopedCacheLife(); + consumeDynamicFetchObservations(); + consumeRenderRequestApiUsage(); + consumeInvalidDynamicUsageError(); + consumeDynamicUsage(); + + options.setNavigationContext({ + pathname: fallbackShell.pathname, + searchParams: fallbackSearchParams, + params: fallbackShell.params, + }); + const finalElement = await options.buildPageElement( + options.route, + fallbackShell.params, + undefined, + fallbackSearchParams, + ); + const finalRscStream = options.renderToReadableStream(finalElement, { + onError, + }); + const finalRscCapture = teeAppPageRscStreamForCapture(finalRscStream, true); + const capturedRscDataRef: { value: Promise | null } = { + value: null, + }; + const ssrHandler = await options.loadSsrHandler(); + const htmlStream = await ssrHandler.handleSsr( + finalRscCapture.ssrStream, + options.getNavigationContext(), + { + links: options.getFontLinks(), + styles: options.getFontStyles(), + preloads: options.getFontPreloads(), + }, + { + basePath: options.basePath, + clientTraceMetadata: options.clientTraceMetadata, + rootParams: options.rootParams, + ...(finalRscCapture.sideStream + ? { + sideStream: finalRscCapture.sideStream, + capturedRscDataRef, + } + : {}), + }, + ); + const htmlStreamNormalized = isAppSsrRenderResult(htmlStream) + ? htmlStream.htmlStream + : htmlStream; + const html = await readStreamAsText(htmlStreamNormalized); + try { + await capturedRscDataRef.value; + } catch { + // HTML rendering owns the user-visible error path. The fallback-shell + // regeneration only writes HTML, but observing the capture promise + // prevents a secondary unhandled rejection from the tee side stream. + } + const cacheLife = _consumeRequestScopedCacheLife(); + const tags = buildAppPageTags( + fallbackShell.pathname, + getCollectedFetchTags(), + options.route.routeSegments, + ); + const observationState = { + dynamicFetches: consumeDynamicFetchObservations(), + requestApis: consumeRenderRequestApiUsage(), + }; + consumeInvalidDynamicUsageError(); + consumeDynamicUsage(); + + return { + html, + htmlRenderObservation: createAppPageRenderObservation({ + boundaryOutcome: { kind: "success" }, + cacheability: "public", + cacheTags: tags, + cleanPathname: fallbackShell.pathname, + completeness: "complete", + output: createAppPageHtmlOutputScope({ + element: finalElement, + renderEpoch: null, + rootBoundaryId: null, + routePattern: options.route.pattern, + }), + params: fallbackShell.params, + state: observationState, + }), + tags, + cacheControl: + typeof cacheLife?.revalidate === "number" + ? { revalidate: cacheLife.revalidate, expire: cacheLife.expire } + : undefined, + }; + } finally { + _consumeRequestScopedCacheLife(); + consumeDynamicFetchObservations(); + consumeRenderRequestApiUsage(); + consumeInvalidDynamicUsageError(); + consumeDynamicUsage(); + options.clearRequestContext(); + } + }); + }, + ); +} + export async function dispatchAppPage( options: DispatchAppPageOptions, ): Promise { - return await runWithFetchDedupe(() => dispatchAppPageInner(options)); + const dispatch = () => runWithFetchDedupe(() => dispatchAppPageInner(options)); + if (!options.pprFallbackShell) { + return await dispatch(); + } + + const fallbackShellState = createPprFallbackShellState(options.pprFallbackShell); + return await runWithPprFallbackShellState(fallbackShellState, dispatch); } async function dispatchAppPageInner( @@ -711,15 +932,85 @@ async function dispatchAppPageInner( } } - const dynamicParamsResponse = await validateAppPageDynamicParams({ - clearRequestContext: options.clearRequestContext, - enforceStaticParamsOnly: options.dynamicParamsConfig === false, - generateStaticParams: options.generateStaticParams, - isDynamicRoute: route.isDynamic, - params: options.staticParamsValidationParams ?? options.params, - }); - if (dynamicParamsResponse) { - return dynamicParamsResponse; + if (options.skipStaticParamsValidation !== true) { + const dynamicParamsResponse = await validateAppPageDynamicParams({ + clearRequestContext: options.clearRequestContext, + enforceStaticParamsOnly: options.dynamicParamsConfig === false, + generateStaticParams: options.generateStaticParams, + isDynamicRoute: route.isDynamic, + params: options.staticParamsValidationParams ?? options.params, + }); + if (dynamicParamsResponse) { + return dynamicParamsResponse; + } + } + + // Before probing fallback shells, check whether this exact URL was a + // pre-rendered concrete route at build time. If so, the exact cache entry + // was seeded at startup and its current absence is a transient condition + // (eviction, cold start, stale-empty, read error) — not a semantic signal + // that this is an unknown dynamic route. Serving the fallback shell would + // silently degrade a known pregenerated route to unknown-param content. + // + // Without this guard, a cache miss on `/en/blog/known-post` with a + // pre-existing fallback shell `/en/blog/[slug]` would serve the shell + // instead of regenerating the exact known page. The fallback shell must + // only be used for truly unknown child params, not for cache misses on + // known generated paths. + const isKnownPregeneratedRoute = + options.renderedConcreteUrlPaths?.has(options.cleanPathname) === true; + if ( + !isKnownPregeneratedRoute && + options.pprFallbackCacheShells && + options.pprFallbackCacheShells.length > 0 && + !options.isRscRequest && + options.request.method === "GET" && + shouldReadAppPageCache({ + isDraftMode, + isForceDynamic, + isProgressiveActionRender: options.isProgressiveActionRender === true, + isProduction: options.isProduction, + isRscRequest: false, + revalidateSeconds: currentRevalidateSeconds, + scriptNonce: options.scriptNonce, + }) + ) { + for (const fallbackShell of options.pprFallbackCacheShells) { + const fallbackShellResponse = await readAppPageFallbackShellCacheResponse({ + clearRequestContext: options.clearRequestContext, + expireSeconds: options.expireSeconds, + fallbackPathname: fallbackShell.pathname, + isEdgeRuntime: options.isEdgeRuntime, + isrDebug: options.isrDebug, + isrGet: options.isrGet, + isrHtmlKey: options.isrHtmlKey, + isrSet: options.isrSet, + middlewareHeaders: options.middlewareContext.headers, + middlewareStatus: options.middlewareContext.status, + revalidateSeconds: currentRevalidateSeconds ?? 0, + renderFreshFallbackShellForCache() { + return renderFreshPprFallbackShellForCache(options, fallbackShell); + }, + rewriteHtml(html) { + return rewriteAppPprFallbackShellHtmlNavigation({ + html, + params: options.params, + pathname: options.cleanPathname, + searchParams: options.searchParams, + }); + }, + scheduleBackgroundRegeneration(key, renderFn) { + options.scheduleBackgroundRegeneration(key, renderFn, { + routerKind: "App Router", + routePath: route.pattern, + routeType: "render", + }); + }, + }); + if (fallbackShellResponse) { + return fallbackShellResponse; + } + } } const interceptResult = await resolveAppPageIntercept< @@ -790,27 +1081,49 @@ async function dispatchAppPageInner( return interceptResult.response; } - const pageBuildResult = await buildAppPageElement({ - buildPageElement() { - if (options.actionFailed) { - throw options.actionError; - } - return options.buildPageElement( - route, - options.params, - interceptResult.interceptOpts, - options.searchParams, - layoutParamAccess, - ); - }, - renderErrorBoundaryPage(buildError) { - return options.renderErrorBoundaryPage(buildError); - }, - renderSpecialError(specialError) { - return renderPageSpecialError(options, specialError); - }, - resolveSpecialError: resolveAppPageSpecialError, - }); + const buildCurrentPageElement = () => + buildAppPageElement({ + buildPageElement() { + if (options.actionFailed) { + throw options.actionError; + } + return options.buildPageElement( + route, + options.params, + interceptResult.interceptOpts, + options.searchParams, + layoutParamAccess, + ); + }, + renderErrorBoundaryPage(buildError) { + return options.renderErrorBoundaryPage(buildError); + }, + renderSpecialError(specialError) { + return renderPageSpecialError(options, specialError); + }, + resolveSpecialError: resolveAppPageSpecialError, + }); + + const fallbackShellState = getPprFallbackShellState(); + if (fallbackShellState && process.env.VINEXT_PRERENDER === "1" && !options.isRscRequest) { + const warmupBuildResult = await buildCurrentPageElement(); + if (warmupBuildResult.response) { + return warmupBuildResult.response; + } + await warmPprFallbackShellCaches({ + element: warmupBuildResult.element, + onError: options.createRscOnErrorHandler(options.cleanPathname, route.pattern), + renderToReadableStream: options.renderToReadableStream, + state: fallbackShellState, + }); + _consumeRequestScopedCacheLife(); + consumeDynamicFetchObservations(); + consumeRenderRequestApiUsage(); + consumeInvalidDynamicUsageError(); + consumeDynamicUsage(); + } + + const pageBuildResult = await buildCurrentPageElement(); if (pageBuildResult.response) { return pageBuildResult.response; } diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 0500d6e6b..6b59e0554 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -838,7 +838,7 @@ export async function renderAppPageLifecycle( scriptNonce: options.scriptNonce, sideStream: rscCapture.sideStream, ssrHandler, - waitForAllReady: options.isPrerender, + waitForAllReady: options.isPrerender === true, }); }, renderSpecialErrorResponse(specialError) { diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 2936ebfbb..d94001456 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -47,6 +47,7 @@ import { finalizeAppRscResponse } from "./app-rsc-response-finalizer.js"; import { normalizeRscRequest } from "./app-rsc-request-normalization.js"; import { normalizeDefaultLocalePathname } from "./pages-i18n.js"; import { notFoundResponse } from "./http-error-responses.js"; +import { getRenderedConcreteUrlPathsForRoute } from "./pregenerated-concrete-paths.js"; import { getScriptNonceFromHeaderSources } from "./csp.js"; import { buildPageCacheTags } from "./implicit-tags.js"; import { isImageOptimizationPath } from "./image-optimization.js"; @@ -66,10 +67,11 @@ import { validateImageUrl, } from "./request-pipeline.js"; import { - prerenderRouteParamsPayloadMatchesRoute, + matchPrerenderRouteParamsPayload, readTrustedPrerenderRouteParams, serializePrerenderRouteParamsHeader, } from "./prerender-route-params.js"; +import { createAppPprFallbackShells } from "./app-ppr-fallback-shell.js"; type AppPageParams = Record; type RequestContext = ReturnType; @@ -84,6 +86,7 @@ type AppRscMiddlewareContext = AppMiddlewareContext; type AppRscHandlerRoute = { isDynamic: boolean; + params?: readonly string[]; page?: unknown; pattern: string; rootParamNames?: readonly string[]; @@ -128,6 +131,19 @@ type DispatchMatchedPageOptions = { middlewareContext: AppRscMiddlewareContext; mountedSlotsHeader: string | null; params: AppPageParams; + pprFallbackCacheShells?: + | readonly { + fallbackParamNames: readonly string[]; + params: AppPageParams; + pathname: string; + }[] + | null; + pprFallbackShell?: { + fallbackParamNames: readonly string[]; + routePattern: string; + }; + renderedConcreteUrlPaths?: ReadonlySet; + skipStaticParamsValidation?: boolean; staticParamsValidationParams?: AppPageParams; rootParams?: RootParams; request: Request; @@ -221,6 +237,7 @@ type NavigationContextValue = { type CreateAppRscHandlerOptions = { basePath: string; + cacheComponents?: boolean; clearRequestContext: () => void; configHeaders: NextHeader[]; configRedirects: NextRedirect[]; @@ -717,14 +734,29 @@ async function handleAppRscRequest( // branch and any downstream synchronous module reads. if (options.ensureRouteLoaded) await options.ensureRouteLoaded(route); const prerenderRouteParamsPayload = readTrustedPrerenderRouteParams(request); - const prerenderRouteParams = prerenderRouteParamsPayloadMatchesRoute( + const prerenderRouteParamsMatch = matchPrerenderRouteParamsPayload( prerenderRouteParamsPayload, route.pattern, params, - ) - ? prerenderRouteParamsPayload.params - : null; + ); + const prerenderRouteParams = prerenderRouteParamsMatch?.params ?? null; + const isPrerenderFallbackShell = prerenderRouteParamsMatch?.kind === "fallback-shell"; const renderParams = prerenderRouteParams ?? params; + const runtimeFallbackShells = + options.cacheComponents === true && + request.method === "GET" && + !isRscRequest && + !isPrerenderFallbackShell && + route.params + ? createAppPprFallbackShells( + { + params: route.params, + pattern: route.pattern, + rootParamNames: route.rootParamNames, + }, + params, + ) + : []; options.setNavigationContext({ pathname: canonicalPathname, searchParams: url.searchParams, @@ -765,7 +797,17 @@ async function handleAppRscRequest( middlewareContext, mountedSlotsHeader, params: renderParams, - staticParamsValidationParams: prerenderRouteParams === null ? undefined : params, + pprFallbackCacheShells: runtimeFallbackShells, + pprFallbackShell: isPrerenderFallbackShell + ? { + fallbackParamNames: prerenderRouteParamsMatch.fallbackParamNames, + routePattern: route.pattern, + } + : undefined, + renderedConcreteUrlPaths: getRenderedConcreteUrlPathsForRoute(route.pattern), + skipStaticParamsValidation: isPrerenderFallbackShell, + staticParamsValidationParams: + prerenderRouteParams === null || isPrerenderFallbackShell ? undefined : params, rootParams, request, route, diff --git a/packages/vinext/src/server/app-ssr-entry.ts b/packages/vinext/src/server/app-ssr-entry.ts index 5a7b7f874..c8fea02a9 100644 --- a/packages/vinext/src/server/app-ssr-entry.ts +++ b/packages/vinext/src/server/app-ssr-entry.ts @@ -48,6 +48,10 @@ import { BfcacheStateKeyMapContext, ElementsContext, Slot } from "vinext/shims/s import { AppRouterContext } from "vinext/shims/internal/app-router-context"; import { createClientReferencePreloader } from "./app-client-reference-preloader.js"; import { RSC_FORM_STATE_GLOBAL } from "./app-browser-hydration.js"; +import { + getPprFallbackShellState, + isPprFallbackShellAbortError, +} from "vinext/shims/ppr-fallback-shell"; /** * `@types/react-dom` does not yet type `maxHeadersLength` (it pairs with the @@ -77,6 +81,51 @@ export type FontData = { preloads?: FontPreload[]; }; +type StaticPrerender = typeof import("react-dom/static.edge").prerender; + +function isReactDevelopmentRuntime(): boolean { + return Function.prototype.toString.call(createReactElement).includes("getOwner"); +} + +function isStaticPrerenderModule(value: unknown): value is { prerender: StaticPrerender } { + return ( + typeof value === "object" && + value !== null && + "prerender" in value && + typeof value.prerender === "function" + ); +} + +async function loadStaticPrerender(): Promise { + if (isReactDevelopmentRuntime()) { + try { + const [{ createRequire }, path] = await Promise.all([ + import("node:module"), + import("node:path"), + ]); + const require = createRequire(import.meta.url); + const reactDomPackageJson = require.resolve("react-dom/package.json"); + const reactDomDir = path.dirname(reactDomPackageJson); + const devRendererPath = path.join(reactDomDir, "cjs/react-dom-server.edge.development.js"); + const devRenderer: unknown = await import(devRendererPath); + if (isStaticPrerenderModule(devRenderer)) { + return devRenderer.prerender; + } + throw new Error("react-dom development renderer did not expose prerender()."); + } catch (error) { + throw new Error("[vinext] Failed to load React static development renderer.", { + cause: error, + }); + } + } + + const staticRenderer: unknown = await import("react-dom/static.edge"); + if (!isStaticPrerenderModule(staticRenderer)) { + throw new Error("[vinext] react-dom/static.edge did not expose prerender()."); + } + return staticRenderer.prerender; +} + const clientReferencePreloader = createClientReferencePreloader({ getReferences() { return clientReferences; @@ -445,6 +494,7 @@ export async function handleSsr( basePath: options?.basePath, }); + const fallbackShellState = getPprFallbackShellState(); // React emits a preload `Link` header (capped to `maxHeadersLength`) // via `onHeaders`. It fires before the shell resolves, so `linkHeader` // is populated by the time `renderToReadableStream` resolves below. @@ -456,16 +506,7 @@ export async function handleSsr( const maxHeadersLength = options?.reactMaxHeadersLength ?? DEFAULT_REACT_MAX_HEADERS_LENGTH; const captureHeaders = maxHeadersLength > 0; - const ssrRenderOptions: SsrRenderOptions = { - onHeaders: captureHeaders - ? (headers: Headers) => { - const link = headers.get("Link"); - if (link) { - reactLinkHeader = link; - } - } - : undefined, - maxHeadersLength: captureHeaders ? maxHeadersLength : undefined, + const renderOptions: SsrRenderOptions = { // `bootstrapScriptContent` was previously how vinext injected the // dynamic-import call. `bootstrapModules` performs the same work // natively (and exposes the URL in the DOM), so passing both would @@ -484,7 +525,20 @@ export async function handleSsr( bootstrapModules: bootstrapModuleUrl ? [bootstrapModuleUrl] : undefined, formState: options?.formState ?? null, nonce: options?.scriptNonce, - onError(error) { + onHeaders: captureHeaders + ? (headers: Headers) => { + const link = headers.get("Link"); + if (link) { + reactLinkHeader = link; + } + } + : undefined, + maxHeadersLength: captureHeaders ? maxHeadersLength : undefined, + onError(error: unknown) { + if (fallbackShellState && isPprFallbackShellAbortError(error)) { + return undefined; + } + errorMetaRenderer.capture(error); if (error && typeof error === "object" && "digest" in error) { @@ -501,7 +555,30 @@ export async function handleSsr( }, }; - const htmlStream = await renderToReadableStream(ssrRoot, ssrRenderOptions); + let htmlStream: ReadableStream; + if (fallbackShellState) { + const prerender = await loadStaticPrerender(); + htmlStream = ( + await prerender(ssrRoot, { + ...renderOptions, + signal: fallbackShellState.abortController.signal, + }) + ).prelude; + } else { + const streamingHtmlStream = await renderToReadableStream(ssrRoot, { + ...renderOptions, + }); + + // When producing static output (prerender / ISR cache writes), wait for + // the full React tree to resolve before emitting bytes. This prevents + // Suspense fallback content from being serialized to the cache. + // Matches Next.js waitForAllReady forkpoint in renderToNodeFizzStream. + if (options?.waitForAllReady === true) { + await streamingHtmlStream.allReady; + } + + htmlStream = streamingHtmlStream; + } // Populated before any SSR request runs: at prod-server startup // (prod-server.ts) or via build-time bundle injection (index.ts). Left @@ -560,10 +637,6 @@ export async function handleSsr( const getBeforeInteractiveHeadHTML = (): string => renderBeforeInteractiveInlineScripts(beforeInteractiveInlineScripts); - if (options?.waitForAllReady === true) { - await htmlStream.allReady; - } - const finalStream = deferUntilStreamConsumed( htmlStream.pipeThrough( createTickBufferedTransform( diff --git a/packages/vinext/src/server/prerender-manifest.ts b/packages/vinext/src/server/prerender-manifest.ts new file mode 100644 index 000000000..1f8954f03 --- /dev/null +++ b/packages/vinext/src/server/prerender-manifest.ts @@ -0,0 +1,74 @@ +import fs from "node:fs"; + +type PrerenderManifestRoute = { + route: string; + status?: string; + revalidate?: number | false; + expire?: number; + path?: string; + router?: string; +}; + +type PrerenderManifest = { + buildId?: string; + trailingSlash?: boolean; + routes?: PrerenderManifestRoute[]; +}; + +export function readPrerenderManifest(manifestPath: string): PrerenderManifest | null { + if (!fs.existsSync(manifestPath)) return null; + try { + return JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + } catch { + return null; + } +} + +export function getRenderedAppRoutes(routes: PrerenderManifestRoute[]): PrerenderManifestRoute[] { + return routes.filter((r) => r.status === "rendered" && r.router === "app"); +} + +function groupRoutesByPattern(routes: PrerenderManifestRoute[]): Map { + const byPattern = new Map(); + for (const r of routes) { + const pathname = r.path ?? r.route; + const existing = byPattern.get(r.route); + if (existing) { + existing.push(pathname); + } else { + byPattern.set(r.route, [pathname]); + } + } + return byPattern; +} + +/** + * Returns true when `pathname` contains bracket-delimited route params, + * indicating it is a fallback-shell placeholder (e.g. `/en/blog/[slug]`) + * rather than a concrete rendered URL. + */ +export function isFallbackShellArtifactPath(pathname: string): boolean { + return pathname.includes("[") || pathname.includes("]"); +} + +/** + * Build the pregenerated concrete-path payload table from a prerender manifest. + * + * Filters out fallback-shell placeholder paths and groups remaining concrete + * paths by route pattern. Returns an empty array when the manifest has no + * rendered App routes or all routes are fallback-shell artifacts. + */ +export function buildPregeneratedConcretePathTable( + manifest: PrerenderManifest, +): Array<[string, string[]]> { + const routes = manifest?.routes; + if (!routes?.length) return []; + + const appRoutes = getRenderedAppRoutes(routes); + const concreteRoutes = appRoutes.filter((r) => { + const pathname = r.path ?? r.route; + return !isFallbackShellArtifactPath(pathname); + }); + + return Array.from(groupRoutesByPattern(concreteRoutes).entries()); +} diff --git a/packages/vinext/src/server/seed-cache.ts b/packages/vinext/src/server/seed-cache.ts index 2230c579d..e71a5faa6 100644 --- a/packages/vinext/src/server/seed-cache.ts +++ b/packages/vinext/src/server/seed-cache.ts @@ -35,25 +35,16 @@ import type { CachedAppPageValue } from "vinext/shims/cache"; import { isrCacheKey, isrSetPrerenderedAppPage } from "./isr-cache.js"; import { buildAppPageCacheTags } from "./app-page-cache.js"; import { getOutputPath, getRscOutputPath } from "../utils/prerender-output-paths.js"; -import { normalizePathnameForRouteMatch } from "../routing/utils.js"; -import { normalizePath } from "./normalize-path.js"; - -// ─── Manifest types ─────────────────────────────────────────────────────────── - -type PrerenderManifest = { - buildId: string; - trailingSlash?: boolean; - routes: PrerenderManifestRoute[]; -}; - -type PrerenderManifestRoute = { - route: string; - status: string; - revalidate?: number | false; - expire?: number; - path?: string; - router?: "app" | "pages"; -}; +import { + addPregeneratedConcretePath, + clearPregeneratedConcretePaths, + normalizePregeneratedPathname, +} from "./pregenerated-concrete-paths.js"; +import { + readPrerenderManifest, + getRenderedAppRoutes, + isFallbackShellArtifactPath, +} from "./prerender-manifest.js"; type PrerenderCacheSeedMetadata = { expireSeconds?: number; @@ -90,16 +81,15 @@ export async function seedMemoryCacheFromPrerender( serverDir: string, options?: PrerenderCacheSeedOptions, ): Promise { + // Clear any pre-existing concrete paths from a previous build BEFORE checking + // whether the manifest exists. This ensures that a missing or corrupt manifest + // in a new build still fails closed to an empty set — the stale paths from a + // previous build are never visible to the new server process. + clearPregeneratedConcretePaths(); + const manifestPath = path.join(serverDir, "vinext-prerender.json"); - if (!fs.existsSync(manifestPath)) return 0; - - let manifest: PrerenderManifest; - try { - manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); - } catch (err) { - console.warn("[vinext] Failed to parse vinext-prerender.json, skipping cache seeding:", err); - return 0; - } + const manifest = readPrerenderManifest(manifestPath); + if (!manifest) return 0; const { buildId, routes } = manifest; if (!buildId || !Array.isArray(routes)) return 0; @@ -109,12 +99,16 @@ export async function seedMemoryCacheFromPrerender( const writeAppPageEntry = options?.writeAppPageEntry ?? createDefaultAppPageEntryWriter(); let seeded = 0; - for (const route of routes) { - if (route.status !== "rendered") continue; - if (route.router !== "app") continue; + const appRoutes = getRenderedAppRoutes(routes); + + for (const route of appRoutes) { + const concretePathname = route.path ?? route.route; + if (!isFallbackShellArtifactPath(concretePathname)) { + addPregeneratedConcretePath(route.route, normalizePregeneratedPathname(concretePathname)); + } const artifactPathname = route.path ?? route.route; - const cachePathname = normalizePrerenderCachePathname(artifactPathname); + const cachePathname = normalizePregeneratedPathname(artifactPathname); // Fallback keys support older generated entries that do not export their // runtime key builders. Current App Router entries inject buildAppPage*Key // so seeded keys match process.env.__VINEXT_BUILD_ID exactly. @@ -159,10 +153,6 @@ export async function seedMemoryCacheFromPrerender( // ─── Internals ──────────────────────────────────────────────────────────────── -function normalizePrerenderCachePathname(pathname: string): string { - return normalizePath(normalizePathnameForRouteMatch(pathname)); -} - function createDefaultAppPageEntryWriter(): NonNullable< PrerenderCacheSeedOptions["writeAppPageEntry"] > { diff --git a/packages/vinext/src/shims/cache-runtime.ts b/packages/vinext/src/shims/cache-runtime.ts index 0afeff5cc..fbf0fc7fe 100644 --- a/packages/vinext/src/shims/cache-runtime.ts +++ b/packages/vinext/src/shims/cache-runtime.ts @@ -46,6 +46,7 @@ import { runWithUnifiedStateMutation, } from "./unified-request-context.js"; import { markDynamicUsage } from "./headers.js"; +import { trackPprFallbackShellCacheTask } from "./ppr-fallback-shell.js"; // --------------------------------------------------------------------------- // Constants for nested-dynamic cache life detection @@ -454,167 +455,168 @@ export function registerCachedFunction( // it's scoped to a single request and doesn't persist across HMR. const isDev = typeof process !== "undefined" && process.env.NODE_ENV === "development"; - const cachedFn = async (...args: TArgs): Promise => { - const rsc = await getRscModule(); - const keySeed = getUseCacheKeySeed(); - - // Build the cache key. Use encodeReply (RSC protocol) when available — - // it correctly handles React elements as temporary references (excluded - // from key). Falls back to stableStringify when RSC is unavailable. - let cacheKey: string; - try { - const processedArgs = - args.length > 0 - ? unwrapThenableObjectArray(args, { omitAppPageSearchParamsFromFirstArg }) - : []; - if (rsc && args.length > 0) { - // Temporary references let encodeReply handle non-serializable values - // (like React elements in args) by excluding them from the key. - const tempRefs = rsc.createClientTemporaryReferenceSet(); - // Unwrap Promise-augmented objects before encoding. - // Next.js 16 params/searchParams are created via - // Object.assign(Promise.resolve(obj), obj) — a Promise with own - // enumerable properties. encodeReply treats Promises as temporary - // references (excluded from the key), which means different param - // values (e.g., section:"sports" vs section:"electronics") produce - // identical cache keys. We must extract the plain data so the actual - // values are included in the cache key. - const encoded = await rsc.encodeReply(processedArgs, { - temporaryReferences: tempRefs, - }); - cacheKey = buildUseCacheKey(id, keySeed, await replyToCacheKey(encoded)); - } else { - const argsKey = processedArgs.length > 0 ? stableStringify(processedArgs) : undefined; - cacheKey = buildUseCacheKey(id, keySeed, argsKey); - } - } catch { - // Non-serializable arguments — run without caching - return fn(...args); - } + const cachedFn = (...args: TArgs): Promise => + trackPprFallbackShellCacheTask(async (): Promise => { + const rsc = await getRscModule(); + const keySeed = getUseCacheKeySeed(); - // "use cache: private" uses per-request in-memory cache - if (cacheVariant === "private") { - const parentCtx = cacheContextStorage.getStore(); - if (parentCtx && parentCtx.variant !== "private") { - throwPrivateUseCacheInsidePublicUseCacheError(); + // Build the cache key. Use encodeReply (RSC protocol) when available — + // it correctly handles React elements as temporary references (excluded + // from key). Falls back to stableStringify when RSC is unavailable. + let cacheKey: string; + try { + const processedArgs = + args.length > 0 + ? unwrapThenableObjectArray(args, { omitAppPageSearchParamsFromFirstArg }) + : []; + if (rsc && args.length > 0) { + // Temporary references let encodeReply handle non-serializable values + // (like React elements in args) by excluding them from the key. + const tempRefs = rsc.createClientTemporaryReferenceSet(); + // Unwrap Promise-augmented objects before encoding. + // Next.js 16 params/searchParams are created via + // Object.assign(Promise.resolve(obj), obj) — a Promise with own + // enumerable properties. encodeReply treats Promises as temporary + // references (excluded from the key), which means different param + // values (e.g., section:"sports" vs section:"electronics") produce + // identical cache keys. We must extract the plain data so the actual + // values are included in the cache key. + const encoded = await rsc.encodeReply(processedArgs, { + temporaryReferences: tempRefs, + }); + cacheKey = buildUseCacheKey(id, keySeed, await replyToCacheKey(encoded)); + } else { + const argsKey = processedArgs.length > 0 ? stableStringify(processedArgs) : undefined; + cacheKey = buildUseCacheKey(id, keySeed, argsKey); + } + } catch { + // Non-serializable arguments — run without caching + return fn(...args); } - if (typeof process !== "undefined" && process.env.VINEXT_PRERENDER === "1") { - // Next.js treats "use cache: private" as dynamic during prerendering: - // it is excluded from the static artifact and resolved per request. - // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-wrapper.ts - markDynamicUsage(); - } + // "use cache: private" uses per-request in-memory cache + if (cacheVariant === "private") { + const parentCtx = cacheContextStorage.getStore(); + if (parentCtx && parentCtx.variant !== "private") { + throwPrivateUseCacheInsidePublicUseCacheError(); + } - const privateCache = _getPrivateState()._privateCache!; - const privateHit = privateCache.get(cacheKey); - if (privateHit !== undefined) { - // The private cache is heterogeneous across cached functions; the key - // includes this function's stable id, so a hit belongs to this TResult. - return privateHit as TResult; - } + if (typeof process !== "undefined" && process.env.VINEXT_PRERENDER === "1") { + // Next.js treats "use cache: private" as dynamic during prerendering: + // it is excluded from the static artifact and resolved per request. + // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/use-cache/use-cache-wrapper.ts + markDynamicUsage(); + } - const result = await executeWithContext(fn, args, cacheVariant); - privateCache.set(cacheKey, result); - return result; - } + const privateCache = _getPrivateState()._privateCache!; + const privateHit = privateCache.get(cacheKey); + if (privateHit !== undefined) { + // The private cache is heterogeneous across cached functions; the key + // includes this function's stable id, so a hit belongs to this TResult. + return privateHit as TResult; + } - // In dev mode, always execute fresh — skip shared cache lookup/storage. - // This ensures HMR changes are reflected immediately. - if (isDev) { - return executeWithContext(fn, args, cacheVariant); - } + const result = await executeWithContext(fn, args, cacheVariant); + privateCache.set(cacheKey, result); + return result; + } - // Shared cache ("use cache" / "use cache: remote") - const handler = getDataCacheHandler(); + // In dev mode, always execute fresh — skip shared cache lookup/storage. + // This ensures HMR changes are reflected immediately. + if (isDev) { + return executeWithContext(fn, args, cacheVariant); + } - // Check cache — deserialize via RSC stream when available, JSON otherwise - const existing = await handler.get(cacheKey, { kind: "FETCH" }); - if (existing?.value && existing.value.kind === "FETCH" && existing.cacheState !== "stale") { - try { - // Surface the cached entry's tags to the surrounding request so the - // enclosing page / route-handler ISR entry carries them even on a data - // cache HIT — otherwise `revalidateTag()` could not evict the rendered - // output that embeds this cached value (issue #1453). - propagateCacheTagsToRequest(existing.value.tags); - if (rsc && existing.value.data.headers[VINEXT_RSC_MARKER_HEADER] === "1") { - // RSC-serialized entry: base64 → bytes → stream → deserialize - const bytes = base64ToUint8(existing.value.data.body); - const stream = uint8ToStream(bytes); - const result = await rsc.createFromReadableStream(stream); + // Shared cache ("use cache" / "use cache: remote") + const handler = getDataCacheHandler(); + + // Check cache — deserialize via RSC stream when available, JSON otherwise + const existing = await handler.get(cacheKey, { kind: "FETCH" }); + if (existing?.value && existing.value.kind === "FETCH" && existing.cacheState !== "stale") { + try { + // Surface the cached entry's tags to the surrounding request so the + // enclosing page / route-handler ISR entry carries them even on a data + // cache HIT — otherwise `revalidateTag()` could not evict the rendered + // output that embeds this cached value (issue #1453). + propagateCacheTagsToRequest(existing.value.tags); + if (rsc && existing.value.data.headers[VINEXT_RSC_MARKER_HEADER] === "1") { + // RSC-serialized entry: base64 → bytes → stream → deserialize + const bytes = base64ToUint8(existing.value.data.body); + const stream = uint8ToStream(bytes); + const result = await rsc.createFromReadableStream(stream); + recordRequestScopedCacheControl(existing.cacheControl); + return result; + } + // JSON-serialized entry (legacy or no RSC available) + const result = JSON.parse(existing.value.data.body); recordRequestScopedCacheControl(existing.cacheControl); return result; + } catch { + // Corrupted entry, fall through to re-execute } - // JSON-serialized entry (legacy or no RSC available) - const result = JSON.parse(existing.value.data.body); - recordRequestScopedCacheControl(existing.cacheControl); - return result; - } catch { - // Corrupted entry, fall through to re-execute } - } - // Cache miss (or stale) — execute with context - const { result, ctx, effectiveLife } = await runCachedFunctionWithContext( - fn, - args, - cacheVariant, - ); - - recordRequestScopedCacheLife(effectiveLife); - // Bubble the cache scope's tags up to the surrounding request so the - // enclosing page / route-handler ISR entry is tagged for on-demand - // revalidation (issue #1453). `ctx.tags` already includes any nested - // child cache's tags via `runCachedFunctionWithContext`. - propagateCacheTagsToRequest(ctx.tags); - const revalidateSeconds = - effectiveLife.revalidate ?? cacheLifeProfiles.default.revalidate ?? 900; - - // Store in cache — use RSC stream serialization when available (handles - // React elements, client refs, Promises, etc.), JSON otherwise. - try { - let body: string; - const headers: Record = {}; - - if (rsc) { - // RSC serialization: result → stream → bytes → base64. - // No temporaryReferences — cached values must be self-contained - // since they're persisted across requests. - const stream = rsc.renderToReadableStream(result); - const bytes = await collectStream(stream); - body = uint8ToBase64(bytes); - headers[VINEXT_RSC_MARKER_HEADER] = "1"; - } else { - // JSON fallback - body = JSON.stringify(result); - if (body === undefined) return result; - } + // Cache miss (or stale) — execute with context + const { result, ctx, effectiveLife } = await runCachedFunctionWithContext( + fn, + args, + cacheVariant, + ); + + recordRequestScopedCacheLife(effectiveLife); + // Bubble the cache scope's tags up to the surrounding request so the + // enclosing page / route-handler ISR entry is tagged for on-demand + // revalidation (issue #1453). `ctx.tags` already includes any nested + // child cache's tags via `runCachedFunctionWithContext`. + propagateCacheTagsToRequest(ctx.tags); + const revalidateSeconds = + effectiveLife.revalidate ?? cacheLifeProfiles.default.revalidate ?? 900; + + // Store in cache — use RSC stream serialization when available (handles + // React elements, client refs, Promises, etc.), JSON otherwise. + try { + let body: string; + const headers: Record = {}; + + if (rsc) { + // RSC serialization: result → stream → bytes → base64. + // No temporaryReferences — cached values must be self-contained + // since they're persisted across requests. + const stream = rsc.renderToReadableStream(result); + const bytes = await collectStream(stream); + body = uint8ToBase64(bytes); + headers[VINEXT_RSC_MARKER_HEADER] = "1"; + } else { + // JSON fallback + body = JSON.stringify(result); + if (body === undefined) return result; + } - const cacheValue = { - kind: "FETCH", - data: { - headers, - body, - url: cacheKey, - }, - tags: ctx.tags, - revalidate: revalidateSeconds, - } satisfies CachedFetchValue; - - await handler.set(cacheKey, cacheValue, { - fetchCache: true, - tags: ctx.tags, - cacheControl: { + const cacheValue = { + kind: "FETCH", + data: { + headers, + body, + url: cacheKey, + }, + tags: ctx.tags, revalidate: revalidateSeconds, - expire: effectiveLife.expire, - }, - }); - } catch { - // Result not serializable — skip caching, still return the result - } + } satisfies CachedFetchValue; + + await handler.set(cacheKey, cacheValue, { + fetchCache: true, + tags: ctx.tags, + cacheControl: { + revalidate: revalidateSeconds, + expire: effectiveLife.expire, + }, + }); + } catch { + // Result not serializable — skip caching, still return the result + } - return result; - }; + return result; + }, cacheVariant); // Preserve the original function's arity on the wrapper. The wrapper is // declared as `(...args)` (arity 0), which hides the original signature. diff --git a/packages/vinext/src/shims/headers.ts b/packages/vinext/src/shims/headers.ts index 69debd4eb..6001ccd26 100644 --- a/packages/vinext/src/shims/headers.ts +++ b/packages/vinext/src/shims/headers.ts @@ -22,6 +22,7 @@ import { getRequestContext, runWithUnifiedStateMutation, } from "./unified-request-context.js"; +import { createPprFallbackShellSuspensePromise } from "./ppr-fallback-shell.js"; import type { RenderRequestApiKind } from "../server/cache-proof.js"; // --------------------------------------------------------------------------- @@ -583,6 +584,30 @@ function _decorateRejectedRequestApiPromise(error: unknown): P return _decorateRequestApiPromise(promise, throwingTarget); } +function _decorateSuspendingRequestApiPromise( + promise: Promise, +): Promise & T { + return new Proxy(promise as Promise & T, { + get(promiseTarget, prop) { + if (prop === "then" || prop === "catch" || prop === "finally") { + const value = Reflect.get(promiseTarget, prop, promiseTarget); + return typeof value === "function" ? value.bind(promiseTarget) : value; + } + + throw promise; + }, + getOwnPropertyDescriptor() { + throw promise; + }, + has() { + throw promise; + }, + ownKeys() { + throw promise; + }, + }); +} + function _sealHeaders(headers: Headers): Headers { return new Proxy(headers, { get(target, prop) { @@ -770,6 +795,11 @@ export function headers(): Promise & Headers { } markDynamicUsage(); + const fallbackShellPromise = createPprFallbackShellSuspensePromise("`headers()`"); + if (fallbackShellPromise) { + return _decorateSuspendingRequestApiPromise(fallbackShellPromise); + } + const readonlyHeaders = _getReadonlyHeaders(state.headersContext); return _getOrCreateDecoratedRequestApiPromise(_decoratedHeadersPromises, readonlyHeaders); } @@ -800,6 +830,11 @@ export function cookies(): Promise & RequestCookies { } markDynamicUsage(); + const fallbackShellPromise = createPprFallbackShellSuspensePromise("`cookies()`"); + if (fallbackShellPromise) { + return _decorateSuspendingRequestApiPromise(fallbackShellPromise); + } + const cookieStore = _areCookiesMutableInCurrentPhase() ? _getMutableCookies(state.headersContext) : _getReadonlyCookies(state.headersContext); diff --git a/tests/app-page-cache.test.ts b/tests/app-page-cache.test.ts index 9e419513f..e911a2d43 100644 --- a/tests/app-page-cache.test.ts +++ b/tests/app-page-cache.test.ts @@ -6,6 +6,7 @@ import { finalizeAppPageHtmlCacheResponse, finalizeAppPageRscCacheResponse, readAppPageCacheResponse, + readAppPageFallbackShellCacheResponse, scheduleAppPageRscCacheWrite, } from "../packages/vinext/src/server/app-page-cache.js"; import type { ISRCacheEntry } from "../packages/vinext/src/server/isr-cache.js"; @@ -684,6 +685,80 @@ describe("app page cache helpers", () => { ]); }); + it("serves stale fallback shells and regenerates the fallback shell key", async () => { + const scheduledRegenerations: Array<{ key: string; render: () => Promise }> = []; + const isrSetCalls: Array<{ + key: string; + html: string; + expireSeconds: number | undefined; + revalidateSeconds: number; + tags: string[]; + }> = []; + const debugCalls: Array<[string, string]> = []; + + const response = await readAppPageFallbackShellCacheResponse({ + clearRequestContext() {}, + async isrGet() { + return buildISRCacheEntry( + buildCachedAppPageValue("stale shell"), + true, + { revalidate: 60, expire: 300 }, + ); + }, + isrDebug(event, detail) { + debugCalls.push([event, detail]); + }, + isrHtmlKey(pathname) { + return "html:" + pathname; + }, + async isrSet(key, data, revalidateSeconds, tags, expireSeconds) { + isrSetCalls.push({ + key, + html: data.html, + expireSeconds, + revalidateSeconds, + tags, + }); + }, + fallbackPathname: "/en/blog/[slug]", + expireSeconds: 300, + middlewareHeaders: new Headers({ "X-From-Middleware": "yes" }), + revalidateSeconds: 60, + async renderFreshFallbackShellForCache() { + return { + cacheControl: { revalidate: 10, expire: 20 }, + html: "fresh shell", + tags: ["/en/blog/[slug]", "_N_T_/en/blog/[slug]/page"], + }; + }, + rewriteHtml(html) { + return html.replace("stale shell", "rewritten stale shell"); + }, + scheduleBackgroundRegeneration(key, render) { + scheduledRegenerations.push({ key, render }); + }, + }); + + expect(response?.headers.get("x-vinext-cache")).toBe("STALE"); + expect(response?.headers.get("x-from-middleware")).toBe("yes"); + await expect(response?.text()).resolves.toContain("rewritten stale shell"); + expect(scheduledRegenerations.map(({ key }) => key)).toEqual(["html:/en/blog/[slug]"]); + expect(debugCalls).toContainEqual(["STALE (fallback shell)", "/en/blog/[slug]"]); + + await scheduledRegenerations[0].render(); + + expect(isrSetCalls).toEqual([ + { + key: "html:/en/blog/[slug]", + html: "fresh shell", + expireSeconds: 20, + revalidateSeconds: 10, + tags: ["/en/blog/[slug]", "_N_T_/en/blog/[slug]/page"], + }, + ]); + expect(debugCalls).toContainEqual(["regen complete (fallback shell)", "/en/blog/[slug]"]); + }); + it("still schedules stale regeneration when the stale payload is unusable for this request", async () => { const debugCalls: Array<[string, string]> = []; const scheduledRegenerations: Array<() => Promise> = []; diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index 9074f40f0..c7f033e8e 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -1,5 +1,7 @@ import React from "react"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import type { NavigationContext } from "vinext/shims/navigation"; +import { clearPregeneratedConcretePaths } from "../packages/vinext/src/server/pregenerated-concrete-paths.js"; import { APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, @@ -255,6 +257,7 @@ function createDispatchOptions( findIntercept?: DispatchOptions["findIntercept"]; generateStaticParams?: DispatchOptions["generateStaticParams"]; formState?: DispatchOptions["formState"]; + getNavigationContext?: DispatchOptions["getNavigationContext"]; getSourceRoute?: DispatchOptions["getSourceRoute"]; actionError?: DispatchOptions["actionError"]; actionFailed?: DispatchOptions["actionFailed"]; @@ -269,7 +272,9 @@ function createDispatchOptions( loadSsrHandler?: DispatchOptions["loadSsrHandler"]; middlewareContext?: AppPageMiddlewareContext; mountedSlotsHeader?: string | null; - params?: Record; + params?: DispatchOptions["params"]; + pprFallbackCacheShells?: DispatchOptions["pprFallbackCacheShells"]; + renderedConcreteUrlPaths?: DispatchOptions["renderedConcreteUrlPaths"]; probeLayoutAt?: DispatchOptions["probeLayoutAt"]; probePage?: DispatchOptions["probePage"]; renderToReadableStream?: DispatchOptions["renderToReadableStream"]; @@ -318,13 +323,13 @@ function createDispatchOptions( getFontStyles() { return []; }, - getNavigationContext() { - return { + getNavigationContext: + overrides.getNavigationContext ?? + (() => ({ pathname: "/posts/hello", searchParams: new URLSearchParams(), params: { slug: "hello" }, - }; - }, + })), getSourceRoute: overrides.getSourceRoute ?? (() => undefined), hasGenerateStaticParams: typeof overrides.generateStaticParams === "function", hasPageDefaultExport: true, @@ -353,12 +358,14 @@ function createDispatchOptions( status: null, }, mountedSlotsHeader: overrides.mountedSlotsHeader, - params, + params: overrides.params ?? { slug: "hello" }, + pprFallbackCacheShells: overrides.pprFallbackCacheShells, probeLayoutAt: overrides.probeLayoutAt ?? createLayoutParamProbe(route, params, []), probePage: overrides.probePage ?? (() => null), renderErrorBoundaryPage: vi.fn(async () => null), renderHttpAccessFallbackPage: vi.fn(async () => null), renderToReadableStream, + renderedConcreteUrlPaths: overrides.renderedConcreteUrlPaths, request: overrides.request ?? new Request("https://example.test/posts/hello"), revalidateSeconds: overrides.revalidateSeconds ?? null, resolveRouteFetchCacheMode: overrides.resolveRouteFetchCacheMode, @@ -476,6 +483,8 @@ describe("app page dispatch", () => { consumeDynamicUsage(); consumeRenderRequestApiUsage(); vi.unstubAllEnvs(); + vi.restoreAllMocks(); + clearPregeneratedConcretePaths(); }); it("serves cached production HTML instead of revalidating params or rendering", async () => { @@ -1195,4 +1204,439 @@ describe("app page dispatch", () => { expect(capturedWaitForAllReady).toBe(true); expect(isrSet).toHaveBeenCalled(); }); + + it("serves exact cache HIT instead of fallback shell", async () => { + const buildPageElement = vi.fn( + async ( + _route: TestRoute, + params: Record, + _opts: Parameters[2], + _searchParams: URLSearchParams, + ) => `element:${JSON.stringify(params)}`, + ); + const isrGet = vi.fn(async (key: string) => { + if (key === "html:/en/blog/known-post") { + return buildISRCacheEntry( + buildCachedAppPageValue("exact HIT"), + false, + ); + } + return null; + }); + const { options } = createDispatchOptions({ + buildPageElement, + cleanPathname: "/en/blog/known-post", + isProduction: true, + isrGet, + params: { locale: "en", slug: "known-post" }, + pprFallbackCacheShells: [ + { + fallbackParamNames: ["slug"], + params: { locale: "en", slug: "[slug]" }, + pathname: "/en/blog/[slug]", + }, + ], + revalidateSeconds: 60, + route: createRoute({ + isDynamic: true, + params: ["locale", "slug"], + pattern: "/:locale/blog/:slug", + routeSegments: ["[locale]", "blog", "[slug]"], + }), + }); + + const response = await dispatchAppPage(options); + + expect(response.headers.get("x-vinext-cache")).toBe("HIT"); + await expect(response.text()).resolves.toBe("exact HIT"); + expect(buildPageElement).not.toHaveBeenCalled(); + }); + + it("static params validation rejects unknown params before shell probing", async () => { + const generateStaticParams = vi.fn(async () => [{ locale: "en", slug: "hello-world" }]); + const buildPageElement = vi.fn( + async ( + _route: TestRoute, + params: Record, + _opts: Parameters[2], + _searchParams: URLSearchParams, + ) => `element:${JSON.stringify(params)}`, + ); + const isrGet = vi.fn(async () => null); + const { options } = createDispatchOptions({ + buildPageElement, + cleanPathname: "/en/blog/unknown-post", + generateStaticParams, + isProduction: true, + isrGet, + params: { locale: "en", slug: "unknown-post" }, + pprFallbackCacheShells: [ + { + fallbackParamNames: ["slug"], + params: { locale: "en", slug: "[slug]" }, + pathname: "/en/blog/[slug]", + }, + ], + revalidateSeconds: 60, + route: createRoute({ + isDynamic: true, + params: ["locale", "slug"], + pattern: "/:locale/blog/:slug", + routeSegments: ["[locale]", "blog", "[slug]"], + }), + }); + + const response = await dispatchAppPage({ + ...options, + dynamicParamsConfig: false, + }); + + expect(response.status).toBe(404); + expect(isrGet).not.toHaveBeenCalledWith("html:/en/blog/[slug]"); + expect(buildPageElement).not.toHaveBeenCalled(); + }); + + it("serves fallback shell HTML for an unknown child param after the exact cache misses", async () => { + const buildPageElement = vi.fn( + async ( + _route: TestRoute, + params: Record, + _opts: Parameters[2], + _searchParams: URLSearchParams, + ) => `element:${JSON.stringify(params)}`, + ); + const isrGet = vi.fn(async (key: string) => { + if (key === "html:/en/blog/[slug]") { + return buildISRCacheEntry( + buildCachedAppPageValue("Locale: en"), + false, + ); + } + return null; + }); + const { options } = createDispatchOptions({ + buildPageElement, + cleanPathname: "/en/blog/new-post", + isProduction: true, + isrGet, + params: { locale: "en", slug: "new-post" }, + pprFallbackCacheShells: [ + { + fallbackParamNames: ["slug"], + params: { locale: "en", slug: "[slug]" }, + pathname: "/en/blog/[slug]", + }, + ], + revalidateSeconds: 60, + route: createRoute({ + isDynamic: true, + params: ["locale", "slug"], + pattern: "/:locale/blog/:slug", + routeSegments: ["[locale]", "blog", "[slug]"], + }), + }); + + const response = await dispatchAppPage(options); + + expect(isrGet.mock.calls.map(([key]) => key)).toEqual([ + "html:/en/blog/new-post", + "html:/en/blog/[slug]", + ]); + expect(response.headers.get("x-vinext-cache")).toBe("HIT"); + await expect(response.text()).resolves.toContain("Locale: en"); + expect(buildPageElement).not.toHaveBeenCalled(); + }); + + it("serves stale PPR fallback-shell HTML and regenerates the shell key", async () => { + let navigationContext: NavigationContext = { + pathname: "/en/blog/new-post", + searchParams: new URLSearchParams(), + params: { slug: "new-post" }, + }; + const scheduledRegeneration: { + key?: string; + render?: () => Promise; + } = {}; + const buildPageElement = vi.fn( + async ( + _route: TestRoute, + params: Record, + _opts: Parameters[2], + _searchParams: URLSearchParams, + ) => `element:${JSON.stringify(params)}`, + ); + const isrGet = vi.fn(async (key: string) => { + if (key === "html:/en/blog/[slug]") { + return buildISRCacheEntry( + buildCachedAppPageValue("Locale: en"), + true, + ); + } + return null; + }); + const { options } = createDispatchOptions({ + buildPageElement, + cleanPathname: "/en/blog/new-post", + getNavigationContext() { + return navigationContext; + }, + isProduction: true, + isrGet, + loadSsrHandler: async () => ({ + async handleSsr(_rscStream, navContext) { + if ( + !navContext || + typeof navContext !== "object" || + !("pathname" in navContext) || + !("params" in navContext) + ) { + throw new Error("expected navigation context for fallback shell regeneration"); + } + return createStream([ + `${String(navContext.pathname)}:${JSON.stringify(navContext.params)}`, + ]); + }, + }), + params: { locale: "en", slug: "new-post" }, + pprFallbackCacheShells: [ + { + fallbackParamNames: ["slug"], + params: { locale: "en", slug: "[slug]" }, + pathname: "/en/blog/[slug]", + }, + ], + revalidateSeconds: 60, + route: createRoute({ + isDynamic: true, + params: ["locale", "slug"], + pattern: "/:locale/blog/:slug", + routeSegments: ["[locale]", "blog", "[slug]"], + }), + scheduleBackgroundRegeneration(key, render) { + scheduledRegeneration.key = key; + scheduledRegeneration.render = render; + }, + setNavigationContext(context) { + navigationContext = context; + }, + }); + + const response = await dispatchAppPage(options); + + expect(isrGet.mock.calls.map(([key]) => key)).toEqual([ + "html:/en/blog/new-post", + "html:/en/blog/[slug]", + ]); + expect(response.headers.get("x-vinext-cache")).toBe("STALE"); + await expect(response.text()).resolves.toContain("Locale: en"); + expect(buildPageElement).not.toHaveBeenCalled(); + expect(scheduledRegeneration.key).toBe("html:/en/blog/[slug]"); + + if (!scheduledRegeneration.render) { + throw new Error("expected fallback shell regeneration to be scheduled"); + } + await scheduledRegeneration.render(); + + expect(buildPageElement).toHaveBeenCalledTimes(2); + expect(buildPageElement.mock.calls.map(([, params]) => params)).toEqual([ + { locale: "en", slug: "[slug]" }, + { locale: "en", slug: "[slug]" }, + ]); + expect(options.isrSet).toHaveBeenCalledWith( + "html:/en/blog/[slug]", + expect.objectContaining({ + html: expect.stringContaining('/en/blog/[slug]:{"locale":"en","slug":"[slug]"}'), + }), + 60, + expect.arrayContaining(["/en/blog/[slug]"]), + undefined, + ); + }); + + it("does not serve the fallback shell for a known pregenerated route whose exact cache is absent", async () => { + const buildPageElement = vi.fn( + async ( + _route: TestRoute, + params: Record, + _opts: Parameters[2], + _searchParams: URLSearchParams, + ) => `fresh:${JSON.stringify(params)}`, + ); + const isrGet = vi.fn(async (key: string) => { + if (key === "html:/en/blog/[slug]") { + return buildISRCacheEntry( + buildCachedAppPageValue("fallback shell"), + false, + ); + } + return null; + }); + const { options } = createDispatchOptions({ + buildPageElement, + cleanPathname: "/en/blog/known-post", + isProduction: true, + isrGet, + loadSsrHandler: async () => ({ + async handleSsr() { + return createStream([ + `fresh:${JSON.stringify({ + locale: "en", + slug: "known-post", + })}`, + ]); + }, + }), + params: { locale: "en", slug: "known-post" }, + pprFallbackCacheShells: [ + { + fallbackParamNames: ["slug"], + params: { locale: "en", slug: "[slug]" }, + pathname: "/en/blog/[slug]", + }, + ], + renderedConcreteUrlPaths: new Set(["/en/blog/known-post"]), + revalidateSeconds: 60, + route: createRoute({ + isDynamic: true, + params: ["locale", "slug"], + pattern: "/:locale/blog/:slug", + routeSegments: ["[locale]", "blog", "[slug]"], + }), + }); + + const response = await dispatchAppPage(options); + + expect(isrGet.mock.calls.map(([key]) => key)).toEqual(["html:/en/blog/known-post"]); + expect(isrGet).not.toHaveBeenCalledWith("html:/en/blog/[slug]"); + expect(buildPageElement).toHaveBeenCalled(); + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + }); + + it("does not serve the fallback shell for an encoded known pregenerated route whose exact cache is absent", async () => { + const buildPageElement = vi.fn( + async ( + _route: TestRoute, + params: Record, + _opts: Parameters[2], + _searchParams: URLSearchParams, + ) => `fresh:${JSON.stringify(params)}`, + ); + const isrGet = vi.fn(async (key: string) => { + if (key === "html:/en/blog/[slug]") { + return buildISRCacheEntry( + buildCachedAppPageValue("fallback shell"), + false, + ); + } + return null; + }); + const { options } = createDispatchOptions({ + buildPageElement, + cleanPathname: "/en/blog/hello world", + isProduction: true, + isrGet, + loadSsrHandler: async () => ({ + async handleSsr() { + return createStream([ + `fresh:${JSON.stringify({ + locale: "en", + slug: "hello world", + })}`, + ]); + }, + }), + params: { locale: "en", slug: "hello world" }, + pprFallbackCacheShells: [ + { + fallbackParamNames: ["slug"], + params: { locale: "en", slug: "[slug]" }, + pathname: "/en/blog/[slug]", + }, + ], + renderedConcreteUrlPaths: new Set(["/en/blog/hello world"]), + revalidateSeconds: 60, + route: createRoute({ + isDynamic: true, + params: ["locale", "slug"], + pattern: "/:locale/blog/:slug", + routeSegments: ["[locale]", "blog", "[slug]"], + }), + }); + + const response = await dispatchAppPage(options); + + expect(isrGet.mock.calls.map(([key]) => key)).toEqual(["html:/en/blog/hello world"]); + expect(isrGet).not.toHaveBeenCalledWith("html:/en/blog/[slug]"); + expect(buildPageElement).toHaveBeenCalled(); + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + }); + + it("does not serve the fallback shell when concrete paths come from the Worker global registry", async () => { + const { getRenderedConcreteUrlPathsForRoute, initPregeneratedPathsFromGlobals } = + await import("../packages/vinext/src/server/pregenerated-concrete-paths.js"); + + globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = [ + ["/:locale/blog/:slug", ["/en/blog/worker-known"]], + ]; + initPregeneratedPathsFromGlobals(); + delete globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS; + const concretePaths = getRenderedConcreteUrlPathsForRoute("/:locale/blog/:slug"); + + const buildPageElement = vi.fn( + async ( + _route: TestRoute, + params: Record, + _opts: Parameters[2], + _searchParams: URLSearchParams, + ) => `fresh:${JSON.stringify(params)}`, + ); + const isrGet = vi.fn(async (key: string) => { + if (key === "html:/en/blog/[slug]") { + return buildISRCacheEntry( + buildCachedAppPageValue("fallback shell"), + false, + ); + } + return null; + }); + const { options } = createDispatchOptions({ + buildPageElement, + cleanPathname: "/en/blog/worker-known", + isProduction: true, + isrGet, + loadSsrHandler: async () => ({ + async handleSsr() { + return createStream([ + `fresh:${JSON.stringify({ + locale: "en", + slug: "worker-known", + })}`, + ]); + }, + }), + params: { locale: "en", slug: "worker-known" }, + pprFallbackCacheShells: [ + { + fallbackParamNames: ["slug"], + params: { locale: "en", slug: "[slug]" }, + pathname: "/en/blog/[slug]", + }, + ], + renderedConcreteUrlPaths: concretePaths, + revalidateSeconds: 60, + route: createRoute({ + isDynamic: true, + params: ["locale", "slug"], + pattern: "/:locale/blog/:slug", + routeSegments: ["[locale]", "blog", "[slug]"], + }), + }); + + const response = await dispatchAppPage(options); + + expect(isrGet.mock.calls.map(([key]) => key)).toEqual(["html:/en/blog/worker-known"]); + expect(isrGet).not.toHaveBeenCalledWith("html:/en/blog/[slug]"); + expect(buildPageElement).toHaveBeenCalled(); + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + }); }); diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 68b7aeebd..037710a50 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -24,6 +24,7 @@ import { hasWranglerConfig, formatMissingCloudflarePluginError, formatMissingCacheAdapterError, + injectPregeneratedConcretePaths, } from "../packages/vinext/src/deploy.js"; import { detectPackageManager, @@ -2897,3 +2898,124 @@ describe("parseWranglerConfig — custom domain extraction", () => { expect(config?.kvNamespaceId).toBe("abc123"); }); }); + +describe("injectPregeneratedConcretePaths", () => { + it("second call replaces first injection", () => { + const sourceCode = `import { handler } from "vinext/server/app-router-entry";\n`; + const manifestA = { + buildId: "build-a", + routes: [ + { + route: "/blog/[slug]", + status: "rendered", + router: "app", + path: "/blog/post-a", + revalidate: 60, + }, + ], + }; + const manifestB = { + buildId: "build-b", + routes: [ + { + route: "/blog/[slug]", + status: "rendered", + router: "app", + path: "/blog/post-b", + revalidate: 60, + }, + ], + }; + + mkdir(tmpDir, "dist/server"); + writeFile(tmpDir, "dist/server/index.js", sourceCode); + + writeFile(tmpDir, "dist/server/vinext-prerender.json", JSON.stringify(manifestA)); + injectPregeneratedConcretePaths(tmpDir); + + const afterA = fs.readFileSync(path.join(tmpDir, "dist/server/index.js"), "utf-8"); + expect(afterA).toContain("post-a"); + expect(afterA).not.toContain("post-b"); + + writeFile(tmpDir, "dist/server/vinext-prerender.json", JSON.stringify(manifestB)); + injectPregeneratedConcretePaths(tmpDir); + + const afterB = fs.readFileSync(path.join(tmpDir, "dist/server/index.js"), "utf-8"); + expect(afterB).toContain("post-b"); + expect(afterB).not.toContain("post-a"); + expect(afterB).toContain('import { handler } from "vinext/server/app-router-entry"'); + }); + + it("missing manifest strips prior injection", () => { + const priorInjection = [ + "/* __VINEXT_PREGENERATED_CONCRETE_PATHS_START__ */", + 'globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = [["/blog/[slug]",["/blog/post-a"]]];', + "/* __VINEXT_PREGENERATED_CONCRETE_PATHS_END__ */", + 'import { handler } from "vinext/server/app-router-entry";', + "", + ].join("\n"); + + mkdir(tmpDir, "dist/server"); + writeFile(tmpDir, "dist/server/index.js", priorInjection); + + injectPregeneratedConcretePaths(tmpDir); + + const after = fs.readFileSync(path.join(tmpDir, "dist/server/index.js"), "utf-8"); + expect(after).not.toContain("__VINEXT_PREGENERATED_CONCRETE_PATHS"); + expect(after).toContain('import { handler } from "vinext/server/app-router-entry"'); + }); + + it("excludes fallback-shell placeholder paths from injection", () => { + const sourceCode = 'export default { fetch(request) { return new Response("ok"); } };\n'; + const manifest = { + buildId: "test", + routes: [ + { + route: "/blog/[slug]", + status: "rendered", + router: "app", + path: "/blog/post-a", + revalidate: 60, + }, + { + route: "/blog/[slug]", + status: "rendered", + router: "app", + path: "/blog/[slug]", + revalidate: 60, + }, + ], + }; + + mkdir(tmpDir, "dist/server"); + writeFile(tmpDir, "dist/server/index.js", sourceCode); + writeFile(tmpDir, "dist/server/vinext-prerender.json", JSON.stringify(manifest)); + injectPregeneratedConcretePaths(tmpDir); + + const code = fs.readFileSync(path.join(tmpDir, "dist/server/index.js"), "utf-8"); + const match = code.match(/globalThis\.__VINEXT_PREGENERATED_CONCRETE_PATHS = (\[.*?\]);/); + expect(match).not.toBeNull(); + const table: unknown = JSON.parse(match![1]); + expect(table).toEqual([["/blog/[slug]", ["/blog/post-a"]]]); + }); + + it("corrupt manifest strips prior injection", () => { + const priorInjection = [ + "/* __VINEXT_PREGENERATED_CONCRETE_PATHS_START__ */", + 'globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = [["/",["/"]]];', + "/* __VINEXT_PREGENERATED_CONCRETE_PATHS_END__ */", + 'export default { fetch(request) { return new Response("ok"); } };', + "", + ].join("\n"); + + mkdir(tmpDir, "dist/server"); + writeFile(tmpDir, "dist/server/index.js", priorInjection); + writeFile(tmpDir, "dist/server/vinext-prerender.json", "{invalid json}"); + + injectPregeneratedConcretePaths(tmpDir); + + const after = fs.readFileSync(path.join(tmpDir, "dist/server/index.js"), "utf-8"); + expect(after).not.toContain("__VINEXT_PREGENERATED_CONCRETE_PATHS"); + expect(after).toContain('export default { fetch(request) { return new Response("ok"); } }'); + }); +}); diff --git a/tests/seed-cache.test.ts b/tests/seed-cache.test.ts index e8a3921dc..20234f788 100644 --- a/tests/seed-cache.test.ts +++ b/tests/seed-cache.test.ts @@ -19,6 +19,7 @@ import { type IncrementalCacheValue, } from "../packages/vinext/src/shims/cache.js"; import { isrCacheKey, getRevalidateDuration } from "../packages/vinext/src/server/isr-cache.js"; +import { getRenderedConcreteUrlPathsForRoute } from "../packages/vinext/src/server/pregenerated-concrete-paths.js"; import { seedMemoryCacheFromPrerender } from "../packages/vinext/src/server/seed-cache.js"; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -667,4 +668,121 @@ describe("seedMemoryCacheFromPrerender", () => { expect(htmlKey).toContain("__hash:"); expect(await getCacheHandler().get(htmlKey)).not.toBeNull(); }); + + it("clears pregenerated concrete paths from a previous build and repopulates from the current manifest", async () => { + setupPrerenderFixture( + serverDir, + { + buildId: "build-a", + routes: [ + { + route: "/en/blog/[slug]", + status: "rendered", + router: "app", + path: "/en/blog/known-post", + revalidate: 60, + }, + ], + }, + { + "en/blog/known-post.html": "build A", + "en/blog/known-post.rsc": "build-a-flight", + }, + ); + await seedMemoryCacheFromPrerender(serverDir); + + const buildAPaths = getRenderedConcreteUrlPathsForRoute("/en/blog/[slug]"); + expect(buildAPaths).toBeDefined(); + expect(buildAPaths!.has("/en/blog/known-post")).toBe(true); + + setupPrerenderFixture( + serverDir, + { + buildId: "build-b", + routes: [ + { + route: "/en/blog/[slug]", + status: "rendered", + router: "app", + path: "/en/blog/new-post", + revalidate: 60, + }, + ], + }, + { + "en/blog/new-post.html": "build B", + "en/blog/new-post.rsc": "build-b-flight", + }, + ); + await seedMemoryCacheFromPrerender(serverDir); + + const buildBPaths = getRenderedConcreteUrlPathsForRoute("/en/blog/[slug]"); + expect(buildBPaths).toBeDefined(); + expect(buildBPaths!.has("/en/blog/known-post")).toBe(false); + expect(buildBPaths!.has("/en/blog/new-post")).toBe(true); + expect(buildBPaths!.size).toBe(1); + }); + + it("excludes fallback-shell placeholder paths from concrete path registry", async () => { + const buildId = "fallback-shell-test"; + setupPrerenderFixture( + serverDir, + { + buildId, + routes: [ + { + route: "/en/blog/[slug]", + status: "rendered", + router: "app", + path: "/en/blog/known-post", + revalidate: 60, + }, + { + route: "/en/blog/[slug]", + status: "rendered", + router: "app", + path: "/en/blog/[slug]", + revalidate: 60, + }, + ], + }, + { + "en/blog/known-post.html": "known post", + "en/blog/known-post.rsc": "flight-data", + }, + ); + await seedMemoryCacheFromPrerender(serverDir); + + const paths = getRenderedConcreteUrlPathsForRoute("/en/blog/[slug]"); + expect(paths).toBeDefined(); + expect(paths!.has("/en/blog/known-post")).toBe(true); + expect(paths!.has("/en/blog/[slug]")).toBe(false); + expect(paths!.size).toBe(1); + }); + + it("clears pregenerated concrete paths when manifest is absent from a subsequent build", async () => { + setupPrerenderFixture( + serverDir, + { + buildId: "build-a", + routes: [ + { + route: "/en/blog/[slug]", + status: "rendered", + router: "app", + path: "/en/blog/known-post", + revalidate: 60, + }, + ], + }, + { "en/blog/known-post.html": "A" }, + ); + await seedMemoryCacheFromPrerender(serverDir); + expect(getRenderedConcreteUrlPathsForRoute("/en/blog/[slug]")).toBeDefined(); + + fs.rmSync(path.join(serverDir, "vinext-prerender.json")); + await seedMemoryCacheFromPrerender(serverDir); + + expect(getRenderedConcreteUrlPathsForRoute("/en/blog/[slug]")).toBeUndefined(); + }); }); From bdc87a40d3dc0d70fb5b50c847cb911da64fa059 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:04:55 +1000 Subject: [PATCH 4/7] refactor(ppr): extract fallback shell render pipeline and dedupe getViteMajorVersion --- packages/vinext/src/index.ts | 35 +- packages/vinext/src/server/app-page-cache.ts | 2 +- .../vinext/src/server/app-page-dispatch.ts | 352 +++++------------- .../server/app-ppr-fallback-shell-render.ts | 309 +++++++++++++++ .../src/server/app-ppr-fallback-shell.ts | 11 + 5 files changed, 420 insertions(+), 289 deletions(-) create mode 100644 packages/vinext/src/server/app-ppr-fallback-shell-render.ts diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index adfba0d65..e46b6fccf 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -174,6 +174,7 @@ import fs from "node:fs"; import { randomBytes, randomUUID } from "node:crypto"; import commonjs from "vite-plugin-commonjs"; import { normalizePathSeparators } from "./utils/path.js"; +import { getViteMajorVersion } from "./utils/vite-version.js"; // Install the process-level peer-disconnect backstop at module load. // Vite plugin lifecycle hooks (config / configureServer) proved @@ -431,40 +432,6 @@ function loadTsconfigPathAliases( }; } -/** - * Detect Vite major version at runtime by resolving from cwd. - * The plugin may be installed in a workspace root with Vite 7 but used - * by a project that has Vite 8 — so we resolve from cwd, not from - * the plugin's own location. - */ -function getViteMajorVersion(): number { - try { - const require = createRequire(path.join(process.cwd(), "package.json")); - const vitePkg = require("vite/package.json"); - - const viteMajor = parseInt(vitePkg?.version, 10); - if (vitePkg?.name === "vite" && Number.isFinite(viteMajor)) { - return viteMajor; - } - - const bundledViteMajor = parseInt(vitePkg?.bundledVersions?.vite, 10); - if (Number.isFinite(bundledViteMajor)) { - return bundledViteMajor; - } - - // npm aliases like `vite: npm:@voidzero-dev/vite-plus-core@...` expose the - // aliased package.json, whose own version is not Vite's version. - console.warn( - `[vinext] Could not determine Vite major version from ${vitePkg?.name ?? "vite/package.json"}; assuming Vite 7`, - ); - return 7; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`[vinext] Failed to resolve vite/package.json (${message}); assuming Vite 7`); - return 7; - } -} - /** * Read the vinext package version once at plugin load. Surfaced via * `process.env.__NEXT_VERSION` define so `window.next.version` lands a diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index 4ad5b8354..931032aba 100644 --- a/packages/vinext/src/server/app-page-cache.ts +++ b/packages/vinext/src/server/app-page-cache.ts @@ -72,7 +72,7 @@ type AppPageCacheRenderResult = { tags: string[]; }; -type AppPageFallbackShellCacheRenderResult = { +export type AppPageFallbackShellCacheRenderResult = { cacheControl?: CacheControlMetadata; html: string; htmlRenderObservation?: RenderObservation; diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 1628ac760..a0fe80cca 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -24,11 +24,7 @@ import { createRequestContext, runWithRequestContext } from "vinext/shims/unifie import { createPprFallbackShellState, getPprFallbackShellState, - isPprFallbackShellAbortError, - preparePprFallbackShellFinalRender, runWithPprFallbackShellState, - waitForPprFallbackShellCacheReady, - type PprFallbackShellState, } from "vinext/shims/ppr-fallback-shell"; import { ensureFetchPatch, @@ -45,7 +41,15 @@ import { readAppPageCacheResponse, readAppPageFallbackShellCacheResponse, } from "./app-page-cache.js"; -import { rewriteAppPprFallbackShellHtmlNavigation } from "./app-ppr-fallback-shell.js"; +import { + rewriteAppPprFallbackShellHtmlNavigation, + type AppPagePprFallbackCacheShell, +} from "./app-ppr-fallback-shell.js"; +import { + renderFreshPprFallbackShellForCache, + warmPprFallbackShellCaches, + type FallbackShellRenderDeps, +} from "./app-ppr-fallback-shell-render.js"; import { resolveAppPageParentHttpAccessBoundary, resolveAppPageParentHttpAccessBoundaryModule, @@ -53,7 +57,6 @@ import { import { readStreamAsText } from "../utils/text-stream.js"; import { buildAppPageSpecialErrorResponse, - readAppPageBinaryStream, resolveAppPageSpecialError, teeAppPageRscStreamForCapture, type AppPageFontPreload, @@ -184,12 +187,6 @@ type AppPageDispatchRoute = { unauthorizeds?: readonly (AppPageModule | null | undefined)[]; }; -type AppPagePprFallbackCacheShell = { - fallbackParamNames: readonly string[]; - params: AppPageParams; - pathname: string; -}; - function resolveAppPageRouteBoundaryModule( route: AppPageDispatchRoute, statusCode: number, @@ -507,186 +504,90 @@ function toInterceptOptions( }; } -async function warmPprFallbackShellCaches(options: { - element: AppPageRenderableElement; - onError: AppPageBoundaryOnError; - renderToReadableStream: DispatchAppPageOptions["renderToReadableStream"]; - state: PprFallbackShellState; -}): Promise { - let warmupError: unknown = null; - const warmupStream = options.renderToReadableStream(options.element, { - onError(error, requestInfo, errorContext) { - if (options.state.abortController.signal.aborted || isPprFallbackShellAbortError(error)) { - return undefined; - } - - return options.onError(error, requestInfo, errorContext); - }, - }); - const warmupDrain = readAppPageBinaryStream(warmupStream).catch((error: unknown) => { - if (options.state.abortController.signal.aborted || isPprFallbackShellAbortError(error)) { - return; - } - warmupError = error; - }); - - try { - await waitForPprFallbackShellCacheReady(options.state); - } finally { - options.state.abortController.abort(); - await warmupDrain; - preparePprFallbackShellFinalRender(options.state); +/** + * Probe PPR fallback-shell cache entries after an exact cache miss. + * + * Guards against serving fallback shells for known pregenerated routes whose + * exact cache entry is temporarily absent (eviction, cold start, etc.). + * Returns the first matching shell response, or `null` when probing is + * skipped or every shell misses. + */ +async function tryServePprFallbackShell( + options: DispatchAppPageOptions, + route: TRoute, + currentRevalidateSeconds: number | null, + isDraftMode: boolean, + isForceDynamic: boolean, +): Promise { + // Before probing fallback shells, check whether this exact URL was a + // pre-rendered concrete route at build time. If so, the exact cache entry + // was seeded at startup and its current absence is a transient condition + // (eviction, cold start, stale-empty, read error) — not a semantic signal + // that this is an unknown dynamic route. Serving the fallback shell would + // silently degrade a known pregenerated route to unknown-param content. + const isKnownPregeneratedRoute = + options.renderedConcreteUrlPaths?.has(options.cleanPathname) === true; + if ( + isKnownPregeneratedRoute || + !options.pprFallbackCacheShells || + options.pprFallbackCacheShells.length === 0 || + options.isRscRequest || + options.request.method !== "GET" || + !shouldReadAppPageCache({ + isDraftMode, + isForceDynamic, + isProgressiveActionRender: options.isProgressiveActionRender === true, + isProduction: options.isProduction, + isRscRequest: false, + revalidateSeconds: currentRevalidateSeconds, + scriptNonce: options.scriptNonce, + }) + ) { + return null; } - if (warmupError) { - throw warmupError; + for (const fallbackShell of options.pprFallbackCacheShells) { + const fallbackShellResponse = await readAppPageFallbackShellCacheResponse({ + clearRequestContext: options.clearRequestContext, + expireSeconds: options.expireSeconds, + fallbackPathname: fallbackShell.pathname, + isEdgeRuntime: options.isEdgeRuntime, + isrDebug: options.isrDebug, + isrGet: options.isrGet, + isrHtmlKey: options.isrHtmlKey, + isrSet: options.isrSet, + middlewareHeaders: options.middlewareContext.headers, + middlewareStatus: options.middlewareContext.status, + revalidateSeconds: currentRevalidateSeconds ?? 0, + renderFreshFallbackShellForCache() { + return renderFreshPprFallbackShellForCache( + options as FallbackShellRenderDeps, + runAppPageRevalidationContext, + fallbackShell, + ); + }, + rewriteHtml(html) { + return rewriteAppPprFallbackShellHtmlNavigation({ + html, + params: options.params, + pathname: options.cleanPathname, + searchParams: options.searchParams, + }); + }, + scheduleBackgroundRegeneration(key, renderFn) { + options.scheduleBackgroundRegeneration(key, renderFn, { + routerKind: "App Router", + routePath: route.pattern, + routeType: "render", + }); + }, + }); + if (fallbackShellResponse) { + return fallbackShellResponse; + } } -} -async function renderFreshPprFallbackShellForCache( - options: DispatchAppPageOptions, - fallbackShell: AppPagePprFallbackCacheShell, -) { - const fallbackSearchParams = new URLSearchParams(); - return runAppPageRevalidationContext( - { - cleanPathname: fallbackShell.pathname, - currentFetchCacheMode: - options.resolveRouteFetchCacheMode?.(options.route) ?? options.fetchCache ?? null, - draftModeSecret: options.draftModeSecret, - dynamicConfig: options.dynamicConfig, - params: fallbackShell.params, - routePattern: options.route.pattern, - routeSegments: options.route.routeSegments, - setNavigationContext: options.setNavigationContext, - }, - async () => { - const fallbackShellState = createPprFallbackShellState({ - fallbackParamNames: fallbackShell.fallbackParamNames, - routePattern: options.route.pattern, - }); - - return await runWithPprFallbackShellState(fallbackShellState, async () => { - try { - const onError = options.createRscOnErrorHandler( - fallbackShell.pathname, - options.route.pattern, - ); - const warmupElement = await options.buildPageElement( - options.route, - fallbackShell.params, - undefined, - fallbackSearchParams, - ); - await warmPprFallbackShellCaches({ - element: warmupElement, - onError, - renderToReadableStream: options.renderToReadableStream, - state: fallbackShellState, - }); - _consumeRequestScopedCacheLife(); - consumeDynamicFetchObservations(); - consumeRenderRequestApiUsage(); - consumeInvalidDynamicUsageError(); - consumeDynamicUsage(); - - options.setNavigationContext({ - pathname: fallbackShell.pathname, - searchParams: fallbackSearchParams, - params: fallbackShell.params, - }); - const finalElement = await options.buildPageElement( - options.route, - fallbackShell.params, - undefined, - fallbackSearchParams, - ); - const finalRscStream = options.renderToReadableStream(finalElement, { - onError, - }); - const finalRscCapture = teeAppPageRscStreamForCapture(finalRscStream, true); - const capturedRscDataRef: { value: Promise | null } = { - value: null, - }; - const ssrHandler = await options.loadSsrHandler(); - const htmlStream = await ssrHandler.handleSsr( - finalRscCapture.ssrStream, - options.getNavigationContext(), - { - links: options.getFontLinks(), - styles: options.getFontStyles(), - preloads: options.getFontPreloads(), - }, - { - basePath: options.basePath, - clientTraceMetadata: options.clientTraceMetadata, - rootParams: options.rootParams, - ...(finalRscCapture.sideStream - ? { - sideStream: finalRscCapture.sideStream, - capturedRscDataRef, - } - : {}), - }, - ); - const htmlStreamNormalized = isAppSsrRenderResult(htmlStream) - ? htmlStream.htmlStream - : htmlStream; - const html = await readStreamAsText(htmlStreamNormalized); - try { - await capturedRscDataRef.value; - } catch { - // HTML rendering owns the user-visible error path. The fallback-shell - // regeneration only writes HTML, but observing the capture promise - // prevents a secondary unhandled rejection from the tee side stream. - } - const cacheLife = _consumeRequestScopedCacheLife(); - const tags = buildAppPageTags( - fallbackShell.pathname, - getCollectedFetchTags(), - options.route.routeSegments, - ); - const observationState = { - dynamicFetches: consumeDynamicFetchObservations(), - requestApis: consumeRenderRequestApiUsage(), - }; - consumeInvalidDynamicUsageError(); - consumeDynamicUsage(); - - return { - html, - htmlRenderObservation: createAppPageRenderObservation({ - boundaryOutcome: { kind: "success" }, - cacheability: "public", - cacheTags: tags, - cleanPathname: fallbackShell.pathname, - completeness: "complete", - output: createAppPageHtmlOutputScope({ - element: finalElement, - renderEpoch: null, - rootBoundaryId: null, - routePattern: options.route.pattern, - }), - params: fallbackShell.params, - state: observationState, - }), - tags, - cacheControl: - typeof cacheLife?.revalidate === "number" - ? { revalidate: cacheLife.revalidate, expire: cacheLife.expire } - : undefined, - }; - } finally { - _consumeRequestScopedCacheLife(); - consumeDynamicFetchObservations(); - consumeRenderRequestApiUsage(); - consumeInvalidDynamicUsageError(); - consumeDynamicUsage(); - options.clearRequestContext(); - } - }); - }, - ); + return null; } export async function dispatchAppPage( @@ -945,72 +846,15 @@ async function dispatchAppPageInner( } } - // Before probing fallback shells, check whether this exact URL was a - // pre-rendered concrete route at build time. If so, the exact cache entry - // was seeded at startup and its current absence is a transient condition - // (eviction, cold start, stale-empty, read error) — not a semantic signal - // that this is an unknown dynamic route. Serving the fallback shell would - // silently degrade a known pregenerated route to unknown-param content. - // - // Without this guard, a cache miss on `/en/blog/known-post` with a - // pre-existing fallback shell `/en/blog/[slug]` would serve the shell - // instead of regenerating the exact known page. The fallback shell must - // only be used for truly unknown child params, not for cache misses on - // known generated paths. - const isKnownPregeneratedRoute = - options.renderedConcreteUrlPaths?.has(options.cleanPathname) === true; - if ( - !isKnownPregeneratedRoute && - options.pprFallbackCacheShells && - options.pprFallbackCacheShells.length > 0 && - !options.isRscRequest && - options.request.method === "GET" && - shouldReadAppPageCache({ - isDraftMode, - isForceDynamic, - isProgressiveActionRender: options.isProgressiveActionRender === true, - isProduction: options.isProduction, - isRscRequest: false, - revalidateSeconds: currentRevalidateSeconds, - scriptNonce: options.scriptNonce, - }) - ) { - for (const fallbackShell of options.pprFallbackCacheShells) { - const fallbackShellResponse = await readAppPageFallbackShellCacheResponse({ - clearRequestContext: options.clearRequestContext, - expireSeconds: options.expireSeconds, - fallbackPathname: fallbackShell.pathname, - isEdgeRuntime: options.isEdgeRuntime, - isrDebug: options.isrDebug, - isrGet: options.isrGet, - isrHtmlKey: options.isrHtmlKey, - isrSet: options.isrSet, - middlewareHeaders: options.middlewareContext.headers, - middlewareStatus: options.middlewareContext.status, - revalidateSeconds: currentRevalidateSeconds ?? 0, - renderFreshFallbackShellForCache() { - return renderFreshPprFallbackShellForCache(options, fallbackShell); - }, - rewriteHtml(html) { - return rewriteAppPprFallbackShellHtmlNavigation({ - html, - params: options.params, - pathname: options.cleanPathname, - searchParams: options.searchParams, - }); - }, - scheduleBackgroundRegeneration(key, renderFn) { - options.scheduleBackgroundRegeneration(key, renderFn, { - routerKind: "App Router", - routePath: route.pattern, - routeType: "render", - }); - }, - }); - if (fallbackShellResponse) { - return fallbackShellResponse; - } - } + const fallbackShellResponse = await tryServePprFallbackShell( + options, + route, + currentRevalidateSeconds, + isDraftMode, + isForceDynamic, + ); + if (fallbackShellResponse) { + return fallbackShellResponse; } const interceptResult = await resolveAppPageIntercept< diff --git a/packages/vinext/src/server/app-ppr-fallback-shell-render.ts b/packages/vinext/src/server/app-ppr-fallback-shell-render.ts new file mode 100644 index 000000000..279410762 --- /dev/null +++ b/packages/vinext/src/server/app-ppr-fallback-shell-render.ts @@ -0,0 +1,309 @@ +import type { ReactNode } from "react"; +import type { NavigationContext } from "vinext/shims/navigation"; +import type { RootParams } from "vinext/shims/root-params"; +import { + consumeDynamicUsage, + consumeInvalidDynamicUsageError, + consumeRenderRequestApiUsage, +} from "vinext/shims/headers"; +import { _consumeRequestScopedCacheLife } from "vinext/shims/cache"; +import { + consumeDynamicFetchObservations, + type FetchCacheMode, + getCollectedFetchTags, +} from "vinext/shims/fetch-cache"; +import { + createPprFallbackShellState, + isPprFallbackShellAbortError, + preparePprFallbackShellFinalRender, + runWithPprFallbackShellState, + waitForPprFallbackShellCacheReady, + type PprFallbackShellState, +} from "vinext/shims/ppr-fallback-shell"; +import type { AppPagePprFallbackCacheShell } from "./app-ppr-fallback-shell.js"; +import { buildPageCacheTags } from "./implicit-tags.js"; +import { readStreamAsText } from "../utils/text-stream.js"; +import { + readAppPageBinaryStream, + teeAppPageRscStreamForCapture, + type AppPageFontPreload, +} from "./app-page-execution.js"; +import { + createAppPageHtmlOutputScope, + createAppPageRenderObservation, +} from "./app-page-render-observation.js"; +import { isAppSsrRenderResult, type AppPageSsrHandler } from "./app-page-stream.js"; +import type { AppPageFallbackShellCacheRenderResult } from "./app-page-cache.js"; + +type AppPageParams = Record; + +type AppPageBoundaryOnError = ( + error: unknown, + requestInfo: unknown, + errorContext: unknown, +) => unknown; + +type AppPageRenderableElement = ReactNode | Record; + +/** Dependencies needed to render a fresh PPR fallback shell for cache storage. */ +export type FallbackShellRenderDeps = { + basePath?: string; + buildPageElement: ( + route: { + pattern: string; + routeSegments: readonly string[]; + }, + params: AppPageParams, + opts: unknown, + searchParams: URLSearchParams, + ) => Promise>>; + clearRequestContext: () => void; + clientTraceMetadata?: readonly string[]; + createRscOnErrorHandler: (pathname: string, routePath: string) => AppPageBoundaryOnError; + draftModeSecret: string; + dynamicConfig?: string; + fetchCache?: FetchCacheMode | null; + getFontLinks: () => string[]; + getFontPreloads: () => AppPageFontPreload[]; + getFontStyles: () => string[]; + getNavigationContext: () => NavigationContext | null; + loadSsrHandler: () => Promise; + renderToReadableStream: ( + element: AppPageRenderableElement, + options: { onError: AppPageBoundaryOnError }, + ) => ReadableStream; + resolveRouteFetchCacheMode?: (route: { + pattern: string; + routeSegments: readonly string[]; + }) => FetchCacheMode | null; + rootParams?: RootParams; + route: { + pattern: string; + routeSegments: readonly string[]; + }; + setNavigationContext: (context: { + params: AppPageParams; + pathname: string; + searchParams: URLSearchParams; + }) => void; +}; + +function buildAppPageTags( + cleanPathname: string, + extraTags: string[], + routeSegments: readonly string[], +): string[] { + return buildPageCacheTags(cleanPathname, extraTags, [...routeSegments], "page"); +} + +export async function warmPprFallbackShellCaches(options: { + element: AppPageRenderableElement; + onError: AppPageBoundaryOnError; + renderToReadableStream: FallbackShellRenderDeps["renderToReadableStream"]; + state: PprFallbackShellState; +}): Promise { + let warmupError: unknown = null; + const warmupStream = options.renderToReadableStream(options.element, { + onError(error, requestInfo, errorContext) { + if (options.state.abortController.signal.aborted || isPprFallbackShellAbortError(error)) { + return undefined; + } + + return options.onError(error, requestInfo, errorContext); + }, + }); + const warmupDrain = readAppPageBinaryStream(warmupStream).catch((error: unknown) => { + if (options.state.abortController.signal.aborted || isPprFallbackShellAbortError(error)) { + return; + } + warmupError = error; + }); + + try { + await waitForPprFallbackShellCacheReady(options.state); + } finally { + options.state.abortController.abort(); + await warmupDrain; + preparePprFallbackShellFinalRender(options.state); + } + + if (warmupError) { + throw warmupError; + } +} + +/** + * Render a fresh PPR fallback shell for cache storage. + * + * This is the fallback-shell counterpart to the regular ISR revalidation + * path in `dispatchAppPageInner`. It runs the full RSC→SSR→HTML pipeline + * with placeholder params (e.g. `[slug]`) so the resulting HTML can be + * cached and later served for any unknown child param value. + * + * Extracted from `app-page-dispatch.ts` so the dispatch orchestrator stays + * focused on routing decisions rather than render pipeline construction. + */ +export async function renderFreshPprFallbackShellForCache( + deps: FallbackShellRenderDeps, + runRevalidationContext: < + TResult extends { + html: string; + tags: string[]; + }, + >( + options: { + cleanPathname: string; + currentFetchCacheMode?: FetchCacheMode | null; + draftModeSecret: string; + dynamicConfig?: string; + params: AppPageParams; + routePattern: string; + routeSegments: readonly string[]; + setNavigationContext: (context: { + params: AppPageParams; + pathname: string; + searchParams: URLSearchParams; + }) => void; + }, + renderFn: () => Promise, + ) => Promise, + fallbackShell: AppPagePprFallbackCacheShell, +): Promise { + const fallbackSearchParams = new URLSearchParams(); + return runRevalidationContext( + { + cleanPathname: fallbackShell.pathname, + currentFetchCacheMode: + deps.resolveRouteFetchCacheMode?.(deps.route) ?? deps.fetchCache ?? null, + draftModeSecret: deps.draftModeSecret, + dynamicConfig: deps.dynamicConfig, + params: fallbackShell.params, + routePattern: deps.route.pattern, + routeSegments: deps.route.routeSegments, + setNavigationContext: deps.setNavigationContext, + }, + async () => { + const fallbackShellState = createPprFallbackShellState({ + fallbackParamNames: fallbackShell.fallbackParamNames, + routePattern: deps.route.pattern, + }); + + return await runWithPprFallbackShellState(fallbackShellState, async () => { + try { + const onError = deps.createRscOnErrorHandler(fallbackShell.pathname, deps.route.pattern); + const warmupElement = await deps.buildPageElement( + deps.route, + fallbackShell.params, + undefined, + fallbackSearchParams, + ); + await warmPprFallbackShellCaches({ + element: warmupElement, + onError, + renderToReadableStream: deps.renderToReadableStream, + state: fallbackShellState, + }); + _consumeRequestScopedCacheLife(); + consumeDynamicFetchObservations(); + consumeRenderRequestApiUsage(); + consumeInvalidDynamicUsageError(); + consumeDynamicUsage(); + + deps.setNavigationContext({ + pathname: fallbackShell.pathname, + searchParams: fallbackSearchParams, + params: fallbackShell.params, + }); + const finalElement = await deps.buildPageElement( + deps.route, + fallbackShell.params, + undefined, + fallbackSearchParams, + ); + const finalRscStream = deps.renderToReadableStream(finalElement, { + onError, + }); + const finalRscCapture = teeAppPageRscStreamForCapture(finalRscStream, true); + const capturedRscDataRef: { value: Promise | null } = { + value: null, + }; + const ssrHandler = await deps.loadSsrHandler(); + const htmlStream = await ssrHandler.handleSsr( + finalRscCapture.ssrStream, + deps.getNavigationContext(), + { + links: deps.getFontLinks(), + styles: deps.getFontStyles(), + preloads: deps.getFontPreloads(), + }, + { + basePath: deps.basePath, + clientTraceMetadata: deps.clientTraceMetadata, + rootParams: deps.rootParams, + ...(finalRscCapture.sideStream + ? { + sideStream: finalRscCapture.sideStream, + capturedRscDataRef, + } + : {}), + }, + ); + const htmlStreamNormalized = isAppSsrRenderResult(htmlStream) + ? htmlStream.htmlStream + : htmlStream; + const html = await readStreamAsText(htmlStreamNormalized); + try { + await capturedRscDataRef.value; + } catch { + // HTML rendering owns the user-visible error path. The fallback-shell + // regeneration only writes HTML, but observing the capture promise + // prevents a secondary unhandled rejection from the tee side stream. + } + const cacheLife = _consumeRequestScopedCacheLife(); + const tags = buildAppPageTags( + fallbackShell.pathname, + getCollectedFetchTags(), + deps.route.routeSegments, + ); + const observationState = { + dynamicFetches: consumeDynamicFetchObservations(), + requestApis: consumeRenderRequestApiUsage(), + }; + consumeInvalidDynamicUsageError(); + consumeDynamicUsage(); + + return { + html, + htmlRenderObservation: createAppPageRenderObservation({ + boundaryOutcome: { kind: "success" }, + cacheability: "public", + cacheTags: tags, + cleanPathname: fallbackShell.pathname, + completeness: "complete", + output: createAppPageHtmlOutputScope({ + element: finalElement, + renderEpoch: null, + rootBoundaryId: null, + routePattern: deps.route.pattern, + }), + params: fallbackShell.params, + state: observationState, + }), + tags, + cacheControl: + typeof cacheLife?.revalidate === "number" + ? { revalidate: cacheLife.revalidate, expire: cacheLife.expire } + : undefined, + }; + } finally { + _consumeRequestScopedCacheLife(); + consumeDynamicFetchObservations(); + consumeRenderRequestApiUsage(); + consumeInvalidDynamicUsageError(); + consumeDynamicUsage(); + deps.clearRequestContext(); + } + }); + }, + ); +} diff --git a/packages/vinext/src/server/app-ppr-fallback-shell.ts b/packages/vinext/src/server/app-ppr-fallback-shell.ts index 20195ec95..e39a9e0a7 100644 --- a/packages/vinext/src/server/app-ppr-fallback-shell.ts +++ b/packages/vinext/src/server/app-ppr-fallback-shell.ts @@ -13,6 +13,17 @@ type AppPprFallbackShell = { params: Record; }; +/** + * A fallback-shell cache entry as consumed by the dispatch layer. + * Produced at build time by the PPR prerender and served at request time + * when the exact cache entry for a dynamic child param is missing. + */ +export type AppPagePprFallbackCacheShell = { + fallbackParamNames: readonly string[]; + params: Record; + pathname: string; +}; + function routeRootParamNames(route: AppPprFallbackShellRoute): Set { return new Set(route.rootParamNames ?? []); } From e5b6d6549692d064dca6ac7ce29fb3679986f750 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:21:05 +1000 Subject: [PATCH 5/7] fix(ppr): restore safe-serving CI checks The safe-serving branch failed CI because a client-side server-only guard rejected valid 'use server' action modules, a fallback-shell dependency cast no longer matched its helper type, apps/web lost the Cloudflare cache adapter source path during typecheck, and Knip could not resolve the documented Cloudflare cache adapter subpaths. The server-only guard now reads the directive prologue and skips top-level 'use server' modules, fallback-shell regeneration passes an explicit typed dependency object, apps/web gets the workspace Cloudflare cache path, the Cloudflare package exports explicit adapter subpaths, Knip uses a relative source import in the dispatch unit test, and the readiness test awaits the actual cache-ready promise. --- apps/web/tsconfig.json | 3 ++- packages/cloudflare/package.json | 16 +++++++++++ packages/vinext/src/deploy.ts | 5 +--- packages/vinext/src/index.ts | 27 +++++++++++-------- .../vinext/src/server/app-page-dispatch.ts | 27 ++++++++++++++++++- tests/app-page-dispatch.test.ts | 2 +- tests/ppr-fallback-shell.test.ts | 3 +-- 7 files changed, 63 insertions(+), 20 deletions(-) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 7ff54c763..99294d444 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -20,7 +20,8 @@ ], "types": ["@cloudflare/workers-types"], "paths": { - "@/*": ["./*"] + "@/*": ["./*"], + "@vinext/cloudflare/cache/*": ["../../packages/cloudflare/src/cache/*"] } }, "include": [ diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 663027c0b..e007e46fd 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -13,6 +13,22 @@ ], "type": "module", "exports": { + "./cache/cdn-adapter": { + "types": "./dist/cache/cdn-adapter.d.ts", + "import": "./dist/cache/cdn-adapter.js" + }, + "./cache/cdn-adapter.runtime": { + "types": "./dist/cache/cdn-adapter.runtime.d.ts", + "import": "./dist/cache/cdn-adapter.runtime.js" + }, + "./cache/kv-data-adapter": { + "types": "./dist/cache/kv-data-adapter.d.ts", + "import": "./dist/cache/kv-data-adapter.js" + }, + "./cache/kv-data-adapter.runtime": { + "types": "./dist/cache/kv-data-adapter.runtime.d.ts", + "import": "./dist/cache/kv-data-adapter.runtime.js" + }, "./cache/*": { "types": "./dist/cache/*.d.ts", "import": "./dist/cache/*.js" diff --git a/packages/vinext/src/deploy.ts b/packages/vinext/src/deploy.ts index 70a28e7e4..0b21dc5c2 100644 --- a/packages/vinext/src/deploy.ts +++ b/packages/vinext/src/deploy.ts @@ -36,6 +36,7 @@ import { readPrerenderManifest, buildPregeneratedConcretePathTable, } from "./server/prerender-manifest.js"; +import { escapeRegExp } from "./utils/regex.js"; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -1479,10 +1480,6 @@ function runWranglerDeploy(root: string, options: Pick= len) return false; + if (i >= len) return null; // Skip line comments. if (code[i] === "/" && code[i + 1] === "/") { const nl = code.indexOf("\n", i + 2); - if (nl === -1) return false; + if (nl === -1) return null; i = nl + 1; continue; } // Skip block comments. if (code[i] === "/" && code[i + 1] === "*") { const end = code.indexOf("*/", i + 2); - if (end === -1) return false; + if (end === -1) return null; i = end + 2; continue; } // At first non-comment, non-whitespace token. Must be a string literal // directive to qualify (per ECMA-262 Directive Prologue grammar). const quote = code[i]; - if (quote !== '"' && quote !== "'") return false; + if (quote !== '"' && quote !== "'") return null; const closing = code.indexOf(quote, i + 1); - if (closing === -1) return false; + if (closing === -1) return null; const directive = code.slice(i + 1, closing); - if (directive === "use client" || directive === "use server") return true; + if (directive === "use client" || directive === "use server") return directive; // Other directives (e.g., "use strict") may precede the React directive. // Continue scanning past the statement-terminating `;` or newline. i = closing + 1; while (i < len && (code[i] === ";" || code[i] === " " || code[i] === "\t")) i++; if (code[i] === "\n") i++; } - return false; + return null; +} + +function hasReactDirective(code: string): boolean { + return getLeadingReactDirective(code) !== null; } function generateRootParamsModule(rootParamNames: Iterable): string { @@ -3785,6 +3789,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { handler(code, id) { if (this.environment?.name !== "client") return null; if (id.startsWith("\0")) return null; + if (getLeadingReactDirective(code) === "use server") return null; if (!hasServerOnlyMarkerImport(code)) return null; throw new Error( diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index a0fe80cca..82a8d0c04 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -560,8 +560,33 @@ async function tryServePprFallbackShell( middlewareStatus: options.middlewareContext.status, revalidateSeconds: currentRevalidateSeconds ?? 0, renderFreshFallbackShellForCache() { + const fallbackShellRenderDeps = { + basePath: options.basePath, + buildPageElement(_route, params, _opts, searchParams) { + return options.buildPageElement(route, params, undefined, searchParams); + }, + clearRequestContext: options.clearRequestContext, + clientTraceMetadata: options.clientTraceMetadata, + createRscOnErrorHandler: options.createRscOnErrorHandler, + draftModeSecret: options.draftModeSecret, + dynamicConfig: options.dynamicConfig, + fetchCache: options.fetchCache, + getFontLinks: options.getFontLinks, + getFontPreloads: options.getFontPreloads, + getFontStyles: options.getFontStyles, + getNavigationContext: options.getNavigationContext, + loadSsrHandler: options.loadSsrHandler, + renderToReadableStream: options.renderToReadableStream, + resolveRouteFetchCacheMode: options.resolveRouteFetchCacheMode + ? () => options.resolveRouteFetchCacheMode?.(route) ?? null + : undefined, + rootParams: options.rootParams, + route, + setNavigationContext: options.setNavigationContext, + } satisfies FallbackShellRenderDeps; + return renderFreshPprFallbackShellForCache( - options as FallbackShellRenderDeps, + fallbackShellRenderDeps, runAppPageRevalidationContext, fallbackShell, ); diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index c7f033e8e..9010fdbc5 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -1,6 +1,6 @@ import React from "react"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import type { NavigationContext } from "vinext/shims/navigation"; +import type { NavigationContext } from "../packages/vinext/src/shims/navigation.js"; import { clearPregeneratedConcretePaths } from "../packages/vinext/src/server/pregenerated-concrete-paths.js"; import { APP_ROOT_LAYOUT_KEY, diff --git a/tests/ppr-fallback-shell.test.ts b/tests/ppr-fallback-shell.test.ts index ef864949c..0ff5822b0 100644 --- a/tests/ppr-fallback-shell.test.ts +++ b/tests/ppr-fallback-shell.test.ts @@ -277,11 +277,10 @@ describe("ppr fallback shell render lifecycle", () => { expect(p2).not.toBeNull(); }); - await delay(5); + await ready; expect(isReady).toBe(true); expect(state.pendingCacheTasks).toBe(0); state.abortController.abort(); - await ready.catch(() => {}); }); }); From 6d6dc4103a97afc6e76f7fc2efb0b439eaecf7e9 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 9 Jun 2026 16:45:41 +1000 Subject: [PATCH 6/7] fix(ppr): guard fallback shell reads for queries --- .../vinext/src/server/app-page-dispatch.ts | 3 + .../vinext/src/server/prerender-manifest.ts | 3 +- tests/app-page-dispatch.test.ts | 56 +++++++++++++++++++ tests/deploy.test.ts | 48 +++++++++++++++- 4 files changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 82a8d0c04..526dbf3b6 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -517,6 +517,7 @@ async function tryServePprFallbackShell( route: TRoute, currentRevalidateSeconds: number | null, isDraftMode: boolean, + isForceStatic: boolean, isForceDynamic: boolean, ): Promise { // Before probing fallback shells, check whether this exact URL was a @@ -533,6 +534,7 @@ async function tryServePprFallbackShell( options.pprFallbackCacheShells.length === 0 || options.isRscRequest || options.request.method !== "GET" || + (!isForceStatic && hasSearchParams(options.searchParams)) || !shouldReadAppPageCache({ isDraftMode, isForceDynamic, @@ -876,6 +878,7 @@ async function dispatchAppPageInner( route, currentRevalidateSeconds, isDraftMode, + isForceStatic, isForceDynamic, ); if (fallbackShellResponse) { diff --git a/packages/vinext/src/server/prerender-manifest.ts b/packages/vinext/src/server/prerender-manifest.ts index 1f8954f03..9152eb3ec 100644 --- a/packages/vinext/src/server/prerender-manifest.ts +++ b/packages/vinext/src/server/prerender-manifest.ts @@ -19,7 +19,8 @@ export function readPrerenderManifest(manifestPath: string): PrerenderManifest | if (!fs.existsSync(manifestPath)) return null; try { return JSON.parse(fs.readFileSync(manifestPath, "utf-8")); - } catch { + } catch (error) { + console.warn(`[vinext] Failed to read prerender manifest at ${manifestPath}:`, error); return null; } } diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index 9010fdbc5..148f7de50 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -1347,6 +1347,62 @@ describe("app page dispatch", () => { expect(buildPageElement).not.toHaveBeenCalled(); }); + it("does not serve fallback shell HTML for an unknown child param when the request has search params", async () => { + const buildPageElement = vi.fn( + async ( + _route: TestRoute, + params: Record, + _opts: Parameters[2], + searchParams: URLSearchParams, + ) => `fresh:${JSON.stringify(params)}?${searchParams}`, + ); + const isrGet = vi.fn(async (key: string) => { + if (key === "html:/en/blog/[slug]") { + return buildISRCacheEntry( + buildCachedAppPageValue("Locale: en"), + false, + ); + } + return null; + }); + const { options } = createDispatchOptions({ + buildPageElement, + cleanPathname: "/en/blog/new-post", + isProduction: true, + isrGet, + loadSsrHandler: async () => ({ + async handleSsr() { + return createStream(["fresh render"]); + }, + }), + params: { locale: "en", slug: "new-post" }, + pprFallbackCacheShells: [ + { + fallbackParamNames: ["slug"], + params: { locale: "en", slug: "[slug]" }, + pathname: "/en/blog/[slug]", + }, + ], + revalidateSeconds: 60, + request: new Request("https://example.test/en/blog/new-post?preview=1"), + route: createRoute({ + isDynamic: true, + params: ["locale", "slug"], + pattern: "/:locale/blog/:slug", + routeSegments: ["[locale]", "blog", "[slug]"], + }), + searchParams: new URLSearchParams("preview=1"), + }); + + const response = await dispatchAppPage(options); + + expect(isrGet.mock.calls.map(([key]) => key)).toEqual(["html:/en/blog/new-post"]); + expect(isrGet).not.toHaveBeenCalledWith("html:/en/blog/[slug]"); + expect(response.headers.get("x-vinext-cache")).toBe("MISS"); + await expect(response.text()).resolves.toContain("fresh render"); + expect(buildPageElement).toHaveBeenCalled(); + }); + it("serves stale PPR fallback-shell HTML and regenerates the shell key", async () => { let navigationContext: NavigationContext = { pathname: "/en/blog/new-post", diff --git a/tests/deploy.test.ts b/tests/deploy.test.ts index 037710a50..98199f378 100644 --- a/tests/deploy.test.ts +++ b/tests/deploy.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect, beforeEach, afterEach } from "vite-plus/test"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vite-plus/test"; import fs from "node:fs"; import path from "node:path"; import os from "node:os"; +import { pathToFileURL } from "node:url"; import { detectProject, generateWranglerConfig, @@ -41,6 +42,7 @@ import { resolveStaticAssetSignal, } from "../packages/vinext/src/server/worker-utils.js"; import { domainCandidates, parseWranglerConfig } from "../packages/vinext/src/cloudflare/tpr.js"; +import { clearPregeneratedConcretePaths } from "../packages/vinext/src/server/pregenerated-concrete-paths.js"; // ─── Test Helpers ──────────────────────────────────────────────────────────── @@ -2999,7 +3001,46 @@ describe("injectPregeneratedConcretePaths", () => { expect(table).toEqual([["/blog/[slug]", ["/blog/post-a"]]]); }); + it("hydrates the concrete-path registry when the generated Worker entry is imported", async () => { + const registryModuleUrl = pathToFileURL( + path.resolve("packages/vinext/src/server/pregenerated-concrete-paths.ts"), + ).href; + const sourceCode = [ + `import { getRenderedConcreteUrlPathsForRoute, initPregeneratedPathsFromGlobals } from ${JSON.stringify(registryModuleUrl)};`, + "initPregeneratedPathsFromGlobals();", + 'export const renderedPaths = [...(getRenderedConcreteUrlPathsForRoute("/blog/[slug]") ?? [])];', + 'export default { fetch(request) { return new Response("ok"); } };', + "", + ].join("\n"); + const manifest = { + buildId: "test", + routes: [ + { + route: "/blog/[slug]", + status: "rendered", + router: "app", + path: "/blog/post-a", + revalidate: 60, + }, + ], + }; + + clearPregeneratedConcretePaths(); + mkdir(tmpDir, "dist/server"); + writeFile(tmpDir, "dist/server/index.js", sourceCode); + writeFile(tmpDir, "dist/server/vinext-prerender.json", JSON.stringify(manifest)); + injectPregeneratedConcretePaths(tmpDir); + + const entryUrl = pathToFileURL(path.join(tmpDir, "dist/server/index.js")).href; + const workerEntry: unknown = await import(`${entryUrl}?t=${Date.now()}`); + + expect(workerEntry).toMatchObject({ + renderedPaths: ["/blog/post-a"], + }); + }); + it("corrupt manifest strips prior injection", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const priorInjection = [ "/* __VINEXT_PREGENERATED_CONCRETE_PATHS_START__ */", 'globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = [["/",["/"]]];', @@ -3017,5 +3058,10 @@ describe("injectPregeneratedConcretePaths", () => { const after = fs.readFileSync(path.join(tmpDir, "dist/server/index.js"), "utf-8"); expect(after).not.toContain("__VINEXT_PREGENERATED_CONCRETE_PATHS"); expect(after).toContain('export default { fetch(request) { return new Response("ok"); } }'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("[vinext] Failed to read prerender manifest"), + expect.any(SyntaxError), + ); + warnSpy.mockRestore(); }); }); From 77379e01651302d4b6b64b30771b119e41ac923f Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:18:20 +1000 Subject: [PATCH 7/7] fix(ppr): normalize pregenerated concrete paths --- .../src/server/pregenerated-concrete-paths.ts | 4 +-- tests/app-ppr-fallback-shell.test.ts | 25 +++++++++++++++++++ tests/pregenerated-concrete-paths.test.ts | 8 ++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/vinext/src/server/pregenerated-concrete-paths.ts b/packages/vinext/src/server/pregenerated-concrete-paths.ts index 74bc5db6d..587a692fa 100644 --- a/packages/vinext/src/server/pregenerated-concrete-paths.ts +++ b/packages/vinext/src/server/pregenerated-concrete-paths.ts @@ -29,7 +29,7 @@ export function addPregeneratedConcretePath(routePattern: string, pathname: stri paths = new Set(); concreteUrlPathsByRoute.set(routePattern, paths); } - paths.add(pathname); + paths.add(normalizePregeneratedPathname(pathname)); } export function getRenderedConcreteUrlPathsForRoute( @@ -50,7 +50,7 @@ export function initPregeneratedPathsFromGlobals(): void { clearPregeneratedConcretePaths(); for (const [routePattern, pathnames] of data) { for (const pathname of pathnames) { - addPregeneratedConcretePath(routePattern, normalizePregeneratedPathname(pathname)); + addPregeneratedConcretePath(routePattern, pathname); } } } diff --git a/tests/app-ppr-fallback-shell.test.ts b/tests/app-ppr-fallback-shell.test.ts index 07fd5dd19..d53d3e512 100644 --- a/tests/app-ppr-fallback-shell.test.ts +++ b/tests/app-ppr-fallback-shell.test.ts @@ -126,4 +126,29 @@ describe("rewriteAppPprFallbackShellHtmlNavigation", () => { expect(headCloseIndex).toBeGreaterThanOrEqual(0); expect(paramsIndex).toBeLessThan(headCloseIndex); }); + + it("appends actual request metadata after cached placeholder metadata", () => { + const placeholderHtml = rewriteAppPprFallbackShellHtmlNavigation({ + html: "xshell", + params: { locale: "en", slug: "[slug]" }, + pathname: "/en/blog/[slug]", + searchParams: new URLSearchParams(), + }); + const html = rewriteAppPprFallbackShellHtmlNavigation({ + html: placeholderHtml, + params: { locale: "en", slug: "new-post" }, + pathname: "/en/blog/new-post", + searchParams: new URLSearchParams([["preview", "1"]]), + }); + + const placeholderIndex = html.indexOf('params:{"locale":"en","slug":"[slug]"}'); + const actualIndex = html.indexOf('params:{"locale":"en","slug":"new-post"}'); + const headCloseIndex = html.indexOf(""); + + expect(placeholderIndex).toBeGreaterThanOrEqual(0); + expect(actualIndex).toBeGreaterThan(placeholderIndex); + expect(actualIndex).toBeLessThan(headCloseIndex); + expect(html).toContain('"pathname":"/en/blog/new-post"'); + expect(html).toContain('"searchParams":[["preview","1"]]'); + }); }); diff --git a/tests/pregenerated-concrete-paths.test.ts b/tests/pregenerated-concrete-paths.test.ts index f89cffe6d..470d3057e 100644 --- a/tests/pregenerated-concrete-paths.test.ts +++ b/tests/pregenerated-concrete-paths.test.ts @@ -65,6 +65,14 @@ describe("pregenerated concrete paths", () => { expect(normalizePregeneratedPathname("/en/blog/hello%20world")).toBe("/en/blog/hello world"); }); + it("normalizes pathnames when adding concrete paths", () => { + addPregeneratedConcretePath("/:locale/blog/:slug", "/en/blog/hello%20world"); + + expect([...getRenderedConcreteUrlPathsForRoute("/:locale/blog/:slug")!]).toEqual([ + "/en/blog/hello world", + ]); + }); + it("initializes from the Worker global concrete-path table", () => { globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS = [ ["/:locale/blog/:slug", ["/en/blog/hello%20world"]],