diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index f19fde79196f58..442d0b664fb5ea 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -902,7 +902,9 @@ export default async function build( validFileMatcher.isAppRouterPage(absolutePath) || // For now we only collect the root /not-found page in the app // directory as the 404 fallback - validFileMatcher.isRootNotFound(absolutePath), + validFileMatcher.isRootNotFound(absolutePath) || + // Default slots are also valid pages, and need to be considered during path normalization + validFileMatcher.isDefaultSlot(absolutePath), ignorePartFilter: (part) => part.startsWith('_'), }) ) diff --git a/packages/next/src/build/normalize-catchall-routes.ts b/packages/next/src/build/normalize-catchall-routes.ts index f24f95897e77be..98182cee2d3e59 100644 --- a/packages/next/src/build/normalize-catchall-routes.ts +++ b/packages/next/src/build/normalize-catchall-routes.ts @@ -1,3 +1,4 @@ +import { isDefaultRoute } from '../lib/is-default-route' import { isInterceptionRouteAppPath } from '../server/future/helpers/interception-routes' import { AppPathnameNormalizer } from '../server/future/normalizers/built/app/app-pathname-normalizer' @@ -28,6 +29,7 @@ export function normalizeCatchAllRoutes( const filteredAppPaths = Object.keys(appPaths).filter( (route) => !isInterceptionRouteAppPath(route) ) + const defaultPaths = new Set(filteredAppPaths.filter(isDefaultRoute)) for (const appPath of filteredAppPaths) { for (const catchAllRoute of catchAllRoutes) { @@ -41,7 +43,11 @@ export function normalizeCatchAllRoutes( // check if the appPath could match the catch-all appPath.startsWith(normalizedCatchAllRouteBasePath) && // check if there's not already a slot value that could match the catch-all - !appPaths[appPath].some((path) => hasMatchedSlots(path, catchAllRoute)) + !appPaths[appPath].some((path) => + hasMatchedSlots(path, catchAllRoute) + ) && + // check if there's not already a default route that would match in place of the catch-all + !hasMatchedDefault(appPath, defaultPaths) ) { appPaths[appPath].push(catchAllRoute) } @@ -62,6 +68,26 @@ function hasMatchedSlots(path1: string, path2: string): boolean { return true } +/** + * Checks if the given path would be matched by a default route. + * This is to avoid adding a catch-all route to a path that should be matched by a default route. + */ +function hasMatchedDefault( + pathToCheck: string, + defaultPaths: Set +): boolean { + const pathSegments = pathToCheck.split('/') + + for (let i = 1; i <= pathSegments.length; i++) { + const partialRoute = pathSegments.slice(0, i).join('/') + if (defaultPaths.has(partialRoute + '/default')) { + return true + } + } + + return false +} + const catchAllRouteRegex = /\[?\[\.\.\./ function isCatchAllRoute(pathname: string): boolean { diff --git a/packages/next/src/export/index.ts b/packages/next/src/export/index.ts index eb949220ffb192..90c9c5713244c3 100644 --- a/packages/next/src/export/index.ts +++ b/packages/next/src/export/index.ts @@ -55,6 +55,7 @@ import isError from '../lib/is-error' import { needsExperimentalReact } from '../lib/needs-experimental-react' import { formatManifest } from '../build/manifests/formatter/format-manifest' import { validateRevalidate } from '../server/lib/patch-fetch' +import { isDefaultRoute } from '../lib/is-default-route' function divideSegments(number: number, segments: number): number[] { const result = [] @@ -302,7 +303,7 @@ export async function exportAppImpl( let hasApiRoutes = false for (const page of pages) { - // _document and _app are not real pages + // _document, _app, and default are not real pages // _error is exported as 404.html later on // API Routes are Node.js functions @@ -311,7 +312,12 @@ export async function exportAppImpl( continue } - if (page === '/_document' || page === '/_app' || page === '/_error') { + if ( + page === '/_document' || + page === '/_app' || + page === '/_error' || + isDefaultRoute(page) + ) { continue } diff --git a/packages/next/src/lib/is-default-route.ts b/packages/next/src/lib/is-default-route.ts new file mode 100644 index 00000000000000..adbd42d508f7fc --- /dev/null +++ b/packages/next/src/lib/is-default-route.ts @@ -0,0 +1,3 @@ +export function isDefaultRoute(value?: string) { + return value?.endsWith('/default') +} diff --git a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts index 2a5b0f05be2fc1..bb7ef7cc975873 100644 --- a/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts +++ b/packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts @@ -19,9 +19,11 @@ export class DevAppPageRouteMatcherProvider extends FileCacheRouteMatcherProvide this.normalizers = new DevAppNormalizers(appDir, extensions) - // Match any page file that ends with `/page.${extension}` under the app + // Match any page file that ends with `/page.${extension}` or `/default.${extension}` under the app // directory. - this.expression = new RegExp(`[/\\\\]page\\.(?:${extensions.join('|')})$`) + this.expression = new RegExp( + `[/\\\\](page|default)\\.(?:${extensions.join('|')})$` + ) } protected async transform( diff --git a/packages/next/src/server/lib/find-page-file.ts b/packages/next/src/server/lib/find-page-file.ts index d51935a761d170..42b046e8efc540 100644 --- a/packages/next/src/server/lib/find-page-file.ts +++ b/packages/next/src/server/lib/find-page-file.ts @@ -127,6 +127,10 @@ export function createValidFileMatcher( return validExtensionFileRegex.test(filePath) || isMetadataFile(filePath) } + function isDefaultSlot(filePath: string) { + return filePath.endsWith(`default.${pageExtensions[0]}`) + } + function isRootNotFound(filePath: string) { if (!appDirPath) { return false @@ -143,5 +147,6 @@ export function createValidFileMatcher( isAppRouterPage, isMetadataFile, isRootNotFound, + isDefaultSlot, } } diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/[[...catchAll]]/page.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/[[...catchAll]]/page.tsx new file mode 100644 index 00000000000000..32aad010387345 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/[[...catchAll]]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
/[locale]/[[...catchAll]]/page.tsx
+} diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/[baz]/page.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/[baz]/page.tsx new file mode 100644 index 00000000000000..6078f8edee1d12 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/[baz]/page.tsx @@ -0,0 +1,3 @@ +export default function Page({ params }) { + return
/[locale]/nested/[foo]/[bar]/@slot/[baz]/page.tsx
+} diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/default.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/default.tsx new file mode 100644 index 00000000000000..5a33f15b805915 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/default.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
/[locale]/nested/[foo]/[bar]/@slot/default.tsx
+} diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/page.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/page.tsx new file mode 100644 index 00000000000000..32afde0e4032a5 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/@slot/page.tsx @@ -0,0 +1,3 @@ +export default function Foo() { + return
/[locale]/nested/[foo]/[bar]/@slot/page.tsx
+} diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/default.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/default.tsx new file mode 100644 index 00000000000000..b99ced4ae3d61f --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/default.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
/[locale]/nested/[foo]/[bar]/default.tsx
+} diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/layout.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/layout.tsx new file mode 100644 index 00000000000000..f2619e0b884b96 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/[locale]/nested/[foo]/[bar]/layout.tsx @@ -0,0 +1,8 @@ +export default function Layout({ children, slot }) { + return ( + <> + Children:
{children}
+ Slot:
{slot}
+ + ) +} diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/layout.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/layout.tsx new file mode 100644 index 00000000000000..98b2ba6e286e8d --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/layout.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + + Children:
{children}
+ + + ) +} diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/app/page.tsx b/test/e2e/app-dir/parallel-routes-catchall-default/app/page.tsx new file mode 100644 index 00000000000000..fdfe1866d758ac --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-default/app/page.tsx @@ -0,0 +1,5 @@ +import Link from 'next/link' + +export default async function Home() { + return
Root Page
+} diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/next.config.js b/test/e2e/app-dir/parallel-routes-catchall-default/next.config.js new file mode 100644 index 00000000000000..807126e4cf0bf5 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-default/next.config.js @@ -0,0 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/parallel-routes-catchall-default/parallel-routes-catchall-default.test.ts b/test/e2e/app-dir/parallel-routes-catchall-default/parallel-routes-catchall-default.test.ts new file mode 100644 index 00000000000000..7c4fd86b93aad1 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-default/parallel-routes-catchall-default.test.ts @@ -0,0 +1,44 @@ +import { createNextDescribe } from 'e2e-utils' + +createNextDescribe( + 'parallel-routes-catchall-default', + { + files: __dirname, + }, + ({ next }) => { + it('should match default paths before catch-all', async () => { + let browser = await next.browser('/en/nested') + + // we have a top-level catch-all but the /nested dir doesn't have a default/page until the /[foo]/[bar] segment + // so we expect the top-level catch-all to render + expect(await browser.elementById('children').text()).toBe( + '/[locale]/[[...catchAll]]/page.tsx' + ) + + browser = await next.browser('/en/nested/foo/bar') + + // we're now at the /[foo]/[bar] segment, so we expect the matched page to be the default (since there's no page defined) + expect(await browser.elementById('nested-children').text()).toBe( + '/[locale]/nested/[foo]/[bar]/default.tsx' + ) + + // we expect the slot to match since there's a page defined at this segment + expect(await browser.elementById('slot').text()).toBe( + '/[locale]/nested/[foo]/[bar]/@slot/page.tsx' + ) + + browser = await next.browser('/en/nested/foo/bar/baz') + + // the page slot should still be the one matched at the /[foo]/[bar] segment because it's the default and we + // didn't define a page at the /[foo]/[bar]/[baz] segment + expect(await browser.elementById('nested-children').text()).toBe( + '/[locale]/nested/[foo]/[bar]/default.tsx' + ) + + // however we do have a slot for the `[baz]` segment and so we expect that to no match + expect(await browser.elementById('slot').text()).toBe( + '/[locale]/nested/[foo]/[bar]/@slot/[baz]/page.tsx' + ) + }) + } +) diff --git a/test/e2e/app-dir/parallel-routes-catchall/app/@slot/default.tsx b/test/e2e/app-dir/parallel-routes-catchall/app/@slot/default.tsx deleted file mode 100644 index 129f875a30b3a4..00000000000000 --- a/test/e2e/app-dir/parallel-routes-catchall/app/@slot/default.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function Default() { - return ( -
-
Default
-
- ) -}