From a8dbd51efe05563612031b97e906f91979eb5b33 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 7 Oct 2025 01:37:50 -0600 Subject: [PATCH 1/2] 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 --- packages/next/src/build/index.ts | 11 +++ .../app/blog/[slug]/page.tsx | 16 ++++ .../app/layout.tsx | 11 +++ .../static-rendering-with-connection.test.ts | 89 +++++++++++++++++++ 4 files changed, 127 insertions(+) create mode 100644 test/production/app-dir/static-rendering-with-connection/app/blog/[slug]/page.tsx create mode 100644 test/production/app-dir/static-rendering-with-connection/app/layout.tsx create mode 100644 test/production/app-dir/static-rendering-with-connection/static-rendering-with-connection.test.ts diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index cd81837f82675c..17c77aca36d3a2 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -3286,6 +3286,17 @@ export default async function build( } } + // After processing all static prerendered routes, check if any had revalidate: 0 + // and update the parent page accordingly. This handles the case where + // generateStaticParams was used but the routes turned out to be dynamic. + if (hasRevalidateZero && pageInfos.get(page)?.isSSG) { + pageInfos.set(page, { + ...(pageInfos.get(page) as PageInfo), + isStatic: false, + isSSG: false, + }) + } + if (!hasRevalidateZero && isDynamicRoute(page)) { // When PPR fallbacks aren't used, we need to include it here. If // they are enabled, then it'll already be included in the diff --git a/test/production/app-dir/static-rendering-with-connection/app/blog/[slug]/page.tsx b/test/production/app-dir/static-rendering-with-connection/app/blog/[slug]/page.tsx new file mode 100644 index 00000000000000..6c84de297f4db7 --- /dev/null +++ b/test/production/app-dir/static-rendering-with-connection/app/blog/[slug]/page.tsx @@ -0,0 +1,16 @@ +import { connection } from 'next/server' + +export async function generateStaticParams() { + return [{ slug: 'slug-01' }, { slug: 'slug-02' }, { slug: 'slug-03' }] +} + +export default async function Page({ + params, +}: { + params: Promise<{ slug: string }> +}) { + await connection() + const { slug } = await params + + return
Page {slug}
+} diff --git a/test/production/app-dir/static-rendering-with-connection/app/layout.tsx b/test/production/app-dir/static-rendering-with-connection/app/layout.tsx new file mode 100644 index 00000000000000..38c6ce0e06626d --- /dev/null +++ b/test/production/app-dir/static-rendering-with-connection/app/layout.tsx @@ -0,0 +1,11 @@ +import { ReactNode, Suspense } from 'react' + +export default function Root({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ) +} diff --git a/test/production/app-dir/static-rendering-with-connection/static-rendering-with-connection.test.ts b/test/production/app-dir/static-rendering-with-connection/static-rendering-with-connection.test.ts new file mode 100644 index 00000000000000..db7654ae00732d --- /dev/null +++ b/test/production/app-dir/static-rendering-with-connection/static-rendering-with-connection.test.ts @@ -0,0 +1,89 @@ +import { nextTestSetup } from 'e2e-utils' + +describe('static-rendering-with-connection', () => { + const { next, isNextDeploy } = nextTestSetup({ + files: __dirname, + skipStart: true, + skipDeployment: true, + env: { + __NEXT_PRIVATE_DETERMINISTIC_BUILD_OUTPUT: '1', + }, + }) + + if (isNextDeploy) { + it.skip('should skip deployment', () => {}) + return + } + + beforeAll(async () => { + await next.build() + }) + + if (process.env.__NEXT_EXPERIMENTAL_CACHE_COMPONENTS === 'true') { + it('should mark routes with connection() as partial prerendered', async () => { + // When cache components are enabled, routes with connection() should be + // marked as partial prerendered. + expect(getTreeView(next.cliOutput)).toMatchInlineSnapshot(` + "Route (app) + ┌ ○ /_not-found + └ ◐ /blog/[slug] + ├ /blog/[slug] + ├ /blog/slug-01 + ├ /blog/slug-02 + └ /blog/slug-03 + + + ○ (Static) prerendered as static content + ◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content" + `) + }) + } else { + it('should mark routes with connection() as dynamic, not SSG', async () => { + // Routes with generateStaticParams that use connection() should be marked as dynamic + // because connection() makes the page dynamic. This is a regression test for a bug + // where pages with generateStaticParams were incorrectly marked as SSG even when + // the individual routes used dynamic APIs. + expect(getTreeView(next.cliOutput)).toMatchInlineSnapshot(` + "Route (app) + ┌ ○ /_not-found + └ ƒ /blog/[slug] + ├ /blog/slug-01 + ├ /blog/slug-02 + └ /blog/slug-03 + + + ○ (Static) prerendered as static content + ƒ (Dynamic) server-rendered on demand" + `) + }) + } + + it('should render the blog pages correctly', async () => { + await next.start() + + // Test one of the generated routes + const $ = await next.render$('/blog/slug-01') + expect($('#page').text()).toBe('Page slug-01') + + await next.stop() + }) +}) + +/** + * Extracts the route tree view from the build output. + * This captures everything from "Route " onwards to show the build output. + */ +function getTreeView(cliOutput: string): string { + let foundStart = false + const lines: string[] = [] + + for (const line of cliOutput.split('\n')) { + foundStart ||= line.startsWith('Route ') + + if (foundStart) { + lines.push(line) + } + } + + return lines.join('\n').trim() +} From c68fcf381d338df5f52946759a0def7c5c4551c9 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Tue, 7 Oct 2025 17:38:38 -0600 Subject: [PATCH 2/2] test: move tests to pre-existing suite --- .../build-output-tree-view.test.ts | 70 +++++++++++++++ .../app/dynamic}/[slug]/page.tsx | 0 .../app/layout.tsx | 0 .../static-rendering-with-connection.test.ts | 89 ------------------- 4 files changed, 70 insertions(+), 89 deletions(-) rename test/production/app-dir/{static-rendering-with-connection/app/blog => build-output-tree-view/fixtures/dynamic-generate-static-params/app/dynamic}/[slug]/page.tsx (100%) rename test/production/app-dir/{static-rendering-with-connection => build-output-tree-view/fixtures/dynamic-generate-static-params}/app/layout.tsx (100%) delete mode 100644 test/production/app-dir/static-rendering-with-connection/static-rendering-with-connection.test.ts diff --git a/test/production/app-dir/build-output-tree-view/build-output-tree-view.test.ts b/test/production/app-dir/build-output-tree-view/build-output-tree-view.test.ts index 5524a14c072eeb..ca813cba137e12 100644 --- a/test/production/app-dir/build-output-tree-view/build-output-tree-view.test.ts +++ b/test/production/app-dir/build-output-tree-view/build-output-tree-view.test.ts @@ -67,13 +67,83 @@ describe('build-output-tree-view', () => { `) }) }) + + describe('with dynamic access and generateStaticParams', () => { + describe.each([true, false])('cache components: %s', (cacheComponents) => { + const { next } = nextTestSetup({ + files: path.join(__dirname, 'fixtures/dynamic-generate-static-params'), + env: { + __NEXT_PRIVATE_DETERMINISTIC_BUILD_OUTPUT: '1', + }, + // We don't skip start in this test because we want to actually hit the + // dynamic pages, and starting again would cause the current API to + // re-build the app again. + nextConfig: { + experimental: { + cacheComponents, + }, + }, + }) + + it('should mark routes with connection() as dynamic, not SSG', async () => { + if (cacheComponents) { + // When cache components are enabled, routes with connection() should be + // marked as partial prerendered. + expect(getTreeView(next.cliOutput)).toMatchInlineSnapshot(` + "Route (app) + ┌ ○ /_not-found + └ ◐ /dynamic/[slug] + ├ /dynamic/[slug] + ├ /dynamic/slug-01 + ├ /dynamic/slug-02 + └ /dynamic/slug-03 + + + ○ (Static) prerendered as static content + ◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content" + `) + } else { + // Routes with generateStaticParams that use connection() should be marked as dynamic + // because connection() makes the page dynamic. This is a regression test for a bug + // where pages with generateStaticParams were incorrectly marked as SSG even when + // the individual routes used dynamic APIs. + expect(getTreeView(next.cliOutput)).toMatchInlineSnapshot(` + "Route (app) + ┌ ○ /_not-found + └ ƒ /dynamic/[slug] + ├ /dynamic/slug-01 + ├ /dynamic/slug-02 + └ /dynamic/slug-03 + + + ○ (Static) prerendered as static content + ƒ (Dynamic) server-rendered on demand" + `) + } + }) + + it('should render the dynamic pages correctly', async () => { + // Test one of the generated routes. We expect it to render and not + // error. + const $ = await next.render$('/dynamic/slug-01') + expect($('#page').text()).toBe('Page slug-01') + }) + }) + }) }) function getTreeView(cliOutput: string): string { let foundStart = false + let cliHeader = 0 const lines: string[] = [] for (const line of cliOutput.split('\n')) { + // Once we've seen the CLI header twice, we can stop reading the output, + // as we've already collected the first command (the `next build` command) + // and we can ignore the rest of the output. + if (line.includes('▲ Next.js')) cliHeader++ + if (cliHeader === 2) break + foundStart ||= line.startsWith('Route ') if (foundStart) { diff --git a/test/production/app-dir/static-rendering-with-connection/app/blog/[slug]/page.tsx b/test/production/app-dir/build-output-tree-view/fixtures/dynamic-generate-static-params/app/dynamic/[slug]/page.tsx similarity index 100% rename from test/production/app-dir/static-rendering-with-connection/app/blog/[slug]/page.tsx rename to test/production/app-dir/build-output-tree-view/fixtures/dynamic-generate-static-params/app/dynamic/[slug]/page.tsx diff --git a/test/production/app-dir/static-rendering-with-connection/app/layout.tsx b/test/production/app-dir/build-output-tree-view/fixtures/dynamic-generate-static-params/app/layout.tsx similarity index 100% rename from test/production/app-dir/static-rendering-with-connection/app/layout.tsx rename to test/production/app-dir/build-output-tree-view/fixtures/dynamic-generate-static-params/app/layout.tsx diff --git a/test/production/app-dir/static-rendering-with-connection/static-rendering-with-connection.test.ts b/test/production/app-dir/static-rendering-with-connection/static-rendering-with-connection.test.ts deleted file mode 100644 index db7654ae00732d..00000000000000 --- a/test/production/app-dir/static-rendering-with-connection/static-rendering-with-connection.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { nextTestSetup } from 'e2e-utils' - -describe('static-rendering-with-connection', () => { - const { next, isNextDeploy } = nextTestSetup({ - files: __dirname, - skipStart: true, - skipDeployment: true, - env: { - __NEXT_PRIVATE_DETERMINISTIC_BUILD_OUTPUT: '1', - }, - }) - - if (isNextDeploy) { - it.skip('should skip deployment', () => {}) - return - } - - beforeAll(async () => { - await next.build() - }) - - if (process.env.__NEXT_EXPERIMENTAL_CACHE_COMPONENTS === 'true') { - it('should mark routes with connection() as partial prerendered', async () => { - // When cache components are enabled, routes with connection() should be - // marked as partial prerendered. - expect(getTreeView(next.cliOutput)).toMatchInlineSnapshot(` - "Route (app) - ┌ ○ /_not-found - └ ◐ /blog/[slug] - ├ /blog/[slug] - ├ /blog/slug-01 - ├ /blog/slug-02 - └ /blog/slug-03 - - - ○ (Static) prerendered as static content - ◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed content" - `) - }) - } else { - it('should mark routes with connection() as dynamic, not SSG', async () => { - // Routes with generateStaticParams that use connection() should be marked as dynamic - // because connection() makes the page dynamic. This is a regression test for a bug - // where pages with generateStaticParams were incorrectly marked as SSG even when - // the individual routes used dynamic APIs. - expect(getTreeView(next.cliOutput)).toMatchInlineSnapshot(` - "Route (app) - ┌ ○ /_not-found - └ ƒ /blog/[slug] - ├ /blog/slug-01 - ├ /blog/slug-02 - └ /blog/slug-03 - - - ○ (Static) prerendered as static content - ƒ (Dynamic) server-rendered on demand" - `) - }) - } - - it('should render the blog pages correctly', async () => { - await next.start() - - // Test one of the generated routes - const $ = await next.render$('/blog/slug-01') - expect($('#page').text()).toBe('Page slug-01') - - await next.stop() - }) -}) - -/** - * Extracts the route tree view from the build output. - * This captures everything from "Route " onwards to show the build output. - */ -function getTreeView(cliOutput: string): string { - let foundStart = false - const lines: string[] = [] - - for (const line of cliOutput.split('\n')) { - foundStart ||= line.startsWith('Route ') - - if (foundStart) { - lines.push(line) - } - } - - return lines.join('\n').trim() -}