Skip to content

feat: X-Vinext-Router-Skip header optimization for static layouts#768

Draft
NathanDrake2406 wants to merge 34 commits intocloudflare:mainfrom
NathanDrake2406:feat/layout-persistence-pr-7-skip-header
Draft

feat: X-Vinext-Router-Skip header optimization for static layouts#768
NathanDrake2406 wants to merge 34 commits intocloudflare:mainfrom
NathanDrake2406:feat/layout-persistence-pr-7-skip-header

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Summary

Depends on PR 6 (#767). Base branch should be rebased onto PR 6 once merged.

Implements the skip header optimization that lets the client tell the server "I already have these static layouts cached," and the server omits them from the RSC payload. This reduces bandwidth and parse time for navigation between routes that share static layouts.

  • Skip header constants + parser: parseSkipHeader (filters to layout:* entries only), buildSkipHeaderValue (emits only static IDs), X_VINEXT_ROUTER_SKIP_HEADER constant
  • Classification wiring: Thread LayoutClassificationOptions through renderAppPageLifecycle → probe → __layoutFlags injection into the RSC elements payload
  • Client sends skip header: createRscRequestHeaders includes X-Vinext-Router-Skip with static layout IDs from the current router state. Only sent on navigation RSC fetches (not SSR, HMR, server actions, or prefetches)
  • Server respects skip header: After probe + flags injection, deletes layout elements the client requested to skip — but only if the server independently classifies them as static (defense-in-depth)
  • Generated entry wiring: app-rsc-entry.ts parses the skip header, wires classification with getLayoutId + runWithIsolatedDynamicScope, passes skip set to render lifecycle

Safety guarantees

  1. Skip is a hint, not a command. Server validates every skip against its own classification
  2. Metadata always present. __route, __rootLayout, __interceptionContext, __layoutFlags are never skippable
  3. Dynamic layouts never skipped. Even if client requests skip, server renders if classified as "d"
  4. Backward compatible. Empty skip header = render everything. Missing __layoutFlags = empty flags
  5. SSR unaffected. Skip header only sent for navigation RSC requests

Test plan

  • parseSkipHeader: null, empty, single, comma-separated, whitespace, non-layout filtered
  • buildSkipHeaderValue: empty → null, all dynamic → null, mix → only static, all static
  • __layoutFlags injection: static, dynamic, no classification (backward compat), multiple layouts
  • Skip filtering: static omitted, dynamic preserved, metadata preserved, non-layout preserved, SSR bypass
  • Zero regressions: 276 app-router tests pass, all existing render tests pass
  • vp check clean: format, lint, types all pass

- 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.
Pure functions for the skip header optimization:
- parseSkipHeader: parses comma-separated layout IDs, filters to layout:* only
- buildSkipHeaderValue: builds header value from static layout flags
- X_VINEXT_ROUTER_SKIP_HEADER: header name constant

Defense-in-depth: parser rejects non-layout entries at the parse boundary.
…ecycle

- Add classification and requestedSkipLayoutIds to RenderAppPageLifecycleOptions
- Thread classification to probeAppPageBeforeRender for per-layout dynamic detection
- Inject __layoutFlags into elements record after probe, before RSC serialization
- Skip filtering: omit static layouts the client has cached (RSC requests only)
- Server validates every skip against its own classification (defense-in-depth)
Client side:
- createRscRequestHeaders now accepts layoutFlags and sends skip header
- Only sent on navigation fetches (not SSR, HMR, server actions, or prefetches)

Generated entry:
- Wire classification with getLayoutId, runWithIsolatedDynamicScope
- Parse skip header from incoming RSC requests, pass to renderAppPageLifecycle
@NathanDrake2406 NathanDrake2406 marked this pull request as draft April 3, 2026 10:54
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d1fe67be4e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +164 to +169
if (options.isRscRequest && options.requestedSkipLayoutIds) {
const elementsRecord = options.element as Record<string, unknown>;
for (const id of options.requestedSkipLayoutIds) {
if (id in elementsRecord && layoutFlags[id] === "s") {
delete elementsRecord[id];
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Avoid caching skip-filtered RSC payloads

When isRscRequest and requestedSkipLayoutIds are set, this branch deletes static layout:* entries directly from options.element before rendering; that same rendered stream is then used by scheduleAppPageRscCacheWrite() and stored under the path-only ISR key. In production ISR routes (revalidate > 0), a request from a client that already has layouts cached can therefore write a partial RSC payload to shared cache, and later clients that do not have those layouts will read a response missing required layout entries (rendering incomplete UI). The cache write path needs to use an unfiltered payload (or skip caching when skip headers are applied).

Useful? React with 👍 / 👎.

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.

1 participant