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
5 changes: 5 additions & 0 deletions .changeset/lazy-poems-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

fix: keep match.pathname consistent across nested matches when a route uses params.parse
36 changes: 30 additions & 6 deletions packages/router-core/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = Object.create(null)

// Check to see if the route needs a 404 entry
if (
Expand Down Expand Up @@ -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<string, unknown>
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

Expand All @@ -1587,7 +1611,7 @@ export class RouterCore<
}
}

Object.assign(routeParams, strictParams)
Object.assign(accumulatedStrictParams, strictParams)

const cause = previousMatch ? 'stay' : 'enter'

Expand All @@ -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)
Expand All @@ -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(),
Expand Down Expand Up @@ -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]
Expand Down
46 changes: 46 additions & 0 deletions packages/router-core/tests/match-params.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down