Skip to content

Commit 6ec8065

Browse files
authored
fix: strip _NEXTSEP_ from interpolated pathnames (#84430)
## What Fixes an issue where internal route normalization markers (`_NEXTSEP_`) were leaking into compiled URLs for interception routes with adjacent dynamic parameters. ## Why When Next.js normalizes route patterns like `/photos/(.):author/:id` for path-to-regexp validation, it inserts `_NEXTSEP_` as a literal separator between adjacent parameters. This internal marker was appearing in the final compiled URLs (e.g., `/photos/(.)_NEXTSEP_next/123` instead of `/photos/(.)next/123`), exposing implementation details to users. ## How - Modified `safeCompile` to wrap the path-to-regexp compiler when normalization is applied - The wrapper calls `stripNormalizedSeparators` to remove the internal marker from compiled output - Added targeted regex pattern that only strips separators after interception route markers (`)_NEXTSEP_` → `)`) - Preserved separators in user content that legitimately contains this string NAR-431
1 parent 30c64ba commit 6ec8065

File tree

3 files changed

+228
-3
lines changed

3 files changed

+228
-3
lines changed

packages/next/src/lib/route-pattern-normalizer.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import type { Token } from 'next/dist/compiled/path-to-regexp'
1414
* This unique marker is inserted between adjacent parameters and stripped out
1515
* during parameter extraction to avoid conflicts with real URL content.
1616
*/
17-
const PARAM_SEPARATOR = '_NEXTSEP_'
17+
export const PARAM_SEPARATOR = '_NEXTSEP_'
1818

1919
/**
2020
* Detects if a route pattern needs normalization for path-to-regexp compatibility.
@@ -97,6 +97,24 @@ export function normalizeTokensForRegexp(tokens: Token[]): Token[] {
9797
})
9898
}
9999

100+
/**
101+
* Strips normalization separators from compiled pathname.
102+
* This removes separators that were inserted by normalizeAdjacentParameters
103+
* to satisfy path-to-regexp validation.
104+
*
105+
* Only removes separators in the specific contexts where they were inserted:
106+
* - After interception route markers: (.)_NEXTSEP_ -> (.)
107+
*
108+
* This targeted approach ensures we don't accidentally remove the separator
109+
* from legitimate user content.
110+
*/
111+
export function stripNormalizedSeparators(pathname: string): string {
112+
// Remove separator after interception route markers
113+
// Pattern: (.)_NEXTSEP_ -> (.), (..)_NEXTSEP_ -> (..), etc.
114+
// The separator appears after the closing paren of interception markers
115+
return pathname.replace(new RegExp(`\\)${PARAM_SEPARATOR}`, 'g'), ')')
116+
}
117+
100118
/**
101119
* Strips normalization separators from extracted route parameters.
102120
* Used by both server and client code to clean up parameters after route matching.
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { safeCompile } from './route-match-utils'
2+
import {
3+
PARAM_SEPARATOR,
4+
stripNormalizedSeparators,
5+
} from '../../../../lib/route-pattern-normalizer'
6+
7+
describe('safeCompile', () => {
8+
describe('interception route patterns', () => {
9+
it('should strip _NEXTSEP_ from compiled output for (.) interception marker', () => {
10+
// Pattern with interception marker followed by parameter
11+
const pattern = '/photos/(.):author/:id'
12+
const compile = safeCompile(pattern, { validate: false })
13+
14+
// The interception marker (.) is treated as an unnamed parameter (index 0)
15+
const result = compile({ '0': '(.)', author: 'next', id: '123' })
16+
17+
// Should NOT contain the internal separator
18+
expect(result).toBe('/photos/(.)next/123')
19+
})
20+
21+
it('should strip _NEXTSEP_ from compiled output for (..) interception marker', () => {
22+
const pattern = '/photos/(..):category/:id'
23+
const compile = safeCompile(pattern, { validate: false })
24+
25+
const result = compile({ '0': '(..)', category: 'blog', id: '456' })
26+
27+
expect(result).toBe('/photos/(..)blog/456')
28+
})
29+
30+
it('should strip _NEXTSEP_ from compiled output for (...) interception marker', () => {
31+
const pattern = '/photos/(...):path'
32+
const compile = safeCompile(pattern, { validate: false })
33+
34+
const result = compile({ '0': '(...)', path: 'deep/nested/route' })
35+
36+
expect(result).toBe('/photos/(...)deep/nested/route')
37+
})
38+
39+
it('should strip _NEXTSEP_ from compiled output for (..)(..) interception marker', () => {
40+
const pattern = '/photos/(.)(..)/:id'
41+
const compile = safeCompile(pattern, { validate: false })
42+
43+
// (..)(..) is treated as two unnamed parameters
44+
const result = compile({ '0': '(..)', '1': '(..)', id: '789' })
45+
46+
expect(result).toBe('/photos/(..)(..)/789')
47+
})
48+
49+
it('should handle multiple interception markers in one pattern', () => {
50+
const pattern = '/(.):author/photos/(.):id'
51+
const compile = safeCompile(pattern, { validate: false })
52+
53+
// Multiple markers are numbered sequentially
54+
const result = compile({
55+
'0': '(.)',
56+
author: 'john',
57+
'1': '(.)',
58+
id: '999',
59+
})
60+
61+
expect(result).toBe('/(.)john/photos/(.)999')
62+
})
63+
64+
it('should work with the actual failing case from interception routes', () => {
65+
// This is the exact pattern that was failing
66+
const pattern =
67+
'/intercepting-routes-dynamic/photos/(.):nxtPauthor/:nxtPid'
68+
const compile = safeCompile(pattern, { validate: false })
69+
70+
const result = compile({
71+
'0': '(.)',
72+
nxtPauthor: 'next',
73+
nxtPid: '123',
74+
})
75+
76+
expect(result).toBe('/intercepting-routes-dynamic/photos/(.)next/123')
77+
})
78+
})
79+
80+
describe('patterns without normalization needs', () => {
81+
it('should work normally for patterns without adjacent parameters', () => {
82+
const pattern = '/photos/:author/:id'
83+
const compile = safeCompile(pattern, { validate: false })
84+
85+
const result = compile({ author: 'jane', id: '456' })
86+
87+
expect(result).toBe('/photos/jane/456')
88+
})
89+
90+
it('should work with optional parameters', () => {
91+
const pattern = '/photos/:author?/:id'
92+
const compile = safeCompile(pattern, { validate: false })
93+
94+
const result = compile({ id: '789' })
95+
96+
expect(result).toBe('/photos/789')
97+
})
98+
99+
it('should work with catchall parameters', () => {
100+
const pattern = '/files/:path*'
101+
const compile = safeCompile(pattern, { validate: false })
102+
103+
const result = compile({ path: ['folder', 'subfolder', 'file.txt'] })
104+
105+
expect(result).toBe('/files/folder/subfolder/file.txt')
106+
})
107+
})
108+
109+
describe('edge cases', () => {
110+
it('should handle patterns with path separators between parameters', () => {
111+
// Normal case - parameters separated by path segments
112+
const pattern = '/:param1/separator/:param2'
113+
const compile = safeCompile(pattern, { validate: false })
114+
115+
const result = compile({ param1: 'value1', param2: 'value2' })
116+
117+
expect(result).toBe('/value1/separator/value2')
118+
})
119+
120+
it('should not strip _NEXTSEP_ from user content outside interception markers', () => {
121+
// If user content happens to contain _NEXTSEP_, it should be preserved
122+
// Only separators after interception markers should be stripped
123+
const pattern = '/:folder/:file'
124+
const compile = safeCompile(pattern, { validate: false })
125+
126+
// User has a file or folder named something_NEXTSEP_something
127+
const result = compile({
128+
folder: 'my_NEXTSEP_folder',
129+
file: 'my_NEXTSEP_file.txt',
130+
})
131+
132+
// The _NEXTSEP_ in user content should be preserved
133+
expect(result).toBe('/my_NEXTSEP_folder/my_NEXTSEP_file.txt')
134+
})
135+
})
136+
})
137+
138+
describe('stripNormalizedSeparators', () => {
139+
it('should strip _NEXTSEP_ after single dot interception marker', () => {
140+
const input = `/photos/(.)${PARAM_SEPARATOR}next/123`
141+
const result = stripNormalizedSeparators(input)
142+
expect(result).toBe('/photos/(.)next/123')
143+
})
144+
145+
it('should strip _NEXTSEP_ after double dot interception marker', () => {
146+
const input = `/photos/(..)${PARAM_SEPARATOR}blog/456`
147+
const result = stripNormalizedSeparators(input)
148+
expect(result).toBe('/photos/(..)blog/456')
149+
})
150+
151+
it('should strip _NEXTSEP_ after triple dot interception marker', () => {
152+
const input = `/photos/(...)${PARAM_SEPARATOR}deep/nested/route`
153+
const result = stripNormalizedSeparators(input)
154+
expect(result).toBe('/photos/(...)deep/nested/route')
155+
})
156+
157+
it('should strip _NEXTSEP_ for adjacent interception markers with parameters', () => {
158+
// When there are two separate interception paths, each with parameters
159+
// Pattern: /(.)_NEXTSEP_:param1/(..)_NEXTSEP_:param2
160+
// After compilation: /(.)_NEXTSEP_value1/(..)_NEXTSEP_value2
161+
const input = `/(.)${PARAM_SEPARATOR}first/(..)${PARAM_SEPARATOR}second`
162+
const result = stripNormalizedSeparators(input)
163+
expect(result).toBe('/(.)first/(..)second')
164+
})
165+
166+
it('should handle multiple interception markers in one path', () => {
167+
const input = `/(.)${PARAM_SEPARATOR}john/photos/(.)${PARAM_SEPARATOR}999`
168+
const result = stripNormalizedSeparators(input)
169+
expect(result).toBe('/(.)john/photos/(.)999')
170+
})
171+
172+
it('should NOT strip _NEXTSEP_ from user content', () => {
173+
// If the separator appears outside the interception marker context,
174+
// it should be preserved as it's part of user content
175+
const input = `/folder/my${PARAM_SEPARATOR}file/data${PARAM_SEPARATOR}value`
176+
const result = stripNormalizedSeparators(input)
177+
expect(result).toBe(
178+
`/folder/my${PARAM_SEPARATOR}file/data${PARAM_SEPARATOR}value`
179+
)
180+
})
181+
182+
it('should only strip after closing paren, not before', () => {
183+
const input = `/path${PARAM_SEPARATOR}(.)${PARAM_SEPARATOR}value`
184+
const result = stripNormalizedSeparators(input)
185+
// Should only strip the one after ), not the one before
186+
expect(result).toBe(`/path${PARAM_SEPARATOR}(.)value`)
187+
})
188+
})

packages/next/src/shared/lib/router/utils/route-match-utils.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
hasAdjacentParameterIssues,
1919
normalizeAdjacentParameters,
2020
stripParameterSeparators,
21+
stripNormalizedSeparators,
2122
} from '../../../../lib/route-pattern-normalizer'
2223

2324
/**
@@ -59,6 +60,8 @@ export function safePathToRegexp(
5960
/**
6061
* Client-safe wrapper around compile that handles path-to-regexp 6.3.0+ validation errors.
6162
* No server-side error reporting to avoid bundling issues.
63+
* When normalization is applied, the returned compiler function automatically strips
64+
* the internal separator from the output URL.
6265
*/
6366
export function safeCompile(
6467
route: string,
@@ -71,13 +74,29 @@ export function safeCompile(
7174
: route
7275

7376
try {
74-
return compile(routeToUse, options)
77+
const compiler = compile(routeToUse, options)
78+
79+
// If we normalized the route, wrap the compiler to strip separators from output
80+
// The normalization inserts _NEXTSEP_ as a literal string in the pattern to satisfy
81+
// path-to-regexp validation, but we don't want it in the final compiled URL
82+
if (needsNormalization) {
83+
return (params: any) => {
84+
return stripNormalizedSeparators(compiler(params))
85+
}
86+
}
87+
88+
return compiler
7589
} catch (error) {
7690
// Only try normalization if we haven't already normalized
7791
if (!needsNormalization) {
7892
try {
7993
const normalizedRoute = normalizeAdjacentParameters(route)
80-
return compile(normalizedRoute, options)
94+
const compiler = compile(normalizedRoute, options)
95+
96+
// Wrap the compiler to strip separators from output
97+
return (params: any) => {
98+
return stripNormalizedSeparators(compiler(params))
99+
}
81100
} catch (retryError) {
82101
// If that doesn't work, fall back to original error
83102
throw error

0 commit comments

Comments
 (0)