diff --git a/packages/router-generator/src/generator.ts b/packages/router-generator/src/generator.ts index bb3f92735b2..e9efdea4456 100644 --- a/packages/router-generator/src/generator.ts +++ b/packages/router-generator/src/generator.ts @@ -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 { @@ -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, diff --git a/packages/router-generator/src/validate-route-params.ts b/packages/router-generator/src/validate-route-params.ts new file mode 100644 index 00000000000..62c7a8403ce --- /dev/null +++ b/packages/router-generator/src/validate-route-params.ts @@ -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 { + const params: Array = [] + + // 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 { + if (!path || !path.includes('$')) { + return [] + } + + const segments = path.split('/') + const allParams: Array = [] + + 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_$]*/).`, + ) + } +} diff --git a/packages/router-generator/tests/generator/invalid-param-names/routeTree.snapshot.ts b/packages/router-generator/tests/generator/invalid-param-names/routeTree.snapshot.ts new file mode 100644 index 00000000000..4728dd439ea --- /dev/null +++ b/packages/router-generator/tests/generator/invalid-param-names/routeTree.snapshot.ts @@ -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() diff --git a/packages/router-generator/tests/generator/invalid-param-names/routes/$123.tsx b/packages/router-generator/tests/generator/invalid-param-names/routes/$123.tsx new file mode 100644 index 00000000000..5ead8abbb34 --- /dev/null +++ b/packages/router-generator/tests/generator/invalid-param-names/routes/$123.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/$123')({ + component: () =>
Invalid param starting with number
, +}) diff --git a/packages/router-generator/tests/generator/invalid-param-names/routes/$user-name.tsx b/packages/router-generator/tests/generator/invalid-param-names/routes/$user-name.tsx new file mode 100644 index 00000000000..4913327d6c4 --- /dev/null +++ b/packages/router-generator/tests/generator/invalid-param-names/routes/$user-name.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/$user-name')({ + component: () =>
Invalid param with hyphen
, +}) diff --git a/packages/router-generator/tests/generator/invalid-param-names/routes/$validParam.tsx b/packages/router-generator/tests/generator/invalid-param-names/routes/$validParam.tsx new file mode 100644 index 00000000000..6a879114ab3 --- /dev/null +++ b/packages/router-generator/tests/generator/invalid-param-names/routes/$validParam.tsx @@ -0,0 +1,5 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/$validParam')({ + component: () =>
Valid param
, +}) diff --git a/packages/router-generator/tests/generator/invalid-param-names/routes/__root.tsx b/packages/router-generator/tests/generator/invalid-param-names/routes/__root.tsx new file mode 100644 index 00000000000..f463b796b44 --- /dev/null +++ b/packages/router-generator/tests/generator/invalid-param-names/routes/__root.tsx @@ -0,0 +1,5 @@ +import { createRootRoute, Outlet } from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: () => , +}) diff --git a/packages/router-generator/tests/validate-route-params.test.ts b/packages/router-generator/tests/validate-route-params.test.ts new file mode 100644 index 00000000000..14b61adc3db --- /dev/null +++ b/packages/router-generator/tests/validate-route-params.test.ts @@ -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'), + ) + }) +})