Skip to content

Commit 479d556

Browse files
fix(router-generator): respect explicit virtual route siblings
Preserve virtual-config parentage during flattening and prefer it during parent resolution so sibling routes like /posts and /posts/$id stay siblings fixes #5822
1 parent d0b472e commit 479d556

File tree

9 files changed

+207
-31
lines changed

9 files changed

+207
-31
lines changed

packages/router-generator/src/filesystem/virtual/getRouteNodes.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,27 @@ function ensureLeadingUnderScore(id: string) {
2626
return `_${id}`
2727
}
2828

29-
function flattenTree(node: RouteNode): Array<RouteNode> {
29+
function flattenTree(
30+
node: RouteNode,
31+
parentRoutePath?: string,
32+
): Array<RouteNode> {
33+
// Store the explicit parent's routePath for virtual routes.
34+
// This prevents the generator from auto-nesting based on path matching.
35+
//
36+
// Skip setting an explicit virtual parent when the parent is the synthetic
37+
// virtual root (`/${rootPathId}`). This keeps top-level virtual routes using
38+
// the generator's normal parent inference (instead of forcing them to attach
39+
// directly to the root route node).
40+
const isRootParent = parentRoutePath === `/${rootPathId}`
41+
if (parentRoutePath !== undefined && !isRootParent) {
42+
node._virtualParentRoutePath = parentRoutePath
43+
}
44+
3045
const result = [node]
3146

3247
if (node.children) {
3348
for (const child of node.children) {
34-
result.push(...flattenTree(child))
49+
result.push(...flattenTree(child, node.routePath))
3550
}
3651
}
3752
delete node.children

packages/router-generator/src/generator.ts

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1420,35 +1420,54 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
14201420
prefixMap: RoutePrefixMap,
14211421
config?: Config,
14221422
) {
1423-
let parentRoute = hasParentRoute(prefixMap, node, node.routePath)
1424-
1425-
// Check routeNodesByPath for a closer parent that may not be in prefixMap.
1426-
//
1427-
// Why: The prefixMap excludes lazy routes by design. When lazy-only routes are
1428-
// nested inside a pathless layout, the virtual route created from the lazy file
1429-
// won't be in the prefixMap, but it will be in routeNodesByPath.
1430-
//
1431-
// Example: Given files _layout/path.lazy.tsx and _layout/path.index.lazy.tsx:
1432-
// - prefixMap contains: /_layout (from route.tsx)
1433-
// - routeNodesByPath contains: /_layout AND /_layout/path (virtual from lazy)
1434-
// - For /_layout/path/, hasParentRoute returns /_layout (wrong)
1435-
// - But the correct parent is /_layout/path (the virtual route from path.lazy.tsx)
1436-
//
1437-
// Optimization: Only search if we might find a closer parent. The search walks
1438-
// up from the immediate parent path, so if the first candidate matches what
1439-
// prefixMap found, there's no closer parent to find.
1440-
if (node.routePath) {
1441-
const lastSlash = node.routePath.lastIndexOf('/')
1442-
if (lastSlash > 0) {
1443-
const immediateParentPath = node.routePath.substring(0, lastSlash)
1444-
const candidate = acc.routeNodesByPath.get(immediateParentPath)
1445-
if (
1446-
candidate &&
1447-
candidate.routePath !== node.routePath &&
1448-
candidate !== parentRoute
1449-
) {
1450-
// Found a closer parent in routeNodesByPath that differs from prefixMap result
1451-
parentRoute = candidate
1423+
let parentRoute: RouteNode | null = null
1424+
1425+
// Virtual routes with explicit parent-child relationships should respect
1426+
// the explicit parent instead of auto-nesting based on path matching.
1427+
// Check if this node has an explicit parent from virtual config.
1428+
if (node._virtualParentRoutePath !== undefined) {
1429+
// Look up the explicit parent in routeNodesByPath
1430+
const explicitParent = acc.routeNodesByPath.get(
1431+
node._virtualParentRoutePath,
1432+
)
1433+
if (explicitParent) {
1434+
parentRoute = explicitParent
1435+
} else {
1436+
// If the explicit parent hasn't been processed yet, try prefixMap
1437+
parentRoute = prefixMap.get(node._virtualParentRoutePath) ?? null
1438+
}
1439+
} else {
1440+
// Standard path-based parent inference
1441+
parentRoute = hasParentRoute(prefixMap, node, node.routePath)
1442+
1443+
// Check routeNodesByPath for a closer parent that may not be in prefixMap.
1444+
//
1445+
// Why: The prefixMap excludes lazy routes by design. When lazy-only routes are
1446+
// nested inside a pathless layout, the virtual route created from the lazy file
1447+
// won't be in the prefixMap, but it will be in routeNodesByPath.
1448+
//
1449+
// Example: Given files _layout/path.lazy.tsx and _layout/path.index.lazy.tsx:
1450+
// - prefixMap contains: /_layout (from route.tsx)
1451+
// - routeNodesByPath contains: /_layout AND /_layout/path (virtual from lazy)
1452+
// - For /_layout/path/, hasParentRoute returns /_layout (wrong)
1453+
// - But the correct parent is /_layout/path (the virtual route from path.lazy.tsx)
1454+
//
1455+
// Optimization: Only search if we might find a closer parent. The search walks
1456+
// up from the immediate parent path, so if the first candidate matches what
1457+
// prefixMap found, there's no closer parent to find.
1458+
if (node.routePath) {
1459+
const lastSlash = node.routePath.lastIndexOf('/')
1460+
if (lastSlash > 0) {
1461+
const immediateParentPath = node.routePath.substring(0, lastSlash)
1462+
const candidate = acc.routeNodesByPath.get(immediateParentPath)
1463+
if (
1464+
candidate &&
1465+
candidate.routePath !== node.routePath &&
1466+
candidate !== parentRoute
1467+
) {
1468+
// Found a closer parent in routeNodesByPath that differs from prefixMap result
1469+
parentRoute = candidate
1470+
}
14521471
}
14531472
}
14541473
}

packages/router-generator/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ export type RouteNode = {
1313
children?: Array<RouteNode>
1414
parent?: RouteNode
1515
createFileRouteProps?: Set<string>
16+
/**
17+
* When a route is defined in virtual config with explicit parent-child relationships,
18+
* this property stores the parent's routePath. This prevents the generator from
19+
* auto-nesting routes based on path prefix matching.
20+
* See issue #5822 for context.
21+
*/
22+
_virtualParentRoutePath?: string
1623
}
1724

1825
export interface GetRouteNodesResult {

packages/router-generator/tests/generator.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,20 @@ function rewriteConfigByFolderName(folderName: string, config: Config) {
165165
config.indexToken = /[a-z]+-page/
166166
config.routeToken = /[a-z]+-layout/
167167
break
168+
case 'virtual-sibling-routes':
169+
{
170+
// Test case for issue #5822: Virtual routes should respect explicit sibling relationships
171+
// Routes /posts and /posts/$id should remain siblings under the layout,
172+
// NOT auto-nested based on path matching
173+
const virtualRouteConfig = rootRoute('__root.tsx', [
174+
layout('_main', 'layout.tsx', [
175+
route('/posts', 'posts.tsx'),
176+
route('/posts/$id', 'post-detail.tsx'),
177+
]),
178+
])
179+
config.virtualRouteConfig = virtualRouteConfig
180+
}
181+
break
168182
default:
169183
break
170184
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/* eslint-disable */
2+
3+
// @ts-nocheck
4+
5+
// noinspection JSUnusedGlobalSymbols
6+
7+
// This file was automatically generated by TanStack Router.
8+
// You should NOT make any changes in this file as it will be overwritten.
9+
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10+
11+
import { Route as rootRouteImport } from './routes/__root'
12+
import { Route as layoutRouteImport } from './routes/layout'
13+
import { Route as postsRouteImport } from './routes/posts'
14+
import { Route as postDetailRouteImport } from './routes/post-detail'
15+
16+
const layoutRoute = layoutRouteImport.update({
17+
id: '/_main',
18+
getParentRoute: () => rootRouteImport,
19+
} as any)
20+
const postsRoute = postsRouteImport.update({
21+
id: '/posts',
22+
path: '/posts',
23+
getParentRoute: () => layoutRoute,
24+
} as any)
25+
const postDetailRoute = postDetailRouteImport.update({
26+
id: '/posts/$id',
27+
path: '/posts/$id',
28+
getParentRoute: () => layoutRoute,
29+
} as any)
30+
31+
export interface FileRoutesByFullPath {
32+
'/': typeof layoutRouteWithChildren
33+
'/posts': typeof postsRoute
34+
'/posts/$id': typeof postDetailRoute
35+
}
36+
export interface FileRoutesByTo {
37+
'/': typeof layoutRouteWithChildren
38+
'/posts': typeof postsRoute
39+
'/posts/$id': typeof postDetailRoute
40+
}
41+
export interface FileRoutesById {
42+
__root__: typeof rootRouteImport
43+
'/_main': typeof layoutRouteWithChildren
44+
'/_main/posts': typeof postsRoute
45+
'/_main/posts/$id': typeof postDetailRoute
46+
}
47+
export interface FileRouteTypes {
48+
fileRoutesByFullPath: FileRoutesByFullPath
49+
fullPaths: '/' | '/posts' | '/posts/$id'
50+
fileRoutesByTo: FileRoutesByTo
51+
to: '/' | '/posts' | '/posts/$id'
52+
id: '__root__' | '/_main' | '/_main/posts' | '/_main/posts/$id'
53+
fileRoutesById: FileRoutesById
54+
}
55+
export interface RootRouteChildren {
56+
layoutRoute: typeof layoutRouteWithChildren
57+
}
58+
59+
declare module '@tanstack/react-router' {
60+
interface FileRoutesByPath {
61+
'/_main': {
62+
id: '/_main'
63+
path: ''
64+
fullPath: '/'
65+
preLoaderRoute: typeof layoutRouteImport
66+
parentRoute: typeof rootRouteImport
67+
}
68+
'/_main/posts': {
69+
id: '/_main/posts'
70+
path: '/posts'
71+
fullPath: '/posts'
72+
preLoaderRoute: typeof postsRouteImport
73+
parentRoute: typeof layoutRoute
74+
}
75+
'/_main/posts/$id': {
76+
id: '/_main/posts/$id'
77+
path: '/posts/$id'
78+
fullPath: '/posts/$id'
79+
preLoaderRoute: typeof postDetailRouteImport
80+
parentRoute: typeof layoutRoute
81+
}
82+
}
83+
}
84+
85+
interface layoutRouteChildren {
86+
postsRoute: typeof postsRoute
87+
postDetailRoute: typeof postDetailRoute
88+
}
89+
90+
const layoutRouteChildren: layoutRouteChildren = {
91+
postsRoute: postsRoute,
92+
postDetailRoute: postDetailRoute,
93+
}
94+
95+
const layoutRouteWithChildren =
96+
layoutRoute._addFileChildren(layoutRouteChildren)
97+
98+
const rootRouteChildren: RootRouteChildren = {
99+
layoutRoute: layoutRouteWithChildren,
100+
}
101+
export const routeTree = rootRouteImport
102+
._addFileChildren(rootRouteChildren)
103+
._addFileTypes<FileRouteTypes>()
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createRootRoute } from '@tanstack/react-router'
2+
3+
export const Route = createRootRoute()
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
3+
export const Route = createFileRoute('/_main')({
4+
component: () => 'Layout',
5+
})
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
3+
export const Route = createFileRoute('/_main/posts/$id')({
4+
component: () => 'Post Detail',
5+
})
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
3+
export const Route = createFileRoute('/_main/posts')({
4+
component: () => 'Posts List',
5+
})

0 commit comments

Comments
 (0)