Skip to content

Commit 5a0e958

Browse files
committed
fix(build): correct SSG classification for dynamic pages with generateStaticParams
Pages with generateStaticParams that use dynamic APIs (cookies(), headers(), connection()) were incorrectly marked as SSG in build output. This caused users to mistakenly believe their pages were statically generated when they were actually rendered dynamically at request time. The bug occurred because the dynamic detection check ran before individual routes were processed. The check looked at the pattern route (e.g., /blog/[slug]) which was never actually rendered, causing it to return false. When the actual routes were processed later (e.g., /blog/post-1), they correctly detected revalidate: 0 but the parent page had already been marked as SSG. This fix adds a second check after processing all prerendered routes to correct the parent page classification if any routes turned out to be dynamic. Fixes #84584
1 parent c0a7c84 commit 5a0e958

File tree

4 files changed

+127
-0
lines changed

4 files changed

+127
-0
lines changed

packages/next/src/build/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3279,6 +3279,17 @@ export default async function build(
32793279
}
32803280
}
32813281

3282+
// After processing all static prerendered routes, check if any had revalidate: 0
3283+
// and update the parent page accordingly. This handles the case where
3284+
// generateStaticParams was used but the routes turned out to be dynamic.
3285+
if (hasRevalidateZero && pageInfos.get(page)?.isSSG) {
3286+
pageInfos.set(page, {
3287+
...(pageInfos.get(page) as PageInfo),
3288+
isStatic: false,
3289+
isSSG: false,
3290+
})
3291+
}
3292+
32823293
if (!hasRevalidateZero && isDynamicRoute(page)) {
32833294
// When PPR fallbacks aren't used, we need to include it here. If
32843295
// they are enabled, then it'll already be included in the
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { connection } from 'next/server'
2+
3+
export async function generateStaticParams() {
4+
return [{ slug: 'slug-01' }, { slug: 'slug-02' }, { slug: 'slug-03' }]
5+
}
6+
7+
export default async function Page({
8+
params,
9+
}: {
10+
params: Promise<{ slug: string }>
11+
}) {
12+
await connection()
13+
const { slug } = await params
14+
15+
return <div id="page">Page {slug}</div>
16+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { ReactNode, Suspense } from 'react'
2+
3+
export default function Root({ children }: { children: ReactNode }) {
4+
return (
5+
<html>
6+
<body>
7+
<Suspense>{children}</Suspense>
8+
</body>
9+
</html>
10+
)
11+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
3+
describe('static-rendering-with-connection', () => {
4+
const { next, isNextDeploy } = nextTestSetup({
5+
files: __dirname,
6+
skipStart: true,
7+
skipDeployment: true,
8+
env: {
9+
__NEXT_PRIVATE_DETERMINISTIC_BUILD_OUTPUT: '1',
10+
},
11+
})
12+
13+
if (isNextDeploy) {
14+
it.skip('should skip deployment', () => {})
15+
return
16+
}
17+
18+
beforeAll(async () => {
19+
await next.build()
20+
})
21+
22+
if (process.env.__NEXT_EXPERIMENTAL_CACHE_COMPONENTS === 'true') {
23+
it('should mark routes with connection() as partial prerendered', async () => {
24+
// When cache components are enabled, routes with connection() should be
25+
// marked as partial prerendered.
26+
expect(getTreeView(next.cliOutput)).toMatchInlineSnapshot(`
27+
"Route (app)
28+
┌ ○ /_not-found
29+
└ ◐ /blog/[slug]
30+
├ /blog/[slug]
31+
├ /blog/slug-01
32+
├ /blog/slug-02
33+
└ /blog/slug-03
34+
35+
36+
○ (Static) prerendered as static content
37+
◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content"
38+
`)
39+
})
40+
} else {
41+
it('should mark routes with connection() as dynamic, not SSG', async () => {
42+
// Routes with generateStaticParams that use connection() should be marked as dynamic
43+
// because connection() makes the page dynamic. This is a regression test for a bug
44+
// where pages with generateStaticParams were incorrectly marked as SSG even when
45+
// the individual routes used dynamic APIs.
46+
expect(getTreeView(next.cliOutput)).toMatchInlineSnapshot(`
47+
"Route (app)
48+
┌ ○ /_not-found
49+
└ ƒ /blog/[slug]
50+
├ /blog/slug-01
51+
├ /blog/slug-02
52+
└ /blog/slug-03
53+
54+
55+
○ (Static) prerendered as static content
56+
ƒ (Dynamic) server-rendered on demand"
57+
`)
58+
})
59+
}
60+
61+
it('should render the blog pages correctly', async () => {
62+
await next.start()
63+
64+
// Test one of the generated routes
65+
const $ = await next.render$('/blog/slug-01')
66+
expect($('#page').text()).toBe('Page slug-01')
67+
68+
await next.stop()
69+
})
70+
})
71+
72+
/**
73+
* Extracts the route tree view from the build output.
74+
* This captures everything from "Route " onwards to show the build output.
75+
*/
76+
function getTreeView(cliOutput: string): string {
77+
let foundStart = false
78+
const lines: string[] = []
79+
80+
for (const line of cliOutput.split('\n')) {
81+
foundStart ||= line.startsWith('Route ')
82+
83+
if (foundStart) {
84+
lines.push(line)
85+
}
86+
}
87+
88+
return lines.join('\n').trim()
89+
}

0 commit comments

Comments
 (0)