From b048734180c8e4c1c13bdeb31f39a24bd6335a89 Mon Sep 17 00:00:00 2001 From: Manuel Schiller Date: Thu, 22 Jan 2026 23:05:53 +0100 Subject: [PATCH] fix(router-generator): respect explicit virtual route siblings fixes #5822 --- .../src/filesystem/virtual/getRouteNodes.ts | 17 +- packages/router-generator/src/generator.ts | 15 ++ packages/router-generator/src/types.ts | 7 + .../router-generator/tests/generator.test.ts | 31 ++++ .../routeTree.snapshot.ts | 152 ++++++++++++++++++ .../routes/__root.tsx | 3 + .../routes/a.tsx | 7 + .../routes/b.tsx | 7 + .../routes/home.tsx | 5 + .../routes/layout/first-layout.tsx | 10 ++ .../routes/layout/second-layout.tsx | 10 ++ .../routeTree.snapshot.ts | 103 ++++++++++++ .../virtual-sibling-routes/routes/__root.tsx | 3 + .../virtual-sibling-routes/routes/layout.tsx | 5 + .../routes/post-detail.tsx | 5 + .../virtual-sibling-routes/routes/posts.tsx | 5 + 16 files changed, 383 insertions(+), 2 deletions(-) create mode 100644 packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routeTree.snapshot.ts create mode 100644 packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/__root.tsx create mode 100644 packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/a.tsx create mode 100644 packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/b.tsx create mode 100644 packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/home.tsx create mode 100644 packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/layout/first-layout.tsx create mode 100644 packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/layout/second-layout.tsx create mode 100644 packages/router-generator/tests/generator/virtual-sibling-routes/routeTree.snapshot.ts create mode 100644 packages/router-generator/tests/generator/virtual-sibling-routes/routes/__root.tsx create mode 100644 packages/router-generator/tests/generator/virtual-sibling-routes/routes/layout.tsx create mode 100644 packages/router-generator/tests/generator/virtual-sibling-routes/routes/post-detail.tsx create mode 100644 packages/router-generator/tests/generator/virtual-sibling-routes/routes/posts.tsx diff --git a/packages/router-generator/src/filesystem/virtual/getRouteNodes.ts b/packages/router-generator/src/filesystem/virtual/getRouteNodes.ts index c7d2db03076..793abf9bd76 100644 --- a/packages/router-generator/src/filesystem/virtual/getRouteNodes.ts +++ b/packages/router-generator/src/filesystem/virtual/getRouteNodes.ts @@ -26,12 +26,25 @@ function ensureLeadingUnderScore(id: string) { return `_${id}` } -function flattenTree(node: RouteNode): Array { +function flattenTree( + node: RouteNode, + parentRoutePath?: string, +): Array { + // 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 diff --git a/packages/router-generator/src/generator.ts b/packages/router-generator/src/generator.ts index 62cab9a106d..bb3f92735b2 100644 --- a/packages/router-generator/src/generator.ts +++ b/packages/router-generator/src/generator.ts @@ -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) diff --git a/packages/router-generator/src/types.ts b/packages/router-generator/src/types.ts index d8b00e15588..09a4d2baf22 100644 --- a/packages/router-generator/src/types.ts +++ b/packages/router-generator/src/types.ts @@ -13,6 +13,13 @@ export type RouteNode = { children?: Array parent?: RouteNode createFileRouteProps?: Set + /** + * 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 { diff --git a/packages/router-generator/tests/generator.test.ts b/packages/router-generator/tests/generator.test.ts index 485767e51aa..a5fd5815b69 100644 --- a/packages/router-generator/tests/generator.test.ts +++ b/packages/router-generator/tests/generator.test.ts @@ -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 } diff --git a/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routeTree.snapshot.ts b/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routeTree.snapshot.ts new file mode 100644 index 00000000000..e991cd39645 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routeTree.snapshot.ts @@ -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() diff --git a/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/__root.tsx b/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/__root.tsx new file mode 100644 index 00000000000..9c657c7d5b4 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/__root.tsx @@ -0,0 +1,3 @@ +import { createRootRoute } from '@tanstack/react-router' + +export const Route = createRootRoute() diff --git a/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/a.tsx b/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/a.tsx new file mode 100644 index 00000000000..5fdae7492ff --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/a.tsx @@ -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', +}) diff --git a/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/b.tsx b/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/b.tsx new file mode 100644 index 00000000000..76b51a31f44 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/b.tsx @@ -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', +}) diff --git a/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/home.tsx b/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/home.tsx new file mode 100644 index 00000000000..81b0ad9d39b --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/home.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: () => 'Home', +}) diff --git a/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/layout/first-layout.tsx b/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/layout/first-layout.tsx new file mode 100644 index 00000000000..8d5e091521a --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/layout/first-layout.tsx @@ -0,0 +1,10 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_first')({ + component: () => ( +
+
First Layout
+ +
+ ), +}) diff --git a/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/layout/second-layout.tsx b/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/layout/second-layout.tsx new file mode 100644 index 00000000000..d1b590afbc6 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-nested-layouts-with-virtual-route/routes/layout/second-layout.tsx @@ -0,0 +1,10 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_first/_second-layout')({ + component: () => ( +
+
Second Layout (nested)
+ +
+ ), +}) diff --git a/packages/router-generator/tests/generator/virtual-sibling-routes/routeTree.snapshot.ts b/packages/router-generator/tests/generator/virtual-sibling-routes/routeTree.snapshot.ts new file mode 100644 index 00000000000..8bd4dede1e4 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-sibling-routes/routeTree.snapshot.ts @@ -0,0 +1,103 @@ +/* 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 layoutRouteImport } from './routes/layout' +import { Route as postsRouteImport } from './routes/posts' +import { Route as postDetailRouteImport } from './routes/post-detail' + +const layoutRoute = layoutRouteImport.update({ + id: '/_main', + getParentRoute: () => rootRouteImport, +} as any) +const postsRoute = postsRouteImport.update({ + id: '/posts', + path: '/posts', + getParentRoute: () => layoutRoute, +} as any) +const postDetailRoute = postDetailRouteImport.update({ + id: '/posts/$id', + path: '/posts/$id', + getParentRoute: () => layoutRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof layoutRouteWithChildren + '/posts': typeof postsRoute + '/posts/$id': typeof postDetailRoute +} +export interface FileRoutesByTo { + '/': typeof layoutRouteWithChildren + '/posts': typeof postsRoute + '/posts/$id': typeof postDetailRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/_main': typeof layoutRouteWithChildren + '/_main/posts': typeof postsRoute + '/_main/posts/$id': typeof postDetailRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/posts' | '/posts/$id' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/posts' | '/posts/$id' + id: '__root__' | '/_main' | '/_main/posts' | '/_main/posts/$id' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + layoutRoute: typeof layoutRouteWithChildren +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/_main': { + id: '/_main' + path: '' + fullPath: '/' + preLoaderRoute: typeof layoutRouteImport + parentRoute: typeof rootRouteImport + } + '/_main/posts': { + id: '/_main/posts' + path: '/posts' + fullPath: '/posts' + preLoaderRoute: typeof postsRouteImport + parentRoute: typeof layoutRoute + } + '/_main/posts/$id': { + id: '/_main/posts/$id' + path: '/posts/$id' + fullPath: '/posts/$id' + preLoaderRoute: typeof postDetailRouteImport + parentRoute: typeof layoutRoute + } + } +} + +interface layoutRouteChildren { + postsRoute: typeof postsRoute + postDetailRoute: typeof postDetailRoute +} + +const layoutRouteChildren: layoutRouteChildren = { + postsRoute: postsRoute, + postDetailRoute: postDetailRoute, +} + +const layoutRouteWithChildren = + layoutRoute._addFileChildren(layoutRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + layoutRoute: layoutRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/packages/router-generator/tests/generator/virtual-sibling-routes/routes/__root.tsx b/packages/router-generator/tests/generator/virtual-sibling-routes/routes/__root.tsx new file mode 100644 index 00000000000..9c657c7d5b4 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-sibling-routes/routes/__root.tsx @@ -0,0 +1,3 @@ +import { createRootRoute } from '@tanstack/react-router' + +export const Route = createRootRoute() diff --git a/packages/router-generator/tests/generator/virtual-sibling-routes/routes/layout.tsx b/packages/router-generator/tests/generator/virtual-sibling-routes/routes/layout.tsx new file mode 100644 index 00000000000..4d1465f31ab --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-sibling-routes/routes/layout.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_main')({ + component: () => 'Layout', +}) diff --git a/packages/router-generator/tests/generator/virtual-sibling-routes/routes/post-detail.tsx b/packages/router-generator/tests/generator/virtual-sibling-routes/routes/post-detail.tsx new file mode 100644 index 00000000000..071a5cbb026 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-sibling-routes/routes/post-detail.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_main/posts/$id')({ + component: () => 'Post Detail', +}) diff --git a/packages/router-generator/tests/generator/virtual-sibling-routes/routes/posts.tsx b/packages/router-generator/tests/generator/virtual-sibling-routes/routes/posts.tsx new file mode 100644 index 00000000000..b540b23bae7 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-sibling-routes/routes/posts.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_main/posts')({ + component: () => 'Posts List', +})