Skip to content

Commit d07db5b

Browse files
committed
fix: redirect should always return updated router state
1 parent c0c75e4 commit d07db5b

File tree

8 files changed

+115
-12
lines changed

8 files changed

+115
-12
lines changed

packages/next/src/client/components/redirect-boundary.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export class RedirectErrorBoundary extends React.Component<
4848
if (isRedirectError(error)) {
4949
const url = getURLFromRedirectError(error)
5050
const redirectType = getRedirectTypeFromError(error)
51+
if ('handled' in error) {
52+
// The redirect was already handled. We'll still catch the redirect error
53+
// so that we can remount the subtree, but we don't actually need to trigger the
54+
// router.push.
55+
return { redirect: null, redirectType: null }
56+
}
57+
5158
return { redirect: url, redirectType }
5259
}
5360
// Re-throw if error is not for redirect

packages/next/src/client/components/router-reducer/reducers/server-action-reducer.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -415,19 +415,19 @@ export function serverActionReducer(
415415
// the component that called the action as the error boundary will remount the tree.
416416
// The status code doesn't matter here as the action handler will have already sent
417417
// a response with the correct status code.
418-
reject(
419-
getRedirectError(
420-
hasBasePath(redirectHref)
421-
? removeBasePath(redirectHref)
422-
: redirectHref,
423-
redirectType || RedirectType.push
424-
)
418+
const redirectError = getRedirectError(
419+
hasBasePath(redirectHref)
420+
? removeBasePath(redirectHref)
421+
: redirectHref,
422+
redirectType || RedirectType.push
425423
)
426-
427-
// TODO: Investigate why this is needed with Activity.
428-
if (process.env.__NEXT_CACHE_COMPONENTS) {
429-
return state
430-
}
424+
// We mark the error as handled because we don't want the redirect to be tried later by
425+
// the RedirectBoundary, in case the user goes back and `Activity` triggers the redirect
426+
// again, as it's run within an effect.
427+
// We don't actually need the RedirectBoundary to do a router.push because we already
428+
// have all the necessary RSC data to render the new page within a single roundtrip.
429+
;(redirectError as any).handled = true
430+
reject(redirectError)
431431
} else {
432432
resolve(actionResult)
433433
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use server'
2+
3+
import { cookies } from 'next/headers'
4+
import { refresh } from 'next/cache'
5+
import { redirect } from 'next/navigation'
6+
7+
export async function addTodoEntry(entry: string) {
8+
// simulate some latency
9+
await new Promise((resolve) => setTimeout(resolve, 500))
10+
11+
const cookieStore = await cookies()
12+
const existing = cookieStore.get('todo-entries')?.value || '[]'
13+
const entries = JSON.parse(existing)
14+
entries.push(entry)
15+
cookieStore.set('todo-entries', JSON.stringify(entries))
16+
}
17+
18+
export async function getTodoEntries() {
19+
const cookieStore = await cookies()
20+
const existing = cookieStore.get('todo-entries')?.value || '[]'
21+
return JSON.parse(existing) as string[]
22+
}
23+
24+
export async function addEntryAndRefresh(formData: FormData) {
25+
const entry = formData.get('entry') as string
26+
27+
await addTodoEntry(entry)
28+
29+
refresh()
30+
redirect('/redirect-and-refresh/foo')
31+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Foo() {
2+
return <div id="foo-page">Hello from Foo</div>
3+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { getTodoEntries } from './actions'
2+
3+
export default async function Layout({ children }) {
4+
const entries = await getTodoEntries()
5+
6+
console.log({ entries })
7+
return (
8+
<div>
9+
<div id="todo-entries">
10+
{entries.length > 0 ? (
11+
entries.map((phrase, index) => <div key={index}>{phrase}</div>)
12+
) : (
13+
<div id="no-entries">No entries</div>
14+
)}
15+
</div>
16+
<div>{children}</div>
17+
</div>
18+
)
19+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { addEntryAndRefresh } from './actions'
2+
3+
export default function Page() {
4+
return (
5+
<>
6+
<form action={addEntryAndRefresh}>
7+
<label htmlFor="todo-input">Entry</label>
8+
<input
9+
id="todo-input"
10+
type="text"
11+
name="entry"
12+
placeholder="Enter a new entry"
13+
/>
14+
<button id="add-button" type="submit">
15+
Add New Todo Entry
16+
</button>
17+
</form>
18+
</>
19+
)
20+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports = {
2+
cacheComponents: true,
3+
}

test/e2e/app-dir/refresh/refresh.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,24 @@ describe('app-dir refresh', () => {
8080
})
8181
}
8282
})
83+
84+
it('should let you read your write after a redirect and refresh', async () => {
85+
const browser = await next.browser('/redirect-and-refresh')
86+
87+
const todoEntries = await browser.elementById('todo-entries').text()
88+
expect(todoEntries).toBe('No entries')
89+
90+
const todoInput = await browser.elementById('todo-input')
91+
await todoInput.fill('foo')
92+
93+
await browser.elementById('add-button').click()
94+
95+
await retry(async () => {
96+
const newTodoEntries = await browser.elementById('todo-entries').text()
97+
expect(newTodoEntries).toContain('foo')
98+
})
99+
100+
expect(await browser.hasElementByCssSelector('#foo-page')).toBe(true)
101+
expect(await browser.url()).toContain('/redirect-and-refresh/foo')
102+
})
83103
})

0 commit comments

Comments
 (0)