Skip to content

Commit 7fdad74

Browse files
committed
ix(next): prevent cached fallback shell from intercepting server actions on dynamic routes
When cacheComponents is enabled, the build creates fallbackRouteParams for dynamic routes. This caused the fallback shell serving path in app-page.ts to trigger for server action POST requests, returning the prerendered HTML instead of executing the action. The condition at line 1076 now checks !isPossibleServerAction to skip the fallback shell for action requests.
1 parent afbce6a commit 7fdad74

File tree

4 files changed

+66
-0
lines changed

4 files changed

+66
-0
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1073,6 +1073,7 @@ export async function handler(
10731073
// contains param references, and therefore we can't use the fallback.
10741074
if (
10751075
isRoutePPREnabled &&
1076+
!isPossibleServerAction &&
10761077
(nextConfig.cacheComponents ? !isDynamicRSCRequest : !isRSCRequest)
10771078
) {
10781079
const cacheKey =
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
'use server'
2+
3+
export async function dynamicAction(value: string) {
4+
return value
5+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
'use client'
2+
3+
import { useState, useTransition } from 'react'
4+
import { dynamicAction } from './action'
5+
6+
export default function Page() {
7+
const [result, setResult] = useState('initial')
8+
const [isPending, startTransition] = useTransition()
9+
10+
return (
11+
<div>
12+
<h1>Server Action on Optional Catch-All Route</h1>
13+
<button
14+
id="action-button"
15+
onClick={() => {
16+
startTransition(async () => {
17+
const res = await dynamicAction('action-result')
18+
setResult(res)
19+
})
20+
}}
21+
>
22+
{isPending ? 'Pending...' : 'Call Action'}
23+
</button>
24+
<p id="action-result">{result}</p>
25+
</div>
26+
)
27+
}

test/e2e/app-dir/cache-components/cache-components.server-action.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,37 @@ describe('cache-components', () => {
4444
expect($('#page').text()).toBe('at buildtime')
4545
}
4646
})
47+
48+
it('should not serve cached HTML for server action POST on optional catch-all routes', async () => {
49+
// Regression: with cacheComponents enabled, POST requests for server
50+
// actions on dynamic routes (e.g. [[...slug]]) were incorrectly served
51+
// the cached fallback shell HTML instead of executing the action.
52+
// This verifies the response to a server action POST is NOT text/html.
53+
const res = await next.fetch('/server-action-dynamic', {
54+
method: 'POST',
55+
headers: {
56+
'next-action': 'test-action-id',
57+
'content-type': 'text/plain;charset=UTF-8',
58+
},
59+
body: '',
60+
})
61+
62+
const contentType = res.headers.get('content-type') || ''
63+
expect(contentType).not.toContain('text/html')
64+
})
65+
66+
it('should not serve cached HTML for server action POST on optional catch-all routes with params', async () => {
67+
// Same regression but when the catch-all has actual path segments.
68+
const res = await next.fetch('/server-action-dynamic/foo/bar', {
69+
method: 'POST',
70+
headers: {
71+
'next-action': 'test-action-id',
72+
'content-type': 'text/plain;charset=UTF-8',
73+
},
74+
body: '',
75+
})
76+
77+
const contentType = res.headers.get('content-type') || ''
78+
expect(contentType).not.toContain('text/html')
79+
})
4780
})

0 commit comments

Comments
 (0)