Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ import {
import {
handleProgressiveServerActionRequest as __handleProgressiveServerActionRequest,
handleServerActionRscRequest as __handleServerActionRscRequest,
isProgressiveServerActionRequest as __isProgressiveServerActionRequest,
readActionBodyWithLimit as __readBodyWithLimit,
readActionFormDataWithLimit as __readFormDataWithLimit,
} from ${JSON.stringify(appServerActionExecutionPath)};
Expand Down Expand Up @@ -781,6 +782,26 @@ export default __createAppRscHandler({
middlewareContext,
request,
}) {
// A multipart form POST to a page is always a server-action attempt, so a
// body that decodes to no action must surface as 404 action-not-found
// (#1340). Route handlers run after this dispatch and accept raw multipart
// POSTs, so only flag actual page routes. The __loadPage / __loadRouteHandler
// markers are static and available before lazy module hydration.
//
// Only the progressive (multipart, no actionId) POST path consults
// hasPageRoute, so skip the route match entirely for every other request
// rather than re-matching on each App Router request.
const __isProgressiveAction = __isProgressiveServerActionRequest(
request,
contentType,
actionId,
);
const __progressiveActionMatch = __isProgressiveAction ? matchRoute(cleanPathname) : null;
const __hasPageRoute = Boolean(
__progressiveActionMatch &&
__progressiveActionMatch.route.__loadPage &&
!__progressiveActionMatch.route.__loadRouteHandler,
);
return __handleProgressiveServerActionRequest({
actionId,
allowedOrigins: __allowedOrigins,
Expand All @@ -794,6 +815,7 @@ export default __createAppRscHandler({
decodeFormState,
getAndClearPendingCookies,
getDraftModeCookieHeader,
hasPageRoute: __hasPageRoute,
maxActionBodySize: __MAX_ACTION_BODY_SIZE,
middlewareHeaders: middlewareContext.headers,
readFormDataWithLimit: __readFormDataWithLimit,
Expand Down
21 changes: 21 additions & 0 deletions packages/vinext/src/server/app-server-action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,15 @@ export type HandleProgressiveServerActionRequestOptions = {
decodeFormState: AppServerActionFormStateDecoder;
getAndClearPendingCookies: () => string[];
getDraftModeCookieHeader: () => string | null | undefined;
/**
* Whether the posted-to route resolves to an App Router *page* (as opposed to
* a route handler or no match). Multipart form POSTs to a page are always
* server-action attempts in Next.js, so a body that decodes to no action must
* surface as 404 action-not-found rather than rendering the page. Route
* handlers (which run *after* this dispatch in vinext) legitimately receive
* raw multipart POSTs, so they must still fall through. See issue #1340.
*/
hasPageRoute: boolean;
maxActionBodySize: number;
middlewareHeaders: Headers | null;
readFormDataWithLimit: ReadFormDataWithLimit;
Expand Down Expand Up @@ -757,6 +766,18 @@ export async function handleProgressiveServerActionRequest(

const action = await options.decodeAction(body);
if (!isAppServerActionFunction(action)) {
// A multipart POST to a *page* is always a server-action attempt; a body
// that decodes to no action means the referenced action doesn't exist
// (e.g. the build has no server actions). Mirror Next.js' 404 +
// action-not-found rather than rendering the page. Route handlers run
// after this dispatch and legitimately receive raw multipart POSTs, so
// fall through for them (and for unmatched routes). See issue #1340.
if (options.hasPageRoute) {
return createActionNotFoundResponse(null, {
clearRequestContext: options.clearRequestContext,
getAndClearPendingCookies: options.getAndClearPendingCookies,
});
}
return null;
}

Expand Down
21 changes: 21 additions & 0 deletions tests/app-router-dev-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2033,6 +2033,27 @@ describe("App Router integration", () => {
expect(res.headers.get("x-nextjs-action-not-found")).toBe("1");
});

it("returns action-not-found for an MPA form POST to a page with no decodable action", async () => {
// Ported from Next.js: test/e2e/app-dir/no-server-actions/no-server-actions.test.ts
// ("should error when triggering an MPA action on an app with no server actions")
//
// A multipart form POST to a *page* route is always a server-action
// attempt. When the body carries no action reference, it must surface as
// Next.js' 404 + x-nextjs-action-not-found rather than rendering the page.
// This exercises the entry-side route classification (matchRoute +
// __loadPage / __loadRouteHandler markers) end-to-end. See issue #1340.
const body = new FormData();
body.append("test", "value");
const res = await fetch(`${baseUrl}/about`, {
method: "POST",
headers: { Origin: baseUrl, Host: new URL(baseUrl).host },
body,
});
expect(res.status).toBe(404);
expect(res.headers.get("x-nextjs-action-not-found")).toBe("1");
expect(await res.text()).toBe("Server action not found.");
});

it("rejects cyclic multipart server action payloads before decodeReply", async () => {
const body = new FormData();
body.set("0", '["$Q0"]');
Expand Down
63 changes: 63 additions & 0 deletions tests/app-server-action-execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ function createOptions(
getDraftModeCookieHeader() {
return null;
},
hasPageRoute: false,
maxActionBodySize: 1024,
middlewareHeaders: null,
async readFormDataWithLimit() {
Expand Down Expand Up @@ -789,6 +790,68 @@ describe("app server action execution helpers", () => {
expect(response.headers.get("x-nextjs-action-not-found")).toBe("1");
});

// Ported from Next.js: test/e2e/app-dir/no-server-actions/no-server-actions.test.ts
// ("should error when triggering an MPA action on an app with no server actions")
//
// A multipart form POST to a *page* route that decodes to no action at all
// (e.g. the build has no server actions, so `decodeAction` returns null
// rather than throwing) must surface Next.js' 404 + action-not-found, not
// fall through to a 200 page render. The fetch-action variant of this case
// (handled via the `Next-Action` header) already worked; the MPA/form-POST
// variant did not. See issue #1340.
it("returns action-not-found when an MPA action targets a page with no server actions", async () => {
const clearContext = vi.fn();
const reportedErrors: Error[] = [];
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

try {
const response = requireProgressiveActionResponse(
await handleProgressiveServerActionRequest(
createOptions({
clearRequestContext: clearContext,
hasPageRoute: true,
async decodeAction() {
return null;
},
reportRequestError(error) {
reportedErrors.push(error);
},
}),
),
);

expect(response.status).toBe(404);
expect(response.headers.get("x-nextjs-action-not-found")).toBe("1");
expect(await response.text()).toBe("Server action not found.");
expect(reportedErrors).toEqual([]);
expect(clearContext).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"Failed to find Server Action. This request might be from an older or newer deployment.",
),
);
} finally {
warnSpy.mockRestore();
}
});

// Route handlers (route.ts) accept raw multipart POSTs that legitimately
// decode to no action; those must still fall through to the route-handler
// dispatch rather than 404. The page-vs-route distinction comes from the
// caller (`hasPageRoute`).
it("falls through for multipart posts that decode to no action on a non-page route", async () => {
const response = await handleProgressiveServerActionRequest(
createOptions({
hasPageRoute: false,
async decodeAction() {
return null;
},
}),
);

expect(response).toBeNull();
});

it("returns null for non-fetch RSC action requests", async () => {
const response = await handleServerActionRscRequest(
createRscOptions({
Expand Down
Loading