diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index ba6244a4d97..2ea27bad1e2 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -986,7 +986,7 @@ function extractParams( ] } -function buildRouteBranch(route: T) { +export function buildRouteBranch(route: T) { const list = [route] while (route.parentRoute) { route = route.parentRoute as T diff --git a/packages/router-core/src/router.ts b/packages/router-core/src/router.ts index e59c87bb409..dd87b96dc96 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -15,6 +15,7 @@ import { replaceEqualDeep, } from './utils' import { + buildRouteBranch, findFlatMatch, findRouteMatch, findSingleMatch, @@ -1848,10 +1849,44 @@ export class RouterCore< functionalUpdate(dest.params as any, fromParams), ) - // Interpolate the path first to get the actual resolved path, then match against that + // Apply stringify BEFORE interpolating to ensure route matching works with skipRouteOnParseError.params: true + // We look up the route by its template path and apply stringify functions from the route branch before interpolation + const trimmedNextTo = trimPathRight(nextTo) + const targetRoute = this.routesByPath[trimmedNextTo] + let prestringifiedParams: Record | null = null + if (targetRoute && Object.keys(nextParams).length > 0) { + const routeBranch = buildRouteBranch(targetRoute) + let hasParamsStringifyFn = false + let hasSkipRouteOnParseErrorDependantOnStringification = false + for (const route of routeBranch) { + if (route.options.params?.stringify ?? route.options.stringifyParams) + hasParamsStringifyFn = true + if ( + hasParamsStringifyFn && + route.options.skipRouteOnParseError?.params + ) { + hasSkipRouteOnParseErrorDependantOnStringification = true + break + } + } + if (hasSkipRouteOnParseErrorDependantOnStringification) { + prestringifiedParams = { ...nextParams } + for (const route of routeBranch) { + const fn = + route.options.params?.stringify ?? route.options.stringifyParams + if (fn) { + Object.assign(prestringifiedParams!, fn(prestringifiedParams)) + } + } + } + } + const attemptedPrestringify = prestringifiedParams !== null + + // Interpolate the path to get the actual resolved path for route matching + // When prestringifiedParams is available, use it for correct matching with skipRouteOnParseError const interpolatedNextTo = interpolatePath({ path: nextTo, - params: nextParams, + params: prestringifiedParams ?? nextParams, decoder: this.pathParamsDecoder, server: this.isServer, }).interpolatedPath @@ -1861,6 +1896,12 @@ export class RouterCore< // which are expensive and not needed for buildLocation const destMatchResult = this.getMatchedRoutes(interpolatedNextTo) let destRoutes = destMatchResult.matchedRoutes + if ( + !destMatchResult.foundRoute || + !comparePaths(destMatchResult.foundRoute.fullPath, trimmedNextTo) + ) { + prestringifiedParams = null + } // Compute globalNotFoundRouteId using the same logic as matchRoutesInternal const isGlobalNotFound = destMatchResult.foundRoute @@ -1874,7 +1915,7 @@ export class RouterCore< // If there are any params, we need to stringify them let changedParams = false - if (Object.keys(nextParams).length > 0) { + if (!prestringifiedParams && Object.keys(nextParams).length > 0) { for (const route of destRoutes) { const fn = route.options.params?.stringify ?? route.options.stringifyParams @@ -1890,7 +1931,7 @@ export class RouterCore< // This preserves the original parameter syntax including optional parameters nextTo : decodePath( - !changedParams + prestringifiedParams || (!attemptedPrestringify && !changedParams) ? interpolatedNextTo : interpolatePath({ path: nextTo, diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts index f713eb4b4a7..abb9adfb99c 100644 --- a/packages/router-core/tests/build-location.test.ts +++ b/packages/router-core/tests/build-location.test.ts @@ -2,6 +2,148 @@ import { describe, expect, test, vi } from 'vitest' import { createMemoryHistory } from '@tanstack/history' import { BaseRootRoute, BaseRoute, RouterCore } from '../src' +describe('buildLocation - #6490 skipRouteOnParseError respects params.stringify', () => { + test('skipRouteOnParseError.params=true should match using stringified params', () => { + const rootRoute = new BaseRootRoute({}) + const langRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/$lang', + skipRouteOnParseError: { + params: true, + }, + params: { + parse: (rawParams) => { + if (rawParams.lang === 'en') { + return { lang: 'en-US' } + } + + if (rawParams.lang === 'pl') { + return { lang: 'pl-PL' } + } + + throw new Error('Invalid language') + }, + stringify: (params) => { + if (params.lang === 'en-US') { + return { lang: 'en' } + } + + if (params.lang === 'pl-PL') { + return { lang: 'pl' } + } + + return params + }, + }, + }) + const routeTree = rootRoute.addChildren([langRoute]) + + const router = new RouterCore({ + routeTree, + history: createMemoryHistory(), + }) + + const location = router.buildLocation({ + to: '/$lang', + params: { lang: 'en-US' }, + }) + + expect(location.pathname).toBe('/en') + }) + + test('skipRouteOnParseError.params=false should still stringify params', () => { + const rootRoute = new BaseRootRoute({}) + const langRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/$lang', + skipRouteOnParseError: { + params: false, + }, + params: { + parse: (rawParams) => { + if (rawParams.lang === 'en') { + return { lang: 'en-US' } + } + + if (rawParams.lang === 'pl') { + return { lang: 'pl-PL' } + } + + throw new Error('Invalid language') + }, + stringify: (params) => { + if (params.lang === 'en-US') { + return { lang: 'en' } + } + + if (params.lang === 'pl-PL') { + return { lang: 'pl' } + } + + return params + }, + }, + }) + const routeTree = rootRoute.addChildren([langRoute]) + + const router = new RouterCore({ + routeTree, + history: createMemoryHistory(), + }) + + const location = router.buildLocation({ + to: '/$lang', + params: { lang: 'en-US' }, + }) + + expect(location.pathname).toBe('/en') + }) + + test('falls back when prestringified params match a different route', () => { + const rootRoute = new BaseRootRoute({}) + const englishRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/en', + }) + const langRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/$lang', + skipRouteOnParseError: { + params: true, + }, + params: { + parse: (rawParams) => { + if (rawParams.lang === 'en') { + return { lang: 'en-US' } + } + + throw new Error('Invalid language') + }, + stringify: (params) => { + if (params.lang === 'en-US') { + return { lang: 'en' } + } + + return params + }, + }, + }) + const routeTree = rootRoute.addChildren([englishRoute, langRoute]) + + const router = new RouterCore({ + routeTree, + history: createMemoryHistory(), + }) + + const location = router.buildLocation({ + to: '/$lang', + params: { lang: 'en-US' }, + }) + + expect(location.pathname).toBe('/en-US') + }) +}) + describe('buildLocation - params function receives parsed params', () => { test('prev params should contain parsed params from route params.parse', async () => { const rootRoute = new BaseRootRoute({})