Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 20 additions & 21 deletions packages/router-core/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ export function resolvePath({
trailingSlash = 'never',
cache,
}: ResolvePathOptions) {
const isAbsolute = to.startsWith('/')
const isBase = !isAbsolute && to === '.'
const isAbsolute = to[0] === '/'
const isBase = to === '.'

let key
if (cache) {
Expand All @@ -137,7 +137,7 @@ export function resolvePath({
const toSegments = to.split('/')
for (let index = 0, length = toSegments.length; index < length; index++) {
const value = toSegments[index]!
if (value === '') {
if (!value) {
if (!index) {
// Leading slash
baseSegments = [value]
Expand Down Expand Up @@ -222,7 +222,7 @@ function encodeParam(

if (key === '_splat') {
// Early return if value only contains URL-safe characters (performance optimization)
if (/^[a-zA-Z0-9\-._~!/]*$/.test(value)) return value
if (/^[-\w.~!/]*$/.test(value)) return value
// the splat/catch-all routes shouldn't have the '/' encoded out
// Use encodeURIComponent for each segment to properly encode spaces,
// plus signs, and other special characters that encodeURI leaves unencoded
Expand Down Expand Up @@ -255,10 +255,9 @@ export function interpolatePath({
let isMissingParams = false
const usedParams: Record<string, unknown> = Object.create(null)

if (!path || path === '/')
return { interpolatedPath: '/', usedParams, isMissingParams }
if (!path.includes('$'))
return { interpolatedPath: path, usedParams, isMissingParams }
if (!path || !path.includes('$')) {
return { interpolatedPath: path || '/', usedParams, isMissingParams }
}

if (isServer ?? rest.server) {
// Fast path for common templates like `/posts/$id` or `/files/$`.
Expand All @@ -279,7 +278,7 @@ export function interpolatePath({
if (end === -1) end = length
cursor = end

const part = path.substring(start, end)
const part = path.slice(start, end)
if (!part) continue

// `$id` or `$` (splat). '$' code is 36
Expand All @@ -298,7 +297,7 @@ export function interpolatePath({
const value = encodeParam('_splat', params, decoder)
joined += '/' + value
} else {
const key = part.substring(1)
const key = part.slice(1)
if (!isMissingParams && !(key in params)) {
isMissingParams = true
}
Expand All @@ -312,7 +311,7 @@ export function interpolatePath({
}
}

if (path.endsWith('/')) joined += '/'
if (path[length - 1] === '/') joined += '/'

const interpolatedPath = joined || '/'
return { usedParams, interpolatedPath, isMissingParams }
Expand All @@ -334,7 +333,7 @@ export function interpolatePath({
const kind = segment[0]

if (kind === SEGMENT_TYPE_PATHNAME) {
joined += '/' + path.substring(start, end)
joined += '/' + path.slice(start, end)
continue
}

Expand All @@ -344,8 +343,8 @@ export function interpolatePath({
// TODO: Deprecate *
usedParams['*'] = splat

const prefix = path.substring(start, segment[1])
const suffix = path.substring(segment[4], end)
const prefix = path.slice(start, segment[1])
const suffix = path.slice(segment[4], end)

// Check if _splat parameter is missing. _splat could be missing if undefined or an empty string or some other falsy value.
if (!splat) {
Expand All @@ -364,37 +363,37 @@ export function interpolatePath({
}

if (kind === SEGMENT_TYPE_PARAM) {
const key = path.substring(segment[2], segment[3])
const key = path.slice(segment[2], segment[3])
if (!isMissingParams && !(key in params)) {
isMissingParams = true
}
usedParams[key] = params[key]

const prefix = path.substring(start, segment[1])
const suffix = path.substring(segment[4], end)
const prefix = path.slice(start, segment[1])
const suffix = path.slice(segment[4], end)
const value = encodeParam(key, params, decoder) ?? 'undefined'
joined += '/' + prefix + value + suffix
continue
}

if (kind === SEGMENT_TYPE_OPTIONAL_PARAM) {
const key = path.substring(segment[2], segment[3])
const key = path.slice(segment[2], segment[3])
const valueRaw = params[key]

// Check if optional parameter is missing or undefined
if (valueRaw == null) continue

usedParams[key] = valueRaw

const prefix = path.substring(start, segment[1])
const suffix = path.substring(segment[4], end)
const prefix = path.slice(start, segment[1])
const suffix = path.slice(segment[4], end)
const value = encodeParam(key, params, decoder) ?? ''
joined += '/' + prefix + value + suffix
continue
}
}

if (path.endsWith('/')) joined += '/'
if (path[length - 1] === '/') joined += '/'

const interpolatedPath = joined || '/'

Expand Down
99 changes: 99 additions & 0 deletions packages/router-core/tests/path.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { bench, describe, expect } from 'vitest'
import { createLRUCache } from '../src/lru-cache'
import { compileDecodeCharMap, interpolatePath, resolvePath } from '../src/path'

const decoder = compileDecodeCharMap(['@', '+'])
let sink = ''

const resolveCases = [
{ base: '/', to: '/posts/$', result: '/posts/$' },
{ base: '/posts/123', to: '.', result: '/posts/123' },
{ base: '/posts/123/', to: '../456', result: '/posts/456' },
{ base: '/a/b/c', to: '../../d', result: '/a/d' },
{ base: '/a/b/c', to: './d//e/', result: '/a/b/c/d/e' },
] as const

const interpolateCases = [
{
path: '/static/settings',
params: {},
result: '/static/settings',
server: false,
},
{
path: '/posts/$postId',
params: { postId: '123' },
result: '/posts/123',
server: false,
},
{
path: '/files/$',
params: { _splat: 'docs/readme.md' },
result: '/files/docs/readme.md',
server: false,
},
{
path: '/files/$',
params: { _splat: 'docs/a b/file+name.md' },
result: '/files/docs/a%20b/file%2Bname.md',
server: false,
},
{
path: '/files/prefix{$}-suffix',
params: { _splat: 'a@b/c+d' },
decoder,
result: '/files/prefixa@b/c+d-suffix',
server: false,
},
{
path: '/posts/{-$category}/{-$slug}/',
params: { category: 'router' },
result: '/posts/router/',
server: false,
},
] as const

for (const testCase of resolveCases) {
expect(resolvePath(testCase)).toBe(testCase.result)
}

for (const testCase of interpolateCases) {
expect(interpolatePath(testCase).interpolatedPath).toBe(testCase.result)
}

describe('resolvePath', () => {
bench('mixed uncached paths', () => {
for (const testCase of resolveCases) {
sink = resolvePath(testCase)
}
})

bench('mixed cached paths', () => {
const cache = createLRUCache<string, string>(100)
for (const testCase of resolveCases) {
sink = resolvePath({ ...testCase, cache })
sink = resolvePath({ ...testCase, cache })
}
})
})

describe('interpolatePath', () => {
bench('mixed client parser paths', () => {
for (const testCase of interpolateCases) {
sink = interpolatePath(testCase).interpolatedPath
}
})

bench('server fast path params and splats', () => {
sink = interpolatePath({
path: '/posts/$postId',
params: { postId: '123' },
server: true,
}).interpolatedPath
sink = interpolatePath({
path: '/files/$',
params: { _splat: 'docs/readme.md' },
server: true,
}).interpolatedPath
})
})
39 changes: 38 additions & 1 deletion packages/router-core/tests/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,23 @@ describe('resolvePath', () => {
})
})

describe('empty and base destinations', () => {
it.each([
{ base: '/', to: '', trailingSlash: 'never', result: '/' },
{ base: '/a/b', to: '', trailingSlash: 'never', result: '/' },
{ base: '/a/b/', to: '', trailingSlash: 'never', result: '/' },
{ base: '/a/b', to: '', trailingSlash: 'always', result: '/' },
{ base: '/a/b', to: '.', trailingSlash: 'never', result: '/a/b' },
{ base: '/a/b', to: '.', trailingSlash: 'always', result: '/a/b/' },
{ base: '/a/b/', to: '.', trailingSlash: 'preserve', result: '/a/b/' },
] as const)(
'$base to $to with $trailingSlash',
({ base, to, trailingSlash, result }) => {
expect(resolvePath({ base, to, trailingSlash })).toBe(result)
},
)
})

describe.each([{ base: '/' }, { base: '/nested' }])(
'param routes w/ base=$base',
({ base }) => {
Expand Down Expand Up @@ -373,6 +390,12 @@ describe.each([{ server: true }, { server: false }])(
params: { _splat: 'sean/cassiere' },
result: '/users/sean/cassiere',
},
{
name: 'should preserve slash for a named _splat param',
path: '/users/$_splat',
params: { _splat: 'sean/cassiere' },
result: '/users/sean/cassiere',
},
])('$name', ({ path, params, decoder, result }) => {
expect(
interpolatePath({
Expand Down Expand Up @@ -520,11 +543,25 @@ describe.each([{ server: true }, { server: false }])(
params: { _splat: 'query=value' },
result: '/query%3Dvalue',
},
])('$name', ({ path, params, result }) => {
{
name: 'should preserve URL-safe splat characters',
path: '/$',
params: { _splat: 'AZaz09-_.~!/path' },
result: '/AZaz09-_.~!/path',
},
{
name: 'should decode allowed characters in splat segments',
path: '/$',
params: { _splat: 'a@b/c+d' },
result: '/a@b/c+d',
decoder: compileDecodeCharMap(['@', '+']),
},
])('$name', ({ path, params, result, decoder }) => {
expect(
interpolatePath({
path,
params,
decoder,
server,
}).interpolatedPath,
).toBe(result)
Expand Down
Loading