The progressive (form-state) non-redirect server-action path returned the
raw `getAndClearPendingCookies()` array, so calling `cookies().set(name, ...)`
twice for the same name emitted two `Set-Cookie` headers for that name on
no-JS form submissions. Next.js stores mutable cookies in a name-keyed
`ResponseCookies` map (last value wins) and collapses to one header, as do the
redirect and RSC action paths in vinext via `dedupePendingCookies()`.
Wrap the non-redirect branch's cookie collection in `dedupePendingCookies()`
to match the sibling paths, so same-name sets collapse to a single Set-Cookie
(last value wins). The single binding feeds both the action-failed and success
returns, covering both non-redirect sub-cases.
Follow-up to the #1481 dedup fix, which landed on the redirect and RSC paths
but missed the progressive non-redirect branch.
Adds a regression test mirroring the existing redirect-dedup test for the
non-redirect form-state path.
* 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>
* fix(actions): return 404 for MPA action on app with no server actions (#1340)
A multipart form POST to an App Router page is always a server-action
attempt. When the form body decodes to no action at all (e.g. the build
has no server actions, so decodeAction returns null rather than
throwing), vinext fell through to a 200 page render instead of Next.js'
404 + x-nextjs-action-not-found. The fetch-action variant (via the
Next-Action header) already worked; only the MPA/no-JS form-POST variant
was wrong.
Gate the new 404 on the posted-to route being a page: route handlers run
after action dispatch and legitimately accept raw multipart POSTs, so
they still fall through. The page-vs-route distinction uses the static
__loadPage / __loadRouteHandler markers, available before lazy module
hydration.
Finishes the no-server-actions MPA case of #1340 (error-boundary,
content-type, and unrecognized-action handling landed in #1386/#1668).
* test(actions): add MPA no-action integration test; skip redundant route match (#1340)
Address ask-bonk review on #1853:
- Add a dev-server integration test that POSTs multipart/form-data to a
page (/about) and asserts 404 + x-nextjs-action-not-found, locking in
the entry-side matchRoute + __loadPage/__loadRouteHandler classification
end-to-end (previously only unit-tested by passing hasPageRoute directly).
- Gate the progressive-action route match on isProgressiveServerActionRequest
so matchRoute no longer runs on every App Router request — only for actual
multipart/no-actionId POST candidates.
* fix(actions): enforce serverActions.bodySizeLimit on fetch actions (#1828)
Oversized fetch (client-invoked) server-action requests were rejected with
a bare 413 Payload Too Large response, which bypasses the client error
boundary and surfaces the wrong status/content-type. Next.js instead throws
the body-exceeded error before the action runs and, for fetch actions, emits
a 500 Flight response carrying the rejected action result so the nearest
error boundary catches it.
Mirror that: when an RSC fetch action body exceeds the configured limit
(content-length pre-check or stream read), render a Flight stream with
returnValue { ok: false } and no page root, returning status 500. The
no-JS progressive form path keeps its 413 (no client boundary to catch it).
Closes#1828
* fix(actions): use verbatim bodySizeLimit string in body-exceeded error (#1828)
Address bonk review: the body-exceeded error message must contain the
verbatim serverActions.bodySizeLimit config string (e.g. "2mb") to match
Next.js byte-for-byte — the upstream suites assert the log message literally
('Body exceeded 2mb limit'). The previous reconstruction from the parsed byte
count produced '2 MB', diverging on casing and spacing.
Thread serverActionsBodySizeLimitLabel (the verbatim config value, defaulting
to "1mb") through next-config -> index -> app-rsc-entry -> the RSC action
handler options, and use it directly in createBodyExceededError. Removes the
lossy formatBodySizeLimit reconstructor. Tests assert the verbatim string.
* fix(actions): default bodySizeLimit label to Next.js' '1 MB' literal (#1828)
Address bonk re-review nit: when serverActions.bodySizeLimit is unset, Next.js
emits 'Body exceeded 1 MB limit.' (its defaultBodySizeLimit = '1 MB' literal).
Default serverActionsBodySizeLimitLabel to '1 MB' (was '1mb') so the
unconfigured-case message matches upstream byte-for-byte too.
When a server action id resolved from request headers carries the
`#<exportName>` suffix (e.g. `/app/foo.ts#bar`), `loadServerAction()`
splits the id at `#` and calls `requireModule(file)` with the module
path only. `@vitejs/plugin-rsc`'s reference-validation virtual module
therefore emits `[vite-rsc] invalid server reference '/app/foo.ts'`,
omitting the `#<exportName>` portion. The previous match in
`isServerActionNotFoundError` compared the full actionId against the
Vite error string, so missing/stale references surfaced as a generic
500 instead of the Next.js-compatible 404 with the
`x-nextjs-action-not-found` header.
Also accept the pre-`#` module-path form for both the dev-time
`[vite-rsc] invalid server reference '<id>'` and prod-time
`server reference not found '<id>'` error shapes.
Adds a focused unit test reproducing the dev-mode case and integration
tests asserting that throwing `notFound()` from a server action returns
404 (both fetch and progressive form paths), porting fixture from
Next.js: test/e2e/app-dir/actions/app/server/form.js.
Multiple `cookies().set(name, ...)` calls on the same name inside a server
action emitted multiple Set-Cookie response headers, diverging from
Next.js's `MutableRequestCookiesAdapter`, whose underlying `ResponseCookies`
Map collapses repeated sets to the final value per name.
Apply `dedupePendingCookies` at every cookie-emitting action exit (form
action redirects, fetch action redirects, fetch action responses) so the
last write per name wins and insertion order is preserved.
Reference: packages/next/src/server/web/spec-extension/adapters/request-cookies.ts
Refs #1481.
When a progressive (no-JS) server action POST runs without redirecting, the
pending `cookies().set(...)` / `draftMode().enable()` mutations made during
the action were dropped on the floor — the form-state result returned from
`handleProgressiveServerActionRequest` never read them out of the
request-scoped headers context, and the subsequent page rerender never
flushed them onto the response.
Mirror Next.js' `res.setHeader('set-cookie', ...)` flush in
`action-handler.ts` / `app-render.tsx`: capture pending cookies, the draft
cookie, and the resolved revalidation kind at the end of the non-redirect
action path, expose them on the form-state result, then append them to the
rerender response (and the `x-action-revalidated` marker) in
`app-rsc-handler`. Falls back to rebuilding the response when its headers are
immutable so cookies still ride a `redirect()` issued during the rerender.
Fixes#1483.
Extends #1565: the merged change gated the marker on the resolved segment
config, but only covered the two `buildAppPage*Response` builders. Five
other response construction paths bypassed those helpers and skipped the
header for edge-runtime routes:
- `app-page-cache.ts` — ISR HIT/STALE responses
- `app-server-action-execution.ts` — server action RSC responses
(redirect, no-revalidate, re-render)
- `app-page-boundary.ts` — boundary RSC responses
- `app-page-execution.ts` — 200 + flight-payload redirect responses
- `app-page-stream.ts` — HTML stream responses used by the boundary path
Centralizes the gating in a single `applyEdgeRuntimeHeader` helper so
every site shares one source of truth for header name + condition, and
threads `isEdgeRuntime` through the fallback renderer + dispatch options
so each construction site receives it.
Adds positive + negative coverage for each newly-covered path.
Treat both build-time error shapes raised by `@vitejs/plugin-rsc` when a
server-action reference is missing from the manifest — dev's
`[vite-rsc] invalid server reference '<id>'` and prod's
`server reference not found '<id>'` — as Next.js action-not-found.
Previously only the dev-mode message was recognised, so production
builds (and apps with no server actions defined at all) returned 500
instead of Next.js' 404 + `x-nextjs-action-not-found: 1` response.
Both the fetch-action and progressive (no-JS) code paths benefit, since
the latter calls `isServerActionNotFoundError(error, null)` before it
has resolved an action id.
Refs #1340
* feat(server-action): add x-action-forwarded guard to prevent forwarding loops
Defensively implements the Next.js server action forwarding loop fix.
- Adds ACTION_FORWARDED_HEADER constant to headers.ts
- Adds x-action-forwarded to INTERNAL_HEADERS blocklist
- Adds guard to handleServerActionRscRequest and handleProgressiveServerActionRequest
- Returns 404 + x-nextjs-action-not-found when header is present
Ported from Next.js: vercel/next.js@20892dd
* test(e2e): add action-forward-loop fixtures and tests
- Adds /nextjs-compat/action-forward-loop fixture with middleware rewrite
- Adds /nextjs-compat/action-forward-loop-rewrite as rewrite target
- Adds error boundary component using unstable_isUnrecognizedActionError
- Adds E2E tests: middleware rewrite + action POST, not-found header contract
* test(e2e): fix action-forward-loop tests per review feedback
- page.tsx: use useActionState so action result renders into #action-result
- actions.ts: update runAction signature for useActionState compat
- Remove dead error-boundary.tsx (never exercised in single-worker model)
- Rename second test to match actual behavior (stale action ID -> 404)
* test(request-pipeline): add x-action-forwarded to INTERNAL_HEADERS snapshot
* 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>
* refactor(navigation): extract UnrecognizedActionError into a shared module
`UnrecognizedActionError` and `unstable_isUnrecognizedActionError` were
defined inline in the `next/navigation` shim. The client server-action
dispatcher needs to throw that exact class for the predicate's
`instanceof` check to recognize it, but `instanceof` is identity-based
per module instance, and importing the whole navigation shim into the
dispatcher would be wrong layering.
Move both into a dependency-free `shims/unrecognized-action-error.ts`,
mirroring Next.js's own `unrecognized-action-error.ts`. The navigation
shim re-exports them, so the public `next/navigation` surface is
unchanged.
No behavior change; existing predicate tests still pass.
* fix(app-router): throw a typed error for unrecognized server actions
When a server action POST returns the `x-nextjs-action-not-found`
response header, the App Router client dispatcher threw a plain `Error`.
`unstable_isUnrecognizedActionError` is an `instanceof UnrecognizedActionError`
check, so it returned `false` for the framework's own action-not-found
path. Client `catch` blocks and error boundaries that rely on the
predicate to detect client/server deployment skew never fired, which is
the exact case the predicate exists for.
Next.js's server-action reducer throws `new UnrecognizedActionError(...)`
on this same header. vinext exposed the class and predicate but never
constructed the error from its dispatcher.
Add `throwOnServerActionNotFound`, the client-side counterpart of
`createServerActionNotFoundResponse`, and call it from the client
server-action callback so the thrown error is recognizable. The two
internal helpers it subsumes are no longer exported.
Tests feed the real server-emitted response through the helper and
assert the public predicate recognizes the result.
* 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.
* fix(app-router): hard navigate stale RSC build payloads
App Router soft navigation currently accepts any valid RSC payload after deploy. That lets stale tabs decode RSC responses produced by a different build, which can break client navigation when the server and browser artifacts no longer match.
The missing invariant was that every RSC response consumed by the browser must identify the build that produced it, and the browser must reject payloads from a different build before handing them to React.
Emit a vinext-owned build ID header on App Router RSC responses, preserve it in prefetched and visited response snapshots, and hard navigate when a response is missing or mismatches the current client build ID.
* chore: fix e2e fixture hook lint suppression
* chore: avoid hook-like fixture callback name
* fix(app-router): preserve build IDs for cached RSC payloads
* fix(app-router): use public RSC compatibility IDs
* fix(app-router): preserve UI during action refresh
Server action responses currently always render a page payload, and router.refresh can put BrowserRoot into a pending state that mounts loading.tsx while the same route refresh resolves. That diverges from Next.js, where non-revalidating actions can skip page rendering and refresh/revalidation should preserve the current page UI.
The server action path now omits the root payload when no data was invalidated, suppresses loading boundaries for action rerenders, and carries the same suppress-loading variant through RSC request, cache-busting, and ISR cache keys. The browser action callback now resolves no-root action payloads directly and avoids pending router state for refresh.
Regression coverage includes the no-rerender action payload, loading-boundary suppression, cache key variants, cache-busting compatibility, request normalization, and an app-router Playwright case that catches route loading mounts after action plus router.refresh.
* fix(app-router): carry semantic RSC render modes
Replace the cross-layer suppress-loading boolean with AppRscRenderMode so browser requests, request normalization, rendering, cache-busting, Vary, and ISR keys all carry the same semantic payload variant.
Decode server action responses before clearing client navigation caches so no-root return-only action results keep visited response and prefetch caches intact while redirects, root payloads, and legacy bare payloads still invalidate.
Add coverage for refresh() inside server actions and for normal versus preserve-current-UI ISR RSC cache keys.
* fix(app-router): default helper render mode to navigation
* docs: add review-requested comments for structural discriminator and ISR key format
* fix: add explicit null case to parseAppRscRenderMode switch for exhaustiveness check
---------
Co-authored-by: James <james@eli.cx>
* fix(app-router): 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
* fix(app-router): emit action revalidation headers
Server Actions that call revalidatePath, revalidateTag, updateTag, refresh, or mutate cookies currently return RSC responses without x-action-revalidated. The client router therefore keeps stale cached payloads after an action invalidates data.
The action execution path assumed cache invalidation only needed to touch the server-side cache handler. Record action-scoped revalidation in the cache shim, consume it when building action responses, and preserve Next.js' static-and-dynamic versus dynamic-only header values.
Tests cover normal action rerenders, redirecting actions, and refresh dynamic-only invalidation.
* fix(app-router): match profiled tag action revalidation
Server Actions using revalidateTag with a cacheLife profile should not trigger x-action-revalidated. Next.js treats profiled tag revalidation as stale-while-revalidate and does not force immediate client router cache invalidation.
Only record action revalidation for the no-profile revalidateTag form, keep the action marker cleanup symmetric on error, and cover the profiled tag case in server action tests.
* fix(app-router): notify actions for immediate tag expiry
Server Actions that call revalidateTag with an inline profile of expire zero immediately expire server cache data, but the client router still needs x-action-revalidated to drop stale RSC payloads.
Resolve revalidateTag profiles before deciding whether to mark action revalidation, matching Next.js' no-profile or expire-zero condition. Also clear the action marker on progressive action errors for symmetry with the RSC action path.
* fix(app-router): notify on revalidateTag with unknown profile
Address review feedback:
- revalidateTag with an unrecognized profile string resolves to no
durations and the handler treats it as a full invalidation, but the
client router was not being notified. Mark action revalidation when
durations is missing too.
- Document the cookie override path in resolveActionRevalidationKind so
future readers see why it bypasses the max-escalation pattern used in
markActionRevalidation.
---------
Co-authored-by: James <james@eli.cx>
RSC fetch actions that threw notFound() or another HTTP access fallback still returned a Flight payload with status 200. That diverged from Next.js, which sets the response status to the access fallback status before generating Flight for fetch actions.
The server action path now extracts the fallback status while preserving the action error in the Flight return value. The regression test covers legacy NEXT_NOT_FOUND, modern NEXT_HTTP_ERROR_FALLBACK;404, and another fallback status.
Fetch server actions accepted every value returned by decodeReply and spread that array into action.apply. A hostile action payload could force an oversized argument list and hit the same stack-overflow class Next.js guards against.
Add the Next.js-compatible 1000 argument limit immediately before invoking the action, preserving the existing action error packaging path. Cover the regression with a focused fetch-action test that proves the action is not called for 1001 decoded arguments.
* fix(app-router): validate RSC cache-busting params
App Router RSC responses could vary by request headers while client fetches and server validation still relied on Vary-aware caches. CDNs that key only by URL could reuse a mounted-slot or interception-context RSC payload for a different request.
Next.js validates a cache-busting _rsc search param against the RSC request headers and redirects malformed requests to the canonical URL. Vinext now applies the same invariant to its .rsc request model, centralizes the RSC Vary header set in normal server modules, and de-duplicates middleware Vary additions.
Tests cover missing and stale _rsc values, variant-header hashes, generated-entry delegation, browser RSC request markers, response Vary propagation, and App Router RSC integration.
* fix(app-router): hide RSC cache tokens from navigation URLs
* fix(app-router): hide RSC cache token from searchParams
* fix(app-router): address RSC cache-busting review
* fix(app-router): preserve HTML redirect destination for direct .rsc navigations
Config redirects from next.config.js were unconditionally converted to
.rsc destinations for any request whose URL ended in .rsc. This broke
direct browser navigations to .rsc URLs (e.g. a bookmark or external
link) because the browser was redirected to an RSC payload it could not
render.
Client-side RSC fetches always send the RSC: 1 header, so restrict the
.rsc destination conversion to requests that carry that header. Direct
browser hits without the header continue to redirect to the canonical
HTML URL, matching the pre-PR behaviour for config redirects.
* refactor(app-router): extract RSC runtime primitives
The App Router RSC entry template currently owns low-level ISR keying, server-action body reads, and RSC error digest behavior. That makes generated code harder to inspect and leaves runtime contracts covered only through template snapshots.
Move those primitives into typed server modules and keep app-rsc-entry.ts focused on app-shape imports, route tables, and request-specific wiring. Add focused helper tests for slot normalization, streamed action body limits, production digest behavior, and generated-entry delegation.
* fix(app-router): address RSC primitive review feedback
* fix(app-router): return action-not-found for stale server actions
Unknown or stale Server Action ids currently fall into the generic action error path and return 500. That hides deployment skew from the client and misses the Next.js action-not-found response contract.
The action loader is an external runtime boundary, so the helper now treats loaded actions as unknown until narrowed, maps recognized missing-action failures to the 404 x-nextjs-action-not-found protocol, and keeps unrelated loader failures on the existing 500 reporter path.
Tests cover fetch actions, progressive form decode misses, missing exports, generic loader failures, and the App Router integration response.
* chore: rerun ci