Skip to content
Open
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
34 changes: 31 additions & 3 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1924,9 +1924,14 @@ export class RouterCore<
if (destRoute) {
destRoutes = this.getRouteBranch(destRoute)
} else if (nextTo.includes('$')) {
// Route templates must match routesByPath exactly. A miss here is a
// typed destination mismatch, not a concrete URL to route-match.
destRoutes = []
// Server-only leaf routes can be absent from the client route tree while
// their parent route templates are still present. Use the known parent
// branch so parent params.stringify hooks still canonicalize the href.
destRoutes = getKnownParentRouteBranch(
this.routesByPath,
nextTo,
(route) => this.getRouteBranch(route),
)
} else {
const destMatchResult = this.getMatchedRoutes(nextTo)
destRoutes = destMatchResult.matchedRoutes
Expand Down Expand Up @@ -3092,6 +3097,29 @@ function validateSearch(validateSearch: AnyValidator, input: unknown): unknown {
return {}
}

function getKnownParentRouteBranch(
routesByPath: Record<string, AnyRoute | undefined>,
path: string,
getRouteBranch: (route: AnyRoute) => ReadonlyArray<AnyRoute>,
): ReadonlyArray<AnyRoute> {
let parentPath = trimPathRight(path)

while (parentPath) {
parentPath = parentPath.slice(0, parentPath.lastIndexOf('/')) || '/'
const parentRoute = routesByPath[parentPath]

if (parentRoute) {
return getRouteBranch(parentRoute)
}

if (parentPath === '/') {
break
}
}

return []
}

/**
* Build the matched route chain and extract params for a pathname.
* Falls back to the root route if no specific route is found.
Expand Down
31 changes: 31 additions & 0 deletions packages/router-core/tests/build-location.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1615,6 +1615,37 @@ describe('buildLocation - params edge cases', () => {
expect(currentRouteLocation.pathname).toBe('/pl')
})

test('params.stringify should run for the matched parent of a missing route template', async () => {
const rootRoute = new BaseRootRoute({})
const postTypeRoute = new BaseRoute({
getParentRoute: () => rootRoute,
path: '/$postType',
params: {
parse: ({ postType }: { postType: string }) =>
postType === 'articles' ? { postType: 'article' as const } : false,
stringify: ({ postType }: { postType: 'article' }) => ({
postType: postType === 'article' ? 'articles' : postType,
}),
},
})

const routeTree = rootRoute.addChildren([postTypeRoute])

const router = createTestRouter({
routeTree,
history: createMemoryHistory({ initialEntries: ['/articles'] }),
})

await router.load()

const location = router.buildLocation({
to: '/$postType/$postId/download',
params: { postType: 'article', postId: '1' },
})

expect(location.pathname).toBe('/articles/1/download')
})

test('params.stringify should use the exact route template over path matching priority', async () => {
const rootRoute = new BaseRootRoute({})
const dollarRoute = new BaseRoute({
Expand Down