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
80 changes: 40 additions & 40 deletions packages/vinext/src/build/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,45 +245,54 @@ export function getOutputPath(urlPath: string, trailingSlash: boolean): string {
return `${clean}.html`;
}

/** Map of route patterns to generateStaticParams functions (or null/undefined). */
export type StaticParamsMap = Record<
string,
| ((opts: {
params: Record<string, string | string[]>;
}) => Promise<Record<string, string | string[]>[]>)
| null
| undefined
>;

/**
* Resolve parent dynamic segment params for a route.
* Handles top-down generateStaticParams resolution for nested dynamic routes.
*
* Uses the `staticParamsMap` (pattern → generateStaticParams) exported from
* the production bundle.
*/
async function resolveParentParams(
export async function resolveParentParams(
childRoute: AppRoute,
allRoutes: AppRoute[],
staticParamsMap: Record<
string,
| ((opts: {
params: Record<string, string | string[]>;
}) => Promise<Record<string, string | string[]>[]>)
| null
| undefined
>,
routeIndex: ReadonlyMap<string, AppRoute>,
staticParamsMap: StaticParamsMap,
): Promise<Record<string, string | string[]>[]> {
const patternParts = childRoute.pattern.split("/").filter(Boolean);
const { patternParts } = childRoute;

// The last dynamic segment belongs to the child route itself — its params
// are resolved by the child's own generateStaticParams. We only collect
// params from earlier (parent) dynamic segments.
let lastDynamicIdx = -1;
for (let i = patternParts.length - 1; i >= 0; i--) {
if (patternParts[i].startsWith(":")) {
lastDynamicIdx = i;
break;
}
}

type ParentSegment = {
params: string[];
generateStaticParams: (opts: {
params: Record<string, string | string[]>;
}) => Promise<Record<string, string | string[]>[]>;
};
type GenerateStaticParamsFn = (opts: {
params: Record<string, string | string[]>;
}) => Promise<Record<string, string | string[]>[]>;

const parentSegments: ParentSegment[] = [];
const parentSegments: GenerateStaticParamsFn[] = [];

for (let i = 0; i < patternParts.length; i++) {
let prefixPattern = "";
for (let i = 0; i < lastDynamicIdx; i++) {
const part = patternParts[i];
prefixPattern += "/" + part;
if (!part.startsWith(":")) continue;

const isLastDynamicPart = !patternParts.slice(i + 1).some((p) => p.startsWith(":"));
if (isLastDynamicPart) break;

const prefixPattern = "/" + patternParts.slice(0, i + 1).join("/");
const parentRoute = allRoutes.find((r) => r.pattern === prefixPattern);
const parentRoute = routeIndex.get(prefixPattern);
// TODO: layout-level generateStaticParams — a layout segment can define
// generateStaticParams without a corresponding page file, so parentRoute
// may be undefined here even though the layout exports generateStaticParams.
Expand All @@ -293,22 +302,18 @@ async function resolveParentParams(
if (parentRoute?.pagePath) {
const fn = staticParamsMap[prefixPattern];
if (typeof fn === "function") {
const paramName = part.replace(/^:/, "").replace(/[+*]$/, "");
parentSegments.push({
params: [paramName],
generateStaticParams: fn,
});
parentSegments.push(fn);
}
}
}

if (parentSegments.length === 0) return [];

let currentParams: Record<string, string | string[]>[] = [{}];
for (const segment of parentSegments) {
for (const generateStaticParams of parentSegments) {
const nextParams: Record<string, string | string[]>[] = [];
for (const parentParams of currentParams) {
const results = await segment.generateStaticParams({ params: parentParams });
const results = await generateStaticParams({ params: parentParams });
if (Array.isArray(results)) {
for (const result of results) {
nextParams.push({ ...parentParams, ...result });
Expand Down Expand Up @@ -690,14 +695,7 @@ export async function prerenderApp({
const serverDir = path.dirname(rscBundlePath);

let rscHandler: (request: Request) => Promise<Response>;
let staticParamsMap: Record<
string,
| ((opts: {
params: Record<string, string | string[]>;
}) => Promise<Record<string, string | string[]>[]>)
| null
| undefined
> = {};
let staticParamsMap: StaticParamsMap = {};
// ownedProdServer: a prod server we started ourselves and must close in finally.
// When the caller passes options._prodServer we use that and do NOT close it.
let ownedProdServerHandle: { server: HttpServer; port: number } | null = null;
Expand Down Expand Up @@ -804,6 +802,8 @@ export async function prerenderApp({
},
});

const routeIndex = new Map(routes.map((r) => [r.pattern, r]));

// ── Collect URLs to render ────────────────────────────────────────────────
type UrlToRender = {
urlPath: string;
Expand Down Expand Up @@ -887,7 +887,7 @@ export async function prerenderApp({
continue;
}

const parentParamSets = await resolveParentParams(route, routes, staticParamsMap);
const parentParamSets = await resolveParentParams(route, routeIndex, staticParamsMap);
let paramSets: Record<string, string | string[]>[] | null;

if (parentParamSets.length > 0) {
Expand Down
143 changes: 142 additions & 1 deletion tests/prerender.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { buildPagesFixture, buildAppFixture, buildCloudflareAppFixture } from "./helpers.js";
import type { PrerenderRouteResult } from "../packages/vinext/src/build/prerender.js";
import {
resolveParentParams,
type PrerenderRouteResult,
type StaticParamsMap,
} from "../packages/vinext/src/build/prerender.js";
import type { AppRoute } from "../packages/vinext/src/routing/app-router.js";

const PAGES_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/pages-basic");
const APP_FIXTURE = path.resolve(import.meta.dirname, "./fixtures/app-basic");
Expand Down Expand Up @@ -719,3 +724,139 @@ describe("Cloudflare Workers hybrid build (cf-app-basic)", () => {
});
});
});

// ─── resolveParentParams unit tests ─────────────────────────────────────────

function mockRoute(pattern: string, opts: { pagePath?: string | null } = {}): AppRoute {
const parts = pattern.split("/").filter(Boolean);
return {
pattern,
pagePath: opts.pagePath ?? `/app${pattern}/page.tsx`,
routePath: null,
layouts: [],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [],
notFoundPath: null,
notFoundPaths: [],
forbiddenPath: null,
unauthorizedPath: null,
routeSegments: [],
layoutTreePositions: [],
isDynamic: parts.some((p) => p.startsWith(":")),
params: parts
.filter((p) => p.startsWith(":"))
.map((p) => p.replace(/^:/, "").replace(/[+*]$/, "")),
patternParts: parts,
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.

Ultra-minor: this mockRoute helper manually constructs every field of AppRoute. If AppRoute gains new required fields in the future, this will silently fail to compile or — worse — produce a confusing test failure rather than a clear type error.

Consider adding satisfies AppRoute to the return (TypeScript 4.9+) so the compiler catches any shape drift immediately:

Suggested change
patternParts: parts,
} satisfies AppRoute;

};
}

function routeIndexFrom(routes: AppRoute[]): ReadonlyMap<string, AppRoute> {
return new Map(routes.map((r) => [r.pattern, r]));
}

describe("resolveParentParams", () => {
it("returns empty array when route has no parent dynamic segments", async () => {
const route = mockRoute("/blog/:slug");
const result = await resolveParentParams(route, routeIndexFrom([route]), {});
expect(result).toEqual([]);
});

it("returns empty array when parent route has no pagePath", async () => {
const parent = mockRoute("/shop/:category", { pagePath: null });
const child = mockRoute("/shop/:category/:item");
const result = await resolveParentParams(child, routeIndexFrom([parent, child]), {});
expect(result).toEqual([]);
});

it("returns empty array when parent has no generateStaticParams", async () => {
const parent = mockRoute("/shop/:category");
const child = mockRoute("/shop/:category/:item");
const staticParamsMap: StaticParamsMap = {};
const result = await resolveParentParams(
child,
routeIndexFrom([parent, child]),
staticParamsMap,
);
expect(result).toEqual([]);
});

it("resolves single parent dynamic segment", async () => {
const parent = mockRoute("/shop/:category");
const child = mockRoute("/shop/:category/:item");
const staticParamsMap: StaticParamsMap = {
"/shop/:category": async () => [{ category: "electronics" }, { category: "clothing" }],
};
const result = await resolveParentParams(
child,
routeIndexFrom([parent, child]),
staticParamsMap,
);
expect(result).toEqual([{ category: "electronics" }, { category: "clothing" }]);
});

it("resolves two levels of parent dynamic segments", async () => {
const grandparent = mockRoute("/a/:b");
const parent = mockRoute("/a/:b/c/:d");
const child = mockRoute("/a/:b/c/:d/:e");
const staticParamsMap: StaticParamsMap = {
"/a/:b": async () => [{ b: "1" }, { b: "2" }],
"/a/:b/c/:d": async ({ params }) => {
if (params.b === "1") return [{ d: "x" }];
return [{ d: "y" }, { d: "z" }];
},
};
const result = await resolveParentParams(
child,
routeIndexFrom([grandparent, parent, child]),
staticParamsMap,
);
expect(result).toEqual([
{ b: "1", d: "x" },
{ b: "2", d: "y" },
{ b: "2", d: "z" },
]);
});

it("skips static segments between dynamic parents", async () => {
const parent = mockRoute("/shop/:category");
const child = mockRoute("/shop/:category/details/:item");
const staticParamsMap: StaticParamsMap = {
"/shop/:category": async () => [{ category: "shoes" }],
};
const result = await resolveParentParams(
child,
routeIndexFrom([parent, child]),
staticParamsMap,
);
expect(result).toEqual([{ category: "shoes" }]);
});

it("returns empty array for a fully static route", async () => {
const route = mockRoute("/about/contact");
const result = await resolveParentParams(route, routeIndexFrom([route]), {});
expect(result).toEqual([]);
});

it("returns empty array for a single-segment dynamic route", async () => {
const route = mockRoute("/:id");
const result = await resolveParentParams(route, routeIndexFrom([route]), {});
expect(result).toEqual([]);
});

it("resolves parent with catch-all child segment", async () => {
const parent = mockRoute("/shop/:category");
const child = mockRoute("/shop/:category/:rest+");
const staticParamsMap: StaticParamsMap = {
"/shop/:category": async () => [{ category: "electronics" }],
};
const result = await resolveParentParams(
child,
routeIndexFrom([parent, child]),
staticParamsMap,
);
expect(result).toEqual([{ category: "electronics" }]);
});
});
Loading