Skip to content
Draft
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
50 changes: 50 additions & 0 deletions packages/vinext/src/build/layout-classification-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* Shared types for the layout classification pipeline.
*
* Kept in a leaf module so both `report.ts` (which implements segment-config
* classification) and `layout-classification.ts` (which composes the full
* pipeline) can import them without forming a cycle.
*
* The wire contract between build and runtime is intentionally narrow: the
* runtime only cares about the `"static" | "dynamic"` decision for a layout.
* Reasons live in a sidecar structure so operators can trace how each
* decision was made without bloating the hot-path payload.
*/

/**
* Structured record of which classifier layer produced a decision and what
* evidence it used. Kept as a discriminated union so each layer can carry
* its own diagnostic shape without the consumer having to fall back to
* stringly-typed `reason` fields.
*/
export type ClassificationReason =
| {
layer: "segment-config";
key: "dynamic" | "revalidate";
value: string | number;
}
| {
layer: "module-graph";
result: "static" | "needs-probe";
firstShimMatch?: string;
}
| {
layer: "runtime-probe";
outcome: "static" | "dynamic";
error?: string;
}
| { layer: "no-classifier" };

/**
* Build-time classification outcome for a single layout. Tagged with `kind`
* so callers can branch exhaustively and carry diagnostic reasons alongside
* the decision.
*
* `absent` means no classifier layer had anything to say — the caller should
* defer to the next layer (or to the runtime probe).
*/
export type LayoutBuildClassification =
| { kind: "absent" }
| { kind: "static"; reason: ClassificationReason }
| { kind: "dynamic"; reason: ClassificationReason }
| { kind: "needs-probe"; reason: ClassificationReason };
58 changes: 48 additions & 10 deletions packages/vinext/src/build/layout-classification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,31 @@
*
* Layer 3 (probe-based runtime detection) is handled separately in
* `app-page-execution.ts` at request time.
*
* Every result is carried as a `LayoutBuildClassification` tagged variant so
* operators can trace which layer produced a decision via the structured
* `ClassificationReason` sidecar without that metadata leaking onto the wire.
*/

import { classifyLayoutSegmentConfig } from "./report.js";
import { createAppPageTreePath } from "../server/app-page-route-wiring.js";
import type {
ClassificationReason,
LayoutBuildClassification,
} from "./layout-classification-types.js";

export type {
ClassificationReason,
LayoutBuildClassification,
} from "./layout-classification-types.js";

export type ModuleGraphClassification = "static" | "needs-probe";
export type LayoutClassificationResult = "static" | "dynamic" | "needs-probe";

export type ModuleGraphClassificationResult = {
result: ModuleGraphClassification;
/** First dynamic shim module ID encountered during BFS, when any. */
firstShimMatch?: string;
};

export type ModuleInfoProvider = {
getModuleInfo(id: string): {
Expand All @@ -40,12 +58,16 @@ type RouteForClassification = {
* BFS traversal of a layout's dependency tree. If any transitive import
* resolves to a dynamic shim path (headers, cache, server), the layout
* cannot be proven static at build time and needs a runtime probe.
*
* The returned object carries the classification plus the first matching
* shim module ID (when any). Operators use the shim ID via the debug
* channel to trace why a layout was flagged for probing.
*/
export function classifyLayoutByModuleGraph(
layoutModuleId: string,
dynamicShimPaths: ReadonlySet<string>,
moduleInfo: ModuleInfoProvider,
): ModuleGraphClassification {
): ModuleGraphClassificationResult {
const visited = new Set<string>();
const queue: string[] = [layoutModuleId];
let head = 0;
Expand All @@ -56,7 +78,9 @@ export function classifyLayoutByModuleGraph(
if (visited.has(currentId)) continue;
visited.add(currentId);

if (dynamicShimPaths.has(currentId)) return "needs-probe";
if (dynamicShimPaths.has(currentId)) {
return { result: "needs-probe", firstShimMatch: currentId };
}

const info = moduleInfo.getModuleInfo(currentId);
if (!info) continue;
Expand All @@ -69,7 +93,18 @@ export function classifyLayoutByModuleGraph(
}
}

return "static";
return { result: "static" };
}

function moduleGraphReason(graphResult: ModuleGraphClassificationResult): ClassificationReason {
if (graphResult.firstShimMatch === undefined) {
return { layer: "module-graph", result: graphResult.result };
}
return {
layer: "module-graph",
result: graphResult.result,
firstShimMatch: graphResult.firstShimMatch,
};
}

/**
Expand All @@ -85,8 +120,8 @@ export function classifyAllRouteLayouts(
routes: readonly RouteForClassification[],
dynamicShimPaths: ReadonlySet<string>,
moduleInfo: ModuleInfoProvider,
): Map<string, LayoutClassificationResult> {
const result = new Map<string, LayoutClassificationResult>();
): Map<string, LayoutBuildClassification> {
const result = new Map<string, LayoutBuildClassification>();

for (const route of routes) {
for (const layout of route.layouts) {
Expand All @@ -97,17 +132,20 @@ export function classifyAllRouteLayouts(
// Layer 1: segment config
if (layout.segmentConfig) {
const configResult = classifyLayoutSegmentConfig(layout.segmentConfig.code);
if (configResult !== null) {
if (configResult.kind !== "absent") {
result.set(layoutId, configResult);
continue;
}
}

// Layer 2: module graph
result.set(
layoutId,
classifyLayoutByModuleGraph(layout.moduleId, dynamicShimPaths, moduleInfo),
const graphResult = classifyLayoutByModuleGraph(
layout.moduleId,
dynamicShimPaths,
moduleInfo,
);
const reason = moduleGraphReason(graphResult);
result.set(layoutId, { kind: graphResult.result, reason });
}
}

Expand Down
46 changes: 31 additions & 15 deletions packages/vinext/src/build/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import fs from "node:fs";
import path from "node:path";
import type { Route } from "../routing/pages-router.js";
import type { AppRoute } from "../routing/app-router.js";
import type { LayoutBuildClassification } from "./layout-classification-types.js";
import type { PrerenderResult } from "./prerender.js";

// ─── Types ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -609,33 +610,48 @@ function findMatchingToken(

// ─── Layout segment config classification ────────────────────────────────────

/**
* Classification result for layout segment config analysis.
* "static" means the layout is confirmed static via segment config.
* "dynamic" means the layout is confirmed dynamic via segment config.
*/
export type LayoutClassification = "static" | "dynamic";

/**
* Classifies a layout file by its segment config exports (`dynamic`, `revalidate`).
*
* Returns `"static"` or `"dynamic"` when the config is decisive, or `null`
* when no segment config is present (deferring to module graph analysis).
* Returns a tagged `LayoutBuildClassification` carrying both the decision and
* the specific segment-config field that produced it. `{ kind: "absent" }`
* means no segment config is present and the caller should defer to the next
* layer (module graph analysis).
*
* Unlike page classification, positive `revalidate` values are not meaningful
* for layout skip decisions — ISR is a page-level concept. Only the extremes
* (`revalidate = 0` → dynamic, `revalidate = Infinity` → static) are decisive.
*/
export function classifyLayoutSegmentConfig(code: string): LayoutClassification | null {
export function classifyLayoutSegmentConfig(code: string): LayoutBuildClassification {
const dynamicValue = extractExportConstString(code, "dynamic");
if (dynamicValue === "force-dynamic") return "dynamic";
if (dynamicValue === "force-static" || dynamicValue === "error") return "static";
if (dynamicValue === "force-dynamic") {
return {
kind: "dynamic",
reason: { layer: "segment-config", key: "dynamic", value: "force-dynamic" },
};
}
if (dynamicValue === "force-static" || dynamicValue === "error") {
return {
kind: "static",
reason: { layer: "segment-config", key: "dynamic", value: dynamicValue },
};
}

const revalidateValue = extractExportConstNumber(code, "revalidate");
if (revalidateValue === Infinity) return "static";
if (revalidateValue === 0) return "dynamic";
if (revalidateValue === Infinity) {
return {
kind: "static",
reason: { layer: "segment-config", key: "revalidate", value: Infinity },
};
}
if (revalidateValue === 0) {
return {
kind: "dynamic",
reason: { layer: "segment-config", key: "revalidate", value: 0 },
};
}

return null;
return { kind: "absent" };
}

// ─── Route classification ─────────────────────────────────────────────────────
Expand Down
Loading
Loading