diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 962c536045a..bfac243b3e7 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 { @@ -2684,26 +2686,65 @@ 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 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 = + routeSegmentValue !== undefined && + routeSegmentValue === pathSegmentValue + + 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 } diff --git a/packages/router-core/tests/processRouteTree.test.ts b/packages/router-core/tests/processRouteTree.test.ts index 3a509a3376f..1be4f5b32bf 100644 --- a/packages/router-core/tests/processRouteTree.test.ts +++ b/packages/router-core/tests/processRouteTree.test.ts @@ -438,5 +438,71 @@ 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: { 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 }, + }, + 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: { org: undefined }, + }, + description: 'works with optional params before static', + }, + { + routes: ['/{-$a}/posts/create', '/posts/$id'], + pathname: '/posts/create', + 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: { version: undefined }, + }, + 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) + }, + ) }) })