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
7 changes: 6 additions & 1 deletion packages/router-core/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,12 @@ function encodeParam(

if (key === '_splat') {
// 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
Comment thread
nlynzaad marked this conversation as resolved.
.split('/')
.map((segment) => encodePathParam(segment, decoder))
.join('/')
Comment thread
thejasonxie marked this conversation as resolved.
} else {
return encodePathParam(value, decoder)
}
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
36 changes: 36 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,
encodeNonAscii,
escapeHtml,
isPlainArray,
replaceEqualDeep,
Expand Down Expand Up @@ -1039,3 +1040,38 @@ describe('escapeHtml', () => {
)
})
})

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

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

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

it('should preserve ASCII special characters unchanged', () => {
// encodeNonAscii only encodes non-ASCII, not ASCII special chars
expect(encodeNonAscii('/path/file name.pdf')).toBe('/path/file name.pdf')
expect(encodeNonAscii('/path/file[1].pdf')).toBe('/path/file[1].pdf')
expect(encodeNonAscii('/path#section')).toBe('/path#section')
})

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

it('should handle emoji characters', () => {
expect(encodeNonAscii('/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
8 changes: 4 additions & 4 deletions packages/vue-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/vue-router/tests/useNavigate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2652,8 +2652,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 @@ -2663,8 +2663,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 @@ -2684,10 +2684,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
Loading