Commit Graph

19 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 930e03c449 fix(ppr): gate fallback shells until request-time resume is supported (#1716)
* fix(app-router): prerender cacheComponents root-param fallback shells

PR 1 of 4: core model + build integration.
Model: app-ppr-fallback-shell, pregenerated-concrete-paths, prerender-manifest
Build: prerender/run-prerender fallback shell artifact generation
Tests: createAppPprFallbackShells, pregenerated-concrete-paths core

* feat(ppr): add fallback shell payload identity

* feat(ppr): safely serve fallback shell cache entries

* refactor(ppr): extract fallback shell render pipeline and dedupe getViteMajorVersion

* fix(ppr): restore safe-serving CI checks

The safe-serving branch failed CI because a client-side server-only guard rejected valid 'use server' action modules, a fallback-shell dependency cast no longer matched its helper type, apps/web lost the Cloudflare cache adapter source path during typecheck, and Knip could not resolve the documented Cloudflare cache adapter subpaths.

The server-only guard now reads the directive prologue and skips top-level 'use server' modules, fallback-shell regeneration passes an explicit typed dependency object, apps/web gets the workspace Cloudflare cache path, the Cloudflare package exports explicit adapter subpaths, Knip uses a relative source import in the dispatch unit test, and the readiness test awaits the actual cache-ready promise.

* fix(ppr): guard fallback shell reads for queries

* fix(ppr): normalize pregenerated concrete paths

* fix(ppr): address review comments on fallback shell serving and react runtime check

* fix(ci): restore knip ignores for unlisted binaries and prune dependencies

* fix(ppr): restrict bracket check in fallback detection to legacy manifests

* fix(ppr): prefer stable ESM react-dom/static.edge before CJS fallback

The loadStaticPrerender dev-mode path hard-codes react-dom's internal
CJS file layout (cjs/react-dom-server.edge.development.js). A React
upgrade could silently break fallback-shell rendering in dev without
affecting production because the function never tried the stable ESM
entry in development.

Change the order so that react-dom/static.edge is attempted first in
all environments. Only fall back to the CJS path when the ESM entry
does not export prerender() and we are in a development runtime. This
lets future React dev builds that expose prerender() from the ESM entry
work automatically, and it surfaces the CJS-path failure only when the
stable path is genuinely unavailable.

Also handle the CJS default-export interop shape that Node.js ESM
produces when dynamically importing a CommonJS module.

* refactor(ppr): address four review cleanup items

1. Remove redundant double-normalization in seed-cache.ts
   addPregeneratedConcretePath already normalizes internally, so the
   call-site normalizePregeneratedPathname() was redundant. Dropping it
   gives a single source of truth for pathname normalization.

2. Lazily construct FallbackShellRenderDeps in tryServePprFallbackShell
   Extract the large FallbackShellRenderDeps object into a top-level
   buildFallbackShellRenderDeps helper. The closure in the probe loop
   now just delegates, so the heavy object is only created when a STALE
   entry actually triggers regeneration, not on every HIT probe.

3. Add dev-only warning for legacy bracket heuristic
   When isFallbackShellArtifactPath falls back to the bracket substring
   scan (route.fallback === undefined), emit a console.warn in dev so
   developers know a legacy manifest is being used and concrete URLs
   with literal brackets may be misclassified.

4. Update pregenerated-concrete-paths.test.ts to colon notation
   The production manifest writer uses colon notation (/:slug) for the
   route field, but the test fixtures used bracket notation (/[slug]).
   Update the fixtures so the test format matches production, exercising
   the real key format that the safe-serving guard depends on.

* test(ppr): compact fallback shell dispatch fixtures

* refactor(ppr): name fallback shell render phases

* refactor(app-router): deduplicate PPR fallback shell and app page cache render paths

* fix(ppr): avoid serving incomplete fallback shells

* chore: drop redundant cloudflare cache aliases

* fix(ppr): track fallback navigation hooks

* test(ppr): render navigation hook probes

* test(ppr): type navigation hook probes

* refactor(ppr): remove unused shell regeneration path

* refactor(ppr): remove unused shell result type

* fix(ppr): safely gate fallback shell prerendering

* fix(ppr): keep fallback shell gate internal

* fix(ppr): mark generated dynamic fallback shells

* test(ppr): align fallback fixture and search params

---------

Co-authored-by: James <james@eli.cx>
2026-06-14 00:44:54 +01:00
James Anderson 3ba2528c14 fix(app-router): preserve page notFound precedence during metadata failure (#1957) 2026-06-12 23:18:27 +01:00
Nathan Nguyen 5ac620c2d0 fix(cache): honor fetch opt-outs and force-dynamic revalidate parity (#1907)
* fix(cache): honor fetch opt-outs in app page output

App Router fetches that explicitly opt out of caching could still leave the rendered page eligible for static output, while dynamic = "force-dynamic" was lowered to fetchCache = "force-no-store" and overrode explicit per-fetch cache/revalidate options.

The cache decision was conflating route-level dynamic rendering with a hard segment fetchCache mode. Next.js treats force-dynamic as a default no-store mode only for fetches without explicit cache or revalidate config, and marks explicit uncached fetches as dynamic for the surrounding render.

Mark explicit uncached fetch decisions through the existing dynamic usage signal, keep force-dynamic as a separate per-request fetch default, and preserve Response.url when reconstructing cached fetch responses.

* fix(cache): correct revalidate:false and Response.url semantics

- Split revalidate: false out of the no-store path; it now caches
  indefinitely (INFINITE_CACHE) matching upstream patch-fetch.ts.
- Store actual response.url (freshResp.url / response.url) in cached
  fetch entries instead of the input request URL.
- Remove markUncachedFetchForPageOutput() from the auth-header fallback
  so it matches upstream's autoNoCache path and does not mark pages
  dynamic.
- Update tests to reflect correct revalidate: false caching behavior
  and add a test for redirect-style response URLs.

* fix(app-router): resolve target route dynamic config during intercepts and revalidation

Prevents the outer route's force-dynamic default from leaking into
interception source routes and ISR revalidation targets.

- app-rsc-entry.ts: expose __resolveRouteDynamicConfig
- app-page-dispatch.ts: pass resolveRouteDynamicConfig to dispatch,
  sync currentForceDynamicFetchDefault when the render target changes
- app-page-dispatch.test.ts: 4 tests covering intercept + revalidation
  paths for both lost and leaked defaults

* fix(app-router): sync fetchCache and force-dynamic default during action redirects/re-renders

* fix(cache): treat revalidate:false as non-explicit for force-dynamic parity

Upstream patch-fetch.ts computes noFetchConfigAndForceDynamic using
!currentFetchRevalidate (truthiness), so revalidate: false is treated
as 'no fetch revalidate config' and force-dynamic wins. vinext's
hasExplicitRevalidateValue() previously returned true for false,
blocking the force-dynamic no-store default and causing the fetch to
cache for 1 year instead.

Update hasExplicitRevalidateValue to exclude false (and 0) from the
explicit set, aligning with upstream's truthiness check. Add a parity
test covering force-dynamic + next.revalidate: false.

* fix(cache): split revalidate predicates for force-dynamic vs segment defaults

The previous fix to hasExplicitRevalidateValue() treated false and 0 as
non-explicit, which matched upstream's force-dynamic truthiness check
but broke segment cache defaults: default-cache would override
revalidate: 0 to force-cache, and default-no-store would override
revalidate: false to no-store.

Split into two helpers:
- hasExplicitRevalidateValue(): any defined value is explicit (for
  segment defaults where 0 and false are explicit opt-outs)
- isFalsyRevalidate(): false and 0 are falsy (for force-dynamic's
  noFetchConfigAndForceDynamic parity check)

Add parity tests for default-cache + revalidate: 0 and
default-no-store + revalidate: false.

* refactor(cache): extract ONE_YEAR_SECONDS and cover only-no-store revalidate:false

* fix(cache): keep dynamic fetch observations for auth bypass and cache-key fallback

Restore recordDynamicFetchObservation in the auth-header safety bypass so
auth-keyed fetches still downgrade the page output to fresh render, and
revert the cache-key-generation fallback to a plain observation so an
internal limitation (oversized/unserializable body) does not mark the
whole page dynamic. Lock in force-dynamic vs explicit segment fetchCache
precedence with a test.

* docs(cache): note deliberate no-cache dynamic-marking divergence from upstream

* fix(route-handler): apply segment fetchCache and force-dynamic fetch default to route handler dispatch

Route handlers never called setCurrentFetchCacheMode /
setCurrentForceDynamicFetchDefault, so a force-dynamic route handler did
not get the per-request no-store fetch default that page dispatch applies,
and an explicit fetchCache export on a route handler module was ignored.

Upstream's app-route module copies userland.fetchCache into the work store
and sets workStore.forceDynamic for dynamic = "force-dynamic", which
patch-fetch turns into a no-store default for fetches without explicit
cache config — for route handlers as well as pages. Mirror that in
dispatchAppRouteHandler and re-apply the state inside the background
regeneration request context.

* fix(fetch-cache): tolerate cached entries missing url and document revalidate:false cacheability

* fix(fetch-cache): fall back to the request URL for cached entries missing url

Reconstructing Response.url from the fetch input preserves the
pre-existing guarantee for legacy/foreign cache entries instead of
degrading to "". Also document why the intercept dispatch deliberately
does not save/restore fetch defaults around buildPageElement.

* docs(fetch-cache): note cached responses now join body-stream cleanup

* docs(fetch-cache): note explicit segment fetchCache beats force-dynamic default

* test(fetch-cache): pin tags-only fetch to no-store under force-dynamic

* docs(fetch-cache): document auth-bypass precedence and test no-store + auth

- Note at the auth-safety bypass that explicit no-store/no-cache/revalidate: 0
  takes the stronger branch first and fully marks the page dynamic; the bypass
  only covers implicitly-cacheable auth-keyed fetches.
- Add a test locking in that explicit no-store with auth headers routes through
  markUncachedFetchForPageOutput rather than the softer auth bypass.
- Clarify in dispatchAppRouteHandler that the fetch-cache setters are new
  wiring for route handlers, not a mirror of a pre-existing pattern.

* test(route-handler): lock no-store fetch bailing ISR for revalidating handlers

A route handler with revalidate = 60 that performs
fetch(url, { cache: "no-store" }) must skip its ISR cache write and be
marked known-dynamic, now that the patched fetch's explicit no-store
branch calls markDynamicUsage() (upstream patch-fetch parity). Exercises
the real fetch-cache shim and the real headers-shim dynamic-usage pair,
matching the app-route-handler-dispatch wiring.

---------

Co-authored-by: James <james@eli.cx>
2026-06-12 11:44:28 +01: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 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 9bab92f012 test(deploy): cover generated worker package exports (#1807)
Generated worker entries can import vinext subpaths that must resolve from the published package. Missing package exports break consumers even when the generated template itself looks correct.

Add deploy coverage that extracts vinext subpath imports from generated App and Pages worker entries and verifies each one is covered by packages/vinext/package.json exports.

Tighten the prefer-shared-utils lint rule so copied shared helpers in repo tests are reported too. Test files mask template literal fixture payloads, while source files still scan generated-source templates.
2026-06-07 21:58:41 +01: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
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 49d2f0966d feat(skip): plumb client reuse manifests through the app request path (#1717)
* 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>
2026-06-02 20:25:25 +01:00
Nathan Nguyen bdbd1f833d fix(app-router): preserve source identity for intercepted renders (#1589)
* fix(app-router): preserve source identity for intercepted renders

Intercepted App Router renders used the visible target pathname as the payload route identity. That let a modal navigation such as /feed to /photos/42 replace the retained source tree with target-scoped route and page ids, so refreshes, server actions, chained modals, and history traversal could lose source page state.

The violated invariant is that an intercepted render has separate display and matched-route identities. This commits a fenced render identity helper, keeps source route and page ids stable for retained source-route payloads, leaves the target pathname in interception proof, and includes source context in RSC cache variants.

Regression coverage asserts source payload keys, cache variants, modal refresh/action/history flows, and source client/search state preservation.

* fix(app-router): normalize intercepted rsc cache source

* fix(app-router): harden intercepted cache regeneration

* refactor(app-router): share intercepted path normalization

* fixup! fix(app-router): preserve source identity for intercepted renders

fixup! fix(app-router): preserve source identity for intercepted renders
2026-05-28 12:42:01 +01:00
Nathan Nguyen a0b8ae068a fix(draft-mode): keep draft secret out of client defines (#1592)
* fix(draft-mode): keep draft secret out of client defines

The draft-mode bypass token was emitted through Vite define, so Vite's client env payload and client chunks could contain the server-only cookie secret when app code referenced that env key.

The violated boundary was treating a server runtime credential like a shared compile-time define. Generate one plugin-scoped secret and initialize server entries with it, while next/headers reads only internal server state.

Adds regression coverage for dev env exposure, production client bundle leakage, and the built server draft cookie round trip.

* test(draft-mode): initialize secret after module reset

* fix(draft-mode): scope draft secret to request context

Draft mode validation briefly lived in a package-global singleton after removing the client-visible define. That made multiple app instances in the same process able to overwrite each other's validator.

Thread the generated app secret through the App Router request and dispatch boundaries, store it on HeadersContext, and make cache bypass validation a pure request-plus-secret check. Add a regression that proves two contexts keep independent draft validators.

* fix(draft-mode): guard stale draft controls
2026-05-26 15:00:44 +01:00
Nathan Nguyen c84e383ccd fix(server): render progressive action not-found HTML (#1254)
* fix(server): render progressive action not-found HTML

Progressive multipart server actions returned an empty status response when an action called notFound() or another HTTP fallback. That diverged from Next.js' MPA action path, where the action error re-enters app rendering so the not-found boundary produces full HTML.

The root cause was treating HTTP fallback action throws as terminal responses in the action execution helper. Pass action failures through the progressive form-state path, preserve redirects as 303 responses, and rethrow action failures during page element construction so existing special-error rendering handles the response body.

Regression coverage asserts the action helper handoff and the rendered not-found HTML body.

* chore: retrigger CI

* fix(server): forward progressive action failures in RSC entry

* refactor(server): make progressive action failure state explicit

* Apply suggestion from @james-elicx

---------

Co-authored-by: James Anderson <james@eli.cx>
2026-05-16 17:04:15 +00: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 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 937f5d6a09 fix(app-router): read indefinite app page cache entries (#1129)
* fix(app-router): read indefinite app page cache entries

App pages with revalidate=false currently normalize to Infinity, but the app-page cache read gate excludes Infinity. Pre-rendered static app pages can be seeded into the ISR cache and still miss that cache on every production request.

The cache read policy now treats positive Infinity as read-eligible while preserving force-dynamic, production, RSC, and nonce guards. Cached indefinite responses are normalized to the same static one-year Cache-Control header used by fresh revalidate=false responses, so cache hits do not emit s-maxage=Infinity.

Tests cover the dispatch cache hit path and the shared cached Cache-Control helper.

* chore: rerun ci

* chore: rerun ci
2026-05-07 11:23:28 +00: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 de00ed2e68 refactor(app-router): extract app page dispatch (#986)
* refactor(app-router): extract app page dispatch

App Router page requests now enter a shared dispatch module after generated route matching. The generated RSC entry still describes route modules, app-shape callbacks, and request-local values, but cache reads, static generation context, dynamic param checks, intercept responses, and lifecycle rendering move into normal typed code.

This keeps page orchestration out of codegen without changing the observable App Router behavior covered by the integration suite.

* chore: rerun ci
2026-05-01 23:14:04 +01:00