Skip to content

Commit 4814d4e

Browse files
fix(router-core): use encodeURIComponent for splat route params (#6520)
* fix(router-core): use encodeURIComponent for splat route params Use encodeURIComponent instead of encodeURI for splat route parameters to properly encode spaces, plus signs, and other special characters. This fixes an SSR redirect loop that occurs when: 1. User navigates to a splat route with encoded characters (e.g., %20) 2. TanStack Router decodes params with decodeURIComponent 3. When rebuilding URL, encodeURI doesn't encode spaces/special chars 4. URL mismatch triggers redirect loop (307 redirects) encodeURIComponent properly encodes these characters, preventing the mismatch while still preserving forward slashes as path separators. * update tests to use correct encoding for splat params * use encodePathParam for splat/catch-all * perf(router-core): add early return for URL-safe splat values Add performance optimization that skips the split/map/join operation when splat values only contain URL-safe characters (alphanumeric, dash, period, underscore, tilde, exclamation, forward slash). This avoids unnecessary processing in the hot path for common cases. * fix(router-core): encode whitespace in encodeNonAscii Update encodeNonAscii to also encode whitespace characters (spaces, tabs, etc.) in addition to non-ASCII characters. This fixes SSR redirect loops where: 1. Request URL contains encoded spaces: /path/file%20name.pdf 2. parseLocation decodes pathname: /path/file name.pdf 3. buildLocation calls encodeNonAscii but spaces weren't encoded 4. URL mismatch triggers redirect loop Now encodeNonAscii properly encodes: - Whitespace characters (spaces become %20) - Non-ASCII/unicode characters (including emojis) Other ASCII special characters (parentheses, brackets, etc.) are preserved as they are valid in URL paths. * refactor(router-core): use regex in encodeNonAscii like original * refactor(router-core): rename encodeNonAscii to encodePathLikeUrl Rename the function to better reflect its expanded responsibility of encoding paths the same way new URL() would, including: - Whitespace characters (spaces → %20, tabs → %09) - Non-ASCII/Unicode characters (emojis, accented characters) The old name 'encodeNonAscii' was misleading since the function now encodes more than just non-ASCII characters. --------- Co-authored-by: Nico Lynzaad <nlynzaad@zylangroup.com> Co-authored-by: Nico Lynzaad <44094871+nlynzaad@users.noreply.github.com>
1 parent 080f818 commit 4814d4e

File tree

14 files changed

+183
-56
lines changed

14 files changed

+183
-56
lines changed

packages/react-router/tests/link.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6510,8 +6510,8 @@ describe('encoded and unicode paths', () => {
65106510
name: 'with prefix',
65116511
path: '/foo/prefix@대{$}',
65126512
expectedPath:
6513-
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]',
6514-
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]',
6513+
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]',
6514+
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀%40]',
65156515
params: {
65166516
_splat: 'test[s\\/.\\/parameter%!🚀@]',
65176517
'*': 'test[s\\/.\\/parameter%!🚀@]',
@@ -6521,8 +6521,8 @@ describe('encoded and unicode paths', () => {
65216521
name: 'with suffix',
65226522
path: '/foo/{$}대suffix@',
65236523
expectedPath:
6524-
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@',
6525-
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@',
6524+
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]%EB%8C%80suffix@',
6525+
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀%40]대suffix@',
65266526
params: {
65276527
_splat: 'test[s\\/.\\/parameter%!🚀@]',
65286528
'*': 'test[s\\/.\\/parameter%!🚀@]',

packages/react-router/tests/navigate.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1300,8 +1300,8 @@ describe('encoded and unicode paths', () => {
13001300
name: 'with prefix',
13011301
path: '/foo/prefix@대{$}',
13021302
expectedPath:
1303-
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]',
1304-
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]',
1303+
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]',
1304+
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀%40]',
13051305
params: {
13061306
_splat: 'test[s\\/.\\/parameter%!🚀@]',
13071307
'*': 'test[s\\/.\\/parameter%!🚀@]',
@@ -1311,8 +1311,8 @@ describe('encoded and unicode paths', () => {
13111311
name: 'with suffix',
13121312
path: '/foo/{$}대suffix@',
13131313
expectedPath:
1314-
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@',
1315-
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@',
1314+
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]%EB%8C%80suffix@',
1315+
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀%40]대suffix@',
13161316
params: {
13171317
_splat: 'test[s\\/.\\/parameter%!🚀@]',
13181318
'*': 'test[s\\/.\\/parameter%!🚀@]',

packages/react-router/tests/useNavigate.test.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2667,8 +2667,8 @@ describe('encoded and unicode paths', () => {
26672667
name: 'with prefix',
26682668
path: '/foo/prefix@대{$}',
26692669
expectedPath:
2670-
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]',
2671-
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]',
2670+
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]',
2671+
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀%40]',
26722672
params: {
26732673
_splat: 'test[s\\/.\\/parameter%!🚀@]',
26742674
'*': 'test[s\\/.\\/parameter%!🚀@]',
@@ -2678,8 +2678,8 @@ describe('encoded and unicode paths', () => {
26782678
name: 'with suffix',
26792679
path: '/foo/{$}대suffix@',
26802680
expectedPath:
2681-
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@',
2682-
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@',
2681+
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]%EB%8C%80suffix@',
2682+
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀%40]대suffix@',
26832683
params: {
26842684
_splat: 'test[s\\/.\\/parameter%!🚀@]',
26852685
'*': 'test[s\\/.\\/parameter%!🚀@]',
@@ -2699,10 +2699,10 @@ describe('encoded and unicode paths', () => {
26992699
{
27002700
name: 'with path param',
27012701
path: `/foo/$id`,
2702-
expectedPath: '/foo/test[s%5C%2F.%5C%2Fparameter%25!%F0%9F%9A%80]',
2703-
expectedLocation: '/foo/test[s%5C%2F.%5C%2Fparameter%25!🚀]',
2702+
expectedPath: '/foo/test[s%5C%2F.%5C%2Fparameter%25!%F0%9F%9A%80%40]',
2703+
expectedLocation: '/foo/test[s%5C%2F.%5C%2Fparameter%25!🚀%40]',
27042704
params: {
2705-
id: 'test[s\\/.\\/parameter%!🚀]',
2705+
id: 'test[s\\/.\\/parameter%!🚀@]',
27062706
},
27072707
},
27082708
]

packages/router-core/src/path.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,8 +247,15 @@ function encodeParam(
247247
if (typeof value !== 'string') return value
248248

249249
if (key === '_splat') {
250+
// Early return if value only contains URL-safe characters (performance optimization)
251+
if (/^[a-zA-Z0-9\-._~!/]*$/.test(value)) return value
250252
// the splat/catch-all routes shouldn't have the '/' encoded out
251-
return encodeURI(value)
253+
// Use encodeURIComponent for each segment to properly encode spaces,
254+
// plus signs, and other special characters that encodeURI leaves unencoded
255+
return value
256+
.split('/')
257+
.map((segment) => encodePathParam(segment, decoder))
258+
.join('/')
252259
} else {
253260
return encodePathParam(value, decoder)
254261
}

packages/router-core/src/router.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
createControlledPromise,
77
decodePath,
88
deepEqual,
9-
encodeNonAscii,
9+
encodePathLikeUrl,
1010
findLast,
1111
functionalUpdate,
1212
isDangerousProtocol,
@@ -1962,7 +1962,7 @@ export class RouterCore<
19621962
// fullPath is already the correct href (origin-stripped)
19631963
// We need to encode non-ASCII (unicode) characters for the href
19641964
// since decodePath decoded them from the interpolated path
1965-
href = encodeNonAscii(fullPath)
1965+
href = encodePathLikeUrl(fullPath)
19661966
publicHref = href
19671967
}
19681968

packages/router-core/src/utils.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -628,18 +628,33 @@ export function decodePath(path: string, decodeIgnore?: Array<string>): string {
628628
}
629629

630630
/**
631-
* Encodes non-ASCII (unicode) characters in a path while preserving
632-
* already percent-encoded sequences. This is used to generate proper
633-
* href values without constructing URL objects.
631+
* Encodes a path the same way `new URL()` would, but without the overhead of full URL parsing.
634632
*
635-
* Unlike encodeURI, this won't double-encode percent-encoded sequences
636-
* like %2F or %25 because it only targets non-ASCII characters.
633+
* This function encodes:
634+
* - Whitespace characters (spaces → %20, tabs → %09, etc.)
635+
* - Non-ASCII/Unicode characters (emojis, accented characters, etc.)
636+
*
637+
* It preserves:
638+
* - Already percent-encoded sequences (won't double-encode %2F, %25, etc.)
639+
* - ASCII special characters valid in URL paths (@, $, &, +, etc.)
640+
* - Forward slashes as path separators
641+
*
642+
* Used to generate proper href values for SSR without constructing URL objects.
643+
*
644+
* @example
645+
* encodePathLikeUrl('/path/file name.pdf') // '/path/file%20name.pdf'
646+
* encodePathLikeUrl('/path/日本語') // '/path/%E6%97%A5%E6%9C%AC%E8%AA%9E'
647+
* encodePathLikeUrl('/path/already%20encoded') // '/path/already%20encoded' (preserved)
637648
*/
638-
export function encodeNonAscii(path: string): string {
649+
export function encodePathLikeUrl(path: string): string {
650+
// Encode whitespace and non-ASCII characters that browsers encode in URLs
651+
652+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ASCII range check
639653
// eslint-disable-next-line no-control-regex
640-
if (!/[^\u0000-\u007F]/.test(path)) return path
654+
if (!/\s|[^\u0000-\u007F]/.test(path)) return path
655+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ASCII range check
641656
// eslint-disable-next-line no-control-regex
642-
return path.replace(/[^\u0000-\u007F]/gu, encodeURIComponent)
657+
return path.replace(/\s|[^\u0000-\u007F]/gu, encodeURIComponent)
643658
}
644659

645660
/**

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,73 @@ describe.each([{ server: true }, { server: false }])(
440440
})
441441
})
442442

443+
describe('splat params with special characters', () => {
444+
it.each([
445+
{
446+
name: 'should encode spaces in splat param',
447+
path: '/$',
448+
params: { _splat: 'file name.pdf' },
449+
result: '/file%20name.pdf',
450+
},
451+
{
452+
name: 'should preserve parentheses in splat param (RFC 3986 unreserved)',
453+
path: '/$',
454+
params: { _splat: 'file(1).pdf' },
455+
result: '/file(1).pdf',
456+
},
457+
{
458+
name: 'should encode brackets in splat param',
459+
path: '/$',
460+
params: { _splat: 'file[1].pdf' },
461+
result: '/file%5B1%5D.pdf',
462+
},
463+
{
464+
name: 'should encode spaces in nested splat param paths',
465+
path: '/$',
466+
params: { _splat: 'folder/sub folder/file name.pdf' },
467+
result: '/folder/sub%20folder/file%20name.pdf',
468+
},
469+
{
470+
name: 'should encode spaces and brackets but preserve parentheses',
471+
path: '/$',
472+
params: { _splat: 'docs/file (copy) [2].pdf' },
473+
result: '/docs/file%20(copy)%20%5B2%5D.pdf',
474+
},
475+
{
476+
name: 'should encode hash in splat param',
477+
path: '/$',
478+
params: { _splat: 'page#section' },
479+
result: '/page%23section',
480+
},
481+
{
482+
name: 'should handle splat param with prefix and special characters',
483+
path: '/files/prefix{$}',
484+
params: { _splat: 'my file.pdf' },
485+
result: '/files/prefixmy%20file.pdf',
486+
},
487+
{
488+
name: 'should encode plus signs in splat param',
489+
path: '/$',
490+
params: { _splat: 'file+name.pdf' },
491+
result: '/file%2Bname.pdf',
492+
},
493+
{
494+
name: 'should encode equals signs in splat param',
495+
path: '/$',
496+
params: { _splat: 'query=value' },
497+
result: '/query%3Dvalue',
498+
},
499+
])('$name', ({ path, params, result }) => {
500+
expect(
501+
interpolatePath({
502+
path,
503+
params,
504+
server,
505+
}).interpolatedPath,
506+
).toBe(result)
507+
})
508+
})
509+
443510
describe('named params (prefix + suffix)', () => {
444511
it.each([
445512
{

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

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
22
import {
33
decodePath,
44
deepEqual,
5+
encodePathLikeUrl,
56
escapeHtml,
67
isPlainArray,
78
replaceEqualDeep,
@@ -1039,3 +1040,40 @@ describe('escapeHtml', () => {
10391040
)
10401041
})
10411042
})
1043+
1044+
describe('encodePathLikeUrl', () => {
1045+
it('should return path unchanged if no non-ASCII characters', () => {
1046+
expect(encodePathLikeUrl('/foo/bar/baz')).toBe('/foo/bar/baz')
1047+
})
1048+
1049+
it('should encode non-ASCII characters', () => {
1050+
expect(encodePathLikeUrl('/path/caf\u00e9')).toBe('/path/caf%C3%A9')
1051+
})
1052+
1053+
it('should encode unicode characters in path segments', () => {
1054+
expect(encodePathLikeUrl('/users/\u4e2d\u6587/profile')).toBe(
1055+
'/users/%E4%B8%AD%E6%96%87/profile',
1056+
)
1057+
})
1058+
1059+
it('should encode spaces but preserve other ASCII special characters', () => {
1060+
// encodePathLikeUrl encodes whitespace and non-ASCII, but not other ASCII special chars
1061+
expect(encodePathLikeUrl('/path/file name.pdf')).toBe(
1062+
'/path/file%20name.pdf',
1063+
)
1064+
expect(encodePathLikeUrl('/path/file[1].pdf')).toBe('/path/file[1].pdf')
1065+
expect(encodePathLikeUrl('/path#section')).toBe('/path#section')
1066+
})
1067+
1068+
it('should handle mixed ASCII and non-ASCII characters', () => {
1069+
expect(encodePathLikeUrl('/path/caf\u00e9 (copy).pdf')).toBe(
1070+
'/path/caf%C3%A9%20(copy).pdf',
1071+
)
1072+
})
1073+
1074+
it('should handle emoji characters', () => {
1075+
expect(encodePathLikeUrl('/path/\u{1F600}/file')).toBe(
1076+
'/path/%F0%9F%98%80/file',
1077+
)
1078+
})
1079+
})

packages/solid-router/tests/link.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6506,8 +6506,8 @@ describe('encoded and unicode paths', () => {
65066506
name: 'with prefix',
65076507
path: '/foo/prefix@대{$}',
65086508
expectedPath:
6509-
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]',
6510-
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]',
6509+
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]',
6510+
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀%40]',
65116511
params: {
65126512
_splat: 'test[s\\/.\\/parameter%!🚀@]',
65136513
'*': 'test[s\\/.\\/parameter%!🚀@]',
@@ -6517,8 +6517,8 @@ describe('encoded and unicode paths', () => {
65176517
name: 'with suffix',
65186518
path: '/foo/{$}대suffix@',
65196519
expectedPath:
6520-
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@',
6521-
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@',
6520+
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]%EB%8C%80suffix@',
6521+
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀%40]대suffix@',
65226522
params: {
65236523
_splat: 'test[s\\/.\\/parameter%!🚀@]',
65246524
'*': 'test[s\\/.\\/parameter%!🚀@]',

packages/solid-router/tests/navigate.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1270,8 +1270,8 @@ describe('encoded and unicode paths', () => {
12701270
name: 'with prefix',
12711271
path: '/foo/prefix@대{$}',
12721272
expectedPath:
1273-
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]',
1274-
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]',
1273+
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]',
1274+
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀%40]',
12751275
params: {
12761276
_splat: 'test[s\\/.\\/parameter%!🚀@]',
12771277
'*': 'test[s\\/.\\/parameter%!🚀@]',
@@ -1281,8 +1281,8 @@ describe('encoded and unicode paths', () => {
12811281
name: 'with suffix',
12821282
path: '/foo/{$}대suffix@',
12831283
expectedPath:
1284-
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@',
1285-
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@',
1284+
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]%EB%8C%80suffix@',
1285+
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀%40]대suffix@',
12861286
params: {
12871287
_splat: 'test[s\\/.\\/parameter%!🚀@]',
12881288
'*': 'test[s\\/.\\/parameter%!🚀@]',

0 commit comments

Comments
 (0)