From ecdd3b2eb679ee7f606ae3cf1eb4dde787ef5e19 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 21 May 2026 22:24:01 +0200 Subject: [PATCH] optimize path.ts --- packages/router-core/src/path.ts | 41 +++++----- packages/router-core/tests/path.bench.ts | 99 ++++++++++++++++++++++++ packages/router-core/tests/path.test.ts | 39 +++++++++- 3 files changed, 157 insertions(+), 22 deletions(-) create mode 100644 packages/router-core/tests/path.bench.ts diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index 577ce00ecf..f28aeee563 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -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) { @@ -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] @@ -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 @@ -255,10 +255,9 @@ export function interpolatePath({ let isMissingParams = false const usedParams: Record = 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/$`. @@ -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 @@ -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 } @@ -312,7 +311,7 @@ export function interpolatePath({ } } - if (path.endsWith('/')) joined += '/' + if (path[length - 1] === '/') joined += '/' const interpolatedPath = joined || '/' return { usedParams, interpolatedPath, isMissingParams } @@ -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 } @@ -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) { @@ -364,21 +363,21 @@ 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 @@ -386,15 +385,15 @@ export function interpolatePath({ 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 || '/' diff --git a/packages/router-core/tests/path.bench.ts b/packages/router-core/tests/path.bench.ts new file mode 100644 index 0000000000..d3ee5e1858 --- /dev/null +++ b/packages/router-core/tests/path.bench.ts @@ -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(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 + }) +}) diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 4e49412209..d7a77cebf1 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -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 }) => { @@ -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({ @@ -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)