From 9d9b4e9ed6242c41b5154ebb8c74f790dd672277 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 16:57:28 +1000 Subject: [PATCH 1/5] 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. --- .../vinext/src/server/app-page-dispatch.ts | 5 + packages/vinext/src/server/app-rsc-handler.ts | 19 ++- .../src/server/app-server-action-execution.ts | 83 ++++++-------- tests/app-page-dispatch.test.ts | 29 +++++ tests/app-server-action-execution.test.ts | 108 +++++++++++------- 5 files changed, 150 insertions(+), 94 deletions(-) diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 26733c676..390221422 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -157,6 +157,8 @@ type DispatchAppPageOptions = { fetchCache?: FetchCacheMode | null; findIntercept: (pathname: string) => AppPageDispatchIntercept | null; formState?: ReactFormState | null; + actionError?: unknown; + actionFailed?: boolean; generateStaticParams?: ValidateAppPageDynamicParamsOptions["generateStaticParams"]; getFontLinks: () => string[]; getFontPreloads: () => AppPageFontPreload[]; @@ -583,6 +585,9 @@ async function dispatchAppPageInner( const pageBuildResult = await buildAppPageElement({ buildPageElement() { + if (options.actionFailed) { + throw options.actionError; + } return options.buildPageElement( route, options.params, diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index e3da9ea51..04586335a 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -85,6 +85,8 @@ type AppRscRouteMatch = { type DispatchMatchedPageOptions = { cleanPathname: string; formState: ReactFormState | null; + actionError?: unknown; + actionFailed?: boolean; handlerStart: number; interceptionContext: string | null; isProgressiveActionRender: boolean; @@ -165,9 +167,16 @@ type CreateAppRscHandlerOptions = { options: DispatchMatchedRouteHandlerOptions, ) => Promise; ensureInstrumentation?: () => Promise; - handleProgressiveActionRequest: ( - options: HandleProgressiveActionRequestOptions, - ) => Promise; + handleProgressiveActionRequest: (options: HandleProgressiveActionRequestOptions) => Promise< + | Response + | { + actionError?: unknown; + actionFailed?: boolean; + formState: ReactFormState | null; + kind: "form-state"; + } + | null + >; handleServerActionRequest: ( options: HandleServerActionRequestOptions, ) => Promise; @@ -420,6 +429,8 @@ async function handleAppRscRequest( if (progressiveActionResult instanceof Response) return progressiveActionResult; const isProgressiveActionRender = progressiveActionResult?.kind === "form-state"; const formState = isProgressiveActionRender ? progressiveActionResult.formState : null; + const actionError = isProgressiveActionRender ? progressiveActionResult.actionError : undefined; + const actionFailed = isProgressiveActionRender && progressiveActionResult.actionFailed === true; const serverActionResponse = await options.handleServerActionRequest({ actionId, @@ -521,6 +532,8 @@ async function handleAppRscRequest( return options.dispatchMatchedPage({ cleanPathname, formState, + actionError, + actionFailed, handlerStart, interceptionContext: interceptionContextHeader, isProgressiveActionRender, diff --git a/packages/vinext/src/server/app-server-action-execution.ts b/packages/vinext/src/server/app-server-action-execution.ts index 9aef8217a..ad28c7e98 100644 --- a/packages/vinext/src/server/app-server-action-execution.ts +++ b/packages/vinext/src/server/app-server-action-execution.ts @@ -74,6 +74,8 @@ type AppServerActionRoute = { }; type ProgressiveServerActionResult = { + actionError?: unknown; + actionFailed?: boolean; formState: ReactFormState | null; kind: "form-state"; }; @@ -198,16 +200,6 @@ export type HandleServerActionRscRequestOptions< toInterceptOpts: (intercept: AppServerActionIntercept) => TInterceptOpts; }; -type ActionControlResponse = - | { - kind: "redirect"; - url: string; - } - | { - kind: "status"; - statusCode: number; - }; - /** * Matches Next.js' server action argument cap to prevent stack overflow in * Function.prototype.apply when decoding hostile action payloads. @@ -297,33 +289,6 @@ export async function readActionFormDataWithLimit( }).formData(); } -function getActionControlResponse(error: unknown): ActionControlResponse | null { - const digest = getNextErrorDigest(error); - if (!digest) return null; - - const redirect = parseNextRedirectDigest(digest); - if (redirect) { - return { - kind: "redirect", - url: redirect.url, - }; - } - - const httpError = parseNextHttpErrorDigest(digest); - if (httpError) { - if (!Number.isInteger(httpError.status)) { - return null; - } - - return { - kind: "status", - statusCode: httpError.status, - }; - } - - return null; -} - function getActionRedirect(error: unknown): AppServerActionRedirect | null { const digest = getNextErrorDigest(error); if (!digest) return null; @@ -449,22 +414,43 @@ export async function handleProgressiveServerActionRequest( return null; } - let actionControlResponse: ActionControlResponse | null = null; + let actionRedirect: AppServerActionRedirect | null = null; + let actionError: unknown = undefined; + let actionFailed = false; let actionResult: unknown; const previousHeadersPhase = options.setHeadersAccessPhase("action"); try { actionResult = await action(); } catch (error) { - actionControlResponse = getActionControlResponse(error); - if (!actionControlResponse) { - throw error; + actionRedirect = getActionRedirect(error); + if (!actionRedirect) { + actionError = error; + actionFailed = true; + const isControlFlow = + getActionHttpFallbackStatus(error) !== null || isServerActionNotFoundError(error, null); + if (!isControlFlow) { + console.error("[vinext] Server action error:", error); + options.reportRequestError( + normalizeError(error), + { + path: options.cleanPathname, + method: options.request.method, + headers: Object.fromEntries(options.request.headers.entries()), + }, + { routerKind: "App Router", routePath: options.cleanPathname, routeType: "action" }, + ); + } } } finally { options.setHeadersAccessPhase(previousHeadersPhase); } - if (!actionControlResponse) { + if (!actionRedirect) { getAndClearActionRevalidationKind(); + if (actionFailed) { + return { kind: "form-state", formState: null, actionError, actionFailed }; + } + const formState = await options.decodeFormState(actionResult, body); return { kind: "form-state", formState: formState ?? null }; } @@ -477,9 +463,7 @@ export async function handleProgressiveServerActionRequest( options.clearRequestContext(); const headers = new Headers(); - if (actionControlResponse.kind === "redirect") { - headers.set("Location", new URL(actionControlResponse.url, options.request.url).toString()); - } + headers.set("Location", new URL(actionRedirect.url, options.request.url).toString()); mergeMiddlewareResponseHeaders(headers, options.middlewareHeaders); for (const cookie of actionPendingCookies) { headers.append("Set-Cookie", cookie); @@ -490,7 +474,7 @@ export async function handleProgressiveServerActionRequest( setActionRevalidatedHeader(headers, actionRevalidationKind); return new Response(null, { - status: actionControlResponse.kind === "redirect" ? 303 : actionControlResponse.statusCode, + status: 303, headers, }); } catch (error) { @@ -503,10 +487,7 @@ export async function handleProgressiveServerActionRequest( getAndClearActionRevalidationKind(); options.getAndClearPendingCookies(); - // Next.js rethrows generic MPA action errors into its page render path. - // vinext does not yet implement that form-state render path, so unexpected - // action failures remain request failures here. - console.error("[vinext] Server action error:", error); + console.error("[vinext] Server action payload parsing error:", error); options.reportRequestError( normalizeError(error), { @@ -520,7 +501,7 @@ export async function handleProgressiveServerActionRequest( return internalServerErrorResponse( process.env.NODE_ENV === "production" ? undefined - : "Server action failed: " + getServerActionFailureMessage(error), + : "Server action parsing failed: " + getServerActionFailureMessage(error), ); } } diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index efd628f9e..dd1fbd5aa 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -74,6 +74,8 @@ function createDispatchOptions( clearRequestContext?: DispatchOptions["clearRequestContext"]; generateStaticParams?: DispatchOptions["generateStaticParams"]; formState?: DispatchOptions["formState"]; + actionError?: DispatchOptions["actionError"]; + actionFailed?: DispatchOptions["actionFailed"]; isProgressiveActionRender?: DispatchOptions["isProgressiveActionRender"]; isProduction?: boolean; isRscRequest?: boolean; @@ -126,6 +128,8 @@ function createDispatchOptions( hasPageModule: true, handlerStart: 10, formState: overrides.formState, + actionError: overrides.actionError, + actionFailed: overrides.actionFailed, interceptionContext: null, isProgressiveActionRender: overrides.isProgressiveActionRender, isProduction: overrides.isProduction ?? false, @@ -272,6 +276,31 @@ describe("app page dispatch", () => { await expect(response.text()).resolves.toBe("page"); }); + it("renders not-found HTML when a progressive action calls notFound()", async () => { + const buildPageElement = vi.fn(async () => React.createElement("main", null, "page")); + const renderHttpAccessFallbackPage = vi.fn( + async () => new Response("not found", { status: 404 }), + ); + const { options } = createDispatchOptions({ + actionError: { digest: "NEXT_HTTP_ERROR_FALLBACK;404" }, + actionFailed: true, + buildPageElement, + isProgressiveActionRender: true, + }); + options.renderHttpAccessFallbackPage = renderHttpAccessFallbackPage; + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(404); + await expect(response.text()).resolves.toBe("not found"); + expect(buildPageElement).not.toHaveBeenCalled(); + expect(renderHttpAccessFallbackPage).toHaveBeenCalledWith( + 404, + { matchedParams: { slug: "hello" } }, + null, + ); + }); + it("does not bypass cached production HTML for arbitrary draft cookie values", async () => { vi.stubEnv("__VINEXT_DRAFT_SECRET", "draft-secret"); const isrGet = vi.fn(async () => diff --git a/tests/app-server-action-execution.test.ts b/tests/app-server-action-execution.test.ts index 70b02eec7..fc6402048 100644 --- a/tests/app-server-action-execution.test.ts +++ b/tests/app-server-action-execution.test.ts @@ -458,67 +458,95 @@ describe("app server action execution helpers", () => { expect((await request.formData()).get("field")).toBe("value"); }); - it("maps action HTTP fallback errors to status responses", async () => { - for (const [digest, statusCode] of [ - ["NEXT_NOT_FOUND", 404], - ["NEXT_HTTP_ERROR_FALLBACK;403", 403], - ]) { + it("passes HTTP fallback errors as actionError to be rendered by error boundaries", async () => { + for (const digest of ["NEXT_NOT_FOUND", "NEXT_HTTP_ERROR_FALLBACK;403"]) { const clearContext = vi.fn(); const reportedErrors: Error[] = []; - const response = requireProgressiveActionResponse( - await handleProgressiveServerActionRequest( - createOptions({ - clearRequestContext: clearContext, - async decodeAction() { - return () => { - throw { digest }; - }; - }, - reportRequestError(error) { - reportedErrors.push(error); - }, - }), - ), + const result = await handleProgressiveServerActionRequest( + createOptions({ + clearRequestContext: clearContext, + async decodeAction() { + return () => { + throw { digest }; + }; + }, + reportRequestError(error) { + reportedErrors.push(error); + }, + }), ); - expect(response.status).toBe(statusCode); + expect(result).toEqual({ + kind: "form-state", + formState: null, + actionError: { digest }, + actionFailed: true, + }); expect(reportedErrors).toEqual([]); - expect(clearContext).toHaveBeenCalledTimes(1); + expect(clearContext).not.toHaveBeenCalled(); // Let app-rsc-handler clear it after render } }); - it("reports action execution failures and clears pending cookies", async () => { + it("passes action execution failures as actionError to be rendered by error boundaries", async () => { const reportedErrors: Error[] = []; const clearedCookies = vi.fn(() => ["session=1; Path=/"]); const clearContext = vi.fn(); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const response = requireProgressiveActionResponse( - await handleProgressiveServerActionRequest( + const error = new Error("boom"); + const result = await handleProgressiveServerActionRequest( + createOptions({ + cleanPathname: "/action-source", + clearRequestContext: clearContext, + async decodeAction() { + return () => { + throw error; + }; + }, + getAndClearPendingCookies: clearedCookies, + reportRequestError(err) { + reportedErrors.push(err); + }, + }), + ); + + expect(result).toEqual({ + kind: "form-state", + formState: null, + actionError: error, + actionFailed: true, + }); + expect(reportedErrors.map((e) => e.message)).toEqual(["boom"]); + expect(clearedCookies).not.toHaveBeenCalled(); // Only cleared if response is rendered here + expect(clearContext).not.toHaveBeenCalled(); // Handled by app-rsc-handler + + errorSpy.mockRestore(); + }); + + it("passes falsy action execution failures to the page render path", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const result = await handleProgressiveServerActionRequest( createOptions({ - cleanPathname: "/action-source", - clearRequestContext: clearContext, async decodeAction() { return () => { - throw new Error("boom"); + throw 0; }; }, - getAndClearPendingCookies: clearedCookies, - reportRequestError(error) { - reportedErrors.push(error); - }, }), - ), - ); - - expect(response.status).toBe(500); - expect(await response.text()).toBe("Server action failed: boom"); - expect(reportedErrors.map((error) => error.message)).toEqual(["boom"]); - expect(clearedCookies).toHaveBeenCalledTimes(1); - expect(clearContext).toHaveBeenCalledTimes(1); + ); - errorSpy.mockRestore(); + expect(result).toEqual({ + kind: "form-state", + formState: null, + actionError: 0, + actionFailed: true, + }); + } finally { + errorSpy.mockRestore(); + } }); // Ported from Next.js: test/e2e/app-dir/no-server-actions/no-server-actions.test.ts From f2fbe742cc1897dabc66b0f153e4b23c20e8f71b Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 17:06:09 +1000 Subject: [PATCH 2/5] chore: retrigger CI From 3534c7c9ebac002b983025ce41a323d53b655d37 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 17:18:13 +1000 Subject: [PATCH 3/5] fix(server): forward progressive action failures in RSC entry --- packages/vinext/src/entries/app-rsc-entry.ts | 4 ++++ tests/app-router.test.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index f08b13227..57ab27b2c 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -447,6 +447,8 @@ export default __createAppRscHandler({ dispatchMatchedPage({ cleanPathname, formState, + actionError, + actionFailed, handlerStart, interceptionContext, isProgressiveActionRender, @@ -513,6 +515,8 @@ export default __createAppRscHandler({ interceptionContext, expireSeconds: __expireTime, formState, + actionError, + actionFailed, isProgressiveActionRender, isProduction: process.env.NODE_ENV === "production", isRscRequest, diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 95f0a8432..da9d26d2b 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -3890,6 +3890,20 @@ describe("App Router next.config.js features (generateRscEntry)", () => { expect(code).toContain("matchRoute,"); }); + it("forwards progressive action failures from the generated RSC handler into page dispatch", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false); + + expect(code).toContain(`dispatchMatchedPage({ + cleanPathname, + formState, + actionError, + actionFailed,`); + expect(code).toContain(`formState, + actionError, + actionFailed, + isProgressiveActionRender,`); + }); + it("describes beforeFiles rewrites in the generated app shape", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, { rewrites: { From 2baed5b29a5934990a3b709b428ef706575aefc9 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 17:26:52 +1000 Subject: [PATCH 4/5] refactor(server): make progressive action failure state explicit --- packages/vinext/src/server/app-rsc-handler.ts | 33 ++++++++++++------- .../src/server/app-server-action-execution.ts | 17 ++++++---- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 04586335a..e878a333c 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -118,6 +118,18 @@ type HandleProgressiveActionRequestOptions = { request: Request; }; +type ProgressiveActionFormStateResult = + | { + formState: ReactFormState | null; + kind: "form-state"; + } + | { + actionError: unknown; + actionFailed: true; + formState: null; + kind: "form-state"; + }; + type HandleServerActionRequestOptions = { actionId: string | null; cleanPathname: string; @@ -167,16 +179,9 @@ type CreateAppRscHandlerOptions = { options: DispatchMatchedRouteHandlerOptions, ) => Promise; ensureInstrumentation?: () => Promise; - handleProgressiveActionRequest: (options: HandleProgressiveActionRequestOptions) => Promise< - | Response - | { - actionError?: unknown; - actionFailed?: boolean; - formState: ReactFormState | null; - kind: "form-state"; - } - | null - >; + handleProgressiveActionRequest: ( + options: HandleProgressiveActionRequestOptions, + ) => Promise; handleServerActionRequest: ( options: HandleServerActionRequestOptions, ) => Promise; @@ -429,8 +434,12 @@ async function handleAppRscRequest( if (progressiveActionResult instanceof Response) return progressiveActionResult; const isProgressiveActionRender = progressiveActionResult?.kind === "form-state"; const formState = isProgressiveActionRender ? progressiveActionResult.formState : null; - const actionError = isProgressiveActionRender ? progressiveActionResult.actionError : undefined; - const actionFailed = isProgressiveActionRender && progressiveActionResult.actionFailed === true; + const failedProgressiveActionResult = + isProgressiveActionRender && "actionFailed" in progressiveActionResult + ? progressiveActionResult + : null; + const actionFailed = failedProgressiveActionResult !== null; + const actionError = failedProgressiveActionResult?.actionError; const serverActionResponse = await options.handleServerActionRequest({ actionId, diff --git a/packages/vinext/src/server/app-server-action-execution.ts b/packages/vinext/src/server/app-server-action-execution.ts index ad28c7e98..96bfbb334 100644 --- a/packages/vinext/src/server/app-server-action-execution.ts +++ b/packages/vinext/src/server/app-server-action-execution.ts @@ -73,12 +73,17 @@ type AppServerActionRoute = { pattern: string; }; -type ProgressiveServerActionResult = { - actionError?: unknown; - actionFailed?: boolean; - formState: ReactFormState | null; - kind: "form-state"; -}; +type ProgressiveServerActionResult = + | { + formState: ReactFormState | null; + kind: "form-state"; + } + | { + actionError: unknown; + actionFailed: true; + formState: null; + kind: "form-state"; + }; type AppServerActionMatch = { params: AppPageParams; From a9b5437cc1c4248d2321e516a0e6856043b2aec6 Mon Sep 17 00:00:00 2001 From: James Anderson Date: Sat, 16 May 2026 18:00:24 +0100 Subject: [PATCH 5/5] Apply suggestion from @james-elicx --- tests/app-router.test.ts | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index da9d26d2b..95f0a8432 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -3890,20 +3890,6 @@ describe("App Router next.config.js features (generateRscEntry)", () => { expect(code).toContain("matchRoute,"); }); - it("forwards progressive action failures from the generated RSC handler into page dispatch", () => { - const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false); - - expect(code).toContain(`dispatchMatchedPage({ - cleanPathname, - formState, - actionError, - actionFailed,`); - expect(code).toContain(`formState, - actionError, - actionFailed, - isProgressiveActionRender,`); - }); - it("describes beforeFiles rewrites in the generated app shape", () => { const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false, { rewrites: {