Skip to content

Commit b048734

Browse files
fix(router-generator): respect explicit virtual route siblings
fixes #5822
1 parent d0b472e commit b048734

File tree

16 files changed

+383
-2
lines changed

16 files changed

+383
-2
lines changed

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,25 @@ 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 (#5822).
35+
//
36+
// Skip when the parent is the synthetic virtual root (`/${rootPathId}`).
37+
// Root-level nodes should use path-based inference to find their parent.
38+
const isRootParent = parentRoutePath === `/${rootPathId}`
39+
if (parentRoutePath !== undefined && !isRootParent) {
40+
node._virtualParentRoutePath = parentRoutePath
41+
}
42+
3043
const result = [node]
3144

3245
if (node.children) {
3346
for (const child of node.children) {
34-
result.push(...flattenTree(child))
47+
result.push(...flattenTree(child, node.routePath))
3548
}
3649
}
3750
delete node.children

packages/router-generator/src/generator.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1453,6 +1453,21 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
14531453
}
14541454
}
14551455

1456+
// Virtual routes may have an explicit parent from virtual config.
1457+
// If we can find that exact parent, use it to prevent auto-nesting siblings
1458+
// based on path prefix matching. If the explicit parent is not found (e.g.,
1459+
// it was a virtual file-less route that got filtered out), keep using the
1460+
// path-based parent we already computed above.
1461+
if (node._virtualParentRoutePath !== undefined) {
1462+
const explicitParent =
1463+
acc.routeNodesByPath.get(node._virtualParentRoutePath) ??
1464+
prefixMap.get(node._virtualParentRoutePath)
1465+
if (explicitParent) {
1466+
parentRoute = explicitParent
1467+
}
1468+
// If not found, parentRoute stays as the path-based result (fallback)
1469+
}
1470+
14561471
if (parentRoute) node.parent = parentRoute
14571472

14581473
node.path = determineNodePath(node)

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+
* For virtual routes: the routePath of the explicit parent from virtual config.
18+
* Used to prevent auto-nesting siblings based on path prefix matching (#5822).
19+
* Falls back to path-based inference if the explicit parent is not found
20+
* (e.g., when the parent is a virtual file-less route that gets filtered out).
21+
*/
22+
_virtualParentRoutePath?: string
1623
}
1724

1825
export interface GetRouteNodesResult {

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,37 @@ 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
182+
case 'virtual-nested-layouts-with-virtual-route':
183+
{
184+
// Test case for nested layouts with a virtual file-less route in between.
185+
const virtualRouteConfig = rootRoute('__root.tsx', [
186+
index('home.tsx'),
187+
layout('first', 'layout/first-layout.tsx', [
188+
layout('layout/second-layout.tsx', [
189+
route('route-without-file', [
190+
route('/layout-a', 'a.tsx'),
191+
route('/layout-b', 'b.tsx'),
192+
]),
193+
]),
194+
]),
195+
])
196+
config.virtualRouteConfig = virtualRouteConfig
197+
}
198+
break
168199
default:
169200
break
170201
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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 layoutFirstLayoutRouteImport } from './routes/layout/first-layout'
13+
import { Route as homeRouteImport } from './routes/home'
14+
import { Route as layoutSecondLayoutRouteImport } from './routes/layout/second-layout'
15+
import { Route as bRouteImport } from './routes/b'
16+
import { Route as aRouteImport } from './routes/a'
17+
18+
const layoutFirstLayoutRoute = layoutFirstLayoutRouteImport.update({
19+
id: '/_first',
20+
getParentRoute: () => rootRouteImport,
21+
} as any)
22+
const homeRoute = homeRouteImport.update({
23+
id: '/',
24+
path: '/',
25+
getParentRoute: () => rootRouteImport,
26+
} as any)
27+
const layoutSecondLayoutRoute = layoutSecondLayoutRouteImport.update({
28+
id: '/_second-layout',
29+
getParentRoute: () => layoutFirstLayoutRoute,
30+
} as any)
31+
const bRoute = bRouteImport.update({
32+
id: '/route-without-file/layout-b',
33+
path: '/route-without-file/layout-b',
34+
getParentRoute: () => layoutSecondLayoutRoute,
35+
} as any)
36+
const aRoute = aRouteImport.update({
37+
id: '/route-without-file/layout-a',
38+
path: '/route-without-file/layout-a',
39+
getParentRoute: () => layoutSecondLayoutRoute,
40+
} as any)
41+
42+
export interface FileRoutesByFullPath {
43+
'/': typeof homeRoute
44+
'/route-without-file/layout-a': typeof aRoute
45+
'/route-without-file/layout-b': typeof bRoute
46+
}
47+
export interface FileRoutesByTo {
48+
'/': typeof homeRoute
49+
'/route-without-file/layout-a': typeof aRoute
50+
'/route-without-file/layout-b': typeof bRoute
51+
}
52+
export interface FileRoutesById {
53+
__root__: typeof rootRouteImport
54+
'/': typeof homeRoute
55+
'/_first': typeof layoutFirstLayoutRouteWithChildren
56+
'/_first/_second-layout': typeof layoutSecondLayoutRouteWithChildren
57+
'/_first/_second-layout/route-without-file/layout-a': typeof aRoute
58+
'/_first/_second-layout/route-without-file/layout-b': typeof bRoute
59+
}
60+
export interface FileRouteTypes {
61+
fileRoutesByFullPath: FileRoutesByFullPath
62+
fullPaths:
63+
| '/'
64+
| '/route-without-file/layout-a'
65+
| '/route-without-file/layout-b'
66+
fileRoutesByTo: FileRoutesByTo
67+
to: '/' | '/route-without-file/layout-a' | '/route-without-file/layout-b'
68+
id:
69+
| '__root__'
70+
| '/'
71+
| '/_first'
72+
| '/_first/_second-layout'
73+
| '/_first/_second-layout/route-without-file/layout-a'
74+
| '/_first/_second-layout/route-without-file/layout-b'
75+
fileRoutesById: FileRoutesById
76+
}
77+
export interface RootRouteChildren {
78+
homeRoute: typeof homeRoute
79+
layoutFirstLayoutRoute: typeof layoutFirstLayoutRouteWithChildren
80+
}
81+
82+
declare module '@tanstack/react-router' {
83+
interface FileRoutesByPath {
84+
'/_first': {
85+
id: '/_first'
86+
path: ''
87+
fullPath: '/'
88+
preLoaderRoute: typeof layoutFirstLayoutRouteImport
89+
parentRoute: typeof rootRouteImport
90+
}
91+
'/': {
92+
id: '/'
93+
path: '/'
94+
fullPath: '/'
95+
preLoaderRoute: typeof homeRouteImport
96+
parentRoute: typeof rootRouteImport
97+
}
98+
'/_first/_second-layout': {
99+
id: '/_first/_second-layout'
100+
path: ''
101+
fullPath: '/'
102+
preLoaderRoute: typeof layoutSecondLayoutRouteImport
103+
parentRoute: typeof layoutFirstLayoutRoute
104+
}
105+
'/_first/_second-layout/route-without-file/layout-b': {
106+
id: '/_first/_second-layout/route-without-file/layout-b'
107+
path: '/route-without-file/layout-b'
108+
fullPath: '/route-without-file/layout-b'
109+
preLoaderRoute: typeof bRouteImport
110+
parentRoute: typeof layoutSecondLayoutRoute
111+
}
112+
'/_first/_second-layout/route-without-file/layout-a': {
113+
id: '/_first/_second-layout/route-without-file/layout-a'
114+
path: '/route-without-file/layout-a'
115+
fullPath: '/route-without-file/layout-a'
116+
preLoaderRoute: typeof aRouteImport
117+
parentRoute: typeof layoutSecondLayoutRoute
118+
}
119+
}
120+
}
121+
122+
interface layoutSecondLayoutRouteChildren {
123+
aRoute: typeof aRoute
124+
bRoute: typeof bRoute
125+
}
126+
127+
const layoutSecondLayoutRouteChildren: layoutSecondLayoutRouteChildren = {
128+
aRoute: aRoute,
129+
bRoute: bRoute,
130+
}
131+
132+
const layoutSecondLayoutRouteWithChildren =
133+
layoutSecondLayoutRoute._addFileChildren(layoutSecondLayoutRouteChildren)
134+
135+
interface layoutFirstLayoutRouteChildren {
136+
layoutSecondLayoutRoute: typeof layoutSecondLayoutRouteWithChildren
137+
}
138+
139+
const layoutFirstLayoutRouteChildren: layoutFirstLayoutRouteChildren = {
140+
layoutSecondLayoutRoute: layoutSecondLayoutRouteWithChildren,
141+
}
142+
143+
const layoutFirstLayoutRouteWithChildren =
144+
layoutFirstLayoutRoute._addFileChildren(layoutFirstLayoutRouteChildren)
145+
146+
const rootRouteChildren: RootRouteChildren = {
147+
homeRoute: homeRoute,
148+
layoutFirstLayoutRoute: layoutFirstLayoutRouteWithChildren,
149+
}
150+
export const routeTree = rootRouteImport
151+
._addFileChildren(rootRouteChildren)
152+
._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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
3+
export const Route = createFileRoute(
4+
'/_first/_second-layout/route-without-file/layout-a',
5+
)({
6+
component: () => 'Layout A',
7+
})
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
3+
export const Route = createFileRoute(
4+
'/_first/_second-layout/route-without-file/layout-b',
5+
)({
6+
component: () => 'Layout B',
7+
})
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('/')({
4+
component: () => 'Home',
5+
})
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Outlet, createFileRoute } from '@tanstack/react-router'
2+
3+
export const Route = createFileRoute('/_first')({
4+
component: () => (
5+
<div>
6+
<div>First Layout</div>
7+
<Outlet />
8+
</div>
9+
),
10+
})

0 commit comments

Comments
 (0)