From 6d72eb6950f4f9a698310355b7e954eeab283522 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 21 Dec 2025 12:18:22 +0100 Subject: [PATCH 1/9] refactor(router-core): index routes have index nodes in the segment tree --- .../router-core/src/new-process-route-tree.ts | 129 ++++++++++++------ .../tests/new-process-route-tree.test.ts | 2 +- 2 files changed, 86 insertions(+), 45 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 64abb685b6a..6a4f50d5f1d 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -7,13 +7,22 @@ export const SEGMENT_TYPE_PATHNAME = 0 export const SEGMENT_TYPE_PARAM = 1 export const SEGMENT_TYPE_WILDCARD = 2 export const SEGMENT_TYPE_OPTIONAL_PARAM = 3 +const SEGMENT_TYPE_INDEX = 4 +/** + * All the kinds of segments that can be present in a route path. + */ export type SegmentKind = | typeof SEGMENT_TYPE_PATHNAME | typeof SEGMENT_TYPE_PARAM | typeof SEGMENT_TYPE_WILDCARD | typeof SEGMENT_TYPE_OPTIONAL_PARAM +/** + * All the kinds of segments that can be present in the segment tree. + */ +type ExtendedSegmentKind = SegmentKind | typeof SEGMENT_TYPE_INDEX + const PARAM_W_CURLY_BRACES_RE = /^([^{]*)\{\$([a-zA-Z_$][a-zA-Z0-9_$]*)\}([^}]*)$/ // prefix{$paramName}suffix const OPTIONAL_PARAM_W_CURLY_BRACES_RE = @@ -326,20 +335,26 @@ function parseSegments( } node = nextNode } - if ((route.path || !route.children) && !route.isRoot) { - const isIndex = path.endsWith('/') - // we cannot fuzzy match an index route, - // but if there is *also* a layout route at this path, save it as notFound - // we can use it when fuzzy matching to display the NotFound component in the layout route - if (!isIndex) node.notFound = route - // does the new route take precedence over an existing one? - // yes if previous is not an index route and new one is an index route - if (!node.route || (!node.isIndex && isIndex)) { - node.route = route - // when replacing, replace all attributes that are route-specific (`fullPath` only at the moment) - node.fullPath = route.fullPath ?? route.from - } - node.isIndex ||= isIndex + + const isLeaf = (route.path || !route.children) && !route.isRoot + + // create index node + if (isLeaf && path.endsWith('/')) { + const indexNode = createStaticNode( + route.fullPath ?? route.from, + ) + indexNode.kind = SEGMENT_TYPE_INDEX + indexNode.parent = node + depth++ + indexNode.depth = depth + node.index = indexNode + node = indexNode + } + + // make node "matchable" + if (isLeaf) { + node.route = route + node.fullPath = route.fullPath ?? route.from } } if (route.children) @@ -417,6 +432,7 @@ function createStaticNode( return { kind: SEGMENT_TYPE_PATHNAME, depth: 0, + index: null, static: null, staticInsensitive: null, dynamic: null, @@ -425,8 +441,6 @@ function createStaticNode( route: null, fullPath, parent: null, - isIndex: false, - notFound: null, } } @@ -447,6 +461,7 @@ function createDynamicNode( return { kind, depth: 0, + index: null, static: null, staticInsensitive: null, dynamic: null, @@ -455,8 +470,6 @@ function createDynamicNode( route: null, fullPath, parent: null, - isIndex: false, - notFound: null, caseSensitive, prefix, suffix, @@ -464,7 +477,7 @@ function createDynamicNode( } type StaticSegmentNode = SegmentNode & { - kind: typeof SEGMENT_TYPE_PATHNAME + kind: typeof SEGMENT_TYPE_PATHNAME | typeof SEGMENT_TYPE_INDEX } type DynamicSegmentNode = SegmentNode & { @@ -482,12 +495,15 @@ type AnySegmentNode = | DynamicSegmentNode type SegmentNode = { - kind: SegmentKind + kind: ExtendedSegmentKind + + /** Exact index segment (highest priority) */ + index: StaticSegmentNode | null - /** Static segments (highest priority) */ + /** Static segments (2nd priority) */ static: Map> | null - /** Case insensitive static segments (second highest priority) */ + /** Case insensitive static segments (3rd highest priority) */ staticInsensitive: Map> | null /** Dynamic segments ($param) */ @@ -508,12 +524,6 @@ type SegmentNode = { parent: AnySegmentNode | null depth: number - - /** is it an index route (trailing / path), only valid for nodes with a `route` */ - isIndex: boolean - - /** Same as `route`, but only present if both an "index route" and a "layout route" exist at this path */ - notFound: T | null } type RouteLike = { @@ -711,13 +721,10 @@ function findMatch( ): { route: T; params: Record } | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) - if (!leaf) return null + if (!leaf?.node.route) return null const params = extractParams(path, parts, leaf) - const isFuzzyMatch = '**' in leaf - if (isFuzzyMatch) params['**'] = leaf['**'] - const route = isFuzzyMatch - ? (leaf.node.notFound ?? leaf.node.route!) - : leaf.node.route! + if ('**' in leaf) params['**'] = leaf['**']! + const route = leaf.node.route return { route, params, @@ -727,7 +734,7 @@ function findMatch( function extractParams( path: string, parts: Array, - leaf: { node: AnySegmentNode; skipped: number }, + leaf: { node: AnySegmentNode; skipped?: number }, ) { const list = buildBranch(leaf.node) let nodeParts: Array | null = null @@ -761,7 +768,7 @@ function extractParams( params[name] = decodeURIComponent(part!) } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { - if (leaf.skipped & (1 << nodeIndex)) { + if (leaf.skipped! & (1 << nodeIndex)) { partIndex-- // stay on the same part continue } @@ -837,6 +844,11 @@ function getNodeMatch( segmentTree: AnySegmentNode, fuzzy: boolean, ) { + // quick check for root index + // this is an optimization, algorithm should work correctly without this block + // TODO: it doesn't actually work correctly without this block + if (path === '/' && segmentTree.index) return { node: segmentTree.index } + const trailingSlash = !last(parts) const pathIsIndex = trailingSlash && path !== '/' const partsLength = parts.length - (trailingSlash ? 1 : 0) @@ -872,22 +884,36 @@ function getNodeMatch( let { node, index, skipped, depth, statics, dynamics, optionals } = frame // In fuzzy mode, track the best partial match we've found so far - if (fuzzy && node.notFound && isFrameMoreSpecific(bestFuzzy, frame)) { + if ( + fuzzy && + node.route && + node.kind !== SEGMENT_TYPE_INDEX && + isFrameMoreSpecific(bestFuzzy, frame) + ) { bestFuzzy = frame } - const isBeyondPath = index === partsLength + const isBeyondPath = index >= partsLength if (isBeyondPath) { - if (node.route && (!pathIsIndex || node.isIndex)) { + if (node.route && (!pathIsIndex || node.kind === SEGMENT_TYPE_INDEX)) { if (isFrameMoreSpecific(bestMatch, frame)) { bestMatch = frame } // perfect match, no need to continue - if (statics === partsLength && node.isIndex) return bestMatch + // this is an optimization, algorithm should work correctly without this block + if ( + statics === partsLength && + !dynamics && + !optionals && + !skipped && + node.kind === SEGMENT_TYPE_INDEX + ) { + return bestMatch + } } - // beyond the length of the path parts, only skipped optional segments or wildcard segments can match - if (!node.optional && !node.wildcard) continue + // beyond the length of the path parts, only index segments, or skipped optional segments, or wildcard segments can match + if (!node.optional && !node.wildcard && !node.index) continue } const part = isBeyondPath ? undefined : parts[index]! @@ -1022,6 +1048,19 @@ function getNodeMatch( }) } } + + // 0. Try index match + if (node.index && index > 1 && index >= partsLength) { + stack.push({ + node: node.index, + index: index + 1, + skipped, + depth: depth + 1, + statics, + dynamics, + optionals, + }) + } } if (bestMatch && wildcardMatch) { @@ -1064,8 +1103,10 @@ function isFrameMoreSpecific( (next.dynamics === prev.dynamics && (next.optionals > prev.optionals || (next.optionals === prev.optionals && - (next.node.isIndex > prev.node.isIndex || - (next.node.isIndex === prev.node.isIndex && + ((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.test.ts b/packages/router-core/tests/new-process-route-tree.test.ts index 746cd470928..47083567e21 100644 --- a/packages/router-core/tests/new-process-route-tree.test.ts +++ b/packages/router-core/tests/new-process-route-tree.test.ts @@ -718,7 +718,7 @@ describe('findRouteMatch', () => { path: 'dashboard/', }, { - id: '/dashboard', + id: '/dashboard/invoices', fullPath: '/dashboard/invoices', path: 'invoices', }, From 8c87deefc0e009efa43e43ce55a1046d4f3c38c0 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 21 Dec 2025 12:35:34 +0100 Subject: [PATCH 2/9] fixes --- packages/router-core/src/new-process-route-tree.ts | 2 +- packages/router-plugin/src/core/route-hmr-statement.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 6a4f50d5f1d..78203bb1d54 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -352,7 +352,7 @@ function parseSegments( } // make node "matchable" - if (isLeaf) { + if (isLeaf && !node.route) { node.route = route node.fullPath = route.fullPath ?? route.from } diff --git a/packages/router-plugin/src/core/route-hmr-statement.ts b/packages/router-plugin/src/core/route-hmr-statement.ts index a5771ebee7e..b910c1340b4 100644 --- a/packages/router-plugin/src/core/route-hmr-statement.ts +++ b/packages/router-plugin/src/core/route-hmr-statement.ts @@ -40,8 +40,7 @@ function handleRouteUpdate( node: AnyRouter['processedTree']['segmentTree'], ) { if (node.route?.id === route.id) node.route = route - if (node.notFound?.id === route.id) node.notFound = route - + if (node.index) walkReplaceSegmentTree(route, node.index) node.static?.forEach((child) => walkReplaceSegmentTree(route, child)) node.staticInsensitive?.forEach((child) => walkReplaceSegmentTree(route, child), From d6d4c26dab76caf679869ded7c9585f6195990df Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 21 Dec 2025 12:40:24 +0100 Subject: [PATCH 3/9] fix root index matching --- packages/router-core/src/new-process-route-tree.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 78203bb1d54..3597356fc22 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -846,7 +846,6 @@ function getNodeMatch( ) { // quick check for root index // this is an optimization, algorithm should work correctly without this block - // TODO: it doesn't actually work correctly without this block if (path === '/' && segmentTree.index) return { node: segmentTree.index } const trailingSlash = !last(parts) @@ -1050,7 +1049,7 @@ function getNodeMatch( } // 0. Try index match - if (node.index && index > 1 && index >= partsLength) { + if (node.index && index >= partsLength) { stack.push({ node: node.index, index: index + 1, From cd36c32f735117811260fbe77b28330312bc445e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 21 Dec 2025 12:43:33 +0100 Subject: [PATCH 4/9] fix leaf test in findMatch --- packages/router-core/src/new-process-route-tree.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 3597356fc22..c426498c820 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -721,10 +721,10 @@ function findMatch( ): { route: T; params: Record } | null { const parts = path.split('/') const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) - if (!leaf?.node.route) return null + if (!leaf) return null const params = extractParams(path, parts, leaf) if ('**' in leaf) params['**'] = leaf['**']! - const route = leaf.node.route + const route = leaf.node.route! return { route, params, From 0fe3ad6894ee57b5517533424084ae6083a2c5c3 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 21 Dec 2025 12:47:07 +0100 Subject: [PATCH 5/9] ok this optimization wasn't necessary and makes the code less understandable --- packages/router-core/src/new-process-route-tree.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index c426498c820..6578aa699aa 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -734,7 +734,7 @@ function findMatch( function extractParams( path: string, parts: Array, - leaf: { node: AnySegmentNode; skipped?: number }, + leaf: { node: AnySegmentNode; skipped: number }, ) { const list = buildBranch(leaf.node) let nodeParts: Array | null = null @@ -768,7 +768,7 @@ function extractParams( params[name] = decodeURIComponent(part!) } } else if (node.kind === SEGMENT_TYPE_OPTIONAL_PARAM) { - if (leaf.skipped! & (1 << nodeIndex)) { + if (leaf.skipped & (1 << nodeIndex)) { partIndex-- // stay on the same part continue } @@ -846,7 +846,8 @@ function getNodeMatch( ) { // quick check for root index // this is an optimization, algorithm should work correctly without this block - if (path === '/' && segmentTree.index) return { node: segmentTree.index } + if (path === '/' && segmentTree.index) + return { node: segmentTree.index, skipped: 0 } const trailingSlash = !last(parts) const pathIsIndex = trailingSlash && path !== '/' From a7a19a44bad0acc4e55df97a6758095dc99ede6e Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 21 Dec 2025 13:28:00 +0100 Subject: [PATCH 6/9] update test snapshots in HMR --- .../tests/add-hmr/snapshots/react/arrow-function@true.tsx | 2 +- .../tests/add-hmr/snapshots/react/function-declaration@true.tsx | 2 +- .../tests/add-hmr/snapshots/solid/arrow-function@true.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx index f80eab81e9f..5a3588a7dc8 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/arrow-function@true.tsx @@ -34,7 +34,7 @@ if (import.meta.hot) { ; function walkReplaceSegmentTree(route, node) { if (node.route?.id === route.id) node.route = route; - if (node.notFound?.id === route.id) node.notFound = route; + if (node.index) walkReplaceSegmentTree(route, node.index); node.static?.forEach(child => walkReplaceSegmentTree(route, child)); node.staticInsensitive?.forEach(child => walkReplaceSegmentTree(route, child)); node.dynamic?.forEach(child => walkReplaceSegmentTree(route, child)); diff --git a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx index 1664f20bbbc..bee310532ff 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/react/function-declaration@true.tsx @@ -34,7 +34,7 @@ if (import.meta.hot) { ; function walkReplaceSegmentTree(route, node) { if (node.route?.id === route.id) node.route = route; - if (node.notFound?.id === route.id) node.notFound = route; + if (node.index) walkReplaceSegmentTree(route, node.index); node.static?.forEach(child => walkReplaceSegmentTree(route, child)); node.staticInsensitive?.forEach(child => walkReplaceSegmentTree(route, child)); node.dynamic?.forEach(child => walkReplaceSegmentTree(route, child)); diff --git a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx index e140124caa2..3ad31b8eb2b 100644 --- a/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx +++ b/packages/router-plugin/tests/add-hmr/snapshots/solid/arrow-function@true.tsx @@ -33,7 +33,7 @@ if (import.meta.hot) { ; function walkReplaceSegmentTree(route, node) { if (node.route?.id === route.id) node.route = route; - if (node.notFound?.id === route.id) node.notFound = route; + if (node.index) walkReplaceSegmentTree(route, node.index); node.static?.forEach(child => walkReplaceSegmentTree(route, child)); node.staticInsensitive?.forEach(child => walkReplaceSegmentTree(route, child)); node.dynamic?.forEach(child => walkReplaceSegmentTree(route, child)); From 86cee603d82c7506f61176f2c0e4a05447edb170 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 21 Dec 2025 13:50:08 +0100 Subject: [PATCH 7/9] fix incorrect link.test setup --- packages/react-router/tests/link.test.tsx | 14 ++++++++++++-- packages/solid-router/tests/link.test.tsx | 14 ++++++++++++-- packages/vue-router/tests/link.test.tsx | 14 ++++++++++++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/react-router/tests/link.test.tsx b/packages/react-router/tests/link.test.tsx index bbb9fd8f042..63495f44027 100644 --- a/packages/react-router/tests/link.test.tsx +++ b/packages/react-router/tests/link.test.tsx @@ -2044,10 +2044,16 @@ describe('Link', () => { const postRoute = createRoute({ getParentRoute: () => postsRoute, - path: '$postId/', + path: '$postId', component: PostComponent, }) + const postIndexRoute = createRoute({ + getParentRoute: () => postRoute, + path: '/', + component: () =>
Post Index
, + }) + const DetailsComponent = () => { return ( <> @@ -2080,7 +2086,11 @@ describe('Link', () => { indexRoute, layoutRoute.addChildren([ postsRoute.addChildren([ - postRoute.addChildren([detailsRoute, informationRoute]), + postRoute.addChildren([ + postIndexRoute, + detailsRoute, + informationRoute, + ]), ]), ]), ]), diff --git a/packages/solid-router/tests/link.test.tsx b/packages/solid-router/tests/link.test.tsx index 8d9f36374d8..5adfee22474 100644 --- a/packages/solid-router/tests/link.test.tsx +++ b/packages/solid-router/tests/link.test.tsx @@ -2064,10 +2064,16 @@ describe('Link', () => { const postRoute = createRoute({ getParentRoute: () => postsRoute, - path: '$postId/', + path: '$postId', component: PostComponent, }) + const postIndexRoute = createRoute({ + getParentRoute: () => postRoute, + path: '/', + component: () =>
Post Index
, + }) + const DetailsComponent = () => { return ( <> @@ -2100,7 +2106,11 @@ describe('Link', () => { indexRoute, layoutRoute.addChildren([ postsRoute.addChildren([ - postRoute.addChildren([detailsRoute, informationRoute]), + postRoute.addChildren([ + postIndexRoute, + detailsRoute, + informationRoute, + ]), ]), ]), ]), diff --git a/packages/vue-router/tests/link.test.tsx b/packages/vue-router/tests/link.test.tsx index b4abc5c5818..b49cc08fb8b 100644 --- a/packages/vue-router/tests/link.test.tsx +++ b/packages/vue-router/tests/link.test.tsx @@ -2068,10 +2068,16 @@ describe('Link', () => { const postRoute = createRoute({ getParentRoute: () => postsRoute, - path: '$postId/', + path: '$postId', component: PostComponent, }) + const postIndexRoute = createRoute({ + getParentRoute: () => postRoute, + path: '/', + component: () =>
Post Index
, + }) + const DetailsComponent = () => { return ( <> @@ -2104,7 +2110,11 @@ describe('Link', () => { indexRoute, layoutRoute.addChildren([ postsRoute.addChildren([ - postRoute.addChildren([detailsRoute, informationRoute]), + postRoute.addChildren([ + postIndexRoute, + detailsRoute, + informationRoute, + ]), ]), ]), ]), From c4b1a06a6f55ff5c7c964fc1386bb8239484aa81 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 21 Dec 2025 15:01:04 +0100 Subject: [PATCH 8/9] cleanup --- packages/router-core/src/new-process-route-tree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 6578aa699aa..49f5a40dcc1 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -1050,7 +1050,7 @@ function getNodeMatch( } // 0. Try index match - if (node.index && index >= partsLength) { + if (isBeyondPath && node.index) { stack.push({ node: node.index, index: index + 1, From a8b8021139e1f21e979e462e14ed8c15d4ce0f92 Mon Sep 17 00:00:00 2001 From: Sheraff Date: Sun, 21 Dec 2025 15:16:12 +0100 Subject: [PATCH 9/9] no need to increment segment index beyond path length --- packages/router-core/src/new-process-route-tree.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 49f5a40dcc1..9fa3f91a0b1 100644 --- a/packages/router-core/src/new-process-route-tree.ts +++ b/packages/router-core/src/new-process-route-tree.ts @@ -893,7 +893,7 @@ function getNodeMatch( bestFuzzy = frame } - const isBeyondPath = index >= partsLength + const isBeyondPath = index === partsLength if (isBeyondPath) { if (node.route && (!pathIsIndex || node.kind === SEGMENT_TYPE_INDEX)) { if (isFrameMoreSpecific(bestMatch, frame)) { @@ -1053,7 +1053,7 @@ function getNodeMatch( if (isBeyondPath && node.index) { stack.push({ node: node.index, - index: index + 1, + index, skipped, depth: depth + 1, statics,