diff --git a/packages/vinext/src/build/prerender.ts b/packages/vinext/src/build/prerender.ts index 1c4fe7855..9e95e33cb 100644 --- a/packages/vinext/src/build/prerender.ts +++ b/packages/vinext/src/build/prerender.ts @@ -23,7 +23,7 @@ import type { Server as HttpServer } from "node:http"; import type { Route } from "../routing/pages-router.js"; import type { AppRoute } from "../routing/app-router.js"; import type { ResolvedNextConfig } from "../config/next-config.js"; -import { classifyPagesRoute, classifyAppRoute } from "./report.js"; +import { classifyPagesRoute, classifyAppRoute, getAppRouteRenderEntryPath } from "./report.js"; import { NoOpCacheHandler, setCacheHandler, @@ -836,20 +836,21 @@ export async function prerenderApp({ const urlsToRender: UrlToRender[] = []; for (const route of routes) { - // API-only route handler (no page component) - if (route.routePath && !route.pagePath) { + const renderEntryPath = getAppRouteRenderEntryPath(route); + + if (!renderEntryPath && route.routePath) { results.push({ route: route.pattern, status: "skipped", reason: "api" }); continue; } - if (!route.pagePath) continue; + if (!renderEntryPath) continue; // Use static analysis classification, but note its limitations for dynamic URLs: // classifyAppRoute() returns 'ssr' for dynamic URLs with no explicit config, // meaning "unknown — could have generateStaticParams". We must check // generateStaticParams first before applying the ssr skip/error logic. const { type, revalidate: classifiedRevalidate } = classifyAppRoute( - route.pagePath, + renderEntryPath, route.routePath, route.isDynamic, ); diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 81e87711a..e26ef3a48 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -43,6 +43,23 @@ export type RouteRow = { prerendered?: boolean; }; +type AppRouteRenderEntry = Pick; + +export function getAppRouteRenderEntryPath(route: AppRouteRenderEntry): string | null { + if (route.pagePath) return route.pagePath; + if (route.routePath) return null; + + for (const slot of route.parallelSlots) { + if (slot.pagePath) return slot.pagePath; + } + + for (const slot of route.parallelSlots) { + if (slot.defaultPath) return slot.defaultPath; + } + + return null; +} + // ─── Regex-based export detection ──────────────────────────────────────────── /** @@ -794,7 +811,12 @@ export function buildReportRows(options: { } for (const route of options.appRoutes ?? []) { - const { type, revalidate } = classifyAppRoute(route.pagePath, route.routePath, route.isDynamic); + const renderEntryPath = getAppRouteRenderEntryPath(route); + const { type, revalidate } = classifyAppRoute( + renderEntryPath, + route.routePath, + route.isDynamic, + ); if (type === "unknown" && renderedRoutes.has(route.pattern)) { // Speculative prerender confirmed this route is static. rows.push({ pattern: route.pattern, type: "static", prerendered: true }); diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index 00c4ce730..f753da79a 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -21,7 +21,7 @@ import { formatBuildReport, printBuildReport, } from "../packages/vinext/src/build/report.js"; -import { invalidateAppRouteCache } from "../packages/vinext/src/routing/app-router.js"; +import { appRouter, invalidateAppRouteCache } from "../packages/vinext/src/routing/app-router.js"; import { invalidateRouteCache } from "../packages/vinext/src/routing/pages-router.js"; const FIXTURES_PAGES = path.resolve("tests/fixtures/pages-basic/pages"); @@ -493,6 +493,21 @@ describe("buildReportRows", () => { expect(rows[0].pattern).toBe("/aaa"); expect(rows[1].pattern).toBe("/zzz"); }); + + it("classifies layout-only parallel-slot app routes from their render entry", async () => { + invalidateAppRouteCache(); + const routes = await appRouter(FIXTURES_APP); + const rows = buildReportRows({ appRoutes: routes }); + + expect(rows.find((row) => row.pattern === "/parallel-nested/home")).toMatchObject({ + pattern: "/parallel-nested/home", + type: "unknown", + }); + expect(rows.find((row) => row.pattern === "/slot-collision")).toMatchObject({ + pattern: "/slot-collision", + type: "unknown", + }); + }); }); // ─── formatBuildReport ──────────────────────────────────────────────────────── diff --git a/tests/prerender.test.ts b/tests/prerender.test.ts index fd91fcd94..c79bb1f72 100644 --- a/tests/prerender.test.ts +++ b/tests/prerender.test.ts @@ -478,6 +478,31 @@ describe("prerenderApp — default mode (app-basic)", () => { expect(r).toMatchObject({ route: "/dashboard", status: "rendered", revalidate: false }); }); + it("renders layout-only routes whose content comes from parallel slots", () => { + // Ported from Next.js: test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts + const parent = findRoute(results, "/parallel-nested/home"); + expect(parent).toMatchObject({ + route: "/parallel-nested/home", + status: "rendered", + revalidate: false, + }); + + const nested = findRoute(results, "/parallel-nested/home/nested"); + expect(nested).toMatchObject({ + route: "/parallel-nested/home/nested", + status: "rendered", + revalidate: false, + }); + + const defaultOnly = findRoute(results, "/slot-collision"); + expect(defaultOnly).toMatchObject({ + route: "/slot-collision", + status: "rendered", + revalidate: false, + }); + }); + it("skips /headers-test (unknown route that calls headers())", () => { const r = findRoute(results, "/headers-test"); // headers-test calls headers() — should be skipped as dynamic