diff --git a/packages/next/src/client/components/redirect.ts b/packages/next/src/client/components/redirect.ts index f75c2cd08588b..fe787f5e94bfb 100644 --- a/packages/next/src/client/components/redirect.ts +++ b/packages/next/src/client/components/redirect.ts @@ -37,18 +37,21 @@ export function getRedirectError( * * - In a Server Component, this will insert a meta tag to redirect the user to the target page. * - In a Route Handler or Server Action, it will serve a 307/303 to the caller. + * - In a Server Action, type defaults to 'push' and 'replace' elsewhere. * * Read more: [Next.js Docs: `redirect`](https://nextjs.org/docs/app/api-reference/functions/redirect) */ export function redirect( /** The URL to redirect to */ url: string, - type: RedirectType = RedirectType.replace + type?: RedirectType ): never { const actionStore = actionAsyncStorage.getStore() + const redirectType = + type || (actionStore?.isAction ? RedirectType.push : RedirectType.replace) throw getRedirectError( url, - type, + redirectType, // If we're in an action, we want to use a 303 redirect // as we don't want the POST request to follow the redirect, // as it could result in erroneous re-submissions. diff --git a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts index 803dc7f54175f..fd1f0846085c7 100644 --- a/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts +++ b/packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts @@ -52,6 +52,7 @@ import { hasBasePath } from '../../../has-base-path' type FetchServerActionResult = { redirectLocation: URL | undefined + redirectType: RedirectType | undefined actionResult?: ActionResult actionFlightData?: NormalizedFlightData[] | string isPrerender: boolean @@ -91,7 +92,20 @@ async function fetchServerAction( body, }) - const location = res.headers.get('x-action-redirect') + const redirectHeader = res.headers.get('x-action-redirect') + const [location, _redirectType] = redirectHeader?.split(';') || [] + let redirectType: RedirectType | undefined + switch (_redirectType) { + case 'push': + redirectType = RedirectType.push + break + case 'replace': + redirectType = RedirectType.replace + break + default: + redirectType = undefined + } + const isPrerender = !!res.headers.get(NEXT_IS_PRERENDER_HEADER) let revalidatedParts: FetchServerActionResult['revalidatedParts'] try { @@ -134,6 +148,7 @@ async function fetchServerAction( return { actionFlightData: normalizeFlightData(response.f), redirectLocation, + redirectType, revalidatedParts, isPrerender, } @@ -143,6 +158,7 @@ async function fetchServerAction( actionResult: response.a, actionFlightData: normalizeFlightData(response.f), redirectLocation, + redirectType, revalidatedParts, isPrerender, } @@ -162,6 +178,7 @@ async function fetchServerAction( return { redirectLocation, + redirectType, revalidatedParts, isPrerender, } @@ -197,13 +214,18 @@ export function serverActionReducer( actionResult, actionFlightData: flightData, redirectLocation, + redirectType, isPrerender, }) => { - // Make sure the redirection is a push instead of a replace. - // Issue: https://github.com/vercel/next.js/issues/53911 + // honor the redirect type instead of defaulting to push in case of server actions. if (redirectLocation) { - state.pushRef.pendingPush = true - mutable.pendingPush = true + if (redirectType === RedirectType.replace) { + state.pushRef.pendingPush = false + mutable.pendingPush = false + } else { + state.pushRef.pendingPush = true + mutable.pendingPush = true + } } if (!flightData) { @@ -263,7 +285,7 @@ export function serverActionReducer( reject( getRedirectError( hasBasePath(newHref) ? removeBasePath(newHref) : newHref, - RedirectType.push + redirectType || RedirectType.push ) ) diff --git a/packages/next/src/server/app-render/action-handler.ts b/packages/next/src/server/app-render/action-handler.ts index 6c1bffa84dd56..95cdaccb48ae4 100644 --- a/packages/next/src/server/app-render/action-handler.ts +++ b/packages/next/src/server/app-render/action-handler.ts @@ -14,8 +14,10 @@ import { import { isNotFoundError } from '../../client/components/not-found' import { getRedirectStatusCodeFromError, + getRedirectTypeFromError, getURLFromRedirectError, isRedirectError, + type RedirectType, } from '../../client/components/redirect' import RenderResult from '../render-result' import type { StaticGenerationStore } from '../../client/components/static-generation-async-storage.external' @@ -276,10 +278,11 @@ async function createRedirectRenderResult( res: BaseNextResponse, originalHost: Host, redirectUrl: string, + redirectType: RedirectType, basePath: string, staticGenerationStore: StaticGenerationStore ) { - res.setHeader('x-action-redirect', redirectUrl) + res.setHeader('x-action-redirect', `${redirectUrl};${redirectType}`) // If we're redirecting to another route of this Next.js application, we'll // try to stream the response from the other worker path. When that works, @@ -841,6 +844,7 @@ export async function handleAction({ if (isRedirectError(err)) { const redirectUrl = getURLFromRedirectError(err) const statusCode = getRedirectStatusCodeFromError(err) + const redirectType = getRedirectTypeFromError(err) await addRevalidationHeader(res, { staticGenerationStore, @@ -859,6 +863,7 @@ export async function handleAction({ res, host, redirectUrl, + redirectType, ctx.renderOpts.basePath, staticGenerationStore ), diff --git a/test/e2e/app-dir/actions/app-action.test.ts b/test/e2e/app-dir/actions/app-action.test.ts index 0f7a795c5679a..58d1c2dcf363b 100644 --- a/test/e2e/app-dir/actions/app-action.test.ts +++ b/test/e2e/app-dir/actions/app-action.test.ts @@ -182,6 +182,24 @@ describe('app-dir action handling', () => { }, 'setCookieAndRedirect') }) + it('should replace current route when redirecting with type set to replace', async () => { + const browser = await next.browser('/header') + + let historyLen = await browser.eval('window.history.length') + // chromium's about:blank page is the first item in history + expect(historyLen).toBe(2) + + await browser.elementByCss('#setCookieAndRedirectReplace').click() + await check(async () => { + return (await browser.elementByCss('#redirected').text()) || '' + }, 'redirected') + + // Ensure we cannot navigate back + historyLen = await browser.eval('window.history.length') + // chromium's about:blank page is the first item in history + expect(historyLen).toBe(2) + }) + it('should support headers in client imported actions', async () => { const logs: string[] = [] next.on('stdout', (log) => { diff --git a/test/e2e/app-dir/actions/app/header/actions.ts b/test/e2e/app-dir/actions/app/header/actions.ts index 4c10f7907c320..19dcc06be8cd4 100644 --- a/test/e2e/app-dir/actions/app/header/actions.ts +++ b/test/e2e/app-dir/actions/app/header/actions.ts @@ -24,7 +24,7 @@ export async function setCookie(name, value) { return cookies().get(name) } -export async function setCookieAndRedirect(name, value, path) { +export async function setCookieAndRedirect(name, value, path, type) { cookies().set(name, value) - redirect(path) + redirect(path, type) } diff --git a/test/e2e/app-dir/actions/app/header/ui.js b/test/e2e/app-dir/actions/app/header/ui.js index 68466ebf3a707..00bfca27cb389 100644 --- a/test/e2e/app-dir/actions/app/header/ui.js +++ b/test/e2e/app-dir/actions/app/header/ui.js @@ -79,6 +79,21 @@ export default function UI({ setCookieAndRedirect +
+ +
) }