From a4e39da53e6b1c2a5e15214f49f3488efb856d96 Mon Sep 17 00:00:00 2001 From: raed04 Date: Wed, 1 Apr 2026 06:48:29 +0300 Subject: [PATCH] feat(prerender): support layout-level generateStaticParams (#567) Layout files at dynamic segments (e.g. app/[category]/layout.tsx) can export generateStaticParams to provide parent params for nested child routes. Previously only page-level exports were scanned, silently skipping layout-only dynamic segments. Changes: - Extend generateStaticParamsMap code-gen to emit layout-level entries by walking each route's layouts[] and layoutTreePositions[] - Remove routeIndex guard in resolveParentParams; check staticParamsMap directly so layout-only prefixes are resolved - Handle null results as pass-through (CF Workers Proxy compatibility) instead of collapsing the param accumulator - Use decodeRouteSegment for static segments to match the authoritative convertSegmentsToRouteParts behavior - Remove dead routeIndex parameter and Map construction - Update entry-templates snapshots (removed stale TODO comments) - Add 10 new resolveParentParams tests covering layout-only parents, mixed page/layout, null pass-through, catch-all parents, error propagation, and three-level nesting Closes #567 --- packages/vinext/src/build/prerender.ts | 30 ++- packages/vinext/src/entries/app-rsc-entry.ts | 77 ++++++-- .../entry-templates.test.ts.snap | 30 --- tests/prerender.test.ts | 184 ++++++++++++++---- 4 files changed, 221 insertions(+), 100 deletions(-) diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts index f2491d20..61f82a55 100644 --- a/packages/vinext/src/build/prerender.ts +++ b/packages/vinext/src/build/prerender.ts @@ -264,7 +264,6 @@ export type StaticParamsMap = Record< */ export async function resolveParentParams( childRoute: AppRoute, - routeIndex: ReadonlyMap, staticParamsMap: StaticParamsMap, ): Promise[]> { const { patternParts } = childRoute; @@ -292,18 +291,12 @@ export async function resolveParentParams( prefixPattern += "/" + part; if (!part.startsWith(":")) continue; - 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. - // resolveParentParams currently only looks up routes that have a pagePath - // (i.e. leaf pages), missing layout-level providers. Fix requires scanning - // layout files in addition to page files during route collection. - if (parentRoute?.pagePath) { - const fn = staticParamsMap[prefixPattern]; - if (typeof fn === "function") { - parentSegments.push(fn); - } + // Check staticParamsMap directly — it now includes both page-level and + // layout-level generateStaticParams entries, so we don't need to verify + // that a page route exists at this prefix pattern. + const fn = staticParamsMap[prefixPattern]; + if (typeof fn === "function") { + parentSegments.push(fn); } } @@ -314,7 +307,12 @@ export async function resolveParentParams( const nextParams: Record[] = []; for (const parentParams of currentParams) { const results = await generateStaticParams({ params: parentParams }); - if (Array.isArray(results)) { + if (results === null) { + // No generateStaticParams at this level (e.g. CF Workers Proxy returned + // null for a prefix with no layout/page). Pass parent params through + // unchanged so deeper parent segments can still be resolved. + nextParams.push(parentParams); + } else if (Array.isArray(results)) { for (const result of results) { nextParams.push({ ...parentParams, ...result }); } @@ -802,8 +800,6 @@ export async function prerenderApp({ }, }); - const routeIndex = new Map(routes.map((r) => [r.pattern, r])); - // ── Collect URLs to render ──────────────────────────────────────────────── type UrlToRender = { urlPath: string; @@ -887,7 +883,7 @@ export async function prerenderApp({ continue; } - const parentParamSets = await resolveParentParams(route, routeIndex, staticParamsMap); + const parentParamSets = await resolveParentParams(route, staticParamsMap); let paramSets: Record[] | null; if (parentParamSets.length > 0) { diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 7ad6a457..d349d6a0 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -16,6 +16,7 @@ import type { NextRewrite, } from "../config/next-config.js"; import type { AppRoute } from "../routing/app-router.js"; +import { decodeRouteSegment } from "../routing/utils.js"; import { generateDevOriginCheckCode } from "../server/dev-origin-check.js"; import type { MetadataFileRoute } from "../server/metadata-routes.js"; import { @@ -1320,18 +1321,70 @@ async function __readFormDataWithLimit(request, maxBytes) { // Used by the prerender phase to enumerate dynamic route URLs without // loading route modules via the dev server. export const generateStaticParamsMap = { -// TODO: layout-level generateStaticParams — this map only includes routes that -// have a pagePath (leaf pages). Layout segments can also export generateStaticParams -// to provide parent params for nested dynamic routes, but they don't have a pagePath -// so they are excluded here. Supporting layout-level generateStaticParams requires -// scanning layout.tsx files separately and including them in this map. -${routes - .filter((r) => r.isDynamic && r.pagePath) - .map( - (r) => - ` ${JSON.stringify(r.pattern)}: ${getImportVar(r.pagePath!)}?.generateStaticParams ?? null,`, - ) - .join("\n")} +${(() => { + const entries: string[] = []; + const emittedPatterns = new Set(); + + // 1. Page-level entries (leaf pages with generateStaticParams) + for (const r of routes) { + if (r.isDynamic && r.pagePath) { + entries.push( + ` ${JSON.stringify(r.pattern)}: ${getImportVar(r.pagePath)}?.generateStaticParams ?? null,`, + ); + emittedPatterns.add(r.pattern); + } + } + + // 2. Layout-level entries (layouts at dynamic segments that may export generateStaticParams) + for (const r of routes) { + if (!r.isDynamic) continue; + for (let li = 0; li < r.layouts.length; li++) { + const layoutDepth = r.layoutTreePositions[li]; + // Build the prefix pattern from routeSegments up to this layout's depth + const prefixSegments = r.routeSegments.slice(0, layoutDepth); + // Convert filesystem segments to URL pattern parts (same logic as convertSegmentsToRouteParts) + const urlParts: string[] = []; + let hasDynamic = false; + for (const seg of prefixSegments) { + // Skip invisible segments + if (seg === "." || (seg.startsWith("(") && seg.endsWith(")")) || seg.startsWith("@")) + continue; + // Catch-all + const catchAll = seg.match(/^\[\.\.\.([\w-]+)\]$/); + if (catchAll) { + urlParts.push(`:${catchAll[1]}+`); + hasDynamic = true; + continue; + } + // Optional catch-all + const optCatchAll = seg.match(/^\[\[\.\.\.([\w-]+)\]\]$/); + if (optCatchAll) { + urlParts.push(`:${optCatchAll[1]}*`); + hasDynamic = true; + continue; + } + // Dynamic segment + const dyn = seg.match(/^\[([\w-]+)\]$/); + if (dyn) { + urlParts.push(`:${dyn[1]}`); + hasDynamic = true; + continue; + } + // Static segment — decode percent-encoded chars to match convertSegmentsToRouteParts + urlParts.push(decodeRouteSegment(seg)); + } + if (!hasDynamic) continue; + const prefixPattern = "/" + urlParts.join("/"); + if (emittedPatterns.has(prefixPattern)) continue; + emittedPatterns.add(prefixPattern); + entries.push( + ` ${JSON.stringify(prefixPattern)}: ${getImportVar(r.layouts[li])}?.generateStaticParams ?? null,`, + ); + } + } + + return entries.join("\n"); +})()} }; export default async function handler(request, ctx) { diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 9082534c..e2bbd180 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1246,11 +1246,6 @@ async function __readFormDataWithLimit(request, maxBytes) { // Used by the prerender phase to enumerate dynamic route URLs without // loading route modules via the dev server. export const generateStaticParamsMap = { -// TODO: layout-level generateStaticParams — this map only includes routes that -// have a pagePath (leaf pages). Layout segments can also export generateStaticParams -// to provide parent params for nested dynamic routes, but they don't have a pagePath -// so they are excluded here. Supporting layout-level generateStaticParams requires -// scanning layout.tsx files separately and including them in this map. "/blog/:slug": mod_3?.generateStaticParams ?? null, }; @@ -3440,11 +3435,6 @@ async function __readFormDataWithLimit(request, maxBytes) { // Used by the prerender phase to enumerate dynamic route URLs without // loading route modules via the dev server. export const generateStaticParamsMap = { -// TODO: layout-level generateStaticParams — this map only includes routes that -// have a pagePath (leaf pages). Layout segments can also export generateStaticParams -// to provide parent params for nested dynamic routes, but they don't have a pagePath -// so they are excluded here. Supporting layout-level generateStaticParams requires -// scanning layout.tsx files separately and including them in this map. "/blog/:slug": mod_3?.generateStaticParams ?? null, }; @@ -5646,11 +5636,6 @@ async function __readFormDataWithLimit(request, maxBytes) { // Used by the prerender phase to enumerate dynamic route URLs without // loading route modules via the dev server. export const generateStaticParamsMap = { -// TODO: layout-level generateStaticParams — this map only includes routes that -// have a pagePath (leaf pages). Layout segments can also export generateStaticParams -// to provide parent params for nested dynamic routes, but they don't have a pagePath -// so they are excluded here. Supporting layout-level generateStaticParams requires -// scanning layout.tsx files separately and including them in this map. "/blog/:slug": mod_3?.generateStaticParams ?? null, }; @@ -7870,11 +7855,6 @@ async function __readFormDataWithLimit(request, maxBytes) { // Used by the prerender phase to enumerate dynamic route URLs without // loading route modules via the dev server. export const generateStaticParamsMap = { -// TODO: layout-level generateStaticParams — this map only includes routes that -// have a pagePath (leaf pages). Layout segments can also export generateStaticParams -// to provide parent params for nested dynamic routes, but they don't have a pagePath -// so they are excluded here. Supporting layout-level generateStaticParams requires -// scanning layout.tsx files separately and including them in this map. "/blog/:slug": mod_3?.generateStaticParams ?? null, }; @@ -10074,11 +10054,6 @@ async function __readFormDataWithLimit(request, maxBytes) { // Used by the prerender phase to enumerate dynamic route URLs without // loading route modules via the dev server. export const generateStaticParamsMap = { -// TODO: layout-level generateStaticParams — this map only includes routes that -// have a pagePath (leaf pages). Layout segments can also export generateStaticParams -// to provide parent params for nested dynamic routes, but they don't have a pagePath -// so they are excluded here. Supporting layout-level generateStaticParams requires -// scanning layout.tsx files separately and including them in this map. "/blog/:slug": mod_3?.generateStaticParams ?? null, }; @@ -12497,11 +12472,6 @@ async function __readFormDataWithLimit(request, maxBytes) { // Used by the prerender phase to enumerate dynamic route URLs without // loading route modules via the dev server. export const generateStaticParamsMap = { -// TODO: layout-level generateStaticParams — this map only includes routes that -// have a pagePath (leaf pages). Layout segments can also export generateStaticParams -// to provide parent params for nested dynamic routes, but they don't have a pagePath -// so they are excluded here. Supporting layout-level generateStaticParams requires -// scanning layout.tsx files separately and including them in this map. "/blog/:slug": mod_3?.generateStaticParams ?? null, }; diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index 2b7d0593..7a455381 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -753,53 +753,36 @@ function mockRoute(pattern: string, opts: { pagePath?: string | null } = {}): Ap }; } -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]), {}); + const result = await resolveParentParams(route, {}); expect(result).toEqual([]); }); - it("returns empty array when parent route has no pagePath", async () => { - const parent = mockRoute("/shop/:category", { pagePath: null }); + it("returns empty array when parent route has no pagePath and no map entry", async () => { const child = mockRoute("/shop/:category/:item"); - const result = await resolveParentParams(child, routeIndexFrom([parent, child]), {}); + const result = await resolveParentParams(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, - ); + const result = await resolveParentParams(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, - ); + const result = await resolveParentParams(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" }], @@ -808,11 +791,7 @@ describe("resolveParentParams", () => { return [{ d: "y" }, { d: "z" }]; }, }; - const result = await resolveParentParams( - child, - routeIndexFrom([grandparent, parent, child]), - staticParamsMap, - ); + const result = await resolveParentParams(child, staticParamsMap); expect(result).toEqual([ { b: "1", d: "x" }, { b: "2", d: "y" }, @@ -821,42 +800,165 @@ describe("resolveParentParams", () => { }); 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, - ); + const result = await resolveParentParams(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]), {}); + const result = await resolveParentParams(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]), {}); + const result = await resolveParentParams(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, - ); + const result = await resolveParentParams(child, staticParamsMap); expect(result).toEqual([{ category: "electronics" }]); }); + + it("resolves parent params from layout-level generateStaticParams (no pagePath)", async () => { + const child = mockRoute("/shop/:category/:item"); + const staticParamsMap: StaticParamsMap = { + "/shop/:category": async () => [{ category: "electronics" }, { category: "clothing" }], + }; + const result = await resolveParentParams(child, staticParamsMap); + expect(result).toEqual([{ category: "electronics" }, { category: "clothing" }]); + }); + + it("resolves two levels of layout-only parent segments", async () => { + 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, staticParamsMap); + expect(result).toEqual([ + { b: "1", d: "x" }, + { b: "2", d: "y" }, + { b: "2", d: "z" }, + ]); + }); + + it("resolves mixed page-level and layout-level parent segments", async () => { + const child = mockRoute("/shop/:category/brand/:brand/:item"); + const staticParamsMap: StaticParamsMap = { + "/shop/:category": async () => [{ category: "shoes" }], + "/shop/:category/brand/:brand": async () => [{ brand: "nike" }, { brand: "adidas" }], + }; + const result = await resolveParentParams(child, staticParamsMap); + expect(result).toEqual([ + { category: "shoes", brand: "nike" }, + { category: "shoes", brand: "adidas" }, + ]); + }); + + it("passes through parent params when intermediate prefix returns null (CF Workers Proxy)", async () => { + const child = mockRoute("/a/:b/c/:d/:e"); + const staticParamsMap: StaticParamsMap = { + // /a/:b has no generateStaticParams — simulates CF Workers Proxy returning null + "/a/:b": async () => null as unknown as Record[], + "/a/:b/c/:d": async () => [{ d: "x" }], + }; + const result = await resolveParentParams(child, staticParamsMap); + // null from /a/:b should pass through, allowing /a/:b/c/:d to still resolve + expect(result).toEqual([{ d: "x" }]); + }); + + it("returns empty array when parent generateStaticParams returns []", async () => { + const child = mockRoute("/shop/:category/:item"); + const staticParamsMap: StaticParamsMap = { + "/shop/:category": async () => [], + }; + const result = await resolveParentParams(child, staticParamsMap); + expect(result).toEqual([]); + }); + + it("propagates errors thrown by parent generateStaticParams", async () => { + const child = mockRoute("/shop/:category/:item"); + const staticParamsMap: StaticParamsMap = { + "/shop/:category": async () => { + throw new Error("DB unavailable"); + }, + }; + await expect(resolveParentParams(child, staticParamsMap)).rejects.toThrow("DB unavailable"); + }); + + it("passes through params from real parent when intermediate parent returns null", async () => { + const child = mockRoute("/a/:b/c/:d/:e"); + const staticParamsMap: StaticParamsMap = { + "/a/:b": async () => [{ b: "x" }], + // /a/:b/c/:d returns null — simulates no generateStaticParams at this level + "/a/:b/c/:d": async () => null as unknown as Record[], + }; + const result = await resolveParentParams(child, staticParamsMap); + // null from /a/:b/c/:d should pass through the already-resolved {b: "x"} unchanged + expect(result).toEqual([{ b: "x" }]); + }); + + it("handles double null pass-through from two intermediate parents", async () => { + const child = mockRoute("/a/:b/c/:d/e/:f/:g"); + const staticParamsMap: StaticParamsMap = { + "/a/:b": async () => null as unknown as Record[], + "/a/:b/c/:d": async () => null as unknown as Record[], + "/a/:b/c/:d/e/:f": async () => [{ f: "val" }], + }; + const result = await resolveParentParams(child, staticParamsMap); + // Both /a/:b and /a/:b/c/:d return null, so {} passes through to /a/:b/c/:d/e/:f + expect(result).toEqual([{ f: "val" }]); + }); + + it("resolves three levels of nested parent dynamic segments", async () => { + const child = mockRoute("/a/:b/c/:d/e/:f/:g"); + const staticParamsMap: StaticParamsMap = { + "/a/:b": async () => [{ b: "1" }, { b: "2" }], + "/a/:b/c/:d": async ({ params }) => { + if (params.b === "1") return [{ d: "alpha" }]; + return [{ d: "beta" }, { d: "gamma" }]; + }, + "/a/:b/c/:d/e/:f": async ({ params }) => { + if (params.d === "alpha") return [{ f: "deep1" }]; + return [{ f: "deep2" }]; + }, + }; + const result = await resolveParentParams(child, staticParamsMap); + expect(result).toEqual([ + { b: "1", d: "alpha", f: "deep1" }, + { b: "2", d: "beta", f: "deep2" }, + { b: "2", d: "gamma", f: "deep2" }, + ]); + }); + + it("resolves parent params when parent is a catch-all segment", async () => { + const child = mockRoute("/:slug+/details/:id"); + const staticParamsMap: StaticParamsMap = { + "/:slug+": async () => [{ slug: ["a", "b"] }, { slug: ["c"] }], + }; + const result = await resolveParentParams(child, staticParamsMap); + expect(result).toEqual([{ slug: ["a", "b"] }, { slug: ["c"] }]); + }); + + it("resolves parent params when parent is an optional catch-all segment", async () => { + const child = mockRoute("/:slug*/details/:id"); + const staticParamsMap: StaticParamsMap = { + "/:slug*": async () => [{ slug: [] }, { slug: ["a", "b"] }], + }; + const result = await resolveParentParams(child, staticParamsMap); + expect(result).toEqual([{ slug: [] }, { slug: ["a", "b"] }]); + }); });