Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/resolve-path-positional.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tanstack/router-core': patch
'@tanstack/react-router': patch
'@tanstack/solid-router': patch
'@tanstack/vue-router': patch
---

`resolvePath` (internal helper) now takes positional arguments β€” `resolvePath(base, to, trailingSlash?, cache?)` β€” so `buildLocation` and `matchRoute` no longer allocate an options object per path resolution.
22 changes: 9 additions & 13 deletions packages/router-core/src/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,23 +94,19 @@ export function exactPathTest(
// /a/b/c + d = /a/b/c/d
// /a/b/c + d/ = /a/b/c/d
// /a/b/c + d/e = /a/b/c/d/e
interface ResolvePathOptions {
base: string
to: string
trailingSlash?: 'always' | 'never' | 'preserve'
cache?: SieveCache<string, string>
}

/**
* Resolve a destination path against a base, honoring trailing-slash policy
* and supporting relative segments (`.`/`..`) and absolute `to` values.
*
* Internal: parameters are positional so the router's hot callers pass no
* options object.
*/
export function resolvePath({
base,
to,
trailingSlash = 'never',
cache,
}: ResolvePathOptions) {
export function resolvePath(
base: string,
to: string,
trailingSlash: 'always' | 'never' | 'preserve' = 'never',
cache?: SieveCache<string, string>,
) {
if (to.includes('//')) {
to = cleanPath(to)
}
Expand Down
12 changes: 6 additions & 6 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1522,12 +1522,12 @@ export class RouterCore<

/** Resolve a path using the router's trailing-slash policy. */
resolvePathWithBase = (from: string, path: string) => {
return resolvePath({
base: from,
to: path,
trailingSlash: this.options.trailingSlash,
cache: this.resolvePathCache,
})
return resolvePath(
from,
path,
this.options.trailingSlash,
this.resolvePathCache,
)
}

matchRoutes: MatchRoutesFn = (
Expand Down
110 changes: 23 additions & 87 deletions packages/router-core/tests/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,79 +476,43 @@ describe('resolvePath', () => {
['/posts', '../../data:text/html,test', '/data:text/html,test'],
])('resolves correctly', (a, b, eq) => {
it(`${a} to ${b} === ${eq}`, () => {
expect(resolvePath({ base: a, to: b })).toEqual(eq)
expect(resolvePath(a, b)).toEqual(eq)
})
it(`${a}/ to ${b} === ${eq} (trailing slash)`, () => {
expect(resolvePath({ base: a + '/', to: b })).toEqual(eq)
expect(resolvePath(a + '/', b)).toEqual(eq)
})
it(`${a}/ to ${b}/ === ${eq} (trailing slash + trailing slash)`, () => {
expect(resolvePath({ base: a + '/', to: b + '/' })).toEqual(eq)
expect(resolvePath(a + '/', b + '/')).toEqual(eq)
})
})

it('normalizes repeated slashes when resolving the base path', () => {
expect(resolvePath({ base: '/a//b', to: '.' })).toBe('/a/b')
expect(resolvePath('/a//b', '.')).toBe('/a/b')
})

describe('trailingSlash', () => {
describe(`'always'`, () => {
it('keeps trailing slash', () => {
expect(
resolvePath({
base: '/a/b/c',
to: 'd/',
trailingSlash: 'always',
}),
).toBe('/a/b/c/d/')
expect(resolvePath('/a/b/c', 'd/', 'always')).toBe('/a/b/c/d/')
})
it('adds trailing slash', () => {
expect(
resolvePath({
base: '/a/b/c',
to: 'd',
trailingSlash: 'always',
}),
).toBe('/a/b/c/d/')
expect(resolvePath('/a/b/c', 'd', 'always')).toBe('/a/b/c/d/')
})
})
describe(`'never'`, () => {
it('removes trailing slash', () => {
expect(
resolvePath({
base: '/a/b/c',
to: 'd/',
trailingSlash: 'never',
}),
).toBe('/a/b/c/d')
expect(resolvePath('/a/b/c', 'd/', 'never')).toBe('/a/b/c/d')
})
it('does not add trailing slash', () => {
expect(
resolvePath({
base: '/a/b/c',
to: 'd',
trailingSlash: 'never',
}),
).toBe('/a/b/c/d')
expect(resolvePath('/a/b/c', 'd', 'never')).toBe('/a/b/c/d')
})
})
describe(`'preserve'`, () => {
it('keeps trailing slash', () => {
expect(
resolvePath({
base: '/a/b/c',
to: 'd/',
trailingSlash: 'preserve',
}),
).toBe('/a/b/c/d/')
expect(resolvePath('/a/b/c', 'd/', 'preserve')).toBe('/a/b/c/d/')
})
it('does not add trailing slash', () => {
expect(
resolvePath({
base: '/a/b/c',
to: 'd',
trailingSlash: 'preserve',
}),
).toBe('/a/b/c/d')
expect(resolvePath('/a/b/c', 'd', 'preserve')).toBe('/a/b/c/d')
})
})

Expand All @@ -559,7 +523,7 @@ describe('resolvePath', () => {
] as const)(
"normalizes repeated slashes with trailingSlash '%s'",
(trailingSlash, to, expected) => {
expect(resolvePath({ base: '/', to, trailingSlash })).toBe(expected)
expect(resolvePath('/', to, trailingSlash)).toBe(expected)
},
)
})
Expand All @@ -585,13 +549,7 @@ describe('resolvePath', () => {
},
])('$name', ({ to }) => {
const candidate = base + trimPathLeft(to)
expect(
resolvePath({
base,
to: candidate,
trailingSlash: 'never',
}),
).toEqual(candidate)
expect(resolvePath(base, candidate, 'never')).toEqual(candidate)
})
})

Expand All @@ -613,42 +571,28 @@ describe('resolvePath', () => {
},
])('$name', ({ to }) => {
const candidate = base + trimPathLeft(to)
expect(
resolvePath({
base,
to: candidate,
trailingSlash: 'never',
}),
).toEqual(candidate)
expect(resolvePath(base, candidate, 'never')).toEqual(candidate)
})
})
},
)

it('preserves explicit route-template param syntax', () => {
expect(
resolvePath({
base: '/{$language}',
to: '.',
}),
).toBe('/{$language}')
expect(resolvePath('/{$language}', '.')).toBe('/{$language}')

expect(
resolvePath({
base: '/{$language}/posts',
to: '../{$language}',
}),
).toBe('/{$language}/{$language}')
expect(resolvePath('/{$language}/posts', '../{$language}')).toBe(
'/{$language}/{$language}',
)
})

it('caches route-template paths without changing param syntax', () => {
const cache = createSieveCache<string, string>(10)
const set = vi.spyOn(cache, 'set')

expect(resolvePath({ base: '/', to: '{$id}', cache })).toBe('/{$id}')
expect(resolvePath({ base: '/', to: '$id', cache })).toBe('/$id')
expect(resolvePath({ base: '/', to: '{$id}', cache })).toBe('/{$id}')
expect(resolvePath({ base: '/', to: '$id', cache })).toBe('/$id')
expect(resolvePath('/', '{$id}', undefined, cache)).toBe('/{$id}')
expect(resolvePath('/', '$id', undefined, cache)).toBe('/$id')
expect(resolvePath('/', '{$id}', undefined, cache)).toBe('/{$id}')
expect(resolvePath('/', '$id', undefined, cache)).toBe('/$id')
expect(set).toHaveBeenCalledTimes(2)
})
})
Expand Down Expand Up @@ -1073,16 +1017,8 @@ describe('interpolatePath', () => {
(trailingSlash) => {
const tail = trailingSlash === 'always' ? '/' : ''
const defaultedFromPath = '/'
const fromPath = resolvePath({
base: defaultedFromPath,
to: '.',
trailingSlash,
})
const nextTo = resolvePath({
base: fromPath,
to: '/splat/$',
trailingSlash,
})
const fromPath = resolvePath(defaultedFromPath, '.', trailingSlash)
const nextTo = resolvePath(fromPath, '/splat/$', trailingSlash)
const nextParams = { _splat: '' }
const interpolatedNextTo = interpolatePath(
nextTo,
Expand Down
Loading