From ed6afb2c0d4574f298c814135fc15f3562a094cc 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 1/4] feat(ppr): add fallback shell payload identity --- .../src/server/pregenerated-concrete-paths.ts | 75 +++++++++++++ .../src/server/prerender-route-params.ts | 2 +- tests/pregenerated-concrete-paths.test.ts | 79 ++++++++++++++ tests/prerender-route-params.test.ts | 103 +++++++++++++++++- 4 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 packages/vinext/src/server/pregenerated-concrete-paths.ts create mode 100644 tests/pregenerated-concrete-paths.test.ts diff --git a/packages/vinext/src/server/pregenerated-concrete-paths.ts b/packages/vinext/src/server/pregenerated-concrete-paths.ts new file mode 100644 index 000000000..74bc5db6d --- /dev/null +++ b/packages/vinext/src/server/pregenerated-concrete-paths.ts @@ -0,0 +1,75 @@ +import { normalizePathnameForRouteMatch } from "../routing/utils.js"; +import { normalizePath } from "./normalize-path.js"; + +declare global { + var __VINEXT_PREGENERATED_CONCRETE_PATHS: unknown; +} + +export function normalizePregeneratedPathname(pathname: string): string { + return normalizePath(normalizePathnameForRouteMatch(pathname)); +} + +/** + * Stores concrete URL paths pre-rendered at build time per route pattern. + * Used by the PPR fallback-shell guard to avoid serving fallback shells for + * known routes whose exact cache entry is temporarily absent. + * + * Populated by `seed-cache.ts` (Node) or from `globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS` + * injected by `deploy.ts` after prerender (Workers). + */ +const concreteUrlPathsByRoute = new Map>(); + +export function clearPregeneratedConcretePaths(): void { + concreteUrlPathsByRoute.clear(); +} + +export function addPregeneratedConcretePath(routePattern: string, pathname: string): void { + let paths = concreteUrlPathsByRoute.get(routePattern); + if (!paths) { + paths = new Set(); + concreteUrlPathsByRoute.set(routePattern, paths); + } + paths.add(pathname); +} + +export function getRenderedConcreteUrlPathsForRoute( + routePattern: string, +): ReadonlySet | undefined { + return concreteUrlPathsByRoute.get(routePattern); +} + +/** + * Populate the registry from `globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS`. + * No-op when the global is not set (Node path — seed-cache handles it later). + * Pathnames are normalised so they match the runtime `cleanPathname`. + */ +export function initPregeneratedPathsFromGlobals(): void { + const raw = globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS; + const data = parsePregeneratedConcretePaths(raw); + if (!data) return; + clearPregeneratedConcretePaths(); + for (const [routePattern, pathnames] of data) { + for (const pathname of pathnames) { + addPregeneratedConcretePath(routePattern, normalizePregeneratedPathname(pathname)); + } + } +} + +function parsePregeneratedConcretePaths(value: unknown): Array<[string, string[]]> | undefined { + if (!Array.isArray(value)) return undefined; + const result: Array<[string, string[]]> = []; + for (const entry of value) { + if (!Array.isArray(entry)) return undefined; + if (entry.length !== 2) return undefined; + const [pattern, paths] = entry; + if (typeof pattern !== "string") return undefined; + if (!Array.isArray(paths)) return undefined; + const strings: string[] = []; + for (const p of paths) { + if (typeof p !== "string") return undefined; + strings.push(p); + } + result.push([pattern, strings]); + } + return result; +} 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 new file mode 100644 index 000000000..f89cffe6d --- /dev/null +++ b/tests/pregenerated-concrete-paths.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, afterEach } from "vite-plus/test"; +import { + clearPregeneratedConcretePaths, + addPregeneratedConcretePath, + getRenderedConcreteUrlPathsForRoute, + initPregeneratedPathsFromGlobals, + normalizePregeneratedPathname, +} from "../packages/vinext/src/server/pregenerated-concrete-paths.js"; + +describe("pregenerated concrete paths", () => { + afterEach(() => { + clearPregeneratedConcretePaths(); + }); + + it("returns undefined for an unknown route pattern", () => { + expect(getRenderedConcreteUrlPathsForRoute("/en/blog/[slug]")).toBeUndefined(); + }); + + it("stores and retrieves pathnames for a route pattern", () => { + addPregeneratedConcretePath("/:locale/blog/:slug", "/en/blog/hello"); + addPregeneratedConcretePath("/:locale/blog/:slug", "/fr/blog/bonjour"); + + const paths = getRenderedConcreteUrlPathsForRoute("/:locale/blog/:slug"); + expect(paths).toBeDefined(); + expect([...paths!]).toEqual(["/en/blog/hello", "/fr/blog/bonjour"]); + }); + + it("supports independent route patterns", () => { + addPregeneratedConcretePath("/:locale/blog/:slug", "/en/blog/hello"); + addPregeneratedConcretePath("/products/:id", "/products/42"); + + expect([...getRenderedConcreteUrlPathsForRoute("/:locale/blog/:slug")!]).toEqual([ + "/en/blog/hello", + ]); + expect([...getRenderedConcreteUrlPathsForRoute("/products/:id")!]).toEqual(["/products/42"]); + }); + + it("returns an empty state after clear", () => { + addPregeneratedConcretePath("/en/blog/[slug]", "/en/blog/persistent"); + expect(getRenderedConcreteUrlPathsForRoute("/en/blog/[slug]")).toBeDefined(); + + clearPregeneratedConcretePaths(); + + expect(getRenderedConcreteUrlPathsForRoute("/en/blog/[slug]")).toBeUndefined(); + }); + + it("clears stale paths from a previous build on re-population (issue 3)", () => { + // Build A + addPregeneratedConcretePath("/en/blog/[slug]", "/en/blog/old"); + addPregeneratedConcretePath("/en/blog/[slug]", "/en/blog/also-old"); + expect(getRenderedConcreteUrlPathsForRoute("/en/blog/[slug]")!.size).toBe(2); + + // Build B — clear and re-seed without the old paths + clearPregeneratedConcretePaths(); + addPregeneratedConcretePath("/en/blog/[slug]", "/en/blog/new"); + + const paths = getRenderedConcreteUrlPathsForRoute("/en/blog/[slug]")!; + expect(paths.has("/en/blog/old")).toBe(false); + expect(paths.has("/en/blog/also-old")).toBe(false); + expect(paths.has("/en/blog/new")).toBe(true); + expect(paths.size).toBe(1); + }); + + it("normalizes percent-encoded pathnames", () => { + expect(normalizePregeneratedPathname("/en/blog/hello%20world")).toBe("/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"]], + ]; + initPregeneratedPathsFromGlobals(); + delete globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS; + + expect([...getRenderedConcreteUrlPathsForRoute("/:locale/blog/:slug")!]).toEqual([ + "/en/blog/hello world", + ]); + }); +}); diff --git a/tests/prerender-route-params.test.ts b/tests/prerender-route-params.test.ts index 23c95bb31..37833bada 100644 --- a/tests/prerender-route-params.test.ts +++ b/tests/prerender-route-params.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { encodePrerenderRouteParams, + matchPrerenderRouteParamsPayload, prerenderRouteParamsPayloadMatchesRoute, type PrerenderRouteParamsPayload, } from "../packages/vinext/src/server/prerender-route-params.js"; @@ -95,14 +96,96 @@ describe("prerenderRouteParamsPayloadMatchesRoute", () => { }); }); +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("round-trips fallbackParamNames when provided", () => { - const payload = encodePrerenderRouteParams("/product/:id", { id: "abc" }, ["id"]); + it("encodes exact params without fallbackParamNames", () => { + const result = encodePrerenderRouteParams("/product/:id", { id: "abc" }); - expect(payload).toEqual({ + expect(result).toEqual({ routePattern: "/product/:id", params: { id: "abc" }, - fallbackParamNames: ["id"], + }); + }); + + 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" }, }); }); @@ -122,4 +205,16 @@ describe("encodePrerenderRouteParams", () => { it("returns null when there are no dynamic params even with fallbackParamNames", () => { expect(encodePrerenderRouteParams("/about", {}, ["id"])).toBe(null); }); + + 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" }, + }); + }); }); From 279e609d9890ee02c03ac18b1c1db46799fdb0a6 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 3 Jun 2026 11:12:54 +0100 Subject: [PATCH 2/4] refactor(ppr): address review feedback on payload identity helpers - normalizePregeneratedPathname: document the deliberate non-strict vs strict normalization choice so it isn't "fixed" into a build-time throw - addPregeneratedConcretePath: normalize the pathname internally as the single source of truth, removing the caller pre-normalize footgun - export PrerenderRouteParamsRouteMatch for #1716 consumers - cover direct un-normalized recording with a test --- .../src/server/pregenerated-concrete-paths.ts | 23 ++++++++++++++++--- .../src/server/prerender-route-params.ts | 2 +- tests/pregenerated-concrete-paths.test.ts | 10 ++++++++ 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/vinext/src/server/pregenerated-concrete-paths.ts b/packages/vinext/src/server/pregenerated-concrete-paths.ts index 74bc5db6d..75e0369d8 100644 --- a/packages/vinext/src/server/pregenerated-concrete-paths.ts +++ b/packages/vinext/src/server/pregenerated-concrete-paths.ts @@ -5,6 +5,15 @@ 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)); } @@ -23,13 +32,20 @@ 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(pathname); + paths.add(normalized); } export function getRenderedConcreteUrlPathsForRoute( @@ -41,7 +57,8 @@ 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). - * Pathnames are normalised so they match the runtime `cleanPathname`. + * `addPregeneratedConcretePath` normalizes each pathname so it matches the + * runtime `cleanPathname`. */ export function initPregeneratedPathsFromGlobals(): void { const raw = globalThis.__VINEXT_PREGENERATED_CONCRETE_PATHS; @@ -50,7 +67,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/packages/vinext/src/server/prerender-route-params.ts b/packages/vinext/src/server/prerender-route-params.ts index fdbe1f8ee..535ba76c3 100644 --- a/packages/vinext/src/server/prerender-route-params.ts +++ b/packages/vinext/src/server/prerender-route-params.ts @@ -9,7 +9,7 @@ export type PrerenderRouteParamsPayload = { routePattern: string; }; -type PrerenderRouteParamsRouteMatch = +export type PrerenderRouteParamsRouteMatch = | { kind: "exact"; params: PrerenderRouteParams; diff --git a/tests/pregenerated-concrete-paths.test.ts b/tests/pregenerated-concrete-paths.test.ts index f89cffe6d..b07b67101 100644 --- a/tests/pregenerated-concrete-paths.test.ts +++ b/tests/pregenerated-concrete-paths.test.ts @@ -65,6 +65,16 @@ 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"]], From 578901e40738ab5c26f9b5affa1393c76ba8323a Mon Sep 17 00:00:00 2001 From: James Date: Wed, 3 Jun 2026 11:18:55 +0100 Subject: [PATCH 3/4] fix(ppr): mark PrerenderRouteParamsRouteMatch @public for knip The type is exported for #1716's serving consumers but has no in-repo reference yet, so knip's no-unused-exports gate flags it. Knip excludes exports tagged @public from that report; this keeps the reviewer-requested export without failing CI. --- packages/vinext/src/server/prerender-route-params.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/vinext/src/server/prerender-route-params.ts b/packages/vinext/src/server/prerender-route-params.ts index 535ba76c3..282716edf 100644 --- a/packages/vinext/src/server/prerender-route-params.ts +++ b/packages/vinext/src/server/prerender-route-params.ts @@ -9,6 +9,7 @@ export type PrerenderRouteParamsPayload = { routePattern: string; }; +/** @public exported for #1716 serving consumers; not yet referenced in-repo */ export type PrerenderRouteParamsRouteMatch = | { kind: "exact"; From 41e31f290bb0ab17bcdd368683c6baab33effd80 Mon Sep 17 00:00:00 2001 From: James Date: Wed, 3 Jun 2026 11:26:29 +0100 Subject: [PATCH 4/4] docs(ppr): document live-Set and dedup contracts on concrete-path registry Address bonk review notes: - getRenderedConcreteUrlPathsForRoute returns the live backing Set for allocation-free hot-path lookups; document that callers must not retain the reference across a re-seed (clear empties the map, stranding it). - parsePregeneratedConcretePaths intentionally does not dedupe repeated route patterns; document that they merge additively and value-dedup in addPregeneratedConcretePath makes the result equivalent. --- .../src/server/pregenerated-concrete-paths.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/vinext/src/server/pregenerated-concrete-paths.ts b/packages/vinext/src/server/pregenerated-concrete-paths.ts index 75e0369d8..a8898becc 100644 --- a/packages/vinext/src/server/pregenerated-concrete-paths.ts +++ b/packages/vinext/src/server/pregenerated-concrete-paths.ts @@ -48,6 +48,15 @@ export function addPregeneratedConcretePath(routePattern: string, pathname: stri paths.add(normalized); } +/** + * 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 { @@ -72,6 +81,12 @@ export function initPregeneratedPathsFromGlobals(): void { } } +// 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[]]> = [];