From 4efe356dda6ec2aeb4dfd857235496f4ed3d0b3e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 22 Aug 2026 14:35:37 +0200 Subject: [PATCH 1/5] fix(router-core): match Unicode wildcard suffixes --- .../router-core/src/new-process-route-tree.ts | 22 ++++++++-- .../tests/new-process-route-tree.test.ts | 42 +++++++++++++++++++ 2 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 0baf693042..13b0ac6ecc 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -394,6 +394,15 @@ function sortDynamic( return 0 } +function getSuffixStart(value: string, suffix: string) { + const length = suffix.toLowerCase().length + let start = value.length - length + while (value.slice(start).toLowerCase().length > length) { + start++ + } + return start +} + function createStaticNode( fullPath: string, ): StaticSegmentNode { @@ -857,7 +866,7 @@ function extractParams( const n = node const value = path.substring( currentPathIndex + (n.prefix?.length ?? 0), - path.length - (n.suffix?.length ?? 0), + n.suffix ? getSuffixStart(path, n.suffix) : path.length, ) const splat = decodeURIComponent(value) // TODO: Deprecate * @@ -1061,9 +1070,14 @@ function getNodeMatch( } if (suffix) { if (isBeyondPath) continue - const end = parts.slice(index).join('/').slice(-suffix.length) - const casePart = segment.caseSensitive ? end : end.toLowerCase() - if (casePart !== suffix) continue + const end = parts.slice(index).join('/') + const suffixPart = end.slice(getSuffixStart(end, suffix)) + if ( + (segment.caseSensitive ? suffixPart : suffixPart.toLowerCase()) !== + suffix + ) { + continue + } } // wildcard matches consume the rest of the URL and cannot have children stack.push({ diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 451ab868c4..29329fbd5f 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -716,6 +716,48 @@ describe('findRouteMatch', () => { it('multi-segment wildcard w/ suffix', () => { const tree = makeTree(['/{$}/c/file']) expect(findRouteMatch('/a/b/c/file', tree)?.route.id).toBe('/{$}/c/file') + expect(findRouteMatch('/A/B/C/FILE', tree)?.route.id).toBe('/{$}/c/file') + expect(findRouteMatch('/c/file', tree)).toBeNull() + }) + it('matches wildcard suffixes with Unicode case folding', () => { + const tree = makeTree(['/a/{$}σ', '/b/{$}İ', '/c/{$}İx']) + expect(findRouteMatch('/a/xΣ', tree)?.route.id).toBe('/a/{$}σ') + expect(findRouteMatch('/b/İ', tree)?.route.id).toBe('/b/{$}İ') + const match = findRouteMatch('/b/xİ', tree) + expect(match?.route.id).toBe('/b/{$}İ') + expect(match?.rawParams).toEqual({ '*': 'x', _splat: 'x' }) + expect(findRouteMatch('/b/xi\u0307', tree)?.rawParams).toEqual({ + '*': 'x', + _splat: 'x', + }) + expect(findRouteMatch('/c/yİX', tree)?.rawParams).toEqual({ + '*': 'y', + _splat: 'y', + }) + + const decomposed = makeTree(['/d/{$}i\u0307']) + expect(findRouteMatch('/d/xİ', decomposed)?.rawParams).toEqual({ + '*': 'x', + _splat: 'x', + }) + expect(findRouteMatch('/d/İ', decomposed)?.rawParams).toEqual({ + '*': '', + _splat: '', + }) + expect( + findRouteMatch('/İ', makeTree(['/{$}i\u0307']))?.rawParams, + ).toEqual({ '*': '', _splat: '' }) + expect(findRouteMatch('/İ', makeTree(['/{$}a']))).toBeNull() + + const afterOptional = makeTree(['/e/{-$id}/{$}İ']) + expect(findRouteMatch('/e/value/xİ', afterOptional)?.rawParams).toEqual({ + id: 'value', + '*': 'x', + _splat: 'x', + }) + + const prioritized = makeTree(['/{$}A', '/{$}ba']) + expect(findRouteMatch('/ba', prioritized)?.route.id).toBe('/{$}ba') }) it('multi-segment wildcard w/ prefix and suffix', () => { const tree = makeTree(['/file{$}end']) From da071bb3956eea020b4d662634b11ba347671aac Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:43:47 +0000 Subject: [PATCH 2/5] ci: apply automated fixes --- e2e/react-start/basic/rsbuild.config.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/e2e/react-start/basic/rsbuild.config.ts b/e2e/react-start/basic/rsbuild.config.ts index e6ad170a60..23072b6c1b 100644 --- a/e2e/react-start/basic/rsbuild.config.ts +++ b/e2e/react-start/basic/rsbuild.config.ts @@ -7,10 +7,7 @@ const outDir = process.env.E2E_DIST_DIR ?? 'dist' const startModeConfig = getStartModeConfig() export default defineConfig({ - plugins: [ - pluginReact(), - tanstackStart(startModeConfig), - ], + plugins: [pluginReact(), tanstackStart(startModeConfig)], output: { distPath: { root: outDir, From 0d04894a05a2a436decf3cb1e4e5af398797b6bb Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 22 Aug 2026 16:00:56 +0200 Subject: [PATCH 3/5] refactor(router-core): clarify wildcard suffix boundaries --- .../router-core/src/new-process-route-tree.ts | 20 +++++++++++++------ .../tests/new-process-route-tree.test.ts | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 13b0ac6ecc..b0157e75c0 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -394,10 +394,14 @@ function sortDynamic( return 0 } -function getSuffixStart(value: string, suffix: string) { - const length = suffix.toLowerCase().length - let start = value.length - length - while (value.slice(start).toLowerCase().length > length) { +function getSuffixStart(value: string, suffix: string, caseSensitive: boolean) { + if (caseSensitive) { + return value.length - suffix.length + } + // Lowercasing can expand Unicode characters, changing the raw suffix length. + const foldedLength = suffix.length + let start = value.length - foldedLength + while (value.slice(start).toLowerCase().length > foldedLength) { start++ } return start @@ -866,7 +870,9 @@ function extractParams( const n = node const value = path.substring( currentPathIndex + (n.prefix?.length ?? 0), - n.suffix ? getSuffixStart(path, n.suffix) : path.length, + n.suffix + ? getSuffixStart(path, n.suffix, n.caseSensitive) + : path.length, ) const splat = decodeURIComponent(value) // TODO: Deprecate * @@ -1071,7 +1077,9 @@ function getNodeMatch( if (suffix) { if (isBeyondPath) continue const end = parts.slice(index).join('/') - const suffixPart = end.slice(getSuffixStart(end, suffix)) + const suffixPart = end.slice( + getSuffixStart(end, suffix, segment.caseSensitive), + ) if ( (segment.caseSensitive ? suffixPart : suffixPart.toLowerCase()) !== suffix diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 29329fbd5f..92941c4853 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -758,6 +758,26 @@ describe('findRouteMatch', () => { const prioritized = makeTree(['/{$}A', '/{$}ba']) expect(findRouteMatch('/ba', prioritized)?.route.id).toBe('/{$}ba') + + const sensitive = processRouteTree({ + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/f/{$}İ', + fullPath: '/f/{$}İ', + path: '/f/{$}İ', + options: { caseSensitive: true }, + }, + ], + }).processedTree + expect(findRouteMatch('/f/xİ', sensitive)?.rawParams).toEqual({ + '*': 'x', + _splat: 'x', + }) + expect(findRouteMatch('/f/xi\u0307', sensitive)).toBeNull() }) it('multi-segment wildcard w/ prefix and suffix', () => { const tree = makeTree(['/file{$}end']) From e8c829ccf7952e3724a42df948c98161e4ac5403 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 22 Aug 2026 17:04:28 +0200 Subject: [PATCH 4/5] fix(router-core): handle Unicode parameter affixes --- .../router-core/src/new-process-route-tree.ts | 99 +++++++--- .../tests/new-process-route-tree.test.ts | 175 ++++++++++++++++++ 2 files changed, 244 insertions(+), 30 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index b0157e75c0..6099a99c59 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -394,19 +394,48 @@ function sortDynamic( return 0 } +// Case folding can expand Unicode characters, so folded affix lengths cannot +// be used as offsets into the raw URL. +function getPrefixEnd(value: string, prefix: string, caseSensitive: boolean) { + if (caseSensitive) { + return prefix.length + } + + const foldedLength = prefix.length + let end = Math.min(value.length, foldedLength) + while (value.slice(0, end).toLowerCase().length > foldedLength) { + end-- + } + return end +} + function getSuffixStart(value: string, suffix: string, caseSensitive: boolean) { if (caseSensitive) { return value.length - suffix.length } - // Lowercasing can expand Unicode characters, changing the raw suffix length. + const foldedLength = suffix.length - let start = value.length - foldedLength + let start = Math.max(0, value.length - foldedLength) while (value.slice(start).toLowerCase().length > foldedLength) { start++ } return start } +function affixesOverlap( + value: string, + prefix: string | undefined, + suffix: string | undefined, + caseSensitive: boolean, +) { + return ( + !!prefix && + !!suffix && + getPrefixEnd(value, prefix, caseSensitive) > + getSuffixStart(value, suffix, caseSensitive) + ) +} + function createStaticNode( fullPath: string, ): StaticSegmentNode { @@ -831,22 +860,22 @@ function extractParams( if (node.kind === SEGMENT_TYPE_PARAM) { nodeParts ??= leaf.node.fullPath.split('/') const nodePart = nodeParts[segmentCount]! - const preLength = node.prefix?.length ?? 0 - // we can't rely on the presence of prefix/suffix to know whether it's curly-braced or not, because `/{$param}/` is valid, but has no prefix/suffix - const isCurlyBraced = nodePart.charCodeAt(preLength) === 123 // '{' + const openBrace = + nodePart.charCodeAt(0) === 36 ? -1 : nodePart.indexOf('{') // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node - if (isCurlyBraced) { - const sufLength = node.suffix?.length ?? 0 - const name = nodePart.substring( - preLength + 2, - nodePart.length - sufLength - 1, - ) - const value = part!.substring(preLength, part!.length - sufLength) - rawParams[name] = decodeURIComponent(value) - } else { - const name = nodePart.substring(1) - rawParams[name] = decodeURIComponent(part!) - } + const name = + openBrace === -1 + ? nodePart.substring(1) + : nodePart.substring(openBrace + 2, nodePart.indexOf('}', openBrace)) + const prefixEnd = node.prefix + ? getPrefixEnd(part!, node.prefix, node.caseSensitive) + : 0 + const suffixStart = node.suffix + ? getSuffixStart(part!, node.suffix, node.caseSensitive) + : part!.length + rawParams[name] = decodeURIComponent( + part!.substring(prefixEnd, suffixStart), + ) } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { if (leaf.skipped & (1 << nodeIndex)) { partIndex-- // stay on the same part @@ -855,24 +884,27 @@ function extractParams( } nodeParts ??= leaf.node.fullPath.split('/') const nodePart = nodeParts[segmentCount]! - const preLength = node.prefix?.length ?? 0 - const sufLength = node.suffix?.length ?? 0 + const openBrace = nodePart.indexOf('{') const name = nodePart.substring( - preLength + 3, - nodePart.length - sufLength - 1, + openBrace + 3, + nodePart.indexOf('}', openBrace), ) - const value = - node.suffix || node.prefix - ? part!.substring(preLength, part!.length - sufLength) - : part + const prefixEnd = node.prefix + ? getPrefixEnd(part!, node.prefix, node.caseSensitive) + : 0 + const suffixStart = node.suffix + ? getSuffixStart(part!, node.suffix, node.caseSensitive) + : part!.length + const value = part!.substring(prefixEnd, suffixStart) if (value) rawParams[name] = decodeURIComponent(value) } else if (node.kind === SEGMENT_TYPE_WILDCARD) { const n = node - const value = path.substring( - currentPathIndex + (n.prefix?.length ?? 0), + const remaining = path.substring(currentPathIndex) + const value = remaining.substring( + n.prefix ? getPrefixEnd(remaining, n.prefix, n.caseSensitive) : 0, n.suffix - ? getSuffixStart(path, n.suffix, n.caseSensitive) - : path.length, + ? getSuffixStart(remaining, n.suffix, n.caseSensitive) + : remaining.length, ) const splat = decodeURIComponent(value) // TODO: Deprecate * @@ -1082,7 +1114,8 @@ function getNodeMatch( ) if ( (segment.caseSensitive ? suffixPart : suffixPart.toLowerCase()) !== - suffix + suffix || + affixesOverlap(end, prefix, suffix, segment.caseSensitive) ) { continue } @@ -1129,6 +1162,9 @@ function getNodeMatch( : (lowerPart ??= part!.toLowerCase()) if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue + if (affixesOverlap(part!, prefix, suffix, segment.caseSensitive)) { + continue + } } stack.push({ node: segment, @@ -1155,6 +1191,9 @@ function getNodeMatch( : (lowerPart ??= part.toLowerCase()) if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue + if (affixesOverlap(part, prefix, suffix, segment.caseSensitive)) { + continue + } } stack.push({ node: segment, diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 92941c4853..429737ed6f 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -779,6 +779,181 @@ describe('findRouteMatch', () => { }) expect(findRouteMatch('/f/xi\u0307', sensitive)).toBeNull() }) + it('matches and extracts Unicode case-folded parameter affixes', () => { + const wildcard = makeTree(['/w/İ{$}İ']) + expect(findRouteMatch('/w/İa/bİ', wildcard)?.rawParams).toEqual({ + '*': 'a/b', + _splat: 'a/b', + }) + expect( + findRouteMatch('/w/i\u0307a/bi\u0307', wildcard)?.rawParams, + ).toEqual({ + '*': 'a/b', + _splat: 'a/b', + }) + expect(findRouteMatch('/w/İa/bi\u0307', wildcard)?.rawParams).toEqual({ + '*': 'a/b', + _splat: 'a/b', + }) + expect(findRouteMatch('/w/İİ', wildcard)?.rawParams).toEqual({ + '*': '', + _splat: '', + }) + expect(findRouteMatch('/w/İ', wildcard)).toBeNull() + expect(findRouteMatch('/w/xvalueİ', wildcard)).toBeNull() + expect(findRouteMatch('/w/İvaluex', wildcard)).toBeNull() + + const wildcardPrefix = makeTree(['/wp/İ{$}']) + expect(findRouteMatch('/wp/İa/b', wildcardPrefix)?.rawParams).toEqual({ + '*': 'a/b', + _splat: 'a/b', + }) + expect( + findRouteMatch('/wp/i\u0307a/b', wildcardPrefix)?.rawParams, + ).toEqual({ '*': 'a/b', _splat: 'a/b' }) + + const required = makeTree(['/d/İ{$id}İ']) + expect(findRouteMatch('/d/İvalueİ', required)?.rawParams).toEqual({ + id: 'value', + }) + expect( + findRouteMatch('/d/i\u0307valuei\u0307', required)?.rawParams, + ).toEqual({ id: 'value' }) + expect(findRouteMatch('/d/ivalueİ', required)).toBeNull() + expect(findRouteMatch('/d/İvaluei', required)).toBeNull() + expect(findRouteMatch('/d/İİ', required)?.rawParams).toEqual({ id: '' }) + expect(findRouteMatch('/d/İ😀İ', required)?.rawParams).toEqual({ + id: '😀', + }) + expect(findRouteMatch('/d/İ', required)).toBeNull() + + const requiredPrefix = makeTree(['/dp/İ{$id}']) + expect(findRouteMatch('/dp/İvalue', requiredPrefix)?.rawParams).toEqual({ + id: 'value', + }) + expect( + findRouteMatch('/dp/i\u0307value', requiredPrefix)?.rawParams, + ).toEqual({ id: 'value' }) + + const requiredSuffix = makeTree(['/ds/{$id}İ']) + expect(findRouteMatch('/ds/valueİ', requiredSuffix)?.rawParams).toEqual({ + id: 'value', + }) + expect( + findRouteMatch('/ds/valuei\u0307', requiredSuffix)?.rawParams, + ).toEqual({ id: 'value' }) + + const optional = makeTree(['/o/İ{-$id}İ']) + expect(findRouteMatch('/o/İvalueİ', optional)?.rawParams).toEqual({ + id: 'value', + }) + expect( + findRouteMatch('/o/i\u0307valuei\u0307', optional)?.rawParams, + ).toEqual({ id: 'value' }) + expect(findRouteMatch('/o/İİ', optional)?.rawParams).toEqual({}) + expect(findRouteMatch('/o/İ', optional)).toBeNull() + expect(findRouteMatch('/o/xvalueİ', optional)).toBeNull() + expect(findRouteMatch('/o/İvaluex', optional)).toBeNull() + + const optionalPrefix = makeTree(['/op/İ{-$id}']) + expect(findRouteMatch('/op/İvalue', optionalPrefix)?.rawParams).toEqual({ + id: 'value', + }) + expect( + findRouteMatch('/op/i\u0307value', optionalPrefix)?.rawParams, + ).toEqual({ id: 'value' }) + + const optionalSuffix = makeTree(['/os/{-$id}İ']) + expect(findRouteMatch('/os/valueİ', optionalSuffix)?.rawParams).toEqual({ + id: 'value', + }) + expect( + findRouteMatch('/os/valuei\u0307', optionalSuffix)?.rawParams, + ).toEqual({ id: 'value' }) + + const decomposed = makeTree(['/p/i\u0307{$id}i\u0307']) + expect(findRouteMatch('/p/İvalueİ', decomposed)?.rawParams).toEqual({ + id: 'value', + }) + + const contextual = makeTree(['/c/aσ{$id}', '/s/{$id}aς', '/t/{-$id}aς']) + expect(findRouteMatch('/c/AΣvalue', contextual)?.rawParams).toEqual({ + id: 'value', + }) + expect(findRouteMatch('/s/valueAΣ', contextual)?.rawParams).toEqual({ + id: 'value', + }) + expect(findRouteMatch('/t/valueAΣ', contextual)?.rawParams).toEqual({ + id: 'value', + }) + + const shared = makeTree([ + '/q/İ{$first}İ/one', + '/q/i\u0307{$second}i\u0307/two', + ]) + expect(findRouteMatch('/q/İvalueİ/two', shared)?.rawParams).toEqual({ + second: 'value', + }) + + const afterSkippedOptional = makeTree(['/e/{-$id}/İ{$}İ']) + expect( + findRouteMatch('/e/İvalueİ', afterSkippedOptional)?.rawParams, + ).toEqual({ + '*': 'value', + _splat: 'value', + }) + }) + it('uses raw affix lengths for case-sensitive parameters', () => { + const sensitive = processRouteTree({ + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/w/İ{$}İ', + fullPath: '/w/İ{$}İ', + path: '/w/İ{$}İ', + options: { caseSensitive: true }, + }, + { + id: '/d/İ{$id}İ', + fullPath: '/d/İ{$id}İ', + path: '/d/İ{$id}İ', + options: { caseSensitive: true }, + }, + { + id: '/o/İ{-$id}İ', + fullPath: '/o/İ{-$id}İ', + path: '/o/İ{-$id}İ', + options: { caseSensitive: true }, + }, + ], + }).processedTree + + expect(findRouteMatch('/w/İvalueİ', sensitive)?.rawParams).toEqual({ + '*': 'value', + _splat: 'value', + }) + expect(findRouteMatch('/d/İvalueİ', sensitive)?.rawParams).toEqual({ + id: 'value', + }) + expect(findRouteMatch('/o/İvalueİ', sensitive)?.rawParams).toEqual({ + id: 'value', + }) + expect(findRouteMatch('/w/İİ', sensitive)?.rawParams).toEqual({ + '*': '', + _splat: '', + }) + expect(findRouteMatch('/d/İİ', sensitive)?.rawParams).toEqual({ id: '' }) + expect(findRouteMatch('/o/İİ', sensitive)?.rawParams).toEqual({}) + expect(findRouteMatch('/w/İ', sensitive)).toBeNull() + expect(findRouteMatch('/d/İ', sensitive)).toBeNull() + expect(findRouteMatch('/o/İ', sensitive)).toBeNull() + expect(findRouteMatch('/w/i\u0307valuei\u0307', sensitive)).toBeNull() + expect(findRouteMatch('/d/i\u0307valuei\u0307', sensitive)).toBeNull() + expect(findRouteMatch('/o/i\u0307valuei\u0307', sensitive)).toBeNull() + }) it('multi-segment wildcard w/ prefix and suffix', () => { const tree = makeTree(['/file{$}end']) expect(findRouteMatch('/file/a/b/c/end', tree)?.route.id).toBe( From 32104f68bec16c6b74df530c92e3981a32405c2e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sat, 22 Aug 2026 17:52:21 +0200 Subject: [PATCH 5/5] test(router-core): document U+0130 affix limitation --- .../router-core/src/new-process-route-tree.ts | 119 +++------ .../tests/new-process-route-tree.test.ts | 237 +----------------- 2 files changed, 34 insertions(+), 322 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 6099a99c59..0baf693042 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -394,48 +394,6 @@ function sortDynamic( return 0 } -// Case folding can expand Unicode characters, so folded affix lengths cannot -// be used as offsets into the raw URL. -function getPrefixEnd(value: string, prefix: string, caseSensitive: boolean) { - if (caseSensitive) { - return prefix.length - } - - const foldedLength = prefix.length - let end = Math.min(value.length, foldedLength) - while (value.slice(0, end).toLowerCase().length > foldedLength) { - end-- - } - return end -} - -function getSuffixStart(value: string, suffix: string, caseSensitive: boolean) { - if (caseSensitive) { - return value.length - suffix.length - } - - const foldedLength = suffix.length - let start = Math.max(0, value.length - foldedLength) - while (value.slice(start).toLowerCase().length > foldedLength) { - start++ - } - return start -} - -function affixesOverlap( - value: string, - prefix: string | undefined, - suffix: string | undefined, - caseSensitive: boolean, -) { - return ( - !!prefix && - !!suffix && - getPrefixEnd(value, prefix, caseSensitive) > - getSuffixStart(value, suffix, caseSensitive) - ) -} - function createStaticNode( fullPath: string, ): StaticSegmentNode { @@ -860,22 +818,22 @@ function extractParams( if (node.kind === SEGMENT_TYPE_PARAM) { nodeParts ??= leaf.node.fullPath.split('/') const nodePart = nodeParts[segmentCount]! - const openBrace = - nodePart.charCodeAt(0) === 36 ? -1 : nodePart.indexOf('{') + const preLength = node.prefix?.length ?? 0 + // we can't rely on the presence of prefix/suffix to know whether it's curly-braced or not, because `/{$param}/` is valid, but has no prefix/suffix + const isCurlyBraced = nodePart.charCodeAt(preLength) === 123 // '{' // param name is extracted at match-time so that tree nodes that are identical except for param name can share the same node - const name = - openBrace === -1 - ? nodePart.substring(1) - : nodePart.substring(openBrace + 2, nodePart.indexOf('}', openBrace)) - const prefixEnd = node.prefix - ? getPrefixEnd(part!, node.prefix, node.caseSensitive) - : 0 - const suffixStart = node.suffix - ? getSuffixStart(part!, node.suffix, node.caseSensitive) - : part!.length - rawParams[name] = decodeURIComponent( - part!.substring(prefixEnd, suffixStart), - ) + if (isCurlyBraced) { + const sufLength = node.suffix?.length ?? 0 + const name = nodePart.substring( + preLength + 2, + nodePart.length - sufLength - 1, + ) + const value = part!.substring(preLength, part!.length - sufLength) + rawParams[name] = decodeURIComponent(value) + } else { + const name = nodePart.substring(1) + rawParams[name] = decodeURIComponent(part!) + } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { if (leaf.skipped & (1 << nodeIndex)) { partIndex-- // stay on the same part @@ -884,27 +842,22 @@ function extractParams( } nodeParts ??= leaf.node.fullPath.split('/') const nodePart = nodeParts[segmentCount]! - const openBrace = nodePart.indexOf('{') + const preLength = node.prefix?.length ?? 0 + const sufLength = node.suffix?.length ?? 0 const name = nodePart.substring( - openBrace + 3, - nodePart.indexOf('}', openBrace), + preLength + 3, + nodePart.length - sufLength - 1, ) - const prefixEnd = node.prefix - ? getPrefixEnd(part!, node.prefix, node.caseSensitive) - : 0 - const suffixStart = node.suffix - ? getSuffixStart(part!, node.suffix, node.caseSensitive) - : part!.length - const value = part!.substring(prefixEnd, suffixStart) + const value = + node.suffix || node.prefix + ? part!.substring(preLength, part!.length - sufLength) + : part if (value) rawParams[name] = decodeURIComponent(value) } else if (node.kind === SEGMENT_TYPE_WILDCARD) { const n = node - const remaining = path.substring(currentPathIndex) - const value = remaining.substring( - n.prefix ? getPrefixEnd(remaining, n.prefix, n.caseSensitive) : 0, - n.suffix - ? getSuffixStart(remaining, n.suffix, n.caseSensitive) - : remaining.length, + const value = path.substring( + currentPathIndex + (n.prefix?.length ?? 0), + path.length - (n.suffix?.length ?? 0), ) const splat = decodeURIComponent(value) // TODO: Deprecate * @@ -1108,17 +1061,9 @@ function getNodeMatch( } if (suffix) { if (isBeyondPath) continue - const end = parts.slice(index).join('/') - const suffixPart = end.slice( - getSuffixStart(end, suffix, segment.caseSensitive), - ) - if ( - (segment.caseSensitive ? suffixPart : suffixPart.toLowerCase()) !== - suffix || - affixesOverlap(end, prefix, suffix, segment.caseSensitive) - ) { - continue - } + const end = parts.slice(index).join('/').slice(-suffix.length) + const casePart = segment.caseSensitive ? end : end.toLowerCase() + if (casePart !== suffix) continue } // wildcard matches consume the rest of the URL and cannot have children stack.push({ @@ -1162,9 +1107,6 @@ function getNodeMatch( : (lowerPart ??= part!.toLowerCase()) if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue - if (affixesOverlap(part!, prefix, suffix, segment.caseSensitive)) { - continue - } } stack.push({ node: segment, @@ -1191,9 +1133,6 @@ function getNodeMatch( : (lowerPart ??= part.toLowerCase()) if (prefix && !casePart.startsWith(prefix)) continue if (suffix && !casePart.endsWith(suffix)) continue - if (affixesOverlap(part, prefix, suffix, segment.caseSensitive)) { - continue - } } stack.push({ node: segment, diff --git a/packages/router-core/tests/new-process-route-tree.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 429737ed6f..1240207c5e 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -716,243 +716,16 @@ describe('findRouteMatch', () => { it('multi-segment wildcard w/ suffix', () => { const tree = makeTree(['/{$}/c/file']) expect(findRouteMatch('/a/b/c/file', tree)?.route.id).toBe('/{$}/c/file') - expect(findRouteMatch('/A/B/C/FILE', tree)?.route.id).toBe('/{$}/c/file') - expect(findRouteMatch('/c/file', tree)).toBeNull() }) - it('matches wildcard suffixes with Unicode case folding', () => { - const tree = makeTree(['/a/{$}σ', '/b/{$}İ', '/c/{$}İx']) - expect(findRouteMatch('/a/xΣ', tree)?.route.id).toBe('/a/{$}σ') - expect(findRouteMatch('/b/İ', tree)?.route.id).toBe('/b/{$}İ') - const match = findRouteMatch('/b/xİ', tree) - expect(match?.route.id).toBe('/b/{$}İ') - expect(match?.rawParams).toEqual({ '*': 'x', _splat: 'x' }) - expect(findRouteMatch('/b/xi\u0307', tree)?.rawParams).toEqual({ - '*': 'x', - _splat: 'x', - }) - expect(findRouteMatch('/c/yİX', tree)?.rawParams).toEqual({ - '*': 'y', - _splat: 'y', - }) - - const decomposed = makeTree(['/d/{$}i\u0307']) - expect(findRouteMatch('/d/xİ', decomposed)?.rawParams).toEqual({ - '*': 'x', - _splat: 'x', - }) - expect(findRouteMatch('/d/İ', decomposed)?.rawParams).toEqual({ - '*': '', - _splat: '', - }) - expect( - findRouteMatch('/İ', makeTree(['/{$}i\u0307']))?.rawParams, - ).toEqual({ '*': '', _splat: '' }) - expect(findRouteMatch('/İ', makeTree(['/{$}a']))).toBeNull() - - const afterOptional = makeTree(['/e/{-$id}/{$}İ']) - expect(findRouteMatch('/e/value/xİ', afterOptional)?.rawParams).toEqual({ - id: 'value', - '*': 'x', - _splat: 'x', - }) - - const prioritized = makeTree(['/{$}A', '/{$}ba']) - expect(findRouteMatch('/ba', prioritized)?.route.id).toBe('/{$}ba') - - const sensitive = processRouteTree({ - id: '__root__', - isRoot: true, - fullPath: '/', - path: '/', - children: [ - { - id: '/f/{$}İ', - fullPath: '/f/{$}İ', - path: '/f/{$}İ', - options: { caseSensitive: true }, - }, - ], - }).processedTree - expect(findRouteMatch('/f/xİ', sensitive)?.rawParams).toEqual({ - '*': 'x', - _splat: 'x', - }) - expect(findRouteMatch('/f/xi\u0307', sensitive)).toBeNull() - }) - it('matches and extracts Unicode case-folded parameter affixes', () => { - const wildcard = makeTree(['/w/İ{$}İ']) - expect(findRouteMatch('/w/İa/bİ', wildcard)?.rawParams).toEqual({ - '*': 'a/b', - _splat: 'a/b', - }) - expect( - findRouteMatch('/w/i\u0307a/bi\u0307', wildcard)?.rawParams, - ).toEqual({ - '*': 'a/b', - _splat: 'a/b', - }) - expect(findRouteMatch('/w/İa/bi\u0307', wildcard)?.rawParams).toEqual({ - '*': 'a/b', - _splat: 'a/b', - }) - expect(findRouteMatch('/w/İİ', wildcard)?.rawParams).toEqual({ - '*': '', - _splat: '', - }) - expect(findRouteMatch('/w/İ', wildcard)).toBeNull() - expect(findRouteMatch('/w/xvalueİ', wildcard)).toBeNull() - expect(findRouteMatch('/w/İvaluex', wildcard)).toBeNull() - - const wildcardPrefix = makeTree(['/wp/İ{$}']) - expect(findRouteMatch('/wp/İa/b', wildcardPrefix)?.rawParams).toEqual({ - '*': 'a/b', - _splat: 'a/b', - }) - expect( - findRouteMatch('/wp/i\u0307a/b', wildcardPrefix)?.rawParams, - ).toEqual({ '*': 'a/b', _splat: 'a/b' }) - - const required = makeTree(['/d/İ{$id}İ']) - expect(findRouteMatch('/d/İvalueİ', required)?.rawParams).toEqual({ - id: 'value', - }) - expect( - findRouteMatch('/d/i\u0307valuei\u0307', required)?.rawParams, - ).toEqual({ id: 'value' }) - expect(findRouteMatch('/d/ivalueİ', required)).toBeNull() - expect(findRouteMatch('/d/İvaluei', required)).toBeNull() - expect(findRouteMatch('/d/İİ', required)?.rawParams).toEqual({ id: '' }) - expect(findRouteMatch('/d/İ😀İ', required)?.rawParams).toEqual({ - id: '😀', - }) - expect(findRouteMatch('/d/İ', required)).toBeNull() - - const requiredPrefix = makeTree(['/dp/İ{$id}']) - expect(findRouteMatch('/dp/İvalue', requiredPrefix)?.rawParams).toEqual({ - id: 'value', - }) - expect( - findRouteMatch('/dp/i\u0307value', requiredPrefix)?.rawParams, - ).toEqual({ id: 'value' }) - - const requiredSuffix = makeTree(['/ds/{$id}İ']) - expect(findRouteMatch('/ds/valueİ', requiredSuffix)?.rawParams).toEqual({ - id: 'value', - }) - expect( - findRouteMatch('/ds/valuei\u0307', requiredSuffix)?.rawParams, - ).toEqual({ id: 'value' }) - - const optional = makeTree(['/o/İ{-$id}İ']) - expect(findRouteMatch('/o/İvalueİ', optional)?.rawParams).toEqual({ - id: 'value', - }) - expect( - findRouteMatch('/o/i\u0307valuei\u0307', optional)?.rawParams, - ).toEqual({ id: 'value' }) - expect(findRouteMatch('/o/İİ', optional)?.rawParams).toEqual({}) - expect(findRouteMatch('/o/İ', optional)).toBeNull() - expect(findRouteMatch('/o/xvalueİ', optional)).toBeNull() - expect(findRouteMatch('/o/İvaluex', optional)).toBeNull() - - const optionalPrefix = makeTree(['/op/İ{-$id}']) - expect(findRouteMatch('/op/İvalue', optionalPrefix)?.rawParams).toEqual({ - id: 'value', - }) - expect( - findRouteMatch('/op/i\u0307value', optionalPrefix)?.rawParams, - ).toEqual({ id: 'value' }) - - const optionalSuffix = makeTree(['/os/{-$id}İ']) - expect(findRouteMatch('/os/valueİ', optionalSuffix)?.rawParams).toEqual({ - id: 'value', - }) - expect( - findRouteMatch('/os/valuei\u0307', optionalSuffix)?.rawParams, - ).toEqual({ id: 'value' }) - - const decomposed = makeTree(['/p/i\u0307{$id}i\u0307']) - expect(findRouteMatch('/p/İvalueİ', decomposed)?.rawParams).toEqual({ - id: 'value', - }) - const contextual = makeTree(['/c/aσ{$id}', '/s/{$id}aς', '/t/{-$id}aς']) - expect(findRouteMatch('/c/AΣvalue', contextual)?.rawParams).toEqual({ - id: 'value', - }) - expect(findRouteMatch('/s/valueAΣ', contextual)?.rawParams).toEqual({ - id: 'value', - }) - expect(findRouteMatch('/t/valueAΣ', contextual)?.rawParams).toEqual({ - id: 'value', - }) - - const shared = makeTree([ - '/q/İ{$first}İ/one', - '/q/i\u0307{$second}i\u0307/two', - ]) - expect(findRouteMatch('/q/İvalueİ/two', shared)?.rawParams).toEqual({ - second: 'value', - }) - - const afterSkippedOptional = makeTree(['/e/{-$id}/İ{$}İ']) - expect( - findRouteMatch('/e/İvalueİ', afterSkippedOptional)?.rawParams, - ).toEqual({ - '*': 'value', - _splat: 'value', - }) - }) - it('uses raw affix lengths for case-sensitive parameters', () => { - const sensitive = processRouteTree({ - id: '__root__', - isRoot: true, - fullPath: '/', - path: '/', - children: [ - { - id: '/w/İ{$}İ', - fullPath: '/w/İ{$}İ', - path: '/w/İ{$}İ', - options: { caseSensitive: true }, - }, - { - id: '/d/İ{$id}İ', - fullPath: '/d/İ{$id}İ', - path: '/d/İ{$id}İ', - options: { caseSensitive: true }, - }, - { - id: '/o/İ{-$id}İ', - fullPath: '/o/İ{-$id}İ', - path: '/o/İ{-$id}İ', - options: { caseSensitive: true }, - }, - ], - }).processedTree - - expect(findRouteMatch('/w/İvalueİ', sensitive)?.rawParams).toEqual({ + it.fails('matches U+0130 wildcard suffixes case-insensitively', () => { + // U+0130 is currently the only character whose default lowercase mapping + // changes UTF-16 length, so its folded length cannot index the raw URL. + const tree = makeTree(['/{$}İ']) + expect(findRouteMatch('/valueİ', tree)?.rawParams).toEqual({ '*': 'value', _splat: 'value', }) - expect(findRouteMatch('/d/İvalueİ', sensitive)?.rawParams).toEqual({ - id: 'value', - }) - expect(findRouteMatch('/o/İvalueİ', sensitive)?.rawParams).toEqual({ - id: 'value', - }) - expect(findRouteMatch('/w/İİ', sensitive)?.rawParams).toEqual({ - '*': '', - _splat: '', - }) - expect(findRouteMatch('/d/İİ', sensitive)?.rawParams).toEqual({ id: '' }) - expect(findRouteMatch('/o/İİ', sensitive)?.rawParams).toEqual({}) - expect(findRouteMatch('/w/İ', sensitive)).toBeNull() - expect(findRouteMatch('/d/İ', sensitive)).toBeNull() - expect(findRouteMatch('/o/İ', sensitive)).toBeNull() - expect(findRouteMatch('/w/i\u0307valuei\u0307', sensitive)).toBeNull() - expect(findRouteMatch('/d/i\u0307valuei\u0307', sensitive)).toBeNull() - expect(findRouteMatch('/o/i\u0307valuei\u0307', sensitive)).toBeNull() }) it('multi-segment wildcard w/ prefix and suffix', () => { const tree = makeTree(['/file{$}end'])