* feat(skip): plumb client reuse manifests through the app request path
The browser never sends a client reuse manifest and the server has no path
to receive one, so the static-layout skip planner (#1674) and observation
foundations (#1672) have nothing to act on. Skip transport stays fully off,
but the transport wiring those slices depend on is missing.
Add a bounded browser producer that derives a client reuse manifest from the
visible AppElements state and attaches it as X-Vinext-Client-Reuse-Manifest
only on soft navigations. Parse the header into a render/send-by-default
disposition and thread the parsed result through the RSC handler, the
generated app entry, page dispatch, and the render lifecycle. Wire the
per-layout observation tracker and segment-config classification into dispatch
so the enable-transport slice can read them.
No RSC payload is omitted and no response cache-control changes: the parsed
manifest is carried but never authorizes a skip yet. Unproven and absent-
manifest requests render and send exactly as before.
Slice 3 of 4 from #1437; enable-transport follows.
Refs #726
* fix(skip): address review feedback on manifest plumbing
- app-page-execution: defer layout debug classification until after the
observation override so the emitted reason agrees with the final flag;
emit a runtime-probe reason when an observation flips a build-time static
layout to dynamic.
- app-page-dispatch: document classifyLayoutSegmentConfigFromModule as the
request-time counterpart to the build-time classifier, noting the
intentional revalidate===false divergence and the dev-mode classification.
- app-render-dependency: mark registerAppElementRenderDependencies as
write-only until the enable slice's consumer lands.
* docs(skip): clarify layout classification comments per review
- app-page-dispatch: correct the classifyLayoutSegmentConfigFromModule
docblock — the build-time classifier already normalizes revalidate=false
to Infinity, so both agree on that case; the real difference is scope
(request-time classifies dev-mode layouts not captured at build time).
- app-page-dispatch: document the merge precedence in
createEffectiveLayoutClassifications — module segment config is
authoritative over the build-time map.
* test(skip): pin observation override flipping build-time static layout to dynamic
Adds a parity assertion for the request-time behavioral change the review
flagged: isLayoutObservationDynamic downgrades a build-time `static` layout
to `d` (and emits a runtime-probe reason) when the observed render is unsafe
for static reuse, while a safe observation keeps it static. Complements the
existing children-subtree probe assertion in app-page-route-wiring.test.ts.
* fix(skip): fold probe-scoped dynamic usage into layout observation
The probe rewrite runs layout probes inside runWithIsolatedProbeDependencies,
which opens a child unified scope and resets dynamicUsageDetected. A
markDynamicUsage() during the probe that leaves no other observable trace
(e.g. "use cache: private") was therefore discarded on scope exit, so the
parent's consumeDynamicUsage() in the Layer-3 path returned false and the
layout could classify static where the pre-PR direct probe classified dynamic.
recordProbeDependencies now peeks the child scope's dynamic-usage flag before
it is reset and folds it into the per-layout observation
(dynamicUsageObserved), which isAppLayoutObservationUnsafeForStaticReuse
treats as unsafe. This keeps the observation signal a superset of the
markDynamicUsage() triggers the Layer-3 dynamicDetected path covered, so the
two signals can no longer diverge into a false-static. Adds peekDynamicUsage()
(non-resetting read) and a regression test.
* perf(skip): defer client reuse manifest production to the cache-miss path
The manifest header is excluded from VINEXT_RSC_VARY_HEADER, so it never
affects the cache-busting URL or the visited-response cache lookup. Producing
it up front ran createClientReuseManifestHeaderFromVisibleAppState (iterates
visible layout ids, builds a cache-variant proof per static layout, then
binary-searches the byte budget) on every soft navigate — including ones that
resolve entirely from the visited-response cache or a prefetch and never send
a request, where the work is pure waste.
Move production to just before the network fetch, gated on the same
`navigationKind === "navigate"` condition and computed from the captured
nav-start router state, so the emitted header is unchanged on the request path
while the cache-hit/prefetch-hit hot paths skip it entirely.
---------
Co-authored-by: James <james@eli.cx>
* fix: align app router navigation parity
* fix(app-router): preserve canonical RSC and hard-nav semantics
App Router RSC requests now keep the route pathname shape when adding _rsc. Stripping a trailing slash made trailingSlash routes fetch a non-canonical URL and let the server redirect the client-side RSC request back to the slash form.
The MPA navigation scheduler also keys pending hard navigations by both href and history mode so a later replace does not reuse an earlier push closure. Redirect handling keeps the global browser bridge out of passive server rendering while retaining client-side redirect recovery.
Adds focused regression coverage for trailingSlash RSC requests, same-href push-to-replace hard navigation, and client redirect guard loops. Updates existing navigation observers to the canonical route URL plus _rsc transport.
* fix(app-router): stabilize action and prefetch parity
* fix(app-router): reset MPA dedupe after bfcache restore
A bfcache-restored App Router document can keep the scheduler's last pending hard-navigation marker after the external navigation already left the page. Reissuing the same href and history mode then entered the suspended MPA path without scheduling another location change.
Expose a scheduler reset for the browser lifecycle boundary and call it on persisted pageshow events. Cover the scheduler contract so the same external navigation can run again after reset.
* fix(app-router): isolate redirect bridge from passive boundaries
Unhandled client redirect recovery currently lives inside every RedirectBoundary, while Pages Router params fallback can keep stale App Router params after an App-to-Pages transition. That makes passive boundaries own browser runtime wiring and lets stale app params win over live Pages Router params.
Move the unhandled redirect bridge into BrowserRoot under AppRouterContext, keep RedirectBoundary passive, and prefer live Pages Router params when that compat context exists. Add an App-to-Pages navigation regression fixture.
* fix: extract shared decodeRedirectError helper, rename stale test
- Add decodeRedirectError() to shims/navigation.ts as the single
source of truth for parsing redirect error digests
- Replace three private helpers in error-boundary.tsx with shared
decodeRedirectError() call
- Replace inline digest parsing in AppRouterRedirectBridge with
shared decodeRedirectError() call (fixes unsafe decodeURIComponent)
- Rename test that claimed to test redirect() but actually tests
<Link> navigation
* refactor: eliminate unnecessary type assertions
- isRedirectError: use in-narrowing instead of as-casts
- AppRouterRedirectBridge: remove as after guard already narrows
- RedirectErrorBoundary: inline digest access, use in for handled check
* ci: retrigger checks
* fix: emit noindex for Forbidden/Unauthorized boundaries, extract retryScrollTo helper
* docs: clarify decodeRedirectError comment about defensive join
* fix(app-router): render optimistic loading shells for dynamic navigation
Dynamic App Router links currently skip automatic prefetch for route patterns with params, so a navigation to a sibling dynamic URL waits for fresh RSC data before committing anything. That diverges from Next.js, where automatic prefetch learns the loading shell for dynamic routes and the router can commit the shell immediately while the full payload is fetched.
The client prefetch path now requests a loading-shell render for dynamic app routes, keeps that entry out of the navigation cache, learns reusable optimistic route templates from settled shell prefetches, and commits a detached optimistic payload for matching dynamic navigations. The server emits shell-only RSC payloads under a separate cache variant so normal navigation data is not polluted.
Tests cover matching semantics, shell-only page wiring, cache key isolation, prefetch cache consumption, Link prefetch behavior, and the upstream Next.js optimistic-routing deploy-suite case.
* chore(app-router): address optimistic routing review
* 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
* 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>
* 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.