Skip to content

fix(app-router): throw a typed error for unrecognized server actions#1250

Merged
james-elicx merged 2 commits into
cloudflare:mainfrom
NathanDrake2406:fix-unrecognized-action-error
May 16, 2026
Merged

fix(app-router): throw a typed error for unrecognized server actions#1250
james-elicx merged 2 commits into
cloudflare:mainfrom
NathanDrake2406:fix-unrecognized-action-error

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

What this changes

vinext's App Router client server-action dispatcher now throws UnrecognizedActionError instead of a plain Error when the server reports an unrecognized action id. The public unstable_isUnrecognizedActionError predicate now returns true for the framework's own action-not-found path, so client catch blocks and error boundaries can detect client/server deployment skew and recover (typically by reloading the page).

Why

When a server action POST returns the x-nextjs-action-not-found response header, the client dispatcher in server/app-browser-entry.ts threw new Error(...). unstable_isUnrecognizedActionError is an instanceof UnrecognizedActionError check, so it returned false for the one code path users actually hit.

vinext exposed UnrecognizedActionError and the predicate as public API, but never constructed the error from its own dispatcher. Its own JSDoc admitted this ("vinext does not yet construct this error from its server-action dispatcher"). The deployment-skew recovery API therefore worked only for hand-constructed errors, never the real framework failure.

Next.js throws new UnrecognizedActionError(...) on this exact header in its server-action reducer:
https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts

Approach

  • Extracted UnrecognizedActionError and unstable_isUnrecognizedActionError into a dependency-free shims/unrecognized-action-error.ts, mirroring Next.js's own unrecognized-action-error.ts. instanceof is identity-based per module instance, so the dispatcher and user code must resolve one shared class. navigation.ts re-exports both, keeping the next/navigation public surface unchanged.
  • Added throwOnServerActionNotFound(response, actionId) to server/server-action-not-found.ts, the client-side counterpart of the existing createServerActionNotFoundResponse. The client server-action callback calls it.
  • The two internal helpers it subsumes (isServerActionNotFoundResponse, getServerActionNotFoundClientMessage) are no longer exported; throwOnServerActionNotFound is the single boundary.

Split into two commits: a pure refactor (extract the shared module) and the behavioral fix.

Non-goals: the no-JS / MPA form-action path (a server-rendered error page, not the client predicate) is unchanged. navigation.react-server.ts keeps its existing client-only throwing stub.

Validation

  • vp check and knip clean.
  • New behavior tests in tests/app-server-action-execution.test.ts: feed the real server-emitted response (createServerActionNotFoundResponse()) through throwOnServerActionNotFound, assert the public predicate recognizes the result and the message names the stale action id, and assert a normal response does not throw.
  • Existing tests/shims.test.ts predicate tests still pass, confirming the re-export is intact.
  • tests/app-router.test.ts and tests/entry-templates.test.ts pass.

Reference: Next.js e2e coverage for this behavior is actions-unrecognized.test.ts.

Risks / follow-ups

The one-line wiring in the client callback (throwOnServerActionNotFound(fetchResponse, id)) is not unit-tested in isolation: app-browser-entry.ts self-executes hydration on import, and triggering a genuine action-not-found requires real client/server deployment skew, which a single fixture build cannot produce. The wiring is covered by the type checker and the existing server-actions E2E happy path. A full browser E2E mirroring Next.js's error-boundary test would need two-build skew infrastructure; called out here rather than faked with a mock cascade.

…odule

`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.
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.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 16, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1250

commit: c3e9df5

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-motivated fix. The extraction into a shared zero-dependency module is the right call for the instanceof identity issue, and the test coverage directly validates the end-to-end predicate path (server response → client throw → public predicate).

Two minor observations:

  1. Import style consistency (see inline comment): the new import in server-action-not-found.ts uses the bare-specifier form without .js (matching the convention for vinext/shims/* imports in server/), while navigation.ts uses the relative ./unrecognized-action-error.js with extension. Both resolve correctly — just noting the two styles coexist in the same PR.

  2. react-server condition gap (not introduced here, just awareness): navigation.react-server.ts exports a throwing stub for unstable_isUnrecognizedActionError but doesn't export UnrecognizedActionError at all. If someone imports the class in a Server Component, they'll get a missing-export error rather than the helpful "client-only" message. Next.js behaves the same way (their react-server condition also omits the class), so this is parity — just calling it out since the class now lives in a standalone module that could be imported directly from vinext/shims/unrecognized-action-error in any environment.

Neither is blocking. Looks good.

@@ -1,4 +1,5 @@
import { NEXTJS_ACTION_NOT_FOUND_HEADER as SERVER_ACTION_NOT_FOUND_HEADER } from "./headers.js";
import { UnrecognizedActionError } from "vinext/shims/unrecognized-action-error";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: every other vinext/shims/* import in this directory omits the extension (matching the bare-specifier convention for the package exports wildcard). This is consistent with those — just noting that navigation.ts uses the relative ./unrecognized-action-error.js form with the .js extension. Both resolve correctly, and both styles are used elsewhere, so no action needed.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted. Here's a summary of my assessment:

Approved — this is a well-structured fix for a real bug where unstable_isUnrecognizedActionError was never returning true for the framework's own action-not-found path.

Key points:

  • The extraction of UnrecognizedActionError into a dedicated zero-dependency module (shims/unrecognized-action-error.ts) correctly solves the instanceof identity problem — both the dispatcher and user code now resolve the same class.
  • The throwOnServerActionNotFound helper is a clean consolidation of the two previously-exported helpers into one call-site-oriented API.
  • The test directly validates the full round-trip: server response → throwOnServerActionNotFoundunstable_isUnrecognizedActionError predicate match.
  • The next/navigation public surface is preserved via re-export, and existing shim tests still pass.
  • Two minor non-blocking observations noted in the review about import style consistency and a pre-existing react-server condition gap.

github run

@james-elicx james-elicx merged commit 2603021 into cloudflare:main May 16, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants