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
5 changes: 5 additions & 0 deletions packages/router-generator/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
} from './utils'
import { fillTemplate, getTargetTemplate } from './template'
import { transform } from './transform/transform'
import { validateRouteParams } from './validate-route-params'
import type { GeneratorPlugin } from './plugin/types'
import type { TargetTemplate } from './template'
import type {
Expand Down Expand Up @@ -1056,6 +1057,10 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved
throw new Error(`⚠️ File ${node.fullPath} does not exist`)
}

if (node.routePath) {
validateRouteParams(node.routePath, node.filePath, this.logger)
}

const updatedCacheEntry: RouteNodeCacheEntry = {
fileContent: existingRouteFile.fileContent,
mtimeMs: existingRouteFile.stat.mtimeMs,
Expand Down
118 changes: 118 additions & 0 deletions packages/router-generator/src/validate-route-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { Logger } from './logger'

/**
* Regex for valid JavaScript identifier (param name)
* Must start with letter, underscore, or dollar sign
* Can contain letters, numbers, underscores, or dollar signs
*/
const VALID_PARAM_NAME_REGEX = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/

interface ExtractedParam {
/** The param name without $ prefix (e.g., "userId", "optional") */
paramName: string
/** Whether this param name is valid */
isValid: boolean
}

/**
* Extracts param names from a route path segment.
*
* Handles these patterns:
* - $paramName -> extract "paramName"
* - {$paramName} -> extract "paramName"
* - prefix{$paramName}suffix -> extract "paramName"
* - {-$paramName} -> extract "paramName" (optional)
* - prefix{-$paramName}suffix -> extract "paramName" (optional)
* - $ or {$} -> wildcard, skip validation
*/
function extractParamsFromSegment(segment: string): Array<ExtractedParam> {
const params: Array<ExtractedParam> = []

// Skip empty segments
if (!segment || !segment.includes('$')) {
return params
}

// Check for wildcard ($ alone or {$})
if (segment === '$' || segment === '{$}') {
return params // Wildcard, no param name to validate
}

// Pattern 1: Simple $paramName (entire segment starts with $)
if (segment.startsWith('$') && !segment.includes('{')) {
const paramName = segment.slice(1)
if (paramName) {
params.push({
paramName,
isValid: VALID_PARAM_NAME_REGEX.test(paramName),
})
}
return params
}

// Pattern 2: Braces pattern {$paramName} or {-$paramName} with optional prefix/suffix
// Match patterns like: prefix{$param}suffix, {$param}, {-$param}
const bracePattern = /\{(-?\$)([^}]*)\}/g
let match

while ((match = bracePattern.exec(segment)) !== null) {
const paramName = match[2] // The param name after $ or -$

if (!paramName) {
// This is a wildcard {$} or {-$}, skip
continue
}

params.push({
paramName,
isValid: VALID_PARAM_NAME_REGEX.test(paramName),
})
}

return params
}

/**
* Extracts all params from a route path.
*
* @param path - The route path (e.g., "/users/$userId/posts/$postId")
* @returns Array of extracted params with validation info
*/
function extractParamsFromPath(path: string): Array<ExtractedParam> {
if (!path || !path.includes('$')) {
return []
}

const segments = path.split('/')
const allParams: Array<ExtractedParam> = []

for (const segment of segments) {
const params = extractParamsFromSegment(segment)
allParams.push(...params)
}

return allParams
}

/**
* Validates route params and logs warnings for invalid param names.
*
* @param routePath - The route path to validate
* @param filePath - The file path for error messages
* @param logger - Logger instance for warnings
*/
export function validateRouteParams(
routePath: string,
filePath: string,
logger: Logger,
): void {
const params = extractParamsFromPath(routePath)
const invalidParams = params.filter((p) => !p.isValid)

for (const param of invalidParams) {
logger.warn(
`WARNING: Invalid param name "${param.paramName}" in route "${routePath}" (file: ${filePath}). ` +
`Param names must be valid JavaScript identifiers (match /[a-zA-Z_$][a-zA-Z0-9_$]*/).`,
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/* 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 ValidParamRouteImport } from './routes/$validParam'
import { Route as UserNameRouteImport } from './routes/$user-name'
import { Route as R123RouteImport } from './routes/$123'

const ValidParamRoute = ValidParamRouteImport.update({
id: '/$validParam',
path: '/$validParam',
getParentRoute: () => rootRouteImport,
} as any)
const UserNameRoute = UserNameRouteImport.update({
id: '/$user-name',
path: '/$user-name',
getParentRoute: () => rootRouteImport,
} as any)
const R123Route = R123RouteImport.update({
id: '/$123',
path: '/$123',
getParentRoute: () => rootRouteImport,
} as any)

export interface FileRoutesByFullPath {
'/$123': typeof R123Route
'/$user-name': typeof UserNameRoute
'/$validParam': typeof ValidParamRoute
}
export interface FileRoutesByTo {
'/$123': typeof R123Route
'/$user-name': typeof UserNameRoute
'/$validParam': typeof ValidParamRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/$123': typeof R123Route
'/$user-name': typeof UserNameRoute
'/$validParam': typeof ValidParamRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/$123' | '/$user-name' | '/$validParam'
fileRoutesByTo: FileRoutesByTo
to: '/$123' | '/$user-name' | '/$validParam'
id: '__root__' | '/$123' | '/$user-name' | '/$validParam'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
R123Route: typeof R123Route
UserNameRoute: typeof UserNameRoute
ValidParamRoute: typeof ValidParamRoute
}

declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/$validParam': {
id: '/$validParam'
path: '/$validParam'
fullPath: '/$validParam'
preLoaderRoute: typeof ValidParamRouteImport
parentRoute: typeof rootRouteImport
}
'/$user-name': {
id: '/$user-name'
path: '/$user-name'
fullPath: '/$user-name'
preLoaderRoute: typeof UserNameRouteImport
parentRoute: typeof rootRouteImport
}
'/$123': {
id: '/$123'
path: '/$123'
fullPath: '/$123'
preLoaderRoute: typeof R123RouteImport
parentRoute: typeof rootRouteImport
}
}
}

const rootRouteChildren: RootRouteChildren = {
R123Route: R123Route,
UserNameRoute: UserNameRoute,
ValidParamRoute: ValidParamRoute,
}
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('/$123')({
component: () => <div>Invalid param starting with number</div>,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/$user-name')({
component: () => <div>Invalid param with hyphen</div>,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/$validParam')({
component: () => <div>Valid param</div>,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createRootRoute, Outlet } from '@tanstack/react-router'

export const Route = createRootRoute({
component: () => <Outlet />,
})
36 changes: 36 additions & 0 deletions packages/router-generator/tests/validate-route-params.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { join } from 'node:path'
import { afterAll, describe, expect, it, vi } from 'vitest'
import { Generator, getConfig } from '../src'

describe('validateRouteParams via generator', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
afterAll(() => {
warnSpy.mockRestore()
})

it('should warn for invalid param names when running the generator', async () => {
const folderName = 'invalid-param-names'
const dir = join(process.cwd(), 'tests', 'generator', folderName)

const config = getConfig({
disableLogging: false, // Enable logging to capture warnings
routesDirectory: dir + '/routes',
generatedRouteTree: dir + '/routeTree.gen.ts',
})

const generator = new Generator({ config, root: dir })
await generator.run()

// Should have warned about invalid params: $123 and $user-name
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid param name'),
)
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('123'))
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('user-name'))

// Should NOT have warned about $validParam
expect(warnSpy).not.toHaveBeenCalledWith(
expect.stringContaining('validParam'),
)
})
})
Loading