Commit Graph

18 Commits

Author SHA1 Message Date
James Anderson f0ba7b4afe fix(app-router): reuse committed client cache payloads (#2251)
* fix(app-router): seed client navigation cache

* fix(cache): parse prerender cache metadata marker

* test(cache): cover prerender metadata done marker

* fix(cache): preserve static committed stale time

* fix(link): honor dynamic stale time for auto prefetches

* fix(router): retain committed client cache state

* fix(router): publish client cache after commit

* fix(router): gate hydration cache publication

* fix(router): guard late client cache publication

* fix(router): isolate client cache parity coverage

* fix(router): preserve authoritative navigation commits

* fix(router): preserve committed navigation streams
2026-06-28 22:01:58 +00:00
Nathan Nguyen dabcb55e63 fix(app-router): expose active source page on window.next (#1995)
* fix(app-router): expose active source page on window.next

App Router pages did not update window.next.__internal_src_page, so Next.js-compatible tooling and deploy tests could not observe the active source page on initial render or client transitions.

The payload only exposed URL route IDs, which drop route groups and bracketed dynamic segment names. The fix carries a source-page metadata field from the app route's filesystem segments, reads it from committed browser router state, and mirrors it onto window.next like Next.js does.

Adds regression coverage for route-group and dynamic source-page encoding plus the window.next writer contract ported from the upstream app-dir test.

* fix(app-router): preserve source page across boundary renders

* fix(app-router): tolerate malformed source page metadata

* fix(app-router): preserve intercepted fallback source page

* fix(app-router): omit unknown boundary source page

---------

Co-authored-by: James <james@eli.cx>
2026-06-13 21:59:39 +01:00
Nathan Nguyen 65eaea49a4 feat(skip): omit proven static layouts from RSC transport (#1437)
* feat(skip): omit proven static layouts from RSC transport

App Router RSC responses re-send every layout payload on each navigation, even
when the client already holds an identical static layout from a sibling route.
That repeats transport bytes on the common sibling-page navigation path.

When the client sends a verified reuse manifest, the render lifecycle now runs a
skip-transport planner. Each manifest entry is cross-checked against the
static-layout cache proof (exact artifact compatibility, variant cache key,
payload hash, and invalidation). Layouts that pass are omitted from the outgoing
RSC payload, recorded under __skippedLayoutIds, and their per-element render
dependencies are released. The skip disposition also bypasses the RSC cache so a
partial payload never enters a shared cache, and it is suppressed for HTML
responses.

This is the enable slice. Layouts are gated on the cache proof, not yet on
per-layout runtime observations. The observation-based safety gates (param
scope, request APIs, finite revalidate, cacheLife, unstable_cache, cache-tagged
and dynamic fetches) land in the stacked hardening follow-up.

* fix(skip): preserve parallel slots owned by skipped static layouts

When the skip-transport merge restores a server-omitted static layout into
preserveElementIds, it must also restore the default/unmatched parallel slots
that layout owns. Otherwise the planner invariant ("every preserved slot's owner
layout is present in preserveElementIds, and vice versa") is violated: in the
topology-unknown path the planner returns empty slot persistence, so a
slot-owning layout skipped server-side commits with a missing slot
(mergeElements starts from the next payload and, with preserveAbsentSlots:
false, never restores it).

mergeSkippedLayoutPreservation now derives the owned slots for newly-preserved
skipped layouts via the planner's resolveDefaultOrUnmatchedSlotPersistenceForLayouts,
covering both the topology-unknown path and the known-topology past-divergence
case. Added a regression with a mounted @slot default that fails without the fix.

Also hardcode the static-layout cache-proof's observed request-API set to []
(with a comment naming the invariant) instead of reading it back from the
per-layout observation, so a future reordering of the classification gate cannot
feed stale request-API reads into the synthetic proof. Reaching that point
already requires staticLayoutIds membership, which excludes any layout that
observed a request API.

* docs(skip): explain broader-than-planner layout membership guard

Per review: clarify that the set-membership check in
mergeSkippedLayoutPreservation is intentionally broader than the planner's
prefix-based persistence, and why preserving a proven-identical layout past the
ancestor divergence point is sound. Comment-only.

* refactor(skip): drop redundant cacheable-fetch observation record

Per review: the post-key-generation recordCacheableFetchObservation in
fetch-cache.ts was strictly redundant. The synchronous record before the first
await already covers every path that reaches it (the only intervening early
return records a dynamic observation instead), and the store is an idempotent
set, so the second call added no coverage.

* fix(test): match real RSC request URL in client-reuse-manifest e2e

The 'sends a client reuse manifest for retained static layouts on soft
navigation' e2e hand-rolled its RSC-request matcher as
url.pathname === '/client-nav-test.rsc', but App Router soft-navigation RSC
requests use the bare pathname with a _rsc query param and an rsc: 1 header
(e.g. GET /client-nav-test?_rsc), so the predicate never matched and
page.waitForResponse timed out at 30s. The manifest header was being attached
correctly the whole time. Use the shared isAppRouterRscRequestForPath helper
(as the sibling test already does) so the matcher tracks the real request shape.

---------

Co-authored-by: James <james@eli.cx>
2026-06-03 16:10:25 +01:00
Nathan Nguyen 5748be3ba5 feat(cache): gate planner commits on explicit reuse proof (#1327)
* feat(cache): gate planner commits on explicit reuse proof

Runtime cache entries could enter browser commit planning as ordinary payloads even when the entry had no planner-visible reuse proof. That violated the #726 invariant that a cache hit is not semantic authority by itself.

The planner now receives a compact cache-entry proof decision through AppElements metadata and rejects missing or incompatible cache entries before route-topology commit approval. Cache-restored browser payloads synthesize the missing-proof rejection when metadata is absent, while fresh payloads keep the existing path.

Targeted tests cover proof projection, metadata transport, planner decisions, and the cached-payload lifecycle path.

* fix(cache): skip unproofed visited RSC cache restores

Visited App Router RSC snapshots were still eligible for replay even though production payloads do not yet carry cache-entry reuse proof. That let the new planner gate reject the cache path and turn soft restores into hard navigations.

Require decoded proof metadata before a payload is stored as a visited-response restore candidate, and update the real app-router navigation test to prove unproofed responses refetch without reloading. Also make cache-proof rejection-code parsing exhaustive at the type boundary.

* fix(cache): require explicit App Router payload origins

App Router navigation commits still carried cache-restore semantics as an optional boolean. A fresh default could hide a future restore path that forgot to request reuse proof, reopening the cache authority bug for visited responses.

The violated invariant is that cache origin is part of the payload contract, not a caller memory check.

Replace the boolean with a discriminated AppNavigationPayloadOrigin and thread it through browser entry, navigation controller, visible commit classification, and tests. Fresh payloads remain proof-optional; visited-cache payloads synthesize a missing-proof rejection and fail closed.

* docs(cache): document static layout proof parsing boundary

Runtime cache proof parsing currently accepts one proven reuse class: static layout. Future proof classes must update the parser before visited-cache responses can safely restore as commit-capable payloads.

The review also noted that the cache-restorable metadata predicate behaves as a narrowing boundary. Make that contract explicit as a TypeScript type guard without changing runtime behavior.

* fix(cache): keep accepted proof in rejection traces

Cache-proofed payloads that failed a later interception or root-boundary gate produced correct hard-navigation decisions, but the rejection trace lost the accepted proof fields. That made cache-proof diagnostics incomplete for payloads that passed proof validation and failed a later topology gate.

The root cause was that later rejection branches still used the pre-cache-proof trace fields after commitTraceFields had been computed.

Thread the enriched trace fields into those rejection decisions, document cache proof parser three-way semantics, narrow the cache-restorable metadata type guard precisely, and read the pending commit proof from the canonical top-level field.
2026-05-20 14:15:28 +01:00
Nathan Nguyen 656d954abf feat(router): promote intercepted preservation through planner (#1249)
* feat(router): promote intercepted preservation through planner

Intercepted navigation could still preserve source UI through context-bearing payload shape and same-URL history state. That made modal preservation depend on transport metadata instead of the visible route world that had actually been committed.

The planner now requires explicit interception proof before preserving source layouts or unrelated slots, and the browser lifecycle clears stale previous-next history state when normal target payloads commit. Wire metadata carries proof, while missing, stale, or incompatible proof falls back to hard navigation.

Tests cover approved source-slot preservation, missing, stale, and malformed proof rejection, current-context refresh and action handling, traverse restoration, and direct-target refresh clearing stale interception state.

* fix(router): normalize intercepted proof paths

Interception proof could be built from browser-derived percent-encoded paths while committed route snapshots used the server-normalized route-state form. Non-ASCII intercepted source or target paths could then fail proof validation and fall back to a hard navigation.

Normalize proof matched URLs before encoding source and target route IDs, and normalize client planner snapshot matched URLs to the same route-state representation without changing the user-facing navigation snapshot.

Adds regressions for encoded non-ASCII proof generation and planner validation.

* review: address ask-bonk PR review comments

- Add interceptedRejectedTargetMismatch reason code for target URL
  mismatch in planner traces (was misusing UnknownSource)
- Document double-attempt replaceState fallback for browser quirks
- Clarify navigate branches: proven interception vs legacy fallback
- Document cache migration safety for legacy interceptionContext
- Tighten isInterceptionMetadataValue type guard with string checks
- Extract interception attachment into shared step in wire metadata
  entries to remove asymmetric branching

* fix(router): ignore context-only interception metadata

Context-only AppElements payloads could enter intercepted preservation planning or leave context-suffixed route identity as later proof authority. That made transport metadata influence visible-world preservation when a normal response was rendered from an intercepted request context.

Only explicit interception proof now enters the preservation branch. Planner snapshots also canonicalize context-suffixed route IDs when no proof is present, keeping render keys partitioned while preventing stale context from poisoning future explicit interception proof.

Tests cover the direct planner decision and the two-step browser lifecycle regression.

* test(router): cover intercepted proof rejection matrix

Planner traces now expose distinct rejection reasons for mismatched intercepted targets and incompatible roots. Without direct tests, those branches could regress while the broader intercepted route scenarios still passed.

Add focused planner tests for target mismatch and incompatible root rejection, use the local target snapshot consistently, document two lifecycle ordering contracts, and avoid the no-op interception metadata spread on non-intercepted payloads.
2026-05-18 10:18:49 +01:00
Nathan Nguyen 7d299088b8 fix(app-router): promote default slot persistence through route state (#1229)
* fix(app-router): promote default slot persistence through route state

Default and unmatched parallel slots were still inferred from AppElements transport markers. That made soft navigation persistence depend on wire shape instead of planner-owned route state, which breaks the #726 invariant that AppElementsWire is transport only.

Thread slot binding metadata through AppElements, browser state, and the navigation planner. The commit boundary now preserves previous slot content and binding proof only when target route-state marks the slot default or unmatched and the visible state proves a mounted/default value exists.

Tests cover the new planner contract, metadata validation, route wiring, mergeElements behavior, and browser commit lifecycle for default and unmatched slot targets.

* fix(app-router): align intercepted slot binding metadata

Intercepted slot payloads could render active modal content while their route-state metadata still described the slot as default. The browser planner could then preserve the previous default slot for an ID that the fresh payload should replace, breaking intercepted navigations in app-router E2E.

Derive slot IDs through one helper for both metadata and rendering, assert graph/wire ID divergence, and compute binding state from the same resolved override default export used by the render path. Move the app hydration marker to the committed root layout effect so E2E link clicks wait for a hydrated tree.

* fix(app-router): harden slot binding preservation

Review follow-up for #726-CORE-14. Centralize AppElements slot binding normalization so duplicate slot ids and stale owner layout ids fail at the wire boundary instead of creating quiet last-write-wins behavior.

Alias planner slot binding snapshots to the AppElements binding shape, share slot id ordering helpers, document the no-proof compatibility law, and add negative planner/wire tests plus a browser-visible soft navigation E2E. Refs #726.

* fix(app-router): align slot binding proof with rendering

* docs(app-router): clarify slot preservation invariants
2026-05-16 01:02:56 +01:00
Nathan Nguyen c3b8948196 feat(compat): support declared artifact compatibility sets (#1230)
Artifact compatibility previously treated every known graph, deployment, root boundary, or render epoch mismatch as incompatible, even when a rolling deploy can prove that two versions belong to an explicit compatibility window.

That preserved safety but left no typed path for #726-COMPAT-06/08 canary, rollback, and old/new client coverage.

Add explicit compatibility sets to the evaluator. Matching remains strict by default, unknown, future, and legacy metadata still fall back to renderFresh, and overlapping pairs are not treated as transitive proof.

Cover rolling deploy, canary/rollback, stale map, old-client/new-server, and new-client/old-server scenarios.
2026-05-15 12:07:46 +01:00
Nathan Nguyen 1d01d2989a feat(cache): record app render observations (#1198)
* feat(cache): record app render observations

App Router cache artifacts and payloads did not carry the render-observation metadata required by #726-CACHE-05/07. That left later cache-proof work without an attached record of request APIs, dynamic fetches, output scope, cache tags, and boundary outcome.

The missing boundary was that render-scoped observations lived only as dynamic/cache state, not as metadata owned by each produced payload or artifact.

Record render request API usage and dynamic fetch observations in request-scoped state, build redacted RenderObservation payloads for AppElements and APP_PAGE cache writes, and preserve complete observations for HTML/RSC ISR artifacts including stale regeneration.

Targeted coverage asserts payload metadata, artifact metadata, redaction, and existing cache-proof behavior.

* test(cache): cover search param render observation usage

The CI unit shard failed because app-page-element-builder tests fully mocked the headers shim and omitted the new markRenderRequestApiUsage export. The production path now records searchParams as a render observation whenever populated search params make the page dynamic.

Update the mock to expose the new shim export and assert that populated search params mark both dynamic usage and render-observation usage, while empty search params mark neither.

* refactor(cache): tighten render observation review handling

Render observation metadata now reuses the app page observation state shape instead of duplicating cache-local types. The deferred cache writes also document the stream-consumption boundary that preserves late request API observations.

Dynamic fetch observations are now stored in a Set so repeated no-store fetches do not inflate metadata, and the collector has focused regression coverage. The Link transition test also patches and restores the runtime React default so the full unit project is not order-sensitive when other files instantiate the module graph first.

* fix(cache): preserve parent render observations
2026-05-15 11:51:50 +01:00
Nathan Nguyen 04140e47a3 feat(app-router): promote same-layout ancestor persistence (#1167)
* feat(app-router): promote same-layout ancestor persistence

App Router soft commits previously preserved absent non-slot entries through the merge layer and kept root-layout hard-navigation policy split between browser state and planner. That made same-layout ancestor persistence implicit and allowed unknown root identity to behave like proof of reuse.

The planner now returns explicit preserveElementIds for proven same-root transitions, browser state stores layoutIds from wire metadata, and visible commits preserve only planner-approved ancestors while leaving parallel-slot persistence semantics intact.

Adds coverage for layout id metadata, unknown-root fallback, stale element removal, and visible layout persistence.

* fix(app-router): validate layout persistence proofs

App Router payload metadata treated every string in __layoutIds as planner preservation authority. That let a bad producer, stale payload, or future refactor pass a page, slot, or malformed key as proof for layout ancestor reuse.

The violated invariant is that layout persistence proof must be validated before it reaches the commit planner. Parse __layoutIds through the AppElements wire-key codec and accept only layout keys.

Also add dynamic segment browser coverage for /blog/[slug] navigation, proving param-sensitive layout and page content update while the layout client counter persists.

* fix(app-router): scope boundary layout proofs

HTTP access fallback payloads derived layout metadata from the full route definition. Layout-level access fallbacks can render only ancestor layouts, so the old proof could let the planner preserve child layouts that were absent from the response.

Derive boundary layout metadata from the rendered layout module subset and mark the root boundary unknown when no route layout is rendered.

* docs(app-router): document layout id ordering

Document the ancestor-first layoutIds invariant at the planner snapshot boundary so future payload producers do not break same-layout prefix persistence.
2026-05-12 14:19:21 +01:00
Nathan Nguyen 91b18396d0 fix(app-router): prevent repeated hard-navigation loops (#1111)
* fix(app-router): guard repeated hard navigation targets

Root-boundary changes currently trigger a hard navigation without remembering the target. If an old client keeps receiving an incompatible new-server payload for the same URL, the recovery path can re-enter the same hard navigation instead of stopping the loop.

Persist the attempted hard-navigation target in sessionStorage, block a repeated same-target attempt once the browser is already on that URL, and clear the guard after a successful initial RSC bootstrap. Add old-client/new-server future compatibility coverage so unknown metadata remains a render-fresh fallback, not reuse proof.

* test(app-router): cover cross-page hard navigation guard

Bonk noted that the hard-navigation guard allows cross-URL recovery even when sessionStorage cannot persist the guard, and that same-URL server-action hard navigation intentionally ignores the guard result.

Document those invariants in the controller and add coverage proving a stored guard target does not block a hard navigation when the browser is currently on a different URL.
2026-05-07 09:13:07 +01:00
Christoph Richter 3cfccd0bd4 fix(deploy): resolve wrangler .CMD shim on Windows (#1098)
* fix(deploy): resolve wrangler .CMD shim on Windows (#1095)

`runWranglerDeploy` invoked the bare-name `node_modules/.bin/wrangler`
file, which on Windows is a Unix shebang script that CreateProcess()
cannot execute, surfacing as a misleading `spawnSync wrangler ENOENT`.
Prefer the `.CMD` shim on win32, fall back to the bare name on other
platforms (and as an error-message fallback when nothing is found).

Extracted bin resolution into an exported, platform-injected
`resolveWranglerBin` helper so the behavior is unit-testable without
mutating `process.platform`.

Fixes #1095

* Apply suggestion from @ask-bonk[bot]

Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>

---------

Co-authored-by: James Anderson <james@eli.cx>
Co-authored-by: ask-bonk[bot] <249159057+ask-bonk[bot]@users.noreply.github.com>
2026-05-06 09:11:23 +00:00
Nathan Nguyen b94650bcfb refactor(app-router): fence AppElements wire-key construction (#1088)
* refactor(app-router): fence AppElements wire-key construction

AppElements transport keys could still be constructed or recognized through scattered raw string prefixes outside the wire codec. That kept the flat payload format acting as an informal semantic API and left legacy compatibility behavior covered only by indirect route tests.

Make AppElementsWire the exported constructor and parser boundary for route, page, layout, template, slot, cache, metadata, and unmatched-slot wire values. Route graph semantic ID minting stays separate, while render wiring, layout classification, browser slot merging, and prefetch cache tests now consume the codec boundary.

Tests cover canonical key construction and parsing, legacy metadata read/write behavior, unmatched-slot marker compatibility, and a source-boundary check that rejects new raw AppElements wire-key construction outside the codec and route graph.

* refactor(app-router): address AppElements wire review

Make slot-key recognition a cheap structural predicate, document the absolute tree-path parser contract, and broaden the boundary coverage to catch raw wire-key concatenation.

Also route layout classification ids through AppElementsWire so raw layout-key construction stays fenced inside the codec boundary.
2026-05-06 08:23:01 +01:00
Nathan Nguyen 15c8079604 feat(app-router): add artifact compatibility metadata (#1062)
* feat(app-router): add artifact compatibility envelope

App Router payloads now carry a parsed compatibility envelope at the existing payload metadata boundary. Without this skeleton, future cache and skip work would have no typed place to record graph, deployment, schema, root-boundary, or render-epoch proof.

Add a small constructor/parser pair, attach a default unknown-proof envelope to outgoing record payloads, and reject malformed envelope metadata when reading payload metadata. Tests cover explicit envelopes, legacy missing envelopes, malformed envelopes, and non-mutating payload writes.

* test(app-router): cover future artifact schema rejection

The review correctly identified that the envelope parser had malformed-field coverage but no direct test for the version guard path. Add the future-schema-version case and document why the Wave01 skeleton emits separate schema versions and an unknown-proof envelope without yet splitting mismatch from corruption behavior.

* feat(app-router): attach artifact compatibility metadata

App Router payloads previously carried only the COMPAT-01 unknown envelope unless a caller manually supplied metadata. That left COMPAT-02/03 without concrete graph, deployment, root-boundary, and fallback behavior in the PR.

The render lifecycle now attaches compatibility metadata for record payloads from route/root/deployment facts, while renderEpoch remains unknown until an epoch owner exists. The compatibility evaluator returns explicit compatible, unknown, or incompatible decisions so unknown proof always falls back to render fresh instead of becoming reuse proof.

Tests cover render-boundary metadata attachment and unknown graph, deployment, root-boundary, and render-epoch decisions.

* fix(app-router): tolerate unused compatibility metadata parse failures

App Router metadata parsing crashed when __artifactCompatibility was malformed or from a future schema, even though current render and navigation callers do not consume that field yet.

The parser treated compatibility scaffolding like required route metadata. That made an unused proof capable of failing initial mount, SSR, and client navigation paths.

Default invalid compatibility metadata to the unknown proof for Wave01, document the planned hard-fail point for #726-COMPAT-04/05, and cover malformed, future-version, and non-object payloads.
2026-05-06 08:15:26 +01:00
Nathan Nguyen e216dab483 refactor(app-router): introduce AppElementsWire boundary (#1059)
* refactor(app-router): introduce AppElementsWire boundary

App Router payload ownership currently leaks through raw helper calls for route ids, cache keys, metadata writes, and decode paths. That makes the flat AppElements transport act like router semantics instead of a narrow wire boundary, which violates the #726-WIRE-01 ownership target.

Introduce AppElementsWire as the single codec facade for AppElements wire encoding, decoding, metadata creation, and outgoing payload shaping. Route the current production encode/decode/write call sites through that facade while keeping the legacy helper names re-exported only where existing tests cover compatibility.

* chore(app-router): address AppElementsWire review
2026-05-05 19:15:27 +01:00
Nathan Nguyen 9791a63381 feat(app-router): emit per-layout flags in the RSC payload [2/6] (#839)
* feat(app-router): emit per-layout flags in the RSC payload

Wire runtime layout classification through renderAppPageLifecycle and
attach the resulting flags as __layoutFlags in the outgoing RSC payload
via buildOutgoingAppPayload. Payload-shape helpers (withLayoutFlags,
isAppElementsRecord, buildOutgoingAppPayload, AppOutgoingElements) live
alongside the existing readAppElementsMetadata so the write and read
boundaries sit next to each other.

* fix(app-elements): widen isAppElementsRecord narrowing and drop redundant clone

- isAppElementsRecord narrowed to Record<string, ReactNode>, but the
  outgoing payload legitimately carries heterogeneous values (ReactNode
  for the rendered tree plus LayoutFlags under __layoutFlags). Widen the
  predicate to Readonly<Record<string, unknown>> so the return type
  matches what the runtime check actually proves.

- buildOutgoingAppPayload spread input.element into a new object before
  passing it to withLayoutFlags, which immediately spreads again. Drop
  the outer copy; withLayoutFlags already guarantees immutability.

* fix(app-router): clear leaked dynamic usage on layout probe errors
2026-04-15 09:16:06 +01:00
Nathan Nguyen 18237955fa feat: static/dynamic layout detection for skip-header optimization (#767)
* 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.

* 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.

* 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.

* 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.

* refactor: group classification options into single LayoutClassificationOptions 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.

* fix: default to dynamic flag when layout probe throws non-special error

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.

* 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

* style: fix prettier formatting

https://claude.ai/code/session_01MDJzCNao3dX9tmmVtSqsw5

* Revert "style: fix prettier formatting"

This reverts commit 8e09f70b80.

* Reapply "style: fix prettier formatting"

This reverts commit 1faf2cd081.

* style: apply oxfmt formatting

https://claude.ai/code/session_01MDJzCNao3dX9tmmVtSqsw5

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-13 20:11:47 +01:00
Nathan Nguyen b8c44fdd52 feat: encode interception context in App Router payload IDs and caches (#753)
* test: add E2E verification for layout persistence flat payload pipeline

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.

* test: harden layout persistence hydration assertions

* test: address layout-persistence spec gaps

* test: tighten not.toBeAttached() assertions for absent elements

* chore: trigger CI

* feat: encode interception context in App Router payload IDs and caches

* test: update App Router entry snapshots for interception encoding

* fix: reuse committed interception context for soft navigations

* chore: fix formatting in app-browser-entry after rebase

* fix: update mounted-slot test helper calls for 4-arg createResolvedElements signature

PR 3 added mounted-slot tests using createResolvedElements(routeId, root, extraEntries).
PR 4 inserted interceptionContext as the 3rd param, shifting extraEntries to 4th.
The object was being swallowed as interceptionContext, causing getMountedSlotIds to return [].
Also add missing renderId to the interception context pending-commit test.

* chore: document intentional interception context omission in HMR handler

Add a comment to the HMR rsc:update handler noting that interception
context is intentionally omitted, matching the existing documentation
pattern in the server action handler.

https://claude.ai/code/session_01EnvJuSwsZS9n862q7Veamo

* fix: prefetch cache test now exercises interception-context partitioning

The test was passing pre-computed cache keys as the rscUrl parameter to
storePrefetchResponse(), which internally calls createAppPayloadCacheKey
again. With the default interceptionContext=null the key passed through
unchanged, so the test only verified Map key uniqueness rather than the
actual partitioning logic. Pass raw RSC URLs with separate interception
context parameters instead.

Also add a comment clarifying that PendingNavigationCommit's top-level
interceptionContext/rootLayoutTreePath/routeId are convenience aliases
for the corresponding action fields.

https://claude.ai/code/session_01EnvJuSwsZS9n862q7Veamo

* fix: strip NUL bytes from interception context request header

Defensively remove \0 from the incoming X-Vinext-Interception-Context
header to prevent a malicious client from crafting cache keys that
contain the internal NUL separator.

https://claude.ai/code/session_01EnvJuSwsZS9n862q7Veamo

* fix: resolve rebase conflicts in slot preservation tests

After rebase onto upstream main, two fixes needed:
- Pass extraEntries as 4th arg to createResolvedElements (3rd is now
  interceptionContext)
- Add missing interceptionContext field to test action objects

https://claude.ai/code/session_01EnvJuSwsZS9n862q7Veamo

* test: update entry template snapshots for NUL-byte header sanitization

https://claude.ai/code/session_01EnvJuSwsZS9n862q7Veamo

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-11 17:28:31 +01:00
Nathan Nguyen 0dbfeff1af feat: flat keyed payload for App Router layout persistence (#750)
* Extract app page route wiring helpers

* Add slot client primitives

* Fix app page error boundary serialization

* Fix client error boundary pathname reset

* Document Next.js error boundary verification

* Implement flat App Router payload for layout persistence

* fix: address review findings in flat payload implementation

- 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).

* fix: normalize flat payload after use(), not before

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.

* fix: produce flat RSC payload on all rendering paths

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.

* test: update unit tests for flat RSC payload on all 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

* fix: wrap Flight thenable in Promise.resolve() before chaining .then()

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.

* fix: eliminate Promise from ElementsContext to fix React 19 hydration

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.

* test: update slot and browser state tests for resolved ElementsContext

- 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)

* ci: retrigger

* fix: address code review findings (P1-P3)

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 "/".

* fix: avoid serializing app render dependency wrappers

* Fix flat payload dependency barriers

* Fix template-only route wrappers

* chore: trigger CI review

* fix: skip Slot wrapping for layout entries without a default export

When a layout entry exists but has no default export, the element is
never written to the flat map. The unconditional Slot wrapping would
return null (id not in elements), silently dropping the entire route
subtree below that layout level.

Guard the Slot wrapping with a check for the layout component. When
absent, pass layoutChildren through directly — preserving the
LayoutSegmentProvider and error/not-found boundaries while skipping
the Slot indirection.

* fix: restore merged app router entry behavior

* fix: address app router review regressions

* Fix app-page-request intercept tests

* Address PR 2c review follow-ups

* Refactor same-url payload commits

* Clarify PR 2c browser invariants

* Update App Router entry snapshots

* Tighten App Router review follow-ups

* Align App Router entry helper usage

* Tighten browser commit invariants

* fix(app-router): preserve scoped parallel slot identity

* test(app-router): align slot identity assertions

* fix(app-router): keep boundary root layout metadata unknown

* docs(app-router): refresh stale review comments
2026-04-10 07:49:18 +01:00