-
Notifications
You must be signed in to change notification settings - Fork 333
feat(ppr): add encodePrerenderRouteParams and match kind exact payload tests #1714
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ed6afb2
279e609
578901e
41e31f2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string, Set<string>>(); | ||
|
|
||
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking API-shape note: |
||
| 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking encapsulation note: |
||
| routePattern: string, | ||
| ): ReadonlySet<string> | 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking note: |
||
| 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; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now that |
||
| payload: PrerenderRouteParamsPayload | null, | ||
| routePattern: string, | ||
| params: PrerenderRouteParams, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| ]); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Non-blocking parity note: the runtime computes
cleanPathnameasnormalizePath(normalizePathnameForRouteMatchStrict(...))(the strict variant — seeapp-rsc-request-normalization.ts:94-100), whereas this uses the non-strictnormalizePathnameForRouteMatch. The two only diverge on malformed percent-encoding (e.g.%GG), which the runtime rejects with a 400 before any lookup happens, so valid pathnames normalize identically and lookups will still match. Worth a one-line comment here noting the strict-vs-non-strict choice is intentional (registry seeding shouldn't throw on build-time data), so a future reader doesn't "fix" it to strict and introduce a build-time throw.