diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts index 5a67a0a0..f2491d20 100644 --- a/packages/vinext/src/build/prerender.ts +++ b/packages/vinext/src/build/prerender.ts @@ -245,6 +245,16 @@ 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; + }) => Promise[]>) + | null + | undefined +>; + /** * Resolve parent dynamic segment params for a route. * Handles top-down generateStaticParams resolution for nested dynamic routes. @@ -252,38 +262,37 @@ export function getOutputPath(urlPath: string, trailingSlash: boolean): string { * 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; - }) => Promise[]>) - | null - | undefined - >, + routeIndex: ReadonlyMap, + staticParamsMap: StaticParamsMap, ): Promise[]> { - 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; - }) => Promise[]>; - }; + type GenerateStaticParamsFn = (opts: { + params: Record; + }) => Promise[]>; - 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. @@ -293,11 +302,7 @@ 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); } } } @@ -305,10 +310,10 @@ async function resolveParentParams( if (parentSegments.length === 0) return []; let currentParams: Record[] = [{}]; - for (const segment of parentSegments) { + for (const generateStaticParams of parentSegments) { const nextParams: Record[] = []; 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 }); @@ -690,14 +695,7 @@ export async function prerenderApp({ const serverDir = path.dirname(rscBundlePath); let rscHandler: (request: Request) => Promise; - let staticParamsMap: Record< - string, - | ((opts: { - params: Record; - }) => Promise[]>) - | 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; @@ -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; @@ -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[] | null; if (parentParamSets.length > 0) { diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index 6234220b..2b7d0593 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -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"); @@ -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, + }; +} + +function routeIndexFrom(routes: AppRoute[]): ReadonlyMap { + 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" }]); + }); +});