Skip to content

fix(app-router): action redirects#910

Open
NathanDrake2406 wants to merge 5 commits intocloudflare:mainfrom
NathanDrake2406:nathan/app-post-form-actions
Open

fix(app-router): action redirects#910
NathanDrake2406 wants to merge 5 commits intocloudflare:mainfrom
NathanDrake2406:nathan/app-post-form-actions

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented Apr 26, 2026

What changed

  • Treat no-JS multipart App Router server action form submissions as possible MPA server actions, decode them with React's decodeAction, and return a real HTTP 303 redirect when the action calls redirect().
  • Classify app route-handler POST form submissions as action-like for redirect handling, so redirect() and permanentRedirect() return 303 instead of replay-prone 307/308 semantics.
  • Preserve pending cookies() / draft-mode cookies when a route handler throws a redirect, matching Next.js app-route behavior.
  • Add Next.js-compat fixtures and tests for no-JS server action redirects and POST form route-handler redirects.

Why

Browser form submissions without JavaScript send multipart/form-data and no RSC action header. The previous implementation only handled POSTs with x-rsc-action, so progressive-enhancement server action forms fell through to a normal render and the action was silently skipped.

For route handlers, redirect() encodes 307/308 in the digest, but Next.js overrides action-like POST redirects to 303 so the browser follows with GET instead of resubmitting the original POST.

Next.js references

  • getServerActionRequestMetadata(): marks POST multipart/form-data, POST application/x-www-form-urlencoded, and POST action-header requests as possible server actions.
  • action-handler.ts: multipart non-fetch actions call decodeAction(formData, serverModuleMap) and return through the full-page render path for progressive enhancement.
  • app-route/module.ts: app route redirects return RedirectStatusCode.SeeOther when actionStore.isAction is true, and append mutable cookies to the redirect response.
  • app-action-progressive-enhancement.test.ts: verifies formData + redirect without JavaScript returns 303 and navigates.
  • app-action.test.ts: verifies POST route-handler redirect() / permanentRedirect() flows use 303.

Validation

  • vp check
  • vp test run tests/app-route-handler-policy.test.ts
  • vp test run tests/nextjs-compat/app-routes.test.ts
  • vp test run tests/app-router.test.ts -t "server action"
  • vp test run tests/entry-templates.test.ts
  • vp run vinext#build
  • PLAYWRIGHT_PROJECT=app-router vp exec playwright test tests/e2e/app-router/server-actions.spec.ts

Note: the local pre-commit hook failed in this worktree during Vitest suite initialization before running assertions (Cannot read properties of undefined (reading 'config')). The same snapshot update/test command passes when run directly through vp, and the updated snapshot is committed.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 26, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@910

commit: 7a263b4

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@james-elicx Sorry for the PR dump. I switched from Claude to Codex and lost all restraint.

The code reads pretty good to me, curious about your impression on the code quality of these pr
I don't see much blasphemous error handling anymore

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review April 26, 2026 08:18
Copilot AI review requested due to automatic review settings April 26, 2026 08:18
@NathanDrake2406 NathanDrake2406 marked this pull request as draft April 26, 2026 08:33
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review April 26, 2026 08:42
@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.

Nice extraction — splitting the progressive-enhancement path into its own helper (server/app-server-action-execution.ts) keeps the generated entry thin, which matches the project guidance for entries.

The core behavior changes match Next.js:

  • Progressive enhancement classification follows getServerActionRequestMetadata (POST + multipart-or-urlencoded-or-action-header).
  • Action route-handler 303 override matches AppRouteRouteModule.do() where actionStore.isAction ? RedirectStatusCode.SeeOther : getRedirectStatusCodeFromError(err).
  • Pending cookies + draftMode cookie on route-handler redirect throws now match appendMutableCookies(headers, requestStore.mutableCookies) in the same file.

A few notes worth addressing — most importantly the silent-success return path (the body has already been consumed when decodeAction returned a non-action or the action threw a non-redirect that we re-raise into the outer null-return path). Inline comments below.

One thing missing from the test coverage: there's no test for the case where decodeAction returns a non-function (the “could be any POST” path). Right now the helper returns null, and the request falls through to the normal page render — but the body has already been consumed. Worth at least a unit test asserting we return null in that case, and a deliberate decision/comment about whether the post-decode page render is acceptable (Next.js's MPA path only consumes the body once it has confirmed the request is an action via areAllActionIdsValid / decodeAction, so they don't have this problem).

const action = await options.decodeAction(body);
if (typeof action !== "function") {
return null;
}
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.

When decodeAction returns a non-function we return null, which causes _handleRequest to fall through to normal rendering. By this point we have already consumed the request body via readFormDataWithLimit, so any downstream code that touches request.formData() / request.body will fail.

Next.js avoids this by validating areAllActionIdsValid(formData, serverModuleMap) against its server module map before calling decodeAction, and only enters the decode path when the FormData looks like an action payload (reference). The rest of their non-action multipart POSTs never have their body read here.

At minimum, please add a test that exercises the typeof action !== 'function' branch and document the body-already-consumed implication. A follow-up to gate decoding on a cheap pre-check (analogous to areAllActionIdsValid) would be valuable.

}

if (!actionRedirect) {
return null;
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.

Same body-consumed issue applies here: when an MPA action runs successfully without calling redirect(), we return null and fall through to rendering the page. Next.js handles this by re-rendering the source page with the action's form-state (reference — search for decodeFormState). Today this PR's fall-through path renders the page without any action-result wiring, and formData is already consumed.

For in-scope (redirect-only) progressive enhancement the behavior is fine, but please add a comment explaining the parity gap so it doesn't get silently regressed.

} catch (error) {
actionRedirect = getActionRedirect(error);
if (!actionRedirect) {
throw error;
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.

When the action throws a non-redirect error, we re-throw out of the inner block and end up in the outer catch at line 151, which always returns 500. Two parity issues:

  1. notFound() / forbidden() / unauthorized() from inside an MPA action: Next.js's handleAction returns { type: 'not-found' } for isHTTPAccessFallbackError(err) so the appropriate HTML response is rendered. Today this PR responds 500 with “Server action failed: NEXT_NOT_FOUND” in dev.
  2. Generic user errors: Next.js re-throws into the page render path. We unconditionally surface 500.

If full MPA parity is out of scope (per the PR description), please at least:

  • Detect NEXT_NOT_FOUND / NEXT_HTTP_ERROR_FALLBACK;<status> digests and return a Response(null, { status }) instead of 500.
  • Add an inline comment noting the deliberate gap so reviewers know it's intentional.

contentType: string,
actionId: string | null,
): boolean {
return request.method === "POST" && contentType.startsWith("multipart/form-data") && !actionId;
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.

Method comparison here is request.method === "POST" (case-sensitive), but isPossibleAppRouteActionRequest (added in this PR) uses .toUpperCase(). The Fetch spec normalises method to uppercase so it should always be POST in practice, but the inconsistency between the two helpers is easy to trip over.

Suggested change
return request.method === "POST" && contentType.startsWith("multipart/form-data") && !actionId;
return request.method.toUpperCase() === "POST" && contentType.startsWith("multipart/form-data") && !actionId;

return csrfResponse;
}

const contentLength = parseInt(options.request.headers.get("content-length") || "0", 10);
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.

Minor: the body-size fast path orders Content-Length before validateServerActionPayload, but the CSRF check happens before the Content-Length check. Next.js does CSRF first, then size, then decode (matches this ordering), so that part is fine.

One subtle inconsistency with the existing fetch-action path in app-rsc-entry.ts: that path runs validateCsrfOrigin first, then Content-Length, then stream-limit, then validateServerActionPayload. This helper does CSRF, Content-Length, stream-limit, payload-validate — same order. Good.

No change requested, just confirming the ordering is intentional.

return (
request.headers.has("x-rsc-action") ||
request.headers.has("next-action") ||
contentType === "application/x-www-form-urlencoded" ||
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.

Strict equality on application/x-www-form-urlencoded matches Next.js exactly (contentType === 'application/x-www-form-urlencoded'), but it means a browser that sends the (uncommon but spec-legal) application/x-www-form-urlencoded; charset=UTF-8 content-type will not be classified as an action.

Next.js has the same gap, so this is parity-correct. Worth a short comment so a future contributor doesn't "fix" the strict equality and quietly break parity.


// Handle server action POST requests
const actionId = request.headers.get("x-rsc-action");
const actionId = request.headers.get("x-rsc-action") ?? request.headers.get("next-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.

Adding next-action as a fallback header: vinext's client always sends x-rsc-action (see app-browser-state.ts:125), so this fallback only helps for hand-crafted requests or third-party libraries that mimic the Next.js convention. That's fine, but please add a unit test (or an entry-template assertion) so we don't accidentally drop one of the two header names in a future refactor — right now nothing in the test suite exercises the next-action-only path.

getAndClearPendingCookies() {
return ["session=1; Path=/"];
},
getDraftModeCookieHeader() {
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.

response.headers.getSetCookie?.() will silently return undefined if the runtime under test doesn't expose getSetCookie, and the toEqual([...]) will fail with a confusing error. Vitest's vite-plus runtime does expose it, so this is fine in practice, but consider asserting it more directly:

Suggested change
getDraftModeCookieHeader() {
expect(response?.headers.getSetCookie()).toEqual(["session=1; Path=/", "draft=1; Path=/"]);

If getSetCookie becomes unavailable for any reason, this fails loudly instead of equating undefined with the expected array.

options.clearRequestContext();

const redirectHeaders = new Headers({
Location: new URL(actionRedirect.url, options.request.url).toString(),
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.

Defense-in-depth thought: actionRedirect.url originates from user code calling redirect(...), then is resolved against request.url and put directly into a Location header. If a user passes attacker-controlled input to redirect() (e.g. redirect(formData.get('next'))), new URL("//evil.com", "https://example.com/") resolves to https://evil.com/, producing an open-redirect.

Next.js has the same surface, so this is parity-correct. The existing fetch-action code path in app-rsc-entry.ts also doesn't guard against this. Worth filing as a follow-up: route through isOpenRedirectShaped (or similar) for any Location produced from a NEXT_REDIRECT digest, both here and in the existing fetch-action path.

const pendingCookies = options.getAndClearPendingCookies();
const draftCookie = options.getDraftModeCookieHeader();
const specialError = resolveAppRouteHandlerSpecialError(error, options.request.url, {
isAction: isPossibleAppRouteActionRequest(options.request),
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.

isPossibleAppRouteActionRequest(options.request) is recomputed on every error path, but the caller already knows the method/headers when it dispatches into this function. It's cheap, but if you ever add a non-redirect error path that also wants the flag (e.g. for action error logging), it's worth threading isAction through ExecuteAppRouteHandlerOptions so the classification happens once at request entry. Not blocking.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 26, 2026

Review posted successfully: #910 (review)

Summary of feedback

Posted a single batched review with the following key points:

Positive observations:

  • Extraction into server/app-server-action-execution.ts correctly follows the AGENTS.md guidance about keeping generated entries thin.
  • Classification heuristic matches Next.js's getServerActionRequestMetadata.
  • 303 override and appendMutableCookies semantics match Next.js's AppRouteRouteModule.do().

Issues raised inline:

  1. Body consumed when decodeAction returns non-function — Next.js gates this with areAllActionIdsValid to avoid consuming non-action bodies; this PR consumes the body before knowing.
  2. Body consumed on action success without redirect — falls through to page render with body gone; Next.js handles this with decodeFormState. Parity gap should be commented.
  3. Non-redirect errors return 500notFound()/forbidden() from MPA actions should map to their HTTP statuses, not blanket 500.
  4. Method case inconsistency between isProgressiveServerActionRequest (strict === "POST") and isPossibleAppRouteActionRequest (.toUpperCase()).
  5. Strict equality on application/x-www-form-urlencoded matches Next.js but excludes charset variants — worth a parity comment.
  6. next-action header fallback lacks dedicated test coverage.
  7. getSetCookie?.() in test silently passes if undefined; suggested removing optional chaining.
  8. Open-redirect defense-in-depth follow-upredirect() URLs flow into Location without isOpenRedirectShaped guard (parity with Next.js, but worth tracking).
  9. isPossibleAppRouteActionRequest recomputed per error path — minor, suggest threading via options.

github run

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Addressed review feedback in ff42478.

What changed:

  • Progressive multipart decoding now reads from request.clone(), so non-action multipart POSTs and successful non-redirect MPA actions can fall through without consuming the original request body. Added unit coverage for both cases.
  • NEXT_NOT_FOUND and NEXT_HTTP_ERROR_FALLBACK;<status> thrown from progressive actions now return status responses instead of going through the generic 500 path. Added 404 and 403 coverage.
  • isProgressiveServerActionRequest now normalizes method casing to match the route-handler classifier.
  • Added coverage for the next-action fallback header and documented the strict application/x-www-form-urlencoded parity behavior.
  • Removed optional chaining from the getSetCookie() assertion.

Left unchanged deliberately:

  • The open-redirect defense-in-depth note is broader than this PR because the existing fetch-action redirect path has the same Next.js-parity surface. I did not patch only the new progressive helper and leave the older path divergent.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 26, 2026

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

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

Thanks for the thorough follow-up — every actionable item from the previous review has been addressed:

  • Body consumption: request.clone() before readFormDataWithLimit lets non-action multipart POSTs and successful non-redirect MPA actions fall through cleanly. Both paths now have unit coverage (returns null for non-action multipart posts without consuming the original body, falls through after successful non-redirect actions without consuming the original body).
  • HTTP fallback errors: getActionControlResponse now decodes NEXT_NOT_FOUND and NEXT_HTTP_ERROR_FALLBACK;<status> into typed status responses, and the corresponding test iterates over both 404 and 403 cases.
  • Method casing: isProgressiveServerActionRequest now normalises with .toUpperCase(), matching isPossibleAppRouteActionRequest. Test exercises lowercase post to lock that behaviour in.
  • Parity comments: Strict application/x-www-form-urlencoded equality and the deferred form-state re-render path are both now annotated as deliberate parity gaps — that's exactly the right call.
  • Test cleanup: getSetCookie() no longer uses optional chaining, and the next-action fallback header is now exercised in app-route-handler-policy.test.ts.

Leaving the open-redirect defense-in-depth note as a follow-up that should cover both the progressive helper and the pre-existing fetch-action path is also the right choice — patching only one site would create exactly the kind of silent dev/prod parity drift AGENTS.md warns against.

Two non-blocking observations on the latest revision below — neither needs to land in this PR.

LGTM as soon as CI is green.

}

if (digest === "NEXT_NOT_FOUND" || digest.startsWith("NEXT_HTTP_ERROR_FALLBACK;")) {
const statusCode = digest === "NEXT_NOT_FOUND" ? 404 : parseInt(digest.split(";")[1], 10);
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.

Minor: parseInt(digest.split(";")[1], 10) returns NaN for a malformed NEXT_HTTP_ERROR_FALLBACK; (no status, or non-numeric). The follow-up Number.isInteger(statusCode) guard catches that and returns null, which then propagates back to the outer catch and turns into a 500 — i.e. malformed fallback digests behave identically to non-special errors. That's a defensible choice (a malformed digest is a programming error, not a user-facing case), but worth a one-line comment to make the intent explicit, or alternatively factor this digest-parsing code out and share it with resolveAppRouteHandlerSpecialError in app-route-handler-policy.ts, which has the exact same shape. Not blocking.

// Progressive submissions can still fall through to a regular page render when
// the multipart body is not an action payload. Read a clone so that fallback
// code can still consume the original request body.
body = await options.readFormDataWithLimit(
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.

Subtle reliability note for follow-up: request.clone() followed by readFormDataWithLimit(clone, …) doubles the in-memory peak for a near-limit multipart payload, because both the original and the clone retain their own buffered copy of the body until one is GC'd. For the current __MAX_ACTION_BODY_SIZE default this is fine, but if the limit is raised significantly (or applied per-route) on Workers it becomes meaningful.

A cheaper alternative would be to gate the clone behind a quick areAllActionIdsValid-style check on the parsed FormData keys (matching Next.js's action-handler.ts flow), but that requires plumbing the server module map through to this helper. Worth filing as a follow-up if Cloudflare-deployed apps start running into memory pressure on multipart POSTs.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 26, 2026

Review posted: #910 (review)

Summary

The previous review's actionable items have all been addressed in commit ff42478:

  • Body consumption fixed via request.clone(), with unit tests for both the non-action multipart and successful non-redirect action fall-through paths.
  • HTTP fallback errors (NEXT_NOT_FOUND, NEXT_HTTP_ERROR_FALLBACK;<status>) now map to proper status responses instead of 500, with 404/403 coverage.
  • Method casing normalized in isProgressiveServerActionRequest to match isPossibleAppRouteActionRequest.
  • Parity comments added for strict application/x-www-form-urlencoded equality and the deferred form-state re-render path.
  • Test cleanup: optional chaining removed from getSetCookie(), next-action fallback header now covered.
  • Open-redirect defense-in-depth deferred to a separate PR (correct call — patching only the new helper would diverge from the existing fetch-action path).

Posted two non-blocking follow-up notes (malformed digest handling could be commented or factored to share with resolveAppRouteHandlerSpecialError; request.clone() doubles in-memory peak for near-limit multipart payloads). LGTM once CI is green.

github run

@NathanDrake2406 NathanDrake2406 changed the title Fix app form action redirects fix(app-router): action redirects Apr 27, 2026
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.

3 participants