Skip to content

Commit e06df8c

Browse files
ztannerhuozhi
authored andcommitted
fix: redirect should always return updated router state (#85533)
When a server action triggers a redirect, it will return the RSC payload for the destination state in a single roundtrip. This lets us update the UI without needing to waterfall when getting the target page's data. We reject the server action with a special error signaling that the form state should be reset, but otherwise the redirect itself is unnecessary, since we already are handling it in the server action reducer. However, when we introduced Activity for bfCache, this meant navigating back to the previous page would replay the redirect error that was stashed in the `RedirectBoundary`. I incorrectly patched this by just returning the current router state, rather than the redirect destination's state, but this lost the ability to return the updated state in the same roundtrip as the redirect. Instead, this marks the error that gets stashed in the `RedirectBoundary` as being "handled". Re-activating the hidden subtree will now still correctly reset the state (because the boundary handles it), but won't attempt to perform the redirect again. Closes NAR-470
1 parent 1f71e7d commit e06df8c

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)