diff --git a/packages/next/src/build/normalize-catchall-routes.test.ts b/packages/next/src/build/normalize-catchall-routes.test.ts index ea1e76420e..aae5edafbf 100644 --- a/packages/next/src/build/normalize-catchall-routes.test.ts +++ b/packages/next/src/build/normalize-catchall-routes.test.ts @@ -82,10 +82,7 @@ describe('normalizeCatchallRoutes', () => { // ensure values are correct after normalizing expect(appPaths).toMatchObject({ - '/': [ - '/page', - '/@slot/[...catchAll]/page', // inserted - ], + '/': ['/page'], '/[...catchAll]': ['/[...catchAll]/page', '/@slot/[...catchAll]/page'], '/bar': [ '/bar/page', @@ -103,6 +100,29 @@ describe('normalizeCatchallRoutes', () => { }) }) + it('should only match optional catch-all paths to the "index" of a segment', () => { + const appPaths = { + '/': ['/page'], + '/[[...catchAll]]': ['/@slot/[[...catchAll]]/page'], + '/foo': ['/foo/page'], + '/foo/[[...catchAll]]': ['/foo/@slot/[[...catchAll]]/page'], + } + + // normalize appPaths against catchAlls + normalizeCatchAllRoutes(appPaths) + + // ensure values are correct after normalizing + expect(appPaths).toMatchObject({ + '/': [ + '/page', + '/@slot/[[...catchAll]]/page', // inserted + ], + '/[[...catchAll]]': ['/@slot/[[...catchAll]]/page'], + '/foo': ['/foo/page', '/@slot/[[...catchAll]]/page'], + '/foo/[[...catchAll]]': ['/foo/@slot/[[...catchAll]]/page'], + }) + }) + it('should not add the catch-all route to segments that have a more specific default', () => { const appPaths = { '/': ['/page'], diff --git a/packages/next/src/build/normalize-catchall-routes.ts b/packages/next/src/build/normalize-catchall-routes.ts index 8186688770..99b700d2d4 100644 --- a/packages/next/src/build/normalize-catchall-routes.ts +++ b/packages/next/src/build/normalize-catchall-routes.ts @@ -47,7 +47,17 @@ export function normalizeCatchAllRoutes( // check if appPath is a catch-all OR is not more specific than the catch-all (isCatchAllRoute(appPath) || !isMoreSpecific(appPath, catchAllRoute)) ) { - appPaths[appPath].push(catchAllRoute) + if (isOptionalCatchAll(catchAllRoute)) { + // optional catch-all routes should match both the root segment and any segment after it + // for example, `/[[...slug]]` should match `/` and `/foo` and `/foo/bar` + appPaths[appPath].push(catchAllRoute) + } else if (isCatchAll(catchAllRoute)) { + // regular catch-all (single bracket) should only match segments after it + // for example, `/[...slug]` should match `/foo` and `/foo/bar` but not `/` + if (normalizedCatchAllRouteBasePath !== appPath) { + appPaths[appPath].push(catchAllRoute) + } + } } } } @@ -80,7 +90,15 @@ function isMatchableSlot(segment: string): boolean { const catchAllRouteRegex = /\[?\[\.\.\./ function isCatchAllRoute(pathname: string): boolean { - return pathname.includes('[...') || pathname.includes('[[...') + return isOptionalCatchAll(pathname) || isCatchAll(pathname) +} + +function isOptionalCatchAll(pathname: string): boolean { + return pathname.includes('[[...') +} + +function isCatchAll(pathname: string): boolean { + return pathname.includes('[...') } // test to see if a path is more specific than a catch-all route 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 0000000000..3213ba9659 --- /dev/null +++ b/test/e2e/app-dir/parallel-routes-catchall/app/@slot/default.tsx @@ -0,0 +1,3 @@ +export default function Page() { + return '@slot default' +} 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 index 5a5aa0ee80..398b7c636f 100644 --- 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 @@ -9,7 +9,7 @@ createNextDescribe( ({ 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 check(() => browser.elementById('slot').text(), /@slot default/) await browser.elementByCss('[href="/foo"]').click() @@ -21,7 +21,7 @@ createNextDescribe( 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 check(() => browser.elementById('slot').text(), /@slot default/) await browser.elementByCss('[href="/bar"]').click() @@ -37,11 +37,7 @@ createNextDescribe( 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 check( - () => browser.elementById('slot').text(), - /catchall slot client component/ - ) + await check(() => browser.elementById('slot').text(), /@slot default/) await browser.elementByCss('[href="/baz"]').click() @@ -53,11 +49,8 @@ createNextDescribe( it('should match both the catch-all page & slot', async () => { const browser = await next.browser('/') - await check(() => browser.elementById('slot').text(), /slot catchall/) - await check( - () => browser.elementById('slot').text(), - /catchall slot client component/ - ) + await check(() => browser.elementById('slot').text(), /@slot default/) + await browser.elementByCss('[href="/quux"]').click() // quux doesn't have a page or slot defined. It should use the catch-all for both