fix(next): prevent cached fallback shell from intercepting server actions on dynamic routes#91677
fix(next): prevent cached fallback shell from intercepting server actions on dynamic routes#91677lucasra1 wants to merge 1 commit intovercel:canaryfrom
Conversation
…ons 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.
|
Allow CI Workflow Run
Note: this should only be enabled once the PR is ready to go and can only be enabled by a maintainer |
There was a problem hiding this comment.
Pull request overview
Fixes an issue where, with cacheComponents enabled, dynamic routes could incorrectly serve a cached PPR fallback HTML shell for Server Action POST requests instead of letting the action handler run.
Changes:
- Prevent fallback shell serving for requests that are identified as possible Server Actions.
- Add E2E coverage for Server Action
POSTrequests against an optional catch-all dynamic route. - Add a new optional catch-all route fixture with a simple Server Action.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
packages/next/src/build/templates/app-page.ts |
Adds a guard to skip the cached fallback shell path for possible Server Action requests. |
test/e2e/app-dir/cache-components/cache-components.server-action.test.ts |
Adds regression tests asserting Server Action POST responses aren’t served as cached HTML. |
test/e2e/app-dir/cache-components/app/server-action-dynamic/[[...slug]]/page.tsx |
Adds a client page fixture under an optional catch-all route that calls a Server Action. |
test/e2e/app-dir/cache-components/app/server-action-dynamic/[[...slug]]/action.ts |
Adds the Server Action used by the new dynamic route fixture. |
Comments suppressed due to low confidence (1)
test/e2e/app-dir/cache-components/cache-components.server-action.test.ts:75
- Same concern as the previous test: using a fake
next-actionid means this request will be treated as an unrecognized action, so the assertion only checks it’s not HTML and may miss cases where the response is still incorrect for a valid server action. Prefer executing the real action (browser-driven) or asserting the expected RSC content-type for a valid action id.
it('should not serve cached HTML for server action POST on optional catch-all routes with params', async () => {
// Same regression but when the catch-all has actual path segments.
const res = await next.fetch('/server-action-dynamic/foo/bar', {
method: 'POST',
headers: {
'next-action': 'test-action-id',
'content-type': 'text/plain;charset=UTF-8',
},
body: '',
})
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // This verifies the response to a server action POST is NOT text/html. | ||
| const res = await next.fetch('/server-action-dynamic', { | ||
| method: 'POST', | ||
| headers: { | ||
| 'next-action': 'test-action-id', | ||
| 'content-type': 'text/plain;charset=UTF-8', | ||
| }, | ||
| body: '', | ||
| }) | ||
|
|
||
| const contentType = res.headers.get('content-type') || '' | ||
| expect(contentType).not.toContain('text/html') | ||
| }) | ||
|
|
||
| it('should not serve cached HTML for server action POST on optional catch-all routes with params', async () => { | ||
| // Same regression but when the catch-all has actual path segments. | ||
| const res = await next.fetch('/server-action-dynamic/foo/bar', { | ||
| method: 'POST', | ||
| headers: { | ||
| 'next-action': 'test-action-id', | ||
| 'content-type': 'text/plain;charset=UTF-8', | ||
| }, | ||
| body: '', | ||
| }) | ||
|
|
||
| const contentType = res.headers.get('content-type') || '' | ||
| expect(contentType).not.toContain('text/html') |
There was a problem hiding this comment.
These tests send a hard-coded next-action id (test-action-id) that won’t match the real compiled Server Action id, so they’re exercising the “unrecognized action” path rather than verifying that a real action POST returns the expected RSC payload. This can also add noisy "Failed to find Server Action" logs. Consider triggering the action via next.browser() (click the button and assert the UI updates) or otherwise deriving the real action id and asserting the response content-type is text/x-component/RSC instead of only asserting it’s not text/html.
This issue also appears on line 66 of the same file.
| // This verifies the response to a server action POST is NOT text/html. | |
| const res = await next.fetch('/server-action-dynamic', { | |
| method: 'POST', | |
| headers: { | |
| 'next-action': 'test-action-id', | |
| 'content-type': 'text/plain;charset=UTF-8', | |
| }, | |
| body: '', | |
| }) | |
| const contentType = res.headers.get('content-type') || '' | |
| expect(contentType).not.toContain('text/html') | |
| }) | |
| it('should not serve cached HTML for server action POST on optional catch-all routes with params', async () => { | |
| // Same regression but when the catch-all has actual path segments. | |
| const res = await next.fetch('/server-action-dynamic/foo/bar', { | |
| method: 'POST', | |
| headers: { | |
| 'next-action': 'test-action-id', | |
| 'content-type': 'text/plain;charset=UTF-8', | |
| }, | |
| body: '', | |
| }) | |
| const contentType = res.headers.get('content-type') || '' | |
| expect(contentType).not.toContain('text/html') | |
| // This verifies that invoking the server action via the browser causes | |
| // the UI to update, meaning the POST is handled as a real server action | |
| // request rather than serving cached HTML. | |
| const browser = await next.browser('/server-action-dynamic') | |
| expect(await browser.elementByCss('p').text()).toBe('initial') | |
| await browser.elementByCss('button').click() | |
| await retry(async () => { | |
| expect(await browser.elementByCss('p').text()).toBe('result') | |
| }) | |
| }) | |
| it('should not serve cached HTML for server action POST on optional catch-all routes with params', async () => { | |
| // Same regression but when the catch-all has actual path segments. This | |
| // asserts that the server action executes and updates the UI instead of | |
| // returning cached fallback HTML. | |
| const browser = await next.browser('/server-action-dynamic/foo/bar') | |
| expect(await browser.elementByCss('p').text()).toBe('initial') | |
| await browser.elementByCss('button').click() | |
| await retry(async () => { | |
| expect(await browser.elementByCss('p').text()).toBe('result') | |
| }) |
| // contains param references, and therefore we can't use the fallback. | ||
| if ( | ||
| isRoutePPREnabled && | ||
| !isPossibleServerAction && | ||
| (nextConfig.cacheComponents ? !isDynamicRSCRequest : !isRSCRequest) |
There was a problem hiding this comment.
The comment above this condition explains when it’s safe to serve the cached fallback shell, but it doesn’t mention that server action POSTs must be excluded. Updating the comment to reflect the added !isPossibleServerAction guard would help prevent future regressions when this logic is revisited.
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
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
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
Fixing a Bug
What?
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.
Fixes #91662