From 4a850d1815435ede7d84945d2f9936dea93d4d66 Mon Sep 17 00:00:00 2001 From: Luca Date: Mon, 10 Nov 2025 13:55:35 +0100 Subject: [PATCH 1/5] fix(router): prioritize static suffix over dynamic segment in route matching --- packages/router-core/src/router.ts | 56 +++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index a29a07a6889..71932b4aa92 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -11,9 +11,11 @@ import { } from './utils' import { processRouteTree } from './process-route-tree' import { + SEGMENT_TYPE_PATHNAME, cleanPath, interpolatePath, matchPathname, + parsePathname, resolvePath, trimPath, trimPathRight, @@ -31,7 +33,7 @@ import { executeRewriteOutput, rewriteBasepath, } from './rewrite' -import type { ParsePathnameCache } from './path' +import type { ParsePathnameCache, Segment } from './path' import type { SearchParser, SearchSerializer } from './searchParams' import type { AnyRedirect, ResolvedRedirect } from './redirect' import type { @@ -2686,26 +2688,54 @@ export function getMatchedRoutes({ let fuzzyMatch: | { foundRoute: TRouteLike; routeParams: Record } | undefined = undefined + const exactMatches: Array<{ + route: TRouteLike + routeParams: Record + endsWithStatic: boolean + }> = [] + + const getLastNonSlashSegment = (segments: ReadonlyArray) => { + for (let i = segments.length - 1; i >= 0; i--) { + if (segments[i]?.value !== '/') return segments[i] + } + return undefined + } + for (const route of flatRoutes) { const matchedParams = getMatchedParams(route) if (matchedParams) { - if ( - route.path !== '/' && - (matchedParams as Record)['**'] - ) { - if (!fuzzyMatch) { - fuzzyMatch = { foundRoute: route, routeParams: matchedParams } - } + const isFuzzy = + route.path !== '/' && (matchedParams as Record)['**'] + + if (isFuzzy) { + fuzzyMatch ??= { foundRoute: route, routeParams: matchedParams } } else { - foundRoute = route - routeParams = matchedParams - break + const routeSegments = parsePathname(route.fullPath, parseCache) + const pathSegments = parsePathname(trimmedPath, parseCache) + const lastRouteSegment = getLastNonSlashSegment(routeSegments) + const lastPathSegment = getLastNonSlashSegment(pathSegments) + const endsWithStatic = + lastRouteSegment?.type === SEGMENT_TYPE_PATHNAME && + lastPathSegment?.type === SEGMENT_TYPE_PATHNAME && + lastRouteSegment.value === lastPathSegment.value + + exactMatches.push({ + route, + routeParams: matchedParams, + endsWithStatic: !!endsWithStatic, + }) } } } - // did not find a perfect fit, so take the fuzzy matching route if it exists - if (!foundRoute && fuzzyMatch) { + + if (exactMatches.length > 0) { + exactMatches.sort( + (a, b) => Number(b.endsWithStatic) - Number(a.endsWithStatic), + ) + foundRoute = exactMatches[0]!.route + routeParams = exactMatches[0]!.routeParams + } else if (fuzzyMatch) { foundRoute = fuzzyMatch.foundRoute routeParams = fuzzyMatch.routeParams } From dd55ae139faebfd770a1274954acf2c2f822d01b Mon Sep 17 00:00:00 2001 From: Luca Date: Tue, 11 Nov 2025 11:12:22 +0100 Subject: [PATCH 2/5] test(router): add route matching tests for static suffix preference --- .../tests/processRouteTree.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/router-core/tests/processRouteTree.test.ts b/packages/router-core/tests/processRouteTree.test.ts index 3a509a3376f..a4ed5c640e1 100644 --- a/packages/router-core/tests/processRouteTree.test.ts +++ b/packages/router-core/tests/processRouteTree.test.ts @@ -438,5 +438,59 @@ describe('processRouteTree', () => { expect(result.flatRoutes.map((r) => r.id)).toEqual(expected) }, ) + + it.each([ + { + routes: ['/{-$owner}/posts/new', '/posts/$id'], + pathname: '/posts/new', + expected: { route: '/{-$owner}/posts/new', params: {} }, + description: 'prefers static suffix over dynamic param', + }, + { + routes: ['/{-$owner}/posts/edit', '/posts/$id'], + pathname: '/posts/edit', + expected: { route: '/{-$owner}/posts/edit', params: {} }, + description: 'prefers static suffix with different word', + }, + { + routes: ['/{-$owner}/posts/new', '/posts/$id'], + pathname: '/posts/123', + expected: { route: '/posts/$id', params: { id: '123' } }, + description: 'falls back to dynamic when no static match', + }, + { + routes: ['/users/{-$org}/settings', '/users/$id'], + pathname: '/users/settings', + expected: { route: '/users/{-$org}/settings', params: {} }, + description: 'works with optional params before static', + }, + { + routes: ['/{-$a}/posts/create', '/posts/$id'], + pathname: '/posts/create', + expected: { route: '/{-$a}/posts/create', params: {} }, + description: 'works with optional param and static suffix', + }, + { + routes: ['/api/{-$version}/docs', '/api/$endpoint'], + pathname: '/api/docs', + expected: { route: '/api/{-$version}/docs', params: {} }, + description: 'prefers static suffix in nested routes', + }, + ])( + 'route matching - static suffix: $description', + ({ routes, pathname, expected }) => { + const result = processRouteTree({ routeTree: createRouteTree(routes) }) + const matchResult = getMatchedRoutes({ + pathname, + caseSensitive: false, + routesByPath: result.routesByPath, + routesById: result.routesById, + flatRoutes: result.flatRoutes, + }) + + expect(matchResult.foundRoute?.id).toBe(expected.route) + expect(matchResult.routeParams).toEqual(expected.params) + }, + ) }) }) From ac8366e46ea97702446345f7f8e8db4a0fbe8223 Mon Sep 17 00:00:00 2001 From: Luca Date: Tue, 11 Nov 2025 11:14:02 +0100 Subject: [PATCH 3/5] fix(router): enhance case sensitivity in route matching --- packages/router-core/src/router.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 423ffe18500..bfac243b3e7 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -2713,10 +2713,21 @@ export function getMatchedRoutes({ const pathSegments = parsePathname(trimmedPath, parseCache) const lastRouteSegment = getLastNonSlashSegment(routeSegments) const lastPathSegment = getLastNonSlashSegment(pathSegments) + const routeCaseSensitive = + route.options?.caseSensitive ?? caseSensitive ?? false + const normalizeSegmentValue = (segment?: Segment) => { + if (!segment || segment.type !== SEGMENT_TYPE_PATHNAME) { + return undefined + } + return routeCaseSensitive + ? segment.value + : segment.value.toLowerCase() + } + const routeSegmentValue = normalizeSegmentValue(lastRouteSegment) + const pathSegmentValue = normalizeSegmentValue(lastPathSegment) const endsWithStatic = - lastRouteSegment?.type === SEGMENT_TYPE_PATHNAME && - lastPathSegment?.type === SEGMENT_TYPE_PATHNAME && - lastRouteSegment.value === lastPathSegment.value + routeSegmentValue !== undefined && + routeSegmentValue === pathSegmentValue exactMatches.push({ route, From 8010064e313368c94dbebcbe6774a666667bb2be Mon Sep 17 00:00:00 2001 From: Luca Date: Tue, 11 Nov 2025 11:23:04 +0100 Subject: [PATCH 4/5] test(router): update route matching tests to include undefined params for optional segments --- packages/router-core/tests/processRouteTree.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/router-core/tests/processRouteTree.test.ts b/packages/router-core/tests/processRouteTree.test.ts index a4ed5c640e1..4ca1cfa5def 100644 --- a/packages/router-core/tests/processRouteTree.test.ts +++ b/packages/router-core/tests/processRouteTree.test.ts @@ -443,13 +443,13 @@ describe('processRouteTree', () => { { routes: ['/{-$owner}/posts/new', '/posts/$id'], pathname: '/posts/new', - expected: { route: '/{-$owner}/posts/new', params: {} }, + expected: { route: '/{-$owner}/posts/new', params: { owner: undefined } }, description: 'prefers static suffix over dynamic param', }, { routes: ['/{-$owner}/posts/edit', '/posts/$id'], pathname: '/posts/edit', - expected: { route: '/{-$owner}/posts/edit', params: {} }, + expected: { route: '/{-$owner}/posts/edit', params: { owner: undefined } }, description: 'prefers static suffix with different word', }, { @@ -461,19 +461,19 @@ describe('processRouteTree', () => { { routes: ['/users/{-$org}/settings', '/users/$id'], pathname: '/users/settings', - expected: { route: '/users/{-$org}/settings', params: {} }, + expected: { route: '/users/{-$org}/settings', params: { org: undefined } }, description: 'works with optional params before static', }, { routes: ['/{-$a}/posts/create', '/posts/$id'], pathname: '/posts/create', - expected: { route: '/{-$a}/posts/create', params: {} }, + expected: { route: '/{-$a}/posts/create', params: { a: undefined } }, description: 'works with optional param and static suffix', }, { routes: ['/api/{-$version}/docs', '/api/$endpoint'], pathname: '/api/docs', - expected: { route: '/api/{-$version}/docs', params: {} }, + expected: { route: '/api/{-$version}/docs', params: { version: undefined } }, description: 'prefers static suffix in nested routes', }, ])( From 13142ff7c83a18c7cfc2df512ffad5b3438bd49e Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 15:02:52 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- .../tests/processRouteTree.test.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/router-core/tests/processRouteTree.test.ts b/packages/router-core/tests/processRouteTree.test.ts index 4ca1cfa5def..1be4f5b32bf 100644 --- a/packages/router-core/tests/processRouteTree.test.ts +++ b/packages/router-core/tests/processRouteTree.test.ts @@ -443,13 +443,19 @@ describe('processRouteTree', () => { { routes: ['/{-$owner}/posts/new', '/posts/$id'], pathname: '/posts/new', - expected: { route: '/{-$owner}/posts/new', params: { owner: undefined } }, + expected: { + route: '/{-$owner}/posts/new', + params: { owner: undefined }, + }, description: 'prefers static suffix over dynamic param', }, { routes: ['/{-$owner}/posts/edit', '/posts/$id'], pathname: '/posts/edit', - expected: { route: '/{-$owner}/posts/edit', params: { owner: undefined } }, + expected: { + route: '/{-$owner}/posts/edit', + params: { owner: undefined }, + }, description: 'prefers static suffix with different word', }, { @@ -461,7 +467,10 @@ describe('processRouteTree', () => { { routes: ['/users/{-$org}/settings', '/users/$id'], pathname: '/users/settings', - expected: { route: '/users/{-$org}/settings', params: { org: undefined } }, + expected: { + route: '/users/{-$org}/settings', + params: { org: undefined }, + }, description: 'works with optional params before static', }, { @@ -473,7 +482,10 @@ describe('processRouteTree', () => { { routes: ['/api/{-$version}/docs', '/api/$endpoint'], pathname: '/api/docs', - expected: { route: '/api/{-$version}/docs', params: { version: undefined } }, + expected: { + route: '/api/{-$version}/docs', + params: { version: undefined }, + }, description: 'prefers static suffix in nested routes', }, ])(