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
4 changes: 4 additions & 0 deletions packages/vinext/src/entries/app-rsc-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,8 @@ export default __createAppRscHandler({
dispatchMatchedPage({
cleanPathname,
formState,
actionError,
actionFailed,
handlerStart,
interceptionContext,
isProgressiveActionRender,
Expand Down Expand Up @@ -513,6 +515,8 @@ export default __createAppRscHandler({
interceptionContext,
expireSeconds: __expireTime,
formState,
actionError,
actionFailed,
isProgressiveActionRender,
isProduction: process.env.NODE_ENV === "production",
isRscRequest,
Expand Down
5 changes: 5 additions & 0 deletions packages/vinext/src/server/app-page-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ type DispatchAppPageOptions<TRoute extends AppPageDispatchRoute> = {
fetchCache?: FetchCacheMode | null;
findIntercept: (pathname: string) => AppPageDispatchIntercept | null;
formState?: ReactFormState | null;
actionError?: unknown;
actionFailed?: boolean;
generateStaticParams?: ValidateAppPageDynamicParamsOptions["generateStaticParams"];
getFontLinks: () => string[];
getFontPreloads: () => AppPageFontPreload[];
Expand Down Expand Up @@ -583,6 +585,9 @@ async function dispatchAppPageInner<TRoute extends AppPageDispatchRoute>(

const pageBuildResult = await buildAppPageElement({
buildPageElement() {
if (options.actionFailed) {
throw options.actionError;
}
Comment on lines +588 to +590
Copy link
Copy Markdown
Collaborator

@james-elicx james-elicx May 16, 2026

Choose a reason for hiding this comment

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

wouldn't this be the same as if options.actionError? I don't think we'd have a failure without an associated error?

return options.buildPageElement(
route,
options.params,
Expand Down
24 changes: 23 additions & 1 deletion packages/vinext/src/server/app-rsc-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ type AppRscRouteMatch<TRoute> = {
type DispatchMatchedPageOptions<TRoute> = {
cleanPathname: string;
formState: ReactFormState | null;
actionError?: unknown;
actionFailed?: boolean;
handlerStart: number;
interceptionContext: string | null;
isProgressiveActionRender: boolean;
Expand Down Expand Up @@ -116,6 +118,18 @@ type HandleProgressiveActionRequestOptions = {
request: Request;
};

type ProgressiveActionFormStateResult =
| {
formState: ReactFormState | null;
kind: "form-state";
}
| {
actionError: unknown;
actionFailed: true;
formState: null;
kind: "form-state";
};
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: this type is structurally identical to ProgressiveServerActionResult in app-server-action-execution.ts. They'll drift independently. Consider exporting the type from the execution module and importing it here, or extracting a shared type into a common location.

Not blocking — just a maintenance observation.


type HandleServerActionRequestOptions = {
actionId: string | null;
cleanPathname: string;
Expand Down Expand Up @@ -167,7 +181,7 @@ type CreateAppRscHandlerOptions<TRoute extends AppRscHandlerRoute> = {
ensureInstrumentation?: () => Promise<void>;
handleProgressiveActionRequest: (
options: HandleProgressiveActionRequestOptions,
) => Promise<Response | { formState: ReactFormState | null; kind: "form-state" } | null>;
) => Promise<Response | ProgressiveActionFormStateResult | null>;
handleServerActionRequest: (
options: HandleServerActionRequestOptions,
) => Promise<Response | null>;
Expand Down Expand Up @@ -420,6 +434,12 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
if (progressiveActionResult instanceof Response) return progressiveActionResult;
const isProgressiveActionRender = progressiveActionResult?.kind === "form-state";
const formState = isProgressiveActionRender ? progressiveActionResult.formState : null;
const failedProgressiveActionResult =
isProgressiveActionRender && "actionFailed" in progressiveActionResult
? progressiveActionResult
: null;
const actionFailed = failedProgressiveActionResult !== null;
const actionError = failedProgressiveActionResult?.actionError;

const serverActionResponse = await options.handleServerActionRequest({
actionId,
Expand Down Expand Up @@ -521,6 +541,8 @@ async function handleAppRscRequest<TRoute extends AppRscHandlerRoute>(
return options.dispatchMatchedPage({
cleanPathname,
formState,
actionError,
actionFailed,
handlerStart,
interceptionContext: interceptionContextHeader,
isProgressiveActionRender,
Expand Down
96 changes: 41 additions & 55 deletions packages/vinext/src/server/app-server-action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,17 @@ type AppServerActionRoute = {
pattern: string;
};

type ProgressiveServerActionResult = {
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<TRoute extends AppServerActionRoute> = {
params: AppPageParams;
Expand Down Expand Up @@ -198,16 +205,6 @@ export type HandleServerActionRscRequestOptions<
toInterceptOpts: (intercept: AppServerActionIntercept<TPage>) => 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.
Expand Down Expand Up @@ -297,33 +294,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;
Expand Down Expand Up @@ -449,22 +419,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" },
);
}
Comment on lines +432 to +447
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.

The error reporting for non-control-flow errors is good. Worth noting: this means generic action errors are now reported twice — once here via console.error + reportRequestError, and potentially again downstream when dispatchAppPage re-throws the error through buildAppPageElement and the error boundary / render lifecycle catches it. The createRscOnErrorHandler in the render path would see it again.

This matches the spirit of the change (action execution owns reporting, page render owns rendering), but wanted to flag the double-report possibility in case it matters for observability. The existing Next.js behavior presumably has a similar characteristic since the action handler also logs before handing off.

}
} 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 };
}
Expand All @@ -477,9 +468,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);
Expand All @@ -490,7 +479,7 @@ export async function handleProgressiveServerActionRequest(
setActionRevalidatedHeader(headers, actionRevalidationKind);

return new Response(null, {
status: actionControlResponse.kind === "redirect" ? 303 : actionControlResponse.statusCode,
status: 303,
headers,
});
} catch (error) {
Expand All @@ -503,10 +492,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),
{
Expand All @@ -520,7 +506,7 @@ export async function handleProgressiveServerActionRequest(
return internalServerErrorResponse(
process.env.NODE_ENV === "production"
? undefined
: "Server action failed: " + getServerActionFailureMessage(error),
: "Server action parsing failed: " + getServerActionFailureMessage(error),
);
}
}
Expand Down
29 changes: 29 additions & 0 deletions tests/app-page-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -272,6 +276,31 @@ describe("app page dispatch", () => {
await expect(response.text()).resolves.toBe("<html>page</html>");
});

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("<html>not found</html>", { 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("<html>not found</html>");
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 () =>
Expand Down
Loading
Loading