diff --git a/errors/conflicting-ssg-paths.md b/errors/conflicting-ssg-paths.md new file mode 100644 index 000000000000000..eb3e71a965bd91f --- /dev/null +++ b/errors/conflicting-ssg-paths.md @@ -0,0 +1,68 @@ +# Conflicting SSG Paths + +#### Why This Error Occurred + +In your `getStaticPaths` function for one of your pages you returned conflicting paths. All page paths must be unique and duplicates are not allowed. + +#### Possible Ways to Fix It + +Remove any conflicting paths shown in the error message and only return them from one `getStaticPaths`. + +Example conflicting paths: + +```jsx +// pages/hello/world.js +export default function Hello() { + return 'hello world!' +} + +// pages/[...catchAll].js +export const getStaticProps = () => ({ props: {} }) + +export const getStaticPaths = () => ({ + paths: [ + // this conflicts with the /hello/world.js page, remove to resolve error + '/hello/world', + '/another', + ], + fallback: false, +}) + +export default function CatchAll() { + return 'Catch-all page' +} +``` + +Example conflicting paths: + +```jsx +// pages/blog/[slug].js +export const getStaticPaths = () => ({ + paths: ['/blog/conflicting', '/blog/another'], + fallback: false, +}) + +export default function Blog() { + return 'Blog!' +} + +// pages/[...catchAll].js +export const getStaticProps = () => ({ props: {} }) + +export const getStaticPaths = () => ({ + paths: [ + // this conflicts with the /blog/conflicting path above, remove to resolve error + '/blog/conflicting', + '/another', + ], + fallback: false, +}) + +export default function CatchAll() { + return 'Catch-all page' +} +``` + +### Useful Links + +- [`getStaticPaths` Documentation](https://nextjs.org/docs/basic-features/data-fetching#getstaticpaths-static-generation) diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index ece28d8e4bc3703..eef4d1e54d9395a 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -72,6 +72,7 @@ import createSpinner from './spinner' import { traceAsyncFn, traceFn, tracer } from './tracer' import { collectPages, + detectConflictingPaths, getJsPageSizeInKb, getNamedExports, hasCustomGetInitialProps, @@ -870,6 +871,15 @@ export default async function build( await traceAsyncFn(tracer.startSpan('static-generation'), async () => { if (staticPages.size > 0 || ssgPages.size > 0 || useStatic404) { const combinedPages = [...staticPages, ...ssgPages] + + detectConflictingPaths( + [ + ...combinedPages, + ...pageKeys.filter((page) => !combinedPages.includes(page)), + ], + ssgPages, + additionalSsgPaths + ) const exportApp = require('../export').default const exportOptions = { silent: false, diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index c3fdfdee521f55b..9e77277f29a4dd2 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -28,6 +28,7 @@ import { BuildManifest } from '../next-server/server/get-page-files' import { removePathTrailingSlash } from '../client/normalize-trailing-slash' import { UnwrapPromise } from '../lib/coalesced-function' import { normalizeLocalePath } from '../next-server/lib/i18n/normalize-locale-path' +import * as Log from './output/log' import opentelemetryApi from '@opentelemetry/api' import { tracer, traceAsyncFn } from './tracer' @@ -879,3 +880,79 @@ export function getNamedExports( require('../next-server/lib/runtime-config').setConfig(runtimeEnvConfig) return Object.keys(require(bundle)) } + +export function detectConflictingPaths( + combinedPages: string[], + ssgPages: Set, + additionalSsgPaths: Map +) { + const conflictingPaths = new Map< + string, + Array<{ + path: string + page: string + }> + >() + + const dynamicSsgPages = [...ssgPages].filter((page) => isDynamicRoute(page)) + + additionalSsgPaths.forEach((paths, pathsPage) => { + paths.forEach((curPath) => { + const lowerPath = curPath.toLowerCase() + let conflictingPage = combinedPages.find( + (page) => page.toLowerCase() === lowerPath + ) + + if (conflictingPage) { + conflictingPaths.set(lowerPath, [ + { path: curPath, page: pathsPage }, + { path: conflictingPage, page: conflictingPage }, + ]) + } else { + let conflictingPath: string | undefined + + conflictingPage = dynamicSsgPages.find((page) => { + if (page === pathsPage) return false + + conflictingPath = additionalSsgPaths + .get(page) + ?.find((compPath) => compPath.toLowerCase() === lowerPath) + return conflictingPath + }) + + if (conflictingPage && conflictingPath) { + conflictingPaths.set(lowerPath, [ + { path: curPath, page: pathsPage }, + { path: conflictingPath, page: conflictingPage }, + ]) + } + } + }) + }) + + if (conflictingPaths.size > 0) { + let conflictingPathsOutput = '' + + conflictingPaths.forEach((pathItems) => { + pathItems.forEach((pathItem, idx) => { + const isDynamic = pathItem.page !== pathItem.path + + if (idx > 0) { + conflictingPathsOutput += 'conflicts with ' + } + + conflictingPathsOutput += `path: "${pathItem.path}"${ + isDynamic ? ` from page: "${pathItem.page}" ` : ' ' + }` + }) + conflictingPathsOutput += '\n' + }) + + Log.error( + 'Conflicting paths returned from getStaticPaths, paths must unique per page.\n' + + 'See more info here: https://err.sh/next.js/conflicting-ssg-paths\n\n' + + conflictingPathsOutput + ) + process.exit(1) + } +} diff --git a/test/integration/conflicting-ssg-paths/test/index.test.js b/test/integration/conflicting-ssg-paths/test/index.test.js new file mode 100644 index 000000000000000..5be2b8693203803 --- /dev/null +++ b/test/integration/conflicting-ssg-paths/test/index.test.js @@ -0,0 +1,183 @@ +/* eslint-env jest */ + +import fs from 'fs-extra' +import { join } from 'path' +import { nextBuild } from 'next-test-utils' + +jest.setTimeout(1000 * 60 * 1) + +const appDir = join(__dirname, '../') +const pagesDir = join(appDir, 'pages') + +describe('Conflicting SSG paths', () => { + afterEach(() => fs.remove(pagesDir)) + + it('should show proper error when two dynamic SSG routes have conflicting paths', async () => { + await fs.ensureDir(join(pagesDir, 'blog')) + await fs.writeFile( + join(pagesDir, 'blog/[slug].js'), + ` + export const getStaticProps = () => { + return { + props: {} + } + } + + export const getStaticPaths = () => { + return { + paths: [ + '/blog/conflicting', + '/blog/first' + ], + fallback: false + } + } + + export default function Page() { + return '/blog/[slug]' + } + ` + ) + + await fs.writeFile( + join(pagesDir, '[...catchAll].js'), + ` + export const getStaticProps = () => { + return { + props: {} + } + } + + export const getStaticPaths = () => { + return { + paths: [ + '/blog/conflicting', + '/hello/world' + ], + fallback: false + } + } + + export default function Page() { + return '/[catchAll]' + } + ` + ) + + const result = await nextBuild(appDir, undefined, { + stdout: true, + stderr: true, + }) + const output = result.stdout + result.stderr + expect(output).toContain( + 'Conflicting paths returned from getStaticPaths, paths must unique per page' + ) + expect(output).toContain('err.sh/next.js/conflicting-ssg-paths') + expect(output).toContain( + `path: "/blog/conflicting" from page: "/[...catchAll]"` + ) + expect(output).toContain(`conflicts with path: "/blog/conflicting"`) + }) + + it('should show proper error when a dynamic SSG route conflicts with normal route', async () => { + await fs.ensureDir(join(pagesDir, 'hello')) + await fs.writeFile( + join(pagesDir, 'hello/world.js'), + ` + export default function Page() { + return '/hello/world' + } + ` + ) + + await fs.writeFile( + join(pagesDir, '[...catchAll].js'), + ` + export const getStaticProps = () => { + return { + props: {} + } + } + + export const getStaticPaths = () => { + return { + paths: [ + '/hello', + '/hellO/world' + ], + fallback: false + } + } + + export default function Page() { + return '/[catchAll]' + } + ` + ) + + const result = await nextBuild(appDir, undefined, { + stdout: true, + stderr: true, + }) + const output = result.stdout + result.stderr + expect(output).toContain( + 'Conflicting paths returned from getStaticPaths, paths must unique per page' + ) + expect(output).toContain('err.sh/next.js/conflicting-ssg-paths') + expect(output).toContain( + `path: "/hellO/world" from page: "/[...catchAll]" conflicts with path: "/hello/world"` + ) + }) + + it('should show proper error when a dynamic SSG route conflicts with SSR route', async () => { + await fs.ensureDir(join(pagesDir, 'hello')) + await fs.writeFile( + join(pagesDir, 'hello/world.js'), + ` + export const getServerSideProps = () => ({ props: {} }) + + export default function Page() { + return '/hello/world' + } + ` + ) + + await fs.writeFile( + join(pagesDir, '[...catchAll].js'), + ` + export const getStaticProps = () => { + return { + props: {} + } + } + + export const getStaticPaths = () => { + return { + paths: [ + '/hello', + '/hellO/world' + ], + fallback: false + } + } + + export default function Page() { + return '/[catchAll]' + } + ` + ) + + const result = await nextBuild(appDir, undefined, { + stdout: true, + stderr: true, + }) + const output = result.stdout + result.stderr + expect(output).toContain( + 'Conflicting paths returned from getStaticPaths, paths must unique per page' + ) + expect(output).toContain('err.sh/next.js/conflicting-ssg-paths') + expect(output).toContain( + `path: "/hellO/world" from page: "/[...catchAll]" conflicts with path: "/hello/world"` + ) + }) +})