41 Commits

Author SHA1 Message Date
Nathan Nguyen 7871730c3e fix(app-router): guard deployment fallback hard navigations (#1440)
App Router RSC deployment mismatches and related fallback paths could still write directly to window.location outside the navigation controller. That bypassed the persisted same-target hard-navigation guard, so a canary or rollback mismatch could repeatedly reload the current document instead of stopping after the first hard navigation.

Expose the controller's guarded hard-navigation executor and route RSC compatibility fallbacks, server-action deployment fallbacks, and RSC navigation error fallbacks through it. Add skip/cache canary rollback compatibility coverage plus a direct fallback guard regression test.
2026-05-22 14:09:03 +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 0d8d3e3c60 refactor(router): quarantine navigation runtime topology (#1322)
* refactor(router): quarantine navigation runtime topology

* fix(router): restore manifest-backed interception runtime

App Router browser navigations lost first-hop intercepted route context after snapshot topology stopped acting as fallback proof. That made /feed to /photos/:id request the direct page payload instead of the declared modal payload.

The violated invariant was that route topology must come from the manifest, while snapshots only carry runtime proof. Resolve first-hop interception from the manifest, move RSC bootstrap streams onto the symbol-backed runtime, teach prerender extraction that script shape, and harden the runtime contract around route manifest and RSC data.

Coverage includes runtime seam tests, prerender extraction tests, planner/browser navigation tests, package rebuild, and the app-router intercepted routes E2E slice.

* fix(router): narrow prerender RSC script parsing

The prerender extractor accepted any script starting with the symbol-runtime bootstrap expression, then parsed it as though it had the exact RSC chunk-push prefix. That was harmless at the current head, but it made future runtime scripts a foot-gun.

Only the exact runtime chunk prefix and exact done marker are now accepted. A focused extractor test covers non-chunk runtime scripts that share the bootstrap expression prefix.

* fix(router): address navigation runtime review

* fix(router): resolve navigation runtime review threads
2026-05-20 10:29:13 +01:00
Nathan Nguyen e0eabc122b feat(router): add manifest-backed interception topology (#1314)
* feat(router): add manifest-backed interception topology

* test(router): cover concrete dynamic interception proof ids

* feat(router): index interception facts by slot
2026-05-19 13:15:31 +01:00
Nathan Nguyen 1dbcbfbcc8 feat(app-router): plan navigation from RouteManifest topology (#1312)
* feat(app-router): plan navigation from RouteManifest topology

Navigation commit approval still trusted flight payload snapshots for root-boundary and target-slot topology even when the static RouteManifest existed. That made stale or missing snapshot metadata authoritative during App Router commits, which could preserve layouts across the wrong root or fall back to soft commits when the graph already proved a same-root transition.

The planner now resolves current and target topology through RouteManifest when available, uses snapshot slot bindings only as visible content proof, and keeps explicit interception proof as the authority for intercepted slot commits. The generated browser entry embeds the manifest and approval paths pass it through to the planner.

Targeted coverage exercises stale snapshot roots, missing snapshot roots, dynamic URL pattern resolution, manifest target slot bindings, visible-slot proof, intercepted source topology, and browser-entry manifest serialization.

* fix(app-router): read route manifest lazily during navigation approval

The browser entry assigned the generated RouteManifest global before a static import, but ESM evaluates imported modules before the importer body. That let the browser navigation controller capture null at module evaluation, leaving manifest-aware planning disabled in real browser bootstraps.

Make the controller accept a lazy RouteManifest getter and read it at commit-planning time. Also require direct manifest route-id hits to match the snapshot matchedUrl before trusting them, so stale-but-valid route ids fall back to concrete URL pattern resolution.

Covered by app browser controller and navigation planner regressions, plus targeted entry template checks.

* fix(app-router): clarify manifest route topology resolution

Manifest route lookup now shares one matched-url normalization helper across direct route checks and pattern fallback. That keeps concrete dynamic URL fallback from drifting across call sites.

Document that manifest pattern iteration follows buildAppRouteGraph route precedence, and record why intercepted targets cannot use direct-target manifest slot bindings.
2026-05-18 18:24:45 +01:00
Nathan Nguyen 16f9ea393f fix(app-router): model RSC redirect and traversal lifecycle (#1248)
RSC redirects were followed by mutating browser history before the redirected payload had passed the visible commit lifecycle. That could collapse the initiating history entry and leave back/forward traversal without an explicit intent model.

Keep redirect hops inside the initiating navigation until an approved commit publishes history, add explicit terminal redirect decisions, and record per-entry traversal metadata so unknown browser history state stays unknown instead of being guessed.

Add focused unit coverage for redirect decisions and traversal metadata plus a browser regression for router.push redirect back navigation.
2026-05-18 10:42:19 +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 2468ae5fba fix(app-router): guard stale popstate scroll restoration (#1253)
* fix(server): progressive actions trigger error boundaries on HTTP fallbacks

* fix(server): type progressive action errors

* fix: guard stale popstate scroll restoration with active nav id

* revert(server): drop action error changes from popstate PR

The popstate race branch included server action commits from a separate branch. That made the PR review surface change progressive action error semantics unrelated to browser history restoration.

Restore those files to main's behavior in this branch and keep only the popstate scroll restoration fix. Also correct the new popstate tests so targeted check and unit coverage pass.

* test(app-router): wait for RSC cache seeding

The same-build visited payload E2E asserted cache replay after visible commit from a void router.push. The visited-response cache is populated when the internal RSC navigation promise resolves, so the test could navigate away before the cache entry existed and count a second fetch.

Wrap the internal navigation function in the test and wait for the captured promise before asserting the replay path.
2026-05-16 17:54:34 +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 1e7005bbb6 fix(app-router): refresh after discarded revalidating actions (#1240) 2026-05-15 21:19:35 +01:00
Nathan Nguyen aee62a123a fix(app-router): hydrate initial root in transition (#1243)
App Router hydration currently calls hydrateRoot at urgent priority, unlike Next.js App Router startup. That makes the initial hydration work compete with input that React could otherwise prioritize as urgent.

Move the initial App Router hydrateRoot call behind an explicit transition scheduling helper and keep returning the created root synchronously so existing navigation globals keep their current contract.

Add focused regression coverage that proves hydrateRoot is invoked inside the transition callback.
2026-05-15 17:27:21 +01:00
James Anderson b4898289e7 fix(app-router): match Next.js RSC Content-Type and 404 plain-text body (#1233)
* fix(app-router): match Next.js RSC Content-Type (no charset suffix)

Next.js sets the RSC (React Server Components / 'flight') response
Content-Type to exactly 'text/x-component' (RSC_CONTENT_TYPE_HEADER in
.nextjs-ref/packages/next/src/client/components/app-router-headers.ts:17,
used by FlightRenderResult in
.nextjs-ref/packages/next/src/server/app-render/flight-render-result.ts:15).
vinext was sending 'text/x-component; charset=utf-8'. The suffix breaks
Next.js's own segment-cache deduplication checks (action-handler.ts uses
.startsWith(RSC_CONTENT_TYPE_HEADER) on the unsuffixed string) and fails
Next.js e2e tests that assert strict equality, e.g.:
  - test/e2e/app-dir/app/index.test.ts L362, L371
  - test/e2e/app-dir/segment-cache/deployment-skew/deployment-skew.test.ts L80

Replace the duplicated literal at seven sites with the existing
VINEXT_RSC_CONTENT_TYPE constant in app-rsc-cache-busting.ts.

* fix(app-router): match Next.js 404 plain-text body 'This page could not be found'

Next.js's plain-text 404 response body is exactly
'This page could not be found' (no trailing period in the response body;
the React-rendered not-found component uses the same text with a period).
vinext was returning 'Not Found' / '404 Not Found' / '404 - Not found',
which fails Next.js e2e tests that assert .toContain('This page could
not be found'), e.g.:
  - test/e2e/app-dir/app/index.test.ts (several cases)
  - test/e2e/app-dir/parallel-route-not-found/parallel-route-not-found.test.ts L52
  - test/e2e/app-dir/prefetching-not-found/prefetching-not-found.test.ts L16

Next.js source citations (in .nextjs-ref):
  - packages/next/src/server/route-modules/pages/pages-handler.ts L121, L535
  - packages/next/src/build/templates/app-route.ts L170, L349
  - packages/next/src/build/templates/app-page.ts L701, L1043
  - packages/next/src/pages/_error.tsx L7
  - packages/next/src/client/components/builtin/not-found.tsx L7 (HTML variant)

Updates the body in notFoundResponse() and the open-redirect / pages-router
SSR-fallback / worker-entry inline 404 sites. The HTTP status reason phrase
('Not Found') is unchanged. Route-specific bodies like '404 - API route not
found' are intentionally retained.
2026-05-15 12:39:05 +01:00
Nathan Nguyen 4aa7663084 fix(app-router): hard navigate stale RSC build payloads (#1178)
* fix(app-router): hard navigate stale RSC build payloads

App Router soft navigation currently accepts any valid RSC payload after deploy. That lets stale tabs decode RSC responses produced by a different build, which can break client navigation when the server and browser artifacts no longer match.

The missing invariant was that every RSC response consumed by the browser must identify the build that produced it, and the browser must reject payloads from a different build before handing them to React.

Emit a vinext-owned build ID header on App Router RSC responses, preserve it in prefetched and visited response snapshots, and hard navigate when a response is missing or mismatches the current client build ID.

* chore: fix e2e fixture hook lint suppression

* chore: avoid hook-like fixture callback name

* fix(app-router): preserve build IDs for cached RSC payloads

* fix(app-router): use public RSC compatibility IDs
2026-05-15 12:01:33 +01:00
Nathan Nguyen e534e9bdc3 fix(app-router): promote mounted parallel slot preservation (#1191)
* fix(app-router): promote mounted parallel slot preservation

Mounted slots were still preserved by treating missing AppElements slot keys as proof during promoted same-root commits. That kept legacy wire absence as semantic authority after layout persistence moved into the planner.

The planner snapshot now carries mounted parallel slot ownership and approves preservation only when the slot owner layout is in the retained same-root layout prefix. Approved commits pass that decision to mergeElements, while unknown-root fallbacks keep the legacy absent-slot path fenced.

* test(app-router): lock traverse slot cleanup precedence

Traverse commits already clear absent slots after copying planner-approved preserved ids. That ordering means a mounted parallel slot can be approved by the planner and still be removed when the traverse payload omits it.

Cover that precedence explicitly so the mounted-slot preservation slice keeps navigation slot preservation and traversal cleanup as separate contracts.

* fix(app-router): align traverse slot preservation metadata

Traverse commits clear absent slots, so a planner proposal should not name mounted slot ids that the visible reducer will remove. That made preserveElementIds semantically wider than the commit contract.

Split current-root commit preservation by lane so traverse commits preserve the retained layout prefix only, while ordinary current-root commits still preserve mounted slots owned by retained layouts. Cover both the pure planner decision and browser-state approval boundary.
2026-05-14 09:21:12 +01:00
Yoinky3000 493e72d256 fix(app-router): catch redirect diges, render loading.tsx during cross-route navigation and invalid with calling refresh() (#1152)
* fix(app-router): catch server redirect digest, render loading.tsx across navigation

* Update page.tsx

* fix(app-router): tighten RedirectBoundary digest handling and dedupe nav-signal check

Addresses code-review findings on PR #1152:

- RedirectBoundary.reset() now clears both `redirect` and `redirectType`
  so the state never sits in a half-cleared (`null`, "replace") shape.
- getDerivedStateFromError re-throws on malformed `NEXT_REDIRECT;` digests
  (empty URL segment) instead of silently swallowing the error. Mirrors
  the server-side guard in parseNextRedirectDigest (next-error-digest.ts:51).
- Document the `error.handled` branch as a Next.js parity placeholder so
  it's clear it is not dead code, just unused by vinext today.
- Extract `getErrorDigest` / `isNavigationSignalError` into a shared
  utils/navigation-signal.ts, removing the duplicated digest classification
  in shims/error-boundary.tsx and server/dev-error-overlay.tsx.
- Update stale `snapshotRscResponse` reference in the cached-route comment
  to the current body.tee + createFromFetch flow.
- Strengthen the delayed-redirect-under-loading E2E test to also assert
  the loading.tsx fallback paints during the 50ms server delay.
- Format fix on tests/fixtures/app-basic/app/page.tsx that was failing
  the CI Check job.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(app-router): invalidate client RSC caches on router.refresh()

router.refresh() previously only re-fetched the current URL via the
"refresh" navigationKind, leaving the visited-response and prefetch
caches untouched for sibling routes. After a session change (e.g.
authClient.signOut() → router.push("/") → router.refresh()), a
subsequent <Link> click to a previously visited auth-gated route
would render the stale cached RSC payload and bypass the server's
redirect() entirely.

Matches Next.js's refresh-reducer.ts, whose header comment is
explicit: "During a refresh, we invalidate the segment cache but not
the route cache. The segment cache contains the actual RSC data
which needs to be re-fetched." Next does this via an O(1) version
counter (invalidateSegmentCacheEntries → currentSegmentCacheVersion++);
vinext's bounded LRU is small enough that Map.clear() on the
visited-response + prefetch caches is equivalent in observable
behavior.

Pre-existing in upstream vinext (verified against 8edc010); only
surfaced after the redirect-digest fix earlier in this PR, because
before that the unhandled NEXT_REDIRECT digest aborted the page
render outright and masked the stale-cache reuse.

- Expose clearClientNavigationCaches as window.__VINEXT_CLEAR_NAV_CACHES__
- router.refresh() calls it synchronously before scheduling the RSC
  re-fetch inside React.startTransition. Synchronous ordering matters:
  if the clear ran inside the transition, prefetches kicked off during
  the transition's renders could repopulate stale entries into the
  just-cleared cache.
- Unit test pins the clear-before-navigate ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(app-router): address review — remove HandleRedirect reset, fix boundary nesting, add malformed digest test

Address ask-bonk review comments from all three rounds:

1. Remove reset() from HandleRedirect — calling reset() inside startTransition
   clears the boundary state, re-rendering children and re-mounting the page
   that threw redirect(). For deterministic redirects (auth guards) this
   creates an infinite loop. Next.js intentionally does not call reset() in
   HandleRedirect (redirect-boundary.tsx); the boundary stays in its 'caught'
   state rendering null until router.push()/replace() mounts fresh children.

2. Move RedirectBoundary inside Suspense (loading boundary) — Next.js nesting
   is Error > Loading > AccessFallback > Redirect > content. The previous
   placement had RedirectBoundary outside Suspense, which unmounted the
   loading fallback during redirect transitions. Now RedirectBoundary wraps
   the page slot directly, and Suspense wraps both.

3. Add comment documenting dual-format digest parsing — the parts.length >= 5
   branching handles both vinext 3/4-part and Next.js 5-part digests, which
   was non-obvious to reviewers.

4. Add test for malformed-digest re-throw guard — verifies that
   getDerivedStateFromError re-throws when the URL segment is empty
   (e.g. NEXT_REDIRECT;push;) rather than silently swallowing the error.

* fix(app-router): correct nesting-order comment, add error.handled test

Address ask-bonk round-4 review:

1. Fix the boundary nesting comment in app-page-route-wiring.tsx — it
   incorrectly claimed the order matches Next.js's
   Error > Loading > AccessFallback > Redirect > content. The actual
   vinext order is Error > AccessFallback > Loading > Redirect > content;
   the AccessFallback positioning is a pre-existing divergence.

2. Add test for the error.handled branch in RedirectErrorBoundary —
   verifies the Next.js parity placeholder returns null state when a
   redirect error is already marked as handled by an outer boundary.

* refactor: use shared header constants instead of string literals

Replace two hardcoded header strings in the visited-response cache
snapshot with the already-imported VINEXT_MOUNTED_SLOTS_HEADER and
VINEXT_PARAMS_HEADER constants from server/headers.ts.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
2026-05-12 16:48:15 +00:00
Nathan Nguyen ac75d43e7e fix(app-router): preserve UI during action refresh (#1147)
* fix(app-router): preserve UI during action refresh

Server action responses currently always render a page payload, and router.refresh can put BrowserRoot into a pending state that mounts loading.tsx while the same route refresh resolves. That diverges from Next.js, where non-revalidating actions can skip page rendering and refresh/revalidation should preserve the current page UI.

The server action path now omits the root payload when no data was invalidated, suppresses loading boundaries for action rerenders, and carries the same suppress-loading variant through RSC request, cache-busting, and ISR cache keys. The browser action callback now resolves no-root action payloads directly and avoids pending router state for refresh.

Regression coverage includes the no-rerender action payload, loading-boundary suppression, cache key variants, cache-busting compatibility, request normalization, and an app-router Playwright case that catches route loading mounts after action plus router.refresh.

* fix(app-router): carry semantic RSC render modes

Replace the cross-layer suppress-loading boolean with AppRscRenderMode so browser requests, request normalization, rendering, cache-busting, Vary, and ISR keys all carry the same semantic payload variant.

Decode server action responses before clearing client navigation caches so no-root return-only action results keep visited response and prefetch caches intact while redirects, root payloads, and legacy bare payloads still invalidate.

Add coverage for refresh() inside server actions and for normal versus preserve-current-UI ISR RSC cache keys.

* fix(app-router): default helper render mode to navigation

* docs: add review-requested comments for structural discriminator and ISR key format

* fix: add explicit null case to parseAppRscRenderMode switch for exhaustiveness check

---------

Co-authored-by: James <james@eli.cx>
2026-05-12 15:09:45 +01:00
Nathan Nguyen 11cc64a3af fix(app-router): preserve useActionState form state after hydration (#1162)
* fix(app-router): preserve useActionState form state after hydration

Progressive multipart server actions executed before the initial HTML render were dropping React form state. That meant useActionState hydrated from the JavaScript initializer instead of the server action result after a no-JS form submission.

The progressive action path now decodes React form state, carries it through the App Router render pipeline, passes it to React's server and client hydration APIs, and marks form-state HTML responses as uncacheable so action results cannot be replaced by stale ISR HTML.

Adds targeted unit coverage for action decoding, SSR forwarding, cache policy, cache read bypass, and a Playwright no-JS useActionState regression.

* fix(app-router): keep progressive action result type internal

Knip reported ProgressiveServerActionResult as an unused exported type. The type is only part of this module's implementation detail for the progressive action return path, so keep it module-local.

* fix(app-router): separate progressive action cache bypass

* chore(app-router): tighten form state bootstrap wiring
2026-05-12 14:25:30 +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 77c4d9ce84 feat(app-router): plan root-boundary navigation decisions (#1114)
* feat(app-router): plan root-boundary navigation decisions

* test(app-router): cover unknown visible root planning

* test(app-router): cover stale root-boundary precedence

* fix(app-router): thread target href into navigation approval

Root-boundary hard-navigation planning could derive its decision URL from the pending route snapshot even though the browser executor navigated to the controller target href. That left planner traces able to disagree with the actual hard navigation target.

Require targetHref at visible commit approval boundaries, pass the controller target through navigation and same-url server-action approvals, and record it in lifecycle traces. The focused app-browser tests now assert the planner trace carries the executor URL for hard navigation decisions.

* docs(app-router): note route manifest planner follow-up

The planner input already carries routeManifest for the issue 726 route-graph slices, but CORE-07/08 always passes null and does not read it. Without a comment, the reserved field looks accidentally unused.

Document that routeManifest becomes non-null in CORE-09 and loosen the disposition test helper so render identity can diverge from navigation identity in future coverage.
2026-05-11 13:30:45 +01:00
Nathan Nguyen 108d913bfe fix(app-router): schedule link navigations as transitions (#1133)
* fix(app-router): schedule link navigations as transitions

App Router link clicks could commit cross-route payloads synchronously because the browser entry passed route path equality into the navigation controller as a transition toggle. That diverged from Next.js, where app-router push and replace enter React.startTransition for every app navigation.

Remove the transition override from normal navigation payload commits, keep synchronous commits private for HMR and same-URL server-action handling, and start RSC Link navigations through the same programmatic transition path used by useRouter().

* test(app-router): cover link transition scheduling behavior

* docs(navigation): drop stale isSameRoute references in comments

The isSameRoute concept was removed from app-browser-entry.ts in this PR;
update two comments in navigation.ts that still referenced it.

---------

Co-authored-by: James <james@eli.cx>
2026-05-08 21:43:08 +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
James Anderson b631b5da4a chore(deps): bump next.js version (#1112)
* chore(deps): bump next.js version

* fix tests
2026-05-06 17:32:01 +00:00
Nathan Nguyen 2009afde51 feat(app-router): trace approved visible commit outcomes (#1101)
Visible router state currently has an approved commit path, but the raw reducer remains an exported mutation escape hatch and approval traces only explain low-level reasons. That makes it harder to audit whether a payload became visible, was discarded, or forced a hard navigation.

The violated invariant is that visibleCommitVersion should have one owner behind ApprovedVisibleCommit, with every approval decision carrying an operator-visible transaction outcome.

Move the reducer implementation behind applyApprovedVisibleCommit, keep visibleCommitVersion advancement in that boundary, and prepend transaction trace codes for visible commits, no-commit decisions, and hard navigations.
2026-05-06 17:50:34 +01:00
Nathan Nguyen 9469e7798e fix(router): gate refresh and traverse outcomes (#1102)
* fix(router): gate refresh and traverse outcomes

Implements #726-CORE-05/06A and #726-CORE-06B from issue #726.

Compare pending operation visible commit versions against the latest visible state before applying refresh, traverse, or same-URL navigation payloads. Return explicit navigation payload outcomes so stale and hard-navigation results settle without seeding visited-response cache entries.

Make router.prefetch entries expose a cache-seeded outcome only after the snapshot is ready and pending work is cleared, and keep the ambient next/navigation declarations aligned with the runtime cache shape.

* chore: retrigger ci
2026-05-06 17:47:08 +01:00
Nathan Nguyen ceb846f5b0 fix(router): reject stale same-url server action commits (#1100)
* fix(router): reject stale same-url server action commits

Same-URL server action payloads could commit visible router state after newer visible work had already won. URL equality and activeNavigationId alone did not distinguish two different visible worlds at the same href.

The lifecycle gate now compares a pending operation's started visibleCommitVersion against the current router state's visibleCommitVersion at approval time. Stale results keep returning their action value, but they no longer mutate visible state or trigger stale hard-navigation recovery.

Tests cover out-of-order same-URL server actions and the visible-version stale path taking priority over root-boundary hard navigation.

* fix(router): preserve server action initiation version

Server action POST handling captured the router state before fetch but the same-URL commit helper previously rebased the pending operation when the response was decoded. If a newer visible commit landed while the POST was in flight, the old action could still look current.

Thread the initiation state through same-URL action commit handling so startedVisibleCommitVersion reflects the world that submitted the action. Approval still compares against the live router state after payload decoding, so stale results keep their action value without mutating visible state.

Add a hostile race test where the action response is processed after a newer same-URL visible commit.
2026-05-06 13:06:44 +01:00
Nathan Nguyen 41d62e984e refactor(app-router): gate browser state writes with approved visible commits (#1090)
* refactor(app-router): gate browser state writes with approved visible commits

Browser router commits currently mutate visible state from raw pending navigation actions after separate disposition checks. That leaves lifecycle approval implicit at each writer, which makes later stale-operation and planner work easier to bypass by accident.

Introduce CommitDecision and ApprovedVisibleCommit as the proof object for visible browser commits. Route navigation, same-URL action, and HMR state writes through the approved commit path while preserving the legacy dispatch, skip, and hard-navigate dispositions for current callers.

Tests cover approved navigate, replace, traverse, stale no-commit, and hard-navigation decisions, alongside the existing browser lifecycle regression coverage.

* refactor(app-router): close visible commit approval escape hatch

* ci: rerun checks

* refactor(app-router): isolate visible commit approval authority

* refactor(app-router): remove legacy commit disposition mirror

Visible commit classification still exposed a translated legacy disposition beside the authoritative decision. That left same-URL server action handling able to read two equivalent fields and made the approval boundary less clear than it needs to be.

Use the commit decision directly, remove the translation helper, keep the old state-level disposition private, and assert the non-visible trace reason codes in the approval tests.
2026-05-06 08:24:30 +01:00
Nathan Nguyen 4342311377 test(app-router): lock navigation lifecycle settlement and root-layout hard navigation behaviour (#1053)
* test(app-router): lock navigation lifecycle settlement and root-layout hard navigation behaviour

Adds 13 unit tests covering two Wave 0 baseline areas from issue #726:

BASE-02 — Lock current lifecycle settlement behaviour (9 tests):
- Three concurrent navigations with reverse-order payload resolution
- Rapid sequential navigation: stale deferred payload does not overwrite
- Stale cross-root navigation is skipped instead of hard-navigating
- resolvePendingNavigationCommitDisposition exhausts skip/dispatch/hard-navigate
- resolveAndClassifyNavigationCommit classifies skip when IDs diverge
- Failed RSC payload settles pending router state cleanly

BASE-03 — Lock root-layout hard navigation behaviour (4 tests):
- renderNavigationPayload calls window.location.assign on root change
- Hard-navigate settles the pending router state before navigating away
- shouldHardNavigate negative/null path exhaustiveness
- shouldHardNavigate positive path with distinct roots

These tests close the App Router concurrency gap: the Pages Router has 10
race-condition tests in shims.test.ts but the App Router (which uses a
completely different lifecycle mechanism) had zero equivalent tests for
out-of-order payload resolution and stale commit rejection.

* test: remove hallucinatory comments from app-browser-entry tests

* test: remove duplicate shouldHardNavigate null-root assertion

* test: remove low-signal helper truth-table tests, fix hard-navigate ordering assertion

---------

Co-authored-by: James <james@eli.cx>
2026-05-05 19:09:33 +00:00
Nathan Nguyen 5a18d36bfd feat(app-router): add navigation trace reason-code shell (#1063)
* feat(app-router): add navigation trace reason-code shell

Navigation commit classification can now expose compact reason-code traces for the existing stale, root-boundary, unknown-root, and dispatch outcomes. The plain disposition-only path remains allocation-light for the browser navigation hot path.

The old implicit string result made it hard for follow-up lifecycle work in #726 to attach structured operational context without inventing narrative logs at each call site. This adds a narrow trace contract and threads it through the same-URL classification helper while preserving current commit behavior.

Covered by app-browser-entry tests for stale commit skips, root-boundary hard navigation, unknown root-layout fallback, same-URL classification traces, and trace field ownership.

* fix(app-router): tighten navigation trace shell

The OPS-01 trace shell exposed an append helper before any source path needed multi-entry traces, typed reason-code values too loosely, and let the combined same-URL classifier allocate a trace that the controller never consumed.

Remove the unused append helper, derive reason-code unions from a literal key-to-code map, keep the classifier on the allocation-light disposition path, and add the missing normal dispatch trace coverage.
2026-05-05 19:27:19 +01:00
Nathan Nguyen 57e6b7001e feat(app-router): track visible commit versions for browser operations (#1061)
Browser router state changed without a versioned visible commit baseline or a typed operation record, leaving later stale-result gates without a stable comparison point.

Add pending and committed OperationRecord metadata, thread explicit operation lanes through browser navigation, refresh, traverse, same-URL server actions, and HMR, and advance visibleCommitVersion through the reducer path that accepts a new visible router-state commit.

Also harden the prefetch-cache unit test to wait for async router.prefetch setup rather than relying on a fixed scheduler tick.
2026-05-05 19:05:51 +01:00
Nathan Nguyen 9d5d1ce50d fix(app-router): validate RSC cache-busting params (#991)
* fix(app-router): validate RSC cache-busting params

App Router RSC responses could vary by request headers while client fetches and server validation still relied on Vary-aware caches. CDNs that key only by URL could reuse a mounted-slot or interception-context RSC payload for a different request.

Next.js validates a cache-busting _rsc search param against the RSC request headers and redirects malformed requests to the canonical URL. Vinext now applies the same invariant to its .rsc request model, centralizes the RSC Vary header set in normal server modules, and de-duplicates middleware Vary additions.

Tests cover missing and stale _rsc values, variant-header hashes, generated-entry delegation, browser RSC request markers, response Vary propagation, and App Router RSC integration.

* fix(app-router): hide RSC cache tokens from navigation URLs

* fix(app-router): hide RSC cache token from searchParams

* fix(app-router): address RSC cache-busting review

* fix(app-router): preserve HTML redirect destination for direct .rsc navigations

Config redirects from next.config.js were unconditionally converted to
.rsc destinations for any request whose URL ended in .rsc. This broke
direct browser navigations to .rsc URLs (e.g. a bookmark or external
link) because the browser was redirected to an RSC payload it could not
render.

Client-side RSC fetches always send the RSC: 1 header, so restrict the
.rsc destination conversion to requests that carry that header. Direct
browser hits without the header continue to redirect to the canonical
HTML URL, matching the pre-PR behaviour for config redirects.
2026-05-04 18:55:43 +01:00
James Anderson fffda16106 feat(dev): React-based runtime error overlay (#1029)
Surfaces three error sources that previously only reached the console:
React render errors caught by an error.tsx boundary (onCaughtError),
render errors with no boundary above them (onUncaughtError), and plain
script errors / unhandled promise rejections via window listeners.

The overlay renders into a separate createRoot mounted on a detached div
appended to body, so it survives an unmount of the main hydrateRoot tree
— necessary because most errors we want to surface take that tree down.

Includes a /dev-overlay-test fixture and Playwright spec covering all
three error sources, dismiss, and prev/next pagination across multiple
errors.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 18:12:03 +01:00
Nathan Nguyen ce3af6aa21 refactor(app-router): extract browser navigation lifecycle controller (#1019)
* refactor(app-router): extract browser navigation lifecycle controller

* fix(app-router): separate render ids from navigation ids

* fix(app-router): satisfy ci lint rules

* fix(app-router): narrow controller surface, add hmrReplaceTree, restore lost comments, fix tests
2026-05-03 17:45:53 +01:00
James Anderson 04befb5342 fix(navigation): hard-nav to target when render error tears down the tree (#1028)
When an RSC payload contains an error reference for a slot (e.g. a server
component throws), createPendingNavigationCommit only awaits the top-level
elements promise, so the dispatch goes through and React renders. The slot
throws when use() unwraps the rejected reference. Without an error.tsx
boundary above NavigationCommitSignal, the entire root unmounts — and
because the URL update lives in NavigationCommitSignal's pre-paint layout
effect, the browser is left on the previous URL with a blank page and a
console-only error.

Track the in-flight navigation target in a module-level
pendingNavigationRecoveryHref, set it just before dispatchBrowserTree and
clear it once the commit effect updates history. Add an onUncaughtError
handler to hydrateRoot that reads the recovery href at fire time and
window.location.assign()s to it, so the user lands on the URL they
clicked and the server can render its actual error UI for that route.

Wire the handler in both dev and prod (previously only onCaughtError was
registered in dev).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:36:05 +01:00
yyh e81820279e fix: preserve console output for caught app errors in dev (#862)
* fix: preserve console output for caught app errors in dev

* address bonk review comments on PR 862

---------

Co-authored-by: James <james@eli.cx>
2026-04-20 08:20:59 +01:00
Nathan Nguyen 8f224051b7 Preserve intercepted app-router state across server actions (#854)
* Preserve interception context on app-router actions

* Keep intercept slot overrides on action rerenders

* Pin React versions in static-export fixture

* refactor(app-page-request): unify intercept resolution into a single state machine

Collapse the parallel GET/POST intercept lookup paths into one shared
internal helper that returns a tagged union: none, current-route, or
source-route. Both resolveAppPageInterceptMatch and
resolveAppPageActionRerenderTarget now read from the same lookup result,
eliminating the double findIntercept() scan on current-route fallbacks.

Also add the two missing resolveAppPageActionRerenderTarget fallthrough
test cases (no intercept, current-route intercept with no matching
action route) and a regression assertion that the current-route branch
only performs one intercept lookup.
2026-04-20 07:49:56 +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 5f6bccbd03 feat: track previousNextUrl for intercepted App Router entries (#755)
* docs: refresh stale app browser entry comments

* feat: track previousNextUrl for intercepted App Router entries

* refactor: bind getBrowserRouterState() once in refresh case

Avoid calling getBrowserRouterState() twice in the refresh branch of
getRequestState by binding previousNextUrl to a local. Behavior-preserving
nit raised in PR review.

* refactor: drop dead history-state interception-context read path

After previousNextUrl became the source of truth for interception context
in history state, nothing in production writes the legacy
__vinext_interceptionContext key anymore. The fall-through read in
getCurrentInterceptionContext was vestigial and getCurrentInterceptionContext
now derives strictly from the current pathname.

- Removes the unused VINEXT_INTERCEPTION_CONTEXT_HISTORY_STATE_KEY export
- Removes readHistoryStateInterceptionContext
- Updates the prefetch-cache unit test to assert the new pathname-derived
  contract instead of the legacy history-state override

* test(e2e): cover back/forward restoration of intercepted modal view

Adds the back-then-forward case for App Router intercepting routes:
after intercepted navigation from /feed to /photos/42, going back to
/feed and then forward should restore the modal (rather than rendering
the full /photos/42 page). Directly exercises the previousNextUrl read
in the traverse branch of getRequestState.

* docs: reword stale 'belongs to PR 5' comments

Two inline comments in app-browser-entry.ts referenced "PR 5" as a future
PR, but this is PR 5. Reworded to describe the deferred scope as-is:
interception context on server-action POSTs and HMR re-renders is
intentionally not propagated.

* fix: distinguish explicit null from undefined in previousNextUrl fallback

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
2026-04-12 13:01:50 +00: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 19f4fac338 fix: clear stale parallel slots on traversal in mergeElements (#816)
* fix: clear stale parallel slots on traversal in mergeElements

Add "traverse" as a first-class action type in the router reducer.
When the browser navigates back/forward, the action type is now
"traverse" instead of "navigate", which tells mergeElements to
clear slot keys absent from the server response.

On soft forward navigation, absent slots are still preserved (the
server may omit parent layout slots that haven't changed). Only
traversals clear them, because the server renders the full
destination route tree on popstate.

Closes #814

* refactor: collapse navigate/traverse reducer cases; add UNMATCHED_SLOT traversal test

Address review feedback on #816:
- Collapse the identical navigate/traverse switch cases in routerReducer,
  passing `action.type === "traverse"` as the clearAbsentSlots boolean.
  Keeps exhaustive switch and never guard intact.
- Add a test pinning the subtle interaction between clearAbsentSlots=true
  and the UNMATCHED_SLOT preservation loop: a slot sent as UNMATCHED_SLOT
  during traversal (key present in next) is restored from prev real content
  and is not cleared by the absent-slot loop.

https://claude.ai/code/session_01RhqyMQWoHBr8qrLZoUjoZj

* refactor: extract toActionType helper; add reducer-level traverse tests

Address second-round review feedback on #816:
- Extract toActionType(kind: NavigationKind) helper to consolidate the
  duplicated `navigationKind === "traverse" ? "traverse" : "navigate"`
  ternary that appeared at two call sites in renderNavigationPayload.
  A comment documents the "refresh" → "navigate" mapping intent and
  flags both sites as needing updates if NavigationKind gains new values.
- Add routerReducer integration tests in app-browser-entry.test.ts:
  "traverse" clears stale parallel slots; "navigate" preserves them.
  These pin the wiring between the reducer and mergeElements.

https://claude.ai/code/session_01RhqyMQWoHBr8qrLZoUjoZj

* fix ci

* fix ci again

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: James <james@eli.cx>
2026-04-10 21:23:27 +01:00
Nathan Nguyen ca0d542947 Fix parallel slot persistence and cache variants (#811)
* Fix parallel slot persistence and cache variants

* Address App Router review follow-ups

* Fix generated mounted-slot regex escaping

* Address review feedback on slot normalization and cache comments

Align generated entry's __normalizeMountedSlotsHeader regex with the
typed module in app-elements.ts (both now use /\s+/). Clarify the
HTML vs RSC cache asymmetry comment in stale regen path.

* Remove redundant .map(trim) and clarify regen slot context

split(/\s+/) never produces tokens with leading/trailing whitespace,
so .map(trim) was a no-op in both normalizeMountedSlotsHeader and its
generated counterpart. Also add a comment clarifying that background
regen intentionally inherits the triggering request's slot context.

* Clarify regen dedup and prefetch slot-mismatch tradeoffs

Add comments explaining that pathname-only regen dedup is intentional
(avoids concurrent regen storms across slot variants) and that
consumePrefetchResponse unconditionally removes the entry before the
slot-mismatch check (simplest correct behavior, prefetch is wasted on
mismatch).

* Document storePrefetchResponse slot-awareness gap

Add JSDoc note that storePrefetchResponse is slot-unaware: the
snapshot's mountedSlotsHeader comes from the response headers rather
than the caller, so consumePrefetchResponse may reject the entry on
slot context mismatch.
2026-04-10 15:29:33 +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