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
11 changes: 6 additions & 5 deletions packages/vinext/src/build/prerender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
);
Expand Down
24 changes: 23 additions & 1 deletion packages/vinext/src/build/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,23 @@ export type RouteRow = {
prerendered?: boolean;
};

type AppRouteRenderEntry = Pick<AppRoute, "pagePath" | "routePath" | "parallelSlots">;

export function getAppRouteRenderEntryPath(route: AppRouteRenderEntry): string | null {
if (route.pagePath) return route.pagePath;
if (route.routePath) return null;

for (const slot of route.parallelSlots) {
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.

Nit: the iteration order over parallelSlots determines which slot's file is used for classification. If a route has multiple parallel slots with different segment configs (e.g., one slot page has force-dynamic and another is static), only the first one's config is read.

This is fine for now — it matches the prerender's needs ("is there any renderable UI?") and the classification result is speculative anyway. But a brief code comment noting the first-wins semantics would help future readers not assume it scans all slots.

if (slot.pagePath) return slot.pagePath;
}

for (const slot of route.parallelSlots) {
if (slot.defaultPath) return slot.defaultPath;
}

return null;
}

// ─── Regex-based export detection ────────────────────────────────────────────

/**
Expand Down Expand Up @@ -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(
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 classifyAppRoute's first argument can be a slot default.tsx (not just a page.tsx), the @param pagePath doc on classifyAppRoute (line 725) is slightly stale — it says "Absolute path to the page.tsx". Consider updating it to reflect the broader contract in a follow-up, e.g.:

@param pagePath Absolute path to the render entry file (page.tsx, slot page, or slot default; null for API-only routes)

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 });
Expand Down
17 changes: 16 additions & 1 deletion tests/build-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 ────────────────────────────────────────────────────────
Expand Down
25 changes: 25 additions & 0 deletions tests/prerender.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading