Skip to content

fix(server): render progressive action not-found HTML#1254

Merged
james-elicx merged 5 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/progressive-action-not-found-html
May 16, 2026
Merged

fix(server): render progressive action not-found HTML#1254
james-elicx merged 5 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/progressive-action-not-found-html

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Overview

Field Details
Goal Match Next.js progressive multipart server action behavior when an action calls notFound() or another HTTP fallback.
Core change Keep redirect actions as 303 responses, but route non-redirect action throws back into page rendering instead of returning an empty status response.
Main boundary handleProgressiveServerActionRequest now hands action failures to createAppRscHandler, which passes them into dispatchAppPage for existing special-error rendering.
Primary files app-server-action-execution.ts, app-rsc-handler.ts, app-page-dispatch.ts
Expected impact No-JS useActionState form submissions that call notFound() render the app's not-found HTML instead of a blank 404 body.

Why

Progressive form actions are MPA-style submissions. When the action throws an HTTP fallback, the server response must be the page render for that fallback, not only the fallback status code. Next.js models this explicitly: fetch actions package the error into Flight, while MPA actions return a not-found action result and app-render renders the not-found loader tree to HTML.

Area Principle / invariant What this PR changes
Progressive actions MPA action responses own full HTML rendering. HTTP fallback throws become render inputs instead of terminal empty responses.
Redirects Redirect control flow remains response-level for MPA form submissions. Redirects still return 303 with Location and action headers.
Error handoff Thrown values may be falsy and still represent a failed action. Uses an explicit actionFailed flag instead of truthiness.

What Changed

Scenario Before After
Progressive action calls notFound() 404 response with no body. 404 response with rendered not-found HTML.
Progressive action throws generic error Immediate 500 from action execution helper. Error is passed into page dispatch so app error handling can render it.
Progressive action redirects 303 response. Still 303 response.
Maintainer review path
  1. packages/vinext/src/server/app-server-action-execution.ts: action execution now distinguishes redirects from action failures and returns an action-failed form-state result for non-redirect throws.
  2. packages/vinext/src/server/app-rsc-handler.ts: progressive action results now carry actionError plus explicit actionFailed into matched page dispatch.
  3. packages/vinext/src/server/app-page-dispatch.ts: action failures are rethrown during element construction so existing HTTP fallback and error boundary machinery owns the response.
  4. tests/app-server-action-execution.test.ts and tests/app-page-dispatch.test.ts: helper contract plus rendered HTML regression coverage.
Validation
  • vp test run tests/app-server-action-execution.test.ts tests/app-rsc-handler.test.ts tests/app-page-dispatch.test.ts
  • vp check tests/app-server-action-execution.test.ts tests/app-rsc-handler.test.ts tests/app-page-dispatch.test.ts
  • vp check
Risk / compatibility
  • Public API: no public API changes.
  • Runtime: changes only progressive multipart action submissions without an action header.
  • Compatibility: intentionally follows Next.js' MPA action semantics for HTTP fallback and generic thrown errors.
  • Existing app risk: redirect behavior is preserved; successful form-state actions still decode form state normally.
Non-goals
  • Does not change fetch action Flight semantics.
  • Does not add browser e2e coverage for every useActionState fallback variant. The added coverage targets the lowest honest server boundaries for this regression.

References

Reference Why it matters
Next.js HandleActionResult includes MPA not-found Establishes the intended non-fetch action result shape.
Next.js HTTP fallback handling in action handler Shows fetch actions package Flight while MPA actions request HTML fallback rendering.
Next.js app-render renders not-found loader tree Confirms the body should be rendered HTML, not an empty status response.
Next.js useActionState action calling notFound() fixture Covers the same form-action surface.
Next.js e2e assertion for rendered not-found boundary Confirms the user-visible behavior expected after submission.

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.
@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@1254

commit: a9b5437

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 16, 2026 07:27
@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.

Solid work. The design is clean: progressive action failures are lifted from terminal responses into render inputs, so buildAppPageElement's existing special-error/error-boundary machinery handles them. The explicit actionFailed flag correctly handles falsy thrown values (the throw 0 test covers this), the type narrowing in app-rsc-handler.ts via "actionFailed" in progressiveActionResult is correct, and the test coverage targets the right contract boundaries.

Two minor observations below, neither blocking.

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.

Comment on lines +432 to +447
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" },
);
}
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.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted with approval. Here's a summary:

Approved. The PR correctly fixes progressive server action notFound() rendering by lifting action failures from terminal empty-body responses into render inputs that flow through the existing buildAppPageElement -> resolveAppPageSpecialError -> renderHttpAccessFallbackPage machinery.

Key things I verified:

  • The actionFailed boolean flag correctly handles falsy thrown values (e.g., throw 0) — using truthiness of actionError alone would miss this
  • The type narrowing in app-rsc-handler.ts:438 via "actionFailed" in progressiveActionResult correctly discriminates the union
  • The re-throw in app-page-dispatch.ts:588-590 correctly feeds into buildAppPageElement's catch clause, which routes to resolveAppPageSpecialError for HTTP fallback digests or renderErrorBoundaryPage for generic errors
  • Redirect behavior is preserved — only non-redirect throws take the new path
  • Test coverage targets the right contract boundaries (action execution helper, page dispatch, and generated entry template)

Two minor non-blocking observations left as inline comments: duplicate type definitions between the handler and execution module, and potential double-reporting of generic action errors.

github run

Comment on lines +588 to +590
if (options.actionFailed) {
throw options.actionError;
}
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?

Comment thread tests/app-router.test.ts Outdated
["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"]) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

could use it.each for this

@james-elicx james-elicx enabled auto-merge (squash) May 16, 2026 17:01
@james-elicx james-elicx merged commit c84e383 into cloudflare:main May 16, 2026
28 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