From 63a2572af87086682a07112b1028bfcffaf803ff Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 13:16:17 +1000 Subject: [PATCH 1/2] 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. --- packages/vinext/src/shims/navigation.ts | 49 ++++--------------- .../src/shims/unrecognized-action-error.ts | 40 +++++++++++++++ 2 files changed, 50 insertions(+), 39 deletions(-) create mode 100644 packages/vinext/src/shims/unrecognized-action-error.ts diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 0a593255e..062051beb 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -1980,47 +1980,18 @@ export function unstable_rethrow(error: unknown): void { // --------------------------------------------------------------------------- // Unrecognized server-action errors // -// `unstable_isUnrecognizedActionError` lets client code detect when a server -// action call failed because the server didn't recognize the action id — this -// typically means the client bundle and the server are from different -// deployments and a hard reload is required. -// -// Ported from Next.js: -// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/unrecognized-action-error.ts +// `UnrecognizedActionError` / `unstable_isUnrecognizedActionError` live in a +// dedicated zero-dependency module so this `next/navigation` shim and vinext's +// client server-action dispatcher (`server/server-action-not-found.ts`) share +// one class. `instanceof` is identity-based per module instance, so the +// dispatcher and user code must resolve the same class for the predicate to +// work. Re-exported here to keep the public `next/navigation` surface intact. // --------------------------------------------------------------------------- -/** - * Error class for unrecognized server-action calls. Thrown by the App Router - * server-action handler when the requested action id is not present in the - * current build's action manifest. - * - * vinext does not yet construct this error from its server-action dispatcher - * — it's exposed primarily so user code can use the predicate below - * (`unstable_isUnrecognizedActionError`) as a stable `instanceof` check. - * Ported as a 1:1 alias of Next.js's class so deployments that throw it - * directly (or third-party action wrappers) interoperate. - */ -export class UnrecognizedActionError extends Error { - constructor(...args: ConstructorParameters) { - super(...args); - this.name = "UnrecognizedActionError"; - } -} - -/** - * Returns true if the error came from a server action whose id was not - * recognized by the server. Useful inside `catch` blocks that surround - * `await myAction(...)` calls; reloading the page generally fixes the - * underlying client/server deployment mismatch. - * - * Ported from Next.js: - * https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/unrecognized-action-error.ts - */ -export function unstable_isUnrecognizedActionError( - error: unknown, -): error is UnrecognizedActionError { - return !!(error && typeof error === "object" && error instanceof UnrecognizedActionError); -} +export { + UnrecognizedActionError, + unstable_isUnrecognizedActionError, +} from "./unrecognized-action-error.js"; // --------------------------------------------------------------------------- // Helpers diff --git a/packages/vinext/src/shims/unrecognized-action-error.ts b/packages/vinext/src/shims/unrecognized-action-error.ts new file mode 100644 index 000000000..45cd8fecc --- /dev/null +++ b/packages/vinext/src/shims/unrecognized-action-error.ts @@ -0,0 +1,40 @@ +/** + * Unrecognized server-action errors. + * + * When a server action call fails because the server did not recognize the + * action id, the client bundle and the server are typically from different + * deployments and a hard reload is required. + * + * This module is intentionally dependency-free: both the `next/navigation` + * shim (which re-exports these for user code) and vinext's client + * server-action dispatcher import `UnrecognizedActionError` from here, so the + * `instanceof` check inside `unstable_isUnrecognizedActionError` resolves + * against a single shared class. + * + * Ported 1:1 from Next.js: + * https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/unrecognized-action-error.ts + */ + +/** + * Error class for unrecognized server-action calls. Thrown by vinext's client + * server-action dispatcher when the server reports the requested action id as + * unknown (the `x-nextjs-action-not-found` response header). + */ +export class UnrecognizedActionError extends Error { + constructor(...args: ConstructorParameters) { + super(...args); + this.name = "UnrecognizedActionError"; + } +} + +/** + * Returns true if the error came from a server action whose id was not + * recognized by the server. Useful inside `catch` blocks that surround + * `await myAction(...)` calls; reloading the page generally fixes the + * underlying client/server deployment mismatch. + */ +export function unstable_isUnrecognizedActionError( + error: unknown, +): error is UnrecognizedActionError { + return !!(error && typeof error === "object" && error instanceof UnrecognizedActionError); +} From c3e9df5989080f8054185d2865071142b518156c Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 13:16:35 +1000 Subject: [PATCH 2/2] 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. --- .../vinext/src/server/app-browser-entry.ts | 11 +++---- .../src/server/server-action-not-found.ts | 28 ++++++++++++++-- tests/app-server-action-execution.test.ts | 33 +++++++++++++++++++ 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 1c40bcd21..19465c2ee 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -86,10 +86,7 @@ import { installDevErrorOverlay, } from "./dev-error-overlay.js"; import { DANGEROUS_URL_BLOCK_MESSAGE, isDangerousScheme } from "vinext/shims/url-safety"; -import { - getServerActionNotFoundClientMessage, - isServerActionNotFoundResponse, -} from "./server-action-not-found.js"; +import { throwOnServerActionNotFound } from "./server-action-not-found.js"; import { createRscRequestHeaders, createRscRequestUrl, @@ -856,9 +853,9 @@ function registerServerActionCallback(): void { body, }); - if (isServerActionNotFoundResponse(fetchResponse)) { - throw new Error(getServerActionNotFoundClientMessage(id)); - } + // Surface an `UnrecognizedActionError` so client `catch` blocks can detect + // client/server deployment skew via `unstable_isUnrecognizedActionError`. + throwOnServerActionNotFound(fetchResponse, id); const actionRedirect = fetchResponse.headers.get(ACTION_REDIRECT_HEADER); if (actionRedirect) { diff --git a/packages/vinext/src/server/server-action-not-found.ts b/packages/vinext/src/server/server-action-not-found.ts index b3a474620..35e350cf8 100644 --- a/packages/vinext/src/server/server-action-not-found.ts +++ b/packages/vinext/src/server/server-action-not-found.ts @@ -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"; const SERVER_ACTION_NOT_FOUND_DOCS = "https://nextjs.org/docs/messages/failed-to-find-server-action"; @@ -14,7 +15,7 @@ export function getServerActionNotFoundMessage(actionId: string | null): string )} This request might be from an older or newer deployment.\nRead more: ${SERVER_ACTION_NOT_FOUND_DOCS}`; } -export function getServerActionNotFoundClientMessage(actionId: string): string { +function getServerActionNotFoundClientMessage(actionId: string): string { return `Server Action "${actionId}" was not found on the server. \nRead more: ${SERVER_ACTION_NOT_FOUND_DOCS}`; } @@ -53,6 +54,29 @@ export function createServerActionNotFoundResponse(): Response { }); } -export function isServerActionNotFoundResponse(response: Pick): boolean { +function isServerActionNotFoundResponse(response: Pick): boolean { return response.headers.get(SERVER_ACTION_NOT_FOUND_HEADER) === "1"; } + +/** + * Throw an `UnrecognizedActionError` when the server reported the requested + * server action id as unknown (the `x-nextjs-action-not-found` response + * header); otherwise return so the caller can keep processing the response. + * + * The client-side counterpart of `createServerActionNotFoundResponse`. The + * typed error lets client `catch` blocks call the public + * `unstable_isUnrecognizedActionError` predicate to detect client/server + * deployment skew and recover (typically by reloading the page). + * + * Mirrors Next.js, whose server-action reducer throws `UnrecognizedActionError` + * on this same response header: + * https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts + */ +export function throwOnServerActionNotFound( + response: Pick, + actionId: string, +): void { + if (isServerActionNotFoundResponse(response)) { + throw new UnrecognizedActionError(getServerActionNotFoundClientMessage(actionId)); + } +} diff --git a/tests/app-server-action-execution.test.ts b/tests/app-server-action-execution.test.ts index 70b02eec7..cdf19e378 100644 --- a/tests/app-server-action-execution.test.ts +++ b/tests/app-server-action-execution.test.ts @@ -8,6 +8,11 @@ import { readActionFormDataWithLimit, type HandleProgressiveServerActionRequestOptions, } from "../packages/vinext/src/server/app-server-action-execution.js"; +import { + createServerActionNotFoundResponse, + throwOnServerActionNotFound, +} from "../packages/vinext/src/server/server-action-not-found.js"; +import { unstable_isUnrecognizedActionError } from "../packages/vinext/src/shims/navigation.js"; import { VINEXT_RSC_COMPATIBILITY_ID_HEADER, VINEXT_RSC_VARY_HEADER, @@ -948,3 +953,31 @@ describe("app server action execution helpers", () => { } }); }); + +// The client-side counterpart of `createServerActionNotFoundResponse`: when the +// server cannot resolve an action id, client code must be able to detect the +// deployment skew through the public `unstable_isUnrecognizedActionError` +// predicate so it can recover (typically by reloading the page). +// +// Mirrors Next.js, whose server-action reducer throws `UnrecognizedActionError` +// on the `x-nextjs-action-not-found` response header: +// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts +describe("client recognition of unrecognized server actions", () => { + it("raises an error the public predicate recognizes, naming the stale action id", () => { + let caught: unknown; + try { + // `createServerActionNotFoundResponse()` is exactly what the server emits. + throwOnServerActionNotFound(createServerActionNotFoundResponse(), "decafc0ffeebad01"); + } catch (error) { + caught = error; + } + + expect(unstable_isUnrecognizedActionError(caught)).toBe(true); + expect(String(caught)).toContain('Server Action "decafc0ffeebad01" was not found'); + }); + + it("does not throw for a recognized action response", () => { + // A recognized action returns an ordinary response without the not-found header. + expect(() => throwOnServerActionNotFound(new Response("ok"), "abc")).not.toThrow(); + }); +});