From b2b0f25f2301a161ac8a42c6a01d8aacfc6e6aa6 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 21 May 2026 22:14:50 +0200 Subject: [PATCH 1/4] test route tree edge cases --- packages/router-core/package.json | 2 + .../router-core/tests/match-params.test.ts | 66 ++++ .../tests/new-process-route-tree.bench.ts | 303 ++++++++++++++++++ .../tests/new-process-route-tree.test.ts | 48 +++ .../tests/optional-path-params.test.ts | 41 +++ 5 files changed, 460 insertions(+) create mode 100644 packages/router-core/tests/new-process-route-tree.bench.ts diff --git a/packages/router-core/package.json b/packages/router-core/package.json index f1ab6b7c36..4b2feaefa2 100644 --- a/packages/router-core/package.json +++ b/packages/router-core/package.json @@ -31,6 +31,8 @@ "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .", "test:unit": "vitest", "test:unit:dev": "pnpm run test:unit --watch", + "test:perf": "vitest bench", + "test:perf:dev": "pnpm run test:perf --watch --hideSkippedTests", "build": "vite build" }, "type": "module", diff --git a/packages/router-core/tests/match-params.test.ts b/packages/router-core/tests/match-params.test.ts index fe05808bd4..11d10a4f2c 100644 --- a/packages/router-core/tests/match-params.test.ts +++ b/packages/router-core/tests/match-params.test.ts @@ -611,6 +611,44 @@ describe('params.parse route selection', () => { expect(usernameResult?.route.id).toBe('/users/$username') expect(usernameResult?.rawParams).toEqual({ username: 'johndoe' }) }) + + it('child params.parse sees merged parent and child params', () => { + const childParse = vi.fn((params: Record) => params) + const { processedTree } = processRouteTree( + root([ + { + id: '/$org', + fullPath: '/$org', + path: '$org', + options: { + params: { + parse: (params) => params, + }, + }, + children: [ + { + id: '/$org/users/$user', + fullPath: '/$org/users/$user', + path: 'users/$user', + options: { + params: { + parse: childParse, + }, + }, + }, + ], + }, + ]), + ) + + const result = findRouteMatch('/tanstack/users/tanner', processedTree) + expect(result?.route.id).toBe('/$org/users/$user') + expect(result?.rawParams).toEqual({ org: 'tanstack', user: 'tanner' }) + expect(childParse).toHaveBeenCalledWith({ + org: 'tanstack', + user: 'tanner', + }) + }) }) describe('pathless routes', () => { @@ -785,5 +823,33 @@ describe('params.parse route selection', () => { expect(result?.route.id).toBe('/a/$') expect(result?.rawParams).toEqual({ '*': '', _splat: '' }) }) + + it('optional prefix/suffix params.parse failure falls back', () => { + const optionalParse = vi.fn(() => false) + const { processedTree } = processRouteTree( + root([ + { + id: '/file{-$id}.txt', + fullPath: '/file{-$id}.txt', + path: 'file{-$id}.txt', + options: { + params: { + parse: optionalParse, + }, + }, + }, + { + id: '/file{$name}.txt', + fullPath: '/file{$name}.txt', + path: 'file{$name}.txt', + }, + ]), + ) + + const result = findRouteMatch('/file123.txt', processedTree) + expect(result?.route.id).toBe('/file{$name}.txt') + expect(result?.rawParams).toEqual({ name: '123' }) + expect(optionalParse).toHaveBeenCalledWith({ id: '123' }) + }) }) }) diff --git a/packages/router-core/tests/new-process-route-tree.bench.ts b/packages/router-core/tests/new-process-route-tree.bench.ts new file mode 100644 index 0000000000..4b0a948365 --- /dev/null +++ b/packages/router-core/tests/new-process-route-tree.bench.ts @@ -0,0 +1,303 @@ +import { bench, describe } from 'vitest' +import { + findRouteMatch, + processRouteMasks, + processRouteTree, +} from '../src/new-process-route-tree' + +type BenchRoute = { + id: string + fullPath: string + path?: string + isRoot?: boolean + children?: Array + options?: { + caseSensitive?: boolean + parseParams?: (params: Record) => unknown + params?: { + parse?: (params: Record) => unknown + priority?: number + } + } +} + +type BenchMask = { + from: string +} + +const sectionCount = 8 + +function makeRouteTree(): BenchRoute { + return { + id: '__root__', + fullPath: '/', + path: '/', + isRoot: true, + children: Array.from({ length: sectionCount }, (_, section) => { + const prefix = `/section-${section}` + const children: Array = [ + { id: `${prefix}/`, fullPath: `${prefix}/`, path: '/' }, + ] + + for (let page = 0; page < 12; page++) { + children.push({ + id: `${prefix}/static/page-${page}`, + fullPath: `${prefix}/static/page-${page}`, + path: `static/page-${page}`, + }) + children.push({ + id: `${prefix}/items/$item${page}`, + fullPath: `${prefix}/items/$item${page}`, + path: `items/$item${page}`, + }) + children.push({ + id: `${prefix}/files/file-{$file${page}}.txt`, + fullPath: `${prefix}/files/file-{$file${page}}.txt`, + path: `files/file-{$file${page}}.txt`, + }) + } + + children.push( + { + id: `${prefix}/archive/{-$year}/month-{-$month}/index`, + fullPath: `${prefix}/archive/{-$year}/month-{-$month}/index`, + path: 'archive/{-$year}/month-{-$month}/index', + }, + { + id: `${prefix}/docs/{$}.md`, + fullPath: `${prefix}/docs/{$}.md`, + path: 'docs/{$}.md', + }, + { + id: `${prefix}/$org/_layout`, + fullPath: `${prefix}/$org`, + path: '$org', + options: { + params: { parse: (params) => params }, + }, + children: [ + { + id: `${prefix}/$org/_layout/settings`, + fullPath: `${prefix}/$org/settings`, + path: 'settings', + }, + { + id: `${prefix}/$org/_layout/report_{-$range}`, + fullPath: `${prefix}/$org/report_{-$range}`, + path: 'report_{-$range}', + }, + ], + }, + ) + + return { + id: prefix, + fullPath: prefix, + path: `section-${section}`, + options: section % 2 ? { caseSensitive: true } : undefined, + children, + } + }), + } +} + +function makeStaticHeavyRouteTree(): BenchRoute { + return { + id: '__root__', + fullPath: '/', + path: '/', + isRoot: true, + children: Array.from({ length: 48 }, (_, section) => { + const prefix = `/static-heavy/section-${section}` + return { + id: prefix, + fullPath: prefix, + path: `static-heavy/section-${section}`, + children: Array.from({ length: 16 }, (_, page) => ({ + id: `${prefix}/page-${page}/$id`, + fullPath: `${prefix}/page-${page}/$id`, + path: `page-${page}/$id`, + })), + } + }), + } +} + +function makeStaticHeavyMasks(): Array { + const masks: Array = [] + for (let section = 0; section < 48; section++) { + for (let page = 0; page < 16; page++) { + masks.push({ from: `/static-heavy/section-${section}/page-${page}/$id` }) + } + } + return masks +} + +function makeSortableFanoutRouteTree(): BenchRoute { + return { + id: '__root__', + fullPath: '/', + path: '/', + isRoot: true, + children: [ + { + id: '/sort', + fullPath: '/sort', + path: 'sort', + children: Array.from({ length: 64 }, (_, index) => ({ + id: `/sort/prefix-${index}{$value}.suffix-${index}`, + fullPath: `/sort/prefix-${index}{$value}.suffix-${index}`, + path: `prefix-${index}{$value}.suffix-${index}`, + options: { + params: { priority: index % 4 }, + }, + })), + }, + ], + } +} + +function makeSortableFanoutMasks(): Array { + return Array.from({ length: 64 }, (_, index) => ({ + from: `/sort/prefix-${index}{$value}.suffix-${index}`, + })) +} + +function makeDecodeRouteTree(): BenchRoute { + return { + id: '__root__', + fullPath: '/', + path: '/', + isRoot: true, + children: [ + { + id: '/decode/$first/file-{$second}.txt/{-$third}/docs/{$}.md', + fullPath: '/decode/$first/file-{$second}.txt/{-$third}/docs/{$}.md', + path: 'decode/$first/file-{$second}.txt/{-$third}/docs/{$}.md', + }, + ], + } +} + +function makeDecodePaths(encoded: boolean): Array { + return Array.from({ length: 1200 }, (_, index) => { + const space = encoded ? '%20' : '-' + return `/decode/first${space}${index}/file-second${space}${index}.txt/third${space}${index}/docs/path${space}to/file-${index}.md` + }) +} + +function makeMixedDecodePaths(): Array { + return Array.from({ length: 1200 }, (_, index) => { + const space = index % 10 === 0 ? '%20' : '-' + return `/decode/first${space}${index}/file-second${space}${index}.txt/third${space}${index}/docs/path${space}to/file-${index}.md` + }) +} + +function verifyRouteTreeBench() { + const { processedTree } = processRouteTree(makeRouteTree()) + const cases: Array<[path: string, expectedId: string, fuzzy?: boolean]> = [ + ['/section-3/static/page-8', '/section-3/static/page-8'], + ['/section-4/items/abc', '/section-4/items/$item0'], + ['/section-2/files/file-abc.txt', '/section-2/files/file-{$file0}.txt'], + [ + '/section-5/archive/2024/month-9/index', + '/section-5/archive/{-$year}/month-{-$month}/index', + ], + [ + '/section-5/archive/month-9/index', + '/section-5/archive/{-$year}/month-{-$month}/index', + ], + ['/section-1/docs/path/to/file.md', '/section-1/docs/{$}.md'], + ['/section-6/acme/settings', '/section-6/$org/_layout/settings'], + [ + '/section-6/acme/settings/details', + '/section-6/$org/_layout/settings', + true, + ], + ] + + for (const [path, expectedId, fuzzy] of cases) { + const routeId = findRouteMatch(path, processedTree, fuzzy)?.route.id + if (routeId !== expectedId) { + throw new Error(`Expected ${path} to match ${expectedId}, got ${routeId}`) + } + } +} + +verifyRouteTreeBench() + +function verifyDecodeBench() { + const processed = processRouteTree(makeDecodeRouteTree()).processedTree + const plain = findRouteMatch(makeDecodePaths(false)[0]!, processed) + if (plain?.rawParams.first !== 'first-0') { + throw new Error(`Expected plain param decode, got ${plain?.rawParams.first}`) + } + + const encoded = findRouteMatch(makeDecodePaths(true)[0]!, processed) + if (encoded?.rawParams.first !== 'first 0') { + throw new Error( + `Expected encoded param decode, got ${encoded?.rawParams.first}`, + ) + } +} + +verifyDecodeBench() + +describe('new process route tree', () => { + const routeTree = makeRouteTree() + const staticHeavyTree = makeStaticHeavyRouteTree() + const sortableFanoutTree = makeSortableFanoutRouteTree() + const staticHeavyMasks = makeStaticHeavyMasks() + const sortableFanoutMasks = makeSortableFanoutMasks() + const masksProcessed = processRouteTree(makeRouteTree()).processedTree + const decodeProcessed = processRouteTree(makeDecodeRouteTree()).processedTree + const encodedDecodePaths = makeDecodePaths(true) + const mixedDecodePaths = makeMixedDecodePaths() + let encodedDecodeIndex = 0 + let mixedDecodeIndex = 0 + + bench('processRouteTree mixed tree', () => { + processRouteTree(routeTree) + }) + + bench('processRouteTree static-heavy singleton dynamics', () => { + processRouteTree(staticHeavyTree) + }) + + bench('processRouteTree sortable dynamic fanout', () => { + processRouteTree(sortableFanoutTree) + }) + + bench('processRouteMasks static-heavy singleton dynamics', () => { + processRouteMasks(staticHeavyMasks, masksProcessed) + }) + + bench('processRouteMasks sortable dynamic fanout', () => { + processRouteMasks(sortableFanoutMasks, masksProcessed) + }) + + bench('findRouteMatch decode encoded params uncached batch', () => { + for (let index = 0; index < 16; index++) { + const path = + encodedDecodePaths[ + (encodedDecodeIndex + index) % encodedDecodePaths.length + ]! + if (!findRouteMatch(path, decodeProcessed)) { + throw new Error(`No encoded decode match for ${path}`) + } + } + encodedDecodeIndex = (encodedDecodeIndex + 16) % encodedDecodePaths.length + }) + + bench('findRouteMatch decode mixed90 params uncached batch', () => { + for (let index = 0; index < 16; index++) { + const path = + mixedDecodePaths[(mixedDecodeIndex + index) % mixedDecodePaths.length]! + if (!findRouteMatch(path, decodeProcessed)) { + throw new Error(`No mixed decode match for ${path}`) + } + } + mixedDecodeIndex = (mixedDecodeIndex + 16) % mixedDecodePaths.length + }) + +}) 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 d93581355e..ecd9a6411b 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -127,6 +127,13 @@ describe('findRouteMatch', () => { const tree = makeTree(['/a/{$}b', '/a/$']) expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/{$}b') }) + + it('wildcard suffix only matches against the remaining path tail', () => { + const tree = makeTree(['/a/b/{$}/b/c', '/a/b/$']) + const result = findRouteMatch('/a/b/c', tree) + expect(result?.route.id).toBe('/a/b/$') + expect(result?.rawParams).toEqual({ '*': 'c', _splat: 'c' }) + }) }) describe('prefix / suffix lengths', () => { @@ -482,6 +489,33 @@ describe('findRouteMatch', () => { '/A{$id}B', ) }) + + it('case sensitive prefix/suffix miss falls through to an insensitive sibling', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/aa{$id}bb', + fullPath: '/aa{$id}bb', + path: 'aa{$id}bb', + options: { caseSensitive: false }, + }, + { + id: '/A{$id}B', + fullPath: '/A{$id}B', + path: 'A{$id}B', + options: { caseSensitive: true }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + const result = findRouteMatch('/aafooBB', processedTree) + expect(result?.route.id).toBe('/aa{$id}bb') + expect(result?.rawParams).toEqual({ id: 'foo' }) + }) }) describe('basic matching', () => { @@ -507,6 +541,20 @@ describe('findRouteMatch', () => { expect(findRouteMatch('/a/b/c', tree)?.route.id).toBe('/$') }) + it('literal dollar after the segment start stays static', () => { + const tree = makeTree(['/price$', '/price$x']) + expect(findRouteMatch('/price$', tree)?.route.id).toBe('/price$') + expect(findRouteMatch('/price$x', tree)?.route.id).toBe('/price$x') + expect(findRouteMatch('/priceabc', tree)).toBeNull() + }) + + it('malformed curly param syntax stays static', () => { + const tree = makeTree(['/foo{$id', '/foo{-$id']) + expect(findRouteMatch('/foo{$id', tree)?.route.id).toBe('/foo{$id') + expect(findRouteMatch('/foo{-$id', tree)?.route.id).toBe('/foo{-$id') + expect(findRouteMatch('/foo123bar', tree)).toBeNull() + }) + describe('prefix / suffix variations', () => { it('dynamic w/ prefix', () => { const tree = makeTree(['/{$id}.txt']) diff --git a/packages/router-core/tests/optional-path-params.test.ts b/packages/router-core/tests/optional-path-params.test.ts index b58c0b14af..73042620ad 100644 --- a/packages/router-core/tests/optional-path-params.test.ts +++ b/packages/router-core/tests/optional-path-params.test.ts @@ -175,6 +175,47 @@ describe('Optional Path Parameters', () => { }) }) + describe('parseSegment with non-zero starts', () => { + function readSegment(path: string, start: number) { + const segment = parseSegment(path, start) + return { + type: segment[0], + prefix: path.slice(start, segment[1]), + value: path.slice(segment[2], segment[3]), + suffix: path.slice(segment[4], segment[5]), + end: segment[5], + } + } + + it('parses curly required and optional params with absolute offsets', () => { + expect(readSegment('/a/prefix{$id}.txt/rest', 3)).toEqual({ + type: SEGMENT_TYPE_PARAM, + prefix: 'prefix', + value: 'id', + suffix: '.txt', + end: '/a/prefix{$id}.txt'.length, + }) + + expect(readSegment('/a/prefix{-$id}.txt/rest', 3)).toEqual({ + type: SEGMENT_TYPE_OPTIONAL_PARAM, + prefix: 'prefix', + value: 'id', + suffix: '.txt', + end: '/a/prefix{-$id}.txt'.length, + }) + }) + + it('parses curly wildcard suffixes with absolute offsets', () => { + expect(readSegment('/a/file{$}.txt', 3)).toEqual({ + type: SEGMENT_TYPE_WILDCARD, + prefix: 'file', + value: '$', + suffix: '.txt', + end: '/a/file{$}.txt'.length, + }) + }) + }) + describe('interpolatePath with optional params', () => { it.each([ { From ddb360d575a65e1aa390054b9a75ba0265383653 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 21 May 2026 22:14:50 +0200 Subject: [PATCH 2/4] perf: optimize new-process-route-tree.ts ## Summary - Optimized route-tree post-processing by sorting only dynamic child arrays that can need ordering. - Applied the same sortable-array tracking to route masks. - Added a param decode fast path that skips `decodeURIComponent` when a matched value has no `%` escape. - Removed the recursive `sortTreeNodes` post-build tree walk. - Pass `sortables` through recursive route parsing so nested dynamic arrays are recorded during construction. - Skip sortable tracking for single-route lazy matching, where no sibling ordering can be needed. ## Bundle Size Scenario: `react-router.minimal` | Metric | Before | After | Delta | | --- | ---: | ---: | ---: | | gzip | 89,392 | 89,269 | -123 | | initial gzip | 89,251 | 89,130 | -121 | | raw | 280,592 | 279,600 | -992 | | brotli | 77,753 | 77,670 | -83 | ## Focused Benchmarks Benchmarks were compared against a baseline worktree with the same benchmark cases applied and only the implementation different. | Case | Baseline hz | Current hz | Delta | | --- | ---: | ---: | ---: | | `processRouteTree static-heavy singleton dynamics` | 2,953.62 | 3,315.89 | +12.26% | | `processRouteTree sortable dynamic fanout` | 23,016.48 | 24,293.93 | +5.55% | | `processRouteMasks static-heavy singleton dynamics` | 3,654.97 | 4,054.59 | +10.93% | | `processRouteMasks sortable dynamic fanout` | 26,499.82 | 27,733.59 | +4.66% | | `findRouteMatch decode mixed90 params uncached batch` | 29,618.76 | 40,791.57 | +37.72% | | `findRouteMatch decode encoded params uncached batch` | 34,053.80 | 32,279.86 | -5.21% | Notes: - Route construction improves most when the tree has many singleton dynamic arrays, because the full post-build traversal is removed. - Mostly-unencoded param extraction improves by avoiding unnecessary decoding. - Encoded-only params are the tradeoff case because the `%` check adds overhead before decoding. ## Validation - `tests/new-process-route-tree.test.ts`: passed, `173 passed`, no type errors. - Focused perf cases: passed. - `git diff --check`: clean. --- .../router-core/src/new-process-route-tree.ts | 405 +++++++----------- 1 file changed, 143 insertions(+), 262 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 6978b071ce..5dc578cb6a 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -1,6 +1,5 @@ import { invariant } from './invariant' import { createLRUCache } from './lru-cache' -import { last } from './utils' import type { LRUCache } from './lru-cache' export const SEGMENT_TYPE_PATHNAME = 0 @@ -27,18 +26,6 @@ type ExtendedSegmentKind = | typeof SEGMENT_TYPE_INDEX | typeof SEGMENT_TYPE_PATHLESS -function getOpenAndCloseBraces( - part: string, -): [openBrace: number, closeBrace: number] | null { - const openBrace = part.indexOf('{') - if (openBrace === -1) return null - const closeBrace = part.indexOf('}', openBrace) - if (closeBrace === -1) return null - const afterOpen = openBrace + 1 - if (afterOpen >= part.length) return null - return [openBrace, closeBrace] -} - type ParsedSegment = Uint16Array & { /** segment type (0 = pathname, 1 = param, 2 = wildcard, 3 = optional param) */ 0: SegmentKind @@ -80,9 +67,9 @@ export function parseSegment( ): ParsedSegment { const next = path.indexOf('/', start) const end = next === -1 ? path.length : next - const part = path.substring(start, end) + const dollar = path.indexOf('$', start) - if (!part || !part.includes('$')) { + if (dollar === -1 || dollar >= end) { // early escape for static pathname output[0] = SEGMENT_TYPE_PATHNAME output[1] = start @@ -94,7 +81,7 @@ export function parseSegment( } // $ (wildcard) - if (part === '$') { + if (path.charCodeAt(start) === 36 && end === start + 1) { const total = path.length output[0] = SEGMENT_TYPE_WILDCARD output[1] = start @@ -106,7 +93,7 @@ export function parseSegment( } // $paramName - if (part.charCodeAt(0) === 36) { + if (path.charCodeAt(start) === 36) { output[0] = SEGMENT_TYPE_PARAM output[1] = start output[2] = start + 1 // skip '$' @@ -116,60 +103,57 @@ export function parseSegment( return output as ParsedSegment } - const braces = getOpenAndCloseBraces(part) - if (braces) { - const [openBrace, closeBrace] = braces - const firstChar = part.charCodeAt(openBrace + 1) - - // Check for {-$...} (optional param) - // prefix{-$paramName}suffix - // /^([^{]*)\{-\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ - if (firstChar === 45) { - // '-' - if ( - openBrace + 2 < part.length && - part.charCodeAt(openBrace + 2) === 36 // '$' - ) { - const paramStart = openBrace + 3 - const paramEnd = closeBrace - // Validate param name exists - if (paramStart < paramEnd) { - output[0] = SEGMENT_TYPE_OPTIONAL_PARAM - output[1] = start + openBrace - output[2] = start + paramStart - output[3] = start + paramEnd - output[4] = start + closeBrace + 1 - output[5] = end + const openBrace = path.indexOf('{', start) + if (openBrace !== -1 && openBrace < end) { + const closeBrace = path.indexOf('}', openBrace) + if (closeBrace !== -1 && closeBrace < end) { + const firstChar = path.charCodeAt(openBrace + 1) + + // Check for {-$...} (optional param) + // prefix{-$paramName}suffix + // /^([^{]*)\{-\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ + if (firstChar === 45) { + // '-' + if (path.charCodeAt(openBrace + 2) === 36 /* '$' */) { + const paramStart = openBrace + 3 + // Validate param name exists + if (paramStart < closeBrace) { + output[0] = SEGMENT_TYPE_OPTIONAL_PARAM + output[1] = openBrace + output[2] = paramStart + output[3] = closeBrace + output[4] = closeBrace + 1 + output[5] = end + return output as ParsedSegment + } + } + } else if (firstChar === 36) { + // '$' + const afterDollar = openBrace + 2 + // Check for {$} (wildcard) + if (afterDollar === closeBrace) { + // For wildcard, value should be '$' (from dollarPos to afterDollar) + // prefix{$}suffix + // /^([^{]*)\{\$\}([^}]*)$/ + output[0] = SEGMENT_TYPE_WILDCARD + output[1] = openBrace + output[2] = openBrace + 1 + output[3] = afterDollar + output[4] = closeBrace + 1 + output[5] = path.length return output as ParsedSegment } - } - } else if (firstChar === 36) { - // '$' - const dollarPos = openBrace + 1 - const afterDollar = openBrace + 2 - // Check for {$} (wildcard) - if (afterDollar === closeBrace) { - // For wildcard, value should be '$' (from dollarPos to afterDollar) - // prefix{$}suffix - // /^([^{]*)\{\$\}([^}]*)$/ - output[0] = SEGMENT_TYPE_WILDCARD - output[1] = start + openBrace - output[2] = start + dollarPos - output[3] = start + afterDollar - output[4] = start + closeBrace + 1 - output[5] = path.length + // Regular param {$paramName} - value is the param name (after $) + // prefix{$paramName}suffix + // /^([^{]*)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ + output[0] = SEGMENT_TYPE_PARAM + output[1] = openBrace + output[2] = afterDollar + output[3] = closeBrace + output[4] = closeBrace + 1 + output[5] = end return output as ParsedSegment } - // Regular param {$paramName} - value is the param name (after $) - // prefix{$paramName}suffix - // /^([^{]*)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ - output[0] = SEGMENT_TYPE_PARAM - output[1] = start + openBrace - output[2] = start + afterDollar - output[3] = start + closeBrace - output[4] = start + closeBrace + 1 - output[5] = end - return output as ParsedSegment } } @@ -190,6 +174,8 @@ export function parseSegment( * @param route The current route to parse. * @param start The starting index for parsing within the route's full path. * @param node The current segment node in the trie to populate. + * @param depth The current depth in the segment trie. + * @param sortables Dynamic child arrays that reached sortable length and need ordering after parsing. * @param onRoute Callback invoked for each route processed. */ function parseSegments( @@ -199,16 +185,18 @@ function parseSegments( start: number, node: AnySegmentNode, depth: number, + sortables: Array>> | undefined, onRoute?: (route: TRouteLike) => void, ) { - onRoute?.(route) + if (onRoute) onRoute(route) let cursor = start { const path = route.fullPath ?? route.from const length = path.length - const caseSensitive = route.options?.caseSensitive ?? defaultCaseSensitive - const parseParams = - route.options?.params?.parse ?? route.options?.parseParams + const options = route.options + const routeParams = options?.params + const caseSensitive = options?.caseSensitive ?? defaultCaseSensitive + const parseParams = routeParams?.parse ?? options?.parseParams while (cursor < length) { const segment = parseSegment(path, cursor, data) let nextNode: AnySegmentNode @@ -219,16 +207,14 @@ function parseSegments( const kind = segment[0] switch (kind) { case SEGMENT_TYPE_PATHNAME: { - const value = path.substring(segment[2], segment[3]) + const value = path.slice(segment[2], segment[3]) if (caseSensitive) { const existingNode = node.static?.get(value) if (existingNode) { nextNode = existingNode } else { node.static ??= new Map() - const next = createStaticNode( - route.fullPath ?? route.from, - ) + const next = createStaticNode(path) next.parent = node next.depth = depth nextNode = next @@ -241,9 +227,7 @@ function parseSegments( nextNode = existingNode } else { node.staticInsensitive ??= new Map() - const next = createStaticNode( - route.fullPath ?? route.from, - ) + const next = createStaticNode(path) next.parent = node next.depth = depth nextNode = next @@ -252,66 +236,32 @@ function parseSegments( } break } - case SEGMENT_TYPE_PARAM: { - const prefix_raw = path.substring(start, segment[1]) - const suffix_raw = path.substring(segment[4], end) - const actuallyCaseSensitive = - caseSensitive && !!(prefix_raw || suffix_raw) - const prefix = !prefix_raw - ? undefined - : actuallyCaseSensitive - ? prefix_raw - : prefix_raw.toLowerCase() - const suffix = !suffix_raw - ? undefined - : actuallyCaseSensitive - ? suffix_raw - : suffix_raw.toLowerCase() - const existingNode = - !parseParams && - node.dynamic?.find( - (s) => - !s.parse && - s.caseSensitive === actuallyCaseSensitive && - s.prefix === prefix && - s.suffix === suffix, - ) - if (existingNode) { - nextNode = existingNode - } else { - const next = createDynamicNode( - SEGMENT_TYPE_PARAM, - route.fullPath ?? route.from, - actuallyCaseSensitive, - prefix, - suffix, - ) - nextNode = next - next.depth = depth - next.parent = node - node.dynamic ??= [] - node.dynamic.push(next) - } - break - } - case SEGMENT_TYPE_OPTIONAL_PARAM: { - const prefix_raw = path.substring(start, segment[1]) - const suffix_raw = path.substring(segment[4], end) + case SEGMENT_TYPE_PARAM: + case SEGMENT_TYPE_OPTIONAL_PARAM: + case SEGMENT_TYPE_WILDCARD: { + const hasPrefix = start !== segment[1] + const hasSuffix = segment[4] !== end const actuallyCaseSensitive = - caseSensitive && !!(prefix_raw || suffix_raw) - const prefix = !prefix_raw + caseSensitive && (hasPrefix || hasSuffix) + const prefix = !hasPrefix ? undefined : actuallyCaseSensitive - ? prefix_raw - : prefix_raw.toLowerCase() - const suffix = !suffix_raw + ? path.slice(start, segment[1]) + : path.slice(start, segment[1]).toLowerCase() + const suffix = !hasSuffix ? undefined : actuallyCaseSensitive - ? suffix_raw - : suffix_raw.toLowerCase() + ? path.slice(segment[4], end) + : path.slice(segment[4], end).toLowerCase() + const list = + kind === SEGMENT_TYPE_PARAM + ? node.dynamic + : kind === SEGMENT_TYPE_OPTIONAL_PARAM + ? node.optional + : undefined const existingNode = !parseParams && - node.optional?.find( + list?.find( (s) => !s.parse && s.caseSensitive === actuallyCaseSensitive && @@ -322,8 +272,8 @@ function parseSegments( nextNode = existingNode } else { const next = createDynamicNode( - SEGMENT_TYPE_OPTIONAL_PARAM, - route.fullPath ?? route.from, + kind, + path, actuallyCaseSensitive, prefix, suffix, @@ -331,39 +281,22 @@ function parseSegments( nextNode = next next.parent = node next.depth = depth - node.optional ??= [] - node.optional.push(next) + if (kind === SEGMENT_TYPE_PARAM) { + node.dynamic ??= [] + node.dynamic.push(next) + if (node.dynamic.length === 2) sortables?.push(node.dynamic) + } else if (kind === SEGMENT_TYPE_OPTIONAL_PARAM) { + node.optional ??= [] + node.optional.push(next) + if (node.optional.length === 2) sortables?.push(node.optional) + } else { + node.wildcard ??= [] + node.wildcard.push(next) + if (node.wildcard.length === 2) sortables?.push(node.wildcard) + } } break } - case SEGMENT_TYPE_WILDCARD: { - const prefix_raw = path.substring(start, segment[1]) - const suffix_raw = path.substring(segment[4], end) - const actuallyCaseSensitive = - caseSensitive && !!(prefix_raw || suffix_raw) - const prefix = !prefix_raw - ? undefined - : actuallyCaseSensitive - ? prefix_raw - : prefix_raw.toLowerCase() - const suffix = !suffix_raw - ? undefined - : actuallyCaseSensitive - ? suffix_raw - : suffix_raw.toLowerCase() - const next = createDynamicNode( - SEGMENT_TYPE_WILDCARD, - route.fullPath ?? route.from, - actuallyCaseSensitive, - prefix, - suffix, - ) - nextNode = next - next.parent = node - next.depth = depth - node.wildcard ??= [] - node.wildcard.push(next) - } } node = nextNode } @@ -376,9 +309,7 @@ function parseSegments( route.id && route.id.charCodeAt(route.id.lastIndexOf('/') + 1) === 95 /* '_' */ ) { - const pathlessNode = createStaticNode( - route.fullPath ?? route.from, - ) + const pathlessNode = createStaticNode(path) pathlessNode.kind = SEGMENT_TYPE_PATHLESS pathlessNode.parent = node depth++ @@ -391,9 +322,7 @@ function parseSegments( const isLeaf = (route.path || !route.children) && !route.isRoot // create index node if (isLeaf && path.endsWith('/')) { - const indexNode = createStaticNode( - route.fullPath ?? route.from, - ) + const indexNode = createStaticNode(path) indexNode.kind = SEGMENT_TYPE_INDEX indexNode.parent = node depth++ @@ -403,12 +332,12 @@ function parseSegments( } node.parse = parseParams ?? null - node.priority = route.options?.params?.priority ?? 0 + node.priority = routeParams?.priority ?? 0 // make node "matchable" if (isLeaf && !node.route) { node.route = route - node.fullPath = route.fullPath ?? route.from + node.fullPath = path } } if (route.children) @@ -420,6 +349,7 @@ function parseSegments( cursor, node, depth, + sortables, onRoute, ) } @@ -441,8 +371,7 @@ function sortDynamic( priority: number }, ) { - if (a.parse && !b.parse) return -1 - if (!a.parse && b.parse) return 1 + if (!!a.parse !== !!b.parse) return a.parse ? -1 : 1 if (a.parse && b.parse && (a.priority || b.priority)) return b.priority - a.priority if (a.prefix && b.prefix && a.prefix !== b.prefix) { @@ -453,53 +382,14 @@ function sortDynamic( if (a.suffix.endsWith(b.suffix)) return -1 if (b.suffix.endsWith(a.suffix)) return 1 } - if (a.prefix && !b.prefix) return -1 - if (!a.prefix && b.prefix) return 1 - if (a.suffix && !b.suffix) return -1 - if (!a.suffix && b.suffix) return 1 - if (a.caseSensitive && !b.caseSensitive) return -1 - if (!a.caseSensitive && b.caseSensitive) return 1 + if (!!a.prefix !== !!b.prefix) return a.prefix ? -1 : 1 + if (!!a.suffix !== !!b.suffix) return a.suffix ? -1 : 1 + if (a.caseSensitive !== b.caseSensitive) return a.caseSensitive ? -1 : 1 // Equal specificity preserves route declaration order through stable sort. return 0 } -function sortTreeNodes(node: SegmentNode) { - if (node.pathless) { - for (const child of node.pathless) { - sortTreeNodes(child) - } - } - if (node.static) { - for (const child of node.static.values()) { - sortTreeNodes(child) - } - } - if (node.staticInsensitive) { - for (const child of node.staticInsensitive.values()) { - sortTreeNodes(child) - } - } - if (node.dynamic?.length) { - node.dynamic.sort(sortDynamic) - for (const child of node.dynamic) { - sortTreeNodes(child) - } - } - if (node.optional?.length) { - node.optional.sort(sortDynamic) - for (const child of node.optional) { - sortTreeNodes(child) - } - } - if (node.wildcard?.length) { - node.wildcard.sort(sortDynamic) - for (const child of node.wildcard) { - sortTreeNodes(child) - } - } -} - function createStaticNode( fullPath: string, ): StaticSegmentNode { @@ -663,10 +553,11 @@ export function processRouteMasks< ) { const segmentTree = createStaticNode('/') const data = new Uint16Array(6) + const sortables: Array>> = [] for (const route of routeList) { - parseSegments(false, data, route, 1, segmentTree, 0) + parseSegments(false, data, route, 1, segmentTree, 0, sortables) } - sortTreeNodes(segmentTree) + for (const list of sortables) list.sort(sortDynamic) processedTree.masksTree = segmentTree processedTree.flatCache = createLRUCache< string, @@ -710,7 +601,7 @@ export function findSingleMatch( // if we haven't seen this route before, process it now tree = createStaticNode<{ from: string }>('/') const data = new Uint16Array(6) - parseSegments(caseSensitive, data, { from }, 1, tree, 0) + parseSegments(caseSensitive, data, { from }, 1, tree, 0, undefined) processedTree.singleCache.set(key, tree) } return findMatch(path, tree, fuzzy) @@ -789,10 +680,11 @@ export function processRouteTree< ): ProcessRouteTreeResult { const segmentTree = createStaticNode(routeTree.fullPath) const data = new Uint16Array(6) + const sortables: Array>> = [] const routesById = {} as Record const routesByPath = {} as Record let index = 0 - parseSegments(caseSensitive, data, routeTree, 1, segmentTree, 0, (route) => { + parseSegments(caseSensitive, data, routeTree, 1, segmentTree, 0, sortables, (route) => { initRoute?.(route, index) if (route.id in routesById) { @@ -816,7 +708,7 @@ export function processRouteTree< index++ }) - sortTreeNodes(segmentTree) + for (const list of sortables) list.sort(sortDynamic) const processedTree: ProcessedTree = { segmentTree, singleCache: createLRUCache>(1000), @@ -915,15 +807,15 @@ function extractParams( // 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( + const name = nodePart.slice( preLength + 2, nodePart.length - sufLength - 1, ) - const value = part!.substring(preLength, part!.length - sufLength) - rawParams[name] = decodeURIComponent(value) + const value = part!.slice(preLength, part!.length - sufLength) + rawParams[name] = decodeParam(value) } else { - const name = nodePart.substring(1) - rawParams[name] = decodeURIComponent(part!) + const name = nodePart.slice(1) + rawParams[name] = decodeParam(part!) } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { if (leaf.skipped & (1 << nodeIndex)) { @@ -935,22 +827,21 @@ function extractParams( const nodePart = nodeParts[segmentCount]! const preLength = node.prefix?.length ?? 0 const sufLength = node.suffix?.length ?? 0 - const name = nodePart.substring( + const name = nodePart.slice( preLength + 3, nodePart.length - sufLength - 1, ) const value = node.suffix || node.prefix - ? part!.substring(preLength, part!.length - sufLength) + ? part!.slice(preLength, part!.length - sufLength) : part - if (value) rawParams[name] = decodeURIComponent(value) + if (value) rawParams[name] = decodeParam(value) } else if (node.kind === SEGMENT_TYPE_WILDCARD) { - const n = node - const value = path.substring( - currentPathIndex + (n.prefix?.length ?? 0), - path.length - (n.suffix?.length ?? 0), + const value = path.slice( + currentPathIndex + (node.prefix?.length ?? 0), + path.length - (node.suffix?.length ?? 0), ) - const splat = decodeURIComponent(value) + const splat = decodeParam(value) // TODO: Deprecate * rawParams['*'] = splat rawParams._splat = splat @@ -1025,7 +916,7 @@ function getNodeMatch( 'node' | 'skipped' > - const trailingSlash = !last(parts) + const trailingSlash = !parts[parts.length - 1] const pathIsIndex = trailingSlash && path !== '/' const partsLength = parts.length - (trailingSlash ? 1 : 0) @@ -1119,19 +1010,16 @@ function getNodeMatch( extract, rawParams, } - let indexValid = true - if (node.index.parse) { - const result = validateParseParams(path, parts, indexFrame) - if (!result) indexValid = false - } - if (indexValid) { + if (!node.index.parse || validateParseParams(path, parts, indexFrame)) { // perfect match, no need to continue // this is an optimization, algorithm should work correctly without this block if ( !dynamics && !optionals && !skipped && - isPerfectStaticMatch(statics, partsLength) + // `statics` is a bitset of matched path segments; all segment bits + // set means this index route is a complete static match. + statics === 2 ** (partsLength - 1) - 1 ) { return indexFrame } @@ -1313,13 +1201,17 @@ function getNodeMatch( } const splat = sliceIndex === path.length ? '/' : path.slice(sliceIndex) bestFuzzy.rawParams ??= Object.create(null) - bestFuzzy.rawParams!['**'] = decodeURIComponent(splat) + bestFuzzy.rawParams!['**'] = decodeParam(splat) return bestFuzzy } return null } +function decodeParam(value: string) { + return value.indexOf('%') === -1 ? value : decodeURIComponent(value) +} + function segmentScore(partsLength: number, index: number): number { // The specificity scores are bitmasks over consumed URL segments. Earlier // URL segments should dominate later ones when comparing scores, so the @@ -1329,10 +1221,6 @@ function segmentScore(partsLength: number, index: number): number { return 2 ** (partsLength - index - 1) } -function isPerfectStaticMatch(statics: number, partsLength: number): boolean { - return statics === 2 ** (partsLength - 1) - 1 -} - function validateParseParams( path: string, parts: Array, @@ -1350,10 +1238,9 @@ function validateParseParams( frame.rawParams = rawParams frame.extract = state - if (!frame.node.parse) return true - try { - if (frame.node.parse(rawParams) === false) return null + // Callers only invoke this for nodes with parse functions. + if (frame.node.parse!(rawParams) === false) return null } catch { // Thrown parse errors should be surfaced on the selected match by // extractStrictParams, not used as fallback route selection. @@ -1369,17 +1256,11 @@ function isFrameMoreSpecific( next: MatchStackFrame, ): boolean { if (!prev) return true - 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))))))) - ) + if (next.statics !== prev.statics) return next.statics > prev.statics + if (next.dynamics !== prev.dynamics) return next.dynamics > prev.dynamics + if (next.optionals !== prev.optionals) return next.optionals > prev.optionals + const nextIndex = next.node.kind === SEGMENT_TYPE_INDEX + const prevIndex = prev.node.kind === SEGMENT_TYPE_INDEX + if (nextIndex !== prevIndex) return nextIndex + return next.depth > prev.depth } From f36a2afcfa9615b9401c6f91f16b92015ab023f1 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 21 May 2026 22:14:50 +0200 Subject: [PATCH 3/4] # Route Tree Optimization Result ## Setup BEFORE: clean `HEAD` worktree with only new tests/benchmarks applied. AFTER: final route-tree implementation with `options` local restored after perf isolation. Relevant logs: - BEFORE worktree: `/var/folders/6f/2t42ntqs4yv4h6qwzbh5pmcm0000gn/T/opencode/route-tree-before-compare` - AFTER worktree: `/var/folders/6f/2t42ntqs4yv4h6qwzbh5pmcm0000gn/T/opencode/route-tree-after-compare` - Final main perf: `/tmp/route-tree-final-perf2.log` - Final full bundle diff: `/tmp/route-tree-final-full-bundle-diff2.txt` ## Tests | Check | BEFORE | AFTER | | --- | --- | --- | | focused unit | 243 passed | 243 passed | | router-core types | passed | passed | | route-tree perf bench | passed | passed | | relevant e2e | not rerun in BEFORE worktree | 44 passed | | whitespace | n/a | clean | ## Bundle Size Direct comparison for this diff, `react-router.minimal`: | Metric | BEFORE | AFTER | Delta | | --- | ---: | ---: | ---: | | gzip | 89269 | 89214 | -55 | | initial gzip | 89130 | 89073 | -57 | | raw | 279600 | 279460 | -140 | | brotli | 77670 | 77582 | -88 | Full benchmark vs unoptimized attribution baseline: | Scenario | Gzip Delta | | --- | ---: | | react-router.full | -159 | | react-router.minimal | -178 | | react-start.deferred-hydration | -207 | | react-start.full | -168 | | react-start.minimal | -177 | | react-start.rsbuild.full | -190 | | react-start.rsbuild.minimal | -175 | | solid-router.full | -165 | | solid-router.minimal | -131 | | solid-start.deferred-hydration | -151 | | solid-start.full | -152 | | solid-start.minimal | -159 | | vue-router.full | -154 | | vue-router.minimal | -148 | ## Perf Matched full-run comparison, same tests/benches in temp worktrees: | Bench | BEFORE hz | AFTER hz | Delta | Relevance | | --- | ---: | ---: | ---: | --- | | processRouteTree mixed tree | 8482.16 | 8406.43 | -0.9% | not relevant | | processRouteTree static-heavy singleton dynamics | 3376.49 | 3414.27 | +1.1% | not relevant, high variance | | processRouteTree sortable dynamic fanout | 25148.24 | 25787.98 | +2.5% | likely positive | | processRouteTree parsed priority fanout | 46308.12 | 45775.45 | -1.2% | contradicted by focused rerun | | processRouteTree optional fanout | 24307.28 | 25594.74 | +5.3% | likely positive, focused overlap | | processRouteTree wildcard fanout | 38286.45 | 40198.57 | +5.0% | not reproduced by focused rerun | | processRouteMasks static-heavy singleton dynamics | 3804.66 | 3709.58 | -2.5% | not relevant, overlaps | | processRouteMasks sortable dynamic fanout | 27902.76 | 28855.50 | +3.4% | likely positive | | findRouteMatch decode encoded params batch | 27156.51 | 30521.50 | +12.4% | not relevant, high RME | | findRouteMatch decode plain params batch | 39202.70 | 42038.73 | +7.2% | not relevant, high RME | | findRouteMatch decode mixed90 params batch | 38627.50 | 38124.96 | -1.3% | not relevant, high RME | | findRouteMatch sortable dynamic fanout | 19710991.25 | 19006507.35 | -3.6% | not relevant, high variance | Focused reruns used for suspected cases: | Bench | BEFORE hz | AFTER hz | Delta | Relevance | | --- | ---: | ---: | ---: | --- | | processRouteTree parsed priority fanout | 47007.46 +/-0.32% | 48042.74 +/-0.29% | +2.2% | statistically relevant positive | | processRouteTree optional fanout | 25094.35 +/-2.24% | 25718.38 +/-1.30% | +2.5% | not conclusive, ranges overlap | | processRouteTree wildcard fanout | 39812.35 +/-1.79% | 39765.98 +/-3.07% | -0.1% | not relevant | | processRouteTree static-heavy singleton dynamics | 3417.00 +/-3.12% | 3392.02 +/-0.77% | -0.7% | not relevant | Static-heavy note: one narrow run showed a clear slowdown before restoring the `options` local. After restoring it, repeated matched runs no longer showed a stable regression; identical BEFORE runs varied more than the final AFTER delta. ## Conclusion Final candidate keeps bundle wins and no statistically reliable perf regression remains. The only statistically relevant focused perf signal is positive for parsed priority fanout. --- .../router-core/src/new-process-route-tree.ts | 112 +++++------ .../tests/new-process-route-tree.bench.ts | 112 ++++++++++- .../tests/new-process-route-tree.test.ts | 190 ++++++++++++++++++ .../tests/optional-path-params.test.ts | 13 ++ 4 files changed, 361 insertions(+), 66 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 5dc578cb6a..02dddad21d 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -185,7 +185,7 @@ function parseSegments( start: number, node: AnySegmentNode, depth: number, - sortables: Array>> | undefined, + sortables: Array>>, onRoute?: (route: TRouteLike) => void, ) { if (onRoute) onRoute(route) @@ -271,29 +271,36 @@ function parseSegments( if (existingNode) { nextNode = existingNode } else { - const next = createDynamicNode( + const next: DynamicSegmentNode = { kind, - path, - actuallyCaseSensitive, + depth: 0, + pathless: null, + index: null, + static: null, + staticInsensitive: null, + dynamic: null, + optional: null, + wildcard: null, + route: null, + fullPath: path, + parent: null, + parse: null, + priority: 0, + caseSensitive: actuallyCaseSensitive, prefix, suffix, - ) + } nextNode = next next.parent = node next.depth = depth - if (kind === SEGMENT_TYPE_PARAM) { - node.dynamic ??= [] - node.dynamic.push(next) - if (node.dynamic.length === 2) sortables?.push(node.dynamic) - } else if (kind === SEGMENT_TYPE_OPTIONAL_PARAM) { - node.optional ??= [] - node.optional.push(next) - if (node.optional.length === 2) sortables?.push(node.optional) - } else { - node.wildcard ??= [] - node.wildcard.push(next) - if (node.wildcard.length === 2) sortables?.push(node.wildcard) - } + const target = + kind === SEGMENT_TYPE_PARAM + ? (node.dynamic ??= []) + : kind === SEGMENT_TYPE_OPTIONAL_PARAM + ? (node.optional ??= []) + : (node.wildcard ??= []) + target.push(next) + if (target.length === 2) sortables.push(target) } break } @@ -371,7 +378,8 @@ function sortDynamic( priority: number }, ) { - if (!!a.parse !== !!b.parse) return a.parse ? -1 : 1 + if (a.parse && !b.parse) return -1 + if (!a.parse && b.parse) return 1 if (a.parse && b.parse && (a.priority || b.priority)) return b.priority - a.priority if (a.prefix && b.prefix && a.prefix !== b.prefix) { @@ -382,9 +390,12 @@ function sortDynamic( if (a.suffix.endsWith(b.suffix)) return -1 if (b.suffix.endsWith(a.suffix)) return 1 } - if (!!a.prefix !== !!b.prefix) return a.prefix ? -1 : 1 - if (!!a.suffix !== !!b.suffix) return a.suffix ? -1 : 1 - if (a.caseSensitive !== b.caseSensitive) return a.caseSensitive ? -1 : 1 + if (a.prefix && !b.prefix) return -1 + if (!a.prefix && b.prefix) return 1 + if (a.suffix && !b.suffix) return -1 + if (!a.suffix && b.suffix) return 1 + if (a.caseSensitive && !b.caseSensitive) return -1 + if (!a.caseSensitive && b.caseSensitive) return 1 // Equal specificity preserves route declaration order through stable sort. return 0 @@ -411,41 +422,6 @@ function createStaticNode( } } -/** - * Keys must be declared in the same order as in `SegmentNode` type, - * to ensure they are represented as the same object class in the engine. - */ -function createDynamicNode( - kind: - | typeof SEGMENT_TYPE_PARAM - | typeof SEGMENT_TYPE_WILDCARD - | typeof SEGMENT_TYPE_OPTIONAL_PARAM, - fullPath: string, - caseSensitive: boolean, - prefix?: string, - suffix?: string, -): DynamicSegmentNode { - return { - kind, - depth: 0, - pathless: null, - index: null, - static: null, - staticInsensitive: null, - dynamic: null, - optional: null, - wildcard: null, - route: null, - fullPath, - parent: null, - parse: null, - priority: 0, - caseSensitive, - prefix, - suffix, - } -} - type StaticSegmentNode = SegmentNode & { kind: | typeof SEGMENT_TYPE_PATHNAME @@ -601,7 +577,7 @@ export function findSingleMatch( // if we haven't seen this route before, process it now tree = createStaticNode<{ from: string }>('/') const data = new Uint16Array(6) - parseSegments(caseSensitive, data, { from }, 1, tree, 0, undefined) + parseSegments(caseSensitive, data, { from }, 1, tree, 0, []) processedTree.singleCache.set(key, tree) } return findMatch(path, tree, fuzzy) @@ -1256,11 +1232,17 @@ function isFrameMoreSpecific( next: MatchStackFrame, ): boolean { if (!prev) return true - if (next.statics !== prev.statics) return next.statics > prev.statics - if (next.dynamics !== prev.dynamics) return next.dynamics > prev.dynamics - if (next.optionals !== prev.optionals) return next.optionals > prev.optionals - const nextIndex = next.node.kind === SEGMENT_TYPE_INDEX - const prevIndex = prev.node.kind === SEGMENT_TYPE_INDEX - if (nextIndex !== prevIndex) return nextIndex - return next.depth > prev.depth + 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))))))) + ) } diff --git a/packages/router-core/tests/new-process-route-tree.bench.ts b/packages/router-core/tests/new-process-route-tree.bench.ts index 4b0a948365..dfb9d4c263 100644 --- a/packages/router-core/tests/new-process-route-tree.bench.ts +++ b/packages/router-core/tests/new-process-route-tree.bench.ts @@ -163,6 +163,75 @@ function makeSortableFanoutMasks(): Array { })) } +function makeParsedPriorityFanoutRouteTree(): BenchRoute { + return { + id: '__root__', + fullPath: '/', + path: '/', + isRoot: true, + children: [ + { + id: '/priority', + fullPath: '/priority', + path: 'priority', + children: Array.from({ length: 64 }, (_, index) => ({ + id: `/priority/item-{$value}-${index}`, + fullPath: `/priority/item-{$value}-${index}`, + path: `item-{$value}-${index}`, + options: { + params: { + parse: (params) => params, + priority: index % 8, + }, + }, + })), + }, + ], + } +} + +function makeOptionalFanoutRouteTree(): BenchRoute { + return { + id: '__root__', + fullPath: '/', + path: '/', + isRoot: true, + children: [ + { + id: '/optional', + fullPath: '/optional', + path: 'optional', + children: Array.from({ length: 64 }, (_, index) => ({ + id: `/optional/prefix-${index}{-$value}.suffix-${index}`, + fullPath: `/optional/prefix-${index}{-$value}.suffix-${index}`, + path: `prefix-${index}{-$value}.suffix-${index}`, + })), + }, + ], + } +} + +function makeWildcardFanoutRouteTree(): BenchRoute { + return { + id: '__root__', + fullPath: '/', + path: '/', + isRoot: true, + children: [ + { + id: '/wildcard', + fullPath: '/wildcard', + path: 'wildcard', + children: Array.from({ length: 64 }, (_, index) => ({ + id: `/wildcard/prefix-${index}{$}.suffix-${index}`, + fullPath: `/wildcard/prefix-${index}{$}.suffix-${index}`, + path: `prefix-${index}{$}.suffix-${index}`, + })), + }, + ], + } +} + function makeDecodeRouteTree(): BenchRoute { return { id: '__root__', @@ -230,7 +299,9 @@ function verifyDecodeBench() { const processed = processRouteTree(makeDecodeRouteTree()).processedTree const plain = findRouteMatch(makeDecodePaths(false)[0]!, processed) if (plain?.rawParams.first !== 'first-0') { - throw new Error(`Expected plain param decode, got ${plain?.rawParams.first}`) + throw new Error( + `Expected plain param decode, got ${plain?.rawParams.first}`, + ) } const encoded = findRouteMatch(makeDecodePaths(true)[0]!, processed) @@ -247,13 +318,20 @@ describe('new process route tree', () => { const routeTree = makeRouteTree() const staticHeavyTree = makeStaticHeavyRouteTree() const sortableFanoutTree = makeSortableFanoutRouteTree() + const parsedPriorityFanoutTree = makeParsedPriorityFanoutRouteTree() + const optionalFanoutTree = makeOptionalFanoutRouteTree() + const wildcardFanoutTree = makeWildcardFanoutRouteTree() const staticHeavyMasks = makeStaticHeavyMasks() const sortableFanoutMasks = makeSortableFanoutMasks() const masksProcessed = processRouteTree(makeRouteTree()).processedTree const decodeProcessed = processRouteTree(makeDecodeRouteTree()).processedTree + const sortableFanoutProcessed = + processRouteTree(sortableFanoutTree).processedTree const encodedDecodePaths = makeDecodePaths(true) + const plainDecodePaths = makeDecodePaths(false) const mixedDecodePaths = makeMixedDecodePaths() let encodedDecodeIndex = 0 + let plainDecodeIndex = 0 let mixedDecodeIndex = 0 bench('processRouteTree mixed tree', () => { @@ -268,6 +346,18 @@ describe('new process route tree', () => { processRouteTree(sortableFanoutTree) }) + bench('processRouteTree parsed priority fanout', () => { + processRouteTree(parsedPriorityFanoutTree) + }) + + bench('processRouteTree optional fanout', () => { + processRouteTree(optionalFanoutTree) + }) + + bench('processRouteTree wildcard fanout', () => { + processRouteTree(wildcardFanoutTree) + }) + bench('processRouteMasks static-heavy singleton dynamics', () => { processRouteMasks(staticHeavyMasks, masksProcessed) }) @@ -289,6 +379,17 @@ describe('new process route tree', () => { encodedDecodeIndex = (encodedDecodeIndex + 16) % encodedDecodePaths.length }) + bench('findRouteMatch decode plain params uncached batch', () => { + for (let index = 0; index < 16; index++) { + const path = + plainDecodePaths[(plainDecodeIndex + index) % plainDecodePaths.length]! + if (!findRouteMatch(path, decodeProcessed)) { + throw new Error(`No plain decode match for ${path}`) + } + } + plainDecodeIndex = (plainDecodeIndex + 16) % plainDecodePaths.length + }) + bench('findRouteMatch decode mixed90 params uncached batch', () => { for (let index = 0; index < 16; index++) { const path = @@ -300,4 +401,13 @@ describe('new process route tree', () => { mixedDecodeIndex = (mixedDecodeIndex + 16) % mixedDecodePaths.length }) + bench('findRouteMatch sortable dynamic fanout', () => { + const result = findRouteMatch( + '/sort/prefix-63value.suffix-63', + sortableFanoutProcessed, + ) + if (result?.route.id !== '/sort/prefix-63{$value}.suffix-63') { + throw new Error(`Unexpected sortable fanout match ${result?.route.id}`) + } + }) }) 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 ecd9a6411b..436c6a636f 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -516,6 +516,154 @@ describe('findRouteMatch', () => { expect(result?.route.id).toBe('/aa{$id}bb') expect(result?.rawParams).toEqual({ id: 'foo' }) }) + + it('sorts parsed dynamic siblings by params priority', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/a/{$low}', + fullPath: '/a/{$low}', + path: 'a/{$low}', + options: { + params: { + priority: 1, + parse: (params: Record) => params, + }, + }, + }, + { + id: '/a/{$high}', + fullPath: '/a/{$high}', + path: 'a/{$high}', + options: { + params: { + priority: 10, + parse: (params: Record) => params, + }, + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + expect(findRouteMatch('/a/value', processedTree)?.route.id).toBe( + '/a/{$high}', + ) + }) + + it('falls back to lower priority parsed dynamic siblings', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/a/{$low}', + fullPath: '/a/{$low}', + path: 'a/{$low}', + options: { + params: { + priority: 1, + parse: (params: Record) => params, + }, + }, + }, + { + id: '/a/{$high}', + fullPath: '/a/{$high}', + path: 'a/{$high}', + options: { + params: { + priority: 10, + parse: () => false, + }, + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + const result = findRouteMatch('/a/value', processedTree) + expect(result?.route.id).toBe('/a/{$low}') + expect(result?.rawParams).toEqual({ low: 'value' }) + }) + + it('sorts parsed optional siblings by params priority', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/a/{-$low}', + fullPath: '/a/{-$low}', + path: 'a/{-$low}', + options: { + params: { + priority: 1, + parse: (params: Record) => params, + }, + }, + }, + { + id: '/a/{-$high}', + fullPath: '/a/{-$high}', + path: 'a/{-$high}', + options: { + params: { + priority: 10, + parse: (params: Record) => params, + }, + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + expect(findRouteMatch('/a/value', processedTree)?.route.id).toBe( + '/a/{-$high}', + ) + }) + + it('sorts parsed wildcard siblings by params priority', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/a/low{$}-low', + fullPath: '/a/low{$}', + path: 'a/low{$}', + options: { + params: { + priority: 1, + parse: (params: Record) => params, + }, + }, + }, + { + id: '/a/low{$}-high', + fullPath: '/a/low{$}', + path: 'a/low{$}', + options: { + params: { + priority: 10, + parse: (params: Record) => params, + }, + }, + }, + ], + } + const { processedTree } = processRouteTree(tree) + expect(findRouteMatch('/a/low/value', processedTree)?.route).toBe( + tree.children[1], + ) + }) }) describe('basic matching', () => { @@ -1720,4 +1868,46 @@ describe('processRouteMasks', { sequential: true }, () => { expect(res?.route.from).toBe('/a/b/{$}.txt') expect(res?.rawParams).toEqual({ '*': 'file/path', _splat: 'file/path' }) }) + + it('sorts dynamic route masks by specificity', () => { + const { processedTree: maskTree } = processRouteTree(routeTree) + processRouteMasks( + [ + { from: '/a/$param', routeTree }, + { from: '/a/file-{$param}.txt', routeTree }, + ], + maskTree, + ) + const res = findFlatMatch('/a/file-value.txt', maskTree) + expect(res?.route.from).toBe('/a/file-{$param}.txt') + expect(res?.rawParams).toEqual({ param: 'value' }) + }) + + it('sorts optional route masks by specificity', () => { + const { processedTree: maskTree } = processRouteTree(routeTree) + processRouteMasks( + [ + { from: '/a/{-$param}', routeTree }, + { from: '/a/file-{-$param}.txt', routeTree }, + ], + maskTree, + ) + const res = findFlatMatch('/a/file-value.txt', maskTree) + expect(res?.route.from).toBe('/a/file-{-$param}.txt') + expect(res?.rawParams).toEqual({ param: 'value' }) + }) + + it('sorts wildcard route masks by specificity', () => { + const { processedTree: maskTree } = processRouteTree(routeTree) + processRouteMasks( + [ + { from: '/a/$', routeTree }, + { from: '/a/file-{$}.txt', routeTree }, + ], + maskTree, + ) + const res = findFlatMatch('/a/file-path.txt', maskTree) + expect(res?.route.from).toBe('/a/file-{$}.txt') + expect(res?.rawParams).toEqual({ '*': 'path', _splat: 'path' }) + }) }) diff --git a/packages/router-core/tests/optional-path-params.test.ts b/packages/router-core/tests/optional-path-params.test.ts index 73042620ad..f10bba018b 100644 --- a/packages/router-core/tests/optional-path-params.test.ts +++ b/packages/router-core/tests/optional-path-params.test.ts @@ -214,6 +214,19 @@ describe('Optional Path Parameters', () => { end: '/a/file{$}.txt'.length, }) }) + + it.each(['foo{}', 'foo{id}', 'foo{-$}', 'foo{-id}'])( + 'falls back to pathname for invalid closed curly segment %s', + (part) => { + expect(readSegment(`/a/${part}/rest`, 3)).toEqual({ + type: SEGMENT_TYPE_PATHNAME, + prefix: '', + value: part, + suffix: '', + end: `/a/${part}`.length, + }) + }, + ) }) describe('interpolatePath with optional params', () => { From 0d5679c4cf3316752f5ad2e9a7f32ce43523eace Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 20:17:12 +0000 Subject: [PATCH 4/4] ci: apply automated fixes --- .../router-core/src/new-process-route-tree.ts | 45 +++++++++++-------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 02dddad21d..09fd9e5058 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -660,30 +660,39 @@ export function processRouteTree< const routesById = {} as Record const routesByPath = {} as Record let index = 0 - parseSegments(caseSensitive, data, routeTree, 1, segmentTree, 0, sortables, (route) => { - initRoute?.(route, index) + parseSegments( + caseSensitive, + data, + routeTree, + 1, + segmentTree, + 0, + sortables, + (route) => { + initRoute?.(route, index) + + if (route.id in routesById) { + if (process.env.NODE_ENV !== 'production') { + throw new Error( + `Invariant failed: Duplicate routes found with id: ${String(route.id)}`, + ) + } - if (route.id in routesById) { - if (process.env.NODE_ENV !== 'production') { - throw new Error( - `Invariant failed: Duplicate routes found with id: ${String(route.id)}`, - ) + invariant() } - invariant() - } - - routesById[route.id] = route + routesById[route.id] = route - if (index !== 0 && route.path) { - const trimmedFullPath = trimPathRight(route.fullPath) - if (!routesByPath[trimmedFullPath] || route.fullPath.endsWith('/')) { - routesByPath[trimmedFullPath] = route + if (index !== 0 && route.path) { + const trimmedFullPath = trimPathRight(route.fullPath) + if (!routesByPath[trimmedFullPath] || route.fullPath.endsWith('/')) { + routesByPath[trimmedFullPath] = route + } } - } - index++ - }) + index++ + }, + ) for (const list of sortables) list.sort(sortDynamic) const processedTree: ProcessedTree = { segmentTree,