Skip to content

Commit ab12c06

Browse files
authored
fix(router-core): parse non-nested path params correctly (#5165)
When parsing path params to interpolate the path non-nested path params ```/path/$param_``` was being parsed in the same manner as regular path params ```/path/$param```. This PR adds consideration for the trailing underscore when parsing the path for non-nested path params as well as unit tests on path interpolation and e2e tests for useParams to ensure this is parsed correctly and updates correctly when params change. This resolves #5164 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - New Features - Support trailing-underscore non-nested path params (e.g., /users/$id_ → /users/123). - UI shows an additional navigation link for the non-nested route (/params-ps/non-nested/foo2/bar2) and improved link display text. - Tests - Expanded React and Solid e2e tests to cover the new link, navigation, and param parsing for foo2/bar2. - Added unit tests validating interpolation/matching for the $param_ syntax. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent a8714cd commit ab12c06

File tree

6 files changed

+99
-12
lines changed

6 files changed

+99
-12
lines changed

e2e/react-router/basic-file-based/src/routes/params-ps/non-nested/route.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,15 @@ function RouteComponent() {
1616
to="./$foo/$bar"
1717
params={{ foo: 'foo', bar: 'bar' }}
1818
>
19-
/params-ps/non-nested/$foo/$bar
19+
/params-ps/non-nested/foo/bar
20+
</Link>
21+
<Link
22+
from={Route.fullPath}
23+
data-testid="l-to-non-nested-foo2-bar2"
24+
to="./$foo/$bar"
25+
params={{ foo: 'foo2', bar: 'bar2' }}
26+
>
27+
/params-ps/non-nested/foo2/bar2
2028
</Link>
2129
</li>
2230
</ul>

e2e/react-router/basic-file-based/tests/params.spec.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ test.describe('params operations + non-nested routes', () => {
6969
'href',
7070
'/params-ps/non-nested/foo/bar',
7171
)
72+
7273
await fooBarLink.click()
73-
await page.waitForLoadState('networkidle')
74+
await page.waitForURL('/params-ps/non-nested/foo/bar')
7475
const pagePathname = new URL(page.url()).pathname
7576
expect(pagePathname).toBe('/params-ps/non-nested/foo/bar')
7677

@@ -83,6 +84,27 @@ test.describe('params operations + non-nested routes', () => {
8384
const paramsText = await paramsValue.innerText()
8485
const paramsObj = JSON.parse(paramsText)
8586
expect(paramsObj).toEqual({ foo: 'foo', bar: 'bar' })
87+
88+
const foo2Bar2Link = page.getByTestId('l-to-non-nested-foo2-bar2')
89+
90+
await expect(foo2Bar2Link).toHaveAttribute(
91+
'href',
92+
'/params-ps/non-nested/foo2/bar2',
93+
)
94+
await foo2Bar2Link.click()
95+
await page.waitForURL('/params-ps/non-nested/foo2/bar2')
96+
const pagePathname2 = new URL(page.url()).pathname
97+
expect(pagePathname2).toBe('/params-ps/non-nested/foo2/bar2')
98+
99+
const foo2ParamsValue = page.getByTestId('foo-params-value')
100+
const foo2ParamsText = await foo2ParamsValue.innerText()
101+
const foo2ParamsObj = JSON.parse(foo2ParamsText)
102+
expect(foo2ParamsObj).toEqual({ foo: 'foo2' })
103+
104+
const params2Value = page.getByTestId('foo-bar-params-value')
105+
const params2Text = await params2Value.innerText()
106+
const params2Obj = JSON.parse(params2Text)
107+
expect(params2Obj).toEqual({ foo: 'foo2', bar: 'bar2' })
86108
})
87109
})
88110

e2e/solid-router/basic-file-based/src/routes/params-ps/non-nested/route.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,15 @@ function RouteComponent() {
1616
to="./$foo/$bar"
1717
params={{ foo: 'foo', bar: 'bar' }}
1818
>
19-
/params-ps/non-nested/$foo/$bar
19+
/params-ps/non-nested/foo/bar
20+
</Link>
21+
<Link
22+
from={Route.fullPath}
23+
data-testid="l-to-non-nested-foo2-bar2"
24+
to="./$foo/$bar"
25+
params={{ foo: 'foo2', bar: 'bar2' }}
26+
>
27+
/params-ps/non-nested/foo2/bar2
2028
</Link>
2129
</li>
2230
</ul>

e2e/solid-router/basic-file-based/tests/params.spec.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,8 @@ test.describe('params operations + non-nested routes', () => {
145145
'/params-ps/non-nested/foo/bar',
146146
)
147147
await fooBarLink.click()
148-
await page.waitForLoadState('networkidle')
148+
await page.waitForURL('/params-ps/non-nested/foo/bar')
149+
149150
const pagePathname = new URL(page.url()).pathname
150151
expect(pagePathname).toBe('/params-ps/non-nested/foo/bar')
151152

@@ -158,5 +159,26 @@ test.describe('params operations + non-nested routes', () => {
158159
const paramsText = await paramsValue.innerText()
159160
const paramsObj = JSON.parse(paramsText)
160161
expect(paramsObj).toEqual({ foo: 'foo', bar: 'bar' })
162+
163+
const foo2Bar2Link = page.getByTestId('l-to-non-nested-foo2-bar2')
164+
165+
await expect(foo2Bar2Link).toHaveAttribute(
166+
'href',
167+
'/params-ps/non-nested/foo2/bar2',
168+
)
169+
await foo2Bar2Link.click()
170+
await page.waitForURL('/params-ps/non-nested/foo2/bar2')
171+
const pagePathname2 = new URL(page.url()).pathname
172+
expect(pagePathname2).toBe('/params-ps/non-nested/foo2/bar2')
173+
174+
const foo2ParamsValue = page.getByTestId('foo-params-value')
175+
const foo2ParamsText = await foo2ParamsValue.innerText()
176+
const foo2ParamsObj = JSON.parse(foo2ParamsText)
177+
expect(foo2ParamsObj).toEqual({ foo: 'foo2' })
178+
179+
const params2Value = page.getByTestId('foo-bar-params-value')
180+
const params2Text = await params2Value.innerText()
181+
const params2Obj = JSON.parse(params2Text)
182+
expect(params2Obj).toEqual({ foo: 'foo2', bar: 'bar2' })
161183
})
162184
})

packages/router-core/src/path.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -265,8 +265,11 @@ function baseParsePathname(pathname: string): ReadonlyArray<Segment> {
265265

266266
segments.push(
267267
...split.map((part): Segment => {
268+
// strip tailing underscore for non-nested paths
269+
const partToMatch = part.slice(-1) === '_' ? part.slice(0, -1) : part
270+
268271
// Check for wildcard with curly braces: prefix{$}suffix
269-
const wildcardBracesMatch = part.match(WILDCARD_W_CURLY_BRACES_RE)
272+
const wildcardBracesMatch = partToMatch.match(WILDCARD_W_CURLY_BRACES_RE)
270273
if (wildcardBracesMatch) {
271274
const prefix = wildcardBracesMatch[1]
272275
const suffix = wildcardBracesMatch[2]
@@ -279,7 +282,7 @@ function baseParsePathname(pathname: string): ReadonlyArray<Segment> {
279282
}
280283

281284
// Check for optional parameter format: prefix{-$paramName}suffix
282-
const optionalParamBracesMatch = part.match(
285+
const optionalParamBracesMatch = partToMatch.match(
283286
OPTIONAL_PARAM_W_CURLY_BRACES_RE,
284287
)
285288
if (optionalParamBracesMatch) {
@@ -295,7 +298,7 @@ function baseParsePathname(pathname: string): ReadonlyArray<Segment> {
295298
}
296299

297300
// Check for the new parameter format: prefix{$paramName}suffix
298-
const paramBracesMatch = part.match(PARAM_W_CURLY_BRACES_RE)
301+
const paramBracesMatch = partToMatch.match(PARAM_W_CURLY_BRACES_RE)
299302
if (paramBracesMatch) {
300303
const prefix = paramBracesMatch[1]
301304
const paramName = paramBracesMatch[2]
@@ -309,8 +312,9 @@ function baseParsePathname(pathname: string): ReadonlyArray<Segment> {
309312
}
310313

311314
// Check for bare parameter format: $paramName (without curly braces)
312-
if (PARAM_RE.test(part)) {
313-
const paramName = part.substring(1)
315+
if (PARAM_RE.test(partToMatch)) {
316+
const paramName = partToMatch.substring(1)
317+
314318
return {
315319
type: SEGMENT_TYPE_PARAM,
316320
value: '$' + paramName,
@@ -320,7 +324,7 @@ function baseParsePathname(pathname: string): ReadonlyArray<Segment> {
320324
}
321325

322326
// Check for bare wildcard: $ (without curly braces)
323-
if (WILDCARD_RE.test(part)) {
327+
if (WILDCARD_RE.test(partToMatch)) {
324328
return {
325329
type: SEGMENT_TYPE_WILDCARD,
326330
value: '$',
@@ -332,8 +336,8 @@ function baseParsePathname(pathname: string): ReadonlyArray<Segment> {
332336
// Handle regular pathname segment
333337
return {
334338
type: SEGMENT_TYPE_PATHNAME,
335-
value: part.includes('%25')
336-
? part
339+
value: partToMatch.includes('%25')
340+
? partToMatch
337341
.split('%25')
338342
.map((segment) => decodeURI(segment))
339343
.join('%25')

packages/router-core/tests/path.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,12 @@ describe('interpolatePath', () => {
432432
params: { _splat: 'sean/cassiere' },
433433
result: '/users/sean/cassiere',
434434
},
435+
{
436+
name: 'should interpolate the non-nested path',
437+
path: '/users/$id_',
438+
params: { id: '123' },
439+
result: '/users/123',
440+
},
435441
])('$name', ({ path, params, decodeCharMap, result }) => {
436442
expect(
437443
interpolatePath({
@@ -654,6 +660,14 @@ describe('matchPathname', () => {
654660
},
655661
expectedMatchedParams: { id: '123' },
656662
},
663+
{
664+
name: 'should match and return the non-nested named path params',
665+
input: '/users/123',
666+
matchingOptions: {
667+
to: '/users/$id_',
668+
},
669+
expectedMatchedParams: { id: '123' },
670+
},
657671
{
658672
name: 'should match and return the the splat param',
659673
input: '/users/123',
@@ -893,6 +907,15 @@ describe('parsePathname', () => {
893907
{ type: SEGMENT_TYPE_PARAM, value: '$bar' },
894908
],
895909
},
910+
{
911+
name: 'should handle non-nested named params',
912+
to: '/foo/$bar_',
913+
expected: [
914+
{ type: SEGMENT_TYPE_PATHNAME, value: '/' },
915+
{ type: SEGMENT_TYPE_PATHNAME, value: 'foo' },
916+
{ type: SEGMENT_TYPE_PARAM, value: '$bar' },
917+
],
918+
},
896919
{
897920
name: 'should handle named params at the root',
898921
to: '/$bar',

0 commit comments

Comments
 (0)