Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions packages/router-generator/src/filesystem/virtual/getRouteNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,25 @@ function ensureLeadingUnderScore(id: string) {
return `_${id}`
}

function flattenTree(node: RouteNode): Array<RouteNode> {
function flattenTree(
node: RouteNode,
parentRoutePath?: string,
): Array<RouteNode> {
// Store the explicit parent's routePath for virtual routes.
// This prevents the generator from auto-nesting based on path matching (#5822).
//
// Skip when the parent is the synthetic virtual root (`/${rootPathId}`).
// Root-level nodes should use path-based inference to find their parent.
const isRootParent = parentRoutePath === `/${rootPathId}`
if (parentRoutePath !== undefined && !isRootParent) {
node._virtualParentRoutePath = parentRoutePath
}

const result = [node]

if (node.children) {
for (const child of node.children) {
result.push(...flattenTree(child))
result.push(...flattenTree(child, node.routePath))
}
}
delete node.children
Expand Down
15 changes: 15 additions & 0 deletions packages/router-generator/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,21 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
}
}

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

if (parentRoute) node.parent = parentRoute

node.path = determineNodePath(node)
Expand Down
7 changes: 7 additions & 0 deletions packages/router-generator/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ export type RouteNode = {
children?: Array<RouteNode>
parent?: RouteNode
createFileRouteProps?: Set<string>
/**
* For virtual routes: the routePath of the explicit parent from virtual config.
* Used to prevent auto-nesting siblings based on path prefix matching (#5822).
* Falls back to path-based inference if the explicit parent is not found
* (e.g., when the parent is a virtual file-less route that gets filtered out).
*/
_virtualParentRoutePath?: string
}

export interface GetRouteNodesResult {
Expand Down
31 changes: 31 additions & 0 deletions packages/router-generator/tests/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,37 @@ function rewriteConfigByFolderName(folderName: string, config: Config) {
config.indexToken = /[a-z]+-page/
config.routeToken = /[a-z]+-layout/
break
case 'virtual-sibling-routes':
{
// Test case for issue #5822: Virtual routes should respect explicit sibling relationships
// Routes /posts and /posts/$id should remain siblings under the layout,
// NOT auto-nested based on path matching
const virtualRouteConfig = rootRoute('__root.tsx', [
layout('_main', 'layout.tsx', [
route('/posts', 'posts.tsx'),
route('/posts/$id', 'post-detail.tsx'),
]),
])
config.virtualRouteConfig = virtualRouteConfig
}
break
case 'virtual-nested-layouts-with-virtual-route':
{
// Test case for nested layouts with a virtual file-less route in between.
const virtualRouteConfig = rootRoute('__root.tsx', [
index('home.tsx'),
layout('first', 'layout/first-layout.tsx', [
layout('layout/second-layout.tsx', [
route('route-without-file', [
route('/layout-a', 'a.tsx'),
route('/layout-b', 'b.tsx'),
]),
]),
]),
])
config.virtualRouteConfig = virtualRouteConfig
}
break
default:
break
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/* eslint-disable */

// @ts-nocheck

// noinspection JSUnusedGlobalSymbols

// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.

import { Route as rootRouteImport } from './routes/__root'
import { Route as layoutFirstLayoutRouteImport } from './routes/layout/first-layout'
import { Route as homeRouteImport } from './routes/home'
import { Route as layoutSecondLayoutRouteImport } from './routes/layout/second-layout'
import { Route as bRouteImport } from './routes/b'
import { Route as aRouteImport } from './routes/a'

const layoutFirstLayoutRoute = layoutFirstLayoutRouteImport.update({
id: '/_first',
getParentRoute: () => rootRouteImport,
} as any)
const homeRoute = homeRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const layoutSecondLayoutRoute = layoutSecondLayoutRouteImport.update({
id: '/_second-layout',
getParentRoute: () => layoutFirstLayoutRoute,
} as any)
const bRoute = bRouteImport.update({
id: '/route-without-file/layout-b',
path: '/route-without-file/layout-b',
getParentRoute: () => layoutSecondLayoutRoute,
} as any)
const aRoute = aRouteImport.update({
id: '/route-without-file/layout-a',
path: '/route-without-file/layout-a',
getParentRoute: () => layoutSecondLayoutRoute,
} as any)

export interface FileRoutesByFullPath {
'/': typeof homeRoute
'/route-without-file/layout-a': typeof aRoute
'/route-without-file/layout-b': typeof bRoute
}
export interface FileRoutesByTo {
'/': typeof homeRoute
'/route-without-file/layout-a': typeof aRoute
'/route-without-file/layout-b': typeof bRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof homeRoute
'/_first': typeof layoutFirstLayoutRouteWithChildren
'/_first/_second-layout': typeof layoutSecondLayoutRouteWithChildren
'/_first/_second-layout/route-without-file/layout-a': typeof aRoute
'/_first/_second-layout/route-without-file/layout-b': typeof bRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/route-without-file/layout-a'
| '/route-without-file/layout-b'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/route-without-file/layout-a' | '/route-without-file/layout-b'
id:
| '__root__'
| '/'
| '/_first'
| '/_first/_second-layout'
| '/_first/_second-layout/route-without-file/layout-a'
| '/_first/_second-layout/route-without-file/layout-b'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
homeRoute: typeof homeRoute
layoutFirstLayoutRoute: typeof layoutFirstLayoutRouteWithChildren
}

declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/_first': {
id: '/_first'
path: ''
fullPath: '/'
preLoaderRoute: typeof layoutFirstLayoutRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof homeRouteImport
parentRoute: typeof rootRouteImport
}
'/_first/_second-layout': {
id: '/_first/_second-layout'
path: ''
fullPath: '/'
preLoaderRoute: typeof layoutSecondLayoutRouteImport
parentRoute: typeof layoutFirstLayoutRoute
}
'/_first/_second-layout/route-without-file/layout-b': {
id: '/_first/_second-layout/route-without-file/layout-b'
path: '/route-without-file/layout-b'
fullPath: '/route-without-file/layout-b'
preLoaderRoute: typeof bRouteImport
parentRoute: typeof layoutSecondLayoutRoute
}
'/_first/_second-layout/route-without-file/layout-a': {
id: '/_first/_second-layout/route-without-file/layout-a'
path: '/route-without-file/layout-a'
fullPath: '/route-without-file/layout-a'
preLoaderRoute: typeof aRouteImport
parentRoute: typeof layoutSecondLayoutRoute
}
}
}

interface layoutSecondLayoutRouteChildren {
aRoute: typeof aRoute
bRoute: typeof bRoute
}

const layoutSecondLayoutRouteChildren: layoutSecondLayoutRouteChildren = {
aRoute: aRoute,
bRoute: bRoute,
}

const layoutSecondLayoutRouteWithChildren =
layoutSecondLayoutRoute._addFileChildren(layoutSecondLayoutRouteChildren)

interface layoutFirstLayoutRouteChildren {
layoutSecondLayoutRoute: typeof layoutSecondLayoutRouteWithChildren
}

const layoutFirstLayoutRouteChildren: layoutFirstLayoutRouteChildren = {
layoutSecondLayoutRoute: layoutSecondLayoutRouteWithChildren,
}

const layoutFirstLayoutRouteWithChildren =
layoutFirstLayoutRoute._addFileChildren(layoutFirstLayoutRouteChildren)

const rootRouteChildren: RootRouteChildren = {
homeRoute: homeRoute,
layoutFirstLayoutRoute: layoutFirstLayoutRouteWithChildren,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createRootRoute } from '@tanstack/react-router'

export const Route = createRootRoute()
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute(
'/_first/_second-layout/route-without-file/layout-a',
)({
component: () => 'Layout A',
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute(
'/_first/_second-layout/route-without-file/layout-b',
)({
component: () => 'Layout B',
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/')({
component: () => 'Home',
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Outlet, createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_first')({
component: () => (
<div>
<div>First Layout</div>
<Outlet />
</div>
),
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Outlet, createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_first/_second-layout')({
component: () => (
<div>
<div>Second Layout (nested)</div>
<Outlet />
</div>
),
})
Loading
Loading