Skip to content

Commit 396fed3

Browse files
Remove bailed out SSG routes from the list of SSG (#83861)
### Why? When SSG, the pages can bail out when detected a dynamic usage. However, the routes that bailed out were still marked as SSG, which can confuse the users. ### How? Remove the bailed-out routes from the SSG routes list. If there are no more SSG routes for the dynamic route, mark it as dynamic in Build CLI. #### Before - Has 1, 2, 3, even though they bailed. ``` Route (app) Size First Load JS ┌ ○ /_not-found 0 B 114 kB └ ● /[id] 0 B 114 kB ├ /1 ├ /2 └ /3 + First Load JS shared by all 114 kB ├ chunks/29c57ad6b43f6070.js 75.8 kB ├ chunks/df142624801412ea.js 24.3 kB └ other shared chunks (total) 13.5 kB ○ (Static) prerendered as static content ● (SSG) prerendered as static HTML (uses generateStaticParams) ``` #### After - No longer has 1, 2, 3, and the route is marked as dynamic. ``` Route (app) Size First Load JS ┌ ○ /_not-found 0 B 114 kB └ ƒ /[id] 0 B 114 kB + First Load JS shared by all 114 kB ├ chunks/4963d796c068bb13.js 24.3 kB ├ chunks/f59bbf014aa0eff6.js 75.8 kB └ other shared chunks (total) 13.5 kB ○ (Static) prerendered as static content ƒ (Dynamic) server-rendered on demand ``` Fixes #76507 Closes NAR-194 --------- Co-authored-by: Wyatt Johnson <[email protected]>
1 parent 954d40b commit 396fed3

File tree

8 files changed

+184
-7
lines changed

8 files changed

+184
-7
lines changed

packages/next/src/build/index.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2811,6 +2811,8 @@ export default async function build(
28112811
buildStage: 'static-generation',
28122812
})
28132813

2814+
const hasGSPAndRevalidateZero = new Set<string>()
2815+
28142816
// we need to trigger automatic exporting when we have
28152817
// - static 404/500
28162818
// - getStaticProps paths
@@ -3087,6 +3089,8 @@ export default async function build(
30873089
const appConfig = appDefaultConfigs.get(originalAppPath)
30883090
if (!appConfig) throw new InvariantError('App config not found')
30893091

3092+
const ssgPageRoutesSet = new Set(pageInfos.get(page)?.ssgPageRoutes)
3093+
30903094
let hasRevalidateZero =
30913095
appConfig.revalidate === 0 ||
30923096
getCacheControl(page).revalidate === 0
@@ -3282,13 +3286,35 @@ export default async function build(
32823286
}
32833287
} else {
32843288
hasRevalidateZero = true
3285-
// we might have determined during prerendering that this page
3286-
// used dynamic data
3287-
pageInfos.set(route.pathname, {
3288-
...(pageInfos.get(route.pathname) as PageInfo),
3289-
isSSG: false,
3290-
isStatic: false,
3291-
})
3289+
3290+
if (ssgPageRoutesSet.has(route.pathname)) {
3291+
const pageInfo = pageInfos.get(page) as PageInfo
3292+
// Remove the route from the SSG page routes if it bailed out
3293+
// during prerendering.
3294+
ssgPageRoutesSet.delete(route.pathname)
3295+
3296+
// Mark the route as having a GSP and revalidate zero.
3297+
if (ssgPageRoutesSet.size === 0) {
3298+
hasGSPAndRevalidateZero.delete(page)
3299+
} else {
3300+
hasGSPAndRevalidateZero.add(page)
3301+
}
3302+
3303+
pageInfos.set(page, {
3304+
...pageInfo,
3305+
ssgPageRoutes: Array.from(ssgPageRoutesSet),
3306+
// If there are no SSG page routes left, then the page is not SSG.
3307+
isSSG: ssgPageRoutesSet.size === 0 ? false : pageInfo.isSSG,
3308+
})
3309+
} else {
3310+
// we might have determined during prerendering that this page
3311+
// used dynamic data
3312+
pageInfos.set(route.pathname, {
3313+
...(pageInfos.get(route.pathname) as PageInfo),
3314+
isSSG: false,
3315+
isStatic: false,
3316+
})
3317+
}
32923318
}
32933319
}
32943320

@@ -4167,6 +4193,7 @@ export default async function build(
41674193
pageExtensions: config.pageExtensions,
41684194
buildManifest,
41694195
middlewareManifest,
4196+
hasGSPAndRevalidateZero,
41704197
})
41714198
)
41724199

packages/next/src/build/utils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,12 +255,14 @@ export async function printTreeView(
255255
pageExtensions,
256256
middlewareManifest,
257257
useStaticPages404,
258+
hasGSPAndRevalidateZero,
258259
}: {
259260
pagesDir?: string
260261
pageExtensions: PageExtensions
261262
buildManifest: BuildManifest
262263
middlewareManifest: MiddlewareManifest
263264
useStaticPages404: boolean
265+
hasGSPAndRevalidateZero: Set<string>
264266
}
265267
) {
266268
// Can be overridden for test purposes to omit the build duration output.
@@ -373,6 +375,23 @@ export async function printTreeView(
373375
symbol = 'ƒ'
374376
}
375377

378+
if (hasGSPAndRevalidateZero.has(item)) {
379+
usedSymbols.add('ƒ')
380+
messages.push([
381+
`${border} ƒ ${item}${
382+
totalDuration > MIN_DURATION
383+
? ` (${getPrettyDuration(totalDuration)})`
384+
: ''
385+
}`,
386+
showRevalidate && pageInfo?.initialCacheControl
387+
? formatRevalidate(pageInfo.initialCacheControl)
388+
: '',
389+
showExpire && pageInfo?.initialCacheControl
390+
? formatExpire(pageInfo.initialCacheControl)
391+
: '',
392+
])
393+
}
394+
376395
usedSymbols.add(symbol)
377396

378397
messages.push([
@@ -393,6 +412,8 @@ export async function printTreeView(
393412
const totalRoutes = pageInfo.ssgPageRoutes.length
394413
const contSymbol = i === arr.length - 1 ? ' ' : '├'
395414

415+
// HERE
416+
396417
let routes: { route: string; duration: number; avgDuration?: number }[]
397418
if (pageInfo.ssgPageDurations?.some((d) => d > MIN_DURATION)) {
398419
const previewPages = totalRoutes === 8 ? 8 : Math.min(totalRoutes, 7)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ReactNode } from 'react'
2+
export default function Root({ children }: { children: ReactNode }) {
3+
return (
4+
<html>
5+
<body>{children}</body>
6+
</html>
7+
)
8+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export default async function Page({
2+
params,
3+
searchParams,
4+
}: {
5+
params: Promise<{ id: string }>
6+
searchParams: Promise<{ id: string }>
7+
}) {
8+
const { id } = await params
9+
// Bail out for /ssg-bailout-partial/1 only.
10+
if (id === '1') {
11+
const { id } = await searchParams
12+
return <p>hello world {id}</p>
13+
}
14+
15+
return <p>hello world</p>
16+
}
17+
18+
export async function generateStaticParams() {
19+
return [{ id: '1' }, { id: '2' }, { id: '3' }]
20+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export default async function Page({
2+
searchParams,
3+
}: {
4+
searchParams: Promise<{ id: string }>
5+
}) {
6+
const { id } = await searchParams
7+
return <p>hello world {id}</p>
8+
}
9+
10+
export async function generateStaticParams() {
11+
return [{ id: '1' }, { id: '2' }, { id: '3' }]
12+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function Page() {
2+
return <p>hello world</p>
3+
}
4+
5+
export function generateStaticParams() {
6+
return [{ id: '1' }, { id: '2' }, { id: '3' }]
7+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { nextTestSetup } from 'e2e-utils'
2+
3+
describe('build-output-ssg-bailout', () => {
4+
if (process.env.__NEXT_EXPERIMENTAL_CACHE_COMPONENTS === 'true') {
5+
it.skip('PPR is enabled, will throw instead of bailing out', () => {})
6+
return
7+
}
8+
9+
const { next } = nextTestSetup({
10+
files: __dirname,
11+
skipStart: true,
12+
env: {
13+
__NEXT_PRIVATE_DETERMINISTIC_BUILD_OUTPUT: '1',
14+
NEXT_DEBUG_BUILD: '1',
15+
},
16+
})
17+
18+
beforeAll(() => next.build())
19+
20+
// This is available when NEXT_DEBUG_BUILD is set (or --debug flag is used).
21+
it('should show error messages for SSG bailout', async () => {
22+
expect(next.cliOutput).toContain(
23+
'Error: Static generation failed due to dynamic usage on /ssg-bailout/1, reason: `await searchParams`, `searchParams.then`, or similar'
24+
)
25+
expect(next.cliOutput).toContain(
26+
'Error: Static generation failed due to dynamic usage on /ssg-bailout/2, reason: `await searchParams`, `searchParams.then`, or similar'
27+
)
28+
expect(next.cliOutput).toContain(
29+
'Error: Static generation failed due to dynamic usage on /ssg-bailout/3, reason: `await searchParams`, `searchParams.then`, or similar'
30+
)
31+
32+
// Bailed out for /ssg-bailout-partial/1 only.
33+
expect(next.cliOutput).toContain(
34+
'Error: Static generation failed due to dynamic usage on /ssg-bailout-partial/1, reason: `await searchParams`, `searchParams.then`, or similar'
35+
)
36+
})
37+
38+
it('should list SSG pages for pages that did not bail out', async () => {
39+
// - /ssg/[id] is marked (SSG) and has 1,2,3 listed.
40+
// - /ssg-bailout-partial/[id] is marked (SSG) and has 2,3 listed.
41+
// - /ssg-bailout/[id] is marked (Dynamic) and has nothing listed.
42+
expect(getTreeView(next.cliOutput)).toMatchInlineSnapshot(`
43+
"Route (app)
44+
┌ ○ /_not-found
45+
├ ƒ /ssg-bailout-partial/[id]
46+
├ ● /ssg-bailout-partial/[id]
47+
├ ├ /ssg-bailout-partial/2
48+
├ └ /ssg-bailout-partial/3
49+
├ ƒ /ssg-bailout/[id]
50+
└ ● /ssg/[id]
51+
├ /ssg/1
52+
├ /ssg/2
53+
└ /ssg/3
54+
55+
56+
○ (Static) prerendered as static content
57+
● (SSG) prerendered as static HTML (uses generateStaticParams)
58+
ƒ (Dynamic) server-rendered on demand"
59+
`)
60+
})
61+
})
62+
63+
function getTreeView(cliOutput: string): string {
64+
let foundStart = false
65+
const lines: string[] = []
66+
67+
for (const line of cliOutput.split('\n')) {
68+
foundStart ||= line.startsWith('Route ')
69+
70+
if (foundStart) {
71+
lines.push(line)
72+
}
73+
}
74+
75+
return lines.join('\n').trim()
76+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {}
5+
6+
module.exports = nextConfig

0 commit comments

Comments
 (0)