diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 6978b071ce..97753f4b34 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -860,6 +860,26 @@ type ParamExtractionState = { segment: number } +/** + * Decodes a splat match while preserving encoded slashes within each segment. + * + * Splat values can span multiple URL segments. Decoding the whole value at once + * would make `%2F` indistinguishable from the literal `/` separators already in + * the matched path, so each segment is decoded independently. + */ +function decodeSplatParam(value: string) { + return value + .split('/') + .map((part) => + decodeURIComponent( + // Decode each path segment, but keep encoded slashes inside a segment + // distinct from the real slashes that separate splat segments. + part.replace(/%2F/gi, (match) => `%25${match.slice(1)}`), + ), + ) + .join('/') +} + /** * This function is "resumable": * - the `leaf` input can contain `extract` and `rawParams` properties from a previous `extractParams` call @@ -950,7 +970,7 @@ function extractParams( currentPathIndex + (n.prefix?.length ?? 0), path.length - (n.suffix?.length ?? 0), ) - const splat = decodeURIComponent(value) + const splat = decodeSplatParam(value) // TODO: Deprecate * rawParams['*'] = splat rawParams._splat = splat @@ -1313,7 +1333,7 @@ function getNodeMatch( } const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex) bestFuzzy.rawParams ??= Object.create(null) - bestFuzzy.rawParams!['**'] = decodeURIComponent(splat) + bestFuzzy.rawParams!['**'] = decodeSplatParam(splat) return bestFuzzy } diff --git a/packages/router-core/tests/path.test.ts b/packages/router-core/tests/path.test.ts index 4e49412209..4780ca166e 100644 --- a/packages/router-core/tests/path.test.ts +++ b/packages/router-core/tests/path.test.ts @@ -758,6 +758,39 @@ describe('matchPathname', () => { _splat: 'tanner/sean/manuel', }, }, + { + name: 'should preserve encoded slashes inside splat path segments', + input: '/parts/make/Ford%2FNew%20Holland', + matchingOptions: { + to: '/parts/$', + }, + expectedMatchedParams: { + '*': 'make/Ford%2FNew Holland', + _splat: 'make/Ford%2FNew Holland', + }, + }, + { + name: 'should keep real splat separators distinct from encoded slashes', + input: '/parts/make/Ford/New%20Holland', + matchingOptions: { + to: '/parts/$', + }, + expectedMatchedParams: { + '*': 'make/Ford/New Holland', + _splat: 'make/Ford/New Holland', + }, + }, + { + name: 'should still decode other reserved characters inside splat path segments', + input: '/docs/page%23section/query%3Dvalue', + matchingOptions: { + to: '/docs/$', + }, + expectedMatchedParams: { + '*': 'page#section/query=value', + _splat: 'page#section/query=value', + }, + }, ])('$name', ({ input, matchingOptions, expectedMatchedParams }) => { expect(matchPathname(input, matchingOptions)).toStrictEqual( toNullObj(expectedMatchedParams), @@ -829,6 +862,21 @@ describe('matchPathname', () => { }) }) + describe('fuzzy matching', () => { + it('should preserve encoded slashes inside fuzzy catch-all params', () => { + expect( + matchPathname('/docs/make/Ford%2FNew%20Holland', { + to: '/docs', + fuzzy: true, + }), + ).toStrictEqual( + toNullObj({ + '**': 'make/Ford%2FNew Holland', + }), + ) + }) + }) + describe('named params (prefix + suffix)', () => { it.each([ {