diff --git a/packages/next/src/build/utils.ts b/packages/next/src/build/utils.ts index 42cf5a2d0fc2c..a9465036df062 100644 --- a/packages/next/src/build/utils.ts +++ b/packages/next/src/build/utils.ts @@ -1330,7 +1330,6 @@ export async function isPageStatic({ isrFlushToDisk, maxMemoryCacheSize, incrementalCacheHandlerPath, - nextConfigOutput, }: { page: string distDir: string @@ -1498,16 +1497,6 @@ export async function isPageStatic({ {} ) - if (nextConfigOutput === 'export') { - if (!appConfig.dynamic || appConfig.dynamic === 'auto') { - appConfig.dynamic = 'error' - } else if (appConfig.dynamic === 'force-dynamic') { - throw new Error( - `export const dynamic = "force-dynamic" on page "${page}" cannot be used with "output: export". See more info here: https://nextjs.org/docs/advanced-features/static-html-export` - ) - } - } - if (appConfig.dynamic === 'force-dynamic') { appConfig.revalidate = 0 } diff --git a/packages/next/src/cli/next-build.ts b/packages/next/src/cli/next-build.ts index fb2dc714b9262..689c1347ede73 100755 --- a/packages/next/src/cli/next-build.ts +++ b/packages/next/src/cli/next-build.ts @@ -86,6 +86,8 @@ const nextBuild: CliCommand = (argv) => { (err.code === 'INVALID_RESOLVE_ALIAS' || err.code === 'WEBPACK_ERRORS' || err.code === 'BUILD_OPTIMIZATION_FAILED' || + err.code === 'NEXT_EXPORT_ERROR' || + err.code === 'NEXT_STATIC_GEN_BAILOUT' || err.code === 'EDGE_RUNTIME_UNSUPPORTED_API') ) { printAndExit(`> ${err.message}`) diff --git a/packages/next/src/cli/next-export.ts b/packages/next/src/cli/next-export.ts index 0d8d38a776eec..bfbca1817b22e 100755 --- a/packages/next/src/cli/next-export.ts +++ b/packages/next/src/cli/next-export.ts @@ -72,9 +72,9 @@ const nextExport: CliCommand = (argv) => { nextExportCliSpan.stop() printAndExit(`Export successful. Files written to ${options.outdir}`, 0) }) - .catch((err: unknown) => { + .catch((err: any) => { nextExportCliSpan.stop() - if (err instanceof ExportError) { + if (err instanceof ExportError || err.code === 'NEXT_EXPORT_ERROR') { Log.error(err.message) } else { console.error(err) diff --git a/packages/next/src/client/components/static-generation-bailout.ts b/packages/next/src/client/components/static-generation-bailout.ts index ae89f65d3df69..8f6b63f7c2295 100644 --- a/packages/next/src/client/components/static-generation-bailout.ts +++ b/packages/next/src/client/components/static-generation-bailout.ts @@ -1,7 +1,19 @@ import { DynamicServerError } from './hooks-server-context' import { staticGenerationAsyncStorage } from './static-generation-async-storage' -export function staticGenerationBailout(reason: string): boolean | never { +class StaticGenBailoutError extends Error { + code = 'NEXT_STATIC_GEN_BAILOUT' +} + +export type StaticGenerationBailout = ( + reason: string, + opts?: { dynamic?: string; link?: string } +) => boolean | never + +export const staticGenerationBailout: StaticGenerationBailout = ( + reason, + opts +) => { const staticGenerationStore = staticGenerationAsyncStorage.getStore() if (staticGenerationStore?.forceStatic) { @@ -9,8 +21,10 @@ export function staticGenerationBailout(reason: string): boolean | never { } if (staticGenerationStore?.dynamicShouldError) { - throw new Error( - `Page with \`dynamic = "error"\` couldn't be rendered statically because it used \`${reason}\`` + const { dynamic = 'error', link } = opts || {} + const suffix = link ? ` See more info here: ${link}` : '' + throw new StaticGenBailoutError( + `Page with \`dynamic = "${dynamic}"\` couldn't be rendered statically because it used \`${reason}\`.${suffix}` ) } diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index 34b46fcb0d739..3bbf2cb99f303 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -144,7 +144,7 @@ const createProgress = (total: number, label: string) => { } export class ExportError extends Error { - type = 'ExportError' + code = 'NEXT_EXPORT_ERROR' } export interface ExportOptions { diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index 4c758690d5d3a..634602e8ae145 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -10,6 +10,7 @@ import type { Segment, } from './types' import type { StaticGenerationAsyncStorage } from '../../client/components/static-generation-async-storage' +import type { StaticGenerationBailout } from '../../client/components/static-generation-bailout' import type { RequestAsyncStorage } from '../../client/components/request-async-storage' import type { MetadataItems } from '../../lib/metadata/resolve-metadata' @@ -166,6 +167,7 @@ export async function renderToHTMLOrFlight( dev, nextFontManifest, supportsDynamicHTML, + nextConfigOutput, } = renderOpts const clientReferenceManifest = renderOpts.clientReferenceManifest! @@ -217,6 +219,8 @@ export async function renderToHTMLOrFlight( ComponentMod.staticGenerationAsyncStorage const requestAsyncStorage: RequestAsyncStorage = ComponentMod.requestAsyncStorage + const staticGenerationBailout: StaticGenerationBailout = + ComponentMod.staticGenerationBailout // we wrap the render in an AsyncLocalStorage context const wrappedRender = async () => { @@ -645,15 +649,33 @@ export async function renderToHTMLOrFlight( ? [DefaultNotFound] : [] - if (typeof layoutOrPageMod?.dynamic === 'string') { + let dynamic = layoutOrPageMod?.dynamic + + if (nextConfigOutput === 'export') { + if (!dynamic || dynamic === 'auto') { + dynamic = 'error' + } else if (dynamic === 'force-dynamic') { + staticGenerationStore.forceDynamic = true + staticGenerationStore.dynamicShouldError = true + staticGenerationBailout(`output: export`, { + dynamic, + link: 'https://nextjs.org/docs/advanced-features/static-html-export', + }) + } + } + + if (typeof dynamic === 'string') { // the nested most config wins so we only force-static // if it's configured above any parent that configured // otherwise - if (layoutOrPageMod.dynamic === 'error') { + if (dynamic === 'error') { staticGenerationStore.dynamicShouldError = true + } else if (dynamic === 'force-dynamic') { + staticGenerationStore.forceDynamic = true + staticGenerationBailout(`force-dynamic`, { dynamic }) } else { staticGenerationStore.dynamicShouldError = false - if (layoutOrPageMod.dynamic === 'force-static') { + if (dynamic === 'force-static') { staticGenerationStore.forceStatic = true } else { staticGenerationStore.forceStatic = false @@ -1460,6 +1482,15 @@ export async function renderToHTMLOrFlight( return result } catch (err: any) { + if ( + err.code === 'NEXT_STATIC_GEN_BAILOUT' || + err.message?.includes( + 'https://nextjs.org/docs/advanced-features/static-html-export' + ) + ) { + // Ensure that "next dev" prints the red error overlay + throw err + } if (err.digest === NEXT_DYNAMIC_NO_SSR_CODE) { warn( `Entire page ${pathname} deopted into client-side rendering. https://nextjs.org/docs/messages/deopted-into-client-rendering`, diff --git a/packages/next/src/server/future/route-modules/app-route/module.ts b/packages/next/src/server/future/route-modules/app-route/module.ts index fcf587af72b20..e711a0702bea2 100644 --- a/packages/next/src/server/future/route-modules/app-route/module.ts +++ b/packages/next/src/server/future/route-modules/app-route/module.ts @@ -278,7 +278,9 @@ export class AppRouteRouteModule extends RouteModule< // The dynamic property is set to force-dynamic, so we should // force the page to be dynamic. staticGenerationStore.forceDynamic = true - this.staticGenerationBailout(`dynamic = 'force-dynamic'`) + this.staticGenerationBailout(`force-dynamic`, { + dynamic: this.dynamic, + }) break case 'force-static': // The dynamic property is set to force-static, so we should diff --git a/test/development/acceptance-app/dynamic-error.test.ts b/test/development/acceptance-app/dynamic-error.test.ts index 73fa43fa1a7c3..21c38dafa228f 100644 --- a/test/development/acceptance-app/dynamic-error.test.ts +++ b/test/development/acceptance-app/dynamic-error.test.ts @@ -32,8 +32,8 @@ createNextDescribe( await session.hasRedbox(true) console.log(await session.getRedboxDescription()) - expect(await session.getRedboxDescription()).toMatchInlineSnapshot( - `"Error: Page with \`dynamic = \\"error\\"\` couldn't be rendered statically because it used \`cookies\`"` + expect(await session.getRedboxDescription()).toBe( + `Error: Page with \`dynamic = "error"\` couldn't be rendered statically because it used \`cookies\`.` ) await cleanup() diff --git a/test/integration/app-dir-export/test/index.test.ts b/test/integration/app-dir-export/test/index.test.ts index 1fd07eac299e1..05c7274ce975c 100644 --- a/test/integration/app-dir-export/test/index.test.ts +++ b/test/integration/app-dir-export/test/index.test.ts @@ -6,16 +6,18 @@ import fs from 'fs-extra' import webdriver from 'next-webdriver' import globOrig from 'glob' import { + check, fetchViaHTTP, File, findPort, + getRedboxHeader, + hasRedbox, killApp, launchApp, nextBuild, nextExport, startStaticServer, stopApp, - waitFor, } from 'next-test-utils' const glob = promisify(globOrig) @@ -51,20 +53,27 @@ const expectedFiles = [ async function getFiles(cwd = exportDir) { const opts = { cwd, nodir: true } const files = ((await glob('**/*', opts)) as string[]) - .filter((f) => !f.startsWith('_next/static/chunks/')) + .filter( + (f) => + !f.startsWith('_next/static/chunks/') && + !f.startsWith('_next/static/development/') && + !f.startsWith('_next/static/webpack/') + ) .sort() return files } async function runTests({ - isDev, - trailingSlash, + isDev = false, + trailingSlash = true, dynamicPage, dynamicApiRoute, + expectedErrMsg, }: { isDev?: boolean trailingSlash?: boolean dynamicPage?: string dynamicApiRoute?: string + expectedErrMsg?: string }) { if (trailingSlash) { nextConfig.replace( @@ -86,81 +95,104 @@ async function runTests({ } await fs.remove(distDir) await fs.remove(exportDir) - const delay = isDev ? 500 : 100 - const appPort = await findPort() + const port = await findPort() let stopOrKill: () => Promise + let result = { code: 0, stdout: '', stderr: '' } if (isDev) { - const app = await launchApp(appDir, appPort) + const app = await launchApp(appDir, port, { + stdout: false, + onStdout(msg: string) { + result.stdout += msg || '' + }, + stderr: false, + onStderr(msg: string) { + result.stderr += msg || '' + }, + }) stopOrKill = async () => await killApp(app) } else { - await nextBuild(appDir) - const app = await startStaticServer(exportDir, null, appPort) + result = await nextBuild(appDir, [], { stdout: true, stderr: true }) + const app = await startStaticServer(exportDir, null, port) stopOrKill = async () => await stopApp(app) } + try { - const a = (n: number) => `li:nth-child(${n}) a` - const browser = await webdriver(appPort, '/') - expect(await browser.elementByCss('h1').text()).toBe('Home') - expect(await browser.elementByCss(a(1)).text()).toBe( - 'another no trailingslash' - ) - await browser.elementByCss(a(1)).click() - await waitFor(delay) + if (expectedErrMsg) { + if (isDev) { + const url = dynamicPage ? '/another/first' : '/api/json' + const browser = await webdriver(port, url) + expect(await hasRedbox(browser, true)).toBe(true) + expect(await getRedboxHeader(browser)).toContain(expectedErrMsg) + } else { + await check(() => result.stderr, /error/i) + } + expect(result.stderr).toMatch(expectedErrMsg) + } else { + const a = (n: number) => `li:nth-child(${n}) a` + const browser = await webdriver(port, '/') + await check(() => browser.elementByCss('h1').text(), 'Home') + expect(await browser.elementByCss(a(1)).text()).toBe( + 'another no trailingslash' + ) + await browser.elementByCss(a(1)).click() - expect(await browser.elementByCss('h1').text()).toBe('Another') - expect(await browser.elementByCss(a(1)).text()).toBe('Visit the home page') - await browser.elementByCss(a(1)).click() - await waitFor(delay) + await check(() => browser.elementByCss('h1').text(), 'Another') + expect(await browser.elementByCss(a(1)).text()).toBe( + 'Visit the home page' + ) + await browser.elementByCss(a(1)).click() - expect(await browser.elementByCss('h1').text()).toBe('Home') - expect(await browser.elementByCss(a(2)).text()).toBe( - 'another has trailingslash' - ) - await browser.elementByCss(a(2)).click() - await waitFor(delay) + await check(() => browser.elementByCss('h1').text(), 'Home') + expect(await browser.elementByCss(a(2)).text()).toBe( + 'another has trailingslash' + ) + await browser.elementByCss(a(2)).click() - expect(await browser.elementByCss('h1').text()).toBe('Another') - expect(await browser.elementByCss(a(1)).text()).toBe('Visit the home page') - await browser.elementByCss(a(1)).click() - await waitFor(delay) + await check(() => browser.elementByCss('h1').text(), 'Another') + expect(await browser.elementByCss(a(1)).text()).toBe( + 'Visit the home page' + ) + await browser.elementByCss(a(1)).click() - expect(await browser.elementByCss('h1').text()).toBe('Home') - expect(await browser.elementByCss(a(3)).text()).toBe('another first page') - await browser.elementByCss(a(3)).click() - await waitFor(delay) + await check(() => browser.elementByCss('h1').text(), 'Home') + expect(await browser.elementByCss(a(3)).text()).toBe('another first page') + await browser.elementByCss(a(3)).click() - expect(await browser.elementByCss('h1').text()).toBe('first') - expect(await browser.elementByCss(a(1)).text()).toBe('Visit another page') - await browser.elementByCss(a(1)).click() - await waitFor(delay) + await check(() => browser.elementByCss('h1').text(), 'first') + expect(await browser.elementByCss(a(1)).text()).toBe('Visit another page') + await browser.elementByCss(a(1)).click() - expect(await browser.elementByCss('h1').text()).toBe('Another') - expect(await browser.elementByCss(a(4)).text()).toBe('another second page') - await browser.elementByCss(a(4)).click() - await waitFor(delay) + await check(() => browser.elementByCss('h1').text(), 'Another') + expect(await browser.elementByCss(a(4)).text()).toBe( + 'another second page' + ) + await browser.elementByCss(a(4)).click() - expect(await browser.elementByCss('h1').text()).toBe('second') - expect(await browser.elementByCss(a(1)).text()).toBe('Visit another page') - await browser.elementByCss(a(1)).click() - await waitFor(delay) + await check(() => browser.elementByCss('h1').text(), 'second') + expect(await browser.elementByCss(a(1)).text()).toBe('Visit another page') + await browser.elementByCss(a(1)).click() - expect(await browser.elementByCss('h1').text()).toBe('Another') - expect(await browser.elementByCss(a(5)).text()).toBe('image import page') - await browser.elementByCss(a(5)).click() - await waitFor(delay) + await check(() => browser.elementByCss('h1').text(), 'Another') + expect(await browser.elementByCss(a(5)).text()).toBe('image import page') + await browser.elementByCss(a(5)).click() - expect(await browser.elementByCss('h1').text()).toBe('Image Import') - expect(await browser.elementByCss(a(2)).text()).toBe('View the image') - expect(await browser.elementByCss(a(2)).getAttribute('href')).toContain( - '/test.3f1a293b.png' - ) - const res1 = await fetchViaHTTP(appPort, '/api/json') - expect(res1.status).toBe(200) - expect(await res1.json()).toEqual({ answer: 42 }) + await check(() => browser.elementByCss('h1').text(), 'Image Import') + expect(await browser.elementByCss(a(2)).text()).toBe('View the image') + expect(await browser.elementByCss(a(2)).getAttribute('href')).toContain( + '/test.3f1a293b.png' + ) + const res1 = await fetchViaHTTP(port, '/api/json') + expect(res1.status).toBe(200) + expect(await res1.json()).toEqual({ answer: 42 }) - const res2 = await fetchViaHTTP(appPort, '/api/txt') - expect(res2.status).toBe(200) - expect(await res2.text()).toEqual('this is plain text') + const res2 = await fetchViaHTTP(port, '/api/txt') + expect(res2.status).toBe(200) + expect(await res2.text()).toEqual('this is plain text') + + if (!isDev && trailingSlash) { + expect(await getFiles()).toEqual(expectedFiles) + } + } } finally { await stopOrKill() nextConfig.restore() @@ -177,68 +209,60 @@ describe('app dir with output export', () => { { isDev: false, trailingSlash: true }, ])( "should work with isDev '$isDev' and trailingSlash '$trailingSlash'", - async ({ trailingSlash }) => { - await runTests({ trailingSlash }) + async ({ isDev, trailingSlash }) => { + await runTests({ isDev, trailingSlash }) } ) it.each([ - { dynamic: 'undefined' }, - { dynamic: "'error'" }, - { dynamic: "'force-static'" }, - ])('should work with dynamic $dynamic on page', async ({ dynamic }) => { - await runTests({ dynamicPage: dynamic }) - expect(await getFiles()).toEqual(expectedFiles) - }) - it("should throw when dynamic 'force-dynamic' on page", async () => { - slugPage.replace( - `const dynamic = 'force-static'`, - `const dynamic = 'force-dynamic'` - ) - await fs.remove(distDir) - await fs.remove(exportDir) - let result = { code: 0, stderr: '' } - try { - result = await nextBuild(appDir, [], { stderr: true }) - } finally { - nextConfig.restore() - slugPage.restore() - apiJson.restore() + { isDev: true, dynamic: 'undefined' }, + { isDev: true, dynamic: "'error'" }, + { isDev: true, dynamic: "'force-static'" }, + { + isDev: true, + dynamic: "'force-dynamic'", + expectedErrMsg: + 'Page with `dynamic = "force-dynamic"` couldn\'t be rendered statically because it used `output: export`.', + }, + { isDev: false, dynamic: 'undefined' }, + { isDev: false, dynamic: "'error'" }, + { isDev: false, dynamic: "'force-static'" }, + { + isDev: false, + dynamic: "'force-dynamic'", + expectedErrMsg: + 'Page with `dynamic = "force-dynamic"` couldn\'t be rendered statically because it used `output: export`.', + }, + ])( + "should work with with isDev '$isDev' and dynamic $dynamic on page", + async ({ isDev, dynamic, expectedErrMsg }) => { + await runTests({ isDev, dynamicPage: dynamic, expectedErrMsg }) } - expect(result.code).toBe(1) - expect(result.stderr).toContain( - 'export const dynamic = "force-dynamic" on page "/another/[slug]" cannot be used with "output: export".' - ) - }) + ) it.each([ - { dynamic: 'undefined' }, - { dynamic: "'error'" }, - { dynamic: "'force-static'" }, + { isDev: true, dynamic: 'undefined' }, + { isDev: true, dynamic: "'error'" }, + { isDev: true, dynamic: "'force-static'" }, + { + isDev: true, + dynamic: "'force-dynamic'", + expectedErrMsg: + 'export const dynamic = "force-dynamic" on page "/api/json" cannot be used with "output: export".', + }, + { isDev: false, dynamic: 'undefined' }, + { isDev: false, dynamic: "'error'" }, + { isDev: false, dynamic: "'force-static'" }, + { + isDev: false, + dynamic: "'force-dynamic'", + expectedErrMsg: + 'export const dynamic = "force-dynamic" on page "/api/json" cannot be used with "output: export".', + }, ])( - 'should work with dynamic $dynamic on route handler', - async ({ dynamic }) => { - await runTests({ dynamicApiRoute: dynamic }) + "should work with with isDev '$isDev' and dynamic $dynamic on route handler", + async ({ isDev, dynamic, expectedErrMsg }) => { + await runTests({ isDev, dynamicApiRoute: dynamic, expectedErrMsg }) } ) - it("should throw when dynamic 'force-dynamic' on route handler", async () => { - apiJson.replace( - `const dynamic = 'force-static'`, - `const dynamic = 'force-dynamic'` - ) - await fs.remove(distDir) - await fs.remove(exportDir) - let result = { code: 0, stderr: '' } - try { - result = await nextBuild(appDir, [], { stderr: true }) - } finally { - nextConfig.restore() - slugPage.restore() - apiJson.restore() - } - expect(result.code).toBe(1) - expect(result.stderr).toContain( - 'export const dynamic = "force-dynamic" on page "/api/json" cannot be used with "output: export".' - ) - }) it('should throw when exportPathMap configured', async () => { nextConfig.replace( 'trailingSlash: true,',