feat: static/dynamic layout detection for skip-header optimization#767
Draft
NathanDrake2406 wants to merge 31 commits intocloudflare:mainfrom
Draft
feat: static/dynamic layout detection for skip-header optimization#767NathanDrake2406 wants to merge 31 commits intocloudflare:mainfrom
NathanDrake2406 wants to merge 31 commits intocloudflare:mainfrom
Conversation
- Fix stale closure on readBrowserRouterState by using a useRef updated synchronously during render instead of a closure captured in useLayoutEffect. External callers (navigate, server actions, HMR) now always read the current router state. - Restore GlobalErrorBoundary wrapping that was dropped when switching from buildPageElement to buildAppPageElements. Apps with app/global-error.tsx now get their global error boundary back. - Add exhaustive default case to routerReducer so new action types produce a compile error and a runtime throw instead of silent undefined. - Remove dead code: createRouteNodeSnapshot, AppRouteNodeSnapshot, AppRouteNodeValue were defined but never imported. - Remove deprecated buildAppPageRouteElement and its test — no production callers remain after the flat payload cutover. - Short-circuit normalizeAppElements when no slot keys need rewriting to avoid unnecessary allocation on every payload. - Align test data in error boundary RSC payload test (matchedParams slug: "post" -> "missing" to match requestUrl /posts/missing).
createFromReadableStream() returns a React thenable whose .then() returns undefined (not a Promise). Chaining .then(normalizeAppElements) broke SSR by assigning undefined to flightRoot. Fix: call use() on the raw thenable, then normalize synchronously after resolution. Also widen renderAppPageLifecycle element type to accept flat map payloads.
The SSR entry always expects a flat Record<string, ReactNode> with __route and __rootLayout metadata from the RSC stream. Three paths were still producing bare ReactNode payloads: 1. renderAppPageBoundaryElementResponse only created the flat map for isRscRequest=true, but HTML requests also flow through RSC→SSR 2. buildPageElements "no default export" early return 3. Server action "Page not found" fallback All three now produce the flat keyed element map, fixing 17 test failures across 404/not-found, forbidden/unauthorized, error boundary, production build, rewrite, and encoded-slash paths.
- Update renderElementToStream mock to extract the route element from the flat map before rendering to HTML (mirrors real SSR entry flow) - Update entry template snapshots for the buildPageElements changes
createFromReadableStream() returns a React Flight thenable whose .then() returns undefined instead of a new Promise. The browser entry's normalizeAppElementsPromise chained .then() on this raw thenable, producing undefined — which crashed use() during hydration with "An unsupported type was passed to use(): undefined". Wrapping in Promise.resolve() first converts the Flight thenable into a real Promise, making .then() chains work correctly. The same fix was already applied to the SSR entry in 5395efc but was missed in the browser entry.
React 19.2.4's use(Promise) during hydration triggers "async Client Component" because native Promises lack React's internal .status property (set only by Flight thenables). When use() encounters a Promise without .status, it suspends — which React interprets as the component being async, causing a fatal error. Fix: store resolved AppElements directly in ElementsContext and router state instead of Promise<AppElements>. The navigation async flow (createPendingNavigationCommit) awaits the Promise before dispatching, so React state never holds a Promise. - ElementsContext: Promise<AppElements> → AppElements - AppRouterState.elements: Promise<AppElements> → AppElements - mergeElementsPromise → mergeElements (sync object spread) - Slot: useContext only, no use(Promise) - SSR entry: pass resolved elements to context - dispatchBrowserTree: simplified, no async error handler Also fix flaky instrumentation E2E test that read the last error entry instead of finding by path.
- Remove Promise wrappers from ElementsContext test values - mergeElementsPromise → mergeElements (sync) - Replace Suspense streaming test with direct render test - Remove unused createDeferred helper and Suspense import - Update browser state test assertions (no longer async)
P1a: mergeElements preserves previous slot content when the new payload marks a parallel slot as unmatched. On soft navigation, unmatched slots keep their previous subtree instead of triggering notFound(). P1b: renderNavigationPayload now receives navId and checks for superseded navigations after its await. Stale payloads are discarded instead of being dispatched into the React tree. P2: The catch block in renderNavigationPayload only calls commitClientNavigationState() when activateNavigationSnapshot() was actually reached, preventing counter underflow. P3: The no-default-export fallback in buildPageElements now derives the root layout tree path from route.layoutTreePositions and route.routeSegments instead of hardcoding "/".
Prove the flat keyed map architecture works end-to-end: - Layout state persists across sibling navigation (counter survives) - Template remounts on segment boundary change, persists within segment - Error boundary clears on navigate-away-and-back - Back/forward preserves layout state through history - Parallel slots persist on soft nav, show default.tsx on hard nav Zero production code changes — test fixtures and Playwright specs only.
…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.
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.
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Three-layer classification
Layer 1 — Segment config (
classifyLayoutSegmentConfiginreport.ts). Readsexport const dynamic/export const revalidatefrom layout source files. Unlike page classification, positiverevalidatevalues return null (ISR is a page concept) — only the extremes (0→ dynamic,Infinity→ static) are decisive.Layer 2 — Module graph (
classifyLayoutByModuleGraphinlayout-classification.ts). BFS traversal of each layout's dependency tree via aModuleInfoProviderabstraction over Vite'sthis.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
probeAppPageLayoutsinapp-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. TherunWithIsolatedDynamicScopecallback is injected from the RSC entry, keeping the execution module decoupled from ALS internals.Wire format
__layoutFlagsmetadata 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
LayoutClassificationOptionsgroups 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.LayoutFlagsis canonically defined inapp-elements.tsand re-exported fromapp-page-execution.ts.readAppElementsMetadataacceptsRecord<string, unknown>since the RSC payload carries heterogeneous values.parseLayoutFlagsvalidates at the wire boundary using a type predicate guard.New files
packages/vinext/src/build/layout-classification.tstests/layout-classification.test.tsTest plan
tests/build-report.test.ts— 7 tests forclassifyLayoutSegmentConfigtests/layout-classification.test.ts— 13 tests for module graph traversal + combined classificationtests/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 classificationtests/app-elements.test.ts— 2 tests for__layoutFlagsmetadata parsing + backward compattests/app-browser-entry.test.ts— 2 tests for layoutFlags merge (navigate) and replace semanticstests/app-router.test.ts— 276 integration tests pass with zero regressionsvp check— format, lint, type checks all cleanRefs #726
Closes #756