diff --git a/packages/next/src/build/normalize-catchall-routes.test.ts b/packages/next/src/build/normalize-catchall-routes.test.ts new file mode 100644 index 0000000000000..1ae0c771d883a --- /dev/null +++ b/packages/next/src/build/normalize-catchall-routes.test.ts @@ -0,0 +1,99 @@ +import { normalizeCatchAllRoutes } from './normalize-catchall-routes' + +describe('normalizeCatchallRoutes', () => { + it('should not add the catch-all to the interception route', () => { + const appPaths = { + '/': ['/page'], + '/[...slug]': ['/[...slug]/page'], + '/things/[...ids]': ['/things/[...ids]/page'], + '/(.)things/[...ids]': ['/@modal/(.)things/[...ids]/page'], + } + + const initialAppPaths = JSON.parse(JSON.stringify(appPaths)) + + normalizeCatchAllRoutes(appPaths) + + expect(appPaths).toMatchObject(initialAppPaths) + }) + + it('should add the catch-all route to all matched paths when nested', () => { + const appPaths = { + '/parallel-nested-catchall': ['/parallel-nested-catchall/page'], + '/parallel-nested-catchall/[...catchAll]': [ + '/parallel-nested-catchall/[...catchAll]/page', + '/parallel-nested-catchall/@slot/[...catchAll]/page', + ], + '/parallel-nested-catchall/bar': ['/parallel-nested-catchall/bar/page'], + '/parallel-nested-catchall/foo': [ + '/parallel-nested-catchall/foo/page', + '/parallel-nested-catchall/@slot/foo/page', + ], + '/parallel-nested-catchall/foo/[id]': [ + '/parallel-nested-catchall/foo/[id]/page', + ], + '/parallel-nested-catchall/foo/[...catchAll]': [ + '/parallel-nested-catchall/@slot/foo/[...catchAll]/page', + ], + } + + normalizeCatchAllRoutes(appPaths) + + expect(appPaths).toMatchObject({ + '/parallel-nested-catchall': ['/parallel-nested-catchall/page'], + '/parallel-nested-catchall/[...catchAll]': [ + '/parallel-nested-catchall/[...catchAll]/page', + '/parallel-nested-catchall/@slot/[...catchAll]/page', + ], + '/parallel-nested-catchall/bar': [ + '/parallel-nested-catchall/bar/page', + '/parallel-nested-catchall/@slot/[...catchAll]/page', // inserted + ], + '/parallel-nested-catchall/foo': [ + '/parallel-nested-catchall/foo/page', + '/parallel-nested-catchall/@slot/foo/page', + ], + '/parallel-nested-catchall/foo/[id]': [ + '/parallel-nested-catchall/foo/[id]/page', + '/parallel-nested-catchall/@slot/foo/[...catchAll]/page', // inserted + ], + '/parallel-nested-catchall/foo/[...catchAll]': [ + '/parallel-nested-catchall/@slot/foo/[...catchAll]/page', + '/parallel-nested-catchall/[...catchAll]/page', // inserted + ], + }) + }) + + it('should add the catch-all route to all matched paths at the root', () => { + const appPaths = { + '/': ['/page'], + '/[...catchAll]': ['/[...catchAll]/page', '/@slot/[...catchAll]/page'], + '/bar': ['/bar/page'], + '/foo': ['/foo/page', '/@slot/foo/page'], + '/foo/[id]': ['/foo/[id]/page'], + '/foo/[...catchAll]': ['/@slot/foo/[...catchAll]/page'], + } + + normalizeCatchAllRoutes(appPaths) + + expect(appPaths).toMatchObject({ + '/': [ + '/page', + '/@slot/[...catchAll]/page', // inserted + ], + '/[...catchAll]': ['/[...catchAll]/page', '/@slot/[...catchAll]/page'], + '/bar': [ + '/bar/page', + '/@slot/[...catchAll]/page', // inserted + ], + '/foo': ['/foo/page', '/@slot/foo/page'], + '/foo/[id]': [ + '/foo/[id]/page', + '/@slot/foo/[...catchAll]/page', // inserted + ], + '/foo/[...catchAll]': [ + '/@slot/foo/[...catchAll]/page', + '/[...catchAll]/page', //inserted + ], + }) + }) +}) diff --git a/packages/next/src/build/normalize-catchall-routes.ts b/packages/next/src/build/normalize-catchall-routes.ts index f89756ca187c4..f24f95897e77b 100644 --- a/packages/next/src/build/normalize-catchall-routes.ts +++ b/packages/next/src/build/normalize-catchall-routes.ts @@ -1,3 +1,4 @@ +import { isInterceptionRouteAppPath } from '../server/future/helpers/interception-routes' import { AppPathnameNormalizer } from '../server/future/normalizers/built/app/app-pathname-normalizer' /** @@ -21,7 +22,14 @@ export function normalizeCatchAllRoutes( ), ] - for (const appPath of Object.keys(appPaths)) { + // interception routes should only be matched by a single entrypoint + // we don't want to push a catch-all route to an interception route + // because it would mean the interception would be handled by the wrong page component + const filteredAppPaths = Object.keys(appPaths).filter( + (route) => !isInterceptionRouteAppPath(route) + ) + + for (const appPath of filteredAppPaths) { for (const catchAllRoute of catchAllRoutes) { const normalizedCatchAllRoute = normalizer.normalize(catchAllRoute) const normalizedCatchAllRouteBasePath = normalizedCatchAllRoute.slice( @@ -30,9 +38,9 @@ export function normalizeCatchAllRoutes( ) if ( - // first check if the appPath could match the catch-all + // check if the appPath could match the catch-all appPath.startsWith(normalizedCatchAllRouteBasePath) && - // then check if there's not already a slot value that could match the catch-all + // check if there's not already a slot value that could match the catch-all !appPaths[appPath].some((path) => hasMatchedSlots(path, catchAllRoute)) ) { appPaths[appPath].push(catchAllRoute) diff --git a/packages/next/src/build/webpack/loaders/next-app-loader.ts b/packages/next/src/build/webpack/loaders/next-app-loader.ts index ce1506a003d4f..7a67426bb5dce 100644 --- a/packages/next/src/build/webpack/loaders/next-app-loader.ts +++ b/packages/next/src/build/webpack/loaders/next-app-loader.ts @@ -546,12 +546,27 @@ const nextAppLoader: AppLoader = async function nextAppLoader() { continue } - // avoid clobbering existing page segments - // if it's a valid parallel segment, the `children` property will be set appropriately if (existingChildrenPath && matched.children !== rest[0]) { - throw new Error( - `You cannot have two parallel pages that resolve to the same path. Please check ${existingChildrenPath} and ${appPath}. Refer to the route group docs for more information: https://nextjs.org/docs/app/building-your-application/routing/route-groups` - ) + // If we get here, it means we already set a `page` segment earlier in the loop, + // meaning we already matched a page to the `children` parallel segment. + const isIncomingParallelPage = appPath.includes('@') + const hasCurrentParallelPage = existingChildrenPath.includes('@') + + if (isIncomingParallelPage) { + // The duplicate segment was for a parallel slot. In this case, + // rather than throwing an error, we can ignore it since this can happen for valid reasons. + // For example, when we attempt to normalize catch-all routes, we'll push potential slot matches so + // that they are available in the loader tree when we go to render the page. + // We only need to throw an error if the duplicate segment was for a regular page. + // For example, /app/(groupa)/page & /app/(groupb)/page is an error since it corresponds + // with the same path. + continue + } else if (!hasCurrentParallelPage && !isIncomingParallelPage) { + // Both the current `children` and the incoming `children` are regular pages. + throw new Error( + `You cannot have two parallel pages that resolve to the same path. Please check ${existingChildrenPath} and ${appPath}. Refer to the route group docs for more information: https://nextjs.org/docs/app/building-your-application/routing/route-groups` + ) + } } existingChildrenPath = appPath 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 ffb585ac6806a..2a5b0f05be2fc 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 @@ -34,7 +34,7 @@ export class DevAppPageRouteMatcherProvider extends FileCacheRouteMatcherProvide { page: string; pathname: string; bundlePath: string } >() const routeFilenames = new Array() - const appPaths: Record = {} + let appPaths: Record = {} for (const filename of files) { // If the file isn't a match for this matcher, then skip it. if (!this.expression.test(filename)) continue @@ -59,6 +59,11 @@ export class DevAppPageRouteMatcherProvider extends FileCacheRouteMatcherProvide normalizeCatchAllRoutes(appPaths) + // Make sure to sort parallel routes to make the result deterministic. + appPaths = Object.fromEntries( + Object.entries(appPaths).map(([k, v]) => [k, v.sort()]) + ) + const matchers: Array = [] for (const filename of routeFilenames) { // Grab the cached values (and the appPaths). diff --git a/packages/next/src/server/load-components.ts b/packages/next/src/server/load-components.ts index cce641eb998f2..91b61d60b668d 100644 --- a/packages/next/src/server/load-components.ts +++ b/packages/next/src/server/load-components.ts @@ -86,11 +86,11 @@ async function loadClientReferenceManifest( manifestPath: string, entryName: string ): Promise { - process.env.NEXT_MINIMAL - ? // @ts-ignore - __non_webpack_require__(manifestPath) - : require(manifestPath) try { + process.env.NEXT_MINIMAL + ? // @ts-ignore + __non_webpack_require__(manifestPath) + : require(manifestPath) return (globalThis as any).__RSC_MANIFEST[ entryName ] as ClientReferenceManifest diff --git a/test/e2e/app-dir/conflicting-page-segments/app/(group-a)/page.tsx b/test/e2e/app-dir/conflicting-page-segments/app/(group-a)/page.tsx new file mode 100644 index 0000000000000..194ea0e4d820e --- /dev/null +++ b/test/e2e/app-dir/conflicting-page-segments/app/(group-a)/page.tsx @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Home() { + return ( +
+ Home To /foo +
+ ) +} diff --git a/test/e2e/app-dir/conflicting-page-segments/app/(group-b)/page.tsx b/test/e2e/app-dir/conflicting-page-segments/app/(group-b)/page.tsx new file mode 100644 index 0000000000000..194ea0e4d820e --- /dev/null +++ b/test/e2e/app-dir/conflicting-page-segments/app/(group-b)/page.tsx @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Home() { + return ( +
+ Home To /foo +
+ ) +} diff --git a/test/e2e/app-dir/conflicting-page-segments/app/layout.tsx b/test/e2e/app-dir/conflicting-page-segments/app/layout.tsx new file mode 100644 index 0000000000000..45d17cfbe1e55 --- /dev/null +++ b/test/e2e/app-dir/conflicting-page-segments/app/layout.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + +
{children}
+ + + ) +} diff --git a/test/e2e/app-dir/conflicting-page-segments/conflicting-page-segments.test.ts b/test/e2e/app-dir/conflicting-page-segments/conflicting-page-segments.test.ts new file mode 100644 index 0000000000000..a9e394a34f962 --- /dev/null +++ b/test/e2e/app-dir/conflicting-page-segments/conflicting-page-segments.test.ts @@ -0,0 +1,31 @@ +import { createNextDescribe } from 'e2e-utils' +import { check } from 'next-test-utils' + +createNextDescribe( + 'conflicting-page-segments', + { + files: __dirname, + // we skip start because the build will fail and we won't be able to catch it + // start is re-triggered but caught in the assertions below + skipStart: true, + }, + ({ next, isNextDev }) => { + it('should throw an error when a route groups causes a conflict with a parallel segment', async () => { + if (isNextDev) { + await next.start() + const html = await next.render('/') + + expect(html).toContain( + 'You cannot have two parallel pages that resolve to the same path.' + ) + } else { + await expect(next.start()).rejects.toThrow('next build failed') + + await check( + () => next.cliOutput, + /You cannot have two parallel pages that resolve to the same path\. Please check \/\(group-a\)\/page and \/\(group-b\)\/page\./i + ) + } + }) + } +) diff --git a/test/e2e/app-dir/conflicting-page-segments/next.config.js b/test/e2e/app-dir/conflicting-page-segments/next.config.js new file mode 100644 index 0000000000000..807126e4cf0bf --- /dev/null +++ b/test/e2e/app-dir/conflicting-page-segments/next.config.js @@ -0,0 +1,6 @@ +/** + * @type {import('next').NextConfig} + */ +const nextConfig = {} + +module.exports = nextConfig diff --git a/test/e2e/app-dir/interception-routes-root-catchall/app/@modal/(.)items/[...ids]/page.tsx b/test/e2e/app-dir/interception-routes-root-catchall/app/@modal/(.)items/[...ids]/page.tsx new file mode 100644 index 0000000000000..134e51794e1b8 --- /dev/null +++ b/test/e2e/app-dir/interception-routes-root-catchall/app/@modal/(.)items/[...ids]/page.tsx @@ -0,0 +1,3 @@ +export default function Page({ params }: { params: { ids: string[] } }) { + return
Intercepted Modal Page. Id: {params.ids}
+} diff --git a/test/e2e/app-dir/interception-routes-root-catchall/app/@modal/default.tsx b/test/e2e/app-dir/interception-routes-root-catchall/app/@modal/default.tsx new file mode 100644 index 0000000000000..7651defa8cbe7 --- /dev/null +++ b/test/e2e/app-dir/interception-routes-root-catchall/app/@modal/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return
default @modal
+} diff --git a/test/e2e/app-dir/interception-routes-root-catchall/app/[...slug]/page.tsx b/test/e2e/app-dir/interception-routes-root-catchall/app/[...slug]/page.tsx new file mode 100644 index 0000000000000..7b605bc61d69d --- /dev/null +++ b/test/e2e/app-dir/interception-routes-root-catchall/app/[...slug]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Root Catch-All Page
+} diff --git a/test/e2e/app-dir/interception-routes-root-catchall/app/default.tsx b/test/e2e/app-dir/interception-routes-root-catchall/app/default.tsx new file mode 100644 index 0000000000000..70e3db7f1e01f --- /dev/null +++ b/test/e2e/app-dir/interception-routes-root-catchall/app/default.tsx @@ -0,0 +1,3 @@ +export default function Default() { + return
default root
+} diff --git a/test/e2e/app-dir/interception-routes-root-catchall/app/items/[...ids]/page.tsx b/test/e2e/app-dir/interception-routes-root-catchall/app/items/[...ids]/page.tsx new file mode 100644 index 0000000000000..85a01be7881f4 --- /dev/null +++ b/test/e2e/app-dir/interception-routes-root-catchall/app/items/[...ids]/page.tsx @@ -0,0 +1,3 @@ +export default function Page({ params }: { params: { ids: string[] } }) { + return
Regular Item Page. Id: {params.ids}
+} diff --git a/test/e2e/app-dir/interception-routes-root-catchall/app/layout.tsx b/test/e2e/app-dir/interception-routes-root-catchall/app/layout.tsx new file mode 100644 index 0000000000000..625f08ac2dddd --- /dev/null +++ b/test/e2e/app-dir/interception-routes-root-catchall/app/layout.tsx @@ -0,0 +1,18 @@ +import React from 'react' + +export default function Root({ + children, + modal, +}: { + children: React.ReactNode + modal: React.ReactNode +}) { + return ( + + +
{children}
+
{modal}
+ + + ) +} diff --git a/test/e2e/app-dir/interception-routes-root-catchall/app/page.tsx b/test/e2e/app-dir/interception-routes-root-catchall/app/page.tsx new file mode 100644 index 0000000000000..765d947d72eaa --- /dev/null +++ b/test/e2e/app-dir/interception-routes-root-catchall/app/page.tsx @@ -0,0 +1,10 @@ +import Link from 'next/link' + +export default async function Home() { + return ( +
+ Open Items #1 (Intercepted) + Go to Catch-All Page +
+ ) +} diff --git a/test/e2e/app-dir/interception-routes-root-catchall/interception-routes-root-catchall.test.ts b/test/e2e/app-dir/interception-routes-root-catchall/interception-routes-root-catchall.test.ts new file mode 100644 index 0000000000000..b6c9ef36bf72a --- /dev/null +++ b/test/e2e/app-dir/interception-routes-root-catchall/interception-routes-root-catchall.test.ts @@ -0,0 +1,41 @@ +import { createNextDescribe } from 'e2e-utils' +import { check } from 'next-test-utils' + +createNextDescribe( + 'interception-routes-root-catchall', + { + files: __dirname, + }, + ({ next }) => { + it('should support having a root catch-all and a catch-all in a parallel route group', async () => { + const browser = await next.browser('/') + await browser.elementByCss('[href="/items/1"]').click() + + // this triggers the /items route interception handling + await check( + () => browser.elementById('slot').text(), + /Intercepted Modal Page. Id: 1/ + ) + await browser.refresh() + + // no longer intercepted, using the page + await check(() => browser.elementById('slot').text(), /default @modal/) + await check( + () => browser.elementById('children').text(), + /Regular Item Page. Id: 1/ + ) + }) + + it('should handle non-intercepted catch-all pages', async () => { + const browser = await next.browser('/') + + // there's no explicit page for /foobar. This will trigger the catchall [...slug] page + await browser.elementByCss('[href="/foobar"]').click() + await check(() => browser.elementById('slot').text(), /default @modal/) + await check( + () => browser.elementById('children').text(), + /Root Catch-All Page/ + ) + }) + } +) diff --git a/test/e2e/app-dir/interception-routes-root-catchall/next.config.js b/test/e2e/app-dir/interception-routes-root-catchall/next.config.js new file mode 100644 index 0000000000000..807126e4cf0bf --- /dev/null +++ b/test/e2e/app-dir/interception-routes-root-catchall/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-and-interception/parallel-routes-and-interception.test.ts b/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts index 12d66ab205067..0c4fc9d01a170 100644 --- a/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts +++ b/test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts @@ -187,7 +187,7 @@ createNextDescribe( expect(pageText).toContain('parallel/(new)/@baz/nested/page') }) - it('should throw an error when a route groups causes a conflict with a parallel segment', async () => { + it('should gracefully handle when two page segments match the `children` parallel slot', async () => { await next.stop() await next.patchFile( 'app/parallel/nested-2/page.js', @@ -198,22 +198,21 @@ createNextDescribe( ` ) - if (isNextDev) { - await next.start() + await next.start() - const html = await next.render('/parallel/nested-2') + const html = await next.render('/parallel/nested-2') - expect(html).toContain( - 'You cannot have two parallel pages that resolve to the same path.' - ) + // before adding this file, the page would have matched `/app/parallel/(new)/@baz/nested-2/page` + // but we've added a more specific page, so it should match that instead + if (process.env.TURBOPACK) { + // TODO: this matches differently in Turbopack because the Webpack loader does some sorting on the paths + // Investigate the discrepancy in a follow-up. For now, since no errors are being thrown (and since this test was previously ignored in Turbopack), + // we'll just verify that the page is rendered and some content was matched. + expect(html).toContain('parallel/(new)/@baz/nested/page') } else { - await expect(next.start()).rejects.toThrow('next build failed') - - await check( - () => next.cliOutput, - /You cannot have two parallel pages that resolve to the same path\. Please check \/parallel\/\(new\)\/@baz\/nested-2\/page and \/parallel\/nested-2\/page\./i - ) + expect(html).toContain('hello world') } + await next.stop() await next.deleteFile('app/parallel/nested-2/page.js') await next.start() @@ -339,7 +338,7 @@ createNextDescribe( ) }) - it('Should match the catch-all routes of the more specific path, If there is more than one catch-all route', async () => { + it('should match the catch-all routes of the more specific path, if there is more than one catch-all route', async () => { const browser = await next.browser('/parallel-nested-catchall') await browser diff --git a/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-a)/@parallel/[...catcher]/page.tsx b/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-a)/@parallel/[...catcher]/page.tsx new file mode 100644 index 0000000000000..33920dc87b05f --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-a)/@parallel/[...catcher]/page.tsx @@ -0,0 +1,3 @@ +export default function Catcher() { + return
Catcher
+} diff --git a/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-a)/layout.tsx b/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-a)/layout.tsx new file mode 100644 index 0000000000000..cb12f61365d90 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-a)/layout.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +export default function Root({ parallel }: { parallel: React.ReactNode }) { + return ( + + +
{parallel}
+ + + ) +} diff --git a/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-b)/foo/page.tsx b/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-b)/foo/page.tsx new file mode 100644 index 0000000000000..4169b336e0e3e --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-b)/foo/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return
Foo Page
+} diff --git a/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-b)/layout.tsx b/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-b)/layout.tsx new file mode 100644 index 0000000000000..45d17cfbe1e55 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-b)/layout.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + +
{children}
+ + + ) +} diff --git a/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-b)/page.tsx b/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-b)/page.tsx new file mode 100644 index 0000000000000..cf3e7fbbe03f2 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-groups/app/(group-b)/page.tsx @@ -0,0 +1,15 @@ +import Link from 'next/link' + +export default function Home() { + return ( +
+ Home +
+ To /foo +
+
+ To /bar +
+
+ ) +} diff --git a/test/e2e/app-dir/parallel-routes-catchall-groups/next.config.js b/test/e2e/app-dir/parallel-routes-catchall-groups/next.config.js new file mode 100644 index 0000000000000..807126e4cf0bf --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-groups/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-groups/parallel-routes-catchall-groups.test.ts b/test/e2e/app-dir/parallel-routes-catchall-groups/parallel-routes-catchall-groups.test.ts new file mode 100644 index 0000000000000..8bcc6f109b5b9 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall-groups/parallel-routes-catchall-groups.test.ts @@ -0,0 +1,25 @@ +import { createNextDescribe } from 'e2e-utils' +import { check } from 'next-test-utils' + +createNextDescribe( + 'parallel-routes-catchall-groups', + { + files: __dirname, + }, + ({ next }) => { + it('should work without throwing any errors about conflicting paths', async () => { + const browser = await next.browser('/') + + await check(() => browser.elementByCss('body').text(), /Home/) + await browser.elementByCss('[href="/foo"]').click() + // Foo has a page route defined, so we'd expect to see the page content + await check(() => browser.elementByCss('body').text(), /Foo Page/) + await browser.back() + + // /bar doesn't have an explicit page path. (group-a) defines a catch-all slot with a separate root layout + // that only renders a slot (ie no children). So we'd expect to see the fallback slot content + await browser.elementByCss('[href="/bar"]').click() + await check(() => browser.elementByCss('body').text(), /Catcher/) + }) + } +) diff --git a/test/e2e/app-dir/parallel-routes-catchall/app/@slot/[...catchAll]/page.tsx b/test/e2e/app-dir/parallel-routes-catchall/app/@slot/[...catchAll]/page.tsx new file mode 100644 index 0000000000000..c53a5bb486dbe --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall/app/@slot/[...catchAll]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return 'slot catchall' +} diff --git a/test/e2e/app-dir/parallel-routes-catchall/app/@slot/baz/page.tsx b/test/e2e/app-dir/parallel-routes-catchall/app/@slot/baz/page.tsx new file mode 100644 index 0000000000000..b75838a639a09 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall/app/@slot/baz/page.tsx @@ -0,0 +1,3 @@ +export default function Baz() { + return 'baz slot' +} 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 new file mode 100644 index 0000000000000..129f875a30b3a --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall/app/@slot/default.tsx @@ -0,0 +1,7 @@ +export default function Default() { + return ( +
+
Default
+
+ ) +} diff --git a/test/e2e/app-dir/parallel-routes-catchall/app/@slot/foo/page.tsx b/test/e2e/app-dir/parallel-routes-catchall/app/@slot/foo/page.tsx new file mode 100644 index 0000000000000..cfa4f3df3f08e --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall/app/@slot/foo/page.tsx @@ -0,0 +1,3 @@ +export default function Foo() { + return 'foo slot' +} diff --git a/test/e2e/app-dir/parallel-routes-catchall/app/[...catchAll]/page.tsx b/test/e2e/app-dir/parallel-routes-catchall/app/[...catchAll]/page.tsx new file mode 100644 index 0000000000000..a305e6e15809b --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall/app/[...catchAll]/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return 'main catchall' +} diff --git a/test/e2e/app-dir/parallel-routes-catchall/app/bar/page.tsx b/test/e2e/app-dir/parallel-routes-catchall/app/bar/page.tsx new file mode 100644 index 0000000000000..74a1debf68973 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall/app/bar/page.tsx @@ -0,0 +1,3 @@ +export default function Foo() { + return 'bar' +} diff --git a/test/e2e/app-dir/parallel-routes-catchall/app/foo/page.tsx b/test/e2e/app-dir/parallel-routes-catchall/app/foo/page.tsx new file mode 100644 index 0000000000000..fa0f41c9d1702 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall/app/foo/page.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return 'foo' +} diff --git a/test/e2e/app-dir/parallel-routes-catchall/app/layout.tsx b/test/e2e/app-dir/parallel-routes-catchall/app/layout.tsx new file mode 100644 index 0000000000000..c6bbc6382dfdd --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall/app/layout.tsx @@ -0,0 +1,18 @@ +import React from 'react' + +export default function Root({ + children, + slot, +}: { + children: React.ReactNode + slot: React.ReactNode +}) { + return ( + + +
{children}
+
{slot}
+ + + ) +} diff --git a/test/e2e/app-dir/parallel-routes-catchall/app/page.tsx b/test/e2e/app-dir/parallel-routes-catchall/app/page.tsx new file mode 100644 index 0000000000000..2fbabf8f33abb --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall/app/page.tsx @@ -0,0 +1,20 @@ +import Link from 'next/link' + +export default async function Home() { + return ( +
+
+ Go to /foo (page & slot) +
+
+ Go to /bar (page & no slot) +
+
+ Go to /baz (no page & slot) +
+
+ Go to /quux (no page & no slot) +
+
+ ) +} diff --git a/test/e2e/app-dir/parallel-routes-catchall/next.config.js b/test/e2e/app-dir/parallel-routes-catchall/next.config.js new file mode 100644 index 0000000000000..807126e4cf0bf --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall/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/parallel-routes-catchall.test.ts b/test/e2e/app-dir/parallel-routes-catchall/parallel-routes-catchall.test.ts new file mode 100644 index 0000000000000..38952ae3ffffc --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall/parallel-routes-catchall.test.ts @@ -0,0 +1,57 @@ +import { createNextDescribe } from 'e2e-utils' +import { check } from 'next-test-utils' + +createNextDescribe( + 'parallel-routes-catchall', + { + files: __dirname, + }, + ({ next }) => { + it('should match correctly when defining an explicit page & slot', async () => { + const browser = await next.browser('/') + await check(() => browser.elementById('slot').text(), /slot catchall/) + + await browser.elementByCss('[href="/foo"]').click() + + // foo has defined a page route and a corresponding parallel slot + // so we'd expect to see the custom slot content & the page content + await check(() => browser.elementById('children').text(), /foo/) + await check(() => browser.elementById('slot').text(), /foo slot/) + }) + + it('should match correctly when defining an explicit page but no slot', async () => { + const browser = await next.browser('/') + await check(() => browser.elementById('slot').text(), /slot catchall/) + + await browser.elementByCss('[href="/bar"]').click() + + // bar has defined a slot but no page route + // so we'd expect to see the catch-all slot & the page content + await check(() => browser.elementById('children').text(), /bar/) + await check(() => browser.elementById('slot').text(), /slot catchall/) + }) + + it('should match correctly when defining an explicit slot but no page', async () => { + const browser = await next.browser('/') + await check(() => browser.elementById('slot').text(), /slot catchall/) + + await browser.elementByCss('[href="/baz"]').click() + + // baz has defined a page route and a corresponding parallel slot + // so we'd expect to see the custom slot content & the page content + await check(() => browser.elementById('children').text(), /main catchall/) + await check(() => browser.elementById('slot').text(), /baz slot/) + }) + + it('should match both the catch-all page & slot', async () => { + const browser = await next.browser('/') + await check(() => browser.elementById('slot').text(), /slot catchall/) + + await browser.elementByCss('[href="/quux"]').click() + + // quux doesn't have a page or slot defined. It should use the catch-all for both + await check(() => browser.elementById('children').text(), /main catchall/) + await check(() => browser.elementById('slot').text(), /slot catchall/) + }) + } +) diff --git a/test/turbopack-tests-manifest.json b/test/turbopack-tests-manifest.json index 6f73c35e94b14..48c570eaa72dc 100644 --- a/test/turbopack-tests-manifest.json +++ b/test/turbopack-tests-manifest.json @@ -3838,8 +3838,7 @@ ], "failed": [ "parallel-routes-and-interception parallel routes Should match the catch-all routes of the more specific path, If there is more than one catch-all route", - "parallel-routes-and-interception parallel routes should apply the catch-all route to the parallel route if no matching route is found", - "parallel-routes-and-interception parallel routes should throw an error when a route groups causes a conflict with a parallel segment" + "parallel-routes-and-interception parallel routes should apply the catch-all route to the parallel route if no matching route is found" ], "pending": [], "flakey": [],