Commit Graph

25 Commits

Author SHA1 Message Date
Nathan Nguyen 47b38a91f3 fix(app-router): recover SSR shell render errors via __next_error__ document (#1908)
* fix(plugin): resolve vinext/shims/* package subpaths to local shim files

Runtime helper modules embedded into generated entries import vinext's
own shims by package subpath (e.g. `vinext/shims/headers`), while source
checkouts alias userland `next/*` imports to the local shim files. The
two specifiers resolved to different module instances, so request-scoped
singleton state (navigation context, headers) split between the source
shim copy and the package export copy.

The violated invariant is that every shim module must be a per-request
singleton regardless of import specifier. Resolve `vinext/shims/*`
through the same plugin path as the `next/*` aliases so both forms land
on the local shim files.

Exercised by the SSR shell-error recovery browser spec, whose fixture
imports vinext from the source checkout and depends on shared
navigation state across both import forms.

* fix(app-router): recover SSR shell render errors via __next_error__ document

When the HTML (Fizz) render rejects during SSR, vinext re-rendered a
server-side global-error page whose flight payload encodes the error
tree. For an app without a custom global-error.tsx that meant the
default error card with no path back to the real page: an SSR-phase-only
throw (e.g. a client component using the "throw to opt out of server
rendering" pattern) left the browser stuck on the card even though the
client render would succeed.

The violated expectation is Next.js's shell-error semantics: a failed
HTML shell is served as the default `__next_error__` error document
carrying the ORIGINAL flight payload and the bootstrap module, and the
browser re-renders the real tree from that payload with createRoot
instead of hydrating. Local error.tsx boundaries still win — they ship
inside the flight payload and catch the re-thrown error client-side.

handleSsr now resolves to that recovery document instead of rejecting,
but only when both hold:
- the error did not originate in the RSC render (no string `digest`),
  so flight errors, redirect()/notFound(), and server-component throws
  keep driving the existing rejection-based boundary machinery, and
- the app has no custom global-error.tsx (the generated entry knows at
  build time and threads hasCustomGlobalError through dispatch/render
  options); apps with one keep the server-rendered boundary re-render.

The browser entry switches from hydrateRoot to createRoot when the
document root carries id="__next_error__", dropping the error-shell
styles first.

Covered by the new ssr-error-shell-recovery browser spec (recovery to
real content, local error.tsx for SSR-only and unconditional client
throws) and the existing tests/nextjs-compat/global-error.test.ts
boundary-semantics suite.

* refactor(review): address PR 1908 non-blocking notes

- Add static prerender no-boundary recovery regression test to
  ssr-error-shell-recovery.browser.spec.ts
- Document broad __next_error__ browser marker in app-browser-entry.ts
- Extract stripJsExtension to utils/path.ts and wire into all shim
  resolution sites (vinext/shims/* + react-server shims)

Non-functional: targeted regression coverage + code documentation
+ minor resolver hardening per reviewer feedback.

* refactor(review): clarify shell recovery assumptions

* fix(ssr): cancel abandoned prerender streams

* refactor(ssr): clarify error shell root options

* fix(isr): recover shell errors during regeneration

* fix(ssr): scope client recovery to marked shells

* fix(plugin): explicitly filter vinext shim subpaths

* fix(plugin): retain null-prefixed shim resolution

* test(nextjs-compat): cover no-boundary shell recovery fallback

An unconditional client throw during SSR shell recovery without a local error boundary must still land on the default global-error card. Without a regression test, a future change could tear down the recovery shell and leave a blank document.\n\nAdd a production browser case that exercises the no-boundary route and asserts the default error UI after the client re-render throws again.

* fix(app-router): preserve shell recovery error semantics

* test(app-router): harden shell recovery cache semantics

* fix(app-router): mark global error responses uncacheable

* test(app-router): align error response status expectations

* fix(build): preserve null-prefixed og resolution

* fix(cache): delegate cache header cleanup to adapters

---------

Co-authored-by: James <james@eli.cx>
2026-06-14 22:28:25 +00:00
Nathan Nguyen dea4472c2f fix(app-router): include parallel slot params in useParams (#1904)
* fix(app-router): include parallel slot params in useParams

App Router navigation state previously used only the primary route match params when setting request navigation context, RSC param headers, render observations, and optimistic navigation payloads. That lost catch-all params from active parallel slot branches, so useParams() diverged from the slot page's params prop after SSR and client navigations.

Next.js derives params from the selected router tree across parallel route branches. Resolve active slot params from route metadata, merge them into the navigation params boundary, and feed that selected param set through server render and optimistic navigation payloads.

Port the upstream parallel-route-navigations fixture and cover the route graph, selected params helper, and optimistic payload boundary.

* fix(app-router): preserve canonical pathname in navigation context after middleware rewrite

* fix(app-router): resolve parallel slot params for static, revalidation, and action paths

* chore: retrigger CI

* fix(app-router): address ask-bonk review comments on parallel slot params

- Remove unused  parameter from
- Add clarifying comment on routeParams fallback being deliberate no-op
- Use  (full matched params) instead
  of narrowed  as base for slot param merge,
  preserving interception-specific extras on ISR regeneration path

* fix(app-router): use full navigationParams in server-action re-render path

The server-action re-render path was passing narrowed
actionRerenderTarget.params to resolveAppPageNavigationParams,
dropping interception-specific extras from source-route intercepts.
Use actionRerenderTarget.navigationParams (full matched params) instead,
matching the pattern used in the dispatch ISR path at
app-page-dispatch.ts:602.

Adds unit tests for the intercepted-slot and mixed intercepted +
non-intercepted slot branches of resolveAppPageNavigationParams.

* docs(router): clarify decode invariant and type cast rationale in parallel-slot params

- app-optimistic-routing.ts: add comment explaining the pre-existing
  double-decode asymmetry between route params and slot params, noting
  TODO to converge decode passes
- app-server-action-execution.ts: expand the comment on the interceptOpts
  cast to document why it's safe and what a follow-up would look like

* fix(app-router): merge slot params for fresh intercepts

---------

Co-authored-by: James <james@eli.cx>
2026-06-11 23:54:27 +01:00
Nathan Nguyen f5bee1a26f fix(app-router): preserve unstable_catchError boundaries (#1906)
* fix(app-router): preserve unstable_catchError boundaries

* test(e2e): add Next-parity test for shouldRerenderAppPageWithGlobalError (#1906)

Add a test proving that for uncaught post-shell RSC errors (with no
client boundary), the server preserves the original HTML stream and the
client renders global-error. This is the Next.js behavior that justifies
keeping shouldRerenderAppPageWithGlobalError unconditional at false.

The server cannot detect userland client boundaries like
unstable_catchError from the route manifest, so the only safe way to
get a global-error rerender is to make a targeted error-boundary test.

The test uses a loading.tsx boundary so the error is truly post-shell,
not caught by the pre-shell error-recovery catch block.

* fix(error): restore sentinel wrapper for falsy thrown values in unstable_catchError

Restores the _CatchErrorState sentinel wrapper ({ thrownValue: unknown }) so
falsy thrown values (null, undefined, 0, "") are routed to the fallback
instead of being treated as no error. Also restores ErrorInfo.error as unknown.

Adds back unit tests for falsy values, class-component lifecycle, and
unstable_retry behavior parity with the component-rendered fallback shape.

Refs: #1906

* fix(error): align ambient ErrorInfo.error type with runtime implementation

* refactor(app-router): remove shouldRerenderAppPageWithGlobalError dead code

The function always returned false and its _options parameter and
ShouldRerenderAppPageWithGlobalErrorOptions type were vestigial.
The call site at app-page-render.ts:892-904 was therefore never taken.

Remove the function, type, import, call site, and the now-redundant
unit test, leaving the shell-level recovery path in
renderAppPageHtmlStreamWithRecovery as the sole error recovery seam.

Addresses ask-bonk review comment on #1906.

* fix(test): restore typed globalAny view and document Fallback as any intent

Addresses two non-blocking review findings from ask-bonk on PR #1906:

- Restored the typed `globalThis as unknown as { window?: unknown }`
  pattern for window stubs in the unstable_catchError test section,
  replacing the `(globalThis as any).window` pattern that was a type-safety
  regression from the prior rewrite.
- Added an inline comment explaining why `Fallback as any` is necessary
  in the class-component lifecycle test: the test probes the inner
  _CatchError class directly, where the fallback function is used as a
  React component type (receiving a single `{ props, errorInfo }` object)
  rather than the public API signature `(props, errorInfo)`.

* chore: trigger CI

* fix(test): rename forwardedProps to props in unstable-catchError test

Addresses ask-bonk review finding on PR #1906: the test was seeding
the inner _CatchError class with the stale key name forwardedProps,
but the production wrapper now passes props (and isPagesRouter).
Since this test only exercises the App Router unstable_retry path
(which reads neither key), behavior was unaffected, but renaming
aligns the test with the current boundary shape for future readers.

* refactor(server): remove dead routeHasLocalBoundary field

Removing shouldRerenderAppPageWithGlobalError (commit abb416b)
deleted its only reader of routeHasLocalBoundary. The field is still
declared in the options type and populated on every render, but
nothing consumes it anymore. Removes:

- routeHasLocalBoundary from AppPageRenderOptions type
- The computation in app-page-dispatch.ts
- Two test stubs in app-page-render.test.ts

Addresses ask-bonk review finding on PR #1906.
2026-06-11 23:21:52 +01:00
Nathan Nguyen cda19e1c58 fix(app-router): preserve recent segment state with Activity BFCache (#1739)
* fix(app-router): preserve recent segment state with Activity BFCache

Fresh App Router navigations were reusing the active React tree when moving between dynamic segment values. That leaked client state into a newly entered segment and made the upstream back-forward-cache suite fail before it even reached browser traversal assertions.

The root cause was that vinext had history-entry bfcacheId plumbing but no separate segment state-key cache. Next.js treats those identities separately: render state is keyed by segment identity/pathname, while useRouter().bfcacheId identifies a concrete history entry.

Add state-key maps for app router slots, render retained entries through React.Activity when cacheComponents is enabled, keep the active one-entry keyed reset path otherwise, and port focused Next.js regression coverage for the three-entry BFCache behavior.

* fix(bfcache): store stateKeyMap in retained slot entries and re-provide on render

Review feedback requested two changes:

1. Add stateKeyMap to BfcacheSlotEntry so each retained BFCache entry carries
the state-key map that belonged to it when active, rather than borrowing the
current navigation's map. This prevents nested hidden entries from being rendered
with old elements but new pathname-derived state keys — an identity corruption
bug that upstream Next.js avoids by rendering each retained entry from its own
state.

2. Add a nested dynamic regression test: /nested/[section]/item/[id] with client
counters in both the [section] layout and [id] page. The existing flat /page/[n]
tests do not exercise nested retained-entry identity, which is exactly where the
missing state-key-map bug lives.

Changes:
- BfcacheSlotEntry gains stateKeyMap?: Readonly<Record<string, string>>
- updateBfcacheSlotEntries accepts and stores an optional stateKeyMap argument
- BfcacheSlotBoundary stores the current stateKeyMap alongside elements when
caching the active entry
- Both the Activity visible/hidden path and the non-cacheComponents single-entry
path now wrap rendered entries in BfcacheStateKeyMapContext.Provider using the
entry's own stateKeyMap (falling back to current map for forward compatibility)
- New e2e test verifies nested layout + page counter preservation across
segment navigations

* refactor(bfcache): extract shared provider stack into BfcacheEntryProviders

BfcacheSlotBoundary rendered the BfcacheStateKeyMapContext / ElementsContext /
SegmentContext provider stack in two places: the one-entry keyed reset path and
the per-entry Activity retention list. The two stacks were identical except for
where the React key sat, so the retained-entry bookkeeping and its render shape
were tangled together and harder to audit than needed.

Extract BfcacheEntryProviders as a pure presentational helper and render it from
both paths. The keyed-reset path keeps its remount semantics: the key now sits on
the BfcacheEntryProviders element, so a fresh active stateKey still remounts the
whole provider subtree.

Hook ordering is unchanged. All hooks stay above the early returns in
BfcacheSlotBoundary, so the boundary still satisfies the Rules of Hooks across the
activeStateKey undefined-to-defined transition. Covered by the slot BFCache
entry-state suite in tests/shims.test.ts.

* test(bfcache): isolate cacheComponents BFCache coverage in a dedicated fixture

The back/forward-cache e2e suite ran against the shared app-basic fixture with
cacheComponents: true. app-basic backs the single app-router Playwright server
that every app-router spec uses, so enabling cacheComponents globally changed a
core DOM invariant for unrelated tests: inactive route trees stay mounted as
hidden Activity DOM. The contamination already leaked a :visible workaround into
the interception-modal bfcacheId spec, which had nothing to do with Activity.

cacheComponents is an opt-in semantic mode, not default app-router behaviour, so
it does not belong on the shared baseline fixture. Move the BFCache routes into a
dedicated tests/fixtures/app-bfcache fixture with cacheComponents: true, served by
a new app-router-bfcache Playwright project on port 4183, and run the
back-forward-cache spec there against the project baseURL. The :visible locators
stay in that spec, where duplicate hidden DOM is the expected contract.

Remove cacheComponents and the BFCache routes from app-basic so the shared fixture
models default behaviour again, and revert the :visible workaround in
use-router-bfcache-id.spec.ts now that the modal route no longer retains hidden
DOM. Fix the nested item page import while moving it: nested/[section]/item/[id]/
page.tsx imported ../counter, but the counter lives two levels up at
nested/[section]/counter.tsx, so it must be ../../counter.

Validation: PLAYWRIGHT_PROJECT=app-router-bfcache (5 passed) and the full
use-router-bfcache-id spec under app-router (11 passed), plus the slot BFCache
unit suite (1172 passed) and vp check (formatting + lint + types clean).

* ci: run the app-router-bfcache e2e project in the matrix

The dedicated app-router-bfcache Playwright project was added to
playwright.config.ts but not to the CI E2E matrix, so the back/forward-cache
specs never ran in CI. Add it to the matrix project list so the BFCache suite
runs on every push. It uses the default chromium browser, so the existing setup
and browser-cache steps apply unchanged.

* fix(bfcache): keep persisted slots mounted when cacheComponents is off

Without cacheComponents there is no Activity retention, yet BfcacheSlotBoundary
still keyed the active entry by its segment stateKey. That stateKey tracks the
pathname (createBfcacheSegmentIdentity), so any slot whose identity moves with
the URL (shared layouts, interception source slots) was remounted on navigation,
discarding client state that survives a normal route change. The baseline router
reconciles these slots in place.

This regressed seven existing app-router e2e tests once cacheComponents was no
longer masking it on the shared fixture: interception source-context and modal
preservation in advanced.spec.ts, and layout-persistence.spec.ts dynamic-segment
layout counter survival. Fresh-entry reset is driven by userland bfcacheId keying
in the fixtures, not by remounting the slot subtree, so the keying was pure
overreach in the non-cacheComponents path.

Render the single active entry unkeyed when cacheComponents is off so the boundary
reconciles in place like the baseline. The keyed Activity retention path is
unchanged for cacheComponents.

Validation: full app-router project 436 passed / 0 failed (the seven regressions
now pass); use-router-bfcache-id 11 passed (fresh-push reset intact); app-bfcache
5 passed (Activity retention intact); shims unit suite 1024 passed.

* docs(test): point bfcacheId spec comment at the app-router-bfcache project

The header noted that Activity-backed preservation is covered in
back-forward-cache.spec.ts, but that spec moved to the dedicated app-bfcache
fixture under the app-router-bfcache project. Name the project and path so the
cross-reference stays accurate.

* docs: align cacheComponents comment with unkeyed active-segment behavior

* test(bfcache): assert fresh bfcacheId after Activity-restored return

* style: collapse locator chains per oxfmt

* refactor(bfcache): split slot entry order from snapshots

* fix(bfcache): keep non-cacheComponents slot path baseline

* fix(bfcache): use string cacheComponents define

* test: remove external fetch from revalidate tag fixture

* fix(bfcache): harden Activity state key retention

* docs(bfcache): document cacheComponents Activity caveats

* fix(bfcache): preserve navigation context for fallback SSR

Fallback boundary rendering can outlive the original request-scoped navigation context. That made app SSR fail fast with a missing navigation context when loading-boundary digest routes rendered their HTTP fallback shell.

The SSR boundary contract still requires a navigation context for real request renders, but boundary renderers now synthesize the same typed shape from the request URL and matched params when request scope has already been cleared.

* refactor(bfcache): normalize pathname in state-key map, document invariant callers

Moves hydration-pathname normalization into createBfcacheSegmentStateKeyMap so SSR and client always produce byte-identical Activity keys. Documents the requireNavigationContext guaranteeing callers per review feedback.

* fix(bfcache): tolerate malformed state-key pathnames

* docs(bfcache): document known Next.js divergence and dead-branch contract

* chore(deps): update lockfile for vite 0.1.24

* fix(app-router): preserve encoded delimiters in bfcache keys

* docs(bfcache): track router-owned retention follow-up

* chore: retrigger ci

* fix(app-router): preserve redirect bridge fallback
2026-06-07 23:07:25 +01:00
Nathan Nguyen 7d280edfbe refactor(server): replace prerender HTML buffering with metadataReady contract (#1809)
* refactor(server): replace prerender HTML buffering with metadataReady contract

* fix(server): wait for allReady before piping through tick-buffered transform when waitForAllReady is true

* refactor(server): clean up AppSsrRenderResult normalization and metadataReady promise resolution

* test(server): cover ISR revalidation waitForAllReady; address review nits

- Add focused test asserting stale HTML cache regeneration passes
  waitForAllReady: true to handleSsr so Suspense fallbacks never leak
  into the cache entry
- Document why metadataReady resolves eagerly (allReady is already
  awaited for the prerender path) in app-ssr-entry.ts
- Remove stray blank line left by the readStreamAsText import removal
- Type capturedOnError in the prerender special-error test instead of any

* fix(pages): export PagesPageModule type consumed by pages-page-handler

The dts bundle build (vp pack) failed because pages-page-handler.ts
imports PagesPageModule from pages-page-data.js, but the type was not
exported, so rolldown-plugin-dts could not resolve it.

---------

Co-authored-by: James <james@eli.cx>
2026-06-07 21:27:23 +00:00
Nathan Nguyen 1a480016f6 fix(app-router): track searchParams access for static bailout (#1788)
* fix(app-router): track searchParams access for static bailout

App Router pages that awaited or used the searchParams prop during prerender could still be classified as static when the prerender request had an empty query string. That seeded no-query output into the deploy cache and served it for requests like ?search=hello.

The dynamic decision was based on query-string content instead of page prop access. Observe the searchParams thenable itself, including React use() status reads, and mark searchParams dynamic only when the prop is consumed.

* chore: rerun ci

* fix(app-router): avoid static cache for searchParams pages

* fix(app-router): tolerate synthetic page paths in manifest codegen

* fix(app-router): observe searchParams access for static bailout

* fix(app-router): preserve app page cache hits

* fix(app-router): observe loading search params during render

* refactor(app-router): extract searchParams probe fan-out into helper

Move the per-request page probe fan-out (matched page + parallel slot
pages + interception page) out of the generated RSC entry and into a
unit-testable buildAppPageProbes() helper in server/app-page-probe.ts,
keeping entries/app-rsc-entry.ts thin (AGENTS.md guidance). Addresses
bonk review concern #3 on #1788.

Document the known slot over-bail (probing inactive parallel-route slot
pages can mark an otherwise-static page dynamic; fails safe toward
dynamic) and track it as a caching-efficiency follow-up in #1798
(bonk review concern #1).

* fix(app-router): skip probing interception-overridden slot pages

Narrow the searchParams probe fan-out so it only probes page components
that actually render for the request. When an interception matches, it
replaces the page of the slot named by intercept.slotKey (the element
builder sets overrides[slotKey].pageModule to the interception page,
which wins over slot.page in app-page-route-wiring.tsx). We now probe
the interception page in place of that slot's own page instead of
probing both — probing the overridden slot page marked an otherwise
static request dynamic for a component that never renders.

Fixes the over-bail flagged in review of #1788.

* docs(app-router): clarify buildAppPageProbes slot precision

Address review feedback: the doc comment overstated precision without
explaining why non-overridden slots are exact. Spell out that a slot
with a page.tsx always renders that page (so probing it is correct),
default-only slots have no slot.page.default so probing is a no-op
(not an over-bail), and a default.tsx awaiting searchParams is
backstopped by the real render's observation (so no under-bail).

* fix(app-router): gate searchParams interception probe on isRscRequest

Interception only fires for RSC navigations (resolveAppPageInterceptState
returns kind:none when !isRscRequest, app-page-request.ts:324). The probe
fan-out called findIntercept unconditionally, so on HTML requests it would
skip the matched route's slot page (which DOES render normally) and probe
the interception page (which never renders). buildAppPageProbes now ignores
the interception match when !isRscRequest, so HTML requests probe every
slot's own page and skip the interception probe.

The source-route interception case (a different route renders) never reaches
this probe: dispatchAppPage returns the intercepted response before calling
probePage, so any interception seen here is the current-route override case.
Documented both in the buildAppPageProbes doc comment.

Addresses review feedback on #1788.

---------

Co-authored-by: James <james@eli.cx>
2026-06-06 23:47:47 +01:00
Stephen Zhou fb233b0a34 feat: improve dev error overlay source frames (#1746)
* Improve dev overlay stack source mapping

* Polish dev error overlay stack display

* Add dev overlay code frames

* Align dev overlay message spacing

* Add dev overlay error copy action

* Fix dev overlay knip exports

* Polish dev overlay layout

* Polish dev error overlay spacing

* Fix dev overlay coverage and source frame metadata

* Fix dev overlay recovery for RSC HMR errors

* Handle Vite HMR build errors in dev overlay

* Fix knip

* Use Shadow DOM for dev error overlay
2026-06-05 12:49:11 +01:00
Nathan Nguyen b8cecf8b46 fix(skip): centralize final static-layout skip rejection (#1722)
* feat(skip): reject runtime-unsafe layouts from static transport skip

The enable slice omits a static layout from RSC transport once it passes the
cache cross-check. The cross-check proves artifact and payload identity, but it
does not, on its own, assert that the layout's render was free of per-request
behaviour. A layout that reads params, calls a request API, sets a finite
revalidate, opens a cacheLife scope, reads unstable_cache, or issues cache-tagged
or dynamic fetches must never be reused across a sibling-route navigation.

This adds an explicit observation gate at the skip decision point. Before a
static layout is omitted, its per-layout observation is checked: an incomplete
probe, observed param keys, structural param scope, request APIs, finite
revalidate, cacheLife, unstable_cache, cache tags, cacheable fetches, or dynamic
fetches each reject the entry with a specific SKIP_LAYOUT_* code and fall the
layout back to render-and-send.

Scope note: the enable base already classifies most runtime-unsafe layouts as
dynamic (so they never enter the static skip set) and the cache cross-check
rejects payload-divergent reuse. This gate is a deliberate last line of defence
at the omission boundary, with diagnostic rejection codes. The tests assert the
end-to-end contract (unsafe layouts are never omitted); they pass on the enable
base alone, so this PR is kept as a draft pending rebase once the enable PR
merges.

* fix(skip): centralize final static-layout skip rejection

* refactor(skip): table-drive static layout rejection rules

* docs(skip): clarify !observation guards missing-tracker, not missing observation
2026-06-04 23:17:35 +01:00
Nathan Nguyen 610cf2e63a fix(app-router): honor per-response dynamic stale times on the client (#1712)
* fix(app-router): honor per-response dynamic stale times on the client

App Router navigation reused and evicted cached RSC responses using one global prefetch TTL, so pages with different unstable_dynamicStaleTime values could not stay fresh independently. Traversal also needed a route instance boundary so browser history restores hidden state instead of reusing a forward-navigation component instance.

The client cache now snapshots dynamic stale-time response metadata, computes expiry per entry, preserves staleAt across cache replays, and derives mounted-slot cache variants from the decoded payload. Browser traversal keeps route instances under React Activity while normal navigations allocate fresh route instances, matching Next's BFCache/stale-time contract.

* fix(link): make prefetch dedupe freshness-aware for navigation prefetches

The old prefetched.has(cacheKey) gate in prefetchUrl returned early for
any exact key match, including stale entries. This meant an expired
prefetch with the same cache key would never reach
hasPrefetchCacheEntryForNavigation, so it would never be deleted and
never refreshed.

The new helper is built to check resolvePrefetchCacheEntryExpiresAt,
delete stale entries, and return false. But the Link path bypassed it
when the old prefetched Set contained the exact key.

Fix: gate the exact-key check on the helper's freshness decision. When
the helper reports the exact entry is stale and deletes it, also remove
the key from the prefetched Set so the fall-through path can issue a
fresh fetch. Keep the second helper call for equivalent _rsc variants.

Add an integration test proving visible Links re-prefetch when their
exact cache entry goes stale, not just that the helper reports stale
entries as unavailable.

* refactor(app-router): collapse redundant prefetch/visited-cache gates

Address review feedback on the client dynamic-stale-time changes:

- link.tsx: the prefetch dedupe path called
  hasPrefetchCacheEntryForNavigation twice when an exact entry already
  existed. Collapse to a single freshness-aware gate that covers both the
  exact entry and equivalent _rsc variants.
- app-browser-entry.ts: drop the redundant early refresh return in
  getVisitedResponse; isVisitedResponseCacheEntryFresh already bypasses
  the cache for refresh navigations (and the miss path evicts the entry).

* docs(app-router): document intentional refresh eviction in visited cache

Per review feedback: getVisitedResponse now routes refresh navigations
through isVisitedResponseCacheEntryFresh (the single source of truth),
which returns false for refresh and therefore falls through to the read
eviction. Document at the fall-through delete that a refresh intentionally
drops any prior snapshot, since the navigation re-fetches and re-stores a
fresh one.

* docs(navigation): clarify load-bearing prefetch-cache dual checks

Per review feedback, document two checks that look superficially redundant
but preserve observable contracts:

- isPrefetchCacheEntryCompatibleWithMountedSlots: the two clauses accept
  either the entry's pinned request-time slot context or the server-declared
  snapshot header; they diverge in the prefetchRscResponse case.
- consumePrefetchResponse expiresAt guard: only synthesize expiresAt onto the
  returned snapshot when the entry already carried one, so entries without an
  explicit expiry round-trip unchanged for callers/tests.

---------

Co-authored-by: James <james@eli.cx>
2026-06-04 16:01:43 +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 8498e4a37e fix(app-router): emit per-page dynamic stale time metadata (#1711)
* fix(app-router): emit per-page dynamic stale time metadata

App Router pages that export unstable_dynamicStaleTime currently behave as if no page-level stale window exists. The server segment config resolver discards the export, so RSC responses cannot carry the per-page metadata that the client cache needs for independently configured pages.

The violated invariant is that route segment config resolution must preserve page-only stale-time metadata across active parallel pages before response construction. Parse the page export, reduce active page slots by shortest stale time, thread the value through generated App page dispatch, and emit it on RSC responses for the client cache to consume.

* fix(app-router): gate dynamic stale time header to dynamic-only renders

Only emit X-Vinext-Dynamic-Stale-Time when the render is truly dynamic
(not prerender or force-static), matching Next.js behavior where the
response `d` field is only set for non-static-generation renders.

The client treats the headers presence as authoritative; leaking it onto
static/prerendered RSC responses would cause the client cache to apply
dynamic stale time semantics to a statically-cached entry.

* test(app-router): add behavioral tests for dynamic stale time gating

* docs(app-page-render): add line-numbered link to Next.js source

* refactor(app-router): drop redundant Number.isFinite check

Number.isInteger already returns false for Infinity/NaN, so the
preceding Number.isFinite guard is redundant in both the dynamic
stale-time header check and resolveDynamicStaleTimeSeconds.

* fix(app-router): gate dynamic stale time on cache-capture predicate

The previous gating (isPrerender || isForceStatic) was too narrow for
production: isPrerender is only true during the build-time prerender
pass, so a production ISR render (revalidate > 0) still emitted
X-Vinext-Dynamic-Stale-Time even though that render is captured into the
ISR cache. Next.js gates on !workStore.isStaticGeneration; the runtime
analog here is shouldCaptureRscForCacheMetadata, so add it to the guard.

Adds production ISR coverage to lock down the gating.

* docs(app-router): document dynamic stale time over-gating boundary

Add a comment explaining that shouldCaptureRscForCacheMetadata is
computed from config before the stream runs, so a production
default-config route that turns dynamic via a late dynamic API loses the
advisory stale-time hint even though Next.js (!isStaticGeneration) would
emit it. The value is unknowable until after headers are built.

Adds a complementary test pinning down this boundary behavior.

---------

Co-authored-by: James <james@eli.cx>
2026-06-02 16:23:46 +01:00
Divanshu Chauhan (divkix) 69165d4557 fix(app-page-render): surface invalid dynamic usage errors via flight in dev (#1290)
Wraps the RSC response body in dev mode to check for invalid dynamic usage errors (e.g. cookies()/headers() inside "use cache") after the stream is fully consumed. This mirrors Next.js behavior where workStore.invalidDynamicUsageError is surfaced via the Flight pipeline so errors caught by user try/catch don't silently disappear.

- Dedups against React's Flight error chunk using digest presence

- Only applies to RSC responses (client-side navigations) in dev

- HTML path intentionally deferred; error still flows through rscErrorTracker

- Ported from Next.js: https://github.com/vercel/next.js/commit/f5e54c06726b571a042fce67417e40a29f6b8689

Closes #1195
2026-05-18 11:10:40 +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 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 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 38e6b9f6c2 fix(app-router): bypass app page cache in draft mode (#1123)
App Page ISR could serve a cached production response to a request carrying a valid __prerender_bypass cookie. That violates Next.js draft-mode semantics because preview users must render dynamically and must not populate shared page cache entries.

The dispatch layer now identifies draft-mode requests with the same cookie parser and secret used by draftMode(), skips App Page cache reads, and passes that state into render response policy. HTML and RSC render paths now treat draft mode as no-store and avoid cache capture/write work.

Tests cover valid draft cookies bypassing cached HTML, arbitrary cookie values preserving cache hits, and response-policy no-store behavior.
2026-05-07 11:23:33 +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 f45fce00d5 fix(isr): honor route expire ceilings (#961)
* fix(isr): honor route expire ceilings

Track expireAt alongside revalidateAt in the memory and KV cache handlers so ISR entries past their expire ceiling become blocking misses instead of stale responses.

Plumb expireTime and request cacheLife expire values through App Router, Pages Router, prerender seeding, and cache writes while keeping generated entries as thin app-shape wiring over normal server modules.

Match Next.js cache-control semantics for finite stale-while-revalidate windows when an expire value is known.

* fix(isr): address cache life review follow-ups

* chore: address latest review follow-ups

* ci: pin vite-plus setup version

* Revert "ci: pin vite-plus setup version"

This reverts commit ee1d1d4e8db7b7406734414c53fa1b79f955a4d1.

* fix: avoid blocking ISR page streams on cache metadata

* fix: preserve headers for speculative cacheLife probes

* fix: preserve prerender cacheLife metadata

* fix: preserve legacy ISR cache metadata behavior

* fix: preserve prerender seed revalidate context

* fix: harden app page cache policy metadata

* fix: resolve app router prerender conflict
2026-05-02 19:54:25 +01:00
Divanshu Chauhan (divkix) d45c5905f3 perf: fuse RSC stream tees reducing allocs from 3 to 2 (#984)
* perf: fuse RSC stream tees reducing allocs from 3 to 2

ISR cache path teed the RSC stream twice (once for capture,
once for embed). Fuse into single tee: sideStream serves both
embed transform and raw byte accumulation via createRscEmbedTransform.

Tee 1 (capture) and Tee 2 (embed) now share one tee. Tee 3 (HTML
cache) remains separate — it operates on post-SSR HTML with a
conditional write decision.

Fixes #981

* fix: address PR review feedback for fused RSC stream tees (#981)

- Fix AppPageSsrHandler type to include sideStream and capturedRscDataRef
- Clear rawChunks after getRawBuffer() to reduce peak memory usage
- Remove misleading no-op null assignment in handleSsr else branch
- Guard await null in ISR cache write to prevent undefined in cache

* fix: repair CSP nonce handling and update snapshots for #981

- Fix renderAppPageHtmlStream to always pass ssrOptions (including scriptNonce)
  to handleSsr, instead of conditionally when sideStream is defined. This fixes
  CSP nonce regressions in dev mode.
- Update 6 snapshot occurrences in entry-templates.test.ts.snap to match the
  updated __revalCapturedRscRef.value access pattern with null safety.

Fixes CI failures for the fuse RSC stream tees PR.
2026-05-01 08:49:58 +01:00
Nathan Nguyen cf5471e95a fix(app-router): fixing cache request leaks (#907) 2026-04-29 14:59:14 +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
Stephen Zhou 14ff303cbd feat: csp support (#795)
* fix: support csp

* tweaks

* tweaks

* tweaks

* tweaks

* tweaks

* tweaks

* clean

* tweaks

* clean

* fix test

* tweaks

* fix lock

* fix test

* trigger ci

* tweaks

* tweaks
2026-04-09 09:14:13 +01:00
James Anderson 1ff166eb92 chore: turn on more lint rules (#714)
* chore: turn on more lint rules

* fmt

* add todos
2026-03-29 20:40:57 +01:00
James Anderson 8e58ead4b0 fix: defer clearRequestContext() until HTML stream is fully consumed (#660) (#661)
* fix: defer clearRequestContext() until HTML stream is fully consumed

headers()/cookies() throw on warm requests because clearRequestContext()
was called synchronously after receiving the RSC/SSR stream handle, before
any Server Components had actually executed. The RSC/SSR pipeline is lazy —
components run while the response body is pulled by the HTTP layer.

Fix: pipe the HTML stream through a TransformStream whose flush() callback
calls clearRequestContext(), deferring cleanup until the last byte is
consumed. Eagerly-read values (draftCookie, consumeDynamicUsage, etc.) are
still captured synchronously before the stream is returned.

Two sites fixed:
- server/app-page-render.ts (renderAppPageLifecycle, covers both the plain
  and ISR-cacheable HTML paths)
- server/app-page-stream.ts (renderAppPageHtmlResponse)

Regression tests added for both sites.

Fixes #660

* fix: guard against stream-cancelled crashes in tick-buffered transform and early-disconnect cleanup

- createTickBufferedTransform: wrap the setTimeout callback in try/catch so
  that a stream cancellation between scheduling and firing (e.g. a health-check
  that calls res.body.cancel()) does not throw ERR_INVALID_STATE and crash the
  Vite dev server process.
- deferUntilStreamConsumed: add a cancel() handler so clearRequestContext() is
  still called when the HTTP consumer drops the connection before the stream is
  fully drained, preventing per-request context leaks on early disconnect.
2026-03-23 08:41:04 +00:00
Steve Faulkner 114ebac08d refactor: extract app page render lifecycle (#638)
* refactor: extract app page render lifecycle

* test: refresh page render lifecycle generator assertions
2026-03-22 03:37:48 +00:00