From 4c425e02837fb899b9aa7090d8faac2699dbbdb2 Mon Sep 17 00:00:00 2001 From: Manuel Schiller <6340397+schiller-manuel@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:42:09 +0200 Subject: [PATCH] fix(router-generator): preserve dots in virtual routes Separate filesystem dot-delimited paths from explicit virtual paths and add regression coverage for layouts, routes, and physical prefixes. fixes #7761 --- .changeset/tidy-virtual-route-dots.md | 6 ++ .../src/filesystem/virtual/getRouteNodes.ts | 14 ++- packages/router-generator/src/utils.ts | 26 +++-- .../router-generator/tests/generator.test.ts | 20 ++++ .../routeTree.snapshot.ts | 77 +++++++++++++ .../routes/direct.tsx | 5 + .../routes/physical-routes/index.tsx | 5 + .../routes/root.tsx | 3 + .../routeTree.snapshot.ts | 101 ++++++++++++++++++ .../routes/index.route.tsx | 5 + .../routes/pathless.layout.tsx | 5 + .../routes/root.route.tsx | 3 + .../routes/subpath.route.tsx | 5 + packages/router-generator/tests/utils.test.ts | 21 ++++ 14 files changed, 286 insertions(+), 10 deletions(-) create mode 100644 .changeset/tidy-virtual-route-dots.md create mode 100644 packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routeTree.snapshot.ts create mode 100644 packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routes/direct.tsx create mode 100644 packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routes/physical-routes/index.tsx create mode 100644 packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routes/root.tsx create mode 100644 packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routeTree.snapshot.ts create mode 100644 packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/index.route.tsx create mode 100644 packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/pathless.layout.tsx create mode 100644 packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/root.route.tsx create mode 100644 packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/subpath.route.tsx diff --git a/.changeset/tidy-virtual-route-dots.md b/.changeset/tidy-virtual-route-dots.md new file mode 100644 index 0000000000..72bbb5565b --- /dev/null +++ b/.changeset/tidy-virtual-route-dots.md @@ -0,0 +1,6 @@ +--- +'@tanstack/router-generator': patch +--- + +Preserve dots in explicit virtual route paths and pathless layout IDs instead +of treating them as flat-file route separators. diff --git a/packages/router-generator/src/filesystem/virtual/getRouteNodes.ts b/packages/router-generator/src/filesystem/virtual/getRouteNodes.ts index 509b1be321..5d3cd610be 100644 --- a/packages/router-generator/src/filesystem/virtual/getRouteNodes.ts +++ b/packages/router-generator/src/filesystem/virtual/getRouteNodes.ts @@ -3,7 +3,7 @@ import { cleanPath, createLiteralRoutePathSegmentMetadata, createRoutePathSegmentMetadata, - determineInitialRoutePath, + determineInitialRoutePathFromExplicitPath, joinRoutePathSegmentMetadata, removeExt, removeLeadingSlash, @@ -156,7 +156,9 @@ export async function getRouteNodesRecursive( routePath: routePathPrefix, originalRoutePath: originalRoutePathPrefix, } = node.pathPrefix - ? determineInitialRoutePath(removeLeadingSlash(node.pathPrefix)) + ? determineInitialRoutePathFromExplicitPath( + removeLeadingSlash(node.pathPrefix), + ) : { routePath: '', originalRoutePath: '' } const { routeNodes, physicalDirectories } = await getRouteNodesPhysical( { @@ -240,7 +242,9 @@ export async function getRouteNodesRecursive( const { routePath: escapedSegment, originalRoutePath: originalSegment, - } = determineInitialRoutePath(removeLeadingSlash(lastSegment)) + } = determineInitialRoutePathFromExplicitPath( + removeLeadingSlash(lastSegment), + ) const routePath = `${parentRoutePath}${escapedSegment}` const originalRoutePath = `${parentOriginalRoutePath}${originalSegment}` const routePathSegmentMetadata = @@ -305,7 +309,9 @@ export async function getRouteNodesRecursive( const { routePath: escapedSegment, originalRoutePath: originalSegment, - } = determineInitialRoutePath(removeLeadingSlash(lastSegment)) + } = determineInitialRoutePathFromExplicitPath( + removeLeadingSlash(lastSegment), + ) const routePath = `${parentRoutePath}${escapedSegment}` // Store the original path with brackets for escape detection const originalRoutePath = `${parentOriginalRoutePath}${originalSegment}` diff --git a/packages/router-generator/src/utils.ts b/packages/router-generator/src/utils.ts index f0a7986b1c..747980aa7b 100644 --- a/packages/router-generator/src/utils.ts +++ b/packages/router-generator/src/utils.ts @@ -186,13 +186,12 @@ const DISALLOWED_ESCAPE_CHARS = new Set([ '%', ]) -export function determineInitialRoutePath(routePath: string) { +function determineInitialRoutePathFromParts( + routePath: string, + parts: Array, +) { const originalRoutePath = - cleanPath( - `/${(cleanPath(routePath) || '').split(SPLIT_REGEX).join('/')}`, - ) || '' - - const parts = routePath.split(SPLIT_REGEX) + cleanPath(`/${cleanPath(parts.join('/')) || ''}`) || '' // Escape any characters that in square brackets // we keep the original path untouched @@ -231,6 +230,21 @@ export function determineInitialRoutePath(routePath: string) { } } +export function determineInitialRoutePath(routePath: string) { + return determineInitialRoutePathFromParts( + routePath, + routePath.split(SPLIT_REGEX), + ) +} + +/** + * Resolves bracket escapes in an explicit route path or ID without applying + * the dot-delimited flat-file route convention. + */ +export function determineInitialRoutePathFromExplicitPath(routePath: string) { + return determineInitialRoutePathFromParts(routePath, [routePath]) +} + /** * Checks if a segment is fully escaped (entirely wrapped in brackets with no nested brackets). * E.g., "[index]" -> true, "[_layout]" -> true, "foo[.]bar" -> false, "index" -> false diff --git a/packages/router-generator/tests/generator.test.ts b/packages/router-generator/tests/generator.test.ts index 79f7d505b2..3e0831fe91 100644 --- a/packages/router-generator/tests/generator.test.ts +++ b/packages/router-generator/tests/generator.test.ts @@ -166,6 +166,26 @@ function rewriteConfigByFolderName(folderName: string, config: Config) { config.virtualRouteConfig = virtualRouteConfig } break + case 'virtual-pathless-layout-dotted-filename': + { + const virtualRouteConfig = rootRoute('root.route.tsx', [ + index('index.route.tsx'), + layout('pathless.layout.tsx', [ + route('subpath', 'subpath.route.tsx'), + ]), + ]) + config.virtualRouteConfig = virtualRouteConfig + } + break + case 'virtual-explicit-dotted-paths': + { + const virtualRouteConfig = rootRoute('root.tsx', [ + route('direct.path', 'direct.tsx'), + physical('/mounted.path', 'physical-routes'), + ]) + config.virtualRouteConfig = virtualRouteConfig + } + break case 'virtual-root-sibling-routes': { // Test case for issue #5431: Virtual routes that are siblings at the root level diff --git a/packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routeTree.snapshot.ts b/packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routeTree.snapshot.ts new file mode 100644 index 0000000000..6a5263d55b --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routeTree.snapshot.ts @@ -0,0 +1,77 @@ +/* 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 directRouteImport } from './routes/direct' +import { Route as MountedDotpathIndexRouteImport } from './routes/physical-routes/index' + +const directRoute = directRouteImport.update({ + id: '/direct.path', + path: '/direct.path', + getParentRoute: () => rootRouteImport, +} as any) +const MountedDotpathIndexRoute = MountedDotpathIndexRouteImport.update({ + id: '/mounted.path/', + path: '/mounted.path/', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/direct.path': typeof directRoute + '/mounted.path/': typeof MountedDotpathIndexRoute +} +export interface FileRoutesByTo { + '/direct.path': typeof directRoute + '/mounted.path': typeof MountedDotpathIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/direct.path': typeof directRoute + '/mounted.path/': typeof MountedDotpathIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/direct.path' | '/mounted.path/' + fileRoutesByTo: FileRoutesByTo + to: '/direct.path' | '/mounted.path' + id: '__root__' | '/direct.path' | '/mounted.path/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + directRoute: typeof directRoute + MountedDotpathIndexRoute: typeof MountedDotpathIndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/direct.path': { + id: '/direct.path' + path: '/direct.path' + fullPath: '/direct.path' + preLoaderRoute: typeof directRouteImport + parentRoute: typeof rootRouteImport + } + '/mounted.path/': { + id: '/mounted.path/' + path: '/mounted.path' + fullPath: '/mounted.path/' + preLoaderRoute: typeof MountedDotpathIndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + directRoute: directRoute, + MountedDotpathIndexRoute: MountedDotpathIndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routes/direct.tsx b/packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routes/direct.tsx new file mode 100644 index 0000000000..e56ad97d40 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routes/direct.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/direct.path')({ + component: () => 'Direct', +}) diff --git a/packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routes/physical-routes/index.tsx b/packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routes/physical-routes/index.tsx new file mode 100644 index 0000000000..2e768e44ac --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routes/physical-routes/index.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/mounted.path/')({ + component: () => 'Mounted index', +}) diff --git a/packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routes/root.tsx b/packages/router-generator/tests/generator/virtual-explicit-dotted-paths/routes/root.tsx new file mode 100644 index 0000000000..9c657c7d5b --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-explicit-dotted-paths/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-pathless-layout-dotted-filename/routeTree.snapshot.ts b/packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routeTree.snapshot.ts new file mode 100644 index 0000000000..e9d9980378 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routeTree.snapshot.ts @@ -0,0 +1,101 @@ +/* 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.route' +import { Route as indexDotrouteRouteImport } from './routes/index.route' +import { Route as pathlessDotlayoutRouteImport } from './routes/pathless.layout' +import { Route as subpathDotrouteRouteImport } from './routes/subpath.route' + +const indexDotrouteRoute = indexDotrouteRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const pathlessDotlayoutRoute = pathlessDotlayoutRouteImport.update({ + id: '/_pathless.layout', + getParentRoute: () => rootRouteImport, +} as any) +const subpathDotrouteRoute = subpathDotrouteRouteImport.update({ + id: '/subpath', + path: '/subpath', + getParentRoute: () => pathlessDotlayoutRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof indexDotrouteRoute + '/subpath': typeof subpathDotrouteRoute +} +export interface FileRoutesByTo { + '/': typeof indexDotrouteRoute + '/subpath': typeof subpathDotrouteRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof indexDotrouteRoute + '/_pathless.layout': typeof pathlessDotlayoutRouteWithChildren + '/_pathless.layout/subpath': typeof subpathDotrouteRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/subpath' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/subpath' + id: '__root__' | '/' | '/_pathless.layout' | '/_pathless.layout/subpath' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + indexDotrouteRoute: typeof indexDotrouteRoute + pathlessDotlayoutRoute: typeof pathlessDotlayoutRouteWithChildren +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof indexDotrouteRouteImport + parentRoute: typeof rootRouteImport + } + '/_pathless.layout': { + id: '/_pathless.layout' + path: '' + fullPath: '/' + preLoaderRoute: typeof pathlessDotlayoutRouteImport + parentRoute: typeof rootRouteImport + } + '/_pathless.layout/subpath': { + id: '/_pathless.layout/subpath' + path: '/subpath' + fullPath: '/subpath' + preLoaderRoute: typeof subpathDotrouteRouteImport + parentRoute: typeof pathlessDotlayoutRoute + } + } +} + +interface pathlessDotlayoutRouteChildren { + subpathDotrouteRoute: typeof subpathDotrouteRoute +} + +const pathlessDotlayoutRouteChildren: pathlessDotlayoutRouteChildren = { + subpathDotrouteRoute: subpathDotrouteRoute, +} + +const pathlessDotlayoutRouteWithChildren = + pathlessDotlayoutRoute._addFileChildren(pathlessDotlayoutRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + indexDotrouteRoute: indexDotrouteRoute, + pathlessDotlayoutRoute: pathlessDotlayoutRouteWithChildren, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/index.route.tsx b/packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/index.route.tsx new file mode 100644 index 0000000000..379428d7b2 --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/index.route.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: () => 'Index', +}) diff --git a/packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/pathless.layout.tsx b/packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/pathless.layout.tsx new file mode 100644 index 0000000000..03c79de0aa --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/pathless.layout.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_pathless.layout')({ + component: () => 'Pathless Layout', +}) diff --git a/packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/root.route.tsx b/packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/root.route.tsx new file mode 100644 index 0000000000..9c657c7d5b --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/root.route.tsx @@ -0,0 +1,3 @@ +import { createRootRoute } from '@tanstack/react-router' + +export const Route = createRootRoute() diff --git a/packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/subpath.route.tsx b/packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/subpath.route.tsx new file mode 100644 index 0000000000..6c28fa210f --- /dev/null +++ b/packages/router-generator/tests/generator/virtual-pathless-layout-dotted-filename/routes/subpath.route.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/_pathless.layout/subpath')({ + component: () => 'Subpath', +}) diff --git a/packages/router-generator/tests/utils.test.ts b/packages/router-generator/tests/utils.test.ts index 1d04d6b90d..504dacc0e6 100644 --- a/packages/router-generator/tests/utils.test.ts +++ b/packages/router-generator/tests/utils.test.ts @@ -8,6 +8,7 @@ import { createRouteNodesByTo, createTokenRegex, determineInitialRoutePath, + determineInitialRoutePathFromExplicitPath, hasEscapedLeadingUnderscore, hasEscapedTrailingUnderscore, inferFullPath, @@ -308,6 +309,26 @@ describe('determineInitialRoutePath', () => { }) }) +describe('determineInitialRoutePathFromExplicitPath', () => { + it('preserves dots as literal route path characters', () => { + expect( + determineInitialRoutePathFromExplicitPath('pathless.layout'), + ).toStrictEqual({ + routePath: '/pathless.layout', + originalRoutePath: '/pathless.layout', + }) + }) + + it('resolves bracket escapes without applying file-route separators', () => { + expect( + determineInitialRoutePathFromExplicitPath('pathless[.]layout'), + ).toStrictEqual({ + routePath: '/pathless.layout', + originalRoutePath: '/pathless[.]layout', + }) + }) +}) + describe('multiSortBy', () => { it('sorts by single accessor', () => { const arr = [{ v: 3 }, { v: 1 }, { v: 2 }]