Skip to content

Commit 742941e

Browse files
fix: Fix literal underscore paths under pathless layouts (#7408)
1 parent 13432ad commit 742941e

34 files changed

Lines changed: 1082 additions & 48 deletions

File tree

.changeset/quiet-ravens-explain.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/router-generator': patch
3+
---
4+
5+
Fix literal underscore route paths when they are nested under pathless layouts. Virtual `route()` paths now treat leading and trailing underscores as literal URL path characters, while physical file routes continue to use bracket escapes for literal underscore segments.

docs/router/routing/virtual-file-routes.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ export const routes = rootRoute('root.tsx', [
187187
])
188188
```
189189

190+
Because `route` receives an explicit URL path, leading and trailing underscores are treated literally. Use `layout` to create pathless layout routes.
191+
190192
You can also define a virtual route without a file name. This allows to set a common path prefix for its children:
191193

192194
```tsx

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import path from 'node:path'
22
import * as fsp from 'node:fs/promises'
33
import {
44
cleanPath,
5+
createRoutePathSegmentMetadata,
56
determineInitialRoutePath,
67
escapeRegExp,
78
hasEscapedLeadingUnderscore,
9+
joinRoutePathSegmentMetadata,
810
removeExt,
911
replaceBackslash,
1012
routePathToVariable,
@@ -126,11 +128,18 @@ export async function getRouteNodes(
126128
const filePath = replaceBackslash(
127129
path.join(normalizedDir, node.filePath),
128130
)
131+
const prefixPath = cleanPath(`/${normalizedDir}`)
129132
const routePath = cleanPath(`/${normalizedDir}${node.routePath}`)
130133

131134
node.variableName = routePathToVariable(
132135
cleanPath(`/${normalizedDir}/${removeExt(node.filePath)}`),
133136
)
137+
node._routePathSegmentMetadata = joinRoutePathSegmentMetadata(
138+
routePath,
139+
prefixPath,
140+
undefined,
141+
node._routePathSegmentMetadata,
142+
)
134143
node.routePath = routePath
135144
// Keep originalRoutePath aligned with routePath for escape detection
136145
if (node.originalRoutePath) {
@@ -334,6 +343,10 @@ export async function getRouteNodes(
334343
variableName,
335344
_fsRouteType: routeType,
336345
originalRoutePath,
346+
_routePathSegmentMetadata: createRoutePathSegmentMetadata(
347+
routePath,
348+
originalRoutePath,
349+
),
337350
})
338351
}
339352
}),

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

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import path, { join, resolve } from 'node:path'
22
import {
33
cleanPath,
4+
createLiteralRoutePathSegmentMetadata,
5+
createRoutePathSegmentMetadata,
46
determineInitialRoutePath,
7+
joinRoutePathSegmentMetadata,
58
removeExt,
69
removeLeadingSlash,
710
removeTrailingSlash,
@@ -162,16 +165,26 @@ export async function getRouteNodesRecursive(
162165
...physicalDirectories,
163166
)
164167
routeNodes.forEach((subtreeNode) => {
168+
const pathPrefix = cleanPath(
169+
`${parent?.routePath ?? ''}${node.pathPrefix}`,
170+
)
171+
const literalPathPrefixSegments =
172+
createLiteralRoutePathSegmentMetadata(pathPrefix, parent, true)
173+
const routePath = cleanPath(`${pathPrefix}${subtreeNode.routePath}`)
165174
subtreeNode.variableName = routePathToVariable(
166175
`${node.pathPrefix}/${removeExt(subtreeNode.filePath)}`,
167176
)
168-
subtreeNode.routePath = cleanPath(
169-
`${parent?.routePath ?? ''}${node.pathPrefix}${subtreeNode.routePath}`,
177+
subtreeNode._routePathSegmentMetadata = joinRoutePathSegmentMetadata(
178+
routePath,
179+
pathPrefix,
180+
literalPathPrefixSegments,
181+
subtreeNode._routePathSegmentMetadata,
170182
)
183+
subtreeNode.routePath = routePath
171184
// Keep originalRoutePath aligned with routePath for escape detection
172185
if (subtreeNode.originalRoutePath) {
173186
subtreeNode.originalRoutePath = cleanPath(
174-
`${parent?.routePath ?? ''}${node.pathPrefix}${subtreeNode.originalRoutePath}`,
187+
`${parent?.originalRoutePath ?? parent?.routePath ?? ''}${node.pathPrefix}${subtreeNode.originalRoutePath}`,
175188
)
176189
}
177190
subtreeNode.filePath = `${node.directory}/${subtreeNode.filePath}`
@@ -186,6 +199,9 @@ export async function getRouteNodesRecursive(
186199
return { filePath, variableName, fullPath }
187200
}
188201
const parentRoutePath = removeTrailingSlash(parent?.routePath ?? '/')
202+
const parentOriginalRoutePath = removeTrailingSlash(
203+
parent?.originalRoutePath ?? parent?.routePath ?? '/',
204+
)
189205
const virtualParentRoutePath = parent?.routePath ?? `/${rootPathId}`
190206

191207
switch (node.type) {
@@ -212,8 +228,9 @@ export async function getRouteNodesRecursive(
212228
originalRoutePath: originalSegment,
213229
} = determineInitialRoutePath(removeLeadingSlash(lastSegment))
214230
const routePath = `${parentRoutePath}${escapedSegment}`
215-
// Store the original path with brackets for escape detection
216-
const originalRoutePath = `${parentRoutePath}${originalSegment}`
231+
const originalRoutePath = `${parentOriginalRoutePath}${originalSegment}`
232+
const routePathSegmentMetadata =
233+
createLiteralRoutePathSegmentMetadata(routePath, parent, true)
217234

218235
if (node.file) {
219236
const { filePath, variableName, fullPath } = getFile(node.file)
@@ -223,6 +240,7 @@ export async function getRouteNodesRecursive(
223240
variableName,
224241
routePath,
225242
originalRoutePath,
243+
_routePathSegmentMetadata: routePathSegmentMetadata,
226244
_fsRouteType: 'static',
227245
_virtualParentRoutePath: virtualParentRoutePath,
228246
}
@@ -233,6 +251,7 @@ export async function getRouteNodesRecursive(
233251
variableName: routePathToVariable(routePath),
234252
routePath,
235253
originalRoutePath,
254+
_routePathSegmentMetadata: routePathSegmentMetadata,
236255
isVirtual: true,
237256
_fsRouteType: 'static',
238257
_virtualParentRoutePath: virtualParentRoutePath,
@@ -275,14 +294,21 @@ export async function getRouteNodesRecursive(
275294
} = determineInitialRoutePath(removeLeadingSlash(lastSegment))
276295
const routePath = `${parentRoutePath}${escapedSegment}`
277296
// Store the original path with brackets for escape detection
278-
const originalRoutePath = `${parentRoutePath}${originalSegment}`
297+
const originalRoutePath = `${parentOriginalRoutePath}${originalSegment}`
298+
const routePathSegmentMetadata = joinRoutePathSegmentMetadata(
299+
routePath,
300+
parentRoutePath,
301+
parent?._routePathSegmentMetadata,
302+
createRoutePathSegmentMetadata(escapedSegment, originalSegment),
303+
)
279304

280305
const routeNode: RouteNode = {
281306
fullPath,
282307
filePath,
283308
variableName,
284309
routePath,
285310
originalRoutePath,
311+
_routePathSegmentMetadata: routePathSegmentMetadata,
286312
_fsRouteType: 'pathless_layout',
287313
_virtualParentRoutePath: virtualParentRoutePath,
288314
}

packages/router-generator/src/generator.ts

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
buildRouteTreeConfig,
1818
checkFileExists,
1919
checkRouteFullPathUniqueness,
20+
countRoutePathSegments,
21+
countSlashSeparatedParts,
2022
createRouteNodesByFullPath,
2123
createRouteNodesById,
2224
createRouteNodesByTo,
@@ -33,9 +35,8 @@ import {
3335
removeExt,
3436
removeGroups,
3537
removeLastSegmentFromPath,
36-
removeLayoutSegmentsWithEscape,
38+
removeLayoutSegmentsAndUnderscoresWithEscape,
3739
removeTrailingSlash,
38-
removeUnderscoresWithEscape,
3940
replaceBackslash,
4041
trimPathLeft,
4142
} from './utils'
@@ -51,6 +52,7 @@ import type {
5152
HandleNodeAccumulator,
5253
ImportDeclaration,
5354
RouteNode,
55+
RoutePathSegmentMetadata,
5456
} from './types'
5557
import type { Config } from './config'
5658
import type { Logger } from './logger'
@@ -70,6 +72,32 @@ interface fs {
7072
chown: (filePath: string, uid: number, gid: number) => Promise<void>
7173
}
7274

75+
function getRoutePathSegmentMetadataForPath(
76+
node: RouteNode,
77+
routePath: string,
78+
parentRoutePath?: string,
79+
): Array<RoutePathSegmentMetadata | undefined> | undefined {
80+
if (!node._routePathSegmentMetadata) return undefined
81+
82+
const segments = routePath.split('/')
83+
const result = new Array<RoutePathSegmentMetadata | undefined>(
84+
segments.length,
85+
)
86+
const parentSegmentCount = countRoutePathSegments(parentRoutePath)
87+
let hasMetadata = false
88+
let segmentCount = 0
89+
90+
for (let i = 0; i < segments.length; i++) {
91+
if (!segments[i]) continue
92+
const sourceIndex = parentSegmentCount + segmentCount + 1
93+
result[i] = node._routePathSegmentMetadata[sourceIndex]
94+
hasMetadata ||= !!result[i]
95+
segmentCount++
96+
}
97+
98+
return hasMetadata ? result : undefined
99+
}
100+
73101
const DefaultFileSystem: fs = {
74102
stat: async (filePath) => {
75103
const res = await fsp.stat(filePath, { bigint: true })
@@ -392,7 +420,10 @@ export class Generator {
392420

393421
const preRouteNodes = multiSortBy(beforeRouteNodes, [
394422
(d) => (d.routePath === '/' ? -1 : 1),
395-
(d) => d.routePath?.split('/').length,
423+
(d) =>
424+
d.routePath === undefined
425+
? undefined
426+
: countSlashSeparatedParts(d.routePath),
396427
(d) => (d.filePath.match(this.indexTokenFilenameRegex) ? 1 : -1),
397428
(d) => (d.filePath.match(Generator.componentPieceRegex) ? 1 : -1),
398429
(d) => (d.filePath.match(this.routeTokenFilenameRegex) ? -1 : 1),
@@ -587,7 +618,10 @@ export class Generator {
587618

588619
const sortedRouteNodes = multiSortBy(acc.routeNodes, [
589620
(d) => (d.routePath?.includes(`/${rootPathId}`) ? -1 : 1),
590-
(d) => d.routePath?.split('/').length,
621+
(d) =>
622+
d.routePath === undefined
623+
? undefined
624+
: countSlashSeparatedParts(d.routePath),
591625
(d) => {
592626
const segments = d.routePath?.split('/').filter(Boolean) ?? []
593627
const last = segments[segments.length - 1] ?? ''
@@ -1430,29 +1464,39 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
14301464
node.path = determineNodePath(node)
14311465

14321466
const trimmedPath = trimPathLeft(node.path ?? '')
1433-
const trimmedOriginalPath = trimPathLeft(
1434-
node.originalRoutePath?.replace(
1435-
node.parent?.originalRoutePath ?? '',
1436-
'',
1437-
) ?? '',
1467+
const originalPath =
1468+
node.originalRoutePath && node.parent?.originalRoutePath
1469+
? node.originalRoutePath.replace(node.parent.originalRoutePath, '') ||
1470+
'/'
1471+
: node.originalRoutePath
1472+
const routePathSegmentMetadata = getRoutePathSegmentMetadataForPath(
1473+
node,
1474+
node.path ?? '/',
1475+
node.parent?.routePath,
14381476
)
1477+
const trimmedOriginalPath = trimPathLeft(originalPath ?? '')
14391478

14401479
const split = trimmedPath.split('/')
1480+
const pathSplit = (node.path ?? '').split('/')
14411481
const originalSplit = trimmedOriginalPath.split('/')
14421482
const lastRouteSegment = split[split.length - 1] ?? trimmedPath
14431483
const lastOriginalSegment =
14441484
originalSplit[originalSplit.length - 1] ?? trimmedOriginalPath
1485+
const lastRouteSegmentMetadata =
1486+
routePathSegmentMetadata?.[pathSplit.length - 1]
14451487

14461488
// A segment is non-path if it starts with underscore AND the underscore is not escaped
14471489
node.isNonPath =
1448-
isSegmentPathless(lastRouteSegment, lastOriginalSegment) ||
1490+
(!lastRouteSegmentMetadata?.literalLeadingUnderscore &&
1491+
isSegmentPathless(lastRouteSegment, lastOriginalSegment)) ||
14491492
split.every((part) => this.routeGroupPatternRegex.test(part))
14501493

1451-
// Use escape-aware functions to compute cleanedPath
1494+
// Use a single pass so layout removal does not desync escaped underscore checks.
14521495
node.cleanedPath = removeGroups(
1453-
removeUnderscoresWithEscape(
1454-
removeLayoutSegmentsWithEscape(node.path, node.originalRoutePath),
1455-
node.originalRoutePath,
1496+
removeLayoutSegmentsAndUnderscoresWithEscape(
1497+
node.path,
1498+
originalPath,
1499+
routePathSegmentMetadata,
14561500
),
14571501
)
14581502

@@ -1530,13 +1574,17 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
15301574
candidate.originalRoutePath ?? '',
15311575
'',
15321576
) || '/'
1577+
const routePathSegmentMetadataRelativeToParent =
1578+
getRoutePathSegmentMetadataForPath(
1579+
node,
1580+
pathRelativeToParent,
1581+
candidate.routePath,
1582+
)
15331583
node.cleanedPath = removeGroups(
1534-
removeUnderscoresWithEscape(
1535-
removeLayoutSegmentsWithEscape(
1536-
pathRelativeToParent,
1537-
originalPathRelativeToParent,
1538-
),
1584+
removeLayoutSegmentsAndUnderscoresWithEscape(
1585+
pathRelativeToParent,
15391586
originalPathRelativeToParent,
1587+
routePathSegmentMetadataRelativeToParent,
15401588
),
15411589
)
15421590
break

packages/router-generator/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
export type RoutePathSegmentMetadata = {
2+
literalLeadingUnderscore?: boolean
3+
literalTrailingUnderscore?: boolean
4+
}
5+
16
export type RouteNode = {
27
filePath: string
38
fullPath: string
@@ -20,6 +25,8 @@ export type RouteNode = {
2025
* (e.g., when the parent is a virtual file-less route that gets filtered out).
2126
*/
2227
_virtualParentRoutePath?: string
28+
/** Internal routePath segment metadata for escaped or explicit literal syntax. */
29+
_routePathSegmentMetadata?: Array<RoutePathSegmentMetadata | undefined>
2330
}
2431

2532
export interface GetRouteNodesResult {

0 commit comments

Comments
 (0)