diff --git a/packages/vinext/src/build/layout-classification.ts b/packages/vinext/src/build/layout-classification.ts new file mode 100644 index 000000000..e564bb529 --- /dev/null +++ b/packages/vinext/src/build/layout-classification.ts @@ -0,0 +1,115 @@ +/** + * Layout classification — determines whether each layout in an App Router + * route tree is static or dynamic via two complementary detection layers: + * + * Layer 1: Segment config (`export const dynamic`, `export const revalidate`) + * Layer 2: Module graph traversal (checks for transitive dynamic shim imports) + * + * Layer 3 (probe-based runtime detection) is handled separately in + * `app-page-execution.ts` at request time. + */ + +import { classifyLayoutSegmentConfig } from "./report.js"; +import { createAppPageTreePath } from "../server/app-page-route-wiring.js"; + +export type ModuleGraphClassification = "static" | "needs-probe"; +export type LayoutClassificationResult = "static" | "dynamic" | "needs-probe"; + +export type ModuleInfoProvider = { + getModuleInfo(id: string): { + importedIds: string[]; + dynamicImportedIds: string[]; + } | null; +}; + +type LayoutEntry = { + /** Rollup/Vite module ID for the layout file. */ + moduleId: string; + /** Directory depth from the app root, used to build the stable layout ID. */ + treePosition: number; + /** Segment config source code extracted at build time, or null when absent. */ + segmentConfig?: { code: string } | null; +}; + +type RouteForClassification = { + layouts: readonly LayoutEntry[]; + routeSegments: string[]; +}; + +/** + * 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. + */ +export function classifyLayoutByModuleGraph( + layoutModuleId: string, + dynamicShimPaths: ReadonlySet, + moduleInfo: ModuleInfoProvider, +): ModuleGraphClassification { + const visited = new Set(); + const queue: string[] = [layoutModuleId]; + let head = 0; + + while (head < queue.length) { + const currentId = queue[head++]!; + + if (visited.has(currentId)) continue; + visited.add(currentId); + + if (dynamicShimPaths.has(currentId)) return "needs-probe"; + + const info = moduleInfo.getModuleInfo(currentId); + if (!info) continue; + + for (const importedId of info.importedIds) { + if (!visited.has(importedId)) queue.push(importedId); + } + for (const dynamicId of info.dynamicImportedIds) { + if (!visited.has(dynamicId)) queue.push(dynamicId); + } + } + + return "static"; +} + +/** + * Classifies all layouts across all routes using a two-layer strategy: + * + * 1. Segment config (Layer 1) — short-circuits to "static" or "dynamic" + * 2. Module graph (Layer 2) — BFS for dynamic shim imports → "static" or "needs-probe" + * + * Shared layouts (same file appearing in multiple routes) are classified once + * and deduplicated by layout ID. + */ +export function classifyAllRouteLayouts( + routes: readonly RouteForClassification[], + dynamicShimPaths: ReadonlySet, + moduleInfo: ModuleInfoProvider, +): Map { + const result = new Map(); + + for (const route of routes) { + for (const layout of route.layouts) { + const layoutId = `layout:${createAppPageTreePath(route.routeSegments, layout.treePosition)}`; + + if (result.has(layoutId)) continue; + + // Layer 1: segment config + if (layout.segmentConfig) { + const configResult = classifyLayoutSegmentConfig(layout.segmentConfig.code); + if (configResult !== null) { + result.set(layoutId, configResult); + continue; + } + } + + // Layer 2: module graph + result.set( + layoutId, + classifyLayoutByModuleGraph(layout.moduleId, dynamicShimPaths, moduleInfo), + ); + } + } + + return result; +} diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index a29b05842..70b30343f 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -607,6 +607,37 @@ function findMatchingToken( return -1; } +// ─── 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). + * + * 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 { + const dynamicValue = extractExportConstString(code, "dynamic"); + if (dynamicValue === "force-dynamic") return "dynamic"; + if (dynamicValue === "force-static" || dynamicValue === "error") return "static"; + + const revalidateValue = extractExportConstNumber(code, "revalidate"); + if (revalidateValue === Infinity) return "static"; + if (revalidateValue === 0) return "dynamic"; + + return null; +} + // ─── Route classification ───────────────────────────────────────────────────── /** diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index cc6c60f62..4f58f3686 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -60,6 +60,7 @@ import { resolveVisitedResponseInterceptionContext, type AppElements, type AppWireElements, + type LayoutFlags, } from "./app-elements.js"; import { createHistoryStateWithPreviousNextUrl, @@ -458,6 +459,7 @@ async function commitSameUrlNavigatePayload( pending.action.renderId, "navigate", pending.interceptionContext, + pending.action.layoutFlags, pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, @@ -491,6 +493,7 @@ function BrowserRoot({ const [treeState, dispatchTreeState] = useReducer(routerReducer, { elements: resolvedElements, interceptionContext: initialMetadata.interceptionContext, + layoutFlags: initialMetadata.layoutFlags, navigationSnapshot: initialNavigationSnapshot, previousNextUrl: null, renderId: 0, @@ -570,6 +573,7 @@ function dispatchBrowserTree( renderId: number, actionType: "navigate" | "replace" | "traverse", interceptionContext: string | null, + layoutFlags: LayoutFlags, previousNextUrl: string | null, routeId: string, rootLayoutTreePath: string | null, @@ -581,6 +585,7 @@ function dispatchBrowserTree( dispatch({ elements, interceptionContext, + layoutFlags, navigationSnapshot, previousNextUrl, renderId, @@ -662,6 +667,7 @@ async function renderNavigationPayload( renderId, actionType, pending.interceptionContext, + pending.action.layoutFlags, pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, @@ -1146,6 +1152,7 @@ async function main(): Promise { pending.action.renderId, "replace", pending.interceptionContext, + pending.action.layoutFlags, pending.previousNextUrl, pending.routeId, pending.rootLayoutTreePath, diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index a70085705..2a596b824 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -1,6 +1,6 @@ import { mergeElements } from "../shims/slot.js"; import { stripBasePath } from "../utils/base-path.js"; -import { readAppElementsMetadata, type AppElements } from "./app-elements.js"; +import { readAppElementsMetadata, type AppElements, type LayoutFlags } from "./app-elements.js"; import type { ClientNavigationRenderSnapshot } from "../shims/navigation.js"; const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl"; @@ -12,6 +12,7 @@ type HistoryStateRecord = { export type AppRouterState = { elements: AppElements; interceptionContext: string | null; + layoutFlags: LayoutFlags; previousNextUrl: string | null; renderId: number; navigationSnapshot: ClientNavigationRenderSnapshot; @@ -22,6 +23,7 @@ export type AppRouterState = { export type AppRouterAction = { elements: AppElements; interceptionContext: string | null; + layoutFlags: LayoutFlags; navigationSnapshot: ClientNavigationRenderSnapshot; previousNextUrl: string | null; renderId: number; @@ -95,6 +97,7 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A return { elements: mergeElements(state.elements, action.elements, action.type === "traverse"), interceptionContext: action.interceptionContext, + layoutFlags: { ...state.layoutFlags, ...action.layoutFlags }, navigationSnapshot: action.navigationSnapshot, previousNextUrl: action.previousNextUrl, renderId: action.renderId, @@ -105,6 +108,7 @@ export function routerReducer(state: AppRouterState, action: AppRouterAction): A return { elements: action.elements, interceptionContext: action.interceptionContext, + layoutFlags: action.layoutFlags, navigationSnapshot: action.navigationSnapshot, previousNextUrl: action.previousNextUrl, renderId: action.renderId, @@ -168,6 +172,7 @@ export async function createPendingNavigationCommit(options: { action: { elements, interceptionContext: metadata.interceptionContext, + layoutFlags: metadata.layoutFlags, navigationSnapshot: options.navigationSnapshot, previousNextUrl, renderId: options.renderId, diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index 00a0b9b99..b8bc42616 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; const APP_INTERCEPTION_SEPARATOR = "\0"; export const APP_INTERCEPTION_CONTEXT_KEY = "__interceptionContext"; +export const APP_LAYOUT_FLAGS_KEY = "__layoutFlags"; export const APP_ROUTE_KEY = "__route"; export const APP_ROOT_LAYOUT_KEY = "__rootLayout"; export const APP_UNMATCHED_SLOT_WIRE_VALUE = "__VINEXT_UNMATCHED_SLOT__"; @@ -15,8 +16,12 @@ export type AppWireElementValue = ReactNode | string | null; export type AppElements = Readonly>; export type AppWireElements = Readonly>; +/** Per-layout static/dynamic flags propagated in the RSC payload. `"s"` = static, `"d"` = dynamic. */ +export type LayoutFlags = Readonly>; + export type AppElementsMetadata = { interceptionContext: string | null; + layoutFlags: LayoutFlags; routeId: string; rootLayoutTreePath: string | null; }; @@ -102,7 +107,27 @@ export function normalizeAppElements(elements: AppWireElements): AppElements { return normalized; } -export function readAppElementsMetadata(elements: AppElements): AppElementsMetadata { +function isLayoutFlagsRecord(value: unknown): value is LayoutFlags { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + for (const v of Object.values(value)) { + if (v !== "s" && v !== "d") return false; + } + return true; +} + +function parseLayoutFlags(value: unknown): LayoutFlags { + if (isLayoutFlagsRecord(value)) return value; + return {}; +} + +/** + * Parses metadata from the wire payload. Accepts `Record` + * because the RSC payload carries heterogeneous values (React elements, + * strings, and plain objects like layout flags) under the same record type. + */ +export function readAppElementsMetadata( + elements: Readonly>, +): AppElementsMetadata { const routeId = elements[APP_ROUTE_KEY]; if (typeof routeId !== "string") { throw new Error("[vinext] Missing __route string in App Router payload"); @@ -125,8 +150,11 @@ export function readAppElementsMetadata(elements: AppElements): AppElementsMetad throw new Error("[vinext] Invalid __rootLayout in App Router payload: expected string or null"); } + const layoutFlags = parseLayoutFlags(elements[APP_LAYOUT_FLAGS_KEY]); + return { interceptionContext: interceptionContext ?? null, + layoutFlags, routeId, rootLayoutTreePath, }; diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index 806d7214b..d698f417d 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -1,3 +1,7 @@ +import type { LayoutFlags } from "./app-elements.js"; + +export type { LayoutFlags }; + export type AppPageSpecialError = | { kind: "redirect"; location: string; statusCode: number } | { kind: "http-access-fallback"; statusCode: number }; @@ -19,11 +23,27 @@ export type BuildAppPageSpecialErrorResponseOptions = { specialError: AppPageSpecialError; }; +export type ProbeAppPageLayoutsResult = { + response: Response | null; + layoutFlags: LayoutFlags; +}; + +export type LayoutClassificationOptions = { + /** Build-time classifications from segment config or module graph, keyed by layout index. */ + buildTimeClassifications?: ReadonlyMap | null; + /** Maps layout index to its layout ID (e.g. "layout:/blog"). */ + getLayoutId: (layoutIndex: number) => string; + /** Runs a function with isolated dynamic usage tracking per layout. */ + runWithIsolatedDynamicScope: (fn: () => T) => Promise<{ result: T; dynamicDetected: boolean }>; +}; + export type ProbeAppPageLayoutsOptions = { layoutCount: number; onLayoutError: (error: unknown, layoutIndex: number) => Promise; probeLayoutAt: (layoutIndex: number) => unknown; runWithSuppressedHookWarning(probe: () => Promise): Promise; + /** When provided, enables per-layout static/dynamic classification. */ + classification?: LayoutClassificationOptions | null; }; export type ProbeAppPageComponentOptions = { @@ -98,24 +118,64 @@ export async function buildAppPageSpecialErrorResponse( export async function probeAppPageLayouts( options: ProbeAppPageLayoutsOptions, -): Promise { - return options.runWithSuppressedHookWarning(async () => { +): Promise { + const layoutFlags: Record = {}; + const cls = options.classification ?? null; + + const response = await options.runWithSuppressedHookWarning(async () => { for (let layoutIndex = options.layoutCount - 1; layoutIndex >= 0; layoutIndex--) { - try { - const layoutResult = options.probeLayoutAt(layoutIndex); - if (isPromiseLike(layoutResult)) { - await layoutResult; - } - } catch (error) { - const response = await options.onLayoutError(error, layoutIndex); - if (response) { - return response; + const buildTimeResult = cls?.buildTimeClassifications?.get(layoutIndex); + + if (cls && buildTimeResult) { + // Build-time classified (Layer 1 or Layer 2): skip dynamic isolation, + // but still probe for special errors (redirects, not-found). + layoutFlags[cls.getLayoutId(layoutIndex)] = buildTimeResult === "static" ? "s" : "d"; + const errorResponse = await probeLayoutForErrors(options, layoutIndex); + if (errorResponse) return errorResponse; + continue; + } + + if (cls) { + // Layer 3: probe with isolated dynamic scope to detect per-layout + // dynamic API usage (headers(), cookies(), connection(), etc.) + try { + const { dynamicDetected } = await cls.runWithIsolatedDynamicScope(() => + options.probeLayoutAt(layoutIndex), + ); + layoutFlags[cls.getLayoutId(layoutIndex)] = dynamicDetected ? "d" : "s"; + } catch (error) { + // Probe failed — conservatively treat as dynamic. + layoutFlags[cls.getLayoutId(layoutIndex)] = "d"; + const errorResponse = await options.onLayoutError(error, layoutIndex); + if (errorResponse) return errorResponse; } + continue; } + + // No classification options — original behavior + const errorResponse = await probeLayoutForErrors(options, layoutIndex); + if (errorResponse) return errorResponse; } return null; }); + + return { response, layoutFlags }; +} + +async function probeLayoutForErrors( + options: ProbeAppPageLayoutsOptions, + layoutIndex: number, +): Promise { + try { + const layoutResult = options.probeLayoutAt(layoutIndex); + if (isPromiseLike(layoutResult)) { + await layoutResult; + } + } catch (error) { + return options.onLayoutError(error, layoutIndex); + } + return null; } export async function probeAppPageComponent( diff --git a/packages/vinext/src/server/app-page-probe.ts b/packages/vinext/src/server/app-page-probe.ts index 58c8c29e0..443bff36a 100644 --- a/packages/vinext/src/server/app-page-probe.ts +++ b/packages/vinext/src/server/app-page-probe.ts @@ -2,8 +2,15 @@ import { probeAppPageComponent, probeAppPageLayouts, type AppPageSpecialError, + type LayoutClassificationOptions, + type LayoutFlags, } from "./app-page-execution.js"; +export type ProbeAppPageBeforeRenderResult = { + response: Response | null; + layoutFlags: LayoutFlags; +}; + export type ProbeAppPageBeforeRenderOptions = { hasLoadingBoundary: boolean; layoutCount: number; @@ -16,15 +23,19 @@ export type ProbeAppPageBeforeRenderOptions = { renderPageSpecialError: (specialError: AppPageSpecialError) => Promise; resolveSpecialError: (error: unknown) => AppPageSpecialError | null; runWithSuppressedHookWarning(probe: () => Promise): Promise; + /** When provided, enables per-layout static/dynamic classification. */ + classification?: LayoutClassificationOptions | null; }; export async function probeAppPageBeforeRender( options: ProbeAppPageBeforeRenderOptions, -): Promise { +): Promise { + let layoutFlags: LayoutFlags = {}; + // Layouts render before their children in Next.js, so layout-level special // errors must be handled before probing the page component itself. if (options.layoutCount > 0) { - const layoutProbeResponse = await probeAppPageLayouts({ + const layoutProbeResult = await probeAppPageLayouts({ layoutCount: options.layoutCount, async onLayoutError(layoutError, layoutIndex) { const specialError = options.resolveSpecialError(layoutError); @@ -38,16 +49,19 @@ export async function probeAppPageBeforeRender( runWithSuppressedHookWarning(probe) { return options.runWithSuppressedHookWarning(probe); }, + classification: options.classification, }); - if (layoutProbeResponse) { - return layoutProbeResponse; + layoutFlags = layoutProbeResult.layoutFlags; + + if (layoutProbeResult.response) { + return { response: layoutProbeResult.response, layoutFlags }; } } // Server Components are functions, so we can probe the page ahead of stream // creation and only turn special throws into immediate responses. - return probeAppPageComponent({ + const pageResponse = await probeAppPageComponent({ awaitAsyncResult: !options.hasLoadingBoundary, async onError(pageError) { const specialError = options.resolveSpecialError(pageError); @@ -65,4 +79,6 @@ export async function probeAppPageBeforeRender( return options.runWithSuppressedHookWarning(probe); }, }); + + return { response: pageResponse, layoutFlags }; } diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index e7b5753cf..672bb4694 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -118,7 +118,7 @@ function buildResponseTiming( export async function renderAppPageLifecycle( options: RenderAppPageLifecycleOptions, ): Promise { - const preRenderResponse = await probeAppPageBeforeRender({ + const preRenderResult = await probeAppPageBeforeRender({ hasLoadingBoundary: options.hasLoadingBoundary, layoutCount: options.layoutCount, probeLayoutAt(layoutIndex) { @@ -138,8 +138,8 @@ export async function renderAppPageLifecycle( return options.runWithSuppressedHookWarning(probe); }, }); - if (preRenderResponse) { - return preRenderResponse; + if (preRenderResult.response) { + return preRenderResult.response; } const compileEnd = options.isProduction ? undefined : performance.now(); diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index b3fae1b41..979de1824 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -40,6 +40,7 @@ function createResolvedElements( function createState(overrides: Partial = {}): AppRouterState { return { elements: createResolvedElements("route:/initial", "/"), + layoutFlags: {}, navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/initial", {}), renderId: 0, interceptionContext: null, @@ -76,6 +77,7 @@ describe("app browser entry state helpers", () => { { elements: nextElements, interceptionContext: null, + layoutFlags: {}, navigationSnapshot: createState().navigationSnapshot, previousNextUrl: null, renderId: 1, @@ -103,6 +105,7 @@ describe("app browser entry state helpers", () => { const nextState = routerReducer(createState(), { elements: nextElements, interceptionContext: null, + layoutFlags: {}, navigationSnapshot: createState().navigationSnapshot, previousNextUrl: null, renderId: 1, @@ -259,10 +262,55 @@ describe("app browser entry state helpers", () => { expect(refreshCommit.previousNextUrl).toBe("/feed"); }); + it("merges layoutFlags on navigate", () => { + const nextState = routerReducer( + createState({ layoutFlags: { "layout:/": "s", "layout:/old": "d" } }), + { + elements: createResolvedElements("route:/next", "/"), + interceptionContext: null, + layoutFlags: { "layout:/": "s", "layout:/blog": "d" }, + navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, + renderId: 1, + rootLayoutTreePath: "/", + routeId: "route:/next", + type: "navigate", + }, + ); + + // Navigate merges: old flags preserved, new flags override + expect(nextState.layoutFlags).toEqual({ + "layout:/": "s", + "layout:/old": "d", + "layout:/blog": "d", + }); + }); + + it("replaces layoutFlags on replace", () => { + const nextState = routerReducer( + createState({ layoutFlags: { "layout:/": "s", "layout:/old": "d" } }), + { + elements: createResolvedElements("route:/next", "/"), + interceptionContext: null, + layoutFlags: { "layout:/": "d" }, + navigationSnapshot: createState().navigationSnapshot, + previousNextUrl: null, + renderId: 1, + rootLayoutTreePath: "/", + routeId: "route:/next", + type: "replace", + }, + ); + + // Replace: only new flags + expect(nextState.layoutFlags).toEqual({ "layout:/": "d" }); + }); + it("stores previousNextUrl on navigate actions", () => { const nextState = routerReducer(createState(), { elements: createResolvedElements("route:/photos/42\0/feed", "/", "/feed"), interceptionContext: "/feed", + layoutFlags: {}, navigationSnapshot: createState().navigationSnapshot, previousNextUrl: "/feed", renderId: 1, @@ -359,6 +407,7 @@ describe("app browser entry previousNextUrl helpers", () => { const nextState = routerReducer(state, { elements: nextElements, interceptionContext: null, + layoutFlags: {}, navigationSnapshot: createState().navigationSnapshot, previousNextUrl: null, renderId: 1, @@ -381,6 +430,7 @@ describe("app browser entry previousNextUrl helpers", () => { const nextState = routerReducer(state, { elements: nextElements, interceptionContext: null, + layoutFlags: {}, navigationSnapshot: createState().navigationSnapshot, previousNextUrl: null, renderId: 1, diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index d0b168956..841bfc039 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { UNMATCHED_SLOT } from "../packages/vinext/src/shims/slot.js"; import { APP_INTERCEPTION_CONTEXT_KEY, + APP_LAYOUT_FLAGS_KEY, APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, APP_UNMATCHED_SLOT_WIRE_VALUE, @@ -119,4 +120,32 @@ describe("app elements payload helpers", () => { ), ).toThrow("[vinext] Invalid __interceptionContext in App Router payload"); }); + + it("reads layoutFlags from payload metadata", () => { + // Layout flags are set directly on the elements object (not via + // normalizeAppElements which expects AppWireElementValue types). + const elements = { + ...normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_ROUTE_KEY]: "route:/blog", + "page:/blog": React.createElement("div", null, "blog"), + }), + [APP_LAYOUT_FLAGS_KEY]: { "layout:/": "s", "layout:/blog": "d" }, + }; + const metadata = readAppElementsMetadata(elements); + + expect(metadata.layoutFlags).toEqual({ "layout:/": "s", "layout:/blog": "d" }); + }); + + it("defaults missing layoutFlags to empty object (backward compat)", () => { + const metadata = readAppElementsMetadata( + normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_ROUTE_KEY]: "route:/dashboard", + "route:/dashboard": React.createElement("div", null, "route"), + }), + ); + + expect(metadata.layoutFlags).toEqual({}); + }); }); diff --git a/tests/app-page-execution.test.ts b/tests/app-page-execution.test.ts index f1548f057..936c61c14 100644 --- a/tests/app-page-execution.test.ts +++ b/tests/app-page-execution.test.ts @@ -103,7 +103,7 @@ describe("app page execution helpers", () => { it("probes layouts from inner to outer and stops on a handled special response", async () => { const probedLayouts: number[] = []; - const response = await probeAppPageLayouts({ + const result = await probeAppPageLayouts({ layoutCount: 3, async onLayoutError(error, layoutIndex) { expect(error).toBeInstanceOf(Error); @@ -122,8 +122,8 @@ describe("app page execution helpers", () => { }); expect(probedLayouts).toEqual([2, 1]); - expect(response?.status).toBe(404); - await expect(response?.text()).resolves.toBe("layout-fallback"); + expect(result.response?.status).toBe(404); + await expect(result.response?.text()).resolves.toBe("layout-fallback"); }); it("does not await async page probes when a loading boundary is present", async () => { @@ -154,6 +154,201 @@ describe("app page execution helpers", () => { ); }); + it("tracks per-layout dynamic usage when classification options are provided", async () => { + const result = await probeAppPageLayouts({ + layoutCount: 3, + onLayoutError() { + return Promise.resolve(null); + }, + probeLayoutAt() { + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + buildTimeClassifications: new Map([ + [0, "static"], + [2, "dynamic"], + ]), + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/blog", "layout:/blog/post"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + return Promise.resolve({ result: fn(), dynamicDetected: false }); + }, + }, + }); + + expect(result.response).toBeNull(); + // Layout 0 is build-time static, layout 2 is build-time dynamic + // Layout 1 has no build-time classification, probed with no dynamic detected + expect(result.layoutFlags).toEqual({ + "layout:/": "s", + "layout:/blog": "s", + "layout:/blog/post": "d", + }); + }); + + it("detects dynamic usage per-layout through isolated scope", async () => { + let probeCallCount = 0; + const result = await probeAppPageLayouts({ + layoutCount: 2, + onLayoutError() { + return Promise.resolve(null); + }, + probeLayoutAt() { + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/dashboard"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + probeCallCount++; + const result = fn(); + // Simulate: second probe call (layout 0, since we iterate inner-to-outer) + // detects dynamic usage + return Promise.resolve({ + result, + dynamicDetected: probeCallCount === 2, + }); + }, + }, + }); + + expect(result.response).toBeNull(); + expect(result.layoutFlags).toEqual({ + "layout:/": "d", + "layout:/dashboard": "s", + }); + }); + + it("returns empty layoutFlags when classification options are absent (backward compat)", async () => { + const result = await probeAppPageLayouts({ + layoutCount: 2, + onLayoutError() { + return Promise.resolve(null); + }, + probeLayoutAt() { + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + }); + + expect(result.response).toBeNull(); + expect(result.layoutFlags).toEqual({}); + }); + + it("defaults to dynamic flag when probe throws a non-special error", async () => { + const result = await probeAppPageLayouts({ + layoutCount: 2, + onLayoutError() { + // Non-special error — return null (don't short-circuit) + return Promise.resolve(null); + }, + probeLayoutAt(layoutIndex) { + if (layoutIndex === 1) throw new Error("use() outside render"); + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/dashboard"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + // Re-throw so the catch path in probeAppPageLayouts fires + return Promise.resolve(fn()).then((result) => ({ result, dynamicDetected: false })); + }, + }, + }); + + expect(result.response).toBeNull(); + // Layout 1 threw → conservatively flagged as dynamic + expect(result.layoutFlags["layout:/dashboard"]).toBe("d"); + // Layout 0 probed successfully + expect(result.layoutFlags["layout:/"]).toBe("s"); + }); + + it("skips probe for build-time classified layouts", async () => { + let probeCalls = 0; + const result = await probeAppPageLayouts({ + layoutCount: 2, + onLayoutError() { + return Promise.resolve(null); + }, + probeLayoutAt() { + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + buildTimeClassifications: new Map([ + [0, "static"], + [1, "dynamic"], + ]), + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/admin"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + probeCalls++; + return Promise.resolve({ result: fn(), dynamicDetected: false }); + }, + }, + }); + + expect(probeCalls).toBe(0); + expect(result.layoutFlags).toEqual({ + "layout:/": "s", + "layout:/admin": "d", + }); + }); + + it("returns special error response when build-time classified layout throws during error probe", async () => { + const layoutError = new Error("layout failed"); + const specialResponse = new Response("layout-fallback", { status: 404 }); + + const result = await probeAppPageLayouts({ + layoutCount: 2, + onLayoutError(error) { + return Promise.resolve(error === layoutError ? specialResponse : null); + }, + probeLayoutAt(layoutIndex) { + if (layoutIndex === 1) throw layoutError; + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + buildTimeClassifications: new Map([ + [0, "static"], + [1, "static"], + ]), + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/admin"][layoutIndex]; + }, + runWithIsolatedDynamicScope() { + throw new Error("isolated scope must not run for build-time classified layouts"); + }, + }, + }); + + // The special-error response from the throwing layout short-circuits the + // loop. The flag for layout 1 is still recorded (set before the error + // probe runs), and layout 0 is never reached. + expect(result.response).toBe(specialResponse); + expect(result.layoutFlags).toEqual({ "layout:/admin": "s" }); + }); + it("builds Link headers for preloaded app-page fonts", () => { expect( buildAppPageFontLinkHeader([ diff --git a/tests/app-page-probe.test.ts b/tests/app-page-probe.test.ts index 052f51a76..8d8211fbf 100644 --- a/tests/app-page-probe.test.ts +++ b/tests/app-page-probe.test.ts @@ -19,7 +19,7 @@ describe("app page probe helpers", () => { const renderPageSpecialError = vi.fn(); const probedLayouts: number[] = []; - const response = await probeAppPageBeforeRender({ + const result = await probeAppPageBeforeRender({ hasLoadingBoundary: false, layoutCount: 3, probeLayoutAt(layoutIndex) { @@ -55,8 +55,8 @@ describe("app page probe helpers", () => { 1, ); expect(renderPageSpecialError).not.toHaveBeenCalled(); - expect(response?.status).toBe(404); - await expect(response?.text()).resolves.toBe("layout-fallback"); + expect(result.response?.status).toBe(404); + await expect(result.response?.text()).resolves.toBe("layout-fallback"); }); it("falls through to the page probe when layout failures are not special", async () => { @@ -64,7 +64,7 @@ describe("app page probe helpers", () => { const pageProbe = vi.fn(() => null); const renderLayoutSpecialError = vi.fn(); - const response = await probeAppPageBeforeRender({ + const result = await probeAppPageBeforeRender({ hasLoadingBoundary: false, layoutCount: 2, probeLayoutAt(layoutIndex) { @@ -86,7 +86,7 @@ describe("app page probe helpers", () => { }, }); - expect(response).toBeNull(); + expect(result.response).toBeNull(); expect(pageProbe).toHaveBeenCalledTimes(1); expect(renderLayoutSpecialError).not.toHaveBeenCalled(); }); @@ -97,7 +97,7 @@ describe("app page probe helpers", () => { async () => new Response("page-fallback", { status: 307 }), ); - const response = await probeAppPageBeforeRender({ + const result = await probeAppPageBeforeRender({ hasLoadingBoundary: false, layoutCount: 0, probeLayoutAt() { @@ -129,8 +129,90 @@ describe("app page probe helpers", () => { location: "/target", statusCode: 307, }); - expect(response?.status).toBe(307); - await expect(response?.text()).resolves.toBe("page-fallback"); + expect(result.response?.status).toBe(307); + await expect(result.response?.text()).resolves.toBe("page-fallback"); + }); + + it("propagates layoutFlags from layout probe result", async () => { + const pageProbe = vi.fn(() => null); + + const result = await probeAppPageBeforeRender({ + hasLoadingBoundary: false, + layoutCount: 2, + probeLayoutAt() { + return null; + }, + probePage: pageProbe, + renderLayoutSpecialError() { + throw new Error("should not render a layout special error"); + }, + renderPageSpecialError() { + throw new Error("should not render a page special error"); + }, + resolveSpecialError() { + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + buildTimeClassifications: new Map([ + [0, "static"], + [1, "dynamic"], + ]), + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/admin"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + return Promise.resolve({ result: fn(), dynamicDetected: false }); + }, + }, + }); + + expect(result.response).toBeNull(); + expect(result.layoutFlags).toEqual({ + "layout:/": "s", + "layout:/admin": "d", + }); + }); + + it("still handles special errors with classification enabled", async () => { + const layoutError = new Error("layout failed"); + + const result = await probeAppPageBeforeRender({ + hasLoadingBoundary: false, + layoutCount: 2, + probeLayoutAt(layoutIndex) { + if (layoutIndex === 1) { + throw layoutError; + } + return null; + }, + probePage() { + throw new Error("should not probe page"); + }, + renderLayoutSpecialError: vi.fn(async () => new Response("layout-fallback", { status: 404 })), + renderPageSpecialError() { + throw new Error("should not render a page special error"); + }, + resolveSpecialError(error) { + return error === layoutError ? { kind: "http-access-fallback", statusCode: 404 } : null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/admin"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + return Promise.resolve({ result: fn(), dynamicDetected: false }); + }, + }, + }); + + // Special error response should still be returned + expect(result.response?.status).toBe(404); }); // ── Regression: probePage must receive thenable params/searchParams ── @@ -156,7 +238,7 @@ describe("app page probe helpers", () => { ); // With thenable params, the probe should catch notFound() - const response = await probeAppPageBeforeRender({ + const result = await probeAppPageBeforeRender({ hasLoadingBoundary: false, layoutCount: 0, probeLayoutAt() { @@ -178,7 +260,7 @@ describe("app page probe helpers", () => { }); expect(renderPageSpecialError).toHaveBeenCalledOnce(); - expect(response?.status).toBe(404); + expect(result.response?.status).toBe(404); }); it("detects redirect() from an async-searchParams page when searchParams are thenable", async () => { @@ -198,7 +280,7 @@ describe("app page probe helpers", () => { async () => new Response(null, { status: 307, headers: { location: "/about" } }), ); - const response = await probeAppPageBeforeRender({ + const result = await probeAppPageBeforeRender({ hasLoadingBoundary: false, layoutCount: 0, probeLayoutAt() { @@ -225,7 +307,7 @@ describe("app page probe helpers", () => { }); expect(renderPageSpecialError).toHaveBeenCalledOnce(); - expect(response?.status).toBe(307); + expect(result.response?.status).toBe(307); }); it("probe silently fails when searchParams is omitted and page awaits it", async () => { @@ -237,7 +319,7 @@ describe("app page probe helpers", () => { // doesn't recognize it as a special error, so it returns null. const renderPageSpecialError = vi.fn(async () => new Response(null, { status: 307 })); - const response = await probeAppPageBeforeRender({ + const result = await probeAppPageBeforeRender({ hasLoadingBoundary: false, layoutCount: 0, probeLayoutAt() { @@ -268,14 +350,14 @@ describe("app page probe helpers", () => { // The probe catches the TypeError but resolveSpecialError returns null // for it (TypeError is not a special error) so the probe returns null. // The redirect is never detected early. - expect(response).toBeNull(); + expect(result.response).toBeNull(); expect(renderPageSpecialError).not.toHaveBeenCalled(); }); it("does not await async page probes when a loading boundary is present", async () => { const renderPageSpecialError = vi.fn(); - const response = await probeAppPageBeforeRender({ + const result = await probeAppPageBeforeRender({ hasLoadingBoundary: true, layoutCount: 0, probeLayoutAt() { @@ -299,7 +381,7 @@ describe("app page probe helpers", () => { }, }); - expect(response).toBeNull(); + expect(result.response).toBeNull(); expect(renderPageSpecialError).not.toHaveBeenCalled(); }); }); diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index d1e63e329..f969b6172 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -16,6 +16,7 @@ import { extractGetStaticPropsRevalidate, classifyPagesRoute, classifyAppRoute, + classifyLayoutSegmentConfig, buildReportRows, formatBuildReport, printBuildReport, @@ -739,3 +740,39 @@ describe("printBuildReport respects pageExtensions", () => { expect(output).toContain("/about"); }); }); + +// ─── classifyLayoutSegmentConfig ───────────────────────────────────────────── + +describe("classifyLayoutSegmentConfig", () => { + it('returns "static" for export const dynamic = "force-static"', () => { + expect(classifyLayoutSegmentConfig('export const dynamic = "force-static";')).toBe("static"); + }); + + it('returns "static" for export const dynamic = "error" (enforces static)', () => { + expect(classifyLayoutSegmentConfig("export const dynamic = 'error';")).toBe("static"); + }); + + it('returns "dynamic" for export const dynamic = "force-dynamic"', () => { + expect(classifyLayoutSegmentConfig('export const dynamic = "force-dynamic";')).toBe("dynamic"); + }); + + it('returns "dynamic" for export const revalidate = 0', () => { + expect(classifyLayoutSegmentConfig("export const revalidate = 0;")).toBe("dynamic"); + }); + + it('returns "static" for export const revalidate = Infinity', () => { + expect(classifyLayoutSegmentConfig("export const revalidate = Infinity;")).toBe("static"); + }); + + it("returns null for no config (defers to module graph)", () => { + expect( + classifyLayoutSegmentConfig( + "export default function Layout({ children }) { return children; }", + ), + ).toBeNull(); + }); + + it("returns null for positive revalidate (ISR is a page concept)", () => { + expect(classifyLayoutSegmentConfig("export const revalidate = 60;")).toBeNull(); + }); +}); diff --git a/tests/layout-classification.test.ts b/tests/layout-classification.test.ts new file mode 100644 index 000000000..96a6cd7e7 --- /dev/null +++ b/tests/layout-classification.test.ts @@ -0,0 +1,244 @@ +/** + * Layout classification tests — module graph traversal and combined + * (segment config + module graph) classification for static/dynamic + * layout detection. + */ +import { describe, expect, it } from "vite-plus/test"; +import { + classifyLayoutByModuleGraph, + classifyAllRouteLayouts, + type ModuleInfoProvider, +} from "../packages/vinext/src/build/layout-classification.js"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Builds a fake module graph for testing. Each key is a module ID, + * and the value lists its static and dynamic imports. + */ +function createFakeModuleGraph( + graph: Record, +): ModuleInfoProvider { + return { + getModuleInfo(id: string) { + const entry = graph[id]; + if (!entry) return null; + return { + importedIds: entry.importedIds ?? [], + dynamicImportedIds: entry.dynamicImportedIds ?? [], + }; + }, + }; +} + +const DYNAMIC_SHIMS = new Set(["/shims/headers", "/shims/cache", "/shims/server"]); + +// ─── classifyLayoutByModuleGraph ───────────────────────────────────────────── + +describe("classifyLayoutByModuleGraph", () => { + it('returns "static" when layout has no transitive dynamic shim imports', () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/components/nav.tsx"] }, + "/components/nav.tsx": { importedIds: [] }, + }); + + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); + }); + + it('returns "needs-probe" when headers shim is transitively imported', () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/components/auth.tsx"] }, + "/components/auth.tsx": { importedIds: ["/shims/headers"] }, + "/shims/headers": { importedIds: [] }, + }); + + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); + }); + + it('returns "needs-probe" when cache shim (noStore) is transitively imported', () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/shims/cache"] }, + "/shims/cache": { importedIds: [] }, + }); + + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); + }); + + it('returns "needs-probe" when server shim (connection) is transitively imported', () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/lib/data.ts"] }, + "/lib/data.ts": { importedIds: ["/shims/server"] }, + "/shims/server": { importedIds: [] }, + }); + + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); + }); + + it("handles circular imports without infinite loop", () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/a.ts"] }, + "/a.ts": { importedIds: ["/b.ts"] }, + "/b.ts": { importedIds: ["/a.ts"] }, + }); + + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); + }); + + it("detects dynamic shim through deep transitive chains", () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/a.ts"] }, + "/a.ts": { importedIds: ["/b.ts"] }, + "/b.ts": { importedIds: ["/c.ts"] }, + "/c.ts": { importedIds: ["/shims/headers"] }, + "/shims/headers": { importedIds: [] }, + }); + + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); + }); + + it("follows dynamicImportedIds (dynamic import())", () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { + importedIds: [], + dynamicImportedIds: ["/lazy.ts"], + }, + "/lazy.ts": { importedIds: ["/shims/headers"] }, + "/shims/headers": { importedIds: [] }, + }); + + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); + }); + + it('returns "static" when module info is null (unknown module)', () => { + const graph = createFakeModuleGraph({}); + + expect(classifyLayoutByModuleGraph("/unknown/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); + }); +}); + +// ─── classifyAllRouteLayouts ───────────────────────────────────────────────── + +describe("classifyAllRouteLayouts", () => { + it("segment config takes priority over module graph", () => { + // Layout imports headers shim, but segment config says force-static + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/shims/headers"] }, + "/shims/headers": { importedIds: [] }, + }); + + const routes = [ + { + layouts: [ + { + moduleId: "/app/layout.tsx", + treePosition: 0, + segmentConfig: { code: 'export const dynamic = "force-static";' }, + }, + ], + routeSegments: ["blog"], + }, + ]; + + const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); + expect(result.get("layout:/")).toBe("static"); + }); + + it("deduplicates shared layout files across routes", () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: [] }, + "/app/blog/layout.tsx": { importedIds: ["/shims/headers"] }, + "/shims/headers": { importedIds: [] }, + }); + + const routes = [ + { + layouts: [ + { moduleId: "/app/layout.tsx", treePosition: 0 }, + { moduleId: "/app/blog/layout.tsx", treePosition: 1 }, + ], + routeSegments: ["blog"], + }, + { + layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0 }], + routeSegments: ["about"], + }, + ]; + + const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); + // Root layout appears in both routes but should only be classified once + expect(result.get("layout:/")).toBe("static"); + expect(result.get("layout:/blog")).toBe("needs-probe"); + expect(result.size).toBe(2); + }); + + it("returns dynamic for force-dynamic segment config", () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: [] }, + }); + + const routes = [ + { + layouts: [ + { + moduleId: "/app/layout.tsx", + treePosition: 0, + segmentConfig: { code: 'export const dynamic = "force-dynamic";' }, + }, + ], + routeSegments: [], + }, + ]; + + const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); + expect(result.get("layout:/")).toBe("dynamic"); + }); + + it("falls through to module graph when segment config returns null", () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: [] }, + }); + + const routes = [ + { + layouts: [ + { + moduleId: "/app/layout.tsx", + treePosition: 0, + segmentConfig: { code: "export default function Layout() {}" }, + }, + ], + routeSegments: [], + }, + ]; + + const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); + expect(result.get("layout:/")).toBe("static"); + }); + + it("classifies layouts without segment configs using module graph only", () => { + const graph = createFakeModuleGraph({ + "/app/layout.tsx": { importedIds: ["/shims/cache"] }, + "/shims/cache": { importedIds: [] }, + }); + + const routes = [ + { + layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0 }], + routeSegments: [], + }, + ]; + + const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); + expect(result.get("layout:/")).toBe("needs-probe"); + }); +});