Skip to content
8 changes: 4 additions & 4 deletions packages/react-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6510,8 +6510,8 @@ describe('encoded and unicode paths', () => {
name: 'with prefix',
path: '/foo/prefix@대{$}',
expectedPath:
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]',
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]',
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]',
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀%40]',
params: {
_splat: 'test[s\\/.\\/parameter%!🚀@]',
'*': 'test[s\\/.\\/parameter%!🚀@]',
Expand All @@ -6521,8 +6521,8 @@ describe('encoded and unicode paths', () => {
name: 'with suffix',
path: '/foo/{$}대suffix@',
expectedPath:
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@',
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@',
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]%EB%8C%80suffix@',
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀%40]대suffix@',
params: {
_splat: 'test[s\\/.\\/parameter%!🚀@]',
'*': 'test[s\\/.\\/parameter%!🚀@]',
Expand Down
8 changes: 4 additions & 4 deletions packages/react-router/tests/navigate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1300,8 +1300,8 @@ describe('encoded and unicode paths', () => {
name: 'with prefix',
path: '/foo/prefix@대{$}',
expectedPath:
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]',
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]',
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]',
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀%40]',
params: {
_splat: 'test[s\\/.\\/parameter%!🚀@]',
'*': 'test[s\\/.\\/parameter%!🚀@]',
Expand All @@ -1311,8 +1311,8 @@ describe('encoded and unicode paths', () => {
name: 'with suffix',
path: '/foo/{$}대suffix@',
expectedPath:
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@',
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@',
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]%EB%8C%80suffix@',
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀%40]대suffix@',
params: {
_splat: 'test[s\\/.\\/parameter%!🚀@]',
'*': 'test[s\\/.\\/parameter%!🚀@]',
Expand Down
14 changes: 7 additions & 7 deletions packages/react-router/tests/useNavigate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2667,8 +2667,8 @@ describe('encoded and unicode paths', () => {
name: 'with prefix',
path: '/foo/prefix@대{$}',
expectedPath:
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]',
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]',
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]',
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀%40]',
params: {
_splat: 'test[s\\/.\\/parameter%!🚀@]',
'*': 'test[s\\/.\\/parameter%!🚀@]',
Expand All @@ -2678,8 +2678,8 @@ describe('encoded and unicode paths', () => {
name: 'with suffix',
path: '/foo/{$}대suffix@',
expectedPath:
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@',
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@',
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]%EB%8C%80suffix@',
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀%40]대suffix@',
params: {
_splat: 'test[s\\/.\\/parameter%!🚀@]',
'*': 'test[s\\/.\\/parameter%!🚀@]',
Expand All @@ -2699,10 +2699,10 @@ describe('encoded and unicode paths', () => {
{
name: 'with path param',
path: `/foo/$id`,
expectedPath: '/foo/test[s%5C%2F.%5C%2Fparameter%25!%F0%9F%9A%80]',
expectedLocation: '/foo/test[s%5C%2F.%5C%2Fparameter%25!🚀]',
expectedPath: '/foo/test[s%5C%2F.%5C%2Fparameter%25!%F0%9F%9A%80%40]',
expectedLocation: '/foo/test[s%5C%2F.%5C%2Fparameter%25!🚀%40]',
params: {
id: 'test[s\\/.\\/parameter%!🚀]',
id: 'test[s\\/.\\/parameter%!🚀@]',
},
},
]
Expand Down
9 changes: 8 additions & 1 deletion packages/router-core/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,15 @@ function encodeParam(
if (typeof value !== 'string') return value

if (key === '_splat') {
// Early return if value only contains URL-safe characters (performance optimization)
if (/^[a-zA-Z0-9\-._~!/]*$/.test(value)) return value
// the splat/catch-all routes shouldn't have the '/' encoded out
return encodeURI(value)
// Use encodeURIComponent for each segment to properly encode spaces,
// plus signs, and other special characters that encodeURI leaves unencoded
return value
.split('/')
.map((segment) => encodePathParam(segment, decoder))
.join('/')
} else {
return encodePathParam(value, decoder)
}
Expand Down
4 changes: 2 additions & 2 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
createControlledPromise,
decodePath,
deepEqual,
encodeNonAscii,
encodePathLikeUrl,
findLast,
functionalUpdate,
isDangerousProtocol,
Expand Down Expand Up @@ -1962,7 +1962,7 @@ export class RouterCore<
// fullPath is already the correct href (origin-stripped)
// We need to encode non-ASCII (unicode) characters for the href
// since decodePath decoded them from the interpolated path
href = encodeNonAscii(fullPath)
href = encodePathLikeUrl(fullPath)
publicHref = href
}

Expand Down
31 changes: 23 additions & 8 deletions packages/router-core/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,18 +628,33 @@ export function decodePath(path: string, decodeIgnore?: Array<string>): string {
}

/**
* Encodes non-ASCII (unicode) characters in a path while preserving
* already percent-encoded sequences. This is used to generate proper
* href values without constructing URL objects.
* Encodes a path the same way `new URL()` would, but without the overhead of full URL parsing.
*
* Unlike encodeURI, this won't double-encode percent-encoded sequences
* like %2F or %25 because it only targets non-ASCII characters.
* This function encodes:
* - Whitespace characters (spaces → %20, tabs → %09, etc.)
* - Non-ASCII/Unicode characters (emojis, accented characters, etc.)
*
* It preserves:
* - Already percent-encoded sequences (won't double-encode %2F, %25, etc.)
* - ASCII special characters valid in URL paths (@, $, &, +, etc.)
* - Forward slashes as path separators
*
* Used to generate proper href values for SSR without constructing URL objects.
*
* @example
* encodePathLikeUrl('/path/file name.pdf') // '/path/file%20name.pdf'
* encodePathLikeUrl('/path/日本語') // '/path/%E6%97%A5%E6%9C%AC%E8%AA%9E'
* encodePathLikeUrl('/path/already%20encoded') // '/path/already%20encoded' (preserved)
*/
export function encodeNonAscii(path: string): string {
export function encodePathLikeUrl(path: string): string {
// Encode whitespace and non-ASCII characters that browsers encode in URLs

// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ASCII range check
// eslint-disable-next-line no-control-regex
if (!/[^\u0000-\u007F]/.test(path)) return path
if (!/\s|[^\u0000-\u007F]/.test(path)) return path
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ASCII range check
// eslint-disable-next-line no-control-regex
return path.replace(/[^\u0000-\u007F]/gu, encodeURIComponent)
return path.replace(/\s|[^\u0000-\u007F]/gu, encodeURIComponent)
}

/**
Expand Down
67 changes: 67 additions & 0 deletions packages/router-core/tests/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,73 @@ describe.each([{ server: true }, { server: false }])(
})
})

describe('splat params with special characters', () => {
it.each([
{
name: 'should encode spaces in splat param',
path: '/$',
params: { _splat: 'file name.pdf' },
result: '/file%20name.pdf',
},
{
name: 'should preserve parentheses in splat param (RFC 3986 unreserved)',
path: '/$',
params: { _splat: 'file(1).pdf' },
result: '/file(1).pdf',
},
{
name: 'should encode brackets in splat param',
path: '/$',
params: { _splat: 'file[1].pdf' },
result: '/file%5B1%5D.pdf',
},
{
name: 'should encode spaces in nested splat param paths',
path: '/$',
params: { _splat: 'folder/sub folder/file name.pdf' },
result: '/folder/sub%20folder/file%20name.pdf',
},
{
name: 'should encode spaces and brackets but preserve parentheses',
path: '/$',
params: { _splat: 'docs/file (copy) [2].pdf' },
result: '/docs/file%20(copy)%20%5B2%5D.pdf',
},
{
name: 'should encode hash in splat param',
path: '/$',
params: { _splat: 'page#section' },
result: '/page%23section',
},
{
name: 'should handle splat param with prefix and special characters',
path: '/files/prefix{$}',
params: { _splat: 'my file.pdf' },
result: '/files/prefixmy%20file.pdf',
},
{
name: 'should encode plus signs in splat param',
path: '/$',
params: { _splat: 'file+name.pdf' },
result: '/file%2Bname.pdf',
},
{
name: 'should encode equals signs in splat param',
path: '/$',
params: { _splat: 'query=value' },
result: '/query%3Dvalue',
},
])('$name', ({ path, params, result }) => {
expect(
interpolatePath({
path,
params,
server,
}).interpolatedPath,
).toBe(result)
})
})

describe('named params (prefix + suffix)', () => {
it.each([
{
Expand Down
38 changes: 38 additions & 0 deletions packages/router-core/tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import {
decodePath,
deepEqual,
encodePathLikeUrl,
escapeHtml,
isPlainArray,
replaceEqualDeep,
Expand Down Expand Up @@ -1039,3 +1040,40 @@ describe('escapeHtml', () => {
)
})
})

describe('encodePathLikeUrl', () => {
it('should return path unchanged if no non-ASCII characters', () => {
expect(encodePathLikeUrl('/foo/bar/baz')).toBe('/foo/bar/baz')
})

it('should encode non-ASCII characters', () => {
expect(encodePathLikeUrl('/path/caf\u00e9')).toBe('/path/caf%C3%A9')
})

it('should encode unicode characters in path segments', () => {
expect(encodePathLikeUrl('/users/\u4e2d\u6587/profile')).toBe(
'/users/%E4%B8%AD%E6%96%87/profile',
)
})

it('should encode spaces but preserve other ASCII special characters', () => {
// encodePathLikeUrl encodes whitespace and non-ASCII, but not other ASCII special chars
expect(encodePathLikeUrl('/path/file name.pdf')).toBe(
'/path/file%20name.pdf',
)
expect(encodePathLikeUrl('/path/file[1].pdf')).toBe('/path/file[1].pdf')
expect(encodePathLikeUrl('/path#section')).toBe('/path#section')
})

it('should handle mixed ASCII and non-ASCII characters', () => {
expect(encodePathLikeUrl('/path/caf\u00e9 (copy).pdf')).toBe(
'/path/caf%C3%A9%20(copy).pdf',
)
})

it('should handle emoji characters', () => {
expect(encodePathLikeUrl('/path/\u{1F600}/file')).toBe(
'/path/%F0%9F%98%80/file',
)
})
})
8 changes: 4 additions & 4 deletions packages/solid-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6506,8 +6506,8 @@ describe('encoded and unicode paths', () => {
name: 'with prefix',
path: '/foo/prefix@대{$}',
expectedPath:
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]',
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]',
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]',
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀%40]',
params: {
_splat: 'test[s\\/.\\/parameter%!🚀@]',
'*': 'test[s\\/.\\/parameter%!🚀@]',
Expand All @@ -6517,8 +6517,8 @@ describe('encoded and unicode paths', () => {
name: 'with suffix',
path: '/foo/{$}대suffix@',
expectedPath:
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@',
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@',
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]%EB%8C%80suffix@',
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀%40]대suffix@',
params: {
_splat: 'test[s\\/.\\/parameter%!🚀@]',
'*': 'test[s\\/.\\/parameter%!🚀@]',
Expand Down
8 changes: 4 additions & 4 deletions packages/solid-router/tests/navigate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1270,8 +1270,8 @@ describe('encoded and unicode paths', () => {
name: 'with prefix',
path: '/foo/prefix@대{$}',
expectedPath:
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]',
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]',
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]',
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀%40]',
params: {
_splat: 'test[s\\/.\\/parameter%!🚀@]',
'*': 'test[s\\/.\\/parameter%!🚀@]',
Expand All @@ -1281,8 +1281,8 @@ describe('encoded and unicode paths', () => {
name: 'with suffix',
path: '/foo/{$}대suffix@',
expectedPath:
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@',
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@',
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]%EB%8C%80suffix@',
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀%40]대suffix@',
params: {
_splat: 'test[s\\/.\\/parameter%!🚀@]',
'*': 'test[s\\/.\\/parameter%!🚀@]',
Expand Down
14 changes: 7 additions & 7 deletions packages/solid-router/tests/useNavigate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2651,8 +2651,8 @@ describe('encoded and unicode paths', () => {
name: 'with prefix',
path: '/foo/prefix@대{$}',
expectedPath:
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]',
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]',
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]',
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀%40]',
params: {
_splat: 'test[s\\/.\\/parameter%!🚀@]',
'*': 'test[s\\/.\\/parameter%!🚀@]',
Expand All @@ -2662,8 +2662,8 @@ describe('encoded and unicode paths', () => {
name: 'with suffix',
path: '/foo/{$}대suffix@',
expectedPath:
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@',
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@',
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]%EB%8C%80suffix@',
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀%40]대suffix@',
params: {
_splat: 'test[s\\/.\\/parameter%!🚀@]',
'*': 'test[s\\/.\\/parameter%!🚀@]',
Expand All @@ -2683,10 +2683,10 @@ describe('encoded and unicode paths', () => {
{
name: 'with path param',
path: `/foo/$id`,
expectedPath: '/foo/test[s%5C%2F.%5C%2Fparameter%25!%F0%9F%9A%80]',
expectedLocation: '/foo/test[s%5C%2F.%5C%2Fparameter%25!🚀]',
expectedPath: '/foo/test[s%5C%2F.%5C%2Fparameter%25!%F0%9F%9A%80%40]',
expectedLocation: '/foo/test[s%5C%2F.%5C%2Fparameter%25!🚀%40]',
params: {
id: 'test[s\\/.\\/parameter%!🚀]',
id: 'test[s\\/.\\/parameter%!🚀@]',
},
},
]
Expand Down
8 changes: 4 additions & 4 deletions packages/vue-router/tests/link.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6542,8 +6542,8 @@ describe('encoded and unicode paths', () => {
name: 'with prefix',
path: '/foo/prefix@대{$}',
expectedPath:
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]',
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀@]',
'/foo/prefix@%EB%8C%80test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]',
expectedLocation: '/foo/prefix@대test[s%5C/.%5C/parameter%25!🚀%40]',
params: {
_splat: 'test[s\\/.\\/parameter%!🚀@]',
'*': 'test[s\\/.\\/parameter%!🚀@]',
Expand All @@ -6553,8 +6553,8 @@ describe('encoded and unicode paths', () => {
name: 'with suffix',
path: '/foo/{$}대suffix@',
expectedPath:
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80@]%EB%8C%80suffix@',
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀@]대suffix@',
'/foo/test[s%5C/.%5C/parameter%25!%F0%9F%9A%80%40]%EB%8C%80suffix@',
expectedLocation: '/foo/test[s%5C/.%5C/parameter%25!🚀%40]대suffix@',
params: {
_splat: 'test[s\\/.\\/parameter%!🚀@]',
'*': 'test[s\\/.\\/parameter%!🚀@]',
Expand Down
Loading
Loading