Skip to content

Commit a73abad

Browse files
authored
fix: server actions initiated from static pages (vercel#51534)
### What? Pages marked with `generateStaticParams` don't currently support server actions, and instead return a 405 Method Not Allowed, with no action being taken on the client. Additionally, pages that are marked static & use server actions are opted into dynamic rendering. ### Why? The page that has `generateStaticParams` is marked as `isSSG` [here](https://github.com/ztanner/next.js/blob/ee2ec3dd1de2f5cdd26ddc64c1036f02c92e1888/packages/next/src/server/base-server.ts#L1337). As a result, the request is short-circuited because a POST request isn't supported on static pages. Upon detecting a server action on a page marked SSG, we bypass the static cache and go straight to the lambda. This PR introduces an experimental option to the prerender manifest that will allow for selectively bypassing the static cache This also removes the need to bail out of static generation Closes NEXT-1167 Closes NEXT-1453 Fixes vercel#49408 Fixes vercel#52840 Fixes vercel#50932
1 parent be38d02 commit a73abad

File tree

13 files changed

+982
-382
lines changed

13 files changed

+982
-382
lines changed

packages/next/src/build/index.ts

Lines changed: 50 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import loadCustomRoutes, {
3636
normalizeRouteRegex,
3737
Redirect,
3838
Rewrite,
39+
RouteHas,
3940
RouteType,
4041
} from '../lib/load-custom-routes'
4142
import { getRedirectStatus, modifyRouteRegex } from '../lib/redirect-status'
@@ -130,6 +131,7 @@ import { flatReaddir } from '../lib/flat-readdir'
130131
import { eventSwcPlugins } from '../telemetry/events/swc-plugins'
131132
import { normalizeAppPath } from '../shared/lib/router/utils/app-paths'
132133
import {
134+
ACTION,
133135
NEXT_ROUTER_PREFETCH,
134136
RSC,
135137
RSC_CONTENT_TYPE_HEADER,
@@ -160,13 +162,15 @@ export type SsgRoute = {
160162
dataRoute: string | null
161163
initialStatus?: number
162164
initialHeaders?: Record<string, string>
165+
experimentalBypassFor?: RouteHas[]
163166
}
164167

165168
export type DynamicSsgRoute = {
166169
routeRegex: string
167170
fallback: string | null | false
168171
dataRoute: string | null
169172
dataRouteRegex: string | null
173+
experimentalBypassFor?: RouteHas[]
170174
}
171175

172176
export type PrerenderManifest = {
@@ -1612,14 +1616,6 @@ export default async function build(
16121616
`Using edge runtime on a page currently disables static generation for that page`
16131617
)
16141618
} else {
1615-
// If a page has action and it is static, we need to
1616-
// change it to SSG to keep the worker created.
1617-
// TODO: This is a workaround for now, we should have a
1618-
// dedicated worker defined in a heuristic way.
1619-
const hasAction = entriesWithAction?.has(
1620-
'app' + originalAppPath
1621-
)
1622-
16231619
if (
16241620
workerResult.encodedPrerenderRoutes &&
16251621
workerResult.prerenderRoutes
@@ -1638,47 +1634,39 @@ export default async function build(
16381634

16391635
const appConfig = workerResult.appConfig || {}
16401636
if (appConfig.revalidate !== 0) {
1641-
if (hasAction) {
1642-
Log.warnOnce(
1643-
`Using server actions on a page currently disables static generation for that page`
1637+
const isDynamic = isDynamicRoute(page)
1638+
const hasGenerateStaticParams =
1639+
!!workerResult.prerenderRoutes?.length
1640+
1641+
if (
1642+
config.output === 'export' &&
1643+
isDynamic &&
1644+
!hasGenerateStaticParams
1645+
) {
1646+
throw new Error(
1647+
`Page "${page}" is missing "generateStaticParams()" so it cannot be used with "output: export" config.`
16441648
)
1645-
} else {
1646-
const isDynamic = isDynamicRoute(page)
1647-
const hasGenerateStaticParams =
1648-
!!workerResult.prerenderRoutes?.length
1649-
1650-
if (
1651-
config.output === 'export' &&
1652-
isDynamic &&
1653-
!hasGenerateStaticParams
1654-
) {
1655-
throw new Error(
1656-
`Page "${page}" is missing "generateStaticParams()" so it cannot be used with "output: export" config.`
1657-
)
1658-
}
1659-
1660-
if (
1661-
// Mark the app as static if:
1662-
// - It has no dynamic param
1663-
// - It doesn't have generateStaticParams but `dynamic` is set to
1664-
// `error` or `force-static`
1665-
!isDynamic
1666-
) {
1667-
appStaticPaths.set(originalAppPath, [page])
1668-
appStaticPathsEncoded.set(originalAppPath, [
1669-
page,
1670-
])
1671-
isStatic = true
1672-
} else if (
1673-
isDynamic &&
1674-
!hasGenerateStaticParams &&
1675-
(appConfig.dynamic === 'error' ||
1676-
appConfig.dynamic === 'force-static')
1677-
) {
1678-
appStaticPaths.set(originalAppPath, [])
1679-
appStaticPathsEncoded.set(originalAppPath, [])
1680-
isStatic = true
1681-
}
1649+
}
1650+
1651+
if (
1652+
// Mark the app as static if:
1653+
// - It has no dynamic param
1654+
// - It doesn't have generateStaticParams but `dynamic` is set to
1655+
// `error` or `force-static`
1656+
!isDynamic
1657+
) {
1658+
appStaticPaths.set(originalAppPath, [page])
1659+
appStaticPathsEncoded.set(originalAppPath, [page])
1660+
isStatic = true
1661+
} else if (
1662+
isDynamic &&
1663+
!hasGenerateStaticParams &&
1664+
(appConfig.dynamic === 'error' ||
1665+
appConfig.dynamic === 'force-static')
1666+
) {
1667+
appStaticPaths.set(originalAppPath, [])
1668+
appStaticPathsEncoded.set(originalAppPath, [])
1669+
isStatic = true
16821670
}
16831671
}
16841672

@@ -2681,6 +2669,17 @@ export default async function build(
26812669

26822670
const isRouteHandler = isAppRouteRoute(originalAppPath)
26832671

2672+
// this flag is used to selectively bypass the static cache and invoke the lambda directly
2673+
// to enable server actions on static routes
2674+
const bypassFor: RouteHas[] = [
2675+
{ type: 'header', key: ACTION },
2676+
{
2677+
type: 'header',
2678+
key: 'content-type',
2679+
value: 'multipart/form-data',
2680+
},
2681+
]
2682+
26842683
routes.forEach((route) => {
26852684
if (isDynamicRoute(page) && route === page) return
26862685
if (route === '/_not-found') return
@@ -2708,10 +2707,7 @@ export default async function build(
27082707
? null
27092708
: path.posix.join(`${normalizedRoute}.rsc`)
27102709

2711-
const routeMeta: {
2712-
initialStatus?: SsgRoute['initialStatus']
2713-
initialHeaders?: SsgRoute['initialHeaders']
2714-
} = {}
2710+
const routeMeta: Partial<SsgRoute> = {}
27152711

27162712
const exportRouteMeta: {
27172713
status?: number
@@ -2748,6 +2744,7 @@ export default async function build(
27482744

27492745
finalPrerenderRoutes[route] = {
27502746
...routeMeta,
2747+
experimentalBypassFor: bypassFor,
27512748
initialRevalidateSeconds: revalidate,
27522749
srcRoute: page,
27532750
dataRoute,
@@ -2771,6 +2768,7 @@ export default async function build(
27712768
// TODO: create a separate manifest to allow enforcing
27722769
// dynamicParams for non-static paths?
27732770
finalDynamicRoutes[page] = {
2771+
experimentalBypassFor: bypassFor,
27742772
routeRegex: normalizeRouteRegex(
27752773
getNamedRouteRegex(page, false).re.source
27762774
),

packages/next/src/server/app-render/app-render.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,16 @@ export async function renderToHTMLOrFlight(
187187
appDirDevErrorLogger,
188188
} = renderOpts
189189

190+
// We need to expose the bundled `require` API globally for
191+
// react-server-dom-webpack. This is a hack until we find a better way.
192+
if (ComponentMod.__next_app__) {
193+
// @ts-ignore
194+
globalThis.__next_require__ = ComponentMod.__next_app__.require
195+
196+
// @ts-ignore
197+
globalThis.__next_chunk_load__ = ComponentMod.__next_app__.loadChunk
198+
}
199+
190200
const extraRenderResultMeta: RenderResultMetadata = {}
191201

192202
const appUsingSizeAdjust = !!nextFontManifest?.appUsingSizeAdjust

packages/next/src/server/app-render/create-server-components-renderer.tsx

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,6 @@ export function createServerComponentRenderer<Props>(
3434
serverComponentsErrorHandler: ReturnType<typeof createErrorHandler>,
3535
nonce?: string
3636
): (props: Props) => JSX.Element {
37-
// We need to expose the bundled `require` API globally for
38-
// react-server-dom-webpack. This is a hack until we find a better way.
39-
if (ComponentMod.__next_app__) {
40-
// @ts-ignore
41-
globalThis.__next_require__ = ComponentMod.__next_app__.require
42-
43-
// @ts-ignore
44-
globalThis.__next_chunk_load__ = ComponentMod.__next_app__.loadChunk
45-
}
46-
4737
let RSCStream: ReadableStream<Uint8Array>
4838
const createRSCStream = (props: Props) => {
4939
if (!RSCStream) {

packages/next/src/server/async-storage/static-generation-async-storage-wrapper.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type StaticGenerationContext = {
1515
nextExport?: boolean
1616
fetchCache?: StaticGenerationStore['fetchCache']
1717
isDraftMode?: boolean
18+
isServerAction?: boolean
1819

1920
/**
2021
* A hack around accessing the store value outside the context of the
@@ -49,11 +50,15 @@ export const StaticGenerationAsyncStorageWrapper: AsyncStorageWrapper<
4950
*
5051
* 3.) If the request is in draft mode, we must generate dynamic HTML.
5152
*
53+
* 4.) If the request is a server action, we must generate dynamic HTML.
54+
*
5255
* These rules help ensure that other existing features like request caching,
5356
* coalescing, and ISR continue working as intended.
5457
*/
5558
const isStaticGeneration =
56-
!renderOpts.supportsDynamicHTML && !renderOpts.isDraftMode
59+
!renderOpts.supportsDynamicHTML &&
60+
!renderOpts.isDraftMode &&
61+
!renderOpts.isServerAction
5762

5863
const store: StaticGenerationStore = {
5964
isStaticGeneration,

packages/next/src/server/base-server.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ import {
8585
RSC_VARY_HEADER,
8686
FLIGHT_PARAMETERS,
8787
NEXT_RSC_UNION_QUERY,
88+
ACTION,
8889
NEXT_ROUTER_PREFETCH,
8990
RSC_CONTENT_TYPE_HEADER,
9091
} from '../client/components/app-router-headers'
@@ -1588,7 +1589,15 @@ export default abstract class Server<ServerOptions extends Options = Options> {
15881589
const isAppPath = components.isAppPath === true
15891590
const hasServerProps = !!components.getServerSideProps
15901591
let hasStaticPaths = !!components.getStaticPaths
1591-
1592+
const actionId = req.headers[ACTION.toLowerCase()] as string
1593+
const contentType = req.headers['content-type']
1594+
const isMultipartAction =
1595+
req.method === 'POST' && contentType?.startsWith('multipart/form-data')
1596+
const isFetchAction =
1597+
actionId !== undefined &&
1598+
typeof actionId === 'string' &&
1599+
req.method === 'POST'
1600+
const isServerAction = isFetchAction || isMultipartAction
15921601
const hasGetInitialProps = !!components.Component?.getInitialProps
15931602
let isSSG = !!components.getStaticProps
15941603

@@ -1725,6 +1734,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
17251734
// requests so ensure we respond with 405 for
17261735
// invalid requests
17271736
if (
1737+
!isServerAction &&
17281738
!is404Page &&
17291739
!is500Page &&
17301740
pathname !== '/_error' &&
@@ -1879,8 +1889,8 @@ export default abstract class Server<ServerOptions extends Options = Options> {
18791889
}
18801890

18811891
let ssgCacheKey =
1882-
isPreviewMode || !isSSG || opts.supportsDynamicHTML
1883-
? null // Preview mode, on-demand revalidate, flight request can bypass the cache
1892+
isPreviewMode || !isSSG || opts.supportsDynamicHTML || isServerAction
1893+
? null // Preview mode, on-demand revalidate, server actions, flight request can bypass the cache
18841894
: `${locale ? `/${locale}` : ''}${
18851895
(pathname === '/' || resolvedUrlPathname === '/') && locale
18861896
? ''
@@ -1996,6 +2006,7 @@ export default abstract class Server<ServerOptions extends Options = Options> {
19962006
supportsDynamicHTML,
19972007
isOnDemandRevalidate,
19982008
isDraftMode: isPreviewMode,
2009+
isServerAction,
19992010
}
20002011

20012012
// Legacy render methods will return a render result that needs to be

packages/next/src/server/render.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ export type RenderOptsPartial = {
274274
strictNextHead: boolean
275275
isDraftMode?: boolean
276276
deploymentId?: string
277+
isServerAction?: boolean
277278
}
278279

279280
export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial

packages/next/src/server/web/spec-extension/adapters/request-cookies.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export function appendMutableCookies(
7070
}
7171

7272
// Return a new response that extends the response with
73-
// the modified cookies as fallbacks. `res`' cookies
73+
// the modified cookies as fallbacks. `res` cookies
7474
// will still take precedence.
7575
const resCookies = new ResponseCookies(headers)
7676
const returnedCookies = resCookies.getAll()

test/e2e/app-dir/actions/app-action.test.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,6 @@ createNextDescribe(
2020
},
2121
},
2222
({ next, isNextDev, isNextStart, isNextDeploy }) => {
23-
if (isNextStart) {
24-
it('should warn for server actions + ISR incompat', async () => {
25-
expect(next.cliOutput).toContain(
26-
'Using server actions on a page currently disables static generation for that page'
27-
)
28-
})
29-
}
30-
3123
it('should handle basic actions correctly', async () => {
3224
const browser = await next.browser('/server')
3325

@@ -499,6 +491,17 @@ createNextDescribe(
499491
})
500492

501493
describe('fetch actions', () => {
494+
it('should handle a fetch action initiated from a static page', async () => {
495+
const browser = await next.browser('/client-static')
496+
await check(() => browser.elementByCss('#count').text(), '0')
497+
498+
await browser.elementByCss('#increment').click()
499+
await check(() => browser.elementByCss('#count').text(), '1')
500+
501+
await browser.elementByCss('#increment').click()
502+
await check(() => browser.elementByCss('#count').text(), '2')
503+
})
504+
502505
it('should handle redirect to a relative URL in a single pass', async () => {
503506
const browser = await next.browser('/client')
504507

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { Counter } from '../../../components/Counter'
2+
import { incrementCounter } from '../actions'
3+
4+
export default function Page() {
5+
return (
6+
<div>
7+
<Counter onClick={incrementCounter} />
8+
</div>
9+
)
10+
}
11+
12+
export const revalidate = 60
13+
14+
export async function generateStaticParams() {
15+
return [{ path: ['asdf'] }]
16+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
'use server'
2+
3+
let counter = 0
4+
5+
export async function incrementCounter() {
6+
console.log('Button clicked!')
7+
8+
counter++
9+
return counter
10+
}

0 commit comments

Comments
 (0)