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..a8898becc --- /dev/null +++ b/packages/vinext/src/server/pregenerated-concrete-paths.ts @@ -0,0 +1,107 @@ +import { normalizePathnameForRouteMatch } from "../routing/utils.js"; +import { normalizePath } from "./normalize-path.js"; + +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)); +} + +/** + * 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(); +} + +/** + * 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); +} + +/** + * 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 { + 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). + * `addPregeneratedConcretePath` normalizes each pathname so it matches 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, 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[]]> = []; + 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..282716edf 100644 --- a/packages/vinext/src/server/prerender-route-params.ts +++ b/packages/vinext/src/server/prerender-route-params.ts @@ -9,7 +9,8 @@ export type PrerenderRouteParamsPayload = { routePattern: string; }; -type PrerenderRouteParamsRouteMatch = +/** @public exported for #1716 serving consumers; not yet referenced in-repo */ +export type PrerenderRouteParamsRouteMatch = | { kind: "exact"; params: PrerenderRouteParams; @@ -145,7 +146,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..b07b67101 --- /dev/null +++ b/tests/pregenerated-concrete-paths.test.ts @@ -0,0 +1,89 @@ +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("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"]], + ]; + 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" }, + }); + }); });