Skip to content
Open
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
142 changes: 120 additions & 22 deletions packages/vinext/src/build/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,10 @@
* execution). Vite's parseAst() is NOT used because it doesn't handle
* TypeScript syntax.
*
* Limitation: without running the build, we cannot detect dynamic API usage
* (headers(), cookies(), connection(), etc.) that implicitly forces a route
* dynamic. Routes without explicit `export const dynamic` or
* `export const revalidate` are classified as "unknown" rather than "static"
* to avoid false confidence.
* Dynamic API imports (headers(), cookies(), connection(), unstable_noStore())
* are detected heuristically and classified as "ssr". Routes without explicit
* config or detectable dynamic API usage are classified as "unknown" rather
* than "static" to avoid false confidence.
*/

import fs from "node:fs";
Expand All @@ -40,6 +39,10 @@ export type RouteRow = {
* Used by `formatBuildReport` to add a note in the legend.
*/
prerendered?: boolean;
/** Pre-render status from the prerender phase, if available. */
prerenderStatus?: "rendered" | "skipped" | "error";
/** For dynamic routes: the concrete URLs that were pre-rendered. */
prerenderPaths?: string[];
};

// ─── Regex-based export detection ────────────────────────────────────────────
Expand Down Expand Up @@ -98,6 +101,47 @@ export function extractExportConstNumber(code: string, name: string): number | n
return m[1] === "Infinity" ? Infinity : parseFloat(m[1]);
}

// ─── Dynamic API detection (module-level compiled regexes) ───────────────────

// Anchored to line start (^) to avoid matching commented-out imports.
// Uses multiline flag so ^ matches each line in multi-line source.
// Negative lookbehind (?<!\btype\s) skips inline type modifiers like
// `import { type cookies }` which are erased at compile time.

/** Detects `import { headers | cookies | draftMode } from "next/headers"` */
const DYNAMIC_HEADERS_RE =
/^\s*import\s*\{[^}]*(?<!\btype\s)\b(?:headers|cookies|draftMode)\b[^}]*\}\s*from\s*['"]next\/headers['"]/m;
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.

The ^ anchor with /m correctly skips // import { headers } ... (single-line comments) because the // precedes import. However, it produces false positives for imports inside multi-line block comments that start on a prior line:

const x = 1; /*
import { headers } from "next/headers";
*/

Here import starts at the beginning of a line (matching ^ in multiline mode), but the code is dead.

This is an inherent limitation of regex-based analysis and the JSDoc already documents the heuristic nature, so it's fine as-is for a build report. But worth adding to the "Limitations" comment block so future readers know.


/** Detects `import { connection } from "next/server"` (stabilized replacement for unstable_noStore) */
const DYNAMIC_SERVER_RE =
/^\s*import\s*\{[^}]*(?<!\btype\s)\bconnection\b[^}]*\}\s*from\s*['"]next\/server['"]/m;

/** Detects `import { unstable_noStore | noStore } from "next/cache"` */
const DYNAMIC_CACHE_RE =
/^\s*import\s*\{[^}]*(?<!\btype\s)\b(?:unstable_noStore|noStore)\b[^}]*\}\s*from\s*['"]next\/cache['"]/m;

/**
* Detects imports of Next.js dynamic APIs that implicitly force a route dynamic.
* Checks for:
* - import { headers, cookies, draftMode } from "next/headers"
* - import { connection } from "next/server"
* - import { unstable_noStore, noStore } from "next/cache"
*
* This is a heuristic — importing a function doesn't guarantee it's called
* at render time (it could be conditionally used or in a non-render helper).
* But it provides better signal than "unknown" for the build report.
*
* Limitations:
* - Does not detect `require()` or dynamic `import()` calls
* - Does not detect `type` imports (which are erased at runtime — correct behavior)
* - Anchored to line start to avoid false positives from commented-out imports
*/
export function detectsDynamicApiUsage(code: string): boolean {
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.

Naming nit: detectsDynamicApiUsage reads like a property/descriptor ("this function detects...") rather than an imperative. The rest of the codebase uses imperative names (extractExportConstString, hasNamedExport, classifyAppRoute). Consider detectDynamicApiUsage or hasDynamicApiImport for consistency.

Not a blocker since this is exported and already referenced in tests, but worth considering if you're touching it again.

return (
DYNAMIC_HEADERS_RE.test(code) || DYNAMIC_SERVER_RE.test(code) || DYNAMIC_CACHE_RE.test(code)
);
}

/**
* Extracts the `revalidate` value from inside a `getStaticProps` return object.
* Looks for: revalidate: <number> or revalidate: false or revalidate: Infinity
Expand Down Expand Up @@ -701,9 +745,15 @@ export function classifyAppRoute(
// Fall back to isDynamic flag (dynamic URL segments without explicit config)
if (isDynamic) return { type: "ssr" };

// No explicit config and no dynamic URL segments — we can't confirm static
// without running the build (dynamic API calls like headers() are invisible
// to static analysis). Report as unknown rather than falsely claiming static.
// Check for imports of dynamic APIs — these strongly suggest the route is
// dynamic even without explicit `export const dynamic` configuration.
// This improves on "unknown" but remains a heuristic: the import could be
// conditional or unused at render time.
if (detectsDynamicApiUsage(code)) return { type: "ssr" };

// No explicit config, no dynamic URL segments, and no detected dynamic API
// imports — we can't confirm static without running the build.
// Report as unknown rather than falsely claiming static.
return { type: "unknown" };
}

Expand All @@ -726,17 +776,33 @@ export function buildReportRows(options: {
}): RouteRow[] {
const rows: RouteRow[] = [];

// Build a set of routes that were confirmed rendered by speculative prerender.
const renderedRoutes = new Set<string>();
// Build maps from prerender results for route enrichment.
const prerenderStatusMap = new Map<string, "rendered" | "skipped" | "error">();
const prerenderPathsMap = new Map<string, string[]>();
if (options.prerenderResult) {
for (const r of options.prerenderResult.routes) {
if (r.status === "rendered") renderedRoutes.add(r.route);
// For rendered routes with a concrete path (dynamic routes expanded to URLs),
// collect all paths under the route pattern.
if (r.status === "rendered") {
prerenderStatusMap.set(r.route, "rendered");
if (r.path) {
const paths = prerenderPathsMap.get(r.route) ?? [];
paths.push(r.path);
prerenderPathsMap.set(r.route, paths);
}
} else if (!prerenderStatusMap.has(r.route)) {
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.

The priority logic — "rendered" wins over "skipped"/"error" for the same route — is smart for dynamic routes with multiple paths where some succeed and some fail. But the else if (!prerenderStatusMap.has(r.route)) guard means the first non-rendered status wins among skipped/error. If a route has both skipped and error results, whichever appears first in the array determines the status.

Is that intentional? It seems unlikely in practice (a route is either skipped or errored, not both), but if it can happen, you might want error to take priority over skipped.

// Only set skipped/error if not already rendered (a dynamic route may
// have some rendered paths and some errors).
prerenderStatusMap.set(r.route, r.status);
}
}
}

for (const route of options.pageRoutes ?? []) {
const { type, revalidate } = classifyPagesRoute(route.filePath);
rows.push({ pattern: route.pattern, type, revalidate });
const prerenderStatus = prerenderStatusMap.get(route.pattern);
const prerenderPaths = prerenderPathsMap.get(route.pattern);
rows.push({ pattern: route.pattern, type, revalidate, prerenderStatus, prerenderPaths });
}

for (const route of options.apiRoutes ?? []) {
Expand All @@ -745,11 +811,19 @@ export function buildReportRows(options: {

for (const route of options.appRoutes ?? []) {
const { type, revalidate } = classifyAppRoute(route.pagePath, route.routePath, route.isDynamic);
if (type === "unknown" && renderedRoutes.has(route.pattern)) {
const prerenderStatus = prerenderStatusMap.get(route.pattern);
const prerenderPaths = prerenderPathsMap.get(route.pattern);
if (type === "unknown" && prerenderStatus === "rendered") {
// Speculative prerender confirmed this route is static.
rows.push({ pattern: route.pattern, type: "static", prerendered: true });
rows.push({
pattern: route.pattern,
type: "static",
prerendered: true,
prerenderStatus,
prerenderPaths,
});
} else {
rows.push({ pattern: route.pattern, type, revalidate });
rows.push({ pattern: route.pattern, type, revalidate, prerenderStatus, prerenderPaths });
}
}

Expand All @@ -761,7 +835,7 @@ export function buildReportRows(options: {

// ─── Formatting ───────────────────────────────────────────────────────────────

const SYMBOLS: Record<RouteType, string> = {
export const SYMBOLS: Record<RouteType, string> = {
static: "○",
isr: "◐",
ssr: "ƒ",
Expand Down Expand Up @@ -804,8 +878,20 @@ export function formatBuildReport(rows: RouteRow[], routerLabel = "app"): string
const sym = SYMBOLS[row.type];
const suffix =
row.type === "isr" && row.revalidate !== undefined ? ` (${row.revalidate}s)` : "";
// Prerender annotation
let prerenderSuffix = "";
if (row.prerenderStatus === "rendered") {
const pathCount = row.prerenderPaths?.length;
prerenderSuffix = pathCount
? ` [prerendered: ${pathCount} path${pathCount !== 1 ? "s" : ""}]`
: " [prerendered]";
} else if (row.prerenderStatus === "skipped") {
prerenderSuffix = " [skipped]";
} else if (row.prerenderStatus === "error") {
prerenderSuffix = " [error]";
}
const padding = " ".repeat(maxPatternLen - row.pattern.length);
lines.push(` ${corner} ${sym} ${row.pattern}${padding}${suffix}`);
lines.push(` ${corner} ${sym} ${row.pattern}${padding}${suffix}${prerenderSuffix}`);
});

lines.push("");
Expand All @@ -819,11 +905,9 @@ export function formatBuildReport(rows: RouteRow[], routerLabel = "app"): string
// Explanatory note — only shown when unknown routes are present
if (usedTypes.includes("unknown")) {
lines.push("");
lines.push(" ? Some routes could not be classified. vinext currently uses static analysis");
lines.push(
" and cannot detect dynamic API usage (headers(), cookies(), etc.) at build time.",
);
lines.push(" Automatic classification will be improved in a future release.");
lines.push(" ? Some routes could not be fully classified by static analysis. Routes that");
lines.push(" import dynamic APIs (headers(), cookies(), etc.) are detected as dynamic,");
lines.push(" but other dynamic patterns may not be caught without running the build.");
}

// Speculative-render note — shown when any routes were confirmed static by prerender
Expand All @@ -836,6 +920,20 @@ export function formatBuildReport(rows: RouteRow[], routerLabel = "app"): string
lines.push(" succeeded without dynamic API usage).");
}

// Prerender summary — shown when any routes have prerender status
const hasAnyPrerender = rows.some((r) => r.prerenderStatus);
if (hasAnyPrerender) {
const renderedCount = rows.filter((r) => r.prerenderStatus === "rendered").length;
const skippedCount = rows.filter((r) => r.prerenderStatus === "skipped").length;
const errorCount = rows.filter((r) => r.prerenderStatus === "error").length;
lines.push("");
const summaryParts: string[] = [];
if (renderedCount > 0) summaryParts.push(`${renderedCount} prerendered`);
if (skippedCount > 0) summaryParts.push(`${skippedCount} skipped`);
if (errorCount > 0) summaryParts.push(`${errorCount} failed`);
lines.push(` Prerender: ${summaryParts.join(", ")}`);
}

return lines.join("\n");
}

Expand Down
Loading
Loading