diff --git a/.changeset/faster-route-trees.md b/.changeset/faster-route-trees.md new file mode 100644 index 00000000000..9bd9e9fcd6b --- /dev/null +++ b/.changeset/faster-route-trees.md @@ -0,0 +1,7 @@ +--- +'@tanstack/router-core': patch +--- + +Improve route-tree construction and matching performance by fusing static and +dynamic node creation, sorting only dynamic sibling lists that need it, and +deriving matcher depth from trie nodes. diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 6978b071ce3..2640961815a 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -199,16 +199,18 @@ function parseSegments( start: number, node: AnySegmentNode, depth: number, + /** Each dynamic sibling list is recorded once, when it first needs sorting. */ + dynamicListsToSort?: Array>>, onRoute?: (route: TRouteLike) => void, ) { onRoute?.(route) let cursor = start { const path = route.fullPath ?? route.from + const options = route.options const length = path.length - const caseSensitive = route.options?.caseSensitive ?? defaultCaseSensitive - const parseParams = - route.options?.params?.parse ?? route.options?.parseParams + const caseSensitive = options?.caseSensitive ?? defaultCaseSensitive + const parseParams = options?.params?.parse ?? options?.parseParams while (cursor < length) { const segment = parseSegment(path, cursor, data) let nextNode: AnySegmentNode @@ -220,81 +222,29 @@ function parseSegments( switch (kind) { case SEGMENT_TYPE_PATHNAME: { const value = path.substring(segment[2], segment[3]) + let name = value + let staticChildren: Map> if (caseSensitive) { - const existingNode = node.static?.get(value) - if (existingNode) { - nextNode = existingNode - } else { - node.static ??= new Map() - const next = createStaticNode( - route.fullPath ?? route.from, - ) - next.parent = node - next.depth = depth - nextNode = next - node.static.set(value, next) - } + staticChildren = node.static ??= new Map() } else { - const name = value.toLowerCase() - const existingNode = node.staticInsensitive?.get(name) - if (existingNode) { - nextNode = existingNode - } else { - node.staticInsensitive ??= new Map() - const next = createStaticNode( - route.fullPath ?? route.from, - ) - next.parent = node - next.depth = depth - nextNode = next - node.staticInsensitive.set(name, next) - } + name = value.toLowerCase() + staticChildren = node.staticInsensitive ??= new Map() } - 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, - ) + const existingNode = staticChildren.get(name) if (existingNode) { nextNode = existingNode } else { - const next = createDynamicNode( - SEGMENT_TYPE_PARAM, - route.fullPath ?? route.from, - actuallyCaseSensitive, - prefix, - suffix, - ) - nextNode = next - next.depth = depth + const next = createStaticNode(path) next.parent = node - node.dynamic ??= [] - node.dynamic.push(next) + next.depth = depth + nextNode = next + staticChildren.set(name, next) } break } - case SEGMENT_TYPE_OPTIONAL_PARAM: { + case SEGMENT_TYPE_PARAM: + case SEGMENT_TYPE_OPTIONAL_PARAM: + case SEGMENT_TYPE_WILDCARD: { const prefix_raw = path.substring(start, segment[1]) const suffix_raw = path.substring(segment[4], end) const actuallyCaseSensitive = @@ -309,9 +259,18 @@ function parseSegments( : actuallyCaseSensitive ? suffix_raw : suffix_raw.toLowerCase() + const siblings = + kind === SEGMENT_TYPE_PARAM + ? node.dynamic + : kind === SEGMENT_TYPE_OPTIONAL_PARAM + ? node.optional + : node.wildcard const existingNode = + // Keep wildcard aliases as separate match candidates, even when + // they have the same shape and no parser. + kind !== SEGMENT_TYPE_WILDCARD && !parseParams && - node.optional?.find( + siblings?.find( (s) => !s.parse && s.caseSensitive === actuallyCaseSensitive && @@ -322,8 +281,8 @@ function parseSegments( nextNode = existingNode } else { const next = createDynamicNode( - SEGMENT_TYPE_OPTIONAL_PARAM, - route.fullPath ?? route.from, + kind, + path, actuallyCaseSensitive, prefix, suffix, @@ -331,39 +290,21 @@ function parseSegments( nextNode = next next.parent = node next.depth = depth - node.optional ??= [] - node.optional.push(next) + let nodes: Array> + if (kind === SEGMENT_TYPE_PARAM) { + nodes = node.dynamic ??= [] + } else if (kind === SEGMENT_TYPE_OPTIONAL_PARAM) { + nodes = node.optional ??= [] + } else { + nodes = node.wildcard ??= [] + } + nodes.push(next) + if (nodes.length === 2) { + dynamicListsToSort?.push(nodes) + } } 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 +317,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 +330,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 +340,12 @@ function parseSegments( } node.parse = parseParams ?? null - node.priority = route.options?.params?.priority ?? 0 + node.priority = options?.params?.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 +357,7 @@ function parseSegments( cursor, node, depth, + dynamicListsToSort, onRoute, ) } @@ -464,42 +402,6 @@ function sortDynamic( 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 +565,13 @@ export function processRouteMasks< ) { const segmentTree = createStaticNode('/') const data = new Uint16Array(6) + const dynamicListsToSort: Array>> = [] for (const route of routeList) { - parseSegments(false, data, route, 1, segmentTree, 0) + parseSegments(false, data, route, 1, segmentTree, 0, dynamicListsToSort) + } + for (const nodes of dynamicListsToSort) { + nodes.sort(sortDynamic) } - sortTreeNodes(segmentTree) processedTree.masksTree = segmentTree processedTree.flatCache = createLRUCache< string, @@ -789,34 +694,46 @@ export function processRouteTree< ): ProcessRouteTreeResult { const segmentTree = createStaticNode(routeTree.fullPath) const data = new Uint16Array(6) + const dynamicListsToSort: Array>> = [] const routesById = {} as Record const routesByPath = {} as Record let index = 0 - parseSegments(caseSensitive, data, routeTree, 1, segmentTree, 0, (route) => { - initRoute?.(route, index) + parseSegments( + caseSensitive, + data, + routeTree, + 1, + segmentTree, + 0, + dynamicListsToSort, + (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++ - }) - sortTreeNodes(segmentTree) + index++ + }, + ) + for (const nodes of dynamicListsToSort) { + nodes.sort(sortDynamic) + } const processedTree: ProcessedTree = { segmentTree, singleCache: createLRUCache>(1000), @@ -992,8 +909,6 @@ type MatchStackFrame = { node: AnySegmentNode /** index of the segment of path */ index: number - /** how many nodes between `node` and the root of the segment tree */ - depth: number /** * Bitmask of skipped optional segments. * @@ -1043,7 +958,6 @@ function getNodeMatch( node: segmentTree, index: 1, skipped: 0, - depth: 1, statics: 0, dynamics: 0, optionals: 0, @@ -1055,7 +969,7 @@ function getNodeMatch( while (stack.length) { const frame = stack.pop()! - const { node, index, skipped, depth, statics, dynamics, optionals } = frame + const { node, index, skipped, statics, dynamics, optionals } = frame let { extract, rawParams } = frame // Wildcard candidates are pushed speculatively as fallbacks in case a @@ -1112,7 +1026,6 @@ function getNodeMatch( node: node.index, index, skipped, - depth: depth + 1, statics, dynamics, optionals, @@ -1165,7 +1078,6 @@ function getNodeMatch( node: segment, index: partsLength, skipped, - depth: depth + 1, statics, dynamics, optionals, @@ -1177,16 +1089,15 @@ function getNodeMatch( // 4. Try optional match if (node.optional) { - const nextSkipped = skipped | (1 << depth) - const nextDepth = depth + 1 + // A skipped optional is keyed by the child node's trie depth. + const nextSkipped = skipped | (1 << (node.depth + 1)) for (let i = node.optional.length - 1; i >= 0; i--) { const segment = node.optional[i]! - // when skipping, node and depth advance by 1, but index doesn't + // when skipping, the node advances by 1, but the index doesn't stack.push({ node: segment, index, skipped: nextSkipped, - depth: nextDepth, statics, dynamics, optionals, @@ -1209,7 +1120,6 @@ function getNodeMatch( node: segment, index: index + 1, skipped, - depth: nextDepth, statics, dynamics, optionals: optionals + segmentScore(partsLength, index), @@ -1236,7 +1146,6 @@ function getNodeMatch( node: segment, index: index + 1, skipped, - depth: depth + 1, statics, dynamics: dynamics + segmentScore(partsLength, index), optionals, @@ -1256,7 +1165,6 @@ function getNodeMatch( node: match, index: index + 1, skipped, - depth: depth + 1, statics: statics + segmentScore(partsLength, index), dynamics, optionals, @@ -1274,7 +1182,6 @@ function getNodeMatch( node: match, index: index + 1, skipped, - depth: depth + 1, statics: statics + segmentScore(partsLength, index), dynamics, optionals, @@ -1286,14 +1193,12 @@ function getNodeMatch( // 0. Try pathless match if (node.pathless) { - const nextDepth = depth + 1 for (let i = node.pathless.length - 1; i >= 0; i--) { const segment = node.pathless[i]! stack.push({ node: segment, index, skipped, - depth: nextDepth, statics, dynamics, optionals, @@ -1380,6 +1285,6 @@ function isFrameMoreSpecific( (prev.node.kind === SEGMENT_TYPE_INDEX) || ((next.node.kind === SEGMENT_TYPE_INDEX) === (prev.node.kind === SEGMENT_TYPE_INDEX) && - next.depth > prev.depth))))))) + next.node.depth > prev.node.depth))))))) ) } 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 d93581355ea..d7e1cd977bf 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' import { findFlatMatch, findRouteMatch, @@ -114,6 +114,10 @@ describe('findRouteMatch', () => { const tree = makeTree(['/a/{-$b}b', '/a/{-$b}']) expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/{-$b}b') }) + it('prefix+suffix optional wins when declared after plain optional', () => { + const tree = makeTree(['/a/{-$b}', '/a/b{-$b}b']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{-$b}b') + }) it('prefix+suffix wildcard wins over plain wildcard', () => { const tree = makeTree(['/a/b{$}b', '/a/$']) @@ -127,6 +131,90 @@ describe('findRouteMatch', () => { const tree = makeTree(['/a/{$}b', '/a/$']) expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/{$}b') }) + it('sorts a third, more specific wildcard declared last', () => { + const tree = makeTree(['/a/$', '/a/b{$}', '/a/b{$}b']) + expect(findRouteMatch('/a/bbb', tree)?.route.id).toBe('/a/b{$}b') + }) + }) + + it('sorts parser priorities after building required, optional, and wildcard siblings', () => { + const cases = [ + ['pre{$value}suf', 'dynamic'], + ['pre{-$value}suf', 'optional'], + ['pre{$}suf', 'wildcard'], + ] as const + + for (const [segment, siblingKind] of cases) { + let acceptHigherPriority = true + const fullPath = `/a/${segment}` + const routeTree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: `low-${siblingKind}`, + fullPath, + path: fullPath.slice(1), + options: { + params: { + priority: 1, + parse: (params: Record) => params, + }, + }, + }, + { + id: `high-${siblingKind}`, + fullPath, + path: fullPath.slice(1), + options: { + params: { + priority: 2, + parse: (params: Record) => + acceptHigherPriority ? params : false, + }, + }, + }, + ], + } + const { processedTree } = processRouteTree(routeTree) + const branch = processedTree.segmentTree.staticInsensitive?.get('a') + + expect(branch?.[siblingKind]).toHaveLength(2) + expect(findRouteMatch('/a/prewinsuf', processedTree)?.route.id).toBe( + `high-${siblingKind}`, + ) + + acceptHigherPriority = false + expect( + findRouteMatch('/a/prefallbacksuf', processedTree)?.route.id, + ).toBe(`low-${siblingKind}`) + } + }) + + it('reuses optional nodes with the same shape when they have no parser', () => { + const tree = makeTree([ + '/a/{-$first}/first-child', + '/a/{-$second}/second-child', + ]) + const branch = tree.segmentTree.staticInsensitive?.get('a') + + expect(branch?.optional).toHaveLength(1) + expect(findRouteMatch('/a/value/first-child', tree)?.route.id).toBe( + '/a/{-$first}/first-child', + ) + expect(findRouteMatch('/a/value/second-child', tree)?.route.id).toBe( + '/a/{-$second}/second-child', + ) + }) + + it('keeps same-shaped wildcard aliases as separate match candidates', () => { + const tree = makeTree(['/a/$', '/a/{$}']) + const branch = tree.segmentTree.staticInsensitive?.get('a') + + expect(branch?.wildcard).toHaveLength(2) + expect(findRouteMatch('/a/value', tree)?.route.id).toBe('/a/$') }) describe('prefix / suffix lengths', () => { @@ -390,6 +478,63 @@ describe('findRouteMatch', () => { }) describe('case sensitivity competition', () => { + it('reuses an insensitive static node for differently cased siblings', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/Docs/API', + fullPath: '/Docs/API', + path: 'Docs/API', + }, + { + id: '/docs/guide', + fullPath: '/docs/guide', + path: 'docs/guide', + }, + ], + } + const { processedTree } = processRouteTree(tree) + const docs = processedTree.segmentTree.staticInsensitive?.get('docs') + + expect(processedTree.segmentTree.staticInsensitive?.size).toBe(1) + expect(docs?.staticInsensitive?.size).toBe(2) + expect(findRouteMatch('/DOCS/api', processedTree)?.route.id).toBe( + '/Docs/API', + ) + expect(findRouteMatch('/Docs/GUIDE', processedTree)?.route.id).toBe( + '/docs/guide', + ) + }) + it('allows a route to override a sensitive tree default', () => { + const tree = { + id: '__root__', + isRoot: true, + fullPath: '/', + path: '/', + children: [ + { + id: '/Strict', + fullPath: '/Strict', + path: 'Strict', + }, + { + id: '/loose', + fullPath: '/loose', + path: 'loose', + options: { caseSensitive: false }, + }, + ], + } + const { processedTree } = processRouteTree(tree, true) + + expect(findRouteMatch('/Strict', processedTree)?.route.id).toBe('/Strict') + expect(findRouteMatch('/strict', processedTree)).toBeNull() + expect(findRouteMatch('/LOOSE', processedTree)?.route.id).toBe('/loose') + }) it('a case sensitive segment early on should not prevent a case insensitive match', () => { const tree = { id: '__root__', @@ -1346,6 +1491,40 @@ describe('findRouteMatch', () => { '/_layout/a/b', ) }) + it('uses trie depth to break equal-specificity pathless ties', () => { + const tree = { + id: '__root__', + fullPath: '/', + path: '/', + isRoot: true, + children: [ + { + id: '/a', + fullPath: '/a', + path: 'a', + }, + { + id: '/_layout', + fullPath: '/', + options: { + params: { + parse: (params: Record) => params, + }, + }, + children: [ + { + id: '/_layout/a', + fullPath: '/a', + path: 'a', + }, + ], + }, + ], + } + const { processedTree } = processRouteTree(tree) + + expect(findRouteMatch('/a', processedTree)?.route.id).toBe('/_layout/a') + }) it('builds segment tree correctly', () => { const tree = { path: '/', @@ -1638,15 +1817,27 @@ describe('processRouteMasks', { sequential: true }, () => { fullPath: '/', } as AnyRoute const { processedTree } = processRouteTree(routeTree) - it('processes a route masks list into a segment tree', () => { - const routeMasks: Array> = [ - { from: '/a/b/c', routeTree }, - { from: '/a/b/d', routeTree }, - { from: '/a/$param/d', routeTree }, - { from: '/a/{-$optional}/d', routeTree }, - { from: '/a/b/{$}.txt', routeTree }, - ] + const routeMasks: Array> = [ + { from: '/a/b/c', routeTree }, + { from: '/a/b/d', routeTree }, + { from: '/a/$param/d', routeTree }, + { from: '/a/{-$optional}/d', routeTree }, + { from: '/a/b/{$}.txt', routeTree }, + { from: '/a/$', routeTree }, + { from: '/a/foo{$}', routeTree }, + { from: '/a/foo{$}bar', routeTree }, + { from: '/required/$param', routeTree }, + { from: '/required/foo{$param}bar', routeTree }, + { from: '/optional/{-$param}', routeTree }, + { from: '/optional/foo{-$param}bar', routeTree }, + { from: '/Admin/Panel', routeTree }, + ] + + beforeAll(() => { processRouteMasks(routeMasks, processedTree) + }) + + it('processes a route masks list into a segment tree', () => { const aBranch = processedTree.masksTree?.staticInsensitive?.get('a') expect(aBranch).toBeDefined() expect(aBranch?.staticInsensitive?.get('b')).toBeDefined() @@ -1657,6 +1848,10 @@ describe('processRouteMasks', { sequential: true }, () => { const res = findFlatMatch('/a/b/c', processedTree) expect(res?.route.from).toBe('/a/b/c') }) + it('matches uppercase static route masks case-insensitively', () => { + const res = findFlatMatch('/admin/panel', processedTree) + expect(res?.route.from).toBe('/Admin/Panel') + }) it('can match dynamic route masks w/ `findFlatMatch`', () => { const res = findFlatMatch('/a/123/d', processedTree) expect(res?.route.from).toBe('/a/$param/d') @@ -1672,4 +1867,44 @@ describe('processRouteMasks', { sequential: true }, () => { expect(res?.route.from).toBe('/a/b/{$}.txt') expect(res?.rawParams).toEqual({ '*': 'file/path', _splat: 'file/path' }) }) + it('sorts competing wildcard masks by specificity', () => { + const res = findFlatMatch('/a/fooxbar', processedTree) + expect(res?.route.from).toBe('/a/foo{$}bar') + expect(res?.rawParams).toEqual({ '*': 'x', _splat: 'x' }) + }) + it('sorts competing required masks by specificity', () => { + const res = findFlatMatch('/required/fooxbar', processedTree) + expect(res?.route.from).toBe('/required/foo{$param}bar') + expect(res?.rawParams).toEqual({ param: 'x' }) + }) + it('sorts competing optional masks by specificity', () => { + const res = findFlatMatch('/optional/fooxbar', processedTree) + expect(res?.route.from).toBe('/optional/foo{-$param}bar') + expect(res?.rawParams).toEqual({ param: 'x' }) + }) + it('sorts competing route masks declared least-specific first', () => { + const localTree = processRouteTree(routeTree).processedTree + processRouteMasks( + [ + { from: '/dynamic/$param', routeTree }, + { from: '/dynamic/prefix{$param}', routeTree }, + { from: '/optional/{-$param}', routeTree }, + { from: '/optional/prefix{-$param}', routeTree }, + { from: '/wildcard/$', routeTree }, + { from: '/wildcard/prefix{$}', routeTree }, + { from: '/wildcard/prefix{$}.txt', routeTree }, + ], + localTree, + ) + + expect(findFlatMatch('/dynamic/prefixvalue', localTree)?.route.from).toBe( + '/dynamic/prefix{$param}', + ) + expect(findFlatMatch('/optional/prefixvalue', localTree)?.route.from).toBe( + '/optional/prefix{-$param}', + ) + expect( + findFlatMatch('/wildcard/prefixvalue.txt', localTree)?.route.from, + ).toBe('/wildcard/prefix{$}.txt') + }) })