diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index ba6244a4d9..2ea27bad1e 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 852e8d324f..896469dcd2 100644 --- a/packages/router-core/src/router.ts +++ b/packages/router-core/src/router.ts @@ -12,6 +12,7 @@ import { replaceEqualDeep, } from './utils' import { + buildRouteBranch, findFlatMatch, findRouteMatch, findSingleMatch, @@ -1775,10 +1776,35 @@ 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) + if ( + routeBranch.some( + (route) => route.options.skipRouteOnParseError?.params, + ) + ) { + prestringifiedParams = { ...nextParams } + for (const route of routeBranch) { + const fn = + route.options.params?.stringify ?? route.options.stringifyParams + if (fn) { + Object.assign(prestringifiedParams!, fn(prestringifiedParams)) + } + } + } + } + + // 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, }).interpolatedPath // Use lightweight getMatchedRoutes instead of matchRoutesInternal @@ -1786,6 +1812,12 @@ export class RouterCore< // which are expensive and not needed for buildLocation const destMatchResult = this.getMatchedRoutes(interpolatedNextTo) let destRoutes = destMatchResult.matchedRoutes + if ( + !destMatchResult.foundRoute || + destMatchResult.foundRoute.fullPath !== trimmedNextTo + ) { + prestringifiedParams = null + } // Compute globalNotFoundRouteId using the same logic as matchRoutesInternal const isGlobalNotFound = destMatchResult.foundRoute @@ -1806,12 +1838,14 @@ export class RouterCore< } // If there are any params, we need to stringify them - if (Object.keys(nextParams).length > 0) { + let stringifiedParams = prestringifiedParams + if (!stringifiedParams && Object.keys(nextParams).length > 0) { + stringifiedParams = nextParams for (const route of destRoutes) { const fn = route.options.params?.stringify ?? route.options.stringifyParams if (fn) { - Object.assign(nextParams, fn(nextParams)) + Object.assign(stringifiedParams!, fn(stringifiedParams)) } } } @@ -1821,11 +1855,13 @@ export class RouterCore< // This preserves the original parameter syntax including optional parameters nextTo : decodePath( - interpolatePath({ - path: nextTo, - params: nextParams, - decoder: this.pathParamsDecoder, - }).interpolatedPath, + prestringifiedParams + ? interpolatedNextTo + : interpolatePath({ + path: nextTo, + params: stringifiedParams ?? {}, + decoder: this.pathParamsDecoder, + }).interpolatedPath, ) // Resolve the next search diff --git a/packages/router-core/tests/build-location.test.ts b/packages/router-core/tests/build-location.test.ts new file mode 100644 index 0000000000..935955df96 --- /dev/null +++ b/packages/router-core/tests/build-location.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { BaseRootRoute, BaseRoute, RouterCore } from '../src' + +describe('buildLocation', () => { + describe('#6490 - skipRouteOnParseError respects params.stringify', () => { + test('skipRouteOnParseError is true', () => { + 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 is false', () => { + 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') + }) + }) +})