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
24 changes: 22 additions & 2 deletions packages/router-core/src/new-process-route-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -950,7 +970,7 @@ function extractParams<T extends RouteLike>(
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
Expand Down Expand Up @@ -1313,7 +1333,7 @@ function getNodeMatch<T extends RouteLike>(
}
const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex)
bestFuzzy.rawParams ??= Object.create(null)
bestFuzzy.rawParams!['**'] = decodeURIComponent(splat)
bestFuzzy.rawParams!['**'] = decodeSplatParam(splat)
return bestFuzzy
}

Expand Down
48 changes: 48 additions & 0 deletions packages/router-core/tests/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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([
{
Expand Down