From ae9f8d1457e4021501eb2243bb7a72aae67a7745 Mon Sep 17 00:00:00 2001 From: MarkXian Date: Sat, 25 Jul 2026 18:21:15 +0800 Subject: [PATCH] fix(router-core): stringify parent params for missing templates --- packages/router-core/src/router.ts | 34 +++++++++++++++++-- .../router-core/tests/build-location.test.ts | 31 +++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index df9251a739..a440c8cf5a 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -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 @@ -3092,6 +3097,29 @@ function validateSearch(validateSearch: AnyValidator, input: unknown): unknown { return {} } +function getKnownParentRouteBranch( + routesByPath: Record, + path: string, + getRouteBranch: (route: AnyRoute) => ReadonlyArray, +): ReadonlyArray { + 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. diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index 131fccf095..f2cd39906d 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -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({