Skip to content

Commit e0d612e

Browse files
authored
Fix build crash with parallel routes and root-level dynamic parameters (#85074)
### What? Fixes a build-time crash that occurs when using parallel routes (`@slot` syntax) combined with root-level dynamic parameters (e.g., `[locale]`) in the App Router. ### Why? When building static paths for routes with both parallel routes and root-level dynamic parameters, the build would crash during pathname generation. This happened because the code was using `RouteRegex.groups` to determine parameter properties (`repeat`/`optional`), but the regex groups didn't properly handle segment names for nested parallel route structures. The pathname replacement logic would fail when trying to match segment patterns, causing the build to abort. This is a critical issue because it blocks developers from using a common pattern: internationalization with parallel routes (e.g., `/[locale]/@modal/...`). ### How? The fix refactors the parameter validation logic to use `DynamicParamTypes` directly instead of relying on `RouteRegex`: 1. Created a new `getParamProperties()` utility in `get-segment-param.tsx` that determines `repeat` and `optional` properties based on the parameter type (catchall, optional-catchall, dynamic, etc.) 2. Updated `buildAppStaticPaths()` to use this utility instead of `RouteRegex.groups` 3. Changed pathname replacement to use the segment name from `childrenRouteParamSegments` rather than reconstructing it from the regex pattern This approach: - Eliminates the mismatch between regex-based pattern matching and actual segment structure - Properly handles parallel route segment names - Makes the code more maintainable by using the type system instead of regex internals Fixes #84509
1 parent ad9013e commit e0d612e

File tree

7 files changed

+66
-31
lines changed

7 files changed

+66
-31
lines changed

packages/next/errors.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -891,5 +891,6 @@
891891
"890": "Received an underlying cookies object that does not match either `cookies` or `mutableCookies`",
892892
"891": "Failed to read build paths file \"%s\": %s",
893893
"892": "Failed to resolve glob pattern \"%s\": %s",
894-
"893": "The \"sassOptions.functions\" option is not supported when using Turbopack. Custom Sass functions are only available with webpack. Please remove the \"functions\" property from your sassOptions in %s."
894+
"893": "The \"sassOptions.functions\" option is not supported when using Turbopack. Custom Sass functions are only available with webpack. Please remove the \"functions\" property from your sassOptions in %s.",
895+
"894": "Param %s not found in childrenRouteParamSegments %s"
895896
}

packages/next/src/build/static-paths/app.ts

Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,6 @@ import path from 'node:path'
1111
import { AfterRunner } from '../../server/after/run-with-after'
1212
import { createWorkStore } from '../../server/async-storage/work-store'
1313
import { FallbackMode } from '../../lib/fallback'
14-
import {
15-
getRouteRegex,
16-
type RouteRegex,
17-
} from '../../shared/lib/router/utils/route-regex'
1814
import type { IncrementalCache } from '../../server/lib/incremental-cache'
1915
import {
2016
normalizePathname,
@@ -27,6 +23,7 @@ import type { NextConfigComplete } from '../../server/config-shared'
2723
import type { WorkStore } from '../../server/app-render/work-async-storage.external'
2824
import type { DynamicParamTypes } from '../../shared/lib/app-router-types'
2925
import { InvariantError } from '../../shared/lib/invariant-error'
26+
import { getParamProperties } from '../../shared/lib/router/utils/get-segment-param'
3027

3128
/**
3229
* Filters out duplicate parameters from a list of parameters.
@@ -301,17 +298,17 @@ export function calculateFallbackMode(
301298
* @param page - The page to validate.
302299
* @param regex - The route regex.
303300
* @param isRoutePPREnabled - Whether the route has partial prerendering enabled.
304-
* @param childrenRouteParams - The keys of the parameters.
301+
* @param childrenRouteParamSegments - The keys of the parameters.
305302
* @param rootParamKeys - The keys of the root params.
306303
* @param routeParams - The list of parameters to validate.
307304
* @returns The list of validated parameters.
308305
*/
309306
function validateParams(
310307
page: string,
311-
regex: RouteRegex,
312308
isRoutePPREnabled: boolean,
313-
childrenRouteParams: ReadonlyArray<{
309+
childrenRouteParamSegments: ReadonlyArray<{
314310
readonly paramName: string
311+
readonly paramType: DynamicParamTypes
315312
}>,
316313
rootParamKeys: readonly string[],
317314
routeParams: readonly Params[]
@@ -342,8 +339,8 @@ function validateParams(
342339
for (const params of routeParams) {
343340
const item: Params = {}
344341

345-
for (const { paramName: key } of childrenRouteParams) {
346-
const { repeat, optional } = regex.groups[key]
342+
for (const { paramName: key, paramType } of childrenRouteParamSegments) {
343+
const { repeat, optional } = getParamProperties(paramType)
347344

348345
let paramValue = params[key]
349346

@@ -776,8 +773,6 @@ export async function buildAppStaticPaths({
776773
cacheMaxMemorySize: maxMemoryCacheSize,
777774
})
778775

779-
const regex = getRouteRegex(page)
780-
781776
const childrenRouteParamSegments: Array<{
782777
readonly name: string
783778
readonly paramName: string
@@ -927,17 +922,6 @@ export async function buildAppStaticPaths({
927922

928923
const prerenderedRoutesByPathname = new Map<string, PrerenderedRoute>()
929924

930-
// Precompile the regex patterns for the route params.
931-
const paramPatterns = new Map<string, string>()
932-
for (const { paramName } of childrenRouteParamSegments) {
933-
const { repeat, optional } = regex.groups[paramName]
934-
let pattern = `[${repeat ? '...' : ''}${paramName}]`
935-
if (optional) {
936-
pattern = `[${pattern}]`
937-
}
938-
paramPatterns.set(paramName, pattern)
939-
}
940-
941925
// Convert rootParamKeys to Set for O(1) lookup.
942926
const rootParamSet = new Set(rootParamKeys)
943927

@@ -984,7 +968,6 @@ export async function buildAppStaticPaths({
984968
childrenRouteParamSegments,
985969
validateParams(
986970
page,
987-
regex,
988971
isRoutePPREnabled,
989972
childrenRouteParamSegments,
990973
rootParamKeys,
@@ -1030,14 +1013,21 @@ export async function buildAppStaticPaths({
10301013
}
10311014
}
10321015

1033-
// Use pre-compiled pattern for replacement
1034-
const pattern = paramPatterns.get(key)!
1016+
const segment = childrenRouteParamSegments.find(
1017+
({ paramName }) => paramName === key
1018+
)
1019+
if (!segment) {
1020+
throw new InvariantError(
1021+
`Param ${key} not found in childrenRouteParamSegments ${childrenRouteParamSegments.map(({ paramName }) => paramName).join(', ')}`
1022+
)
1023+
}
1024+
10351025
pathname = pathname.replace(
1036-
pattern,
1026+
segment.name,
10371027
encodeParam(paramValue, (value) => escapePathDelimiters(value, true))
10381028
)
10391029
encodedPathname = encodedPathname.replace(
1040-
pattern,
1030+
segment.name,
10411031
encodeParam(paramValue, encodeURIComponent)
10421032
)
10431033
}

packages/next/src/shared/lib/router/utils/get-segment-param.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,29 @@ export function isCatchAll(
5353
type === 'optional-catchall'
5454
)
5555
}
56+
57+
export function getParamProperties(paramType: DynamicParamTypes): {
58+
repeat: boolean
59+
optional: boolean
60+
} {
61+
let repeat = false
62+
let optional = false
63+
64+
switch (paramType) {
65+
case 'catchall':
66+
case 'catchall-intercepted':
67+
repeat = true
68+
break
69+
case 'optional-catchall':
70+
repeat = true
71+
optional = true
72+
break
73+
case 'dynamic':
74+
case 'dynamic-intercepted':
75+
break
76+
default:
77+
paramType satisfies never
78+
}
79+
80+
return { repeat, optional }
81+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Default() {
2+
return <div>/[locale]/@slot/default.tsx</div>
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Page() {
2+
return <div>/[locale]/@slot/other/page.tsx</div>
3+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export default function Layout({
2+
children,
3+
slot,
4+
}: {
5+
children: React.ReactNode
6+
slot: React.ReactNode
7+
}) {
8+
return (
9+
<>
10+
Children: <div id="children">{children}</div>
11+
Slot: <div id="slot">{slot}</div>
12+
</>
13+
)
14+
}

test/e2e/app-dir/parallel-routes-catchall-slotted-non-catchalls/app/layout.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@ import React from 'react'
33
export default function Root({ children }: { children: React.ReactNode }) {
44
return (
55
<html>
6-
<body>
7-
Children: <div id="children">{children}</div>
8-
</body>
6+
<body>{children}</body>
97
</html>
108
)
119
}

0 commit comments

Comments
 (0)