Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/early-static-route-matches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/router-core': patch
---

Fix route matching specificity so routes with the same number of static segments prefer the match whose static segment occurs earlier, including when competing with wildcard routes.
52 changes: 40 additions & 12 deletions packages/router-core/src/new-process-route-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,10 @@ function buildBranch<T extends RouteLike>(node: AnySegmentNode<T>) {
return list
}

function appendStaticMask(staticMask: number, index: number) {
return (staticMask | (1 << index)) >>> 0
}
Comment on lines +1013 to +1015
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In JavaScript, are bitwise left-shift counts masked modulo 32? What do 1 << 31, 1 << 32, and 1 << 33 evaluate to?

💡 Result:

Yes, in JavaScript, bitwise left-shift (<<) counts are masked to the lowest 5 bits (modulo 32). The right operand is converted to a uint32 and then shiftCount = rNum % 32, so shifts >=32 wrap around: 32 becomes 0, 33 becomes 1, etc. 1 << 31 evaluates to -2147483648 (0x80000000 in two's complement signed 32-bit int). 1 << 32 evaluates to 1 (equivalent to 1 << 0). 1 << 33 evaluates to 2 (equivalent to 1 << 1).

Citations:


🏁 Script executed:

# First, check the file exists and read the relevant section
cd packages/router-core/src 2>/dev/null || cd . 
if [ -f "new-process-route-tree.ts" ]; then
  echo "=== Lines 1010-1020 (appendStaticMask function) ==="
  sed -n '1010,1020p' new-process-route-tree.ts
  echo ""
  echo "=== Lines 1025-1040 (MatchStackFrame definition) ==="
  sed -n '1025,1040p' new-process-route-tree.ts
  echo ""
  echo "=== Lines 1290-1320 (static mask usage) ==="
  sed -n '1290,1320p' new-process-route-tree.ts
  echo ""
  echo "=== Lines 1390-1415 (isFrameMoreSpecific) ==="
  sed -n '1390,1415p' new-process-route-tree.ts
else
  echo "File not found in current directory, searching..."
  find . -name "new-process-route-tree.ts" 2>/dev/null
fi

Repository: TanStack/router

Length of output: 2951


🏁 Script executed:

# Search for getNodeMatch to verify index starts at 1
rg "getNodeMatch" packages/router-core/src/new-process-route-tree.ts -A 5 -B 2 | head -50

Repository: TanStack/router

Length of output: 444


🏁 Script executed:

# Search for calls to appendStaticMask to understand what indices are passed
rg "appendStaticMask" packages/router-core/src/new-process-route-tree.ts -B 3 -A 1

Repository: TanStack/router

Length of output: 504


🏁 Script executed:

# Find the full getNodeMatch function definition
rg "function getNodeMatch" packages/router-core/src/new-process-route-tree.ts -A 50 | head -80

Repository: TanStack/router

Length of output: 1845


🏁 Script executed:

# Search for the initial stack.push to see the starting index value
rg "stack\.push" packages/router-core/src/new-process-route-tree.ts -B 5 | head -100

Repository: TanStack/router

Length of output: 1367


Fix off-by-one shift in appendStaticMask: use index - 1 for 0-based bit positions.

Line 1014 uses 1 << index with a 1-based index (starting at 1). In JavaScript, shift counts wrap modulo 32, so 1 << 32 equals 1 << 0. This causes segment 32 to set bit 0, which isFrameMoreSpecific treats as the lowest priority bit, making static matches at segment 32 incorrectly rank higher than matches at segment 1.

Suggested fix
 function appendStaticMask(staticMask: number, index: number) {
-  return (staticMask | (1 << index)) >>> 0
+  return (staticMask | (1 << (index - 1))) >>> 0
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function appendStaticMask(staticMask: number, index: number) {
return (staticMask | (1 << index)) >>> 0
}
function appendStaticMask(staticMask: number, index: number) {
return (staticMask | (1 << (index - 1))) >>> 0
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/router-core/src/new-process-route-tree.ts` around lines 1013 - 1015,
The appendStaticMask function uses a 1-based segment index but shifts with 1 <<
index, causing an off-by-one wrap for bit positions; change the shift to use
index - 1 so the bit for segment N sets the (N-1)th bit (i.e., use index - 1
when computing the shift) in appendStaticMask to ensure segments map to correct
0-based bit positions for isFrameMoreSpecific comparisons.


type MatchStackFrame<T extends RouteLike> = {
node: AnySegmentNode<T>
/** index of the segment of path */
Expand All @@ -1024,6 +1028,8 @@ type MatchStackFrame<T extends RouteLike> = {
*/
skipped: number
statics: number
/** Bitmask of static match path indexes, used to prefer earlier static segments. Limited to 32 segments by JS bitwise ops. */
staticMask: number
dynamics: number
optionals: number
/** intermediary state for param extraction */
Expand Down Expand Up @@ -1067,6 +1073,7 @@ function getNodeMatch<T extends RouteLike>(
skipped: 0,
depth: 1,
statics: 1,
staticMask: 0,
dynamics: 0,
optionals: 0,
},
Expand All @@ -1078,7 +1085,16 @@ function getNodeMatch<T extends RouteLike>(

while (stack.length) {
const frame = stack.pop()!
const { node, index, skipped, depth, statics, dynamics, optionals } = frame
const {
node,
index,
skipped,
depth,
statics,
staticMask,
dynamics,
optionals,
} = frame
let { extract, rawParams, parsedParams } = frame

if (node.skipOnParamError) {
Expand Down Expand Up @@ -1120,6 +1136,7 @@ function getNodeMatch<T extends RouteLike>(
skipped,
depth: depth + 1,
statics,
staticMask,
dynamics,
optionals,
extract,
Expand Down Expand Up @@ -1169,6 +1186,7 @@ function getNodeMatch<T extends RouteLike>(
skipped,
depth,
statics,
staticMask,
dynamics,
optionals,
extract,
Expand Down Expand Up @@ -1197,6 +1215,7 @@ function getNodeMatch<T extends RouteLike>(
skipped: nextSkipped,
depth: nextDepth,
statics,
staticMask,
dynamics,
optionals,
extract,
Expand All @@ -1221,6 +1240,7 @@ function getNodeMatch<T extends RouteLike>(
skipped,
depth: nextDepth,
statics,
staticMask,
dynamics,
optionals: optionals + 1,
extract,
Expand Down Expand Up @@ -1249,6 +1269,7 @@ function getNodeMatch<T extends RouteLike>(
skipped,
depth: depth + 1,
statics,
staticMask,
dynamics: dynamics + 1,
optionals,
extract,
Expand All @@ -1270,6 +1291,7 @@ function getNodeMatch<T extends RouteLike>(
skipped,
depth: depth + 1,
statics: statics + 1,
staticMask: appendStaticMask(staticMask, index),
dynamics,
optionals,
extract,
Expand All @@ -1289,6 +1311,7 @@ function getNodeMatch<T extends RouteLike>(
skipped,
depth: depth + 1,
statics: statics + 1,
staticMask: appendStaticMask(staticMask, index),
dynamics,
optionals,
extract,
Expand All @@ -1309,6 +1332,7 @@ function getNodeMatch<T extends RouteLike>(
skipped,
depth: nextDepth,
statics,
staticMask,
dynamics,
optionals,
extract,
Expand Down Expand Up @@ -1371,17 +1395,21 @@ function isFrameMoreSpecific(
next: MatchStackFrame<any>,
): boolean {
if (!prev) return true
if (next.statics !== prev.statics) return next.statics > prev.statics
if (next.staticMask !== prev.staticMask) {
const diff = (next.staticMask ^ prev.staticMask) >>> 0
const firstDifferentPosition = diff & -diff
return (next.staticMask & firstDifferentPosition) !== 0
}
return (
next.statics > prev.statics ||
(next.statics === prev.statics &&
(next.dynamics > prev.dynamics ||
(next.dynamics === prev.dynamics &&
(next.optionals > prev.optionals ||
(next.optionals === prev.optionals &&
((next.node.kind === SEGMENT_TYPE_INDEX) >
(prev.node.kind === SEGMENT_TYPE_INDEX) ||
((next.node.kind === SEGMENT_TYPE_INDEX) ===
(prev.node.kind === SEGMENT_TYPE_INDEX) &&
next.depth > prev.depth)))))))
next.dynamics > prev.dynamics ||
(next.dynamics === prev.dynamics &&
(next.optionals > prev.optionals ||
(next.optionals === prev.optionals &&
((next.node.kind === SEGMENT_TYPE_INDEX) >
(prev.node.kind === SEGMENT_TYPE_INDEX) ||
((next.node.kind === SEGMENT_TYPE_INDEX) ===
(prev.node.kind === SEGMENT_TYPE_INDEX) &&
next.depth > prev.depth)))))
)
}
148 changes: 148 additions & 0 deletions packages/router-core/tests/new-process-route-tree.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { bench, describe } from 'vitest'
import { findRouteMatch, processRouteTree } from '../src/new-process-route-tree'

type BenchRoute = {
id: string
isRoot?: boolean
fullPath: string
path: string
children?: Array<BenchRoute>
}

const BENCH_OPTIONS = {
warmupIterations: 100,
warmupTime: 1_500,
time: 3_000,
}

let benchmarkSink = 0

function makeRouteTree(routes: Array<string>): BenchRoute {
return {
id: '__root__',
isRoot: true,
fullPath: '/',
path: '/',
children: routes.map((route) => ({
id: route,
fullPath: route,
path: route,
})),
}
}

const unitDerivedPatterns = [
// Derived from the priority and edge-case tests in new-process-route-tree.test.ts.
'/{-$a}/b',
'/a/$',
'/a/{-$b}',
'/{-$a}/{-$b}/{-$c}/d/e',
'/{-$a}/{-$b}/c/d/{-$e}',
'/$a/$b/$c/d/e',
'/$a/$b/c/d/$e',
'/users/{-$org}/settings',
'/users/$id',
'/{-$other}/posts/new',
'/posts/$id',
'/a/{-$b}/',
'/a/{-$b}/$c',
'/foo/{-$p}.tsx',
'/foo/{-$p}/{-$x}.tsx',
'/file{$}',
'/{$}/c/file',
]

const unitDerivedMatchPaths = [
'/a/b',
'/a/c',
'/a/b/c',
'/a/b/c/d/e',
'/users/settings',
'/posts/new',
'/foo/bar.tsx',
'/file/a/b/c',
'/x/y/c/file',
]

function prefixed(pattern: string, index: number) {
return `/g${index}${pattern}`
}

function makeBigRoutes(groupCount: number) {
const routes: Array<string> = []
for (let i = 0; i < groupCount; i++) {
for (const pattern of unitDerivedPatterns) {
routes.push(prefixed(pattern, i))
}
}
return routes
}

function makeBigMatchPaths(groupCount: number) {
const paths: Array<string> = []
for (let i = 0; i < groupCount; i++) {
for (const path of unitDerivedMatchPaths) {
paths.push(`/g${i}${path}`)
}
}
return paths
}

const routeSuites = [
{
name: 'small route tree',
routes: unitDerivedPatterns,
matchPaths: unitDerivedMatchPaths,
buildIterations: 3_000,
matchIterations: 3_000,
},
{
name: 'big route tree',
routes: makeBigRoutes(128),
matchPaths: makeBigMatchPaths(128),
buildIterations: 20,
matchIterations: 20,
},
]

describe.each(routeSuites)(
'new process route tree - $name',
({ routes, matchPaths, buildIterations, matchIterations }) => {
const routeTree = makeRouteTree(routes)
const processedTree = processRouteTree(routeTree).processedTree

bench(
`build ${routes.length} routes x ${buildIterations}`,
() => {
for (let i = 0; i < buildIterations; i++) {
const result = processRouteTree(routeTree)
benchmarkSink ^= result.routesById[routes[0]!] ? 1 : 0
}
},
BENCH_OPTIONS,
)

bench(
`match ${matchPaths.length} paths x ${matchIterations} (uncached)`,
() => {
let matches = 0

for (let i = 0; i < matchIterations; i++) {
processedTree.matchCache.clear()

for (const path of matchPaths) {
if (findRouteMatch(path, processedTree)) matches++
}
}

const expectedMatches = matchPaths.length * matchIterations
if (matches !== expectedMatches) {
throw new Error(`Expected ${expectedMatches} matches, got ${matches}`)
}

benchmarkSink ^= matches
},
BENCH_OPTIONS,
)
},
)
12 changes: 12 additions & 0 deletions packages/router-core/tests/new-process-route-tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,18 @@ describe('findRouteMatch', () => {
const treeWithIndex = makeTree(['/a/', '/a/{-$b}'])
expect(findRouteMatch('/a', treeWithIndex)?.route.id).toBe('/a/')
})
it('optional+static vs static+wildcard', () => {
const tree = makeTree(['/{-$a}/b', '/a/$'])
expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/$')
})
it('dynamic+static vs static+wildcard', () => {
const tree = makeTree(['/$a/b', '/a/$'])
expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/$')
})
it('optional+static vs static+optional', () => {
const tree = makeTree(['/{-$a}/b', '/a/{-$b}'])
expect(findRouteMatch('/a/b', tree)?.route.id).toBe('/a/{-$b}')
})
})
})

Expand Down
Loading