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
6 changes: 6 additions & 0 deletions .changeset/tidy-virtual-route-dots.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 10 additions & 4 deletions packages/router-generator/src/filesystem/virtual/getRouteNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
cleanPath,
createLiteralRoutePathSegmentMetadata,
createRoutePathSegmentMetadata,
determineInitialRoutePath,
determineInitialRoutePathFromExplicitPath,
joinRoutePathSegmentMetadata,
removeExt,
removeLeadingSlash,
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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}`
Expand Down
26 changes: 20 additions & 6 deletions packages/router-generator/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,13 +186,12 @@ const DISALLOWED_ESCAPE_CHARS = new Set([
'%',
])

export function determineInitialRoutePath(routePath: string) {
function determineInitialRoutePathFromParts(
routePath: string,
parts: Array<string>,
) {
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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions packages/router-generator/tests/generator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FileRouteTypes>()
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/direct.path')({
component: () => 'Direct',
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/mounted.path/')({
component: () => 'Mounted index',
})
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,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<FileRouteTypes>()
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: () => 'Index',
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_pathless.layout')({
component: () => 'Pathless Layout',
})
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,5 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/_pathless.layout/subpath')({
component: () => 'Subpath',
})
21 changes: 21 additions & 0 deletions packages/router-generator/tests/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createRouteNodesByTo,
createTokenRegex,
determineInitialRoutePath,
determineInitialRoutePathFromExplicitPath,
hasEscapedLeadingUnderscore,
hasEscapedTrailingUnderscore,
inferFullPath,
Expand Down Expand Up @@ -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 }]
Expand Down
Loading