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