Skip to content

fix(next): prevent cached fallback shell from intercepting server actions on dynamic routes#91677

Closed
lucasra1 wants to merge 1 commit intovercel:canaryfrom
lucasra1:fix/91662
Closed

fix(next): prevent cached fallback shell from intercepting server actions on dynamic routes#91677
lucasra1 wants to merge 1 commit intovercel:canaryfrom
lucasra1:fix/91662

Conversation

@lucasra1
Copy link
Copy Markdown

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

…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.
Copilot AI review requested due to automatic review settings March 19, 2026 21:40
@lucasra1 lucasra1 changed the title ix(next): prevent cached fallback shell from intercepting server actions on dynamic routes fix(next): prevent cached fallback shell from intercepting server actions on dynamic routes Mar 19, 2026
@nextjs-bot
Copy link
Copy Markdown
Collaborator

Allow CI Workflow Run

  • approve CI run for commit: 7fdad74

Note: this should only be enabled once the PR is ready to go and can only be enabled by a maintainer

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 POST requests 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-action id 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.

Comment on lines +52 to +78
// 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')
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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')
})

Copilot uses AI. Check for mistakes.
Comment on lines 1073 to 1077
// contains param references, and therefore we can't use the fallback.
if (
isRoutePPREnabled &&
!isPossibleServerAction &&
(nextConfig.cacheComponents ? !isDynamicRSCRequest : !isRSCRequest)
Copy link

Copilot AI Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
unstubbable added a commit that referenced this pull request Mar 20, 2026
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
unstubbable added a commit that referenced this pull request Mar 20, 2026
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
@lucasra1 lucasra1 deleted the fix/91662 branch March 20, 2026 20:52
ijjk pushed a commit that referenced this pull request Mar 20, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Server Action POST returns text/html PPR shell instead of RSC payload after upgrading to 16.2.0

3 participants