diff --git a/.changeset/lazy-poems-repeat.md b/.changeset/lazy-poems-repeat.md new file mode 100644 index 0000000000..cfd36c939b --- /dev/null +++ b/.changeset/lazy-poems-repeat.md @@ -0,0 +1,5 @@ +--- +'@tanstack/router-core': patch +--- + +fix: keep match.pathname consistent across nested matches when a route uses params.parse diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index 2197dab737..5fac286c93 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -1448,6 +1448,13 @@ export class RouterCore< const { foundRoute, routeParams } = matchedRoutesResult let { matchedRoutes } = matchedRoutesResult let isGlobalNotFound = false + // Accumulates each route's strict (params.parse-applied) params as we walk + // down the match chain, so that `match.params` on a child match includes + // its ancestors' parsed params. Kept separate from `routeParams`, which + // must stay as the raw, un-parsed params used to interpolate `match.pathname` + // for every route in the chain -- otherwise a parent route's params.parse + // would leak its parsed value into the pathname of routes matched afterward. + const accumulatedStrictParams: Record = Object.create(null) // Check to see if the route needs a 404 entry if ( @@ -1565,7 +1572,24 @@ export class RouterCore< const previousMatch = previousActiveMatchesByRouteId.get(route.id) - const strictParams = existingMatch?._strictParams ?? usedParams + // `usedParams` holds the raw path params for this route's full path + // (interpolated from the untouched `routeParams`). For keys an ancestor + // route has already parsed via its own `params.parse`, prefer that + // already-accumulated (parsed) value over the raw one, so this route's + // own `params.parse` receives its ancestors' parsed params -- matching + // pre-parse behavior -- without letting a route that owns no `parse` + // for that key clobber the ancestor's parsed value. + let strictParams: Record + if (existingMatch) { + strictParams = existingMatch._strictParams! + } else { + strictParams = Object.assign(Object.create(null), usedParams) + for (const key in usedParams) { + if (key in accumulatedStrictParams) { + strictParams[key] = accumulatedStrictParams[key] + } + } + } let paramsError: unknown = undefined @@ -1587,7 +1611,7 @@ export class RouterCore< } } - Object.assign(routeParams, strictParams) + Object.assign(accumulatedStrictParams, strictParams) const cause = previousMatch ? 'stay' : 'enter' @@ -1597,7 +1621,7 @@ export class RouterCore< match = { ...existingMatch, cause, - params: previousMatch?.params ?? routeParams, + params: previousMatch?.params ?? accumulatedStrictParams, _strictParams: strictParams, search: previousMatch ? nullReplaceEqualDeep(previousMatch.search, preMatchSearch) @@ -1618,7 +1642,7 @@ export class RouterCore< ssr: (isServer ?? this.isServer) ? undefined : route.options.ssr, index, routeId: route.id, - params: previousMatch?.params ?? routeParams, + params: previousMatch?.params ?? accumulatedStrictParams, _strictParams: strictParams, pathname: interpolatedPath, updatedAt: Date.now(), @@ -1681,8 +1705,8 @@ export class RouterCore< // Update the match's params const previousMatch = previousActiveMatchesByRouteId.get(match.routeId) match.params = previousMatch - ? nullReplaceEqualDeep(previousMatch.params, routeParams) - : routeParams + ? nullReplaceEqualDeep(previousMatch.params, accumulatedStrictParams) + : accumulatedStrictParams if (!existingMatch) { const parentMatch = matches[index - 1] diff --git a/packages/router-core/tests/match-params.test.ts b/packages/router-core/tests/match-params.test.ts index fe05808bd4..6261c0eeeb 100644 --- a/packages/router-core/tests/match-params.test.ts +++ b/packages/router-core/tests/match-params.test.ts @@ -613,6 +613,52 @@ describe('params.parse route selection', () => { }) }) + describe('match.pathname consistency', () => { + it('keeps match.pathname consistent across nested matches when params.parse/stringify remap the value', () => { + const rootRoute = new BaseRootRoute({}) + const postTypeRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '$postType', + params: { + parse: ({ postType }: { postType: string }) => ({ + postType: postType === 'articles' ? 'article' : postType, + }), + stringify: ({ postType }: { postType: string }) => ({ + postType: postType === 'article' ? 'articles' : postType, + }), + }, + }) + const postTypeIndexRoute = new BaseRoute({ + getParentRoute: () => postTypeRoute, + path: '/', + }) + const routeTree = rootRoute.addChildren([ + postTypeRoute.addChildren([postTypeIndexRoute]), + ]) + const router = createTestRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: ['/articles'] }), + }) + + const matches = router.matchRoutes('/articles', {}) + const layoutMatch = matches.find((m) => m.routeId === postTypeRoute.id) + const indexMatch = matches.find( + (m) => m.routeId === postTypeIndexRoute.id, + ) + + // params.parse is applied consistently: both the layout match and the + // nested index match see the parsed (singular) value. + expect(layoutMatch?.params.postType).toBe('article') + expect(indexMatch?.params.postType).toBe('article') + + // match.pathname must reflect the raw (public) URL segment on every + // match in the chain -- a route's own params.parse must not leak its + // parsed value into the pathname of routes matched afterward. + expect(layoutMatch?.pathname).toBe('/articles') + expect(indexMatch?.pathname).toBe('/articles/') + }) + }) + describe('pathless routes', () => { it('pathless layout with params.parse gates children', () => { const { processedTree } = processRouteTree(