Skip to content

Commit 88fc430

Browse files
unstubbableijjk
authored andcommitted
Fix server actions in standalone mode with cacheComponents (#91711)
The `staticPathKey` condition added in #91231 inadvertently applies to server action requests on dynamic SSG routes when `cacheComponents` is enabled. Server action fetch requests from the client do not send the `RSC` header (`rsc: 1`). They only send `Accept: text/x-component` and the `Next-Action` header. This means `isRSCRequest` and `isDynamicRSCRequest` are both `false` for action requests. The new `staticPathKey` condition (`isSSG && pageIsDynamic && prerenderInfo?.fallbackRouteParams`) evaluates to `true` for dynamic PPR routes, setting `staticPathKey` even though `ssgCacheKey` is `null` for actions. With `staticPathKey` set, the request enters the fallback rendering block, which serves the cached fallback HTML shell with the action result appended to it, instead of responding with just the RSC action result. The fix excludes server action requests from the `staticPathKey` computation by adding `!isPossibleServerAction` to the condition, restoring the pre-#91231 behavior where `staticPathKey` was always `null` for server actions in production. fixes #91662 closes #91677 closes #91669
1 parent 37aed86 commit 88fc430

File tree

8 files changed

+193
-1
lines changed

8 files changed

+193
-1
lines changed

packages/next/src/build/templates/app-page.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -620,7 +620,14 @@ export async function handler(
620620
if (
621621
!staticPathKey &&
622622
(routeModule.isDev ||
623-
(isSSG && pageIsDynamic && prerenderInfo?.fallbackRouteParams))
623+
(isSSG &&
624+
pageIsDynamic &&
625+
prerenderInfo?.fallbackRouteParams &&
626+
// Server action requests must not get a staticPathKey, otherwise they
627+
// enter the fallback rendering block below and return the cached HTML
628+
// shell with the action result appended, instead of responding with
629+
// just the RSC action result.
630+
!isPossibleServerAction))
624631
) {
625632
staticPathKey = resolvedPathname
626633
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
'use client'
2+
3+
import { Component, type ReactNode } from 'react'
4+
import { useActionState } from 'react'
5+
6+
class ErrorBoundary extends Component<
7+
{ children: ReactNode },
8+
{ error: string | null }
9+
> {
10+
constructor(props: { children: ReactNode }) {
11+
super(props)
12+
this.state = { error: null }
13+
}
14+
15+
static getDerivedStateFromError(error: Error) {
16+
return { error: error.message }
17+
}
18+
19+
render() {
20+
if (this.state.error) {
21+
return (
22+
<p id="result" style={{ color: 'red' }}>
23+
{this.state.error}
24+
</p>
25+
)
26+
}
27+
28+
return this.props.children
29+
}
30+
}
31+
32+
function Form({ action }: { action: () => Promise<string> }) {
33+
const [result, formAction] = useActionState(action, '')
34+
35+
return (
36+
<form action={formAction}>
37+
<button>Submit</button>
38+
{result && (
39+
<p id="result" style={{ color: 'green' }}>
40+
{result}
41+
</p>
42+
)}
43+
</form>
44+
)
45+
}
46+
47+
export function FormWithErrorBoundary({
48+
action,
49+
}: {
50+
action: () => Promise<string>
51+
}) {
52+
return (
53+
<ErrorBoundary>
54+
<Form action={action} />
55+
</ErrorBoundary>
56+
)
57+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { FormWithErrorBoundary } from './form'
2+
3+
export function generateStaticParams() {
4+
return [{ slug: 'world' }]
5+
}
6+
7+
export default async function Page({
8+
params,
9+
}: {
10+
params: Promise<{ slug: string }>
11+
}) {
12+
const { slug } = await params
13+
14+
return (
15+
<FormWithErrorBoundary
16+
action={async () => {
17+
'use server'
18+
return `hello ${slug}`
19+
}}
20+
/>
21+
)
22+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Suspense } from 'react'
2+
3+
export default function Layout({ children }: { children: React.ReactNode }) {
4+
return (
5+
<html>
6+
<body>
7+
<Suspense>{children}</Suspense>
8+
</body>
9+
</html>
10+
)
11+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/bin/bash
2+
set -e
3+
4+
DIR="test/production/standalone-mode/server-actions"
5+
6+
pnpm next build "$DIR"
7+
cp -r "$DIR/public" "$DIR/.next/standalone/$DIR/"
8+
cp -r "$DIR/.next/static" "$DIR/.next/standalone/$DIR/.next/"
9+
10+
echo ""
11+
echo "Build complete. To start the server:"
12+
echo " node $DIR/.next/standalone/$DIR/server.js"
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { NextConfig } from 'next'
2+
3+
const nextConfig: NextConfig = {
4+
output: 'standalone',
5+
}
6+
7+
export default nextConfig
Binary file not shown.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { ChildProcess } from 'child_process'
2+
import { NextInstance, createNext } from 'e2e-utils'
3+
import fs from 'fs-extra'
4+
import { findPort, initNextServerScript, killApp } from 'next-test-utils'
5+
import { join } from 'path'
6+
import webdriver from 'next-webdriver'
7+
8+
describe('standalone mode: server actions', () => {
9+
let next: NextInstance
10+
let server: ChildProcess
11+
let appPort: number
12+
13+
beforeAll(async () => {
14+
next = await createNext({
15+
files: __dirname,
16+
skipStart: true,
17+
})
18+
await next.build()
19+
20+
await fs.move(
21+
join(next.testDir, '.next/standalone'),
22+
join(next.testDir, 'standalone')
23+
)
24+
25+
await fs.copy(
26+
join(next.testDir, 'public'),
27+
join(next.testDir, 'standalone/public')
28+
)
29+
30+
await fs.copy(
31+
join(next.testDir, '.next/static'),
32+
join(next.testDir, 'standalone/.next/static')
33+
)
34+
35+
for (const file of await fs.readdir(next.testDir)) {
36+
if (file !== 'standalone') {
37+
await fs.remove(join(next.testDir, file))
38+
console.log('removed', file)
39+
}
40+
}
41+
42+
const testServer = join(next.testDir, 'standalone/server.js')
43+
appPort = await findPort()
44+
server = await initNextServerScript(testServer, /- Local:/, {
45+
...process.env,
46+
...next.env,
47+
HOSTNAME: '::',
48+
PORT: '' + appPort,
49+
})
50+
})
51+
52+
afterAll(async () => {
53+
await next.destroy()
54+
55+
if (server) {
56+
await killApp(server)
57+
}
58+
})
59+
60+
it('should be able to execute server actions', async () => {
61+
const browser = await webdriver(appPort, `/world`)
62+
await browser.elementByCss('button').click()
63+
64+
expect(await browser.elementByCss('#result').text()).toBe('hello world')
65+
})
66+
67+
it('should be able to execute MPA server actions', async () => {
68+
const browser = await webdriver(appPort, `/world`, {
69+
disableJavaScript: true,
70+
})
71+
72+
await browser.elementByCss('button').click()
73+
74+
expect(await browser.elementByCss('#result').text()).toBe('hello world')
75+
})
76+
})

0 commit comments

Comments
 (0)