diff --git a/e2e/react-router/escaped-special-strings/.gitignore b/e2e/react-router/escaped-special-strings/.gitignore new file mode 100644 index 00000000000..628d6fcc29c --- /dev/null +++ b/e2e/react-router/escaped-special-strings/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +port*.txt +test-results +playwright-report diff --git a/e2e/react-router/escaped-special-strings/index.html b/e2e/react-router/escaped-special-strings/index.html new file mode 100644 index 00000000000..dfa141d52a4 --- /dev/null +++ b/e2e/react-router/escaped-special-strings/index.html @@ -0,0 +1,12 @@ + + + + + + Escaped Special Strings E2E Test + + +
+ + + diff --git a/e2e/react-router/escaped-special-strings/package.json b/e2e/react-router/escaped-special-strings/package.json new file mode 100644 index 00000000000..4aa0fc4b4a9 --- /dev/null +++ b/e2e/react-router/escaped-special-strings/package.json @@ -0,0 +1,28 @@ +{ + "name": "tanstack-router-e2e-react-escaped-special-strings", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "dev:e2e": "vite", + "build": "vite build && tsc --noEmit", + "preview": "vite preview", + "start": "vite", + "test:e2e": "pnpm run test:e2e:default", + "test:e2e:default": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/router-plugin": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.50.1", + "@tanstack/router-e2e-utils": "workspace:^", + "@types/react": "^19.0.8", + "@types/react-dom": "^19.0.3", + "@vitejs/plugin-react": "^4.3.4", + "vite": "^7.1.7" + } +} diff --git a/e2e/react-router/escaped-special-strings/playwright.config.ts b/e2e/react-router/escaped-special-strings/playwright.config.ts new file mode 100644 index 00000000000..6e4e1d7703f --- /dev/null +++ b/e2e/react-router/escaped-special-strings/playwright.config.ts @@ -0,0 +1,53 @@ +import { defineConfig, devices } from '@playwright/test' +import { + getDummyServerPort, + getTestServerPort, +} from '@tanstack/router-e2e-utils' +import packageJson from './package.json' with { type: 'json' } + +const PORT = await getTestServerPort(packageJson.name) +const EXTERNAL_PORT = await getDummyServerPort(packageJson.name) +const baseURL = `http://localhost:${PORT}` +const command = `pnpm build && pnpm preview --port ${PORT}` + +console.info('Running with mode: ', process.env.MODE || 'default') + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + workers: 1, + + reporter: [['line']], + + globalSetup: './tests/setup/global.setup.ts', + globalTeardown: './tests/setup/global.teardown.ts', + + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL, + }, + + webServer: { + command, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + env: { + MODE: process.env.MODE || '', + VITE_MODE: process.env.MODE || '', + VITE_NODE_ENV: 'test', + VITE_EXTERNAL_PORT: String(EXTERNAL_PORT), + VITE_SERVER_PORT: String(PORT), + PORT: String(PORT), + }, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-router/escaped-special-strings/src/main.tsx b/e2e/react-router/escaped-special-strings/src/main.tsx new file mode 100644 index 00000000000..489ebafacf4 --- /dev/null +++ b/e2e/react-router/escaped-special-strings/src/main.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +// Set up a Router instance +const router = createRouter({ + routeTree, + defaultPreload: 'intent', +}) + +// Register things for typesafety +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +const rootElement = document.getElementById('app')! + +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement) + root.render() +} diff --git a/e2e/react-router/escaped-special-strings/src/routeTree.gen.ts b/e2e/react-router/escaped-special-strings/src/routeTree.gen.ts new file mode 100644 index 00000000000..d17ed94957a --- /dev/null +++ b/e2e/react-router/escaped-special-strings/src/routeTree.gen.ts @@ -0,0 +1,131 @@ +/* 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 IndexRouteImport } from './routes/[index]' +import { Route as RouteRouteImport } from './routes/[route]' +import { Route as LazyRouteImport } from './routes/[lazy]' +import { Route as BlogRouteImport } from './routes/blog[_]' +import { Route as LayoutRouteImport } from './routes/[_]layout' + +const IndexRoute = IndexRouteImport.update({ + id: '/index', + path: '/index', + getParentRoute: () => rootRouteImport, +} as any) +const RouteRoute = RouteRouteImport.update({ + id: '/route', + path: '/route', + getParentRoute: () => rootRouteImport, +} as any) +const LazyRoute = LazyRouteImport.update({ + id: '/lazy', + path: '/lazy', + getParentRoute: () => rootRouteImport, +} as any) +const BlogRoute = BlogRouteImport.update({ + id: '/blog_', + path: '/blog_', + getParentRoute: () => rootRouteImport, +} as any) +const LayoutRoute = LayoutRouteImport.update({ + id: '/_layout', + path: '/_layout', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/_layout': typeof LayoutRoute + '/blog_': typeof BlogRoute + '/index': typeof IndexRoute + '/lazy': typeof LazyRoute + '/route': typeof RouteRoute +} +export interface FileRoutesByTo { + '/_layout': typeof LayoutRoute + '/blog_': typeof BlogRoute + '/index': typeof IndexRoute + '/lazy': typeof LazyRoute + '/route': typeof RouteRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/_layout': typeof LayoutRoute + '/blog_': typeof BlogRoute + '/index': typeof IndexRoute + '/lazy': typeof LazyRoute + '/route': typeof RouteRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/_layout' | '/blog_' | '/index' | '/lazy' | '/route' + fileRoutesByTo: FileRoutesByTo + to: '/_layout' | '/blog_' | '/index' | '/lazy' | '/route' + id: '__root__' | '/_layout' | '/blog_' | '/index' | '/lazy' | '/route' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + LayoutRoute: typeof LayoutRoute + BlogRoute: typeof BlogRoute + IndexRoute: typeof IndexRoute + LazyRoute: typeof LazyRoute + RouteRoute: typeof RouteRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/index': { + id: '/index' + path: '/index' + fullPath: '/index' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/route': { + id: '/route' + path: '/route' + fullPath: '/route' + preLoaderRoute: typeof RouteRouteImport + parentRoute: typeof rootRouteImport + } + '/lazy': { + id: '/lazy' + path: '/lazy' + fullPath: '/lazy' + preLoaderRoute: typeof LazyRouteImport + parentRoute: typeof rootRouteImport + } + '/blog_': { + id: '/blog_' + path: '/blog_' + fullPath: '/blog_' + preLoaderRoute: typeof BlogRouteImport + parentRoute: typeof rootRouteImport + } + '/_layout': { + id: '/_layout' + path: '/_layout' + fullPath: '/_layout' + preLoaderRoute: typeof LayoutRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + LayoutRoute: LayoutRoute, + BlogRoute: BlogRoute, + IndexRoute: IndexRoute, + LazyRoute: LazyRoute, + RouteRoute: RouteRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/e2e/react-router/escaped-special-strings/src/routes/[_]layout.tsx b/e2e/react-router/escaped-special-strings/src/routes/[_]layout.tsx new file mode 100644 index 00000000000..84e2826881a --- /dev/null +++ b/e2e/react-router/escaped-special-strings/src/routes/[_]layout.tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from '@tanstack/react-router' + +// This file uses [_]layout escaping to create a literal /_layout path +// instead of being treated as a pathless layout route +export const Route = createFileRoute('/_layout')({ + component: EscapedUnderscoreLayoutComponent, +}) + +function EscapedUnderscoreLayoutComponent() { + return ( +
+

Escaped Underscore Layout Page

+

/_layout

+

+ This route was created using [_]layout.tsx to escape the leading + underscore. It renders at the literal path /_layout instead of being a + pathless layout. +

+
+ ) +} diff --git a/e2e/react-router/escaped-special-strings/src/routes/[index].tsx b/e2e/react-router/escaped-special-strings/src/routes/[index].tsx new file mode 100644 index 00000000000..64076bb343d --- /dev/null +++ b/e2e/react-router/escaped-special-strings/src/routes/[index].tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from '@tanstack/react-router' + +// This file uses [index] escaping to create a literal /index route +// instead of being treated as an index route for the parent +export const Route = createFileRoute('/index')({ + component: EscapedIndexComponent, +}) + +function EscapedIndexComponent() { + return ( +
+

Escaped Index Page

+

/index

+

+ This route was created using [index].tsx to escape the special "index" + token. It renders at the literal path /index instead of being the index + route. +

+
+ ) +} diff --git a/e2e/react-router/escaped-special-strings/src/routes/[lazy].tsx b/e2e/react-router/escaped-special-strings/src/routes/[lazy].tsx new file mode 100644 index 00000000000..486244c2ade --- /dev/null +++ b/e2e/react-router/escaped-special-strings/src/routes/[lazy].tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from '@tanstack/react-router' + +// This file uses [lazy] escaping to create a literal /lazy path +// instead of being treated as a lazy-loaded route +export const Route = createFileRoute('/lazy')({ + component: EscapedLazyComponent, +}) + +function EscapedLazyComponent() { + return ( +
+

Escaped Lazy Page

+

/lazy

+

+ This route was created using [lazy].tsx to escape the special "lazy" + token. It renders at the literal path /lazy instead of being a + lazy-loaded route. +

+
+ ) +} diff --git a/e2e/react-router/escaped-special-strings/src/routes/[route].tsx b/e2e/react-router/escaped-special-strings/src/routes/[route].tsx new file mode 100644 index 00000000000..f9e28fd87e5 --- /dev/null +++ b/e2e/react-router/escaped-special-strings/src/routes/[route].tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from '@tanstack/react-router' + +// This file uses [route] escaping to create a literal /route path +// instead of being treated as a layout configuration file +export const Route = createFileRoute('/route')({ + component: EscapedRouteComponent, +}) + +function EscapedRouteComponent() { + return ( +
+

Escaped Route Page

+

/route

+

+ This route was created using [route].tsx to escape the special "route" + token. It renders at the literal path /route instead of being a layout + configuration. +

+
+ ) +} diff --git a/e2e/react-router/escaped-special-strings/src/routes/__root.tsx b/e2e/react-router/escaped-special-strings/src/routes/__root.tsx new file mode 100644 index 00000000000..148361916c3 --- /dev/null +++ b/e2e/react-router/escaped-special-strings/src/routes/__root.tsx @@ -0,0 +1,48 @@ +import { Link, Outlet, createRootRoute } from '@tanstack/react-router' + +export const Route = createRootRoute({ + component: RootComponent, + notFoundComponent: () => { + return ( +
+

Page not found

+ Go to /index +
+ ) + }, +}) + +function RootComponent() { + return ( +
+

Escaped Special Strings Test

+ +
+ +
+
+ ) +} diff --git a/e2e/react-router/escaped-special-strings/src/routes/blog[_].tsx b/e2e/react-router/escaped-special-strings/src/routes/blog[_].tsx new file mode 100644 index 00000000000..d3cbdf9d8c1 --- /dev/null +++ b/e2e/react-router/escaped-special-strings/src/routes/blog[_].tsx @@ -0,0 +1,21 @@ +import { createFileRoute } from '@tanstack/react-router' + +// This file uses blog[_] escaping to create a literal /blog_ path +// with the trailing underscore preserved in the URL +export const Route = createFileRoute('/blog_')({ + component: EscapedBlogUnderscoreComponent, +}) + +function EscapedBlogUnderscoreComponent() { + return ( +
+

Escaped Blog Underscore Page

+

/blog_

+

+ This route was created using blog[_].tsx to escape the trailing + underscore. It renders at the literal path /blog_ with the underscore + preserved in the URL. +

+
+ ) +} diff --git a/e2e/react-router/escaped-special-strings/tests/escaped-routes.spec.ts b/e2e/react-router/escaped-special-strings/tests/escaped-routes.spec.ts new file mode 100644 index 00000000000..447b5c11945 --- /dev/null +++ b/e2e/react-router/escaped-special-strings/tests/escaped-routes.spec.ts @@ -0,0 +1,124 @@ +import { expect, test } from '@playwright/test' + +test.describe('Escaped special strings routing', () => { + test('escaped [index] route renders at /index path', async ({ page }) => { + await page.goto('/index') + + await expect(page.getByTestId('page-title')).toContainText( + 'Escaped Index Page', + ) + await expect(page.getByTestId('page-path')).toContainText('/index') + await expect(page.getByTestId('page-description')).toContainText( + 'escape the special "index" token', + ) + }) + + test('escaped [route] route renders at /route path', async ({ page }) => { + await page.goto('/route') + + await expect(page.getByTestId('page-title')).toContainText( + 'Escaped Route Page', + ) + await expect(page.getByTestId('page-path')).toContainText('/route') + await expect(page.getByTestId('page-description')).toContainText( + 'escape the special "route" token', + ) + }) + + test('escaped [lazy] route renders at /lazy path', async ({ page }) => { + await page.goto('/lazy') + + await expect(page.getByTestId('page-title')).toContainText( + 'Escaped Lazy Page', + ) + await expect(page.getByTestId('page-path')).toContainText('/lazy') + await expect(page.getByTestId('page-description')).toContainText( + 'escape the special "lazy" token', + ) + }) + + test('escaped [_]layout route renders at /_layout path', async ({ page }) => { + await page.goto('/_layout') + + await expect(page.getByTestId('page-title')).toContainText( + 'Escaped Underscore Layout Page', + ) + await expect(page.getByTestId('page-path')).toContainText('/_layout') + await expect(page.getByTestId('page-description')).toContainText( + 'escape the leading underscore', + ) + }) + + test('escaped blog[_] route renders at /blog_ path', async ({ page }) => { + await page.goto('/blog_') + + await expect(page.getByTestId('page-title')).toContainText( + 'Escaped Blog Underscore Page', + ) + await expect(page.getByTestId('page-path')).toContainText('/blog_') + await expect(page.getByTestId('page-description')).toContainText( + 'escape the trailing underscore', + ) + }) + + test('client-side navigation to escaped /index route', async ({ page }) => { + await page.goto('/route') + await page.getByTestId('link-index').click() + await page.waitForURL('/index') + + await expect(page.getByTestId('page-title')).toContainText( + 'Escaped Index Page', + ) + }) + + test('client-side navigation to escaped /route route', async ({ page }) => { + await page.goto('/index') + await page.getByTestId('link-route').click() + await page.waitForURL('/route') + + await expect(page.getByTestId('page-title')).toContainText( + 'Escaped Route Page', + ) + }) + + test('client-side navigation to escaped /lazy route', async ({ page }) => { + await page.goto('/index') + await page.getByTestId('link-lazy').click() + await page.waitForURL('/lazy') + + await expect(page.getByTestId('page-title')).toContainText( + 'Escaped Lazy Page', + ) + }) + + test('client-side navigation to escaped /_layout route', async ({ page }) => { + await page.goto('/index') + await page.getByTestId('link-underscore-layout').click() + await page.waitForURL('/_layout') + + await expect(page.getByTestId('page-title')).toContainText( + 'Escaped Underscore Layout Page', + ) + }) + + test('client-side navigation to escaped /blog_ route', async ({ page }) => { + await page.goto('/index') + await page.getByTestId('link-blog-underscore').click() + await page.waitForURL('/blog_') + + await expect(page.getByTestId('page-title')).toContainText( + 'Escaped Blog Underscore Page', + ) + }) + + test('URL is correct for escaped routes with underscores', async ({ + page, + baseURL, + }) => { + await page.goto('/_layout') + expect(page.url()).toBe(`${baseURL}/_layout`) + + await page.goto('/blog_') + expect(page.url()).toBe(`${baseURL}/blog_`) + }) +}) diff --git a/e2e/react-router/escaped-special-strings/tests/setup/global.setup.ts b/e2e/react-router/escaped-special-strings/tests/setup/global.setup.ts new file mode 100644 index 00000000000..3593d10ab90 --- /dev/null +++ b/e2e/react-router/escaped-special-strings/tests/setup/global.setup.ts @@ -0,0 +1,6 @@ +import { e2eStartDummyServer } from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +export default async function setup() { + await e2eStartDummyServer(packageJson.name) +} diff --git a/e2e/react-router/escaped-special-strings/tests/setup/global.teardown.ts b/e2e/react-router/escaped-special-strings/tests/setup/global.teardown.ts new file mode 100644 index 00000000000..62fd79911cc --- /dev/null +++ b/e2e/react-router/escaped-special-strings/tests/setup/global.teardown.ts @@ -0,0 +1,6 @@ +import { e2eStopDummyServer } from '@tanstack/router-e2e-utils' +import packageJson from '../../package.json' with { type: 'json' } + +export default async function teardown() { + await e2eStopDummyServer(packageJson.name) +} diff --git a/e2e/react-router/escaped-special-strings/tsconfig.json b/e2e/react-router/escaped-special-strings/tsconfig.json new file mode 100644 index 00000000000..82cf0bcd2c9 --- /dev/null +++ b/e2e/react-router/escaped-special-strings/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "target": "ESNext", + "moduleResolution": "Bundler", + "module": "ESNext", + "skipLibCheck": true, + "resolveJsonModule": true, + "allowJs": true, + "types": ["vite/client"] + }, + "exclude": ["node_modules", "dist"] +} diff --git a/e2e/react-router/escaped-special-strings/vite.config.js b/e2e/react-router/escaped-special-strings/vite.config.js new file mode 100644 index 00000000000..940c8aa2363 --- /dev/null +++ b/e2e/react-router/escaped-special-strings/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import { tanstackRouter } from '@tanstack/router-plugin/vite' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + tanstackRouter({ + target: 'react', + }), + react(), + ], +}) diff --git a/packages/router-generator/src/filesystem/physical/getRouteNodes.ts b/packages/router-generator/src/filesystem/physical/getRouteNodes.ts index 8f137ebfa12..0c64052f6c4 100644 --- a/packages/router-generator/src/filesystem/physical/getRouteNodes.ts +++ b/packages/router-generator/src/filesystem/physical/getRouteNodes.ts @@ -2,6 +2,7 @@ import path from 'node:path' import * as fsp from 'node:fs/promises' import { determineInitialRoutePath, + hasEscapedLeadingUnderscore, removeExt, replaceBackslash, routePathToVariable, @@ -153,7 +154,7 @@ export async function getRouteNodes( throw new Error(errorMessage) } - const meta = getRouteMeta(routePath, config) + const meta = getRouteMeta(routePath, originalRoutePath, config) const variableName = meta.variableName let routeType: FsRouteType = meta.fsRouteType @@ -164,7 +165,14 @@ export async function getRouteNodes( // this check needs to happen after the lazy route has been cleaned up // since the routePath is used to determine if a route is pathless - if (isValidPathlessLayoutRoute(routePath, routeType, config)) { + if ( + isValidPathlessLayoutRoute( + routePath, + originalRoutePath, + routeType, + config, + ) + ) { routeType = 'pathless_layout' } @@ -189,37 +197,64 @@ export async function getRouteNodes( }) } - routePath = routePath.replace( - new RegExp( - `/(component|errorComponent|notFoundComponent|pendingComponent|loader|${config.routeToken}|lazy)$`, - ), - '', - ) - - originalRoutePath = originalRoutePath.replace( - new RegExp( - `/(component|errorComponent|notFoundComponent|pendingComponent|loader|${config.routeToken}|lazy)$`, - ), - '', - ) + // Get the last segment of originalRoutePath to check for escaping + const originalSegments = originalRoutePath.split('/').filter(Boolean) + const lastOriginalSegmentForSuffix = + originalSegments[originalSegments.length - 1] || '' + + // List of special suffixes that can be escaped + const specialSuffixes = [ + 'component', + 'errorComponent', + 'notFoundComponent', + 'pendingComponent', + 'loader', + config.routeToken, + 'lazy', + ] + + // Only strip the suffix if it wasn't escaped (not wrapped in brackets) + const suffixToStrip = specialSuffixes.find((suffix) => { + const endsWithSuffix = routePath.endsWith(`/${suffix}`) + const isEscaped = lastOriginalSegmentForSuffix === `[${suffix}]` + return endsWithSuffix && !isEscaped + }) - if (routePath === config.indexToken) { - routePath = '/' + if (suffixToStrip) { + routePath = routePath.replace(new RegExp(`/${suffixToStrip}$`), '') + originalRoutePath = originalRoutePath.replace( + new RegExp(`/${suffixToStrip}$`), + '', + ) } - if (originalRoutePath === config.indexToken) { - originalRoutePath = '/' + // Check if the index token should be treated specially or as a literal path + // If it's escaped (wrapped in brackets in originalRoutePath), it should be literal + const lastOriginalSegment = + originalRoutePath.split('/').filter(Boolean).pop() || '' + const isIndexEscaped = + lastOriginalSegment === `[${config.indexToken}]` + + if (!isIndexEscaped) { + if (routePath === config.indexToken) { + routePath = '/' + } + + if (originalRoutePath === config.indexToken) { + originalRoutePath = '/' + } + + routePath = + routePath.replace(new RegExp(`/${config.indexToken}$`), '/') || + '/' + + originalRoutePath = + originalRoutePath.replace( + new RegExp(`/${config.indexToken}$`), + '/', + ) || '/' } - routePath = - routePath.replace(new RegExp(`/${config.indexToken}$`), '/') || '/' - - originalRoutePath = - originalRoutePath.replace( - new RegExp(`/${config.indexToken}$`), - '/', - ) || '/' - routeNodes.push({ filePath, fullPath, @@ -266,12 +301,14 @@ export async function getRouteNodes( /** * Determines the metadata for a given route path based on the provided configuration. * - * @param routePath - The determined initial routePath. + * @param routePath - The determined initial routePath (with brackets removed). + * @param originalRoutePath - The original route path (may contain brackets for escaped content). * @param config - The user configuration object. * @returns An object containing the type of the route and the variable name derived from the route path. */ export function getRouteMeta( routePath: string, + originalRoutePath: string, config: Pick, ): { // `__root` is can be more easily determined by filtering down to routePath === /${rootPathId} @@ -292,25 +329,50 @@ export function getRouteMeta( } { let fsRouteType: FsRouteType = 'static' - if (routePath.endsWith(`/${config.routeToken}`)) { + // Get the last segment from the original path to check for escaping + const originalSegments = originalRoutePath.split('/').filter(Boolean) + const lastOriginalSegment = + originalSegments[originalSegments.length - 1] || '' + + // Helper to check if a specific suffix is escaped + const isSuffixEscaped = (suffix: string): boolean => { + return lastOriginalSegment === `[${suffix}]` + } + + if ( + routePath.endsWith(`/${config.routeToken}`) && + !isSuffixEscaped(config.routeToken) + ) { // layout routes, i.e `/foo/route.tsx` or `/foo/_layout/route.tsx` fsRouteType = 'layout' - } else if (routePath.endsWith('/lazy')) { + } else if (routePath.endsWith('/lazy') && !isSuffixEscaped('lazy')) { // lazy routes, i.e. `/foo.lazy.tsx` fsRouteType = 'lazy' - } else if (routePath.endsWith('/loader')) { + } else if (routePath.endsWith('/loader') && !isSuffixEscaped('loader')) { // loader routes, i.e. `/foo.loader.tsx` fsRouteType = 'loader' - } else if (routePath.endsWith('/component')) { + } else if ( + routePath.endsWith('/component') && + !isSuffixEscaped('component') + ) { // component routes, i.e. `/foo.component.tsx` fsRouteType = 'component' - } else if (routePath.endsWith('/pendingComponent')) { + } else if ( + routePath.endsWith('/pendingComponent') && + !isSuffixEscaped('pendingComponent') + ) { // pending component routes, i.e. `/foo.pendingComponent.tsx` fsRouteType = 'pendingComponent' - } else if (routePath.endsWith('/errorComponent')) { + } else if ( + routePath.endsWith('/errorComponent') && + !isSuffixEscaped('errorComponent') + ) { // error component routes, i.e. `/foo.errorComponent.tsx` fsRouteType = 'errorComponent' - } else if (routePath.endsWith('/notFoundComponent')) { + } else if ( + routePath.endsWith('/notFoundComponent') && + !isSuffixEscaped('notFoundComponent') + ) { // not found component routes, i.e. `/foo.notFoundComponent.tsx` fsRouteType = 'notFoundComponent' } @@ -323,11 +385,14 @@ export function getRouteMeta( /** * Used to validate if a route is a pathless layout route * @param normalizedRoutePath Normalized route path, i.e `/foo/_layout/route.tsx` and `/foo._layout.route.tsx` to `/foo/_layout/route` + * @param originalRoutePath Original route path with brackets for escaped content + * @param routeType The route type determined from file extension * @param config The `router-generator` configuration object * @returns Boolean indicating if the route is a pathless layout route */ function isValidPathlessLayoutRoute( normalizedRoutePath: string, + originalRoutePath: string, routeType: FsRouteType, config: Pick, ): boolean { @@ -336,13 +401,18 @@ function isValidPathlessLayoutRoute( } const segments = normalizedRoutePath.split('/').filter(Boolean) + const originalSegments = originalRoutePath.split('/').filter(Boolean) if (segments.length === 0) { return false } const lastRouteSegment = segments[segments.length - 1]! + const lastOriginalSegment = + originalSegments[originalSegments.length - 1] || '' const secondToLastRouteSegment = segments[segments.length - 2] + const secondToLastOriginalSegment = + originalSegments[originalSegments.length - 2] // If segment === __root, then exit as false if (lastRouteSegment === rootPathId) { @@ -352,14 +422,25 @@ function isValidPathlessLayoutRoute( // If segment === config.routeToken and secondToLastSegment is a string that starts with _, then exit as true // Since the route is actually a configuration route for a layout/pathless route // i.e. /foo/_layout/route.tsx === /foo/_layout.tsx + // But if the underscore is escaped, it's not a pathless layout if ( lastRouteSegment === config.routeToken && - typeof secondToLastRouteSegment === 'string' + typeof secondToLastRouteSegment === 'string' && + typeof secondToLastOriginalSegment === 'string' ) { + // Check if the underscore is escaped + if (hasEscapedLeadingUnderscore(secondToLastOriginalSegment)) { + return false + } return secondToLastRouteSegment.startsWith('_') } - // Segment starts with _ + // Segment starts with _ but check if it's escaped + // If the original segment has [_] at the start, the underscore is escaped and it's not a pathless layout + if (hasEscapedLeadingUnderscore(lastOriginalSegment)) { + return false + } + return ( lastRouteSegment !== config.indexToken && lastRouteSegment !== config.routeToken && diff --git a/packages/router-generator/src/generator.ts b/packages/router-generator/src/generator.ts index 6df57f9f7ab..3ec4b46576a 100644 --- a/packages/router-generator/src/generator.ts +++ b/packages/router-generator/src/generator.ts @@ -28,14 +28,15 @@ import { getResolvedRouteNodeVariableName, hasParentRoute, isRouteNodeValidForAugmentation, + isSegmentPathless, mergeImportDeclarations, multiSortBy, removeExt, removeGroups, removeLastSegmentFromPath, - removeLayoutSegments, + removeLayoutSegmentsWithEscape, removeTrailingSlash, - removeUnderscores, + removeUnderscoresWithEscape, replaceBackslash, trimPathLeft, } from './utils' @@ -1363,16 +1364,30 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved node.path = determineNodePath(node) const trimmedPath = trimPathLeft(node.path ?? '') + const trimmedOriginalPath = trimPathLeft( + node.originalRoutePath?.replace( + node.parent?.originalRoutePath ?? '', + '', + ) ?? '', + ) const split = trimmedPath.split('/') + const originalSplit = trimmedOriginalPath.split('/') const lastRouteSegment = split[split.length - 1] ?? trimmedPath + const lastOriginalSegment = + originalSplit[originalSplit.length - 1] ?? trimmedOriginalPath + // A segment is non-path if it starts with underscore AND the underscore is not escaped node.isNonPath = - lastRouteSegment.startsWith('_') || + isSegmentPathless(lastRouteSegment, lastOriginalSegment) || split.every((part) => this.routeGroupPatternRegex.test(part)) + // Use escape-aware functions to compute cleanedPath node.cleanedPath = removeGroups( - removeUnderscores(removeLayoutSegments(node.path)) ?? '', + removeUnderscoresWithEscape( + removeLayoutSegmentsWithEscape(node.path, node.originalRoutePath), + node.originalRoutePath, + ), ) if ( @@ -1431,6 +1446,8 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved if (!node.isVirtual && isPathlessLayoutWithPath) { const immediateParentPath = removeLastSegmentFromPath(node.routePath) || '/' + const immediateParentOriginalPath = + removeLastSegmentFromPath(node.originalRoutePath) || '/' let searchPath = immediateParentPath // Find nearest real (non-virtual, non-index) parent @@ -1442,8 +1459,19 @@ ${acc.routeTree.map((child) => `${child.variableName}Route: typeof ${getResolved node.routePath?.replace(candidate.routePath ?? '', '') || '/' const pathRelativeToParent = immediateParentPath.replace(candidate.routePath ?? '', '') || '/' + const originalPathRelativeToParent = + immediateParentOriginalPath.replace( + candidate.originalRoutePath ?? '', + '', + ) || '/' node.cleanedPath = removeGroups( - removeUnderscores(removeLayoutSegments(pathRelativeToParent)) ?? '', + removeUnderscoresWithEscape( + removeLayoutSegmentsWithEscape( + pathRelativeToParent, + originalPathRelativeToParent, + ), + originalPathRelativeToParent, + ), ) break } diff --git a/packages/router-generator/src/utils.ts b/packages/router-generator/src/utils.ts index 9d9e58286bd..9fa8d229a43 100644 --- a/packages/router-generator/src/utils.ts +++ b/packages/router-generator/src/utils.ts @@ -140,23 +140,26 @@ export function removeTrailingSlash(s: string) { const BRACKET_CONTENT_RE = /\[(.*?)\]/g const SPLIT_REGEX = /(?', - '|', - '!', - '$', - '%', - '_', - ]) +/** + * Characters that cannot be escaped in square brackets. + * These are characters that would cause issues in URLs or file systems. + */ +const DISALLOWED_ESCAPE_CHARS = new Set([ + '/', + '\\', + '?', + '#', + ':', + '*', + '<', + '>', + '|', + '!', + '$', + '%', +]) +export function determineInitialRoutePath(routePath: string) { const originalRoutePath = cleanPath( `/${(cleanPath(routePath) || '').split(SPLIT_REGEX).join('/')}`, @@ -201,6 +204,47 @@ export function determineInitialRoutePath(routePath: string) { } } +/** + * 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 + */ +function isFullyEscapedSegment(originalSegment: string): boolean { + return ( + originalSegment.startsWith('[') && + originalSegment.endsWith(']') && + !originalSegment.slice(1, -1).includes('[') && + !originalSegment.slice(1, -1).includes(']') + ) +} + +/** + * Checks if the leading underscore in a segment is escaped. + * Returns true if: + * - Segment starts with [_] pattern: "[_]layout" -> "_layout" + * - Segment is fully escaped and content starts with _: "[_1nd3x]" -> "_1nd3x" + */ +export function hasEscapedLeadingUnderscore(originalSegment: string): boolean { + // Pattern: [_]something or [_something] + return ( + originalSegment.startsWith('[_]') || + (originalSegment.startsWith('[_') && isFullyEscapedSegment(originalSegment)) + ) +} + +/** + * Checks if the trailing underscore in a segment is escaped. + * Returns true if: + * - Segment ends with [_] pattern: "blog[_]" -> "blog_" + * - Segment is fully escaped and content ends with _: "[_r0ut3_]" -> "_r0ut3_" + */ +export function hasEscapedTrailingUnderscore(originalSegment: string): boolean { + // Pattern: something[_] or [something_] + return ( + originalSegment.endsWith('[_]') || + (originalSegment.endsWith('_]') && isFullyEscapedSegment(originalSegment)) + ) +} + const backslashRegex = /\\/g export function replaceBackslash(s: string) { @@ -271,6 +315,92 @@ export function removeUnderscores(s?: string) { .replace(underscoreSlashRegex, '/') } +/** + * Removes underscores from a path, but preserves underscores that were escaped + * in the original path (indicated by [_] syntax). + * + * @param routePath - The path with brackets removed + * @param originalPath - The original path that may contain [_] escape sequences + * @returns The path with non-escaped underscores removed + */ +export function removeUnderscoresWithEscape( + routePath?: string, + originalPath?: string, +): string { + if (!routePath) return '' + if (!originalPath) return removeUnderscores(routePath) ?? '' + + const routeSegments = routePath.split('/') + const originalSegments = originalPath.split('/') + + const newSegments = routeSegments.map((segment, i) => { + const originalSegment = originalSegments[i] || '' + + // Check if leading underscore is escaped + const leadingEscaped = hasEscapedLeadingUnderscore(originalSegment) + // Check if trailing underscore is escaped + const trailingEscaped = hasEscapedTrailingUnderscore(originalSegment) + + let result = segment + + // Remove leading underscore only if not escaped + if (result.startsWith('_') && !leadingEscaped) { + result = result.slice(1) + } + + // Remove trailing underscore only if not escaped + if (result.endsWith('_') && !trailingEscaped) { + result = result.slice(0, -1) + } + + return result + }) + + return newSegments.join('/') +} + +/** + * Removes layout segments (segments starting with underscore) from a path, + * but preserves segments where the underscore was escaped. + * + * @param routePath - The path with brackets removed + * @param originalPath - The original path that may contain [_] escape sequences + * @returns The path with non-escaped layout segments removed + */ +export function removeLayoutSegmentsWithEscape( + routePath: string = '/', + originalPath?: string, +): string { + if (!originalPath) return removeLayoutSegments(routePath) + + const routeSegments = routePath.split('/') + const originalSegments = originalPath.split('/') + + // Keep segments that are NOT pathless (i.e., don't start with unescaped underscore) + const newSegments = routeSegments.filter((segment, i) => { + const originalSegment = originalSegments[i] || '' + return !isSegmentPathless(segment, originalSegment) + }) + + return newSegments.join('/') +} + +/** + * Checks if a segment should be treated as a pathless/layout segment. + * A segment is pathless if it starts with underscore and the underscore is not escaped. + * + * @param segment - The segment from routePath (brackets removed) + * @param originalSegment - The segment from originalRoutePath (may contain brackets) + * @returns true if the segment is pathless (has non-escaped leading underscore) + */ +export function isSegmentPathless( + segment: string, + originalSegment: string, +): boolean { + if (!segment.startsWith('_')) return false + return !hasEscapedLeadingUnderscore(originalSegment) +} + function escapeRegExp(s: string): string { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } @@ -495,7 +625,13 @@ export const inferPath = (routeNode: RouteNode): string => { */ export const inferFullPath = (routeNode: RouteNode): string => { const fullPath = removeGroups( - removeUnderscores(removeLayoutSegments(routeNode.routePath)) ?? '', + removeUnderscoresWithEscape( + removeLayoutSegmentsWithEscape( + routeNode.routePath, + routeNode.originalRoutePath, + ), + routeNode.originalRoutePath, + ), ) return routeNode.cleanedPath === '/' ? fullPath : fullPath.replace(/\/$/, '') diff --git a/packages/router-generator/tests/generator.test.ts b/packages/router-generator/tests/generator.test.ts index 8b737e0387a..13aa39db3a7 100644 --- a/packages/router-generator/tests/generator.test.ts +++ b/packages/router-generator/tests/generator.test.ts @@ -75,6 +75,10 @@ function rewriteConfigByFolderName(folderName: string, config: Config) { config.indexToken = '_1nd3x' config.routeToken = '_r0ut3_' break + case 'escaped-custom-tokens': + config.indexToken = '_1nd3x' + config.routeToken = '_r0ut3_' + break case 'virtual': { const virtualRouteConfig = rootRoute('root.tsx', [ diff --git a/packages/router-generator/tests/generator/escaped-custom-tokens/routeTree.snapshot.ts b/packages/router-generator/tests/generator/escaped-custom-tokens/routeTree.snapshot.ts new file mode 100644 index 00000000000..a555744dff6 --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-custom-tokens/routeTree.snapshot.ts @@ -0,0 +1,137 @@ +/* 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 R1nd3xRouteImport } from './routes/[_1nd3x]' +import { Route as BlogR0ut3RouteImport } from './routes/blog._r0ut3_' +import { Route as R1nd3xRouteImport } from './routes/_1nd3x' +import { Route as Nested1nd3xRouteImport } from './routes/nested.[_1nd3x]' +import { Route as PostsR0ut3RouteImport } from './routes/posts.[_r0ut3_]' + +const R1nd3xRoute = R1nd3xRouteImport.update({ + id: '/_1nd3x', + path: '/_1nd3x', + getParentRoute: () => rootRouteImport, +} as any) +const BlogR0ut3Route = BlogR0ut3RouteImport.update({ + id: '/blog', + path: '/blog', + getParentRoute: () => rootRouteImport, +} as any) +const R1nd3xRoute = R1nd3xRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const Nested1nd3xRoute = Nested1nd3xRouteImport.update({ + id: '/nested/_1nd3x', + path: '/nested/_1nd3x', + getParentRoute: () => rootRouteImport, +} as any) +const PostsR0ut3Route = PostsR0ut3RouteImport.update({ + id: '/posts/_r0ut3_', + path: '/posts/_r0ut3_', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof R1nd3xRoute + '/blog': typeof BlogR0ut3Route + '/_1nd3x': typeof R1nd3xRoute + '/nested/_1nd3x': typeof Nested1nd3xRoute + '/posts/_r0ut3_': typeof PostsR0ut3Route +} +export interface FileRoutesByTo { + '/': typeof R1nd3xRoute + '/blog': typeof BlogR0ut3Route + '/_1nd3x': typeof R1nd3xRoute + '/nested/_1nd3x': typeof Nested1nd3xRoute + '/posts/_r0ut3_': typeof PostsR0ut3Route +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof R1nd3xRoute + '/blog': typeof BlogR0ut3Route + '/_1nd3x': typeof R1nd3xRoute + '/nested/_1nd3x': typeof Nested1nd3xRoute + '/posts/_r0ut3_': typeof PostsR0ut3Route +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/blog' | '/_1nd3x' | '/nested/_1nd3x' | '/posts/_r0ut3_' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/blog' | '/_1nd3x' | '/nested/_1nd3x' | '/posts/_r0ut3_' + id: + | '__root__' + | '/' + | '/blog' + | '/_1nd3x' + | '/nested/_1nd3x' + | '/posts/_r0ut3_' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + R1nd3xRoute: typeof R1nd3xRoute + BlogR0ut3Route: typeof BlogR0ut3Route + R1nd3xRoute: typeof R1nd3xRoute + Nested1nd3xRoute: typeof Nested1nd3xRoute + PostsR0ut3Route: typeof PostsR0ut3Route +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/_1nd3x': { + id: '/_1nd3x' + path: '/_1nd3x' + fullPath: '/_1nd3x' + preLoaderRoute: typeof R1nd3xRouteImport + parentRoute: typeof rootRouteImport + } + '/blog': { + id: '/blog' + path: '/blog' + fullPath: '/blog' + preLoaderRoute: typeof BlogR0ut3RouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof R1nd3xRouteImport + parentRoute: typeof rootRouteImport + } + '/nested/_1nd3x': { + id: '/nested/_1nd3x' + path: '/nested/_1nd3x' + fullPath: '/nested/_1nd3x' + preLoaderRoute: typeof Nested1nd3xRouteImport + parentRoute: typeof rootRouteImport + } + '/posts/_r0ut3_': { + id: '/posts/_r0ut3_' + path: '/posts/_r0ut3_' + fullPath: '/posts/_r0ut3_' + preLoaderRoute: typeof PostsR0ut3RouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + R1nd3xRoute: R1nd3xRoute, + BlogR0ut3Route: BlogR0ut3Route, + R1nd3xRoute: R1nd3xRoute, + Nested1nd3xRoute: Nested1nd3xRoute, + PostsR0ut3Route: PostsR0ut3Route, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/packages/router-generator/tests/generator/escaped-custom-tokens/routes/[_1nd3x].tsx b/packages/router-generator/tests/generator/escaped-custom-tokens/routes/[_1nd3x].tsx new file mode 100644 index 00000000000..cf5498ee1f0 --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-custom-tokens/routes/[_1nd3x].tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// Escaped custom indexToken - should be literal /_1nd3x path, NOT treated as index route +export const Route = createFileRoute('/_1nd3x')() diff --git a/packages/router-generator/tests/generator/escaped-custom-tokens/routes/_1nd3x.tsx b/packages/router-generator/tests/generator/escaped-custom-tokens/routes/_1nd3x.tsx new file mode 100644 index 00000000000..e1e2b51af6d --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-custom-tokens/routes/_1nd3x.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// Normal index route using custom indexToken +export const Route = createFileRoute('/')() diff --git a/packages/router-generator/tests/generator/escaped-custom-tokens/routes/__root.tsx b/packages/router-generator/tests/generator/escaped-custom-tokens/routes/__root.tsx new file mode 100644 index 00000000000..c11ed1e011b --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-custom-tokens/routes/__root.tsx @@ -0,0 +1,2 @@ +// @ts-nocheck +export const Route = createRootRoute() diff --git a/packages/router-generator/tests/generator/escaped-custom-tokens/routes/blog._r0ut3_.tsx b/packages/router-generator/tests/generator/escaped-custom-tokens/routes/blog._r0ut3_.tsx new file mode 100644 index 00000000000..849c7e619cf --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-custom-tokens/routes/blog._r0ut3_.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// Normal layout config using custom routeToken +export const Route = createFileRoute('/blog')() diff --git a/packages/router-generator/tests/generator/escaped-custom-tokens/routes/nested.[_1nd3x].tsx b/packages/router-generator/tests/generator/escaped-custom-tokens/routes/nested.[_1nd3x].tsx new file mode 100644 index 00000000000..bf2a046b062 --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-custom-tokens/routes/nested.[_1nd3x].tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// Escaped custom indexToken in nested path - should be literal /nested/_1nd3x, NOT index of /nested +export const Route = createFileRoute('/nested/_1nd3x')() diff --git a/packages/router-generator/tests/generator/escaped-custom-tokens/routes/posts.[_r0ut3_].tsx b/packages/router-generator/tests/generator/escaped-custom-tokens/routes/posts.[_r0ut3_].tsx new file mode 100644 index 00000000000..2b0382cce80 --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-custom-tokens/routes/posts.[_r0ut3_].tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// Escaped custom routeToken - should be literal /posts/_r0ut3_ path, NOT treated as layout config +export const Route = createFileRoute('/posts/_r0ut3_')() diff --git a/packages/router-generator/tests/generator/escaped-special-strings/routeTree.snapshot.ts b/packages/router-generator/tests/generator/escaped-special-strings/routeTree.snapshot.ts new file mode 100644 index 00000000000..ac3c416e478 --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-special-strings/routeTree.snapshot.ts @@ -0,0 +1,273 @@ +/* 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 IndexRouteImport } from './routes/[index]' +import { Route as RouteRouteImport } from './routes/[route]' +import { Route as LazyRouteImport } from './routes/[lazy]' +import { Route as Foo_barRouteImport } from './routes/foo[_]bar' +import { Route as BlogRouteImport } from './routes/blog[_]' +import { Route as Api_v2_usersRouteImport } from './routes/api[_]v2[_]users' +import { Route as Prefix_middle_suffixRouteImport } from './routes/[_]prefix[_]middle[_]suffix' +import { Route as LayoutRouteImport } from './routes/[_]layout' +import { Route as AuthRouteRouteImport } from './routes/[_]auth.route' +import { Route as IndexRouteImport } from './routes/index' +import { Route as NestedIndexRouteImport } from './routes/nested.[index]' + +const IndexRoute = IndexRouteImport.update({ + id: '/index', + path: '/index', + getParentRoute: () => rootRouteImport, +} as any) +const RouteRoute = RouteRouteImport.update({ + id: '/route', + path: '/route', + getParentRoute: () => rootRouteImport, +} as any) +const LazyRoute = LazyRouteImport.update({ + id: '/lazy', + path: '/lazy', + getParentRoute: () => rootRouteImport, +} as any) +const Foo_barRoute = Foo_barRouteImport.update({ + id: '/foo_bar', + path: '/foo_bar', + getParentRoute: () => rootRouteImport, +} as any) +const BlogRoute = BlogRouteImport.update({ + id: '/blog_', + path: '/blog_', + getParentRoute: () => rootRouteImport, +} as any) +const Api_v2_usersRoute = Api_v2_usersRouteImport.update({ + id: '/api_v2_users', + path: '/api_v2_users', + getParentRoute: () => rootRouteImport, +} as any) +const Prefix_middle_suffixRoute = Prefix_middle_suffixRouteImport.update({ + id: '/_prefix_middle_suffix', + path: '/_prefix_middle_suffix', + getParentRoute: () => rootRouteImport, +} as any) +const LayoutRoute = LayoutRouteImport.update({ + id: '/_layout', + path: '/_layout', + getParentRoute: () => rootRouteImport, +} as any) +const AuthRouteRoute = AuthRouteRouteImport.update({ + id: '/_auth', + path: '/_auth', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const NestedIndexRoute = NestedIndexRouteImport.update({ + id: '/nested/index', + path: '/nested/index', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/_auth': typeof AuthRouteRoute + '/_layout': typeof LayoutRoute + '/_prefix_middle_suffix': typeof Prefix_middle_suffixRoute + '/api_v2_users': typeof Api_v2_usersRoute + '/blog_': typeof BlogRoute + '/foo_bar': typeof Foo_barRoute + '/index': typeof IndexRoute + '/lazy': typeof LazyRoute + '/route': typeof RouteRoute + '/nested/index': typeof NestedIndexRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/_auth': typeof AuthRouteRoute + '/_layout': typeof LayoutRoute + '/_prefix_middle_suffix': typeof Prefix_middle_suffixRoute + '/api_v2_users': typeof Api_v2_usersRoute + '/blog_': typeof BlogRoute + '/foo_bar': typeof Foo_barRoute + '/index': typeof IndexRoute + '/lazy': typeof LazyRoute + '/route': typeof RouteRoute + '/nested/index': typeof NestedIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/_auth': typeof AuthRouteRoute + '/_layout': typeof LayoutRoute + '/_prefix_middle_suffix': typeof Prefix_middle_suffixRoute + '/api_v2_users': typeof Api_v2_usersRoute + '/blog_': typeof BlogRoute + '/foo_bar': typeof Foo_barRoute + '/index': typeof IndexRoute + '/lazy': typeof LazyRoute + '/route': typeof RouteRoute + '/nested/index': typeof NestedIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/_auth' + | '/_layout' + | '/_prefix_middle_suffix' + | '/api_v2_users' + | '/blog_' + | '/foo_bar' + | '/index' + | '/lazy' + | '/route' + | '/nested/index' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/_auth' + | '/_layout' + | '/_prefix_middle_suffix' + | '/api_v2_users' + | '/blog_' + | '/foo_bar' + | '/index' + | '/lazy' + | '/route' + | '/nested/index' + id: + | '__root__' + | '/' + | '/_auth' + | '/_layout' + | '/_prefix_middle_suffix' + | '/api_v2_users' + | '/blog_' + | '/foo_bar' + | '/index' + | '/lazy' + | '/route' + | '/nested/index' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AuthRouteRoute: typeof AuthRouteRoute + LayoutRoute: typeof LayoutRoute + Prefix_middle_suffixRoute: typeof Prefix_middle_suffixRoute + Api_v2_usersRoute: typeof Api_v2_usersRoute + BlogRoute: typeof BlogRoute + Foo_barRoute: typeof Foo_barRoute + IndexRoute: typeof IndexRoute + LazyRoute: typeof LazyRoute + RouteRoute: typeof RouteRoute + NestedIndexRoute: typeof NestedIndexRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/index': { + id: '/index' + path: '/index' + fullPath: '/index' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/route': { + id: '/route' + path: '/route' + fullPath: '/route' + preLoaderRoute: typeof RouteRouteImport + parentRoute: typeof rootRouteImport + } + '/lazy': { + id: '/lazy' + path: '/lazy' + fullPath: '/lazy' + preLoaderRoute: typeof LazyRouteImport + parentRoute: typeof rootRouteImport + } + '/foo_bar': { + id: '/foo_bar' + path: '/foo_bar' + fullPath: '/foo_bar' + preLoaderRoute: typeof Foo_barRouteImport + parentRoute: typeof rootRouteImport + } + '/blog_': { + id: '/blog_' + path: '/blog_' + fullPath: '/blog_' + preLoaderRoute: typeof BlogRouteImport + parentRoute: typeof rootRouteImport + } + '/api_v2_users': { + id: '/api_v2_users' + path: '/api_v2_users' + fullPath: '/api_v2_users' + preLoaderRoute: typeof Api_v2_usersRouteImport + parentRoute: typeof rootRouteImport + } + '/_prefix_middle_suffix': { + id: '/_prefix_middle_suffix' + path: '/_prefix_middle_suffix' + fullPath: '/_prefix_middle_suffix' + preLoaderRoute: typeof Prefix_middle_suffixRouteImport + parentRoute: typeof rootRouteImport + } + '/_layout': { + id: '/_layout' + path: '/_layout' + fullPath: '/_layout' + preLoaderRoute: typeof LayoutRouteImport + parentRoute: typeof rootRouteImport + } + '/_auth': { + id: '/_auth' + path: '/_auth' + fullPath: '/_auth' + preLoaderRoute: typeof AuthRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/nested/index': { + id: '/nested/index' + path: '/nested/index' + fullPath: '/nested/index' + preLoaderRoute: typeof NestedIndexRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AuthRouteRoute: AuthRouteRoute, + LayoutRoute: LayoutRoute, + Prefix_middle_suffixRoute: Prefix_middle_suffixRoute, + Api_v2_usersRoute: Api_v2_usersRoute, + BlogRoute: BlogRoute, + Foo_barRoute: Foo_barRoute, + IndexRoute: IndexRoute, + LazyRoute: LazyRoute, + RouteRoute: RouteRoute, + NestedIndexRoute: NestedIndexRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/packages/router-generator/tests/generator/escaped-special-strings/routes/[_]auth.route.tsx b/packages/router-generator/tests/generator/escaped-special-strings/routes/[_]auth.route.tsx new file mode 100644 index 00000000000..8451a485f72 --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-special-strings/routes/[_]auth.route.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// This should be a layout config for /_auth path (underscore is escaped, but .route suffix is honored) +export const Route = createFileRoute('/_auth')() diff --git a/packages/router-generator/tests/generator/escaped-special-strings/routes/[_]layout.tsx b/packages/router-generator/tests/generator/escaped-special-strings/routes/[_]layout.tsx new file mode 100644 index 00000000000..d20b05c5540 --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-special-strings/routes/[_]layout.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// This should be a literal /_layout path, NOT treated as a pathless layout +export const Route = createFileRoute('/_layout')() diff --git a/packages/router-generator/tests/generator/escaped-special-strings/routes/[_]prefix[_]middle[_]suffix.tsx b/packages/router-generator/tests/generator/escaped-special-strings/routes/[_]prefix[_]middle[_]suffix.tsx new file mode 100644 index 00000000000..65d7e2236b5 --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-special-strings/routes/[_]prefix[_]middle[_]suffix.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// This should be a literal /_prefix_middle_suffix path with underscores everywhere +export const Route = createFileRoute('/_prefix_middle_suffix')() diff --git a/packages/router-generator/tests/generator/escaped-special-strings/routes/[index].tsx b/packages/router-generator/tests/generator/escaped-special-strings/routes/[index].tsx new file mode 100644 index 00000000000..422cd911732 --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-special-strings/routes/[index].tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// This should be a literal /index path, NOT treated as the index route +export const Route = createFileRoute('/index')() diff --git a/packages/router-generator/tests/generator/escaped-special-strings/routes/[lazy].tsx b/packages/router-generator/tests/generator/escaped-special-strings/routes/[lazy].tsx new file mode 100644 index 00000000000..0720796d510 --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-special-strings/routes/[lazy].tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// This should be a literal /lazy path, NOT treated as a lazy-loaded route +export const Route = createFileRoute('/lazy')() diff --git a/packages/router-generator/tests/generator/escaped-special-strings/routes/[route].tsx b/packages/router-generator/tests/generator/escaped-special-strings/routes/[route].tsx new file mode 100644 index 00000000000..e8d861dfedc --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-special-strings/routes/[route].tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// This should be a literal /route path, NOT treated as a layout config file +export const Route = createFileRoute('/route')() diff --git a/packages/router-generator/tests/generator/escaped-special-strings/routes/__root.tsx b/packages/router-generator/tests/generator/escaped-special-strings/routes/__root.tsx new file mode 100644 index 00000000000..c11ed1e011b --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-special-strings/routes/__root.tsx @@ -0,0 +1,2 @@ +// @ts-nocheck +export const Route = createRootRoute() diff --git a/packages/router-generator/tests/generator/escaped-special-strings/routes/api[_]v2[_]users.tsx b/packages/router-generator/tests/generator/escaped-special-strings/routes/api[_]v2[_]users.tsx new file mode 100644 index 00000000000..2a6cfceef7e --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-special-strings/routes/api[_]v2[_]users.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// This should be a literal /api_v2_users path with multiple underscores in middle +export const Route = createFileRoute('/api_v2_users')() diff --git a/packages/router-generator/tests/generator/escaped-special-strings/routes/blog[_].tsx b/packages/router-generator/tests/generator/escaped-special-strings/routes/blog[_].tsx new file mode 100644 index 00000000000..0e03cea09e0 --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-special-strings/routes/blog[_].tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// This should be a literal /blog_ path, NOT escaping from parent layout +export const Route = createFileRoute('/blog_')() diff --git a/packages/router-generator/tests/generator/escaped-special-strings/routes/foo[_]bar.tsx b/packages/router-generator/tests/generator/escaped-special-strings/routes/foo[_]bar.tsx new file mode 100644 index 00000000000..1b3ec54a03c --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-special-strings/routes/foo[_]bar.tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// This should be a literal /foo_bar path with underscore in middle +export const Route = createFileRoute('/foo_bar')() diff --git a/packages/router-generator/tests/generator/escaped-special-strings/routes/index.tsx b/packages/router-generator/tests/generator/escaped-special-strings/routes/index.tsx new file mode 100644 index 00000000000..f338b4f8e93 --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-special-strings/routes/index.tsx @@ -0,0 +1,3 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +export const Route = createFileRoute('/')() diff --git a/packages/router-generator/tests/generator/escaped-special-strings/routes/nested.[index].tsx b/packages/router-generator/tests/generator/escaped-special-strings/routes/nested.[index].tsx new file mode 100644 index 00000000000..33b5b3e3886 --- /dev/null +++ b/packages/router-generator/tests/generator/escaped-special-strings/routes/nested.[index].tsx @@ -0,0 +1,4 @@ +import { createFileRoute } from '@tanstack/react-router' +// @ts-nocheck +// This should be /nested/index (literal), NOT treated as the index of /nested +export const Route = createFileRoute('/nested/index')() diff --git a/packages/router-generator/tests/utils.test.ts b/packages/router-generator/tests/utils.test.ts index c556edeaf55..8d1d1be3723 100644 --- a/packages/router-generator/tests/utils.test.ts +++ b/packages/router-generator/tests/utils.test.ts @@ -3,12 +3,17 @@ import { RoutePrefixMap, cleanPath, determineInitialRoutePath, + hasEscapedLeadingUnderscore, + hasEscapedTrailingUnderscore, + isSegmentPathless, mergeImportDeclarations, multiSortBy, removeExt, + removeLayoutSegmentsWithEscape, removeLeadingUnderscores, removeTrailingUnderscores, removeUnderscores, + removeUnderscoresWithEscape, routePathToVariable, } from '../src/utils' import type { ImportDeclaration, RouteNode } from '../src/types' @@ -76,7 +81,7 @@ describe('determineInitialRoutePath', () => { expect(consoleSpy).toBeCalledWith( 'Error: Disallowed character "/" found in square brackets in route path "/a[/]".\n' + - 'You cannot use any of the following characters in square brackets: /, \\, ?, #, :, *, <, >, |, !, $, %, _\n' + + 'You cannot use any of the following characters in square brackets: /, \\, ?, #, :, *, <, >, |, !, $, %\n' + 'Please remove and/or replace them.', ) @@ -296,6 +301,199 @@ describe('removeTrailingUnderscores', () => { }) }) +describe('hasEscapedLeadingUnderscore', () => { + it('returns true for [_] prefix pattern', () => { + expect(hasEscapedLeadingUnderscore('[_]layout')).toBe(true) + expect(hasEscapedLeadingUnderscore('[_]foo')).toBe(true) + expect(hasEscapedLeadingUnderscore('[_]')).toBe(true) + }) + + it('returns true for fully escaped segment starting with underscore', () => { + expect(hasEscapedLeadingUnderscore('[_layout]')).toBe(true) + expect(hasEscapedLeadingUnderscore('[_foo]')).toBe(true) + expect(hasEscapedLeadingUnderscore('[_1nd3x]')).toBe(true) + expect(hasEscapedLeadingUnderscore('[_]')).toBe(true) + }) + + it('returns false for non-escaped leading underscore', () => { + expect(hasEscapedLeadingUnderscore('_layout')).toBe(false) + expect(hasEscapedLeadingUnderscore('_foo')).toBe(false) + }) + + it('returns false for segments without leading underscore', () => { + expect(hasEscapedLeadingUnderscore('layout')).toBe(false) + expect(hasEscapedLeadingUnderscore('[layout]')).toBe(false) + expect(hasEscapedLeadingUnderscore('foo[_]')).toBe(false) + }) + + it('returns false for partial escapes with nested brackets', () => { + expect(hasEscapedLeadingUnderscore('[_foo[bar]')).toBe(false) + expect(hasEscapedLeadingUnderscore('[_foo]bar]')).toBe(false) + }) +}) + +describe('hasEscapedTrailingUnderscore', () => { + it('returns true for [_] suffix pattern', () => { + expect(hasEscapedTrailingUnderscore('blog[_]')).toBe(true) + expect(hasEscapedTrailingUnderscore('foo[_]')).toBe(true) + expect(hasEscapedTrailingUnderscore('[_]')).toBe(true) + }) + + it('returns true for fully escaped segment ending with underscore', () => { + expect(hasEscapedTrailingUnderscore('[blog_]')).toBe(true) + expect(hasEscapedTrailingUnderscore('[foo_]')).toBe(true) + expect(hasEscapedTrailingUnderscore('[_r0ut3_]')).toBe(true) + expect(hasEscapedTrailingUnderscore('[_]')).toBe(true) + }) + + it('returns false for non-escaped trailing underscore', () => { + expect(hasEscapedTrailingUnderscore('blog_')).toBe(false) + expect(hasEscapedTrailingUnderscore('foo_')).toBe(false) + }) + + it('returns false for segments without trailing underscore', () => { + expect(hasEscapedTrailingUnderscore('blog')).toBe(false) + expect(hasEscapedTrailingUnderscore('[blog]')).toBe(false) + expect(hasEscapedTrailingUnderscore('[_]foo')).toBe(false) + }) + + it('returns false for partial escapes with nested brackets', () => { + expect(hasEscapedTrailingUnderscore('[foo[bar]_]')).toBe(false) + expect(hasEscapedTrailingUnderscore('[foo]bar_]')).toBe(false) + }) +}) + +describe('isSegmentPathless', () => { + it('returns true for non-escaped leading underscore', () => { + expect(isSegmentPathless('_layout', '_layout')).toBe(true) + expect(isSegmentPathless('_foo', '_foo')).toBe(true) + }) + + it('returns false for escaped leading underscore with [_] prefix', () => { + expect(isSegmentPathless('_layout', '[_]layout')).toBe(false) + expect(isSegmentPathless('_foo', '[_]foo')).toBe(false) + }) + + it('returns false for fully escaped segment', () => { + expect(isSegmentPathless('_layout', '[_layout]')).toBe(false) + expect(isSegmentPathless('_1nd3x', '[_1nd3x]')).toBe(false) + }) + + it('returns false for segments not starting with underscore', () => { + expect(isSegmentPathless('layout', 'layout')).toBe(false) + expect(isSegmentPathless('foo', '[foo]')).toBe(false) + }) +}) + +describe('removeUnderscoresWithEscape', () => { + it('removes non-escaped leading underscores', () => { + expect(removeUnderscoresWithEscape('/_layout', '/_layout')).toBe('/layout') + expect(removeUnderscoresWithEscape('/_foo/_bar', '/_foo/_bar')).toBe( + '/foo/bar', + ) + }) + + it('removes non-escaped trailing underscores', () => { + expect(removeUnderscoresWithEscape('/blog_', '/blog_')).toBe('/blog') + expect(removeUnderscoresWithEscape('/foo_/bar_', '/foo_/bar_')).toBe( + '/foo/bar', + ) + }) + + it('preserves escaped leading underscores with [_] prefix', () => { + expect(removeUnderscoresWithEscape('/_layout', '/[_]layout')).toBe( + '/_layout', + ) + expect(removeUnderscoresWithEscape('/_foo', '/[_]foo')).toBe('/_foo') + }) + + it('preserves escaped trailing underscores with [_] suffix', () => { + expect(removeUnderscoresWithEscape('/blog_', '/blog[_]')).toBe('/blog_') + expect(removeUnderscoresWithEscape('/foo_', '/foo[_]')).toBe('/foo_') + }) + + it('preserves fully escaped segments with underscores', () => { + expect(removeUnderscoresWithEscape('/_layout', '/[_layout]')).toBe( + '/_layout', + ) + expect(removeUnderscoresWithEscape('/_r0ut3_', '/[_r0ut3_]')).toBe( + '/_r0ut3_', + ) + }) + + it('handles mixed escaped and non-escaped underscores', () => { + expect( + removeUnderscoresWithEscape('/_foo/_bar_/baz_', '/_foo/[_]bar_/baz[_]'), + ).toBe('/foo/_bar/baz_') + }) + + it('falls back to removeUnderscores when no originalPath', () => { + expect(removeUnderscoresWithEscape('/_foo_')).toBe('/foo') + expect(removeUnderscoresWithEscape('/_foo_', undefined)).toBe('/foo') + }) + + it('returns empty string for empty/undefined routePath', () => { + expect(removeUnderscoresWithEscape(undefined)).toBe('') + expect(removeUnderscoresWithEscape('')).toBe('') + }) +}) + +describe('removeLayoutSegmentsWithEscape', () => { + it('removes non-escaped layout segments', () => { + expect(removeLayoutSegmentsWithEscape('/_layout/foo', '/_layout/foo')).toBe( + '/foo', + ) + expect( + removeLayoutSegmentsWithEscape( + '/_auth/_admin/dashboard', + '/_auth/_admin/dashboard', + ), + ).toBe('/dashboard') + }) + + it('preserves escaped layout segments with [_] prefix', () => { + expect( + removeLayoutSegmentsWithEscape('/_layout/foo', '/[_]layout/foo'), + ).toBe('/_layout/foo') + expect( + removeLayoutSegmentsWithEscape('/_auth/dashboard', '/[_]auth/dashboard'), + ).toBe('/_auth/dashboard') + }) + + it('preserves fully escaped segments starting with underscore', () => { + expect( + removeLayoutSegmentsWithEscape('/_layout/foo', '/[_layout]/foo'), + ).toBe('/_layout/foo') + expect(removeLayoutSegmentsWithEscape('/_1nd3x/bar', '/[_1nd3x]/bar')).toBe( + '/_1nd3x/bar', + ) + }) + + it('handles mixed escaped and non-escaped layout segments', () => { + expect( + removeLayoutSegmentsWithEscape( + '/_auth/_admin/dashboard', + '/[_]auth/_admin/dashboard', + ), + ).toBe('/_auth/dashboard') + expect( + removeLayoutSegmentsWithEscape('/_foo/_bar/_baz', '/_foo/[_bar]/_baz'), + ).toBe('/_bar') + }) + + it('falls back to removeLayoutSegments when no originalPath', () => { + expect(removeLayoutSegmentsWithEscape('/_foo/bar/_baz')).toBe('/bar') + expect(removeLayoutSegmentsWithEscape('/_foo/bar/_baz', undefined)).toBe( + '/bar', + ) + }) + + it('handles root path', () => { + expect(removeLayoutSegmentsWithEscape('/')).toBe('/') + expect(removeLayoutSegmentsWithEscape()).toBe('/') + }) +}) + describe('routePathToVariable', () => { it.each([ ['/test/$/index', 'TestSplatIndex'], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d94b8fbe52..4a3eae65cb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -670,6 +670,40 @@ importers: specifier: ^7.1.7 version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + e2e/react-router/escaped-special-strings: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/router-plugin': + specifier: workspace:* + version: link:../../../packages/router-plugin + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + devDependencies: + '@playwright/test': + specifier: ^1.56.1 + version: 1.56.1 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/react': + specifier: ^19.2.2 + version: 19.2.2 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.2(@types/react@19.2.2) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + vite: + specifier: ^7.1.7 + version: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) + e2e/react-router/generator-cli-only: dependencies: '@tailwindcss/postcss': @@ -10555,7 +10589,7 @@ importers: devDependencies: '@netlify/vite-plugin-tanstack-start': specifier: ^1.1.4 - version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + version: 1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) '@tailwindcss/postcss': specifier: ^4.1.15 version: 4.1.15 @@ -26798,13 +26832,13 @@ snapshots: uuid: 11.1.0 write-file-atomic: 5.0.1 - '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)': + '@netlify/dev@4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/config': 23.2.0 '@netlify/dev-utils': 4.3.0 '@netlify/edge-functions-dev': 1.0.0 - '@netlify/functions-dev': 1.0.0(rollup@4.52.5) + '@netlify/functions-dev': 1.0.0(encoding@0.1.13)(rollup@4.52.5) '@netlify/headers': 2.1.0 '@netlify/images': 1.3.0(@netlify/blobs@10.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0) '@netlify/redirects': 3.1.0 @@ -26872,12 +26906,12 @@ snapshots: dependencies: '@netlify/types': 2.1.0 - '@netlify/functions-dev@1.0.0(rollup@4.52.5)': + '@netlify/functions-dev@1.0.0(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@netlify/blobs': 10.1.0 '@netlify/dev-utils': 4.3.0 '@netlify/functions': 5.0.0 - '@netlify/zip-it-and-ship-it': 14.1.11(rollup@4.52.5) + '@netlify/zip-it-and-ship-it': 14.1.11(encoding@0.1.13)(rollup@4.52.5) cron-parser: 4.9.0 decache: 4.6.2 extract-zip: 2.0.1 @@ -26967,9 +27001,9 @@ snapshots: '@netlify/types@2.1.0': {} - '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': + '@netlify/vite-plugin-tanstack-start@1.1.4(@tanstack/solid-start@packages+solid-start)(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) + '@netlify/vite-plugin': 2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1)) vite: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) optionalDependencies: '@tanstack/solid-start': link:packages/solid-start @@ -26997,9 +27031,9 @@ snapshots: - supports-color - uploadthing - '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': + '@netlify/vite-plugin@2.7.4(babel-plugin-macros@3.1.0)(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5)(vite@7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1))': dependencies: - '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(ioredis@5.8.0)(rollup@4.52.5) + '@netlify/dev': 4.6.3(db0@0.3.4(@electric-sql/pglite@0.3.2)(@libsql/client@0.15.15)(mysql2@3.15.3))(encoding@0.1.13)(ioredis@5.8.0)(rollup@4.52.5) '@netlify/dev-utils': 4.3.0 dedent: 1.7.0(babel-plugin-macros@3.1.0) vite: 7.1.7(@types/node@22.10.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.8.1) @@ -27027,13 +27061,13 @@ snapshots: - supports-color - uploadthing - '@netlify/zip-it-and-ship-it@14.1.11(rollup@4.52.5)': + '@netlify/zip-it-and-ship-it@14.1.11(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@babel/parser': 7.28.5 '@babel/types': 7.28.4 '@netlify/binary-info': 1.0.0 '@netlify/serverless-functions-api': 2.7.1 - '@vercel/nft': 0.29.4(rollup@4.52.5) + '@vercel/nft': 0.29.4(encoding@0.1.13)(rollup@4.52.5) archiver: 7.0.1 common-path-prefix: 3.0.0 copy-file: 11.1.0 @@ -30101,7 +30135,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vercel/nft@0.29.4(rollup@4.52.5)': + '@vercel/nft@0.29.4(encoding@0.1.13)(rollup@4.52.5)': dependencies: '@mapbox/node-pre-gyp': 2.0.0(encoding@0.1.13) '@rollup/pluginutils': 5.1.4(rollup@4.52.5)