Skip to content

Commit 5bb867a

Browse files
authored
fix: next/root-params erroring when rerendering after action (vercel#82326)
When implementing the check that disallows using `next/root-params` in server actions, i forgot that when a page is rerendered after an action, the render is still wrapped with an `actionAsyncStorage` with `actionStore.isAction === true`. The fix is to also check that we're in the `'action'` phase, i.e. we haven't switched to rendering yet. Closes vercel#82302
1 parent b322932 commit 5bb867a

File tree

4 files changed

+111
-12
lines changed

4 files changed

+111
-12
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -771,5 +771,6 @@
771771
"770": "createParamsFromClient should not be called in a runtime prerender.",
772772
"771": "\\`%s\\` was called during a runtime prerender. Next.js should be preventing %s from being included in server components statically, but did not in this case.",
773773
"772": "FetchStrategy.PPRRuntime should never be used when `experimental.clientSegmentCache` is disabled",
774-
"773": "Missing workStore in createPrerenderParamsForClientSegment"
774+
"773": "Missing workStore in createPrerenderParamsForClientSegment",
775+
"774": "Route %s used %s outside of a Server Component. This is not allowed."
775776
}

packages/next/src/server/request/root-params.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,13 @@ export function getRootParam(paramName: string): Promise<ParamValue> {
203203
throw new InvariantError(`Missing workStore in ${apiName}`)
204204
}
205205

206+
const workUnitStore = workUnitAsyncStorage.getStore()
207+
if (!workUnitStore) {
208+
throw new Error(
209+
`Route ${workStore.route} used ${apiName} outside of a Server Component. This is not allowed.`
210+
)
211+
}
212+
206213
const actionStore = actionAsyncStorage.getStore()
207214
if (actionStore) {
208215
if (actionStore.isAppRoute) {
@@ -211,23 +218,17 @@ export function getRootParam(paramName: string): Promise<ParamValue> {
211218
`Route ${workStore.route} used ${apiName} inside a Route Handler. Support for this API in Route Handlers is planned for a future version of Next.js.`
212219
)
213220
}
214-
if (actionStore.isAction) {
221+
if (actionStore.isAction && workUnitStore.phase === 'action') {
215222
// Actions are not fundamentally tied to a route (even if they're always submitted from some page),
216223
// so root params would be inconsistent if an action is called from multiple roots.
224+
// Make sure we check if the phase is "action" - we should not error in the rerender
225+
// after an action revalidates or updates cookies (which will still have `actionStore.isAction === true`)
217226
throw new Error(
218227
`${apiName} was used inside a Server Action. This is not supported. Functions from 'next/root-params' can only be called in the context of a route.`
219228
)
220229
}
221230
}
222231

223-
const workUnitStore = workUnitAsyncStorage.getStore()
224-
225-
if (!workUnitStore) {
226-
throw new Error(
227-
`Route ${workStore.route} used ${apiName} in Pages Router. This API is only available within App Router.`
228-
)
229-
}
230-
231232
switch (workUnitStore.type) {
232233
case 'unstable-cache':
233234
case 'cache': {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { lang, locale } from 'next/root-params'
2+
import { connection } from 'next/server'
3+
import { cookies } from 'next/headers'
4+
import { Suspense } from 'react'
5+
6+
export default async function Page() {
7+
const currentLang = await lang()
8+
const currentLocale = await locale()
9+
return (
10+
<main>
11+
<div>
12+
Root params are{' '}
13+
<span id="root-params">
14+
{currentLang} {currentLocale}
15+
</span>
16+
</div>
17+
<Suspense fallback="Loading...">
18+
<Timestamp />
19+
</Suspense>
20+
<form
21+
action={async () => {
22+
'use server'
23+
// rerender the page and return it alongside the action result
24+
const cookieStore = await cookies()
25+
cookieStore.set('my-cookie', Date.now() + '')
26+
}}
27+
>
28+
<button type="submit">Submit form</button>
29+
</form>
30+
</main>
31+
)
32+
}
33+
34+
async function Timestamp() {
35+
await connection()
36+
return <div id="timestamp">{Date.now()}</div>
37+
}

test/e2e/app-dir/app-root-params-getters/simple.test.ts

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,23 @@ import { outdent } from 'outdent'
66
import { createRequestTracker } from '../../../lib/e2e-utils/request-tracker'
77

88
describe('app-root-param-getters - simple', () => {
9+
let currentCliOutputIndex = 0
10+
beforeEach(() => {
11+
resetCliOutput()
12+
})
13+
14+
const getCliOutput = () => {
15+
if (next.cliOutput.length < currentCliOutputIndex) {
16+
// cliOutput shrank since we started the test, so something (like a `sandbox`) reset the logs
17+
currentCliOutputIndex = 0
18+
}
19+
return next.cliOutput.slice(currentCliOutputIndex)
20+
}
21+
22+
const resetCliOutput = () => {
23+
currentCliOutputIndex = next.cliOutput.length
24+
}
25+
926
const { next, isNextDev, isTurbopack, isNextDeploy } = nextTestSetup({
1027
files: join(__dirname, 'fixtures', 'simple'),
1128
})
@@ -109,12 +126,55 @@ describe('app-root-param-getters - simple', () => {
109126
)
110127
expect(response.status()).toBe(500)
111128
if (!isNextDeploy) {
112-
expect(next.cliOutput).toInclude(
129+
expect(getCliOutput()).toInclude(
113130
"`import('next/root-params').lang()` was used inside a Server Action. This is not supported. Functions from 'next/root-params' can only be called in the context of a route."
114131
)
115132
}
116133
})
117134

135+
it('should not error when rerendering the page after a server action', async () => {
136+
const params = { lang: 'en', locale: 'us' }
137+
const browser = await next.browser(
138+
`/${params.lang}/${params.locale}/rerender-after-server-action`
139+
)
140+
expect(await browser.elementById('root-params').text()).toBe(
141+
`${params.lang} ${params.locale}`
142+
)
143+
const initialDate = await browser.elementById('timestamp')
144+
145+
// Run a server action and rerender the page
146+
const tracker = createRequestTracker(browser)
147+
const [, response] = await tracker.captureResponse(
148+
async () => {
149+
await browser.elementByCss('button[type="submit"]').click()
150+
},
151+
{
152+
request: {
153+
method: 'POST',
154+
pathname: `/${params.lang}/${params.locale}/rerender-after-server-action`,
155+
},
156+
}
157+
)
158+
// We're using lang() outside of an action, so we should see no errors
159+
expect(response.status()).toBe(200)
160+
if (!isNextDeploy) {
161+
expect(getCliOutput()).not.toInclude(
162+
"`import('next/root-params').lang()` was used inside a Server Action. This is not supported. Functions from 'next/root-params' can only be called in the context of a route."
163+
)
164+
}
165+
166+
await retry(async () => {
167+
// The page should've been rerendered because of the cookie update
168+
const updatedDate = await browser.elementById('timestamp')
169+
expect(initialDate).not.toEqual(updatedDate)
170+
})
171+
172+
// It should still display correct root params
173+
expect(await browser.elementById('root-params').text()).toBe(
174+
`${params.lang} ${params.locale}`
175+
)
176+
})
177+
118178
// TODO(root-params): add support for route handlers
119179
it('should error when used in a route handler (until we implement it)', async () => {
120180
const params = { lang: 'en', locale: 'us' }
@@ -123,7 +183,7 @@ describe('app-root-param-getters - simple', () => {
123183
)
124184
expect(response.status).toBe(500)
125185
if (!isNextDeploy) {
126-
expect(next.cliOutput).toInclude(
186+
expect(getCliOutput()).toInclude(
127187
"Route /[lang]/[locale]/route-handler used `import('next/root-params').lang()` inside a Route Handler. Support for this API in Route Handlers is planned for a future version of Next.js."
128188
)
129189
}

0 commit comments

Comments
 (0)