From 7a25c7c9d8d92b8adeecefd4246ca6f10d6aebbc Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 8 Aug 2026 14:22:31 +0200 Subject: [PATCH] refactor(router-core): use one interpolation path --- packages/router-core/src/path.ts | 69 ----------------- packages/router-core/src/router.ts | 2 - packages/router-core/tests/path.bench.ts | 40 ++++++++++ packages/router-core/tests/path.test.ts | 94 +++++++++++++++++++++--- 4 files changed, 124 insertions(+), 81 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..a87ac70507 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -1,4 +1,3 @@ -import { isServer } from '@tanstack/router-core/isServer' import { last } from './utils' import { SEGMENT_TYPE_OPTIONAL_PARAM, @@ -199,11 +198,6 @@ interface InterpolatePathOptions { * Obtained from `compileDecodeCharMap(pathParamsAllowedCharacters)`. */ decoder?: (encoded: string) => string - /** - * @internal - * For testing only, in development mode we use the router.isServer value - */ - server?: boolean } type InterPolatePathResult = { @@ -245,10 +239,6 @@ export function interpolatePath({ path, params, decoder, - // `server` is marked @internal and stripped from .d.ts by `stripInternal`. - // We avoid destructuring it in the function signature so the emitted - // declaration doesn't reference a property that no longer exists. - ...rest }: InterpolatePathOptions): InterPolatePathResult { // Tracking if any params are missing in the `params` object // when interpolating the path @@ -260,65 +250,6 @@ export function interpolatePath({ if (!path.includes('$')) return { interpolatedPath: path, usedParams, isMissingParams } - if (isServer ?? rest.server) { - // Fast path for common templates like `/posts/$id` or `/files/$`. - // Braced segments (`{...}`) are more complex (prefix/suffix/optional) and are - // handled by the general parser below. - if (path.indexOf('{') === -1) { - const length = path.length - let cursor = 0 - let joined = '' - - while (cursor < length) { - // Skip slashes between segments. '/' code is 47 - while (cursor < length && path.charCodeAt(cursor) === 47) cursor++ - if (cursor >= length) break - - const start = cursor - let end = path.indexOf('/', cursor) - if (end === -1) end = length - cursor = end - - const part = path.substring(start, end) - if (!part) continue - - // `$id` or `$` (splat). '$' code is 36 - if (part.charCodeAt(0) === 36) { - if (part.length === 1) { - const splat = params._splat - usedParams._splat = splat - // TODO: Deprecate * - usedParams['*'] = splat - - if (!splat) { - isMissingParams = true - continue - } - - const value = encodeParam('_splat', params, decoder) - joined += '/' + value - } else { - const key = part.substring(1) - if (!isMissingParams && !(key in params)) { - isMissingParams = true - } - usedParams[key] = params[key] - - const value = encodeParam(key, params, decoder) ?? 'undefined' - joined += '/' + value - } - } else { - joined += '/' + part - } - } - - if (path.endsWith('/')) joined += '/' - - const interpolatedPath = joined || '/' - return { usedParams, interpolatedPath, isMissingParams } - } - } - const length = path.length let cursor = 0 let segment diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index cc83e358de..f5fc35998a 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1620,7 +1620,6 @@ export class RouterCore< path: route.fullPath, params: rawParams, decoder: this.pathParamsDecoder, - server: this.isServer, }) // Seed planning from the accepted same-ID cache generation first, then @@ -1958,7 +1957,6 @@ export class RouterCore< path: nextTo, params: nextParams, decoder: this.pathParamsDecoder, - server: this.isServer, }).interpolatedPath, ).path diff --git a/packages/router-core/tests/path.bench.ts b/packages/router-core/tests/path.bench.ts new file mode 100644 index 0000000000..826e474407 --- /dev/null +++ b/packages/router-core/tests/path.bench.ts @@ -0,0 +1,40 @@ +import { bench, describe, expect } from 'vitest' +import { interpolatePath } from '../src/path' + +const iterations = 10_000 +const plainOptions = { + path: '/organizations/$organizationId/projects/$projectId', + params: { organizationId: 'tanstack', projectId: 'router' }, +} +const bracedOptions = { + path: '/organizations/prefix{$organizationId}/projects/{-$projectId}', + params: { organizationId: 'tanstack', projectId: 'router' }, +} +let benchmarkSink = 0 + +expect(interpolatePath(plainOptions).interpolatedPath).toBe( + '/organizations/tanstack/projects/router', +) +expect(interpolatePath(bracedOptions).interpolatedPath).toBe( + '/organizations/prefixtanstack/projects/router', +) + +function interpolateBatch(options: Parameters[0]) { + let size = 0 + for (let index = 0; index < iterations; index++) { + size += interpolatePath(options).interpolatedPath.length + } + benchmarkSink = size +} + +describe('path interpolation', () => { + bench('plain route templates', () => { + interpolateBatch(plainOptions) + }) + + bench('braced route templates', () => { + interpolateBatch(bracedOptions) + }) +}) + +void benchmarkSink diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 4e49412209..e1207ae5cd 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -266,9 +266,8 @@ describe('resolvePath', () => { }) }) -describe.each([{ server: true }, { server: false }])( - 'interpolatePath (server: $server)', - ({ server }) => { +describe('interpolatePath', () => { + describe('shared behavior', () => { describe('regular usage', () => { it.each([ { @@ -373,13 +372,18 @@ describe.each([{ server: true }, { server: false }])( params: { _splat: 'sean/cassiere' }, result: '/users/sean/cassiere', }, + { + name: 'should stop interpolating after a non-terminal splat', + path: '/users/$/ignored', + params: { _splat: 'sean/cassiere' }, + result: '/users/sean/cassiere', + }, ])('$name', ({ path, params, decoder, result }) => { expect( interpolatePath({ path, params, decoder, - server, }).interpolatedPath, ).toBe(result) }) @@ -414,7 +418,6 @@ describe.each([{ server: true }, { server: false }])( interpolatePath({ path, params, - server, }).interpolatedPath, ).toBe(result) }, @@ -458,7 +461,6 @@ describe.each([{ server: true }, { server: false }])( interpolatePath({ path: to, params, - server, }).interpolatedPath, ).toBe(result) }) @@ -525,7 +527,6 @@ describe.each([{ server: true }, { server: false }])( interpolatePath({ path, params, - server, }).interpolatedPath, ).toBe(result) }) @@ -574,7 +575,6 @@ describe.each([{ server: true }, { server: false }])( interpolatePath({ path: to, params, - server, }).interpolatedPath, ).toBe(result) }) @@ -622,11 +622,16 @@ describe.each([{ server: true }, { server: false }])( }, expectedResult: '/hello', }, + { + name: 'non-terminal splat route', + path: '/hello/$/ignored', + params: {}, + expectedResult: '/hello', + }, ])('$name', ({ path, params, expectedResult }) => { const result = interpolatePath({ path, params, - server, }) expect(result.interpolatedPath).toBe(expectedResult) expect(result.isMissingParams).toBe(true) @@ -653,12 +658,81 @@ describe.each([{ server: true }, { server: false }])( const interpolatedNextTo = interpolatePath({ path: nextTo, params: nextParams, - server, }).interpolatedPath expect(interpolatedNextTo).toBe(`/splat${tail}`) }, ) }) + }) +}) + +it.each([ + { + name: 'multiple params', + path: '//organizations/$organizationId//projects/$projectId/', + params: { organizationId: 'tanstack', projectId: 'router' }, + expectedPath: '/organizations/tanstack/projects/router/', + expectedParams: { organizationId: 'tanstack', projectId: 'router' }, + isMissingParams: false, + }, + { + name: 'missing param', + path: '/organizations/$organizationId/projects/$projectId', + params: { organizationId: 'tanstack' }, + expectedPath: '/organizations/tanstack/projects/undefined', + expectedParams: { organizationId: 'tanstack', projectId: undefined }, + isMissingParams: true, + }, + { + name: 'terminal splat', + path: '/files/$', + params: { _splat: 'docs/router guide.pdf' }, + expectedPath: '/files/docs/router%20guide.pdf', + expectedParams: { + _splat: 'docs/router guide.pdf', + '*': 'docs/router guide.pdf', + }, + isMissingParams: false, + }, + { + name: 'non-terminal splat', + path: '/files/$/ignored/$param', + params: { _splat: 'docs/router', param: 'unused' }, + expectedPath: '/files/docs/router', + expectedParams: { _splat: 'docs/router', '*': 'docs/router' }, + isMissingParams: false, + }, + { + name: 'missing non-terminal splat', + path: '/files/$/ignored', + params: {}, + expectedPath: '/files', + expectedParams: { _splat: undefined, '*': undefined }, + isMissingParams: true, + }, + { + name: 'custom decoder', + path: '/users/$id', + params: { id: 'tanner+linsley@example.com' }, + decoder: compileDecodeCharMap(['+', '@']), + expectedPath: '/users/tanner+linsley@example.com', + expectedParams: { id: 'tanner+linsley@example.com' }, + isMissingParams: false, + }, +])( + 'plain-template interpolation: $name', + ({ + path, + params, + decoder, + expectedPath, + expectedParams, + isMissingParams, + }) => { + const result = interpolatePath({ path, params, decoder }) + expect(result.interpolatedPath).toBe(expectedPath) + expect(result.usedParams).toEqual(expectedParams) + expect(result.isMissingParams).toBe(isMissingParams) }, )