Skip to content
Closed
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
67 changes: 54 additions & 13 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import {
} from './utils'
import { processRouteTree } from './process-route-tree'
import {
SEGMENT_TYPE_PATHNAME,
cleanPath,
interpolatePath,
matchPathname,
parsePathname,
resolvePath,
trimPath,
trimPathRight,
Expand All @@ -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 {
Expand Down Expand Up @@ -2684,26 +2686,65 @@ export function getMatchedRoutes<TRouteLike extends RouteLike>({
let fuzzyMatch:
| { foundRoute: TRouteLike; routeParams: Record<string, string> }
| undefined = undefined
const exactMatches: Array<{
route: TRouteLike
routeParams: Record<string, string>
endsWithStatic: boolean
}> = []

const getLastNonSlashSegment = (segments: ReadonlyArray<Segment>) => {
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<string, string>)['**']
) {
if (!fuzzyMatch) {
fuzzyMatch = { foundRoute: route, routeParams: matchedParams }
}
const isFuzzy =
route.path !== '/' && (matchedParams as Record<string, string>)['**']

if (isFuzzy) {
fuzzyMatch ??= { foundRoute: route, routeParams: matchedParams }
} else {
foundRoute = route
routeParams = matchedParams
break
const routeSegments = parsePathname(route.fullPath, parseCache)
const pathSegments = parsePathname(trimmedPath, parseCache)
Copy link
Contributor

@nlynzaad nlynzaad Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we only need to parse this once right? since this is the trimmedPath we can move this above the for loop?

const lastRouteSegment = getLastNonSlashSegment(routeSegments)
const lastPathSegment = getLastNonSlashSegment(pathSegments)
Copy link
Contributor

@nlynzaad nlynzaad Nov 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we only need to parse this once right? since this is the trimmedPath we can move this above the for loop?

const routeCaseSensitive =
route.options?.caseSensitive ?? caseSensitive ?? false
const normalizeSegmentValue = (segment?: Segment) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bit of a nitpick but can we move this above the for loop and just add the routeCaseSensitive as a paramater. This way its only declared once and not with every iteration that has matching params

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
}
Comment on lines +2732 to 2750
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should still break out as early possible and if no route is ending with a matched static the original case of first route being matched can still apply.

Suggested change
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
}
if (endsWithStatic) {
foundRoute = route
routeParams = matchedParams
break
}
if (!foundRoute) {
foundRoute = route
routeParams = matchedParams
}
}
}
}
if (!foundRoute && fuzzyMatch) {
foundRoute = fuzzyMatch.foundRoute
routeParams = fuzzyMatch.routeParams
}
}

Expand Down
66 changes: 66 additions & 0 deletions packages/router-core/tests/processRouteTree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
)
})
})
Loading