diff --git a/packages/next/src/client/components/redirect-boundary.tsx b/packages/next/src/client/components/redirect-boundary.tsx index 03ea9553b40bf..470acc9ee1ce0 100644 --- a/packages/next/src/client/components/redirect-boundary.tsx +++ b/packages/next/src/client/components/redirect-boundary.tsx @@ -48,6 +48,13 @@ export class RedirectErrorBoundary extends React.Component< if (isRedirectError(error)) { const url = getURLFromRedirectError(error) const redirectType = getRedirectTypeFromError(error) + if ('handled' in error) { + // The redirect was already handled. We'll still catch the redirect error + // so that we can remount the subtree, but we don't actually need to trigger the + // router.push. + return { redirect: null, redirectType: null } + } + return { redirect: url, redirectType } } // Re-throw if error is not for redirect 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 4084a49cd35f3..b5881bc67020a 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 @@ -415,19 +415,19 @@ export function serverActionReducer( // the component that called the action as the error boundary will remount the tree. // The status code doesn't matter here as the action handler will have already sent // a response with the correct status code. - reject( - getRedirectError( - hasBasePath(redirectHref) - ? removeBasePath(redirectHref) - : redirectHref, - redirectType || RedirectType.push - ) + const redirectError = getRedirectError( + hasBasePath(redirectHref) + ? removeBasePath(redirectHref) + : redirectHref, + redirectType || RedirectType.push ) - - // TODO: Investigate why this is needed with Activity. - if (process.env.__NEXT_CACHE_COMPONENTS) { - return state - } + // We mark the error as handled because we don't want the redirect to be tried later by + // the RedirectBoundary, in case the user goes back and `Activity` triggers the redirect + // again, as it's run within an effect. + // We don't actually need the RedirectBoundary to do a router.push because we already + // have all the necessary RSC data to render the new page within a single roundtrip. + ;(redirectError as any).handled = true + reject(redirectError) } else { resolve(actionResult) } diff --git a/test/e2e/app-dir/refresh/app/redirect-and-refresh/actions.ts b/test/e2e/app-dir/refresh/app/redirect-and-refresh/actions.ts new file mode 100644 index 0000000000000..88a202395aba8 --- /dev/null +++ b/test/e2e/app-dir/refresh/app/redirect-and-refresh/actions.ts @@ -0,0 +1,31 @@ +'use server' + +import { cookies } from 'next/headers' +import { refresh } from 'next/cache' +import { redirect } from 'next/navigation' + +export async function addTodoEntry(entry: string) { + // simulate some latency + await new Promise((resolve) => setTimeout(resolve, 500)) + + const cookieStore = await cookies() + const existing = cookieStore.get('todo-entries')?.value || '[]' + const entries = JSON.parse(existing) + entries.push(entry) + cookieStore.set('todo-entries', JSON.stringify(entries)) +} + +export async function getTodoEntries() { + const cookieStore = await cookies() + const existing = cookieStore.get('todo-entries')?.value || '[]' + return JSON.parse(existing) as string[] +} + +export async function addEntryAndRefresh(formData: FormData) { + const entry = formData.get('entry') as string + + await addTodoEntry(entry) + + refresh() + redirect('/redirect-and-refresh/foo') +} diff --git a/test/e2e/app-dir/refresh/app/redirect-and-refresh/foo/page.tsx b/test/e2e/app-dir/refresh/app/redirect-and-refresh/foo/page.tsx new file mode 100644 index 0000000000000..91a3a3dcd668d --- /dev/null +++ b/test/e2e/app-dir/refresh/app/redirect-and-refresh/foo/page.tsx @@ -0,0 +1,3 @@ +export default function Foo() { + return