From 50fa22fab70a229e71a1af8248f87db2048c9993 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:22:06 +1100 Subject: [PATCH 01/11] feat: add classifyLayoutSegmentConfig for layout segment config detection Reads `export const dynamic` and `export const revalidate` from layout source files to classify them as static or dynamic. Unlike page classification, positive revalidate values return null (ISR is a page concept), deferring to module graph analysis for layout skip decisions. --- packages/vinext/src/build/report.ts | 31 ++++++++++++++++++++++++ tests/build-report.test.ts | 37 +++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+) 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/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(); + }); +}); From 1b85117289a80709d2e26407e37a0c13671f4b42 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:24:04 +1100 Subject: [PATCH 02/11] feat: add module graph layout classification (Layer 2) BFS traversal of each layout's dependency tree via Vite's module graph. If no transitive dynamic shim import (headers, cache, server) is found, the layout is provably static. Otherwise it needs a runtime probe. classifyAllRouteLayouts combines Layer 1 (segment config, from prior commit) with Layer 2 (module graph), deduplicating shared layouts. --- .../vinext/src/build/layout-classification.ts | 112 +++++++++ tests/layout-classification.test.ts | 232 ++++++++++++++++++ 2 files changed, 344 insertions(+) create mode 100644 packages/vinext/src/build/layout-classification.ts create mode 100644 tests/layout-classification.test.ts diff --git a/packages/vinext/src/build/layout-classification.ts b/packages/vinext/src/build/layout-classification.ts new file mode 100644 index 000000000..74f15a992 --- /dev/null +++ b/packages/vinext/src/build/layout-classification.ts @@ -0,0 +1,112 @@ +/** + * 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 RouteForClassification = { + layouts: string[]; + layoutTreePositions: number[]; + routeSegments: string[]; + layoutSegmentConfigs?: ReadonlyArray<{ code: string } | null>; +}; + +/** + * 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 (let i = 0; i < route.layouts.length; i++) { + const treePosition = route.layoutTreePositions[i] ?? 0; + const layoutId = `layout:${createAppPageTreePath(route.routeSegments, treePosition)}`; + + if (result.has(layoutId)) continue; + + // Layer 1: segment config + const segmentConfig = route.layoutSegmentConfigs?.[i]; + if (segmentConfig) { + const configResult = classifyLayoutSegmentConfig(segmentConfig.code); + if (configResult !== null) { + result.set(layoutId, configResult); + continue; + } + } + + // Layer 2: module graph + const graphResult = classifyLayoutByModuleGraph( + route.layouts[i], + dynamicShimPaths, + moduleInfo, + ); + result.set(layoutId, graphResult); + } + } + + return result; +} diff --git a/tests/layout-classification.test.ts b/tests/layout-classification.test.ts new file mode 100644 index 000000000..c7a1b2785 --- /dev/null +++ b/tests/layout-classification.test.ts @@ -0,0 +1,232 @@ +/** + * 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: ["/app/layout.tsx"], + layoutTreePositions: [0], + routeSegments: ["blog"], + layoutSegmentConfigs: [{ code: 'export const dynamic = "force-static";' }], + }, + ]; + + 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: ["/app/layout.tsx", "/app/blog/layout.tsx"], + layoutTreePositions: [0, 1], + routeSegments: ["blog"], + }, + { + layouts: ["/app/layout.tsx"], + layoutTreePositions: [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: ["/app/layout.tsx"], + layoutTreePositions: [0], + routeSegments: [], + layoutSegmentConfigs: [{ code: 'export const dynamic = "force-dynamic";' }], + }, + ]; + + 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: ["/app/layout.tsx"], + layoutTreePositions: [0], + routeSegments: [], + layoutSegmentConfigs: [{ code: "export default function Layout() {}" }], + }, + ]; + + 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: ["/app/layout.tsx"], + layoutTreePositions: [0], + routeSegments: [], + }, + ]; + + const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); + expect(result.get("layout:/")).toBe("needs-probe"); + }); +}); From 86042390f4437cc97cae6758049048b427f3205e Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:28:15 +1100 Subject: [PATCH 03/11] feat: per-layout dynamic detection in probe phase (Layer 3) Extends probeAppPageLayouts to return per-layout flags ("s"/"d") alongside the existing Response. Three paths per layout: - Build-time classified: pass flag through, still probe for errors - Needs probe: run with isolated dynamic scope, detect usage - No classification: original behavior (backward compat) probeAppPageBeforeRender propagates layoutFlags through the result. renderAppPageLifecycle updated to destructure the new return type. --- .../vinext/src/server/app-page-execution.ts | 76 ++++++-- packages/vinext/src/server/app-page-probe.ts | 34 +++- packages/vinext/src/server/app-page-render.ts | 6 +- tests/app-page-execution.test.ts | 164 +++++++++++++++++- tests/app-page-probe.test.ts | 110 ++++++++++-- 5 files changed, 352 insertions(+), 38 deletions(-) diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index 806d7214b..32e59c457 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -19,11 +19,27 @@ export type BuildAppPageSpecialErrorResponseOptions = { specialError: AppPageSpecialError; }; +export type LayoutFlags = Readonly>; + +export type ProbeAppPageLayoutsResult = { + response: Response | null; + layoutFlags: LayoutFlags; +}; + export type ProbeAppPageLayoutsOptions = { layoutCount: number; onLayoutError: (error: unknown, layoutIndex: number) => Promise; probeLayoutAt: (layoutIndex: number) => unknown; runWithSuppressedHookWarning(probe: () => Promise): Promise; + + /** Build-time classifications from segment config or module graph. */ + 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 ProbeAppPageComponentOptions = { @@ -98,24 +114,62 @@ export async function buildAppPageSpecialErrorResponse( export async function probeAppPageLayouts( options: ProbeAppPageLayoutsOptions, -): Promise { - return options.runWithSuppressedHookWarning(async () => { +): Promise { + const layoutFlags: Record = {}; + const hasClassification = !!(options.getLayoutId && options.runWithIsolatedDynamicScope); + + 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 = options.buildTimeClassifications?.get(layoutIndex); + + if (hasClassification && buildTimeResult) { + // Build-time classified (Layer 1 or Layer 2): skip dynamic isolation, + // but still probe for special errors (redirects, not-found). + layoutFlags[options.getLayoutId!(layoutIndex)] = buildTimeResult === "static" ? "s" : "d"; + const errorResponse = await probeLayoutForErrors(options, layoutIndex); + if (errorResponse) return errorResponse; + continue; + } + + if (hasClassification) { + // Layer 3: probe with isolated dynamic scope to detect per-layout + // dynamic API usage (headers(), cookies(), connection(), etc.) + try { + const { dynamicDetected } = await options.runWithIsolatedDynamicScope!(() => + options.probeLayoutAt(layoutIndex), + ); + layoutFlags[options.getLayoutId!(layoutIndex)] = dynamicDetected ? "d" : "s"; + } catch (error) { + 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..d896b540b 100644 --- a/packages/vinext/src/server/app-page-probe.ts +++ b/packages/vinext/src/server/app-page-probe.ts @@ -2,8 +2,14 @@ import { probeAppPageComponent, probeAppPageLayouts, type AppPageSpecialError, + type LayoutFlags, } from "./app-page-execution.js"; +export type ProbeAppPageBeforeRenderResult = { + response: Response | null; + layoutFlags: LayoutFlags; +}; + export type ProbeAppPageBeforeRenderOptions = { hasLoadingBoundary: boolean; layoutCount: number; @@ -16,15 +22,26 @@ export type ProbeAppPageBeforeRenderOptions = { renderPageSpecialError: (specialError: AppPageSpecialError) => Promise; resolveSpecialError: (error: unknown) => AppPageSpecialError | null; runWithSuppressedHookWarning(probe: () => Promise): Promise; + + /** Build-time classifications from segment config or module graph. */ + 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 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 +55,21 @@ export async function probeAppPageBeforeRender( runWithSuppressedHookWarning(probe) { return options.runWithSuppressedHookWarning(probe); }, + buildTimeClassifications: options.buildTimeClassifications, + getLayoutId: options.getLayoutId, + runWithIsolatedDynamicScope: options.runWithIsolatedDynamicScope, }); - 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 +87,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-page-execution.test.ts b/tests/app-page-execution.test.ts index f1548f057..ab37d98b6 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,164 @@ 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(); + }, + buildTimeClassifications: new Map([ + [0, "static"], + [2, "dynamic"], + ]), + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/blog", "layout:/blog/post"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + // Simulate: layout 1 triggers dynamic usage, others don't + 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(); + }, + 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("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(); + }, + 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..26feed3f8 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,86 @@ 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(); + }, + 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(); + }, + 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 +234,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 +256,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 +276,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 +303,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 +315,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 +346,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 +377,7 @@ describe("app page probe helpers", () => { }, }); - expect(response).toBeNull(); + expect(result.response).toBeNull(); expect(renderPageSpecialError).not.toHaveBeenCalled(); }); }); From 6d99a42646038aa56b301718ae19d7e67ce24707 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:35:13 +1100 Subject: [PATCH 04/11] feat: add __layoutFlags payload metadata and thread through router state Adds APP_LAYOUT_FLAGS_KEY to the RSC payload metadata, carrying per-layout static/dynamic flags ("s"/"d"). readAppElementsMetadata now parses layoutFlags with a type predicate guard. AppRouterState and AppRouterAction carry layoutFlags. Navigate merges flags (preserving previously-seen layouts), replace replaces them. All dispatchBrowserTree call sites updated to pass layoutFlags. --- .../vinext/src/server/app-browser-entry.ts | 7 +++ .../vinext/src/server/app-browser-state.ts | 7 ++- packages/vinext/src/server/app-elements.ts | 29 ++++++++++- tests/app-browser-entry.test.ts | 50 +++++++++++++++++++ tests/app-elements.test.ts | 29 +++++++++++ 5 files changed, 120 insertions(+), 2 deletions(-) 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..4166050d3 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,11 @@ export type AppWireElementValue = ReactNode | string | null; export type AppElements = Readonly>; export type AppWireElements = Readonly>; +export type LayoutFlags = Readonly>; + export type AppElementsMetadata = { interceptionContext: string | null; + layoutFlags: LayoutFlags; routeId: string; rootLayoutTreePath: string | null; }; @@ -102,7 +106,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 +149,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/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({}); + }); }); From bfbbd593dce6f19e37399e37195107fc893a5d43 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:44:25 +1100 Subject: [PATCH 05/11] refactor: group classification options into single LayoutClassificationOptions type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three optional fields (buildTimeClassifications, getLayoutId, runWithIsolatedDynamicScope) had an all-or-nothing invariant enforced only at runtime. Grouping them into a single optional `classification` object makes the constraint type-safe — you either provide the full classification context or nothing. Also deduplicates the LayoutFlags type: canonical definition lives in app-elements.ts, re-exported from app-page-execution.ts. --- .../vinext/src/server/app-page-execution.ts | 40 ++++++----- packages/vinext/src/server/app-page-probe.ts | 16 ++--- tests/app-page-execution.test.ts | 69 ++++++++++--------- tests/app-page-probe.test.ts | 32 +++++---- 4 files changed, 81 insertions(+), 76 deletions(-) diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index 32e59c457..5b440842b 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,27 +23,27 @@ export type BuildAppPageSpecialErrorResponseOptions = { specialError: AppPageSpecialError; }; -export type LayoutFlags = Readonly>; - export type ProbeAppPageLayoutsResult = { response: Response | null; layoutFlags: LayoutFlags; }; +export type LayoutClassificationOptions = { + /** Build-time classifications from segment config or module graph. */ + 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; - - /** Build-time classifications from segment config or module graph. */ - 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 }>; + /** When provided, enables per-layout static/dynamic classification. */ + classification?: LayoutClassificationOptions | null; }; export type ProbeAppPageComponentOptions = { @@ -116,29 +120,29 @@ export async function probeAppPageLayouts( options: ProbeAppPageLayoutsOptions, ): Promise { const layoutFlags: Record = {}; - const hasClassification = !!(options.getLayoutId && options.runWithIsolatedDynamicScope); + const cls = options.classification ?? null; const response = await options.runWithSuppressedHookWarning(async () => { for (let layoutIndex = options.layoutCount - 1; layoutIndex >= 0; layoutIndex--) { - const buildTimeResult = options.buildTimeClassifications?.get(layoutIndex); + const buildTimeResult = cls?.buildTimeClassifications?.get(layoutIndex); - if (hasClassification && buildTimeResult) { + if (cls && buildTimeResult) { // Build-time classified (Layer 1 or Layer 2): skip dynamic isolation, // but still probe for special errors (redirects, not-found). - layoutFlags[options.getLayoutId!(layoutIndex)] = buildTimeResult === "static" ? "s" : "d"; + layoutFlags[cls.getLayoutId(layoutIndex)] = buildTimeResult === "static" ? "s" : "d"; const errorResponse = await probeLayoutForErrors(options, layoutIndex); if (errorResponse) return errorResponse; continue; } - if (hasClassification) { + if (cls) { // Layer 3: probe with isolated dynamic scope to detect per-layout // dynamic API usage (headers(), cookies(), connection(), etc.) try { - const { dynamicDetected } = await options.runWithIsolatedDynamicScope!(() => + const { dynamicDetected } = await cls.runWithIsolatedDynamicScope(() => options.probeLayoutAt(layoutIndex), ); - layoutFlags[options.getLayoutId!(layoutIndex)] = dynamicDetected ? "d" : "s"; + layoutFlags[cls.getLayoutId(layoutIndex)] = dynamicDetected ? "d" : "s"; } catch (error) { const errorResponse = await options.onLayoutError(error, layoutIndex); if (errorResponse) return errorResponse; diff --git a/packages/vinext/src/server/app-page-probe.ts b/packages/vinext/src/server/app-page-probe.ts index d896b540b..443bff36a 100644 --- a/packages/vinext/src/server/app-page-probe.ts +++ b/packages/vinext/src/server/app-page-probe.ts @@ -2,6 +2,7 @@ import { probeAppPageComponent, probeAppPageLayouts, type AppPageSpecialError, + type LayoutClassificationOptions, type LayoutFlags, } from "./app-page-execution.js"; @@ -22,15 +23,8 @@ export type ProbeAppPageBeforeRenderOptions = { renderPageSpecialError: (specialError: AppPageSpecialError) => Promise; resolveSpecialError: (error: unknown) => AppPageSpecialError | null; runWithSuppressedHookWarning(probe: () => Promise): Promise; - - /** Build-time classifications from segment config or module graph. */ - 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 }>; + /** When provided, enables per-layout static/dynamic classification. */ + classification?: LayoutClassificationOptions | null; }; export async function probeAppPageBeforeRender( @@ -55,9 +49,7 @@ export async function probeAppPageBeforeRender( runWithSuppressedHookWarning(probe) { return options.runWithSuppressedHookWarning(probe); }, - buildTimeClassifications: options.buildTimeClassifications, - getLayoutId: options.getLayoutId, - runWithIsolatedDynamicScope: options.runWithIsolatedDynamicScope, + classification: options.classification, }); layoutFlags = layoutProbeResult.layoutFlags; diff --git a/tests/app-page-execution.test.ts b/tests/app-page-execution.test.ts index ab37d98b6..9d1aa9221 100644 --- a/tests/app-page-execution.test.ts +++ b/tests/app-page-execution.test.ts @@ -166,16 +166,17 @@ describe("app page execution helpers", () => { runWithSuppressedHookWarning(probe) { return probe(); }, - buildTimeClassifications: new Map([ - [0, "static"], - [2, "dynamic"], - ]), - getLayoutId(layoutIndex) { - return ["layout:/", "layout:/blog", "layout:/blog/post"][layoutIndex]; - }, - runWithIsolatedDynamicScope(fn) { - // Simulate: layout 1 triggers dynamic usage, others don't - return Promise.resolve({ result: fn(), dynamicDetected: false }); + 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 }); + }, }, }); @@ -202,18 +203,20 @@ describe("app page execution helpers", () => { runWithSuppressedHookWarning(probe) { return probe(); }, - 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, - }); + 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, + }); + }, }, }); @@ -255,16 +258,18 @@ describe("app page execution helpers", () => { runWithSuppressedHookWarning(probe) { return probe(); }, - buildTimeClassifications: new Map([ - [0, "static"], - [1, "dynamic"], - ]), - getLayoutId(layoutIndex) { - return ["layout:/", "layout:/admin"][layoutIndex]; - }, - runWithIsolatedDynamicScope(fn) { - probeCalls++; - return Promise.resolve({ result: fn(), dynamicDetected: false }); + 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 }); + }, }, }); diff --git a/tests/app-page-probe.test.ts b/tests/app-page-probe.test.ts index 26feed3f8..8d8211fbf 100644 --- a/tests/app-page-probe.test.ts +++ b/tests/app-page-probe.test.ts @@ -155,15 +155,17 @@ describe("app page probe helpers", () => { runWithSuppressedHookWarning(probe) { return probe(); }, - buildTimeClassifications: new Map([ - [0, "static"], - [1, "dynamic"], - ]), - getLayoutId(layoutIndex) { - return ["layout:/", "layout:/admin"][layoutIndex]; - }, - runWithIsolatedDynamicScope(fn) { - return Promise.resolve({ result: fn(), dynamicDetected: false }); + classification: { + buildTimeClassifications: new Map([ + [0, "static"], + [1, "dynamic"], + ]), + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/admin"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + return Promise.resolve({ result: fn(), dynamicDetected: false }); + }, }, }); @@ -199,11 +201,13 @@ describe("app page probe helpers", () => { runWithSuppressedHookWarning(probe) { return probe(); }, - getLayoutId(layoutIndex) { - return ["layout:/", "layout:/admin"][layoutIndex]; - }, - runWithIsolatedDynamicScope(fn) { - return Promise.resolve({ result: fn(), dynamicDetected: false }); + classification: { + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/admin"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + return Promise.resolve({ result: fn(), dynamicDetected: false }); + }, }, }); From 4392f14512039026f230df0d19e4e206596ed21a Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:54:29 +1100 Subject: [PATCH 06/11] fix: default to dynamic flag when layout probe throws non-special error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When runWithIsolatedDynamicScope throws and the error is non-special (onLayoutError returns null), the layout was silently omitted from layoutFlags. Now conservatively defaults to "d" — if probing failed, the layout cannot be proven static. --- .../vinext/src/server/app-page-execution.ts | 2 ++ tests/app-page-execution.test.ts | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index 5b440842b..b55b981e0 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -144,6 +144,8 @@ export async function probeAppPageLayouts( ); 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; } diff --git a/tests/app-page-execution.test.ts b/tests/app-page-execution.test.ts index 9d1aa9221..936c61c14 100644 --- a/tests/app-page-execution.test.ts +++ b/tests/app-page-execution.test.ts @@ -245,6 +245,38 @@ describe("app page execution helpers", () => { 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({ From bcdc760aed179b84825063a6e195da651c8be5bb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 16:56:09 +0000 Subject: [PATCH 07/11] refactor: replace parallel arrays with LayoutEntry struct, tighten names - Introduce LayoutEntry (moduleId, treePosition, segmentConfig) in layout-classification.ts, collapsing the three parallel arrays (layouts/layoutTreePositions/layoutSegmentConfigs) in RouteForClassification into a single struct. Switches the inner loop to for...of, removing the ?? 0 fallback that masked index mismatches. - Update all five classifyAllRouteLayouts test fixtures to the struct shape. - Add a JSDoc on LayoutFlags clarifying that "s" = static, "d" = dynamic. - Tighten the buildTimeClassifications comment to say "keyed by layout index". https://claude.ai/code/session_01MDJzCNao3dX9tmmVtSqsw5 --- .../vinext/src/build/layout-classification.ts | 31 ++++++++++--------- packages/vinext/src/server/app-elements.ts | 1 + .../vinext/src/server/app-page-execution.ts | 2 +- tests/layout-classification.test.ts | 24 ++++++-------- 4 files changed, 28 insertions(+), 30 deletions(-) diff --git a/packages/vinext/src/build/layout-classification.ts b/packages/vinext/src/build/layout-classification.ts index 74f15a992..e564bb529 100644 --- a/packages/vinext/src/build/layout-classification.ts +++ b/packages/vinext/src/build/layout-classification.ts @@ -22,11 +22,18 @@ export type ModuleInfoProvider = { } | 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: string[]; - layoutTreePositions: number[]; + layouts: readonly LayoutEntry[]; routeSegments: string[]; - layoutSegmentConfigs?: ReadonlyArray<{ code: string } | null>; }; /** @@ -82,16 +89,14 @@ export function classifyAllRouteLayouts( const result = new Map(); for (const route of routes) { - for (let i = 0; i < route.layouts.length; i++) { - const treePosition = route.layoutTreePositions[i] ?? 0; - const layoutId = `layout:${createAppPageTreePath(route.routeSegments, treePosition)}`; + for (const layout of route.layouts) { + const layoutId = `layout:${createAppPageTreePath(route.routeSegments, layout.treePosition)}`; if (result.has(layoutId)) continue; // Layer 1: segment config - const segmentConfig = route.layoutSegmentConfigs?.[i]; - if (segmentConfig) { - const configResult = classifyLayoutSegmentConfig(segmentConfig.code); + if (layout.segmentConfig) { + const configResult = classifyLayoutSegmentConfig(layout.segmentConfig.code); if (configResult !== null) { result.set(layoutId, configResult); continue; @@ -99,12 +104,10 @@ export function classifyAllRouteLayouts( } // Layer 2: module graph - const graphResult = classifyLayoutByModuleGraph( - route.layouts[i], - dynamicShimPaths, - moduleInfo, + result.set( + layoutId, + classifyLayoutByModuleGraph(layout.moduleId, dynamicShimPaths, moduleInfo), ); - result.set(layoutId, graphResult); } } diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index 4166050d3..b8bc42616 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -16,6 +16,7 @@ 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 = { diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index b55b981e0..d698f417d 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -29,7 +29,7 @@ export type ProbeAppPageLayoutsResult = { }; export type LayoutClassificationOptions = { - /** Build-time classifications from segment config or module graph. */ + /** 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; diff --git a/tests/layout-classification.test.ts b/tests/layout-classification.test.ts index c7a1b2785..7d8b9f7f1 100644 --- a/tests/layout-classification.test.ts +++ b/tests/layout-classification.test.ts @@ -138,10 +138,8 @@ describe("classifyAllRouteLayouts", () => { const routes = [ { - layouts: ["/app/layout.tsx"], - layoutTreePositions: [0], + layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0, segmentConfig: { code: 'export const dynamic = "force-static";' } }], routeSegments: ["blog"], - layoutSegmentConfigs: [{ code: 'export const dynamic = "force-static";' }], }, ]; @@ -158,13 +156,14 @@ describe("classifyAllRouteLayouts", () => { const routes = [ { - layouts: ["/app/layout.tsx", "/app/blog/layout.tsx"], - layoutTreePositions: [0, 1], + layouts: [ + { moduleId: "/app/layout.tsx", treePosition: 0 }, + { moduleId: "/app/blog/layout.tsx", treePosition: 1 }, + ], routeSegments: ["blog"], }, { - layouts: ["/app/layout.tsx"], - layoutTreePositions: [0], + layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0 }], routeSegments: ["about"], }, ]; @@ -183,10 +182,8 @@ describe("classifyAllRouteLayouts", () => { const routes = [ { - layouts: ["/app/layout.tsx"], - layoutTreePositions: [0], + layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0, segmentConfig: { code: 'export const dynamic = "force-dynamic";' } }], routeSegments: [], - layoutSegmentConfigs: [{ code: 'export const dynamic = "force-dynamic";' }], }, ]; @@ -201,10 +198,8 @@ describe("classifyAllRouteLayouts", () => { const routes = [ { - layouts: ["/app/layout.tsx"], - layoutTreePositions: [0], + layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0, segmentConfig: { code: "export default function Layout() {}" } }], routeSegments: [], - layoutSegmentConfigs: [{ code: "export default function Layout() {}" }], }, ]; @@ -220,8 +215,7 @@ describe("classifyAllRouteLayouts", () => { const routes = [ { - layouts: ["/app/layout.tsx"], - layoutTreePositions: [0], + layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0 }], routeSegments: [], }, ]; From 8e09f70b8039cd43b80839b637523f6b9be5d671 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 17:04:23 +0000 Subject: [PATCH 08/11] style: fix prettier formatting https://claude.ai/code/session_01MDJzCNao3dX9tmmVtSqsw5 --- .../vinext/src/build/layout-classification.ts | 10 ++- packages/vinext/src/server/app-elements.ts | 30 ++++++-- .../vinext/src/server/app-page-execution.ts | 57 ++++++++++---- tests/layout-classification.test.ts | 77 +++++++++++++------ 4 files changed, 128 insertions(+), 46 deletions(-) diff --git a/packages/vinext/src/build/layout-classification.ts b/packages/vinext/src/build/layout-classification.ts index e564bb529..920e615d7 100644 --- a/packages/vinext/src/build/layout-classification.ts +++ b/packages/vinext/src/build/layout-classification.ts @@ -96,7 +96,9 @@ export function classifyAllRouteLayouts( // Layer 1: segment config if (layout.segmentConfig) { - const configResult = classifyLayoutSegmentConfig(layout.segmentConfig.code); + const configResult = classifyLayoutSegmentConfig( + layout.segmentConfig.code, + ); if (configResult !== null) { result.set(layoutId, configResult); continue; @@ -106,7 +108,11 @@ export function classifyAllRouteLayouts( // Layer 2: module graph result.set( layoutId, - classifyLayoutByModuleGraph(layout.moduleId, dynamicShimPaths, moduleInfo), + classifyLayoutByModuleGraph( + layout.moduleId, + dynamicShimPaths, + moduleInfo, + ), ); } } diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index b8bc42616..bf07bdb3b 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -26,12 +26,16 @@ export type AppElementsMetadata = { rootLayoutTreePath: string | null; }; -export function normalizeMountedSlotsHeader(header: string | null | undefined): string | null { +export function normalizeMountedSlotsHeader( + header: string | null | undefined, +): string | null { if (!header) { return null; } - const slotIds = Array.from(new Set(header.split(/\s+/).filter(Boolean))).sort(); + const slotIds = Array.from( + new Set(header.split(/\s+/).filter(Boolean)), + ).sort(); return slotIds.length > 0 ? slotIds.join(" ") : null; } @@ -41,7 +45,10 @@ export function getMountedSlotIds(elements: AppElements): string[] { .filter((key) => { const value = elements[key]; return ( - key.startsWith("slot:") && value !== null && value !== undefined && value !== UNMATCHED_SLOT + key.startsWith("slot:") && + value !== null && + value !== undefined && + value !== UNMATCHED_SLOT ); }) .sort(); @@ -51,7 +58,10 @@ export function getMountedSlotIdsHeader(elements: AppElements): string | null { return normalizeMountedSlotsHeader(getMountedSlotIds(elements).join(" ")); } -function appendInterceptionContext(identity: string, interceptionContext: string | null): string { +function appendInterceptionContext( + identity: string, + interceptionContext: string | null, +): string { return interceptionContext === null ? identity : `${identity}${APP_INTERCEPTION_SEPARATOR}${interceptionContext}`; @@ -101,7 +111,9 @@ export function normalizeAppElements(elements: AppWireElements): AppElements { const normalized: Record = {}; for (const [key, value] of Object.entries(elements)) { normalized[key] = - key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE ? UNMATCHED_SLOT : value; + key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE + ? UNMATCHED_SLOT + : value; } return normalized; @@ -139,7 +151,9 @@ export function readAppElementsMetadata( interceptionContext !== null && typeof interceptionContext !== "string" ) { - throw new Error("[vinext] Invalid __interceptionContext in App Router payload"); + throw new Error( + "[vinext] Invalid __interceptionContext in App Router payload", + ); } const rootLayoutTreePath = elements[APP_ROOT_LAYOUT_KEY]; @@ -147,7 +161,9 @@ export function readAppElementsMetadata( throw new Error("[vinext] Missing __rootLayout key in App Router payload"); } if (rootLayoutTreePath !== null && typeof rootLayoutTreePath !== "string") { - throw new Error("[vinext] Invalid __rootLayout in App Router payload: expected string or null"); + throw new Error( + "[vinext] Invalid __rootLayout in App Router payload: expected string or null", + ); } const layoutFlags = parseLayoutFlags(elements[APP_LAYOUT_FLAGS_KEY]); diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index d698f417d..22890c43a 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -34,12 +34,17 @@ export type LayoutClassificationOptions = { /** 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 }>; + runWithIsolatedDynamicScope: ( + fn: () => T, + ) => Promise<{ result: T; dynamicDetected: boolean }>; }; export type ProbeAppPageLayoutsOptions = { layoutCount: number; - onLayoutError: (error: unknown, layoutIndex: number) => Promise; + onLayoutError: ( + error: unknown, + layoutIndex: number, + ) => Promise; probeLayoutAt: (layoutIndex: number) => unknown; runWithSuppressedHookWarning(probe: () => Promise): Promise; /** When provided, enables per-layout static/dynamic classification. */ @@ -63,10 +68,16 @@ function isPromiseLike(value: unknown): value is PromiseLike { } function getAppPageStatusText(statusCode: number): string { - return statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; + return statusCode === 403 + ? "Forbidden" + : statusCode === 401 + ? "Unauthorized" + : "Not Found"; } -export function resolveAppPageSpecialError(error: unknown): AppPageSpecialError | null { +export function resolveAppPageSpecialError( + error: unknown, +): AppPageSpecialError | null { if (!(error && typeof error === "object" && "digest" in error)) { return null; } @@ -82,10 +93,14 @@ export function resolveAppPageSpecialError(error: unknown): AppPageSpecialError }; } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { + if ( + digest === "NEXT_NOT_FOUND" || + digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;") + ) { return { kind: "http-access-fallback", - statusCode: digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10), + statusCode: + digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10), }; } @@ -104,7 +119,9 @@ export async function buildAppPageSpecialErrorResponse( } if (options.renderFallbackPage) { - const fallbackResponse = await options.renderFallbackPage(options.specialError.statusCode); + const fallbackResponse = await options.renderFallbackPage( + options.specialError.statusCode, + ); if (fallbackResponse) { return fallbackResponse; } @@ -123,13 +140,18 @@ export async function probeAppPageLayouts( const cls = options.classification ?? null; const response = await options.runWithSuppressedHookWarning(async () => { - for (let layoutIndex = options.layoutCount - 1; layoutIndex >= 0; layoutIndex--) { + for ( + let layoutIndex = options.layoutCount - 1; + layoutIndex >= 0; + layoutIndex-- + ) { 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"; + layoutFlags[cls.getLayoutId(layoutIndex)] = + buildTimeResult === "static" ? "s" : "d"; const errorResponse = await probeLayoutForErrors(options, layoutIndex); if (errorResponse) return errorResponse; continue; @@ -139,10 +161,12 @@ export async function probeAppPageLayouts( // 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), + const { dynamicDetected } = await cls.runWithIsolatedDynamicScope( + () => options.probeLayoutAt(layoutIndex), ); - layoutFlags[cls.getLayoutId(layoutIndex)] = dynamicDetected ? "d" : "s"; + layoutFlags[cls.getLayoutId(layoutIndex)] = dynamicDetected + ? "d" + : "s"; } catch (error) { // Probe failed — conservatively treat as dynamic. layoutFlags[cls.getLayoutId(layoutIndex)] = "d"; @@ -199,7 +223,9 @@ export async function probeAppPageComponent( }); } -export async function readAppPageTextStream(stream: ReadableStream): Promise { +export async function readAppPageTextStream( + stream: ReadableStream, +): Promise { const reader = stream.getReader(); const decoder = new TextDecoder(); const chunks: string[] = []; @@ -268,6 +294,9 @@ export function buildAppPageFontLinkHeader( } return preloads - .map((preload) => `<${preload.href}>; rel=preload; as=font; type=${preload.type}; crossorigin`) + .map( + (preload) => + `<${preload.href}>; rel=preload; as=font; type=${preload.type}; crossorigin`, + ) .join(", "); } diff --git a/tests/layout-classification.test.ts b/tests/layout-classification.test.ts index 7d8b9f7f1..d5385bb62 100644 --- a/tests/layout-classification.test.ts +++ b/tests/layout-classification.test.ts @@ -17,7 +17,10 @@ import { * and the value lists its static and dynamic imports. */ function createFakeModuleGraph( - graph: Record, + graph: Record< + string, + { importedIds?: string[]; dynamicImportedIds?: string[] } + >, ): ModuleInfoProvider { return { getModuleInfo(id: string) { @@ -31,7 +34,11 @@ function createFakeModuleGraph( }; } -const DYNAMIC_SHIMS = new Set(["/shims/headers", "/shims/cache", "/shims/server"]); +const DYNAMIC_SHIMS = new Set([ + "/shims/headers", + "/shims/cache", + "/shims/server", +]); // ─── classifyLayoutByModuleGraph ───────────────────────────────────────────── @@ -42,7 +49,9 @@ describe("classifyLayoutByModuleGraph", () => { "/components/nav.tsx": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); + expect( + classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), + ).toBe("static"); }); it('returns "needs-probe" when headers shim is transitively imported', () => { @@ -52,9 +61,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/headers": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( - "needs-probe", - ); + expect( + classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), + ).toBe("needs-probe"); }); it('returns "needs-probe" when cache shim (noStore) is transitively imported', () => { @@ -63,9 +72,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/cache": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( - "needs-probe", - ); + expect( + classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), + ).toBe("needs-probe"); }); it('returns "needs-probe" when server shim (connection) is transitively imported', () => { @@ -75,9 +84,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/server": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( - "needs-probe", - ); + expect( + classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), + ).toBe("needs-probe"); }); it("handles circular imports without infinite loop", () => { @@ -87,7 +96,9 @@ describe("classifyLayoutByModuleGraph", () => { "/b.ts": { importedIds: ["/a.ts"] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); + expect( + classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), + ).toBe("static"); }); it("detects dynamic shim through deep transitive chains", () => { @@ -99,9 +110,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/headers": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( - "needs-probe", - ); + expect( + classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), + ).toBe("needs-probe"); }); it("follows dynamicImportedIds (dynamic import())", () => { @@ -114,15 +125,17 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/headers": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( - "needs-probe", - ); + 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"); + expect( + classifyLayoutByModuleGraph("/unknown/layout.tsx", DYNAMIC_SHIMS, graph), + ).toBe("static"); }); }); @@ -138,7 +151,13 @@ describe("classifyAllRouteLayouts", () => { const routes = [ { - layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0, segmentConfig: { code: 'export const dynamic = "force-static";' } }], + layouts: [ + { + moduleId: "/app/layout.tsx", + treePosition: 0, + segmentConfig: { code: 'export const dynamic = "force-static";' }, + }, + ], routeSegments: ["blog"], }, ]; @@ -182,7 +201,13 @@ describe("classifyAllRouteLayouts", () => { const routes = [ { - layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0, segmentConfig: { code: 'export const dynamic = "force-dynamic";' } }], + layouts: [ + { + moduleId: "/app/layout.tsx", + treePosition: 0, + segmentConfig: { code: 'export const dynamic = "force-dynamic";' }, + }, + ], routeSegments: [], }, ]; @@ -198,7 +223,13 @@ describe("classifyAllRouteLayouts", () => { const routes = [ { - layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0, segmentConfig: { code: "export default function Layout() {}" } }], + layouts: [ + { + moduleId: "/app/layout.tsx", + treePosition: 0, + segmentConfig: { code: "export default function Layout() {}" }, + }, + ], routeSegments: [], }, ]; From 1faf2cd0810074fee16bba52f2bd6ff1d2767497 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 17:07:22 +0000 Subject: [PATCH 09/11] Revert "style: fix prettier formatting" This reverts commit 8e09f70b8039cd43b80839b637523f6b9be5d671. --- .../vinext/src/build/layout-classification.ts | 10 +-- packages/vinext/src/server/app-elements.ts | 30 ++------ .../vinext/src/server/app-page-execution.ts | 57 ++++---------- tests/layout-classification.test.ts | 77 ++++++------------- 4 files changed, 46 insertions(+), 128 deletions(-) diff --git a/packages/vinext/src/build/layout-classification.ts b/packages/vinext/src/build/layout-classification.ts index 920e615d7..e564bb529 100644 --- a/packages/vinext/src/build/layout-classification.ts +++ b/packages/vinext/src/build/layout-classification.ts @@ -96,9 +96,7 @@ export function classifyAllRouteLayouts( // Layer 1: segment config if (layout.segmentConfig) { - const configResult = classifyLayoutSegmentConfig( - layout.segmentConfig.code, - ); + const configResult = classifyLayoutSegmentConfig(layout.segmentConfig.code); if (configResult !== null) { result.set(layoutId, configResult); continue; @@ -108,11 +106,7 @@ export function classifyAllRouteLayouts( // Layer 2: module graph result.set( layoutId, - classifyLayoutByModuleGraph( - layout.moduleId, - dynamicShimPaths, - moduleInfo, - ), + classifyLayoutByModuleGraph(layout.moduleId, dynamicShimPaths, moduleInfo), ); } } diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index bf07bdb3b..b8bc42616 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -26,16 +26,12 @@ export type AppElementsMetadata = { rootLayoutTreePath: string | null; }; -export function normalizeMountedSlotsHeader( - header: string | null | undefined, -): string | null { +export function normalizeMountedSlotsHeader(header: string | null | undefined): string | null { if (!header) { return null; } - const slotIds = Array.from( - new Set(header.split(/\s+/).filter(Boolean)), - ).sort(); + const slotIds = Array.from(new Set(header.split(/\s+/).filter(Boolean))).sort(); return slotIds.length > 0 ? slotIds.join(" ") : null; } @@ -45,10 +41,7 @@ export function getMountedSlotIds(elements: AppElements): string[] { .filter((key) => { const value = elements[key]; return ( - key.startsWith("slot:") && - value !== null && - value !== undefined && - value !== UNMATCHED_SLOT + key.startsWith("slot:") && value !== null && value !== undefined && value !== UNMATCHED_SLOT ); }) .sort(); @@ -58,10 +51,7 @@ export function getMountedSlotIdsHeader(elements: AppElements): string | null { return normalizeMountedSlotsHeader(getMountedSlotIds(elements).join(" ")); } -function appendInterceptionContext( - identity: string, - interceptionContext: string | null, -): string { +function appendInterceptionContext(identity: string, interceptionContext: string | null): string { return interceptionContext === null ? identity : `${identity}${APP_INTERCEPTION_SEPARATOR}${interceptionContext}`; @@ -111,9 +101,7 @@ export function normalizeAppElements(elements: AppWireElements): AppElements { const normalized: Record = {}; for (const [key, value] of Object.entries(elements)) { normalized[key] = - key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE - ? UNMATCHED_SLOT - : value; + key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE ? UNMATCHED_SLOT : value; } return normalized; @@ -151,9 +139,7 @@ export function readAppElementsMetadata( interceptionContext !== null && typeof interceptionContext !== "string" ) { - throw new Error( - "[vinext] Invalid __interceptionContext in App Router payload", - ); + throw new Error("[vinext] Invalid __interceptionContext in App Router payload"); } const rootLayoutTreePath = elements[APP_ROOT_LAYOUT_KEY]; @@ -161,9 +147,7 @@ export function readAppElementsMetadata( throw new Error("[vinext] Missing __rootLayout key in App Router payload"); } if (rootLayoutTreePath !== null && typeof rootLayoutTreePath !== "string") { - throw new Error( - "[vinext] Invalid __rootLayout in App Router payload: expected string or null", - ); + throw new Error("[vinext] Invalid __rootLayout in App Router payload: expected string or null"); } const layoutFlags = parseLayoutFlags(elements[APP_LAYOUT_FLAGS_KEY]); diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index 22890c43a..d698f417d 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -34,17 +34,12 @@ export type LayoutClassificationOptions = { /** 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 }>; + runWithIsolatedDynamicScope: (fn: () => T) => Promise<{ result: T; dynamicDetected: boolean }>; }; export type ProbeAppPageLayoutsOptions = { layoutCount: number; - onLayoutError: ( - error: unknown, - layoutIndex: number, - ) => Promise; + onLayoutError: (error: unknown, layoutIndex: number) => Promise; probeLayoutAt: (layoutIndex: number) => unknown; runWithSuppressedHookWarning(probe: () => Promise): Promise; /** When provided, enables per-layout static/dynamic classification. */ @@ -68,16 +63,10 @@ function isPromiseLike(value: unknown): value is PromiseLike { } function getAppPageStatusText(statusCode: number): string { - return statusCode === 403 - ? "Forbidden" - : statusCode === 401 - ? "Unauthorized" - : "Not Found"; + return statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; } -export function resolveAppPageSpecialError( - error: unknown, -): AppPageSpecialError | null { +export function resolveAppPageSpecialError(error: unknown): AppPageSpecialError | null { if (!(error && typeof error === "object" && "digest" in error)) { return null; } @@ -93,14 +82,10 @@ export function resolveAppPageSpecialError( }; } - if ( - digest === "NEXT_NOT_FOUND" || - digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;") - ) { + if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { return { kind: "http-access-fallback", - statusCode: - digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10), + statusCode: digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10), }; } @@ -119,9 +104,7 @@ export async function buildAppPageSpecialErrorResponse( } if (options.renderFallbackPage) { - const fallbackResponse = await options.renderFallbackPage( - options.specialError.statusCode, - ); + const fallbackResponse = await options.renderFallbackPage(options.specialError.statusCode); if (fallbackResponse) { return fallbackResponse; } @@ -140,18 +123,13 @@ export async function probeAppPageLayouts( const cls = options.classification ?? null; const response = await options.runWithSuppressedHookWarning(async () => { - for ( - let layoutIndex = options.layoutCount - 1; - layoutIndex >= 0; - layoutIndex-- - ) { + for (let layoutIndex = options.layoutCount - 1; layoutIndex >= 0; layoutIndex--) { 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"; + layoutFlags[cls.getLayoutId(layoutIndex)] = buildTimeResult === "static" ? "s" : "d"; const errorResponse = await probeLayoutForErrors(options, layoutIndex); if (errorResponse) return errorResponse; continue; @@ -161,12 +139,10 @@ export async function probeAppPageLayouts( // 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), + const { dynamicDetected } = await cls.runWithIsolatedDynamicScope(() => + options.probeLayoutAt(layoutIndex), ); - layoutFlags[cls.getLayoutId(layoutIndex)] = dynamicDetected - ? "d" - : "s"; + layoutFlags[cls.getLayoutId(layoutIndex)] = dynamicDetected ? "d" : "s"; } catch (error) { // Probe failed — conservatively treat as dynamic. layoutFlags[cls.getLayoutId(layoutIndex)] = "d"; @@ -223,9 +199,7 @@ export async function probeAppPageComponent( }); } -export async function readAppPageTextStream( - stream: ReadableStream, -): Promise { +export async function readAppPageTextStream(stream: ReadableStream): Promise { const reader = stream.getReader(); const decoder = new TextDecoder(); const chunks: string[] = []; @@ -294,9 +268,6 @@ export function buildAppPageFontLinkHeader( } return preloads - .map( - (preload) => - `<${preload.href}>; rel=preload; as=font; type=${preload.type}; crossorigin`, - ) + .map((preload) => `<${preload.href}>; rel=preload; as=font; type=${preload.type}; crossorigin`) .join(", "); } diff --git a/tests/layout-classification.test.ts b/tests/layout-classification.test.ts index d5385bb62..7d8b9f7f1 100644 --- a/tests/layout-classification.test.ts +++ b/tests/layout-classification.test.ts @@ -17,10 +17,7 @@ import { * and the value lists its static and dynamic imports. */ function createFakeModuleGraph( - graph: Record< - string, - { importedIds?: string[]; dynamicImportedIds?: string[] } - >, + graph: Record, ): ModuleInfoProvider { return { getModuleInfo(id: string) { @@ -34,11 +31,7 @@ function createFakeModuleGraph( }; } -const DYNAMIC_SHIMS = new Set([ - "/shims/headers", - "/shims/cache", - "/shims/server", -]); +const DYNAMIC_SHIMS = new Set(["/shims/headers", "/shims/cache", "/shims/server"]); // ─── classifyLayoutByModuleGraph ───────────────────────────────────────────── @@ -49,9 +42,7 @@ describe("classifyLayoutByModuleGraph", () => { "/components/nav.tsx": { importedIds: [] }, }); - expect( - classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), - ).toBe("static"); + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); }); it('returns "needs-probe" when headers shim is transitively imported', () => { @@ -61,9 +52,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/headers": { importedIds: [] }, }); - expect( - classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), - ).toBe("needs-probe"); + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); }); it('returns "needs-probe" when cache shim (noStore) is transitively imported', () => { @@ -72,9 +63,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/cache": { importedIds: [] }, }); - expect( - classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), - ).toBe("needs-probe"); + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); }); it('returns "needs-probe" when server shim (connection) is transitively imported', () => { @@ -84,9 +75,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/server": { importedIds: [] }, }); - expect( - classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), - ).toBe("needs-probe"); + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); }); it("handles circular imports without infinite loop", () => { @@ -96,9 +87,7 @@ describe("classifyLayoutByModuleGraph", () => { "/b.ts": { importedIds: ["/a.ts"] }, }); - expect( - classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), - ).toBe("static"); + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); }); it("detects dynamic shim through deep transitive chains", () => { @@ -110,9 +99,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/headers": { importedIds: [] }, }); - expect( - classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), - ).toBe("needs-probe"); + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); }); it("follows dynamicImportedIds (dynamic import())", () => { @@ -125,17 +114,15 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/headers": { importedIds: [] }, }); - expect( - classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), - ).toBe("needs-probe"); + 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"); + expect(classifyLayoutByModuleGraph("/unknown/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); }); }); @@ -151,13 +138,7 @@ describe("classifyAllRouteLayouts", () => { const routes = [ { - layouts: [ - { - moduleId: "/app/layout.tsx", - treePosition: 0, - segmentConfig: { code: 'export const dynamic = "force-static";' }, - }, - ], + layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0, segmentConfig: { code: 'export const dynamic = "force-static";' } }], routeSegments: ["blog"], }, ]; @@ -201,13 +182,7 @@ describe("classifyAllRouteLayouts", () => { const routes = [ { - layouts: [ - { - moduleId: "/app/layout.tsx", - treePosition: 0, - segmentConfig: { code: 'export const dynamic = "force-dynamic";' }, - }, - ], + layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0, segmentConfig: { code: 'export const dynamic = "force-dynamic";' } }], routeSegments: [], }, ]; @@ -223,13 +198,7 @@ describe("classifyAllRouteLayouts", () => { const routes = [ { - layouts: [ - { - moduleId: "/app/layout.tsx", - treePosition: 0, - segmentConfig: { code: "export default function Layout() {}" }, - }, - ], + layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0, segmentConfig: { code: "export default function Layout() {}" } }], routeSegments: [], }, ]; From 5e7cf128191aa8354d491c6d9fbcaa40b7ca6f01 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 17:07:28 +0000 Subject: [PATCH 10/11] Reapply "style: fix prettier formatting" This reverts commit 1faf2cd0810074fee16bba52f2bd6ff1d2767497. --- .../vinext/src/build/layout-classification.ts | 10 ++- packages/vinext/src/server/app-elements.ts | 30 ++++++-- .../vinext/src/server/app-page-execution.ts | 57 ++++++++++---- tests/layout-classification.test.ts | 77 +++++++++++++------ 4 files changed, 128 insertions(+), 46 deletions(-) diff --git a/packages/vinext/src/build/layout-classification.ts b/packages/vinext/src/build/layout-classification.ts index e564bb529..920e615d7 100644 --- a/packages/vinext/src/build/layout-classification.ts +++ b/packages/vinext/src/build/layout-classification.ts @@ -96,7 +96,9 @@ export function classifyAllRouteLayouts( // Layer 1: segment config if (layout.segmentConfig) { - const configResult = classifyLayoutSegmentConfig(layout.segmentConfig.code); + const configResult = classifyLayoutSegmentConfig( + layout.segmentConfig.code, + ); if (configResult !== null) { result.set(layoutId, configResult); continue; @@ -106,7 +108,11 @@ export function classifyAllRouteLayouts( // Layer 2: module graph result.set( layoutId, - classifyLayoutByModuleGraph(layout.moduleId, dynamicShimPaths, moduleInfo), + classifyLayoutByModuleGraph( + layout.moduleId, + dynamicShimPaths, + moduleInfo, + ), ); } } diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index b8bc42616..bf07bdb3b 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -26,12 +26,16 @@ export type AppElementsMetadata = { rootLayoutTreePath: string | null; }; -export function normalizeMountedSlotsHeader(header: string | null | undefined): string | null { +export function normalizeMountedSlotsHeader( + header: string | null | undefined, +): string | null { if (!header) { return null; } - const slotIds = Array.from(new Set(header.split(/\s+/).filter(Boolean))).sort(); + const slotIds = Array.from( + new Set(header.split(/\s+/).filter(Boolean)), + ).sort(); return slotIds.length > 0 ? slotIds.join(" ") : null; } @@ -41,7 +45,10 @@ export function getMountedSlotIds(elements: AppElements): string[] { .filter((key) => { const value = elements[key]; return ( - key.startsWith("slot:") && value !== null && value !== undefined && value !== UNMATCHED_SLOT + key.startsWith("slot:") && + value !== null && + value !== undefined && + value !== UNMATCHED_SLOT ); }) .sort(); @@ -51,7 +58,10 @@ export function getMountedSlotIdsHeader(elements: AppElements): string | null { return normalizeMountedSlotsHeader(getMountedSlotIds(elements).join(" ")); } -function appendInterceptionContext(identity: string, interceptionContext: string | null): string { +function appendInterceptionContext( + identity: string, + interceptionContext: string | null, +): string { return interceptionContext === null ? identity : `${identity}${APP_INTERCEPTION_SEPARATOR}${interceptionContext}`; @@ -101,7 +111,9 @@ export function normalizeAppElements(elements: AppWireElements): AppElements { const normalized: Record = {}; for (const [key, value] of Object.entries(elements)) { normalized[key] = - key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE ? UNMATCHED_SLOT : value; + key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE + ? UNMATCHED_SLOT + : value; } return normalized; @@ -139,7 +151,9 @@ export function readAppElementsMetadata( interceptionContext !== null && typeof interceptionContext !== "string" ) { - throw new Error("[vinext] Invalid __interceptionContext in App Router payload"); + throw new Error( + "[vinext] Invalid __interceptionContext in App Router payload", + ); } const rootLayoutTreePath = elements[APP_ROOT_LAYOUT_KEY]; @@ -147,7 +161,9 @@ export function readAppElementsMetadata( throw new Error("[vinext] Missing __rootLayout key in App Router payload"); } if (rootLayoutTreePath !== null && typeof rootLayoutTreePath !== "string") { - throw new Error("[vinext] Invalid __rootLayout in App Router payload: expected string or null"); + throw new Error( + "[vinext] Invalid __rootLayout in App Router payload: expected string or null", + ); } const layoutFlags = parseLayoutFlags(elements[APP_LAYOUT_FLAGS_KEY]); diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index d698f417d..22890c43a 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -34,12 +34,17 @@ export type LayoutClassificationOptions = { /** 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 }>; + runWithIsolatedDynamicScope: ( + fn: () => T, + ) => Promise<{ result: T; dynamicDetected: boolean }>; }; export type ProbeAppPageLayoutsOptions = { layoutCount: number; - onLayoutError: (error: unknown, layoutIndex: number) => Promise; + onLayoutError: ( + error: unknown, + layoutIndex: number, + ) => Promise; probeLayoutAt: (layoutIndex: number) => unknown; runWithSuppressedHookWarning(probe: () => Promise): Promise; /** When provided, enables per-layout static/dynamic classification. */ @@ -63,10 +68,16 @@ function isPromiseLike(value: unknown): value is PromiseLike { } function getAppPageStatusText(statusCode: number): string { - return statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; + return statusCode === 403 + ? "Forbidden" + : statusCode === 401 + ? "Unauthorized" + : "Not Found"; } -export function resolveAppPageSpecialError(error: unknown): AppPageSpecialError | null { +export function resolveAppPageSpecialError( + error: unknown, +): AppPageSpecialError | null { if (!(error && typeof error === "object" && "digest" in error)) { return null; } @@ -82,10 +93,14 @@ export function resolveAppPageSpecialError(error: unknown): AppPageSpecialError }; } - if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { + if ( + digest === "NEXT_NOT_FOUND" || + digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;") + ) { return { kind: "http-access-fallback", - statusCode: digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10), + statusCode: + digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10), }; } @@ -104,7 +119,9 @@ export async function buildAppPageSpecialErrorResponse( } if (options.renderFallbackPage) { - const fallbackResponse = await options.renderFallbackPage(options.specialError.statusCode); + const fallbackResponse = await options.renderFallbackPage( + options.specialError.statusCode, + ); if (fallbackResponse) { return fallbackResponse; } @@ -123,13 +140,18 @@ export async function probeAppPageLayouts( const cls = options.classification ?? null; const response = await options.runWithSuppressedHookWarning(async () => { - for (let layoutIndex = options.layoutCount - 1; layoutIndex >= 0; layoutIndex--) { + for ( + let layoutIndex = options.layoutCount - 1; + layoutIndex >= 0; + layoutIndex-- + ) { 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"; + layoutFlags[cls.getLayoutId(layoutIndex)] = + buildTimeResult === "static" ? "s" : "d"; const errorResponse = await probeLayoutForErrors(options, layoutIndex); if (errorResponse) return errorResponse; continue; @@ -139,10 +161,12 @@ export async function probeAppPageLayouts( // 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), + const { dynamicDetected } = await cls.runWithIsolatedDynamicScope( + () => options.probeLayoutAt(layoutIndex), ); - layoutFlags[cls.getLayoutId(layoutIndex)] = dynamicDetected ? "d" : "s"; + layoutFlags[cls.getLayoutId(layoutIndex)] = dynamicDetected + ? "d" + : "s"; } catch (error) { // Probe failed — conservatively treat as dynamic. layoutFlags[cls.getLayoutId(layoutIndex)] = "d"; @@ -199,7 +223,9 @@ export async function probeAppPageComponent( }); } -export async function readAppPageTextStream(stream: ReadableStream): Promise { +export async function readAppPageTextStream( + stream: ReadableStream, +): Promise { const reader = stream.getReader(); const decoder = new TextDecoder(); const chunks: string[] = []; @@ -268,6 +294,9 @@ export function buildAppPageFontLinkHeader( } return preloads - .map((preload) => `<${preload.href}>; rel=preload; as=font; type=${preload.type}; crossorigin`) + .map( + (preload) => + `<${preload.href}>; rel=preload; as=font; type=${preload.type}; crossorigin`, + ) .join(", "); } diff --git a/tests/layout-classification.test.ts b/tests/layout-classification.test.ts index 7d8b9f7f1..d5385bb62 100644 --- a/tests/layout-classification.test.ts +++ b/tests/layout-classification.test.ts @@ -17,7 +17,10 @@ import { * and the value lists its static and dynamic imports. */ function createFakeModuleGraph( - graph: Record, + graph: Record< + string, + { importedIds?: string[]; dynamicImportedIds?: string[] } + >, ): ModuleInfoProvider { return { getModuleInfo(id: string) { @@ -31,7 +34,11 @@ function createFakeModuleGraph( }; } -const DYNAMIC_SHIMS = new Set(["/shims/headers", "/shims/cache", "/shims/server"]); +const DYNAMIC_SHIMS = new Set([ + "/shims/headers", + "/shims/cache", + "/shims/server", +]); // ─── classifyLayoutByModuleGraph ───────────────────────────────────────────── @@ -42,7 +49,9 @@ describe("classifyLayoutByModuleGraph", () => { "/components/nav.tsx": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); + expect( + classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), + ).toBe("static"); }); it('returns "needs-probe" when headers shim is transitively imported', () => { @@ -52,9 +61,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/headers": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( - "needs-probe", - ); + expect( + classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), + ).toBe("needs-probe"); }); it('returns "needs-probe" when cache shim (noStore) is transitively imported', () => { @@ -63,9 +72,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/cache": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( - "needs-probe", - ); + expect( + classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), + ).toBe("needs-probe"); }); it('returns "needs-probe" when server shim (connection) is transitively imported', () => { @@ -75,9 +84,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/server": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( - "needs-probe", - ); + expect( + classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), + ).toBe("needs-probe"); }); it("handles circular imports without infinite loop", () => { @@ -87,7 +96,9 @@ describe("classifyLayoutByModuleGraph", () => { "/b.ts": { importedIds: ["/a.ts"] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); + expect( + classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), + ).toBe("static"); }); it("detects dynamic shim through deep transitive chains", () => { @@ -99,9 +110,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/headers": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( - "needs-probe", - ); + expect( + classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), + ).toBe("needs-probe"); }); it("follows dynamicImportedIds (dynamic import())", () => { @@ -114,15 +125,17 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/headers": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( - "needs-probe", - ); + 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"); + expect( + classifyLayoutByModuleGraph("/unknown/layout.tsx", DYNAMIC_SHIMS, graph), + ).toBe("static"); }); }); @@ -138,7 +151,13 @@ describe("classifyAllRouteLayouts", () => { const routes = [ { - layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0, segmentConfig: { code: 'export const dynamic = "force-static";' } }], + layouts: [ + { + moduleId: "/app/layout.tsx", + treePosition: 0, + segmentConfig: { code: 'export const dynamic = "force-static";' }, + }, + ], routeSegments: ["blog"], }, ]; @@ -182,7 +201,13 @@ describe("classifyAllRouteLayouts", () => { const routes = [ { - layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0, segmentConfig: { code: 'export const dynamic = "force-dynamic";' } }], + layouts: [ + { + moduleId: "/app/layout.tsx", + treePosition: 0, + segmentConfig: { code: 'export const dynamic = "force-dynamic";' }, + }, + ], routeSegments: [], }, ]; @@ -198,7 +223,13 @@ describe("classifyAllRouteLayouts", () => { const routes = [ { - layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0, segmentConfig: { code: "export default function Layout() {}" } }], + layouts: [ + { + moduleId: "/app/layout.tsx", + treePosition: 0, + segmentConfig: { code: "export default function Layout() {}" }, + }, + ], routeSegments: [], }, ]; From 0a192d6e5f43b4046b122dfa3370f2a837c3ee8e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Apr 2026 17:09:19 +0000 Subject: [PATCH 11/11] style: apply oxfmt formatting https://claude.ai/code/session_01MDJzCNao3dX9tmmVtSqsw5 --- .../vinext/src/build/layout-classification.ts | 10 +--- packages/vinext/src/server/app-elements.ts | 30 +++------- .../vinext/src/server/app-page-execution.ts | 57 +++++-------------- tests/layout-classification.test.ts | 53 +++++++---------- 4 files changed, 43 insertions(+), 107 deletions(-) diff --git a/packages/vinext/src/build/layout-classification.ts b/packages/vinext/src/build/layout-classification.ts index 920e615d7..e564bb529 100644 --- a/packages/vinext/src/build/layout-classification.ts +++ b/packages/vinext/src/build/layout-classification.ts @@ -96,9 +96,7 @@ export function classifyAllRouteLayouts( // Layer 1: segment config if (layout.segmentConfig) { - const configResult = classifyLayoutSegmentConfig( - layout.segmentConfig.code, - ); + const configResult = classifyLayoutSegmentConfig(layout.segmentConfig.code); if (configResult !== null) { result.set(layoutId, configResult); continue; @@ -108,11 +106,7 @@ export function classifyAllRouteLayouts( // Layer 2: module graph result.set( layoutId, - classifyLayoutByModuleGraph( - layout.moduleId, - dynamicShimPaths, - moduleInfo, - ), + classifyLayoutByModuleGraph(layout.moduleId, dynamicShimPaths, moduleInfo), ); } } diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index bf07bdb3b..b8bc42616 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -26,16 +26,12 @@ export type AppElementsMetadata = { rootLayoutTreePath: string | null; }; -export function normalizeMountedSlotsHeader( - header: string | null | undefined, -): string | null { +export function normalizeMountedSlotsHeader(header: string | null | undefined): string | null { if (!header) { return null; } - const slotIds = Array.from( - new Set(header.split(/\s+/).filter(Boolean)), - ).sort(); + const slotIds = Array.from(new Set(header.split(/\s+/).filter(Boolean))).sort(); return slotIds.length > 0 ? slotIds.join(" ") : null; } @@ -45,10 +41,7 @@ export function getMountedSlotIds(elements: AppElements): string[] { .filter((key) => { const value = elements[key]; return ( - key.startsWith("slot:") && - value !== null && - value !== undefined && - value !== UNMATCHED_SLOT + key.startsWith("slot:") && value !== null && value !== undefined && value !== UNMATCHED_SLOT ); }) .sort(); @@ -58,10 +51,7 @@ export function getMountedSlotIdsHeader(elements: AppElements): string | null { return normalizeMountedSlotsHeader(getMountedSlotIds(elements).join(" ")); } -function appendInterceptionContext( - identity: string, - interceptionContext: string | null, -): string { +function appendInterceptionContext(identity: string, interceptionContext: string | null): string { return interceptionContext === null ? identity : `${identity}${APP_INTERCEPTION_SEPARATOR}${interceptionContext}`; @@ -111,9 +101,7 @@ export function normalizeAppElements(elements: AppWireElements): AppElements { const normalized: Record = {}; for (const [key, value] of Object.entries(elements)) { normalized[key] = - key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE - ? UNMATCHED_SLOT - : value; + key.startsWith("slot:") && value === APP_UNMATCHED_SLOT_WIRE_VALUE ? UNMATCHED_SLOT : value; } return normalized; @@ -151,9 +139,7 @@ export function readAppElementsMetadata( interceptionContext !== null && typeof interceptionContext !== "string" ) { - throw new Error( - "[vinext] Invalid __interceptionContext in App Router payload", - ); + throw new Error("[vinext] Invalid __interceptionContext in App Router payload"); } const rootLayoutTreePath = elements[APP_ROOT_LAYOUT_KEY]; @@ -161,9 +147,7 @@ export function readAppElementsMetadata( throw new Error("[vinext] Missing __rootLayout key in App Router payload"); } if (rootLayoutTreePath !== null && typeof rootLayoutTreePath !== "string") { - throw new Error( - "[vinext] Invalid __rootLayout in App Router payload: expected string or null", - ); + throw new Error("[vinext] Invalid __rootLayout in App Router payload: expected string or null"); } const layoutFlags = parseLayoutFlags(elements[APP_LAYOUT_FLAGS_KEY]); diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index 22890c43a..d698f417d 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -34,17 +34,12 @@ export type LayoutClassificationOptions = { /** 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 }>; + runWithIsolatedDynamicScope: (fn: () => T) => Promise<{ result: T; dynamicDetected: boolean }>; }; export type ProbeAppPageLayoutsOptions = { layoutCount: number; - onLayoutError: ( - error: unknown, - layoutIndex: number, - ) => Promise; + onLayoutError: (error: unknown, layoutIndex: number) => Promise; probeLayoutAt: (layoutIndex: number) => unknown; runWithSuppressedHookWarning(probe: () => Promise): Promise; /** When provided, enables per-layout static/dynamic classification. */ @@ -68,16 +63,10 @@ function isPromiseLike(value: unknown): value is PromiseLike { } function getAppPageStatusText(statusCode: number): string { - return statusCode === 403 - ? "Forbidden" - : statusCode === 401 - ? "Unauthorized" - : "Not Found"; + return statusCode === 403 ? "Forbidden" : statusCode === 401 ? "Unauthorized" : "Not Found"; } -export function resolveAppPageSpecialError( - error: unknown, -): AppPageSpecialError | null { +export function resolveAppPageSpecialError(error: unknown): AppPageSpecialError | null { if (!(error && typeof error === "object" && "digest" in error)) { return null; } @@ -93,14 +82,10 @@ export function resolveAppPageSpecialError( }; } - if ( - digest === "NEXT_NOT_FOUND" || - digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;") - ) { + if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) { return { kind: "http-access-fallback", - statusCode: - digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10), + statusCode: digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10), }; } @@ -119,9 +104,7 @@ export async function buildAppPageSpecialErrorResponse( } if (options.renderFallbackPage) { - const fallbackResponse = await options.renderFallbackPage( - options.specialError.statusCode, - ); + const fallbackResponse = await options.renderFallbackPage(options.specialError.statusCode); if (fallbackResponse) { return fallbackResponse; } @@ -140,18 +123,13 @@ export async function probeAppPageLayouts( const cls = options.classification ?? null; const response = await options.runWithSuppressedHookWarning(async () => { - for ( - let layoutIndex = options.layoutCount - 1; - layoutIndex >= 0; - layoutIndex-- - ) { + for (let layoutIndex = options.layoutCount - 1; layoutIndex >= 0; layoutIndex--) { 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"; + layoutFlags[cls.getLayoutId(layoutIndex)] = buildTimeResult === "static" ? "s" : "d"; const errorResponse = await probeLayoutForErrors(options, layoutIndex); if (errorResponse) return errorResponse; continue; @@ -161,12 +139,10 @@ export async function probeAppPageLayouts( // 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), + const { dynamicDetected } = await cls.runWithIsolatedDynamicScope(() => + options.probeLayoutAt(layoutIndex), ); - layoutFlags[cls.getLayoutId(layoutIndex)] = dynamicDetected - ? "d" - : "s"; + layoutFlags[cls.getLayoutId(layoutIndex)] = dynamicDetected ? "d" : "s"; } catch (error) { // Probe failed — conservatively treat as dynamic. layoutFlags[cls.getLayoutId(layoutIndex)] = "d"; @@ -223,9 +199,7 @@ export async function probeAppPageComponent( }); } -export async function readAppPageTextStream( - stream: ReadableStream, -): Promise { +export async function readAppPageTextStream(stream: ReadableStream): Promise { const reader = stream.getReader(); const decoder = new TextDecoder(); const chunks: string[] = []; @@ -294,9 +268,6 @@ export function buildAppPageFontLinkHeader( } return preloads - .map( - (preload) => - `<${preload.href}>; rel=preload; as=font; type=${preload.type}; crossorigin`, - ) + .map((preload) => `<${preload.href}>; rel=preload; as=font; type=${preload.type}; crossorigin`) .join(", "); } diff --git a/tests/layout-classification.test.ts b/tests/layout-classification.test.ts index d5385bb62..96a6cd7e7 100644 --- a/tests/layout-classification.test.ts +++ b/tests/layout-classification.test.ts @@ -17,10 +17,7 @@ import { * and the value lists its static and dynamic imports. */ function createFakeModuleGraph( - graph: Record< - string, - { importedIds?: string[]; dynamicImportedIds?: string[] } - >, + graph: Record, ): ModuleInfoProvider { return { getModuleInfo(id: string) { @@ -34,11 +31,7 @@ function createFakeModuleGraph( }; } -const DYNAMIC_SHIMS = new Set([ - "/shims/headers", - "/shims/cache", - "/shims/server", -]); +const DYNAMIC_SHIMS = new Set(["/shims/headers", "/shims/cache", "/shims/server"]); // ─── classifyLayoutByModuleGraph ───────────────────────────────────────────── @@ -49,9 +42,7 @@ describe("classifyLayoutByModuleGraph", () => { "/components/nav.tsx": { importedIds: [] }, }); - expect( - classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), - ).toBe("static"); + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); }); it('returns "needs-probe" when headers shim is transitively imported', () => { @@ -61,9 +52,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/headers": { importedIds: [] }, }); - expect( - classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), - ).toBe("needs-probe"); + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); }); it('returns "needs-probe" when cache shim (noStore) is transitively imported', () => { @@ -72,9 +63,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/cache": { importedIds: [] }, }); - expect( - classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), - ).toBe("needs-probe"); + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); }); it('returns "needs-probe" when server shim (connection) is transitively imported', () => { @@ -84,9 +75,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/server": { importedIds: [] }, }); - expect( - classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), - ).toBe("needs-probe"); + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); }); it("handles circular imports without infinite loop", () => { @@ -96,9 +87,7 @@ describe("classifyLayoutByModuleGraph", () => { "/b.ts": { importedIds: ["/a.ts"] }, }); - expect( - classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), - ).toBe("static"); + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); }); it("detects dynamic shim through deep transitive chains", () => { @@ -110,9 +99,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/headers": { importedIds: [] }, }); - expect( - classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), - ).toBe("needs-probe"); + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + "needs-probe", + ); }); it("follows dynamicImportedIds (dynamic import())", () => { @@ -125,17 +114,15 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/headers": { importedIds: [] }, }); - expect( - classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph), - ).toBe("needs-probe"); + 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"); + expect(classifyLayoutByModuleGraph("/unknown/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); }); });