Skip to content

feat: static/dynamic layout detection for skip-header optimization#767

Merged
james-elicx merged 11 commits intocloudflare:mainfrom
NathanDrake2406:feat/layout-persistence-pr-6-static-dynamic
Apr 13, 2026
Merged

feat: static/dynamic layout detection for skip-header optimization#767
james-elicx merged 11 commits intocloudflare:mainfrom
NathanDrake2406:feat/layout-persistence-pr-6-static-dynamic

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Apr 3, 2026

Summary

Part of #726 (Phase 3, PR 1). Adds a three-layer classification system that determines whether each layout in an App Router route tree is static or dynamic. This metadata is the foundation for PR 7's skip-header optimization, which avoids re-rendering static layouts on RSC navigations. Wiring is done in PR 768.

Stacked on #755 (PR 5). Review only the last 6 commits.

Three-layer classification

Segment config: force-dynamic?           → DYNAMIC (final)
Segment config: force-static?            → STATIC (final)
Module graph: no dynamic shim imports?   → STATIC (proven, skip probe)
Module graph: has dynamic shim imports?  → run probe at request time
Probe: dynamicUsageDetected = true?      → DYNAMIC (observed)
Probe: dynamicUsageDetected = false?     → STATIC (observed)

Layer 1 — Segment config (classifyLayoutSegmentConfig in report.ts). Reads export const dynamic / export const revalidate from layout source files. Unlike page classification, positive revalidate values return null (ISR is a page concept) — only the extremes (0 → dynamic, Infinity → static) are decisive.

Layer 2 — Module graph (classifyLayoutByModuleGraph in layout-classification.ts). BFS traversal of each layout's dependency tree via a ModuleInfoProvider abstraction over Vite's this.getModuleInfo. If no transitive dynamic shim import (headers, cache, server) is found, the layout is provably static without any runtime cost.

Layer 3 — Runtime probe (extended probeAppPageLayouts in app-page-execution.ts). For layouts that import dynamic shims but may not call them on every request, the probe runs the layout function with isolated dynamic scope tracking. The runWithIsolatedDynamicScope callback is injected from the RSC entry, keeping the execution module decoupled from ALS internals.

Wire format

__layoutFlags metadata key in the RSC payload: Record<string, "s" | "d"> mapping layout IDs to compact flags. Follows the established pattern of __route, __rootLayout, __interceptionContext.

On client-side navigation, the router merges new flags with existing ones (navigate) or replaces them entirely (replace), building up cumulative knowledge of which layouts are safe to skip.

Type design

LayoutClassificationOptions groups the three classification fields (getLayoutId, runWithIsolatedDynamicScope, buildTimeClassifications) into a single optional object. This makes the all-or-nothing invariant type-safe — you either provide the full classification context or nothing.

LayoutFlags is canonically defined in app-elements.ts and re-exported from app-page-execution.ts.

readAppElementsMetadata accepts Record<string, unknown> since the RSC payload carries heterogeneous values. parseLayoutFlags validates at the wire boundary using a type predicate guard.

New files

File Purpose
packages/vinext/src/build/layout-classification.ts Module graph traversal + combined classification
tests/layout-classification.test.ts 13 tests for module graph + combined classification

Test plan

  • tests/build-report.test.ts — 7 tests for classifyLayoutSegmentConfig
  • tests/layout-classification.test.ts — 13 tests for module graph traversal + combined classification
  • tests/app-page-execution.test.ts — 5 tests for per-layout probe classification (dynamic detection, build-time skip, backward compat, error default)
  • tests/app-page-probe.test.ts — 2 tests for layout flags propagation + special error handling with classification
  • tests/app-elements.test.ts — 2 tests for __layoutFlags metadata parsing + backward compat
  • tests/app-browser-entry.test.ts — 2 tests for layoutFlags merge (navigate) and replace semantics
  • tests/app-router.test.ts — 276 integration tests pass with zero regressions
  • vp check — format, lint, type checks all clean
vp test run tests/build-report.test.ts -t "classifyLayoutSegmentConfig"
vp test run tests/layout-classification.test.ts
vp test run tests/app-page-execution.test.ts tests/app-page-probe.test.ts
vp test run tests/app-elements.test.ts tests/app-browser-entry.test.ts
vp test run tests/app-router.test.ts
vp check

Refs #726
Closes #756

…tion

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.
@NathanDrake2406 NathanDrake2406 force-pushed the feat/layout-persistence-pr-6-static-dynamic branch from a212cf5 to 8bd1d01 Compare April 13, 2026 06:35
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 13, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@767

commit: 0a192d6

@NathanDrake2406 NathanDrake2406 force-pushed the feat/layout-persistence-pr-6-static-dynamic branch from 8bd1d01 to 41dd434 Compare April 13, 2026 07:05
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review April 13, 2026 07:15
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Static/dynamic layout detection for skip-header optimization

Clean, well-structured PR. The three-layer classification design is sound, the test coverage is thorough (31 new tests across 7 files), and backward compatibility is properly maintained. A few observations below, none blocking.

Architecture

The separation of concerns is good: Layer 1 (segment config) and Layer 2 (module graph) are build-time and live in build/, while Layer 3 (runtime probe) stays in server/. The LayoutClassificationOptions grouping enforces the all-or-nothing invariant cleanly.

The type gap between classifyAllRouteLayouts returning "needs-probe" and buildTimeClassifications accepting only "static" | "dynamic" is intentional and correct — the wiring layer in PR 768 will filter "needs-probe" entries. Worth noting for anyone reading this in isolation.

Wire format

The "s" / "d" compact flags are fine for a payload that goes over the wire on every RSC navigation. parseLayoutFlags correctly validates at the boundary and defaults gracefully.

Minor items below

const queue: string[] = [layoutModuleId];

while (queue.length > 0) {
const currentId = queue.shift()!;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: queue.shift() is O(n) on JS arrays. For typical layout dependency graphs this won't matter, but if you ever want to make this more robust against pathological module graphs (e.g., barrel files that re-export hundreds of modules), you could swap to a pointer-based approach:

Suggested change
const currentId = queue.shift()!;
let head = 0;

…and then const currentId = queue[head++] instead of queue.shift(). Not blocking — just noting since this runs once per layout at build time and the visited set bounds the work.

return response;
const buildTimeResult = cls?.buildTimeClassifications?.get(layoutIndex);

if (cls && buildTimeResult) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subtle: cls && buildTimeResult means buildTimeResult === undefined falls through to the Layer 3 probe (correct), but it also means a layout that was explicitly not in the buildTimeClassifications map falls through to isolated-scope probing. This is the right behavior, but the condition reads as "if cls is non-null AND buildTimeResult is truthy" — since the classification values are "static" | "dynamic" (both truthy strings), this works. Worth a brief comment that undefined (not in map) intentionally falls through.

Also: if buildTimeClassifications is an empty Map or null, every layout falls through to the Layer 3 cls branch, which is correct and tested.

if (cls) {
// Layer 3: probe with isolated dynamic scope to detect per-layout
// dynamic API usage (headers(), cookies(), connection(), etc.)
try {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the Layer 3 catch path (line 146), the error is passed to options.onLayoutError which resolves special errors (redirect/not-found). But the error originates from inside runWithIsolatedDynamicScope, which wraps the probe. If runWithIsolatedDynamicScope itself throws (implementation bug, not the layout function), the error still gets routed through onLayoutError and the layout is flagged dynamic. That seems like the right conservative behavior, but it does mean implementation bugs in the scope-isolation machinery would be silently swallowed as "dynamic layout" rather than surfacing as a crash. Probably fine for production, but worth a debug log in the future.

}

export function readAppElementsMetadata(elements: AppElements): AppElementsMetadata {
function isLayoutFlagsRecord(value: unknown): value is LayoutFlags {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: isLayoutFlagsRecord returns true for {} (empty object), which is intentional — an empty flags record is valid. Good.

One edge case: if the RSC payload somehow contains a __layoutFlags value that is a non-plain object (e.g., a Date, RegExp, or Map), the typeof value !== "object" check passes, and Object.values() would enumerate own properties. In practice this can't happen since the RSC wire format only produces plain objects, but the guard is correct regardless.

return {
elements: mergeElements(state.elements, action.elements, action.type === "traverse"),
interceptionContext: action.interceptionContext,
layoutFlags: { ...state.layoutFlags, ...action.layoutFlags },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The merge semantics are clean: navigate/traverse spread { ...state.layoutFlags, ...action.layoutFlags } (cumulative), replace uses only action.layoutFlags. This means layout flags accumulate across soft navigations, which is what you want for skip-header — once a layout is known static, that knowledge persists until a hard navigation.

One thing to consider for the wiring PR: a layout that was "s" on one route might be "d" on another (e.g., a shared root layout that conditionally calls headers() based on a child import). The merge here would update "s""d", which is correct (latest probe wins). But it would never go back from "d" to "s" on a subsequent navigate to the original route, unless the server re-sends "s". As long as the server always sends flags for all layouts in the current tree, this is fine.

options: RenderAppPageLifecycleOptions,
): Promise<Response> {
const preRenderResponse = await probeAppPageBeforeRender({
const preRenderResult = await probeAppPageBeforeRender({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good that classification is not passed here yet — the backward-compatible path (no classification, empty layoutFlags) runs until PR 768 wires it in. The preRenderResult.layoutFlags is computed but unused, which is expected for this intermediate PR.

layoutCount: 2,
onLayoutError() {
return Promise.resolve(null);
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice test for the error → conservative dynamic fallback. The runWithIsolatedDynamicScope here does Promise.resolve(fn()) which means if fn() throws synchronously (as probeLayoutAt does for layoutIndex === 1), the Promise.resolve() catches it and creates a rejected promise that propagates to the outer try/catch. This correctly exercises the catch path in probeAppPageLayouts.

One scenario not explicitly tested: what happens when the layout probe for a build-time classified layout (buildTimeResult is set) throws a special error? The probeLayoutForErrors path handles it, but there's no test with both buildTimeClassifications and a throwing probeLayoutAt for a classified layout. The "still handles special errors with classification enabled" test in app-page-probe.test.ts covers this at the probeAppPageBeforeRender level (no buildTimeClassifications in that test, so it goes through Layer 3), but not the build-time + error combo. Minor gap.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 13, 2026

Review submitted successfully. Here's a summary of what I covered:

Overall assessment: Clean, well-structured PR with sound three-layer classification design and thorough test coverage (31 new tests across 7 files). No blocking issues.

Key observations posted:

  1. BFS performance (layout-classification.ts:46) — queue.shift() is O(n); noted a pointer-based alternative for robustness, though not blocking at build-time scale.

  2. Truthiness guard (app-page-execution.ts:129) — The cls && buildTimeResult condition works correctly because classification values are always truthy strings, but the undefined fallthrough to Layer 3 could use a brief comment.

  3. Error swallowing in Layer 3 (app-page-execution.ts:141) — Implementation bugs in runWithIsolatedDynamicScope get silently classified as "dynamic" rather than surfacing. Correct conservative behavior for production, but worth a debug log in the future.

  4. Wire validation (app-elements.ts:109) — isLayoutFlagsRecord correctly handles empty objects and edge cases.

  5. Merge semantics (app-browser-state.ts:100) — Navigate/traverse accumulate flags, replace resets. Noted that the server must always re-send flags for all layouts to allow "d""s" transitions.

  6. Unused layoutFlags in render (app-page-render.ts:121) — Correctly deferred to PR 768 for wiring.

  7. Minor test gap (app-page-execution.test.ts:235) — No test for a build-time classified layout that also throws a special error during probeLayoutForErrors.

github run

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.
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.
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.
…onOptions type

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.
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.
@NathanDrake2406 NathanDrake2406 force-pushed the feat/layout-persistence-pr-6-static-dynamic branch from 41dd434 to 4392f14 Compare April 13, 2026 07:26
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Thanks for the thorough pass. Walking through each item:

Addressed

#1 (layout-classification.ts:46, BFS shift()): done in 1b85117. Swapped to a head pointer (let head = 0; queue[head++]). The 13 existing tests in tests/layout-classification.test.ts still pass.

#7 (tests/app-page-execution.test.ts, build-time + special-error gap): done in 8604239. New test "returns special error response when build-time classified layout throws during error probe". Two build-time-static layouts, probeLayoutAt(1) throws, onLayoutError resolves to a 404, and we assert: the special-error response propagates, the build-time flag for layout 1 is recorded before the short-circuit, and layout 0 is never reached. The mock also installs a runWithIsolatedDynamicScope that throws if called, which proves the build-time branch never falls through to Layer 3.

Skipped intentionally

#2 (app-page-execution.ts:129, truthiness comment): the existing comment on the next line already explains the intent ("Build-time classified... skip dynamic isolation, but still probe for special errors"). The fall-through reads naturally from the type. A second comment about how `undefined` happens to evaluate falsy felt like commenting on commenting.

#3 (app-page-execution.ts:141, debug log for swallowed probe errors): conservative dynamic fallback on probe failure is the intended production behavior. Adding a debug log without a known failure mode would be defensive observability with no actionable signal. Happy to revisit if we see a real pattern of probe-failure noise in the wild.

Acknowledged, no action needed

#4 (app-elements.ts:109, isLayoutFlagsRecord edge cases): ack. The wire format only emits plain objects, but the guard handles the theoretical Date/RegExp/Map case correctly anyway.

#5 (app-browser-state.ts:100, `d → s` requires server resend): the invariant you named already holds. `probeAppPageLayouts` in `packages/vinext/src/server/app-page-execution.ts` iterates `layoutCount - 1 → 0` and writes `layoutFlags[cls.getLayoutId(layoutIndex)]` for every layout in the current tree on every request. The server always re-emits flags for the entire current tree, so a `"d" → "s"` flip is reflected on the next navigation that includes the layout.

#6 (app-page-render.ts:121, unused preRenderResult.layoutFlags): ack. Consumption lands in PR #768 (`pr-7-skip-header`), the next branch in this stack.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Static/dynamic layout detection

Clean, well-designed PR. The three-layer classification is well-separated (build-time in build/, runtime in server/), the backward compatibility path is solid, and the test coverage is thorough. The previous review's feedback has been addressed. A few minor observations below — none blocking.

What looks good

  • Type design: LayoutClassificationOptions as a single optional object enforces the all-or-nothing invariant cleanly. The gap between classifyAllRouteLayouts returning "needs-probe" and buildTimeClassifications accepting only "static" | "dynamic" is correctly intentional — PR 768 will filter.
  • Backward compat: The classification?: ... | null optional field means the entire probe path is a no-op addition when classification isn't wired. The parseLayoutFlags{} default handles missing wire data gracefully.
  • Wire validation: isLayoutFlagsRecord is strict enough for the wire format while handling edge cases (empty objects, non-plain objects).
  • Merge vs replace semantics: Navigate/traverse accumulate flags, replace resets. This is the correct behavior for the skip-header optimization.
  • BFS pointer fix: The head pointer approach from the previous review is clean.

Minor observations (all non-blocking)


// Layer 2: module graph
const graphResult = classifyLayoutByModuleGraph(
route.layouts[i],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: route.layouts[i] is typed as string[], so the index access returns string | undefined under noUncheckedIndexedAccess. The loop bounds (i < route.layouts.length) guarantee it's in range, but the non-null assertion is missing (unlike line 86 where layoutTreePositions[i] uses ?? 0). This is fine since the project likely doesn't enable that tsconfig flag, but worth noting for consistency.

Not blocking — just flagging the implicit assumption that layouts and layoutTreePositions arrays are always the same length. The type doesn't enforce this invariant.

// Layer 3: probe with isolated dynamic scope to detect per-layout
// dynamic API usage (headers(), cookies(), connection(), etc.)
try {
const { dynamicDetected } = await cls.runWithIsolatedDynamicScope(() =>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Layer 3 catch path (line 146) conflates "the layout function threw" with "runWithIsolatedDynamicScope itself threw". Both get flagged as dynamic and routed through onLayoutError. This is the right conservative behavior, but it means an implementation bug in the scope-isolation machinery would be silently classified as dynamic rather than surfacing. The author addressed this in the previous review (no actionable signal without a known failure mode) — just reaffirming this is a reasonable tradeoff for now.

options: RenderAppPageLifecycleOptions,
): Promise<Response> {
const preRenderResponse = await probeAppPageBeforeRender({
const preRenderResult = await probeAppPageBeforeRender({
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good: preRenderResult.layoutFlags is computed but not passed to probeAppPageBeforeRender's classification option, so this runs the backward-compatible path (no classification, empty layoutFlags). The wiring lands in PR 768. No issue here — just confirming the intermediate state is correct.

return {
elements: mergeElements(state.elements, action.elements, action.type === "traverse"),
interceptionContext: action.interceptionContext,
layoutFlags: { ...state.layoutFlags, ...action.layoutFlags },
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor thought: the spread merge { ...state.layoutFlags, ...action.layoutFlags } means stale flags for layouts no longer in the current tree persist indefinitely (until a replace/hard navigation). This is harmless for the skip-header optimization (the consumer in PR 768 will only read flags for layouts actually in the current tree), but the state object grows monotonically across soft navigations. Not a practical concern for typical apps, but worth keeping in mind if layout flags ever carry more data.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk bot commented Apr 13, 2026

Review submitted with approval. Here's a summary of what I covered:

Verdict: Approve — Clean, well-designed PR with no blocking issues.

Key observations posted as inline comments:

  1. layout-classification.ts:103route.layouts[i] lacks a non-null assertion, unlike the adjacent layoutTreePositions[i] ?? 0 pattern. Not blocking since the loop bounds guarantee the index is valid, but noted the implicit assumption that layouts and layoutTreePositions arrays are always the same length.

  2. app-page-execution.ts:142 — Reaffirmed the Layer 3 catch-path tradeoff: implementation bugs in runWithIsolatedDynamicScope get silently classified as dynamic rather than surfacing. Reasonable for now per the author's earlier response.

  3. app-page-render.ts:121 — Confirmed the intermediate state is correct: preRenderResult.layoutFlags is computed but not wired to classification yet (deferred to PR 768).

  4. app-browser-state.ts:100 — Noted that the spread merge causes layout flags to grow monotonically across soft navigations (stale flags for layouts no longer in the tree persist). Harmless for skip-header since the consumer only reads flags for layouts in the current tree, but worth keeping in mind.

github run

claude added 5 commits April 13, 2026 16:56
- 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
Comment on lines +147 to +148
// Probe failed — conservatively treat as dynamic.
layoutFlags[cls.getLayoutId(layoutIndex)] = "d";
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i wonder if it's worth having a separate flag for failures that we interpret as dynamic but still track as being errored

@james-elicx
Copy link
Copy Markdown
Collaborator

sorry about the delay

@james-elicx james-elicx merged commit 1823795 into cloudflare:main Apr 13, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Static/dynamic detection

3 participants