diff --git a/packages/router-core/src/path.ts b/packages/router-core/src/path.ts index a8032d1fd7f..373ad257036 100644 --- a/packages/router-core/src/path.ts +++ b/packages/router-core/src/path.ts @@ -1,3 +1,4 @@ +import { isServer } from '@tanstack/router-core/isServer' import { last } from './utils' import { SEGMENT_TYPE_OPTIONAL_PARAM, @@ -224,6 +225,11 @@ 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 = { @@ -258,6 +264,7 @@ export function interpolatePath({ path, params, decoder, + server, }: InterpolatePathOptions): InterPolatePathResult { // Tracking if any params are missing in the `params` object // when interpolating the path @@ -269,6 +276,65 @@ export function interpolatePath({ if (!path.includes('$')) return { interpolatedPath: path, usedParams, isMissingParams } + if (isServer ?? 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 ffc698d241b..76f1d523450 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1440,6 +1440,7 @@ export class RouterCore< path: route.fullPath, params: routeParams, decoder: this.pathParamsDecoder, + server: this.isServer, }) // Waste not, want not. If we already have a match for this route, @@ -1804,6 +1805,7 @@ export class RouterCore< const interpolatedNextTo = interpolatePath({ path: nextTo, params: nextParams, + server: this.isServer, }).interpolatedPath // Use lightweight getMatchedRoutes instead of matchRoutesInternal @@ -1850,6 +1852,7 @@ export class RouterCore< path: nextTo, params: nextParams, decoder: this.pathParamsDecoder, + server: this.isServer, }).interpolatedPath, ) diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index f9467cac06d..39641dd4ef9 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -242,325 +242,334 @@ describe('resolvePath', () => { ) }) -describe('interpolatePath', () => { - describe('regular usage', () => { - it.each([ - { - name: 'should interpolate the path', - path: '/users/$id', - params: { id: '123' }, - result: '/users/123', - }, - { - name: 'should interpolate the path', - path: '/users/$id', - params: { id: '123_' }, - result: '/users/123_', - }, - { - name: 'should interpolate the path with multiple params', - path: '/users/$id/$name', - params: { id: '123', name: 'tanner' }, - result: '/users/123/tanner', - }, - { - name: 'should interpolate the path with multiple params', - path: '/users/$id/$name', - params: { id: '123_', name: 'tanner' }, - result: '/users/123_/tanner', - }, - { - name: 'should interpolate the path with extra params', - path: '/users/$id', - params: { id: '123', name: 'tanner' }, - result: '/users/123', - }, - { - name: 'should interpolate the path with missing params', - path: '/users/$id/$name', - params: { id: '123' }, - result: '/users/123/undefined', - }, - { - name: 'should interpolate the path with missing params and extra params', - path: '/users/$id', - params: { name: 'john' }, - result: '/users/undefined', - }, - { - name: 'should interpolate the path with the param being a number', - path: '/users/$id', - params: { id: 123 }, - result: '/users/123', - }, - { - name: 'should interpolate the path with the param being a falsey number', - path: '/users/$id', - params: { id: 0 }, - result: '/users/0', - }, - { - name: 'should interpolate the path with URI component encoding', - path: '/users/$id', - params: { id: '?#@john+smith' }, - result: '/users/%3F%23%40john%2Bsmith', - }, - { - name: 'should interpolate the path without URI encoding characters in decodeCharMap', - path: '/users/$id', - params: { id: '?#@john+smith' }, - result: '/users/%3F%23@john+smith', - decoder: compileDecodeCharMap(['@', '+']), - }, - { - name: 'should interpolate the path with the splat param at the end', - path: '/users/$', - params: { _splat: '123' }, - result: '/users/123', - }, - { - name: 'should interpolate the path with a single named path param and the splat param at the end', - path: '/users/$username/$', - params: { username: 'seancassiere', _splat: '123' }, - result: '/users/seancassiere/123', - }, - { - name: 'should interpolate the path with 2 named path params with the splat param at the end', - path: '/users/$username/$id/$', - params: { username: 'seancassiere', id: '123', _splat: '456' }, - result: '/users/seancassiere/123/456', - }, - { - name: 'should interpolate the path with multiple named path params with the splat param at the end', - path: '/$username/settings/$repo/$id/$', - params: { - username: 'sean-cassiere', - repo: 'my-repo', - id: '123', - _splat: '456', +describe.each([{ server: true }, { server: false }])( + 'interpolatePath (server: $server)', + ({ server }) => { + describe('regular usage', () => { + it.each([ + { + name: 'should interpolate the path', + path: '/users/$id', + params: { id: '123' }, + result: '/users/123', }, - result: '/sean-cassiere/settings/my-repo/123/456', - }, - { - name: 'should interpolate the path with the splat param containing slashes', - path: '/users/$', - params: { _splat: 'sean/cassiere' }, - result: '/users/sean/cassiere', - }, - ])('$name', ({ path, params, decoder, result }) => { - expect( - interpolatePath({ - path, - params, - decoder, - }).interpolatedPath, - ).toBe(result) - }) - }) - - describe('preserve trailing slash', () => { - it.each([ - { - path: '/', - params: {}, - result: '/', - }, - { - path: '/a/b/', - params: {}, - result: '/a/b/', - }, - { - path: '/a/$id/', - params: { id: '123' }, - result: '/a/123/', - }, - { - path: '/a/{-$id}/', - params: { id: '123' }, - result: '/a/123/', - }, - ])( - 'should preserve trailing slash for $path', - ({ path, params, result }) => { + { + name: 'should interpolate the path', + path: '/users/$id', + params: { id: '123_' }, + result: '/users/123_', + }, + { + name: 'should interpolate the path with multiple params', + path: '/users/$id/$name', + params: { id: '123', name: 'tanner' }, + result: '/users/123/tanner', + }, + { + name: 'should interpolate the path with multiple params', + path: '/users/$id/$name', + params: { id: '123_', name: 'tanner' }, + result: '/users/123_/tanner', + }, + { + name: 'should interpolate the path with extra params', + path: '/users/$id', + params: { id: '123', name: 'tanner' }, + result: '/users/123', + }, + { + name: 'should interpolate the path with missing params', + path: '/users/$id/$name', + params: { id: '123' }, + result: '/users/123/undefined', + }, + { + name: 'should interpolate the path with missing params and extra params', + path: '/users/$id', + params: { name: 'john' }, + result: '/users/undefined', + }, + { + name: 'should interpolate the path with the param being a number', + path: '/users/$id', + params: { id: 123 }, + result: '/users/123', + }, + { + name: 'should interpolate the path with the param being a falsey number', + path: '/users/$id', + params: { id: 0 }, + result: '/users/0', + }, + { + name: 'should interpolate the path with URI component encoding', + path: '/users/$id', + params: { id: '?#@john+smith' }, + result: '/users/%3F%23%40john%2Bsmith', + }, + { + name: 'should interpolate the path without URI encoding characters in decodeCharMap', + path: '/users/$id', + params: { id: '?#@john+smith' }, + result: '/users/%3F%23@john+smith', + decoder: compileDecodeCharMap(['@', '+']), + }, + { + name: 'should interpolate the path with the splat param at the end', + path: '/users/$', + params: { _splat: '123' }, + result: '/users/123', + }, + { + name: 'should interpolate the path with a single named path param and the splat param at the end', + path: '/users/$username/$', + params: { username: 'seancassiere', _splat: '123' }, + result: '/users/seancassiere/123', + }, + { + name: 'should interpolate the path with 2 named path params with the splat param at the end', + path: '/users/$username/$id/$', + params: { username: 'seancassiere', id: '123', _splat: '456' }, + result: '/users/seancassiere/123/456', + }, + { + name: 'should interpolate the path with multiple named path params with the splat param at the end', + path: '/$username/settings/$repo/$id/$', + params: { + username: 'sean-cassiere', + repo: 'my-repo', + id: '123', + _splat: '456', + }, + result: '/sean-cassiere/settings/my-repo/123/456', + }, + { + name: 'should interpolate the path with the splat param containing slashes', + path: '/users/$', + params: { _splat: 'sean/cassiere' }, + result: '/users/sean/cassiere', + }, + ])('$name', ({ path, params, decoder, result }) => { expect( interpolatePath({ path, params, + decoder, + server, }).interpolatedPath, ).toBe(result) - }, - ) - }) - - describe('wildcard (prefix + suffix)', () => { - it.each([ - { - name: 'regular', - to: '/$', - params: { _splat: 'bar/foo/me' }, - result: '/bar/foo/me', - }, - { - name: 'regular curly braces', - to: '/{$}', - params: { _splat: 'bar/foo/me' }, - result: '/bar/foo/me', - }, - { - name: 'with prefix', - to: '/prefix{$}', - params: { _splat: 'bar' }, - result: '/prefixbar', - }, - { - name: 'with suffix', - to: '/{$}-suffix', - params: { _splat: 'bar' }, - result: '/bar-suffix', - }, - { - name: 'with prefix + suffix', - to: '/prefix{$}-suffix', - params: { _splat: 'bar' }, - result: '/prefixbar-suffix', - }, - ])('$name', ({ to, params, result }) => { - expect( - interpolatePath({ - path: to, - params, - }).interpolatedPath, - ).toBe(result) + }) }) - }) - describe('named params (prefix + suffix)', () => { - it.each([ - { - name: 'regular', - to: '/$foo', - params: { foo: 'bar' }, - result: '/bar', - }, - { - name: 'regular curly braces', - to: '/{$foo}', - params: { foo: 'bar' }, - result: '/bar', - }, - { - name: 'with prefix', - to: '/prefix{$bar}', - params: { bar: 'baz' }, - result: '/prefixbaz', - }, - { - name: 'with suffix', - to: '/{$foo}.suffix', - params: { foo: 'bar' }, - result: '/bar.suffix', - }, - { - name: 'with suffix', - to: '/{$foo}.suffix', - params: { foo: 'bar_' }, - result: '/bar_.suffix', - }, - { - name: 'with prefix and suffix', - to: '/prefix{$param}.suffix', - params: { param: 'foobar' }, - result: '/prefixfoobar.suffix', - }, - ])('$name', ({ to, params, result }) => { - expect( - interpolatePath({ - path: to, - params, - }).interpolatedPath, - ).toBe(result) + describe('preserve trailing slash', () => { + it.each([ + { + path: '/', + params: {}, + result: '/', + }, + { + path: '/a/b/', + params: {}, + result: '/a/b/', + }, + { + path: '/a/$id/', + params: { id: '123' }, + result: '/a/123/', + }, + { + path: '/a/{-$id}/', + params: { id: '123' }, + result: '/a/123/', + }, + ])( + 'should preserve trailing slash for $path', + ({ path, params, result }) => { + expect( + interpolatePath({ + path, + params, + server, + }).interpolatedPath, + ).toBe(result) + }, + ) }) - }) - describe('should handle missing _splat parameter for', () => { - it.each([ - { - name: 'basic splat route', - path: '/hello/$', - params: {}, - expectedResult: '/hello', - }, - { - name: 'splat route with prefix', - path: '/hello/prefix{$}', - params: {}, - expectedResult: '/hello/prefix', - }, - { - name: 'splat route with suffix', - path: '/hello/{$}suffix', - params: {}, - expectedResult: '/hello/suffix', - }, - { - name: 'splat route with prefix and suffix', - path: '/hello/prefix{$}suffix', - params: {}, - expectedResult: '/hello/prefixsuffix', - }, - { - name: 'splat route with empty splat', - path: '/hello/$', - params: { - _splat: '', + describe('wildcard (prefix + suffix)', () => { + it.each([ + { + name: 'regular', + to: '/$', + params: { _splat: 'bar/foo/me' }, + result: '/bar/foo/me', }, - expectedResult: '/hello', - }, - { - name: 'splat route with undefined splat', - path: '/hello/$', - params: { - _splat: undefined, + { + name: 'regular curly braces', + to: '/{$}', + params: { _splat: 'bar/foo/me' }, + result: '/bar/foo/me', }, - expectedResult: '/hello', - }, - ])('$name', ({ path, params, expectedResult }) => { - const result = interpolatePath({ - path, - params, + { + name: 'with prefix', + to: '/prefix{$}', + params: { _splat: 'bar' }, + result: '/prefixbar', + }, + { + name: 'with suffix', + to: '/{$}-suffix', + params: { _splat: 'bar' }, + result: '/bar-suffix', + }, + { + name: 'with prefix + suffix', + to: '/prefix{$}-suffix', + params: { _splat: 'bar' }, + result: '/prefixbar-suffix', + }, + ])('$name', ({ to, params, result }) => { + expect( + interpolatePath({ + path: to, + params, + server, + }).interpolatedPath, + ).toBe(result) }) - expect(result.interpolatedPath).toBe(expectedResult) - expect(result.isMissingParams).toBe(true) }) - }) -}) -describe('resolvePath + interpolatePath', () => { - it.each(['never', 'preserve', 'always'] as const)( - 'trailing slash: %s', - (trailingSlash) => { - const tail = trailingSlash === 'always' ? '/' : '' - const defaultedFromPath = '/' - const fromPath = resolvePath({ - base: defaultedFromPath, - to: '.', - trailingSlash, + describe('named params (prefix + suffix)', () => { + it.each([ + { + name: 'regular', + to: '/$foo', + params: { foo: 'bar' }, + result: '/bar', + }, + { + name: 'regular curly braces', + to: '/{$foo}', + params: { foo: 'bar' }, + result: '/bar', + }, + { + name: 'with prefix', + to: '/prefix{$bar}', + params: { bar: 'baz' }, + result: '/prefixbaz', + }, + { + name: 'with suffix', + to: '/{$foo}.suffix', + params: { foo: 'bar' }, + result: '/bar.suffix', + }, + { + name: 'with suffix', + to: '/{$foo}.suffix', + params: { foo: 'bar_' }, + result: '/bar_.suffix', + }, + { + name: 'with prefix and suffix', + to: '/prefix{$param}.suffix', + params: { param: 'foobar' }, + result: '/prefixfoobar.suffix', + }, + ])('$name', ({ to, params, result }) => { + expect( + interpolatePath({ + path: to, + params, + server, + }).interpolatedPath, + ).toBe(result) }) - const nextTo = resolvePath({ - base: fromPath, - to: '/splat/$', - trailingSlash, + }) + + describe('should handle missing _splat parameter for', () => { + it.each([ + { + name: 'basic splat route', + path: '/hello/$', + params: {}, + expectedResult: '/hello', + }, + { + name: 'splat route with prefix', + path: '/hello/prefix{$}', + params: {}, + expectedResult: '/hello/prefix', + }, + { + name: 'splat route with suffix', + path: '/hello/{$}suffix', + params: {}, + expectedResult: '/hello/suffix', + }, + { + name: 'splat route with prefix and suffix', + path: '/hello/prefix{$}suffix', + params: {}, + expectedResult: '/hello/prefixsuffix', + }, + { + name: 'splat route with empty splat', + path: '/hello/$', + params: { + _splat: '', + }, + expectedResult: '/hello', + }, + { + name: 'splat route with undefined splat', + path: '/hello/$', + params: { + _splat: undefined, + }, + expectedResult: '/hello', + }, + ])('$name', ({ path, params, expectedResult }) => { + const result = interpolatePath({ + path, + params, + server, + }) + expect(result.interpolatedPath).toBe(expectedResult) + expect(result.isMissingParams).toBe(true) }) - const nextParams = { _splat: '' } - const interpolatedNextTo = interpolatePath({ - path: nextTo, - params: nextParams, - }).interpolatedPath - expect(interpolatedNextTo).toBe(`/splat${tail}`) - }, - ) -}) + }) + + describe('resolvePath + interpolatePath', () => { + it.each(['never', 'preserve', 'always'] as const)( + 'trailing slash: %s', + (trailingSlash) => { + const tail = trailingSlash === 'always' ? '/' : '' + const defaultedFromPath = '/' + const fromPath = resolvePath({ + base: defaultedFromPath, + to: '.', + trailingSlash, + }) + const nextTo = resolvePath({ + base: fromPath, + to: '/splat/$', + trailingSlash, + }) + const nextParams = { _splat: '' } + const interpolatedNextTo = interpolatePath({ + path: nextTo, + params: nextParams, + server, + }).interpolatedPath + expect(interpolatedNextTo).toBe(`/splat${tail}`) + }, + ) + }) + }, +) describe('matchPathname', () => { const { processedTree } = processRouteTree({