Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions packages/vinext/src/server/pregenerated-concrete-paths.ts
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 {

Copy link
Copy Markdown
Contributor

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 cleanPathname as normalizePath(normalizePathnameForRouteMatchStrict(...)) (the strict variant — see app-rsc-request-normalization.ts:94-100), whereas this uses the non-strict normalizePathnameForRouteMatch. 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.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking API-shape note: addPregeneratedConcretePath stores the pathname verbatim, while initPregeneratedPathsFromGlobals normalizes before calling it. The doc comment says the Node path (seed-cache.ts) will populate this directly — that caller will need to remember to call normalizePregeneratedPathname itself, or it'll store un-normalized paths that won't match the runtime cleanPathname lookup. Consider either normalizing inside addPregeneratedConcretePath (single source of truth) or adding a brief comment that callers must pre-normalize. The current tests only exercise already-normalized inputs, so this footgun isn't caught.

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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking encapsulation note: getRenderedConcreteUrlPathsForRoute returns the live backing Set directly. The ReadonlySet<string> return type prevents accidental mutation at compile time, but a caller could still cast or a future JS-only consumer could mutate the registry's internal state. Given the serving consumer in #1716 will be read-only this is fine, but worth keeping in mind if any caller ever needs to hold the reference across re-seeds (initPregeneratedPathsFromGlobals calls clearPregeneratedConcretePaths, which empties the map but the previously-returned Set reference would be stale).

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking note: parsePregeneratedConcretePaths validates shape strictly (good — rejects the whole payload on any malformed entry), but does not dedupe repeated routePattern entries in the global array. If deploy.ts ever emits the same pattern twice, the paths merge additively into the same Set (harmless, since addPregeneratedConcretePath dedups by value). Just confirming this is the intended/acceptable behavior — no change needed unless you want a strict "one entry per pattern" invariant enforced here.

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;
}
5 changes: 3 additions & 2 deletions packages/vinext/src/server/prerender-route-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -145,7 +146,7 @@ export function prerenderRouteParamsPayloadMatchesRoute(
return match?.kind === "exact";
}

function matchPrerenderRouteParamsPayload(
export function matchPrerenderRouteParamsPayload(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that matchPrerenderRouteParamsPayload is exported for the serving PR, consider also exporting the PrerenderRouteParamsRouteMatch return type (line 12). Consumers in #1716 that want to discriminate on match.kind or write explicit annotations will otherwise have to re-derive it via ReturnType<typeof matchPrerenderRouteParamsPayload>.

payload: PrerenderRouteParamsPayload | null,
routePattern: string,
params: PrerenderRouteParams,
Expand Down
89 changes: 89 additions & 0 deletions tests/pregenerated-concrete-paths.test.ts
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",
]);
});
});
103 changes: 99 additions & 4 deletions tests/prerender-route-params.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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" },
});
});

Expand All @@ -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" },
});
});
});
Loading