Skip to content

Commit 4024b25

Browse files
authored
parallel routes: fix catch all route support (#58215)
This PR fixes a bug where parallel routes would not apply appropriately on navigation when used within slots. The following scenarios: ``` /foo /bar /@slot /[...catchAll] ``` or ``` /foo /[...catchAll] /@slot /bar ``` will now function correctly when accessing /foo/bar, and Next.js will render both /bar and the catchall slots. The issue was that the tree constructed by `next-app-loader` for a given path, /foo/bar in the example, would not include the paths for the catch-all files at build time. The routing was done 1-1 when compiling files, where a path would only match one file, with parallel routes, a path could hit a defined path but also a catch all route at the same time in a different slot. The fix consists of adding another normalisation layer that will look for all catch-all in `appPaths` and iterate over the other paths and add the relevant information when needed. The tricky part was making sure that we only included the relevant paths to the loader: we don't want to overwrite a slot with a catch all if there's already a more specific subpath in that slot, i.e. if there's /foo/@slot/bar/page.js, no need to inject /foo/@slot/bar/[...catchAll]. One thing that is not supported right now is optional catch all routes, will add later. fixes #48719 fixes #49662
1 parent b740fe8 commit 4024b25

File tree

7 files changed

+72
-3
lines changed

7 files changed

+72
-3
lines changed

packages/next/src/build/entries.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import {
6060
import { isStaticMetadataRouteFile } from '../lib/metadata/is-metadata-route'
6161
import { RouteKind } from '../server/future/route-kind'
6262
import { encodeToBase64 } from './webpack/loaders/utils'
63+
import { normalizeCatchAllRoutes } from './normalize-catchall-routes'
6364

6465
export function sortByPageExts(pageExtensions: string[]) {
6566
return (a: string, b: string) => {
@@ -545,6 +546,9 @@ export async function createEntrypoints(
545546
)
546547
}
547548

549+
// TODO: find a better place to do this
550+
normalizeCatchAllRoutes(appPathsPerRoute)
551+
548552
// Make sure to sort parallel routes to make the result deterministic.
549553
appPathsPerRoute = Object.fromEntries(
550554
Object.entries(appPathsPerRoute).map(([k, v]) => [k, v.sort()])
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { AppPathnameNormalizer } from '../server/future/normalizers/built/app/app-pathname-normalizer'
2+
3+
/**
4+
* This function will transform the appPaths in order to support catch-all routes and parallel routes.
5+
* It will traverse the appPaths, looking for catch-all routes and try to find parallel routes that could match
6+
* the catch-all. If it finds a match, it will add the catch-all to the parallel route's list of possible routes.
7+
*
8+
* @param appPaths The appPaths to transform
9+
*/
10+
export function normalizeCatchAllRoutes(
11+
appPaths: Record<string, string[]>,
12+
normalizer = new AppPathnameNormalizer()
13+
) {
14+
const catchAllRoutes = [
15+
...new Set(Object.values(appPaths).flat().filter(isCatchAllRoute)),
16+
]
17+
18+
for (const appPath of Object.keys(appPaths)) {
19+
for (const catchAllRoute of catchAllRoutes) {
20+
const normalizedCatchAllRoute = normalizer.normalize(catchAllRoute)
21+
const normalizedCatchAllRouteBasePath = normalizedCatchAllRoute.slice(
22+
0,
23+
normalizedCatchAllRoute.indexOf('[')
24+
)
25+
26+
if (
27+
// first check if the appPath could match the catch-all
28+
appPath.startsWith(normalizedCatchAllRouteBasePath) &&
29+
// then check if there's not already a slot value that could match the catch-all
30+
!appPaths[appPath].some((path) => hasMatchedSlots(path, catchAllRoute))
31+
) {
32+
appPaths[appPath].push(catchAllRoute)
33+
}
34+
}
35+
}
36+
}
37+
38+
function hasMatchedSlots(path1: string, path2: string): boolean {
39+
const slots1 = path1.split('/').filter((segment) => segment.startsWith('@'))
40+
const slots2 = path2.split('/').filter((segment) => segment.startsWith('@'))
41+
42+
if (slots1.length !== slots2.length) return false
43+
44+
for (let i = 0; i < slots1.length; i++) {
45+
if (slots1[i] !== slots2[i]) return false
46+
}
47+
48+
return true
49+
}
50+
51+
function isCatchAllRoute(pathname: string): boolean {
52+
return pathname.includes('[...') || pathname.includes('[[...')
53+
}

packages/next/src/server/future/route-matcher-providers/dev/dev-app-page-route-matcher-provider.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { RouteKind } from '../../route-kind'
44
import { FileCacheRouteMatcherProvider } from './file-cache-route-matcher-provider'
55

66
import { DevAppNormalizers } from '../../normalizers/built/app'
7+
import { normalizeCatchAllRoutes } from '../../../../build/normalize-catchall-routes'
78

89
export class DevAppPageRouteMatcherProvider extends FileCacheRouteMatcherProvider<AppPageRouteMatcher> {
910
private readonly expression: RegExp
@@ -56,6 +57,8 @@ export class DevAppPageRouteMatcherProvider extends FileCacheRouteMatcherProvide
5657
else appPaths[pathname] = [page]
5758
}
5859

60+
normalizeCatchAllRoutes(appPaths)
61+
5962
const matchers: Array<AppPageRouteMatcher> = []
6063
for (const filename of routeFilenames) {
6164
// Grab the cached values (and the appPaths).
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Baz() {
2+
return 'baz slot'
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Foo() {
2+
return 'bar slot'
3+
}

test/e2e/app-dir/parallel-routes-and-interception/app/parallel-catchall/layout.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export default function Layout({ children, slot }) {
1616
<div>
1717
<Link href="/parallel-catchall/bar">catchall bar</Link>
1818
</div>
19+
<div>
20+
<Link href="/parallel-catchall/baz">catchall baz</Link>
21+
</div>
1922
</div>
2023
)
2124
}

test/e2e/app-dir/parallel-routes-and-interception/parallel-routes-and-interception.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ createNextDescribe(
314314
await browser.elementByCss('[href="/parallel-catchall/bar"]').click()
315315
await check(
316316
() => browser.waitForElementByCss('#main').text(),
317-
'main catchall'
317+
'bar slot'
318318
)
319319
await check(
320320
() => browser.waitForElementByCss('#slot-content').text(),
@@ -328,14 +328,14 @@ createNextDescribe(
328328
'foo slot'
329329
)
330330

331-
await browser.elementByCss('[href="https://github.com/parallel-catchall/bar"]').click()
331+
await browser.elementByCss('[href="https://github.com/parallel-catchall/baz"]').click()
332332
await check(
333333
() => browser.waitForElementByCss('#main').text(),
334334
'main catchall'
335335
)
336336
await check(
337337
() => browser.waitForElementByCss('#slot-content').text(),
338-
'slot catchall'
338+
'baz slot'
339339
)
340340
})
341341

0 commit comments

Comments
 (0)