Skip to content

fix: default redirect() to "push" in Server Action context#779

Merged
james-elicx merged 1 commit intocloudflare:mainfrom
NathanDrake2406:fix/server-action-redirect-type
Apr 4, 2026
Merged

fix: default redirect() to "push" in Server Action context#779
james-elicx merged 1 commit intocloudflare:mainfrom
NathanDrake2406:fix/server-action-redirect-type

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Summary

  • redirect() called inside a Server Action always used "replace" navigation, breaking the Back button after form submissions. Next.js defaults to "push" in action context (via actionAsyncStorage.getStore()?.isAction).
  • Instead of adding a new AsyncLocalStorage, use an empty string sentinel in the digest when no explicit type is provided. The action handler resolves empty to "push", while SSR render contexts only extract location/statusCode (no type needed at HTTP level).
  • Also adds an optional type parameter to permanentRedirect() matching the Next.js signature (defaults to "replace").

How it works

  1. redirect("/foo") now produces digest NEXT_REDIRECT;;%2Ffoo (empty type field)
  2. redirect("/foo", "push") produces NEXT_REDIRECT;push;%2Ffoo (explicit, always respected)
  3. The action handler in app-rsc-entry.ts resolves empty to "push" (parts[1] || "push")
  4. The browser entry reads x-action-redirect-type header and uses window.location.assign() for push / window.location.replace() for replace

Next.js reference

Test plan

  • redirect() without explicit type produces empty sentinel in digest
  • redirect() with explicit "push" or "replace" preserves type in digest
  • Semicolon-in-URL encoding still prevents digest injection (updated assertion)
  • permanentRedirect() accepts optional type parameter
  • permanentRedirect() defaults to "replace" when no type given
  • Generated action handler defaults empty type to "push" (template assertion)
  • Entry template snapshots updated
  • All existing redirect/error-boundary/route-handler tests pass

redirect() called inside a Server Action always used "replace" navigation,
breaking the Back button after form submissions. Next.js defaults to "push"
in action context via actionAsyncStorage.

Instead of adding a new AsyncLocalStorage, use an empty string sentinel in
the digest when no explicit type is provided. The catch site resolves the
default based on context: "push" for Server Actions, "replace" for SSR
renders. This matches Next.js behavior.

Also adds an optional `type` parameter to permanentRedirect() matching
the Next.js signature (defaults to "replace").
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 4, 2026

Open in StackBlitz

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

commit: df4b44a

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

Good fix. The approach of deferring type resolution to the catch site via an empty sentinel is a pragmatic alternative to adding a new AsyncLocalStorage (which is what Next.js uses with actionAsyncStorage). The SSR catch sites (app-route-handler-policy.ts, app-page-execution.ts) only extract location and statusCode from the digest and ignore parts[1], so the empty sentinel is harmless there. The action handler correctly resolves empty to "push", and the browser entry already falls back to "replace" when the header is absent.

Tests are thorough — they cover the empty sentinel, explicit types, semicolon injection, permanentRedirect with optional type, and the generated template assertion. The permanentRedirect change correctly matches Next.js, which also defaults to "replace" without isAction context-dependence.

One minor observation and one question below, but neither blocks merging.

throw new VinextNavigationError(
`NEXT_REDIRECT:${url}`,
`NEXT_REDIRECT;${type ?? "replace"};${encodeURIComponent(url)}`,
`NEXT_REDIRECT;${type ?? ""};${encodeURIComponent(url)}`,
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: worth noting a subtle difference from Next.js here. Next.js resolves the type at call time using actionAsyncStorage.getStore()?.isAction, so the digest always carries an explicit "push" or "replace". Vinext defers it to the catch site using an empty sentinel.

This works correctly today because the only catch site that cares about the type is the Server Action handler (which defaults empty → "push"). The SSR catch sites (app-page-execution.ts, app-route-handler-policy.ts) ignore the type field entirely.

If a future catch site needs the type and doesn't know whether it's in an action context, the sentinel approach would need revisiting. Not a blocker — just documenting the trade-off for future readers.

export function permanentRedirect(url: string): never {
export function permanentRedirect(
url: string,
type: "replace" | "push" | RedirectType = "replace",
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.

Next.js also does not use isAction-dependent defaulting for permanentRedirect — it always defaults to "replace". This matches. Good.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented Apr 4, 2026

Review submitted. Here's a summary of my assessment:

Approved. The PR correctly fixes redirect() to default to "push" in Server Action context, matching Next.js behavior where the Back button should work after form submissions.

What the PR does well:

  • Uses an empty sentinel in the digest (NEXT_REDIRECT;;%2Ffoo) to defer type resolution to the catch site, avoiding a new AsyncLocalStorage. This is a pragmatic design trade-off vs Next.js's actionAsyncStorage.getStore()?.isAction approach.
  • The SSR catch sites (app-page-execution.ts, app-route-handler-policy.ts) only extract location and statusCode — they never read parts[1], so the empty sentinel is harmless.
  • The browser entry (app-browser-entry.ts:526) already falls back to "replace" when the header is missing, providing correct default behavior for non-action contexts.
  • permanentRedirect() correctly matches Next.js by always defaulting to "replace" (no action-context dependence), and the optional type parameter is added to match the full signature.
  • Test coverage is thorough: empty sentinel, explicit types, semicolon injection, permanentRedirect with/without type, and generated template assertion.

One observation left as a comment: The sentinel approach differs from Next.js's call-time resolution. If a future catch site needs the type without knowing whether it's in an action context, this would need revisiting. Not blocking — just documented for future readers.

github run

@james-elicx james-elicx merged commit 336289b into cloudflare:main Apr 4, 2026
26 checks passed
@NathanDrake2406 NathanDrake2406 deleted the fix/server-action-redirect-type branch April 5, 2026 17:49
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