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/router-core/src/new-process-route-tree.ts b/packages/router-core/src/new-process-route-tree.ts index 64abb685b6a..9fa3f91a0b1 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) { + 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 = { @@ -713,11 +723,8 @@ function findMatch( const leaf = getNodeMatch(path, parts, segmentTree, fuzzy) if (!leaf) 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, @@ -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 + if (path === '/' && segmentTree.index) + return { node: segmentTree.index, skipped: 0 } + 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 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 (isBeyondPath && node.index) { + stack.push({ + node: node.index, + index, + 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', }, 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), 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)); 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, + ]), ]), ]), ]),