diff --git a/e2e/react-router/compiled-matcher/.gitignore b/e2e/react-router/compiled-matcher/.gitignore new file mode 100644 index 00000000000..4d2da67b504 --- /dev/null +++ b/e2e/react-router/compiled-matcher/.gitignore @@ -0,0 +1,11 @@ +node_modules +.DS_Store +dist +dist-hash +dist-ssr +*.local + +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/e2e/react-router/compiled-matcher/index.html b/e2e/react-router/compiled-matcher/index.html new file mode 100644 index 00000000000..21e30f16951 --- /dev/null +++ b/e2e/react-router/compiled-matcher/index.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/e2e/react-router/compiled-matcher/package.json b/e2e/react-router/compiled-matcher/package.json new file mode 100644 index 00000000000..766f04cb608 --- /dev/null +++ b/e2e/react-router/compiled-matcher/package.json @@ -0,0 +1,36 @@ +{ + "name": "tanstack-router-e2e-react-compiled-matcher", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 3000", + "dev:e2e": "vite", + "build": "vite build && tsc --noEmit", + "build:fresh": "rm -rf .tanstack && rm ./src/routeTree.gen.ts && pnpm i && vite build", + "serve": "vite preview", + "start": "vite", + "test:e2e": "rm -rf port*.txt; playwright test --project=chromium" + }, + "dependencies": { + "@tanstack/react-router": "workspace:^", + "@tanstack/react-router-devtools": "workspace:^", + "@tanstack/router-plugin": "workspace:^", + "@tanstack/zod-adapter": "workspace:^", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "redaxios": "^0.5.1", + "postcss": "^8.5.1", + "autoprefixer": "^10.4.20", + "tailwindcss": "^3.4.17", + "zod": "^3.24.2" + }, + "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", + "combinate": "^1.1.11", + "vite": "^6.3.5" + } +} diff --git a/e2e/react-router/compiled-matcher/playwright.config.ts b/e2e/react-router/compiled-matcher/playwright.config.ts new file mode 100644 index 00000000000..4dc2271f01e --- /dev/null +++ b/e2e/react-router/compiled-matcher/playwright.config.ts @@ -0,0 +1,41 @@ +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}` +/** + * 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: `VITE_NODE_ENV="test" VITE_SERVER_PORT=${PORT} VITE_EXTERNAL_PORT=${EXTERNAL_PORT} pnpm build && VITE_SERVER_PORT=${PORT} pnpm serve --port ${PORT}`, + url: baseURL, + reuseExistingServer: !process.env.CI, + stdout: 'pipe', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/e2e/react-router/compiled-matcher/postcss.config.mjs b/e2e/react-router/compiled-matcher/postcss.config.mjs new file mode 100644 index 00000000000..2e7af2b7f1a --- /dev/null +++ b/e2e/react-router/compiled-matcher/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/e2e/react-router/compiled-matcher/src/main.tsx b/e2e/react-router/compiled-matcher/src/main.tsx new file mode 100644 index 00000000000..3dc73ddd511 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/main.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' +import './styles.css' + +// Set up a Router instance +const router = createRouter({ + routeTree, + defaultPreload: 'intent', + defaultStaleTime: 5000, + scrollRestoration: true, +}) + +// 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/compiled-matcher/src/routeTree.gen.ts b/e2e/react-router/compiled-matcher/src/routeTree.gen.ts new file mode 100644 index 00000000000..4dd627fae30 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routeTree.gen.ts @@ -0,0 +1,899 @@ +/* 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 OneRouteImport } from './routes/one' +import { Route as AboutRouteImport } from './routes/about' +import { Route as IndexRouteImport } from './routes/index' +import { Route as BIndexRouteImport } from './routes/b/index' +import { Route as AIndexRouteImport } from './routes/a/index' +import { Route as UsersIdRouteImport } from './routes/users/$id' +import { Route as PostsChar123SlugChar125RouteImport } from './routes/posts/{-$slug}' +import { Route as OneTwoRouteImport } from './routes/one/two' +import { Route as ImagesThumb_Char123Char125RouteImport } from './routes/images/thumb_{$}' +import { Route as FooBarRouteImport } from './routes/foo/$bar' +import { Route as FilesSplatRouteImport } from './routes/files/$' +import { Route as BeepBoopRouteImport } from './routes/beep/boop' +import { Route as BChar123SlugChar125RouteImport } from './routes/b/{-$slug}' +import { Route as BUserChar123idChar125RouteImport } from './routes/b/user-{$id}' +import { Route as BIdRouteImport } from './routes/b/$id' +import { Route as BSplatRouteImport } from './routes/b/$' +import { Route as ApiUserChar123idChar125RouteImport } from './routes/api/user-{$id}' +import { Route as AChar123SlugChar125RouteImport } from './routes/a/{-$slug}' +import { Route as AUserChar123idChar125RouteImport } from './routes/a/user-{$id}' +import { Route as AIdRouteImport } from './routes/a/$id' +import { Route as ASplatRouteImport } from './routes/a/$' +import { Route as UsersProfileIndexRouteImport } from './routes/users/profile/index' +import { Route as FooBarIndexRouteImport } from './routes/foo/$bar.index' +import { Route as BProfileIndexRouteImport } from './routes/b/profile/index' +import { Route as AProfileIndexRouteImport } from './routes/a/profile/index' +import { Route as UsersProfileSettingsRouteImport } from './routes/users/profile/settings' +import { Route as LogsChar123Char125TxtRouteImport } from './routes/logs/{$}.txt' +import { Route as FooChar123BarChar125QuxRouteImport } from './routes/foo/{-$bar}/qux' +import { Route as FooBarIdRouteImport } from './routes/foo/bar/$id' +import { Route as FooIdBarRouteImport } from './routes/foo/$id/bar' +import { Route as CacheTemp_Char123Char125LogRouteImport } from './routes/cache/temp_{$}.log' +import { Route as BProfileSettingsRouteImport } from './routes/b/profile/settings' +import { Route as AProfileSettingsRouteImport } from './routes/a/profile/settings' +import { Route as IdFooBarRouteImport } from './routes/$id/foo/bar' +import { Route as IdBarFooRouteImport } from './routes/$id/bar/foo' +import { Route as ZYXIndexRouteImport } from './routes/z/y/x/index' +import { Route as ZYXWRouteImport } from './routes/z/y/x/w' +import { Route as ZYXVRouteImport } from './routes/z/y/x/v' +import { Route as ZYXURouteImport } from './routes/z/y/x/u' +import { Route as ABCDEFRouteImport } from './routes/a/b/c/d/e/f' + +const OneRoute = OneRouteImport.update({ + id: '/one', + path: '/one', + getParentRoute: () => rootRouteImport, +} as any) +const AboutRoute = AboutRouteImport.update({ + id: '/about', + path: '/about', + getParentRoute: () => rootRouteImport, +} as any) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +} as any) +const BIndexRoute = BIndexRouteImport.update({ + id: '/b/', + path: '/b/', + getParentRoute: () => rootRouteImport, +} as any) +const AIndexRoute = AIndexRouteImport.update({ + id: '/a/', + path: '/a/', + getParentRoute: () => rootRouteImport, +} as any) +const UsersIdRoute = UsersIdRouteImport.update({ + id: '/users/$id', + path: '/users/$id', + getParentRoute: () => rootRouteImport, +} as any) +const PostsChar123SlugChar125Route = PostsChar123SlugChar125RouteImport.update({ + id: '/posts/{-$slug}', + path: '/posts/{-$slug}', + getParentRoute: () => rootRouteImport, +} as any) +const OneTwoRoute = OneTwoRouteImport.update({ + id: '/two', + path: '/two', + getParentRoute: () => OneRoute, +} as any) +const ImagesThumb_Char123Char125Route = + ImagesThumb_Char123Char125RouteImport.update({ + id: '/images/thumb_{$}', + path: '/images/thumb_{$}', + getParentRoute: () => rootRouteImport, + } as any) +const FooBarRoute = FooBarRouteImport.update({ + id: '/foo/$bar', + path: '/foo/$bar', + getParentRoute: () => rootRouteImport, +} as any) +const FilesSplatRoute = FilesSplatRouteImport.update({ + id: '/files/$', + path: '/files/$', + getParentRoute: () => rootRouteImport, +} as any) +const BeepBoopRoute = BeepBoopRouteImport.update({ + id: '/beep/boop', + path: '/beep/boop', + getParentRoute: () => rootRouteImport, +} as any) +const BChar123SlugChar125Route = BChar123SlugChar125RouteImport.update({ + id: '/b/{-$slug}', + path: '/b/{-$slug}', + getParentRoute: () => rootRouteImport, +} as any) +const BUserChar123idChar125Route = BUserChar123idChar125RouteImport.update({ + id: '/b/user-{$id}', + path: '/b/user-{$id}', + getParentRoute: () => rootRouteImport, +} as any) +const BIdRoute = BIdRouteImport.update({ + id: '/b/$id', + path: '/b/$id', + getParentRoute: () => rootRouteImport, +} as any) +const BSplatRoute = BSplatRouteImport.update({ + id: '/b/$', + path: '/b/$', + getParentRoute: () => rootRouteImport, +} as any) +const ApiUserChar123idChar125Route = ApiUserChar123idChar125RouteImport.update({ + id: '/api/user-{$id}', + path: '/api/user-{$id}', + getParentRoute: () => rootRouteImport, +} as any) +const AChar123SlugChar125Route = AChar123SlugChar125RouteImport.update({ + id: '/a/{-$slug}', + path: '/a/{-$slug}', + getParentRoute: () => rootRouteImport, +} as any) +const AUserChar123idChar125Route = AUserChar123idChar125RouteImport.update({ + id: '/a/user-{$id}', + path: '/a/user-{$id}', + getParentRoute: () => rootRouteImport, +} as any) +const AIdRoute = AIdRouteImport.update({ + id: '/a/$id', + path: '/a/$id', + getParentRoute: () => rootRouteImport, +} as any) +const ASplatRoute = ASplatRouteImport.update({ + id: '/a/$', + path: '/a/$', + getParentRoute: () => rootRouteImport, +} as any) +const UsersProfileIndexRoute = UsersProfileIndexRouteImport.update({ + id: '/users/profile/', + path: '/users/profile/', + getParentRoute: () => rootRouteImport, +} as any) +const FooBarIndexRoute = FooBarIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => FooBarRoute, +} as any) +const BProfileIndexRoute = BProfileIndexRouteImport.update({ + id: '/b/profile/', + path: '/b/profile/', + getParentRoute: () => rootRouteImport, +} as any) +const AProfileIndexRoute = AProfileIndexRouteImport.update({ + id: '/a/profile/', + path: '/a/profile/', + getParentRoute: () => rootRouteImport, +} as any) +const UsersProfileSettingsRoute = UsersProfileSettingsRouteImport.update({ + id: '/users/profile/settings', + path: '/users/profile/settings', + getParentRoute: () => rootRouteImport, +} as any) +const LogsChar123Char125TxtRoute = LogsChar123Char125TxtRouteImport.update({ + id: '/logs/{$}/txt', + path: '/logs/{$}/txt', + getParentRoute: () => rootRouteImport, +} as any) +const FooChar123BarChar125QuxRoute = FooChar123BarChar125QuxRouteImport.update({ + id: '/foo/{-$bar}/qux', + path: '/foo/{-$bar}/qux', + getParentRoute: () => rootRouteImport, +} as any) +const FooBarIdRoute = FooBarIdRouteImport.update({ + id: '/foo/bar/$id', + path: '/foo/bar/$id', + getParentRoute: () => rootRouteImport, +} as any) +const FooIdBarRoute = FooIdBarRouteImport.update({ + id: '/foo/$id/bar', + path: '/foo/$id/bar', + getParentRoute: () => rootRouteImport, +} as any) +const CacheTemp_Char123Char125LogRoute = + CacheTemp_Char123Char125LogRouteImport.update({ + id: '/cache/temp_{$}/log', + path: '/cache/temp_{$}/log', + getParentRoute: () => rootRouteImport, + } as any) +const BProfileSettingsRoute = BProfileSettingsRouteImport.update({ + id: '/b/profile/settings', + path: '/b/profile/settings', + getParentRoute: () => rootRouteImport, +} as any) +const AProfileSettingsRoute = AProfileSettingsRouteImport.update({ + id: '/a/profile/settings', + path: '/a/profile/settings', + getParentRoute: () => rootRouteImport, +} as any) +const IdFooBarRoute = IdFooBarRouteImport.update({ + id: '/$id/foo/bar', + path: '/$id/foo/bar', + getParentRoute: () => rootRouteImport, +} as any) +const IdBarFooRoute = IdBarFooRouteImport.update({ + id: '/$id/bar/foo', + path: '/$id/bar/foo', + getParentRoute: () => rootRouteImport, +} as any) +const ZYXIndexRoute = ZYXIndexRouteImport.update({ + id: '/z/y/x/', + path: '/z/y/x/', + getParentRoute: () => rootRouteImport, +} as any) +const ZYXWRoute = ZYXWRouteImport.update({ + id: '/z/y/x/w', + path: '/z/y/x/w', + getParentRoute: () => rootRouteImport, +} as any) +const ZYXVRoute = ZYXVRouteImport.update({ + id: '/z/y/x/v', + path: '/z/y/x/v', + getParentRoute: () => rootRouteImport, +} as any) +const ZYXURoute = ZYXURouteImport.update({ + id: '/z/y/x/u', + path: '/z/y/x/u', + getParentRoute: () => rootRouteImport, +} as any) +const ABCDEFRoute = ABCDEFRouteImport.update({ + id: '/a/b/c/d/e/f', + path: '/a/b/c/d/e/f', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/one': typeof OneRouteWithChildren + '/a/$': typeof ASplatRoute + '/a/$id': typeof AIdRoute + '/a/user-{$id}': typeof AUserChar123idChar125Route + '/a/{-$slug}': typeof AChar123SlugChar125Route + '/api/user-{$id}': typeof ApiUserChar123idChar125Route + '/b/$': typeof BSplatRoute + '/b/$id': typeof BIdRoute + '/b/user-{$id}': typeof BUserChar123idChar125Route + '/b/{-$slug}': typeof BChar123SlugChar125Route + '/beep/boop': typeof BeepBoopRoute + '/files/$': typeof FilesSplatRoute + '/foo/$bar': typeof FooBarRouteWithChildren + '/images/thumb_{$}': typeof ImagesThumb_Char123Char125Route + '/one/two': typeof OneTwoRoute + '/posts/{-$slug}': typeof PostsChar123SlugChar125Route + '/users/$id': typeof UsersIdRoute + '/a': typeof AIndexRoute + '/b': typeof BIndexRoute + '/$id/bar/foo': typeof IdBarFooRoute + '/$id/foo/bar': typeof IdFooBarRoute + '/a/profile/settings': typeof AProfileSettingsRoute + '/b/profile/settings': typeof BProfileSettingsRoute + '/cache/temp_{$}/log': typeof CacheTemp_Char123Char125LogRoute + '/foo/$id/bar': typeof FooIdBarRoute + '/foo/bar/$id': typeof FooBarIdRoute + '/foo/{-$bar}/qux': typeof FooChar123BarChar125QuxRoute + '/logs/{$}/txt': typeof LogsChar123Char125TxtRoute + '/users/profile/settings': typeof UsersProfileSettingsRoute + '/a/profile': typeof AProfileIndexRoute + '/b/profile': typeof BProfileIndexRoute + '/foo/$bar/': typeof FooBarIndexRoute + '/users/profile': typeof UsersProfileIndexRoute + '/z/y/x/u': typeof ZYXURoute + '/z/y/x/v': typeof ZYXVRoute + '/z/y/x/w': typeof ZYXWRoute + '/z/y/x': typeof ZYXIndexRoute + '/a/b/c/d/e/f': typeof ABCDEFRoute +} +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/one': typeof OneRouteWithChildren + '/a/$': typeof ASplatRoute + '/a/$id': typeof AIdRoute + '/a/user-{$id}': typeof AUserChar123idChar125Route + '/a/{-$slug}': typeof AChar123SlugChar125Route + '/api/user-{$id}': typeof ApiUserChar123idChar125Route + '/b/$': typeof BSplatRoute + '/b/$id': typeof BIdRoute + '/b/user-{$id}': typeof BUserChar123idChar125Route + '/b/{-$slug}': typeof BChar123SlugChar125Route + '/beep/boop': typeof BeepBoopRoute + '/files/$': typeof FilesSplatRoute + '/images/thumb_{$}': typeof ImagesThumb_Char123Char125Route + '/one/two': typeof OneTwoRoute + '/posts/{-$slug}': typeof PostsChar123SlugChar125Route + '/users/$id': typeof UsersIdRoute + '/a': typeof AIndexRoute + '/b': typeof BIndexRoute + '/$id/bar/foo': typeof IdBarFooRoute + '/$id/foo/bar': typeof IdFooBarRoute + '/a/profile/settings': typeof AProfileSettingsRoute + '/b/profile/settings': typeof BProfileSettingsRoute + '/cache/temp_{$}/log': typeof CacheTemp_Char123Char125LogRoute + '/foo/$id/bar': typeof FooIdBarRoute + '/foo/bar/$id': typeof FooBarIdRoute + '/foo/{-$bar}/qux': typeof FooChar123BarChar125QuxRoute + '/logs/{$}/txt': typeof LogsChar123Char125TxtRoute + '/users/profile/settings': typeof UsersProfileSettingsRoute + '/a/profile': typeof AProfileIndexRoute + '/b/profile': typeof BProfileIndexRoute + '/foo/$bar': typeof FooBarIndexRoute + '/users/profile': typeof UsersProfileIndexRoute + '/z/y/x/u': typeof ZYXURoute + '/z/y/x/v': typeof ZYXVRoute + '/z/y/x/w': typeof ZYXWRoute + '/z/y/x': typeof ZYXIndexRoute + '/a/b/c/d/e/f': typeof ABCDEFRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/': typeof IndexRoute + '/about': typeof AboutRoute + '/one': typeof OneRouteWithChildren + '/a/$': typeof ASplatRoute + '/a/$id': typeof AIdRoute + '/a/user-{$id}': typeof AUserChar123idChar125Route + '/a/{-$slug}': typeof AChar123SlugChar125Route + '/api/user-{$id}': typeof ApiUserChar123idChar125Route + '/b/$': typeof BSplatRoute + '/b/$id': typeof BIdRoute + '/b/user-{$id}': typeof BUserChar123idChar125Route + '/b/{-$slug}': typeof BChar123SlugChar125Route + '/beep/boop': typeof BeepBoopRoute + '/files/$': typeof FilesSplatRoute + '/foo/$bar': typeof FooBarRouteWithChildren + '/images/thumb_{$}': typeof ImagesThumb_Char123Char125Route + '/one/two': typeof OneTwoRoute + '/posts/{-$slug}': typeof PostsChar123SlugChar125Route + '/users/$id': typeof UsersIdRoute + '/a/': typeof AIndexRoute + '/b/': typeof BIndexRoute + '/$id/bar/foo': typeof IdBarFooRoute + '/$id/foo/bar': typeof IdFooBarRoute + '/a/profile/settings': typeof AProfileSettingsRoute + '/b/profile/settings': typeof BProfileSettingsRoute + '/cache/temp_{$}/log': typeof CacheTemp_Char123Char125LogRoute + '/foo/$id/bar': typeof FooIdBarRoute + '/foo/bar/$id': typeof FooBarIdRoute + '/foo/{-$bar}/qux': typeof FooChar123BarChar125QuxRoute + '/logs/{$}/txt': typeof LogsChar123Char125TxtRoute + '/users/profile/settings': typeof UsersProfileSettingsRoute + '/a/profile/': typeof AProfileIndexRoute + '/b/profile/': typeof BProfileIndexRoute + '/foo/$bar/': typeof FooBarIndexRoute + '/users/profile/': typeof UsersProfileIndexRoute + '/z/y/x/u': typeof ZYXURoute + '/z/y/x/v': typeof ZYXVRoute + '/z/y/x/w': typeof ZYXWRoute + '/z/y/x/': typeof ZYXIndexRoute + '/a/b/c/d/e/f': typeof ABCDEFRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/about' + | '/one' + | '/a/$' + | '/a/$id' + | '/a/user-{$id}' + | '/a/{-$slug}' + | '/api/user-{$id}' + | '/b/$' + | '/b/$id' + | '/b/user-{$id}' + | '/b/{-$slug}' + | '/beep/boop' + | '/files/$' + | '/foo/$bar' + | '/images/thumb_{$}' + | '/one/two' + | '/posts/{-$slug}' + | '/users/$id' + | '/a' + | '/b' + | '/$id/bar/foo' + | '/$id/foo/bar' + | '/a/profile/settings' + | '/b/profile/settings' + | '/cache/temp_{$}/log' + | '/foo/$id/bar' + | '/foo/bar/$id' + | '/foo/{-$bar}/qux' + | '/logs/{$}/txt' + | '/users/profile/settings' + | '/a/profile' + | '/b/profile' + | '/foo/$bar/' + | '/users/profile' + | '/z/y/x/u' + | '/z/y/x/v' + | '/z/y/x/w' + | '/z/y/x' + | '/a/b/c/d/e/f' + fileRoutesByTo: FileRoutesByTo + to: + | '/' + | '/about' + | '/one' + | '/a/$' + | '/a/$id' + | '/a/user-{$id}' + | '/a/{-$slug}' + | '/api/user-{$id}' + | '/b/$' + | '/b/$id' + | '/b/user-{$id}' + | '/b/{-$slug}' + | '/beep/boop' + | '/files/$' + | '/images/thumb_{$}' + | '/one/two' + | '/posts/{-$slug}' + | '/users/$id' + | '/a' + | '/b' + | '/$id/bar/foo' + | '/$id/foo/bar' + | '/a/profile/settings' + | '/b/profile/settings' + | '/cache/temp_{$}/log' + | '/foo/$id/bar' + | '/foo/bar/$id' + | '/foo/{-$bar}/qux' + | '/logs/{$}/txt' + | '/users/profile/settings' + | '/a/profile' + | '/b/profile' + | '/foo/$bar' + | '/users/profile' + | '/z/y/x/u' + | '/z/y/x/v' + | '/z/y/x/w' + | '/z/y/x' + | '/a/b/c/d/e/f' + id: + | '__root__' + | '/' + | '/about' + | '/one' + | '/a/$' + | '/a/$id' + | '/a/user-{$id}' + | '/a/{-$slug}' + | '/api/user-{$id}' + | '/b/$' + | '/b/$id' + | '/b/user-{$id}' + | '/b/{-$slug}' + | '/beep/boop' + | '/files/$' + | '/foo/$bar' + | '/images/thumb_{$}' + | '/one/two' + | '/posts/{-$slug}' + | '/users/$id' + | '/a/' + | '/b/' + | '/$id/bar/foo' + | '/$id/foo/bar' + | '/a/profile/settings' + | '/b/profile/settings' + | '/cache/temp_{$}/log' + | '/foo/$id/bar' + | '/foo/bar/$id' + | '/foo/{-$bar}/qux' + | '/logs/{$}/txt' + | '/users/profile/settings' + | '/a/profile/' + | '/b/profile/' + | '/foo/$bar/' + | '/users/profile/' + | '/z/y/x/u' + | '/z/y/x/v' + | '/z/y/x/w' + | '/z/y/x/' + | '/a/b/c/d/e/f' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AboutRoute: typeof AboutRoute + OneRoute: typeof OneRouteWithChildren + ASplatRoute: typeof ASplatRoute + AIdRoute: typeof AIdRoute + AUserChar123idChar125Route: typeof AUserChar123idChar125Route + AChar123SlugChar125Route: typeof AChar123SlugChar125Route + ApiUserChar123idChar125Route: typeof ApiUserChar123idChar125Route + BSplatRoute: typeof BSplatRoute + BIdRoute: typeof BIdRoute + BUserChar123idChar125Route: typeof BUserChar123idChar125Route + BChar123SlugChar125Route: typeof BChar123SlugChar125Route + BeepBoopRoute: typeof BeepBoopRoute + FilesSplatRoute: typeof FilesSplatRoute + FooBarRoute: typeof FooBarRouteWithChildren + ImagesThumb_Char123Char125Route: typeof ImagesThumb_Char123Char125Route + PostsChar123SlugChar125Route: typeof PostsChar123SlugChar125Route + UsersIdRoute: typeof UsersIdRoute + AIndexRoute: typeof AIndexRoute + BIndexRoute: typeof BIndexRoute + IdBarFooRoute: typeof IdBarFooRoute + IdFooBarRoute: typeof IdFooBarRoute + AProfileSettingsRoute: typeof AProfileSettingsRoute + BProfileSettingsRoute: typeof BProfileSettingsRoute + CacheTemp_Char123Char125LogRoute: typeof CacheTemp_Char123Char125LogRoute + FooIdBarRoute: typeof FooIdBarRoute + FooBarIdRoute: typeof FooBarIdRoute + FooChar123BarChar125QuxRoute: typeof FooChar123BarChar125QuxRoute + LogsChar123Char125TxtRoute: typeof LogsChar123Char125TxtRoute + UsersProfileSettingsRoute: typeof UsersProfileSettingsRoute + AProfileIndexRoute: typeof AProfileIndexRoute + BProfileIndexRoute: typeof BProfileIndexRoute + UsersProfileIndexRoute: typeof UsersProfileIndexRoute + ZYXURoute: typeof ZYXURoute + ZYXVRoute: typeof ZYXVRoute + ZYXWRoute: typeof ZYXWRoute + ZYXIndexRoute: typeof ZYXIndexRoute + ABCDEFRoute: typeof ABCDEFRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/one': { + id: '/one' + path: '/one' + fullPath: '/one' + preLoaderRoute: typeof OneRouteImport + parentRoute: typeof rootRouteImport + } + '/about': { + id: '/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof AboutRouteImport + parentRoute: typeof rootRouteImport + } + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexRouteImport + parentRoute: typeof rootRouteImport + } + '/b/': { + id: '/b/' + path: '/b' + fullPath: '/b' + preLoaderRoute: typeof BIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/a/': { + id: '/a/' + path: '/a' + fullPath: '/a' + preLoaderRoute: typeof AIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/users/$id': { + id: '/users/$id' + path: '/users/$id' + fullPath: '/users/$id' + preLoaderRoute: typeof UsersIdRouteImport + parentRoute: typeof rootRouteImport + } + '/posts/{-$slug}': { + id: '/posts/{-$slug}' + path: '/posts/{-$slug}' + fullPath: '/posts/{-$slug}' + preLoaderRoute: typeof PostsChar123SlugChar125RouteImport + parentRoute: typeof rootRouteImport + } + '/one/two': { + id: '/one/two' + path: '/two' + fullPath: '/one/two' + preLoaderRoute: typeof OneTwoRouteImport + parentRoute: typeof OneRoute + } + '/images/thumb_{$}': { + id: '/images/thumb_{$}' + path: '/images/thumb_{$}' + fullPath: '/images/thumb_{$}' + preLoaderRoute: typeof ImagesThumb_Char123Char125RouteImport + parentRoute: typeof rootRouteImport + } + '/foo/$bar': { + id: '/foo/$bar' + path: '/foo/$bar' + fullPath: '/foo/$bar' + preLoaderRoute: typeof FooBarRouteImport + parentRoute: typeof rootRouteImport + } + '/files/$': { + id: '/files/$' + path: '/files/$' + fullPath: '/files/$' + preLoaderRoute: typeof FilesSplatRouteImport + parentRoute: typeof rootRouteImport + } + '/beep/boop': { + id: '/beep/boop' + path: '/beep/boop' + fullPath: '/beep/boop' + preLoaderRoute: typeof BeepBoopRouteImport + parentRoute: typeof rootRouteImport + } + '/b/{-$slug}': { + id: '/b/{-$slug}' + path: '/b/{-$slug}' + fullPath: '/b/{-$slug}' + preLoaderRoute: typeof BChar123SlugChar125RouteImport + parentRoute: typeof rootRouteImport + } + '/b/user-{$id}': { + id: '/b/user-{$id}' + path: '/b/user-{$id}' + fullPath: '/b/user-{$id}' + preLoaderRoute: typeof BUserChar123idChar125RouteImport + parentRoute: typeof rootRouteImport + } + '/b/$id': { + id: '/b/$id' + path: '/b/$id' + fullPath: '/b/$id' + preLoaderRoute: typeof BIdRouteImport + parentRoute: typeof rootRouteImport + } + '/b/$': { + id: '/b/$' + path: '/b/$' + fullPath: '/b/$' + preLoaderRoute: typeof BSplatRouteImport + parentRoute: typeof rootRouteImport + } + '/api/user-{$id}': { + id: '/api/user-{$id}' + path: '/api/user-{$id}' + fullPath: '/api/user-{$id}' + preLoaderRoute: typeof ApiUserChar123idChar125RouteImport + parentRoute: typeof rootRouteImport + } + '/a/{-$slug}': { + id: '/a/{-$slug}' + path: '/a/{-$slug}' + fullPath: '/a/{-$slug}' + preLoaderRoute: typeof AChar123SlugChar125RouteImport + parentRoute: typeof rootRouteImport + } + '/a/user-{$id}': { + id: '/a/user-{$id}' + path: '/a/user-{$id}' + fullPath: '/a/user-{$id}' + preLoaderRoute: typeof AUserChar123idChar125RouteImport + parentRoute: typeof rootRouteImport + } + '/a/$id': { + id: '/a/$id' + path: '/a/$id' + fullPath: '/a/$id' + preLoaderRoute: typeof AIdRouteImport + parentRoute: typeof rootRouteImport + } + '/a/$': { + id: '/a/$' + path: '/a/$' + fullPath: '/a/$' + preLoaderRoute: typeof ASplatRouteImport + parentRoute: typeof rootRouteImport + } + '/users/profile/': { + id: '/users/profile/' + path: '/users/profile' + fullPath: '/users/profile' + preLoaderRoute: typeof UsersProfileIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/foo/$bar/': { + id: '/foo/$bar/' + path: '/' + fullPath: '/foo/$bar/' + preLoaderRoute: typeof FooBarIndexRouteImport + parentRoute: typeof FooBarRoute + } + '/b/profile/': { + id: '/b/profile/' + path: '/b/profile' + fullPath: '/b/profile' + preLoaderRoute: typeof BProfileIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/a/profile/': { + id: '/a/profile/' + path: '/a/profile' + fullPath: '/a/profile' + preLoaderRoute: typeof AProfileIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/users/profile/settings': { + id: '/users/profile/settings' + path: '/users/profile/settings' + fullPath: '/users/profile/settings' + preLoaderRoute: typeof UsersProfileSettingsRouteImport + parentRoute: typeof rootRouteImport + } + '/logs/{$}/txt': { + id: '/logs/{$}/txt' + path: '/logs/{$}/txt' + fullPath: '/logs/{$}/txt' + preLoaderRoute: typeof LogsChar123Char125TxtRouteImport + parentRoute: typeof rootRouteImport + } + '/foo/{-$bar}/qux': { + id: '/foo/{-$bar}/qux' + path: '/foo/{-$bar}/qux' + fullPath: '/foo/{-$bar}/qux' + preLoaderRoute: typeof FooChar123BarChar125QuxRouteImport + parentRoute: typeof rootRouteImport + } + '/foo/bar/$id': { + id: '/foo/bar/$id' + path: '/foo/bar/$id' + fullPath: '/foo/bar/$id' + preLoaderRoute: typeof FooBarIdRouteImport + parentRoute: typeof rootRouteImport + } + '/foo/$id/bar': { + id: '/foo/$id/bar' + path: '/foo/$id/bar' + fullPath: '/foo/$id/bar' + preLoaderRoute: typeof FooIdBarRouteImport + parentRoute: typeof rootRouteImport + } + '/cache/temp_{$}/log': { + id: '/cache/temp_{$}/log' + path: '/cache/temp_{$}/log' + fullPath: '/cache/temp_{$}/log' + preLoaderRoute: typeof CacheTemp_Char123Char125LogRouteImport + parentRoute: typeof rootRouteImport + } + '/b/profile/settings': { + id: '/b/profile/settings' + path: '/b/profile/settings' + fullPath: '/b/profile/settings' + preLoaderRoute: typeof BProfileSettingsRouteImport + parentRoute: typeof rootRouteImport + } + '/a/profile/settings': { + id: '/a/profile/settings' + path: '/a/profile/settings' + fullPath: '/a/profile/settings' + preLoaderRoute: typeof AProfileSettingsRouteImport + parentRoute: typeof rootRouteImport + } + '/$id/foo/bar': { + id: '/$id/foo/bar' + path: '/$id/foo/bar' + fullPath: '/$id/foo/bar' + preLoaderRoute: typeof IdFooBarRouteImport + parentRoute: typeof rootRouteImport + } + '/$id/bar/foo': { + id: '/$id/bar/foo' + path: '/$id/bar/foo' + fullPath: '/$id/bar/foo' + preLoaderRoute: typeof IdBarFooRouteImport + parentRoute: typeof rootRouteImport + } + '/z/y/x/': { + id: '/z/y/x/' + path: '/z/y/x' + fullPath: '/z/y/x' + preLoaderRoute: typeof ZYXIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/z/y/x/w': { + id: '/z/y/x/w' + path: '/z/y/x/w' + fullPath: '/z/y/x/w' + preLoaderRoute: typeof ZYXWRouteImport + parentRoute: typeof rootRouteImport + } + '/z/y/x/v': { + id: '/z/y/x/v' + path: '/z/y/x/v' + fullPath: '/z/y/x/v' + preLoaderRoute: typeof ZYXVRouteImport + parentRoute: typeof rootRouteImport + } + '/z/y/x/u': { + id: '/z/y/x/u' + path: '/z/y/x/u' + fullPath: '/z/y/x/u' + preLoaderRoute: typeof ZYXURouteImport + parentRoute: typeof rootRouteImport + } + '/a/b/c/d/e/f': { + id: '/a/b/c/d/e/f' + path: '/a/b/c/d/e/f' + fullPath: '/a/b/c/d/e/f' + preLoaderRoute: typeof ABCDEFRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +interface OneRouteChildren { + OneTwoRoute: typeof OneTwoRoute +} + +const OneRouteChildren: OneRouteChildren = { + OneTwoRoute: OneTwoRoute, +} + +const OneRouteWithChildren = OneRoute._addFileChildren(OneRouteChildren) + +interface FooBarRouteChildren { + FooBarIndexRoute: typeof FooBarIndexRoute +} + +const FooBarRouteChildren: FooBarRouteChildren = { + FooBarIndexRoute: FooBarIndexRoute, +} + +const FooBarRouteWithChildren = + FooBarRoute._addFileChildren(FooBarRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AboutRoute: AboutRoute, + OneRoute: OneRouteWithChildren, + ASplatRoute: ASplatRoute, + AIdRoute: AIdRoute, + AUserChar123idChar125Route: AUserChar123idChar125Route, + AChar123SlugChar125Route: AChar123SlugChar125Route, + ApiUserChar123idChar125Route: ApiUserChar123idChar125Route, + BSplatRoute: BSplatRoute, + BIdRoute: BIdRoute, + BUserChar123idChar125Route: BUserChar123idChar125Route, + BChar123SlugChar125Route: BChar123SlugChar125Route, + BeepBoopRoute: BeepBoopRoute, + FilesSplatRoute: FilesSplatRoute, + FooBarRoute: FooBarRouteWithChildren, + ImagesThumb_Char123Char125Route: ImagesThumb_Char123Char125Route, + PostsChar123SlugChar125Route: PostsChar123SlugChar125Route, + UsersIdRoute: UsersIdRoute, + AIndexRoute: AIndexRoute, + BIndexRoute: BIndexRoute, + IdBarFooRoute: IdBarFooRoute, + IdFooBarRoute: IdFooBarRoute, + AProfileSettingsRoute: AProfileSettingsRoute, + BProfileSettingsRoute: BProfileSettingsRoute, + CacheTemp_Char123Char125LogRoute: CacheTemp_Char123Char125LogRoute, + FooIdBarRoute: FooIdBarRoute, + FooBarIdRoute: FooBarIdRoute, + FooChar123BarChar125QuxRoute: FooChar123BarChar125QuxRoute, + LogsChar123Char125TxtRoute: LogsChar123Char125TxtRoute, + UsersProfileSettingsRoute: UsersProfileSettingsRoute, + AProfileIndexRoute: AProfileIndexRoute, + BProfileIndexRoute: BProfileIndexRoute, + UsersProfileIndexRoute: UsersProfileIndexRoute, + ZYXURoute: ZYXURoute, + ZYXVRoute: ZYXVRoute, + ZYXWRoute: ZYXWRoute, + ZYXIndexRoute: ZYXIndexRoute, + ABCDEFRoute: ABCDEFRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/e2e/react-router/compiled-matcher/src/routes/$id/bar/foo.tsx b/e2e/react-router/compiled-matcher/src/routes/$id/bar/foo.tsx new file mode 100644 index 00000000000..d297530468a --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/$id/bar/foo.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/$id/bar/foo')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/$id/bar/foo"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/$id/foo/bar.tsx b/e2e/react-router/compiled-matcher/src/routes/$id/foo/bar.tsx new file mode 100644 index 00000000000..47af8bdfe9b --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/$id/foo/bar.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/$id/foo/bar')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/$id/foo/bar"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/__root.tsx b/e2e/react-router/compiled-matcher/src/routes/__root.tsx new file mode 100644 index 00000000000..3b0a92c6043 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/__root.tsx @@ -0,0 +1,46 @@ +import { + HeadContent, + Link, + Outlet, + createRootRoute, + useRouter, +} from '@tanstack/react-router' +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools' + +export const Route = createRootRoute({ + component: RootComponent, + notFoundComponent: () => { + return ( +
+

This is the notFoundComponent configured on root route

+ Start Over +
+ ) + }, +}) + +function RootComponent() { + const router = useRouter() + return ( + <> + +
+ {Object.keys(router.routesByPath).map((to) => ( + + {to} + + ))} +
+
+ + {/* Start rendering router matches */} + + + ) +} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/$.tsx b/e2e/react-router/compiled-matcher/src/routes/a/$.tsx new file mode 100644 index 00000000000..c1efe777e3e --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/$.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/$')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/a/$"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/$id.tsx b/e2e/react-router/compiled-matcher/src/routes/a/$id.tsx new file mode 100644 index 00000000000..563909e296b --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/$id.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/$id')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/a/$id"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/b/c/d/e/f.tsx b/e2e/react-router/compiled-matcher/src/routes/a/b/c/d/e/f.tsx new file mode 100644 index 00000000000..e6cb4db25ee --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/b/c/d/e/f.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/b/c/d/e/f')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/a/b/c/d/e/f"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/index.tsx b/e2e/react-router/compiled-matcher/src/routes/a/index.tsx new file mode 100644 index 00000000000..9f3aaa404e5 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/a/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/profile/index.tsx b/e2e/react-router/compiled-matcher/src/routes/a/profile/index.tsx new file mode 100644 index 00000000000..5d67db2e70d --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/profile/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/profile/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/a/profile/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/profile/settings.tsx b/e2e/react-router/compiled-matcher/src/routes/a/profile/settings.tsx new file mode 100644 index 00000000000..517b4219fe5 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/profile/settings.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/profile/settings')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/a/profile/settings"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/user-{$id}.tsx b/e2e/react-router/compiled-matcher/src/routes/a/user-{$id}.tsx new file mode 100644 index 00000000000..d8b6f32fc17 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/user-{$id}.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/user-{$id}')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/a/user-{$id}"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/a/{-$slug}.tsx b/e2e/react-router/compiled-matcher/src/routes/a/{-$slug}.tsx new file mode 100644 index 00000000000..8ce00b261f7 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/a/{-$slug}.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/a/{-$slug}')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/a/{-$slug}"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/about.tsx b/e2e/react-router/compiled-matcher/src/routes/about.tsx new file mode 100644 index 00000000000..1e6c7068e00 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/about.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/about')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/about"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/api/user-{$id}.tsx b/e2e/react-router/compiled-matcher/src/routes/api/user-{$id}.tsx new file mode 100644 index 00000000000..85578d57917 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/api/user-{$id}.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/api/user-{$id}')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/api/user-{$id}"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/$.tsx b/e2e/react-router/compiled-matcher/src/routes/b/$.tsx new file mode 100644 index 00000000000..1d360267045 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/b/$.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/b/$')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/b/$"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/$id.tsx b/e2e/react-router/compiled-matcher/src/routes/b/$id.tsx new file mode 100644 index 00000000000..004b382eaed --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/b/$id.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/b/$id')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/b/$id"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/index.tsx b/e2e/react-router/compiled-matcher/src/routes/b/index.tsx new file mode 100644 index 00000000000..dc875444191 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/b/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/b/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/b/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/profile/index.tsx b/e2e/react-router/compiled-matcher/src/routes/b/profile/index.tsx new file mode 100644 index 00000000000..5f32f2d4eef --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/b/profile/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/b/profile/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/b/profile/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/profile/settings.tsx b/e2e/react-router/compiled-matcher/src/routes/b/profile/settings.tsx new file mode 100644 index 00000000000..eeec76d78ab --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/b/profile/settings.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/b/profile/settings')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/b/profile/settings"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/user-{$id}.tsx b/e2e/react-router/compiled-matcher/src/routes/b/user-{$id}.tsx new file mode 100644 index 00000000000..afd075f3501 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/b/user-{$id}.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/b/user-{$id}')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/b/user-{$id}"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/b/{-$slug}.tsx b/e2e/react-router/compiled-matcher/src/routes/b/{-$slug}.tsx new file mode 100644 index 00000000000..d03dcbe5371 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/b/{-$slug}.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/b/{-$slug}')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/b/{-$slug}"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/beep/boop.tsx b/e2e/react-router/compiled-matcher/src/routes/beep/boop.tsx new file mode 100644 index 00000000000..2d0115db443 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/beep/boop.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/beep/boop')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/beep/boop"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/cache/temp_{$}.log.tsx b/e2e/react-router/compiled-matcher/src/routes/cache/temp_{$}.log.tsx new file mode 100644 index 00000000000..2c0abaaf804 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/cache/temp_{$}.log.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/cache/temp_{$}/log')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/cache/temp_{$}.log"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/files/$.tsx b/e2e/react-router/compiled-matcher/src/routes/files/$.tsx new file mode 100644 index 00000000000..0c4ab5c1a07 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/files/$.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/files/$')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/files/$"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/foo/$bar.index.tsx b/e2e/react-router/compiled-matcher/src/routes/foo/$bar.index.tsx new file mode 100644 index 00000000000..a55255222b4 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/foo/$bar.index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/foo/$bar/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/foo/$bar/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/foo/$bar.tsx b/e2e/react-router/compiled-matcher/src/routes/foo/$bar.tsx new file mode 100644 index 00000000000..b83a197ac7c --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/foo/$bar.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/foo/$bar')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/foo/$bar"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/foo/$id/bar.tsx b/e2e/react-router/compiled-matcher/src/routes/foo/$id/bar.tsx new file mode 100644 index 00000000000..025eb7a2bf6 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/foo/$id/bar.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/foo/$id/bar')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/foo/$id/bar"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/foo/bar/$id.tsx b/e2e/react-router/compiled-matcher/src/routes/foo/bar/$id.tsx new file mode 100644 index 00000000000..e06fb3ff6b2 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/foo/bar/$id.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/foo/bar/$id')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/foo/bar/$id"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/foo/{-$bar}/qux.tsx b/e2e/react-router/compiled-matcher/src/routes/foo/{-$bar}/qux.tsx new file mode 100644 index 00000000000..37af5c50756 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/foo/{-$bar}/qux.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/foo/{-$bar}/qux')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/foo/{-$bar}/qux"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/images/thumb_{$}.tsx b/e2e/react-router/compiled-matcher/src/routes/images/thumb_{$}.tsx new file mode 100644 index 00000000000..e81725cb16f --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/images/thumb_{$}.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/images/thumb_{$}')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/images/thumb_{$}"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/index.tsx b/e2e/react-router/compiled-matcher/src/routes/index.tsx new file mode 100644 index 00000000000..d58928d9ed0 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/logs/{$}.txt.tsx b/e2e/react-router/compiled-matcher/src/routes/logs/{$}.txt.tsx new file mode 100644 index 00000000000..456bceb9483 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/logs/{$}.txt.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/logs/{$}/txt')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/logs/{$}.txt"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/one.tsx b/e2e/react-router/compiled-matcher/src/routes/one.tsx new file mode 100644 index 00000000000..8334d30aa20 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/one.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/one')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/one"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/one/two.tsx b/e2e/react-router/compiled-matcher/src/routes/one/two.tsx new file mode 100644 index 00000000000..8fe6c5615bb --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/one/two.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/one/two')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/one/two"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/posts/{-$slug}.tsx b/e2e/react-router/compiled-matcher/src/routes/posts/{-$slug}.tsx new file mode 100644 index 00000000000..c494586b528 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/posts/{-$slug}.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/posts/{-$slug}')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
{'Hello "/posts/{-$slug}"!'}
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/users/$id.tsx b/e2e/react-router/compiled-matcher/src/routes/users/$id.tsx new file mode 100644 index 00000000000..99635ef89a8 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/users/$id.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/users/$id')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/users/$id"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/users/profile/index.tsx b/e2e/react-router/compiled-matcher/src/routes/users/profile/index.tsx new file mode 100644 index 00000000000..b042f0e5c94 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/users/profile/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/users/profile/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/users/profile/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/users/profile/settings.tsx b/e2e/react-router/compiled-matcher/src/routes/users/profile/settings.tsx new file mode 100644 index 00000000000..d0eece68ae4 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/users/profile/settings.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/users/profile/settings')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/users/profile/settings"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/z/y/x/index.tsx b/e2e/react-router/compiled-matcher/src/routes/z/y/x/index.tsx new file mode 100644 index 00000000000..fb49b8aeaeb --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/z/y/x/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/z/y/x/')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/z/y/x/"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/z/y/x/u.tsx b/e2e/react-router/compiled-matcher/src/routes/z/y/x/u.tsx new file mode 100644 index 00000000000..20afb916ca7 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/z/y/x/u.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/z/y/x/u')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/z/y/x/u"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/z/y/x/v.tsx b/e2e/react-router/compiled-matcher/src/routes/z/y/x/v.tsx new file mode 100644 index 00000000000..a19ed76a280 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/z/y/x/v.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/z/y/x/v')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/z/y/x/v"!
+} diff --git a/e2e/react-router/compiled-matcher/src/routes/z/y/x/w.tsx b/e2e/react-router/compiled-matcher/src/routes/z/y/x/w.tsx new file mode 100644 index 00000000000..e6f02739452 --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/routes/z/y/x/w.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router' + +export const Route = createFileRoute('/z/y/x/w')({ + component: RouteComponent, +}) + +function RouteComponent() { + return
Hello "/z/y/x/w"!
+} diff --git a/e2e/react-router/compiled-matcher/src/styles.css b/e2e/react-router/compiled-matcher/src/styles.css new file mode 100644 index 00000000000..0b8e317099c --- /dev/null +++ b/e2e/react-router/compiled-matcher/src/styles.css @@ -0,0 +1,13 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html { + color-scheme: light dark; +} +* { + @apply border-gray-200 dark:border-gray-800; +} +body { + @apply bg-gray-50 text-gray-950 dark:bg-gray-900 dark:text-gray-200; +} diff --git a/e2e/react-router/compiled-matcher/tailwind.config.mjs b/e2e/react-router/compiled-matcher/tailwind.config.mjs new file mode 100644 index 00000000000..4986094b9d5 --- /dev/null +++ b/e2e/react-router/compiled-matcher/tailwind.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'], +} diff --git a/e2e/react-router/compiled-matcher/tests/app.spec.ts b/e2e/react-router/compiled-matcher/tests/app.spec.ts new file mode 100644 index 00000000000..285474fb480 --- /dev/null +++ b/e2e/react-router/compiled-matcher/tests/app.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from '@playwright/test' + +test.beforeEach(async ({ page }) => { + await page.goto('/') +}) + +test('exact path matching', async ({ page }) => { + const links = [ + { url: '', route: '/' }, + { url: '/', route: '/' }, + { url: '/users/profile/settings', route: '/users/profile/settings' }, + // { url: '/foo/123', route: '/foo/$bar/' }, + // { url: '/FOO/123', route: '/foo/$bar/' }, + // { url: '/foo/123/', route: '/foo/$bar/' }, + { url: '/b/123', route: '/b/$id' }, + { url: '/foo/qux', route: '/foo/{-$bar}/qux' }, + { url: '/foo/123/qux', route: '/foo/{-$bar}/qux' }, + { url: '/a/user-123', route: '/a/user-{$id}' }, + { url: '/a/123', route: '/a/$id' }, + { url: '/a/123/more', route: '/a/$' }, + { url: '/files', route: '/files/$' }, + { url: '/files/hello-world.txt', route: '/files/$' }, + { url: '/something/foo/bar', route: '/$id/foo/bar' }, + { url: '/files/deep/nested/file.json', route: '/files/$' }, + { url: '/files/', route: '/files/$' }, + { url: '/images/thumb_200x300.jpg', route: '/images/thumb_{$}' }, + { url: '/logs/2020/01/01/error.txt', route: '/logs/{$}.txt' }, + { url: '/cache/temp_user456.log', route: '/cache/temp_{$}.log' }, + { url: '/a/b/c/d/e', route: '/a/$' }, + ] + for (const link of links) { + await test.step(`nav to '${link.url}'`, async () => { + console.log(`nav to '${link.url}'`) + await page.goto(link.url) + await expect(page.getByText(`Hello "${link.route}"!`)).toBeVisible() + }) + } +}) diff --git a/e2e/react-router/compiled-matcher/tests/setup/global.setup.ts b/e2e/react-router/compiled-matcher/tests/setup/global.setup.ts new file mode 100644 index 00000000000..3593d10ab90 --- /dev/null +++ b/e2e/react-router/compiled-matcher/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/compiled-matcher/tests/setup/global.teardown.ts b/e2e/react-router/compiled-matcher/tests/setup/global.teardown.ts new file mode 100644 index 00000000000..62fd79911cc --- /dev/null +++ b/e2e/react-router/compiled-matcher/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/compiled-matcher/tsconfig.json b/e2e/react-router/compiled-matcher/tsconfig.json new file mode 100644 index 00000000000..82cf0bcd2c9 --- /dev/null +++ b/e2e/react-router/compiled-matcher/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/compiled-matcher/vite.config.js b/e2e/react-router/compiled-matcher/vite.config.js new file mode 100644 index 00000000000..ab615485ae6 --- /dev/null +++ b/e2e/react-router/compiled-matcher/vite.config.js @@ -0,0 +1,11 @@ +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', autoCodeSplitting: true }), + react(), + ], +}) diff --git a/packages/router-core/package.json b/packages/router-core/package.json index c1ca570c79b..f827a08d251 100644 --- a/packages/router-core/package.json +++ b/packages/router-core/package.json @@ -30,6 +30,7 @@ "test:types:ts59": "tsc", "test:build": "publint --strict && attw --ignore-rules no-resolution --pack .", "test:unit": "vitest", + "test:perf": "vitest bench", "test:unit:dev": "pnpm run test:unit --watch", "build": "vite build" }, diff --git a/packages/router-core/src/compile-matcher.ts b/packages/router-core/src/compile-matcher.ts new file mode 100644 index 00000000000..9d2d4d58e62 --- /dev/null +++ b/packages/router-core/src/compile-matcher.ts @@ -0,0 +1,721 @@ +import { + SEGMENT_TYPE_OPTIONAL_PARAM, + SEGMENT_TYPE_PARAM, + SEGMENT_TYPE_PATHNAME, + SEGMENT_TYPE_WILDCARD, + parsePathname, +} from './path' +import type { LRUCache } from './lru-cache' +import type { Segment } from './path' +import type { processRouteTree } from './router' + +export type CompiledMatcher = ( + parser: typeof parsePathname, + from: string, + fuzzy?: boolean, + cache?: LRUCache>, +) => readonly [path: string, params: Record] | undefined + +/** + * Compiles a sorted list of routes (as returned by `processRouteTree().flatRoutes`) + * into a matcher function. + * + * Run-time use (requires eval permissions): + * ```ts + * const fn = compileMatcher(processRouteTree({ routeTree }).flatRoutes) + * const matcher = new Function('parsePathname', 'from', 'fuzzy', 'cache', fn) as CompiledMatcher + * ``` + * + * Build-time use: + * ```ts + * const fn = compileMatcher(processRouteTree({ routeTree }).flatRoutes) + * sourceCode += `const matcher = (parsePathname, from, fuzzy, cache) => { ${fn} }` + * ``` + */ +export function compileMatcher( + flatRoutes: ReturnType['flatRoutes'], +) { + const parsedRoutes = flatRoutes.map((route) => ({ + path: route.fullPath, + segments: parsePathname(route.fullPath), + })) + + const all = toConditions( + prepareOptionalParams(prepareIndexRoutes(parsedRoutes)), + ) + + // We start by building a flat tree with all routes as leaf nodes, children of the same root node. + const tree: RootNode = { type: 'root', children: [] } + + const children: Array = [] + for (const { conditions, path, segments } of all) { + children.push({ + type: 'leaf', + route: { path, segments }, + parent: tree, + conditions, + }) + } + tree.children = removeUnreachable(children) + + expandTree(tree) + contractTree(tree) + + let fn = '' + fn += printHead(all) + fn += printTree(tree) + + return fn +} + +type ParsedRoute = { + path: string + segments: ReturnType +} + +// we duplicate routes that end in a static `/`, so they're also matched if that final `/` is not present +function prepareIndexRoutes( + parsedRoutes: Array, +): Array { + const result: Array = [] + for (const route of parsedRoutes) { + result.push(route) + const last = route.segments.at(-1)! + if ( + route.segments.length > 1 && + last.type === SEGMENT_TYPE_PATHNAME && + last.value === '/' + ) { + const clone: ParsedRoute = { + ...route, + segments: route.segments.slice(0, -1), + } + result.push(clone) + } + } + return result +} + +// we replace routes w/ optional params, with +// - 1 version where it's a regular param +// - 1 version where it's removed entirely +function prepareOptionalParams( + parsedRoutes: Array, +): Array { + const result: Array = [] + for (const route of parsedRoutes) { + const index = route.segments.findIndex( + (s) => s.type === SEGMENT_TYPE_OPTIONAL_PARAM, + ) + if (index === -1) { + result.push(route) + continue + } + // for every optional param in the route, we need to push a version of the route without it, and a version of the route with it as a regular param + // example: + // /foo/{-$bar}/qux => [/foo/qux, /foo/$bar/qux] + // /a/{-$b}/c/{-$d} => [/a/c, /a/c/$d, /a/$b/c, /a/$b/c/$d] + const withRegular: ParsedRoute = { + ...route, + segments: route.segments.map((s, i) => + i === index ? { ...s, type: SEGMENT_TYPE_PARAM } : s, + ), + } + const withoutOptional: ParsedRoute = { + ...route, + segments: route.segments.filter((_, i) => i !== index), + } + const chunk = prepareOptionalParams([withRegular, withoutOptional]) + result.push(...chunk) + } + return result +} + +type Condition = + | { key: string; type: 'static'; index: number; value: string; caseSensitive: boolean } + | { key: string; type: 'length'; direction: 'eq' | 'gte'; value: number } + | { key: string; type: 'startsWith'; index: number; value: string; caseSensitive: boolean } + | { key: string; type: 'endsWith'; index: number; value: string; caseSensitive: boolean } + | { key: string; type: 'globalEndsWith'; value: string; caseSensitive: boolean } + +// each segment of a route can have zero or more conditions that need to be met for the route to match +function toConditions(routes: Array) { + return routes.map((route) => { + const conditions: Array = [] + + let hasWildcard = false + let minLength = 0 + for (let i = 0; i < route.segments.length; i++) { + const segment = route.segments[i]! + if (segment.type === SEGMENT_TYPE_PATHNAME) { + minLength += 1 + if (i === 0 && segment.value === '/') continue // skip leading slash + // @ts-expect-error -- not typed yet, i don't know how I'm gonna get this value here + const caseSensitive = route.caseSensitive ?? false + const value = caseSensitive ? segment.value : segment.value.toLowerCase() + conditions.push({ + type: 'static', + index: i, + value, + caseSensitive, + key: `static_${caseSensitive}_${i}_${value}`, + }) + continue + } + if (segment.type === SEGMENT_TYPE_PARAM) { + minLength += 1 + // @ts-expect-error -- not typed yet, i don't know how I'm gonna get this value here + const caseSensitive = route.caseSensitive ?? false + if (segment.prefixSegment) { + const value = caseSensitive ? segment.prefixSegment : segment.prefixSegment.toLowerCase() + conditions.push({ + type: 'startsWith', + index: i, + value, + caseSensitive, + key: `startsWith_${caseSensitive}_${i}_${value}`, + }) + } + if (segment.suffixSegment) { + const value = caseSensitive ? segment.suffixSegment : segment.suffixSegment.toLowerCase() + conditions.push({ + type: 'endsWith', + index: i, + value, + caseSensitive, + key: `endsWith_${caseSensitive}_${i}_${value}`, + }) + } + continue + } + if (segment.type === SEGMENT_TYPE_WILDCARD) { + hasWildcard = true + // @ts-expect-error -- not typed yet, i don't know how I'm gonna get this value here + const caseSensitive = route.caseSensitive ?? false + if (segment.prefixSegment) { + const value = caseSensitive ? segment.prefixSegment : segment.prefixSegment.toLowerCase() + conditions.push({ + type: 'startsWith', + index: i, + value, + caseSensitive, + key: `startsWith_${caseSensitive}_${i}_${value}`, + }) + } + if (segment.suffixSegment) { + const value = caseSensitive ? segment.suffixSegment : segment.suffixSegment.toLowerCase() + conditions.push({ + type: 'globalEndsWith', + value, + caseSensitive, + key: `globalEndsWith_${caseSensitive}_${i}_${value}`, + }) + } + if (segment.suffixSegment || segment.prefixSegment) { + minLength += 1 + } + continue + } + throw new Error(`Unhandled segment type: ${segment.type}`) + } + + if (hasWildcard) { + conditions.push({ + type: 'length', + direction: 'gte', + value: minLength, + key: `length_gte_${minLength}`, + }) + } else { + conditions.push({ + type: 'length', + direction: 'eq', + value: minLength, + key: `length_eq_${minLength}`, + }) + } + + return { + ...route, + conditions, + } + }) +} + +type LeafNode = { + type: 'leaf' + conditions: Array + route: ParsedRoute + parent: BranchNode | RootNode +} +type RootNode = { type: 'root'; children: Array } +type BranchNode = { + type: 'branch' + conditions: Array + children: Array + parent: BranchNode | RootNode +} + +/** + * Recursively expand each node of the tree until there is only one child left + * + * For each child node in a parent node, we try to find subsequent siblings that would share the same condition to be matched. + * If we find any, we group them together into a new branch node that replaces the original child node and the grouped siblings in the parent node. + * + * We repeat the process in each newly created branch node until there is only one child left in each branch node. + * + * This turns + * ``` + * if (a && b && c && d) return route1; + * if (a && b && e && f) return route2; + * ``` + * into + * ``` + * if (a && b) { + * if (c) { if (d) return route1; } + * if (e) { if (f) return route2; } + * } + * ``` + * + */ +function expandTree(tree: RootNode) { + const stack: Array = [tree] + while (stack.length > 0) { + const node = stack.shift()! + if (node.children.length <= 1) continue + + const resolved = new Set() + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]! + if (resolved.has(child)) continue + + // segment-based conditions should try to group as many children as possible + const bestSegment = findBestSegmentCondition( + node, + i, + node.children.length - i - 1, + ) + // length-based conditions should try to group as few children as possible + const bestLength = findBestLengthCondition(node, i, 0) + + if (bestSegment.score === Infinity && bestLength.score === Infinity) { + // no grouping possible, just add the child as is + resolved.add(child) + continue + } + + const selected = + bestSegment.score < bestLength.score ? bestSegment : bestLength + const condition = selected.condition! + const newNode: BranchNode = { + type: 'branch', + conditions: [condition], + children: selected.candidates, + parent: node, + } + node.children.splice(i, selected.candidates.length, newNode) + stack.push(newNode) + resolved.add(newNode) + for (const c of selected.candidates) { + c.conditions = c.conditions.filter((sc) => sc.key !== condition.key) + } + + // find all conditions that are shared by all candidates, and lift them to the new node + for (const condition of newNode.children[0]!.conditions) { + if ( + newNode.children.every((c) => + c.conditions.some((sc) => sc.key === condition.key), + ) + ) { + newNode.conditions.push(condition) + } + } + for (let i = 1; i < newNode.conditions.length; i++) { + const condition = newNode.conditions[i]! + for (const c of newNode.children) { + c.conditions = c.conditions.filter((sc) => sc.key !== condition.key) + } + } + } + } +} + +/** + * Recursively shorten branches that have a single child into a leaf node. + * + * For each branch node in the tree, if it has only one child, we can replace the branch node with that child node, + * and merge the conditions of the branch node into the child node. + * + * This turns + * `if (a) { if (b) { return route } }` + * into + * `if (a && b) { return route }` + */ +function contractTree(tree: RootNode) { + const stack = tree.children.filter((c) => c.type === 'branch') + while (stack.length > 0) { + const node = stack.pop()! + if (node.children.length === 1) { + const child = node.children[0]! + node.parent.children.splice(node.parent.children.indexOf(node), 1, child) + child.parent = node.parent + child.conditions = [...node.conditions, ...child.conditions] + + // reduce length-based conditions into a single condition + const lengthConditions = child.conditions.filter( + (c) => c.type === 'length', + ) + if (lengthConditions.some((c) => c.direction === 'eq')) { + for (const c of lengthConditions) { + if (c.direction === 'gte') { + child.conditions = child.conditions.filter((sc) => sc.key !== c.key) + } + } + } else if (lengthConditions.length > 0) { + const minLength = Math.min(...lengthConditions.map((c) => c.value)) + child.conditions = child.conditions.filter((c) => c.type !== 'length') + child.conditions.push({ + type: 'length', + direction: 'eq', + value: minLength, + key: `length_eq_${minLength}`, + }) + } + } + for (const child of node.children) { + if (child.type === 'branch') { + stack.push(child) + } + } + } +} + +/** + * Remove leaves that are not reachable due to the conditions a previous leaf sibling. + * + * This turns + * ``` + * if (a) return route1; + * if (a && b) return route2; + * ``` + * into + * ``` + * if (a) return route1; + * ``` + */ +function removeUnreachable(nodes: Array) { + loop: for (let i = 0; i < nodes.length; i++) { + const candidate = nodes[i]! + + // look through all previous siblings + for (let j = 0; j < i; j++) { + const sibling = nodes[j]! + // if every condition the sibling requires is also present in the candidate, + // then that means the candidate is unreachable + const candidateIsUnreachable = sibling.conditions.every((c) => { + if (c.type === 'length' && c.direction === 'gte') { + return candidate.conditions.some((sc) => sc.key === c.key || (sc.type === 'length' && sc.direction === 'eq' && sc.value >= c.value)) + } + // TODO: we could add other "covering" cases like the one above here, + // such as the sibling having a `startsWith` condition and the candidate having a static condition that starts with the same value (taking case sensitivity into account) + return candidate.conditions.some((sc) => sc.key === c.key) + }) + if (candidateIsUnreachable) { + nodes.splice(i, 1) + i -= 1 + continue loop + } + } + } + return nodes +} + +function printTree(node: RootNode | BranchNode | LeafNode) { + let str = '' + if (node.type === 'root') { + for (const child of node.children) { + str += printTree(child) + } + return str + } + if (node.conditions.length) { + str += 'if (' + str += printConditions(node) + str += ')' + } + if (node.type === 'branch') { + if (node.conditions.length && node.children.length) str += `{` + for (const child of node.children) { + str += printTree(child) + } + if (node.conditions.length && node.children.length) str += `}` + } else { + str += printRoute(node.route) + } + return str +} + +function printConditions(node: BranchNode | LeafNode) { + const conditions = node.conditions + const lengths = conditions.filter((c) => c.type === 'length') + const segment = conditions.filter((c) => c.type !== 'length') + const results: Array = [] + if (lengths.length > 1) { + const exact = lengths.find((c) => c.direction === 'eq') + if (exact) { + results.push(printCondition(exact)) + } else { + results.push(printCondition(lengths[0]!)) + } + } else if (lengths.length === 1) { + results.push(printCondition(lengths[0]!)) + } + const [minLength] = findLengthAtNode(node) + for (const c of segment) { + results.push(printCondition(c, minLength)) + } + return results.join(' && ') +} + +function printCondition(condition: Condition, minLength: number = 0) { + switch (condition.type) { + case 'static': + if (condition.caseSensitive) { + return `s${condition.index} === '${condition.value}'` + } else { + return `sc${condition.index} === '${condition.value}'` + } + case 'length': + if (condition.direction === 'eq') { + return `length(${condition.value})` + } else if (condition.direction === 'gte') { + return `l >= ${condition.value}` + } + break + case 'startsWith': + if (condition.caseSensitive) { + return `s${condition.index}.startsWith('${condition.value}')` + } else if (minLength > condition.index) { + return `sc${condition.index}.startsWith('${condition.value}')` + } else { + return `sc${condition.index}?.startsWith('${condition.value}')` + } + case 'endsWith': + if (condition.caseSensitive) { + return `s${condition.index}.endsWith('${condition.value}')` + } else if (minLength > condition.index) { + return `sc${condition.index}.endsWith('${condition.value}')` + } else { + return `sc${condition.index}?.endsWith('${condition.value}')` + } + case 'globalEndsWith': + if (condition.caseSensitive) { + return `last.endsWith('${condition.value}')` + } else { + return `last.toLowerCase().endsWith('${condition.value}')` + } + } + throw new Error(`Unhandled condition type: ${condition.type}`) +} + +function printRoute(route: ParsedRoute) { + const length = route.segments.length + /** + * return [ + * route.path, + * { foo: s2, bar: s4 } + * ] + */ + let result = `{` + let hasWildcard = false + for (let i = 0; i < route.segments.length; i++) { + const segment = route.segments[i]! + if (segment.type === SEGMENT_TYPE_PARAM) { + const name = segment.value.replace(/^\$/, '') + const value = `s${i}` + if (segment.prefixSegment && segment.suffixSegment) { + result += `${name}: ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` + } else if (segment.prefixSegment) { + result += `${name}: ${value}.slice(${segment.prefixSegment.length}), ` + } else if (segment.suffixSegment) { + result += `${name}: ${value}.slice(0, -${segment.suffixSegment.length}), ` + } else { + result += `${name}: ${value}, ` + } + } else if (segment.type === SEGMENT_TYPE_WILDCARD) { + hasWildcard = true + const value = `s.slice(${i}).join('/')` + if (segment.prefixSegment && segment.suffixSegment) { + result += `_splat: ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` + result += `'*': ${value}.slice(${segment.prefixSegment.length}, -${segment.suffixSegment.length}), ` + } else if (segment.prefixSegment) { + result += `_splat: ${value}.slice(${segment.prefixSegment.length}), ` + result += `'*': ${value}.slice(${segment.prefixSegment.length}), ` + } else if (segment.suffixSegment) { + result += `_splat: ${value}.slice(0, -${segment.suffixSegment.length}), ` + result += `'*': ${value}.slice(0, -${segment.suffixSegment.length}), ` + } else { + result += `_splat: ${value}, ` + result += `'*': ${value}, ` + } + break + } + } + result += `}` + return hasWildcard + ? `return ['${route.path}', ${result}];` + : `return ['${route.path}', params(${result}, ${length})];` +} + +function printHead( + routes: Array }>, +) { + let head = + 'const s = parsePathname(from[0] === "/" ? from : "/" + from, cache).map((s) => s.value);' + head += 'const l = s.length;' + + // the `length()` function does exact match by default, but greater-than-or-equal match if `fuzzy` is true + head += 'const length = fuzzy ? (n) => l >= n : (n) => l === n;' + + // the `params()` function returns the params object, and if `fuzzy` is true, it also adds a `**` property with the remaining segments + head += + "const params = fuzzy ? (p, n) => { if (n && l > n) p['**'] = s.slice(n).join('/'); return p } : (p) => p;" + + // extract all segments from the input + // const [, s1, s2, s3] = s; + const max = routes.reduce((max, r) => Math.max(max, r.segments.length), 0) + if (max > 0) + head += `const [,${Array.from({ length: max - 1 }, (_, i) => `s${i + 1}`).join(', ')}] = s;` + + // add toLowerCase version of each segment that is needed in a case-insensitive match + // const sc1 = s1?.toLowerCase(); + const caseInsensitiveSegments = new Set() + for (const route of routes) { + for (const condition of route.conditions) { + if ((condition.type === 'static' || condition.type === 'endsWith' || condition.type === 'startsWith') && !condition.caseSensitive) { + caseInsensitiveSegments.add(condition.index) + } + } + } + for (const index of caseInsensitiveSegments) { + head += `const sc${index} = s${index}?.toLowerCase();` + } + + // wildcard with a suffix requires accessing the last segment, whithout knowing its index + const hasWildcardWithSuffix = routes.some((route) => route.conditions.some((c) => c.type === 'globalEndsWith')) + if (hasWildcardWithSuffix) { + head += 'const last = s[l - 1];' + } + + return head +} + +function findBestSegmentCondition( + node: RootNode | BranchNode, + i: number, + target: number, +) { + const child = node.children[i]! + let bestCondition: Condition | undefined + let bestMatchScore = Infinity + let bestCandidates = [child] + for (const c of child.conditions) { + const candidates = [child] + for (let j = i + 1; j < node.children.length; j++) { + const sibling = node.children[j]! + if (sibling.conditions.some((sc) => sc.key === c.key)) { + candidates.push(sibling) + } else { + break + } + } + const score = Math.abs(candidates.length - target) + if (score < bestMatchScore) { + bestMatchScore = score + bestCondition = c + bestCandidates = candidates + } + } + + return { + score: bestMatchScore, + condition: bestCondition, + candidates: bestCandidates, + } +} + +function findBestLengthCondition( + node: RootNode | BranchNode, + i: number, + target: number, +) { + const child = node.children[i]! + const childLengthCondition = child.conditions.find((c) => c.type === 'length') + if (!childLengthCondition) { + return { score: Infinity, condition: undefined, candidates: [child] } + } + const [currentMinLength, lengthKind] = findLengthAtNode(node) + if (lengthKind === 'exact' || currentMinLength >= childLengthCondition.value) { + return { score: Infinity, condition: undefined, candidates: [child] } + } + let bestMatchScore = Infinity + let bestLength: number | undefined + let bestCandidates = [child] + let bestExact = false + for (let l = currentMinLength + 1; l <= childLengthCondition.value; l++) { + const candidates = [child] + let exact = + childLengthCondition.direction === 'eq' && + l === childLengthCondition.value + for (let j = i + 1; j < node.children.length; j++) { + const sibling = node.children[j]! + const lengthCondition = sibling.conditions.find( + (c) => c.type === 'length', + ) + if (!lengthCondition) break + if (lengthCondition.value < l) break + candidates.push(sibling) + exact &&= + lengthCondition.direction === 'eq' && lengthCondition.value === l + } + const score = Math.abs(candidates.length - target) + if (score < bestMatchScore) { + bestMatchScore = score + bestLength = l + bestCandidates = candidates + bestExact = exact + } + } + const condition: Condition = { + type: 'length', + direction: bestExact ? 'eq' : 'gte', + value: bestLength!, + key: `length_${bestExact ? 'eq' : 'gte'}_${bestLength}`, + } + return { score: bestMatchScore, condition, candidates: bestCandidates } +} + +function findLengthAtNode( + node: RootNode | BranchNode | LeafNode, +) { + if (node.type === 'root') return [1, 'min'] as const + let currentMinLength = 1 + let exactLength = false + let n = node + do { + const lengthCondition = n.conditions.find((c) => c.type === 'length') + if (!lengthCondition) continue + if (lengthCondition.direction === 'eq') { + exactLength = true + break + } + if (lengthCondition.direction === 'gte') { + currentMinLength = lengthCondition.value + break + } + } while (n.parent.type === 'branch' && (n = n.parent)) + return [ + currentMinLength, + exactLength ? 'exact' : 'min', + ] as const +} \ No newline at end of file diff --git a/packages/router-core/src/index.ts b/packages/router-core/src/index.ts index ca34da17222..050af54a253 100644 --- a/packages/router-core/src/index.ts +++ b/packages/router-core/src/index.ts @@ -107,7 +107,12 @@ export { matchByPath, } from './path' export type { Segment } from './path' + +export { compileMatcher } from './compile-matcher' +export type { CompiledMatcher } from './compile-matcher' + export { encode, decode } from './qss' + export { rootRouteId } from './root' export type { RootRouteId } from './root' diff --git a/packages/router-core/tests/compile-matcher.bench.ts b/packages/router-core/tests/compile-matcher.bench.ts new file mode 100644 index 00000000000..b80a194cc43 --- /dev/null +++ b/packages/router-core/tests/compile-matcher.bench.ts @@ -0,0 +1,221 @@ +import { bench, describe } from 'vitest' +import { + joinPaths, + matchPathname, + parsePathname, + processRouteTree, +} from '../src' +import { createLRUCache } from '../src/lru-cache' +import { compileMatcher } from '../src/compile-matcher' +import type { CompiledMatcher } from '../src/compile-matcher' +import type { ParsePathnameCache } from '../src/path' + +interface TestRoute { + id: string + isRoot?: boolean + path?: string + fullPath: string + rank?: number + parentRoute?: TestRoute + children?: Array + options?: { + caseSensitive?: boolean + } +} + +type PathOrChildren = string | [string, Array] + +function createRoute( + pathOrChildren: Array, + parentPath: string, +): Array { + return pathOrChildren.map((route) => { + if (Array.isArray(route)) { + const fullPath = joinPaths([parentPath, route[0]]) + const children = createRoute(route[1], fullPath) + const r = { + id: fullPath, + path: route[0], + fullPath, + children: children, + } + children.forEach((child) => { + child.parentRoute = r + }) + + return r + } + + const fullPath = joinPaths([parentPath, route]) + + return { + id: fullPath, + path: route, + fullPath, + } + }) +} + +function createRouteTree(pathOrChildren: Array): TestRoute { + return { + id: '__root__', + fullPath: '', + isRoot: true, + path: undefined, + children: createRoute(pathOrChildren, ''), + } +} + +const routeTree = createRouteTree([ + '/', + '/users/profile/settings', // static-deep (longest static path) + '/users/profile', // static-medium (medium static path) + '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) + '/users/$id', // param-simple (plain param) + '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) + '/files/$', // wildcard (lowest priority) + '/about', // static-shallow (shorter static path) + '/a/profile/settings', + '/a/profile', + '/a/user-{$id}', + '/a/$id', + '/a/{-$slug}', + '/a/$', + '/a', + '/b/profile/settings', + '/b/profile', + '/b/user-{$id}', + '/b/$id', + '/b/{-$slug}', + '/b/$', + '/b', + '/foo/bar/$id', + '/foo/$id/bar', + '/foo/$bar', + '/foo/$bar/', + '/foo/{-$bar}/qux', + '/$id/bar/foo', + '/$id/foo/bar', + '/a/b/c/d/e/f', + '/beep/boop', + '/compiled/two', + '/compiled', + '/z/y/x/w', + '/z/y/x/v', + '/z/y/x/u', + '/z/y/x', + '/images/thumb_{$}', // wildcard with prefix + '/logs/{$}.txt', // wildcard with suffix + '/cache/temp_{$}.log', // wildcard with prefix and suffix + '/momomo/{-$one}/$two' +]) +const result = processRouteTree({ routeTree }) + +const compiled = (() => { + const cache: ParsePathnameCache = createLRUCache(1000) + const fn = compileMatcher(result.flatRoutes) + const buildMatcher = new Function( + 'parsePathname', + 'from', + 'fuzzy', + 'cache', + fn, + ) as CompiledMatcher + const wrappedMatcher = (from: string) => { + return buildMatcher(parsePathname, from, false, cache) + } + return wrappedMatcher +})() + +const original = (() => { + const cache: ParsePathnameCache = createLRUCache(1000) + + const wrappedMatcher = (from: string) => { + const match = result.flatRoutes.find((r) => + matchPathname('/', from, { to: r.fullPath }, cache), + ) + return match + } + return wrappedMatcher +})() + +const testCases = [ + '', + '/', + '/users/profile/settings', + '/foo/123', + '/foo/123/', + '/b/123', + '/foo/qux', + '/foo/123/qux', + '/foo/qux', + '/a/user-123', + '/a/123', + '/a/123/more', + '/files', + '/files/hello-world.txt', + '/something/foo/bar', + '/files/deep/nested/file.json', + '/files/', + '/images/thumb_200x300.jpg', + '/logs/error.txt', + '/cache/temp_user456.log', + '/a/b/c/d/e', + '/momomo/1111/2222', + '/momomo/2222', +] + +describe('build.bench needle in a haystack', () => { + bench( + 'original', + () => { + for (const from of testCases) { + original(from) + } + }, + { warmupIterations: 10 }, + ) + bench( + 'compiled', + () => { + for (const from of testCases) { + compiled(from) + } + }, + { warmupIterations: 10 }, + ) +}) + +/** + * Sometimes in the app, we already know the path we want to match against. + * The compiled matcher does not support this. + * This benchmark tests the performance of the compiled matcher looking through ALL routes + * vs. the original matcher comparing against a single path. + */ +describe('build.bench single match', () => { + const solutions = testCases.map((from) => original(from)?.fullPath) + + const cache: ParsePathnameCache = createLRUCache(1000) + const originalSingle = (from: string, to: string) => matchPathname('/', from, { to }, cache) + + bench( + 'original (single)', + () => { + for (let i = 0; i < testCases.length; i++) { + const from = testCases[i]! + const match = solutions[i]! + originalSingle(from, match) + } + }, + { warmupIterations: 10 }, + ) + bench( + 'compiled', + () => { + for (const from of testCases) { + compiled(from) + } + }, + { warmupIterations: 10 }, + ) +}) diff --git a/packages/router-core/tests/compile-matcher.test.ts b/packages/router-core/tests/compile-matcher.test.ts new file mode 100644 index 00000000000..ebb8de388da --- /dev/null +++ b/packages/router-core/tests/compile-matcher.test.ts @@ -0,0 +1,391 @@ +import { describe, expect, it, test } from 'vitest' +import { format } from 'prettier' +import { + joinPaths, + matchPathname, + parsePathname, + processRouteTree, +} from '../src' +import { compileMatcher } from '../src/compile-matcher' +import type { CompiledMatcher } from '../src/compile-matcher' + +interface TestRoute { + id: string + isRoot?: boolean + path?: string + fullPath: string + rank?: number + parentRoute?: TestRoute + children?: Array + options?: { + caseSensitive?: boolean + } +} + +type PathOrChildren = string | [string, Array] + +function createRoute( + pathOrChildren: Array, + parentPath: string, +): Array { + return pathOrChildren.map((route) => { + if (Array.isArray(route)) { + const fullPath = joinPaths([parentPath, route[0]]) + const children = createRoute(route[1], fullPath) + const r = { + id: fullPath, + path: route[0], + fullPath, + children: children, + } + children.forEach((child) => { + child.parentRoute = r + }) + + return r + } + + const fullPath = joinPaths([parentPath, route]) + + return { + id: fullPath, + path: route, + fullPath, + } + }) +} + +function createRouteTree(pathOrChildren: Array): TestRoute { + return { + id: '__root__', + fullPath: '', + isRoot: true, + path: undefined, + children: createRoute(pathOrChildren, ''), + } +} + +const routeTree = createRouteTree([ + '/', + '/users/profile/settings', // static-deep (longest static path) + '/users/profile', // static-medium (medium static path) + '/api/user-{$id}', // param-with-prefix (param with prefix has higher score) + '/users/$id', // param-simple (plain param) + '/posts/{-$slug}', // optional-param (optional param ranks lower than regular param) + '/files/$', // wildcard (lowest priority) + '/about', // static-shallow (shorter static path) + '/a/profile/settings', + '/a/profile', + '/a/user-{$id}', + '/a/$id', + '/a/{-$slug}', + '/a/$', + '/a', + '/b/profile/settings', + '/b/profile', + '/b/user-{$id}', + '/b/$id', + '/b/{-$slug}', + '/b/$', + '/b', + '/foo/bar/$id', + '/foo/$id/bar', + '/foo/$bar', + '/foo/$bar/', + '/foo/{-$bar}/qux', + '/$id/bar/foo', + '/$id/foo/bar', + '/a/b/c/d/e/f', + '/beep/boop', + '/one/two', + '/one', + '/z/y/x/w', + '/z/y/x/v', + '/z/y/x/u', + '/z/y/x', + '/images/thumb_{$}', // wildcard with prefix + '/logs/{$}.txt', // wildcard with suffix + '/cache/temp_{$}.log', // wildcard with prefix and suffix, + '/momomo/{-$one}/$two' +]) + +// required keys on a `route` object for `processRouteTree` to correctly generate `flatRoutes` +// - id +// - children +// - isRoot +// - path +// - fullPath + +const result = processRouteTree({ routeTree }) + +function originalMatcher( + from: string, + fuzzy?: boolean, +): readonly [string, Record] | undefined { + let match + for (const route of result.flatRoutes) { + const result = matchPathname('/', from, { to: route.fullPath, fuzzy }) + if (result) { + match = [route.fullPath, result] as const + break + } + } + return match +} + +describe('work in progress', () => { + it('is ordered', () => { + expect(result.flatRoutes.map((r) => r.id)).toMatchInlineSnapshot(` + [ + "/a/b/c/d/e/f", + "/z/y/x/u", + "/z/y/x/v", + "/z/y/x/w", + "/a/profile/settings", + "/b/profile/settings", + "/users/profile/settings", + "/z/y/x", + "/foo/bar/$id", + "/a/profile", + "/b/profile", + "/beep/boop", + "/one/two", + "/users/profile", + "/foo/$id/bar", + "/foo/{-$bar}/qux", + "/a/user-{$id}", + "/api/user-{$id}", + "/b/user-{$id}", + "/foo/$bar/", + "/a/$id", + "/b/$id", + "/foo/$bar", + "/users/$id", + "/momomo/{-$one}/$two", + "/a/{-$slug}", + "/b/{-$slug}", + "/posts/{-$slug}", + "/cache/temp_{$}.log", + "/images/thumb_{$}", + "/logs/{$}.txt", + "/a/$", + "/b/$", + "/files/$", + "/a", + "/about", + "/b", + "/one", + "/", + "/$id/bar/foo", + "/$id/foo/bar", + ] + `) + }) + + const fn = compileMatcher(result.flatRoutes) + + it('generates a matching function', async () => { + expect(await format(fn, { parser: 'typescript' })).toMatchInlineSnapshot(` + "const s = parsePathname(from[0] === "/" ? from : "/" + from, cache).map( + (s) => s.value, + ); + const l = s.length; + const length = fuzzy ? (n) => l >= n : (n) => l === n; + const params = fuzzy + ? (p, n) => { + if (n && l > n) p["**"] = s.slice(n).join("/"); + return p; + } + : (p) => p; + const [, s1, s2, s3, s4, s5, s6] = s; + const sc1 = s1?.toLowerCase(); + const sc2 = s2?.toLowerCase(); + const sc3 = s3?.toLowerCase(); + const sc4 = s4?.toLowerCase(); + const sc5 = s5?.toLowerCase(); + const sc6 = s6?.toLowerCase(); + const last = s[l - 1]; + if ( + length(7) && + sc1 === "a" && + sc2 === "b" && + sc3 === "c" && + sc4 === "d" && + sc5 === "e" && + sc6 === "f" + ) + return ["/a/b/c/d/e/f", params({}, 7)]; + if (length(5) && sc1 === "z" && sc2 === "y" && sc3 === "x") { + if (sc4 === "u") return ["/z/y/x/u", params({}, 5)]; + if (sc4 === "v") return ["/z/y/x/v", params({}, 5)]; + if (sc4 === "w") return ["/z/y/x/w", params({}, 5)]; + } + if (length(4)) { + if (sc2 === "profile" && sc3 === "settings") { + if (sc1 === "a") return ["/a/profile/settings", params({}, 4)]; + if (sc1 === "b") return ["/b/profile/settings", params({}, 4)]; + if (sc1 === "users") return ["/users/profile/settings", params({}, 4)]; + } + if (sc1 === "z" && sc2 === "y" && sc3 === "x") + return ["/z/y/x", params({}, 4)]; + if (sc1 === "foo" && sc2 === "bar") + return ["/foo/bar/$id", params({ id: s3 }, 4)]; + } + if (l >= 3) { + if (length(3)) { + if (sc2 === "profile") { + if (sc1 === "a") return ["/a/profile", params({}, 3)]; + if (sc1 === "b") return ["/b/profile", params({}, 3)]; + } + if (sc1 === "beep" && sc2 === "boop") return ["/beep/boop", params({}, 3)]; + if (sc1 === "one" && sc2 === "two") return ["/one/two", params({}, 3)]; + if (sc1 === "users" && sc2 === "profile") + return ["/users/profile", params({}, 3)]; + } + if (length(4) && sc1 === "foo") { + if (sc3 === "bar") return ["/foo/$id/bar", params({ id: s2 }, 4)]; + if (sc3 === "qux") return ["/foo/{-$bar}/qux", params({ bar: s2 }, 4)]; + } + if (length(3)) { + if (sc1 === "foo" && sc2 === "qux") + return ["/foo/{-$bar}/qux", params({}, 3)]; + if (sc1 === "a" && sc2?.startsWith("user-")) + return ["/a/user-{$id}", params({ id: s2.slice(5) }, 3)]; + if (sc1 === "api" && sc2?.startsWith("user-")) + return ["/api/user-{$id}", params({ id: s2.slice(5) }, 3)]; + if (sc1 === "b" && sc2?.startsWith("user-")) + return ["/b/user-{$id}", params({ id: s2.slice(5) }, 3)]; + } + if (length(4) && sc1 === "foo" && sc3 === "/") + return ["/foo/$bar/", params({ bar: s2 }, 4)]; + if (length(3)) { + if (sc1 === "foo") return ["/foo/$bar/", params({ bar: s2 }, 3)]; + if (sc1 === "a") return ["/a/$id", params({ id: s2 }, 3)]; + if (sc1 === "b") return ["/b/$id", params({ id: s2 }, 3)]; + if (sc1 === "users") return ["/users/$id", params({ id: s2 }, 3)]; + } + if (length(4) && sc1 === "momomo") + return ["/momomo/{-$one}/$two", params({ one: s2, two: s3 }, 4)]; + if (length(3) && sc1 === "momomo") + return ["/momomo/{-$one}/$two", params({ two: s2 }, 3)]; + } + if (l >= 2) { + if (length(2)) { + if (sc1 === "a") return ["/a/{-$slug}", params({}, 2)]; + if (sc1 === "b") return ["/b/{-$slug}", params({}, 2)]; + } + if (length(3) && sc1 === "posts") + return ["/posts/{-$slug}", params({ slug: s2 }, 3)]; + if (length(2) && sc1 === "posts") return ["/posts/{-$slug}", params({}, 2)]; + if (l >= 3) { + if ( + sc1 === "cache" && + sc2.startsWith("temp_") && + last.toLowerCase().endsWith(".log") + ) + return [ + "/cache/temp_{$}.log", + { + _splat: s.slice(2).join("/").slice(5, -4), + "*": s.slice(2).join("/").slice(5, -4), + }, + ]; + if (sc1 === "images" && sc2.startsWith("thumb_")) + return [ + "/images/thumb_{$}", + { + _splat: s.slice(2).join("/").slice(6), + "*": s.slice(2).join("/").slice(6), + }, + ]; + if (sc1 === "logs" && last.toLowerCase().endsWith(".txt")) + return [ + "/logs/{$}.txt", + { + _splat: s.slice(2).join("/").slice(0, -4), + "*": s.slice(2).join("/").slice(0, -4), + }, + ]; + } + if (sc1 === "a") + return [ + "/a/$", + { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, + ]; + if (sc1 === "b") + return [ + "/b/$", + { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, + ]; + if (sc1 === "files") + return [ + "/files/$", + { _splat: s.slice(2).join("/"), "*": s.slice(2).join("/") }, + ]; + if (length(2) && sc1 === "about") return ["/about", params({}, 2)]; + if (length(2) && sc1 === "one") return ["/one", params({}, 2)]; + } + if (length(1)) return ["/", params({}, 1)]; + if (length(4) && sc2 === "bar" && sc3 === "foo") + return ["/$id/bar/foo", params({ id: s1 }, 4)]; + if (length(4) && sc2 === "foo" && sc3 === "bar") + return ["/$id/foo/bar", params({ id: s1 }, 4)]; + " + `) + }) + + const buildMatcher = new Function( + 'parsePathname', + 'from', + 'fuzzy', + 'cache', + fn, + ) as CompiledMatcher + + test.each([ + '', + '/', + '/users/profile/settings', + '/foo/123', + '/FOO/123', + '/foo/123/', + '/b/123', + '/foo/qux', + '/foo/123/qux', + '/a/user-123', + '/a/123', + '/a/123/more', + '/files', + '/files/hello-world.txt', + '/something/foo/bar', + '/files/deep/nested/file.json', + '/files/', + '/images/thumb_200x300.jpg', + '/logs/2020/01/01/error.txt', + '/cache/temp_user456.log', + '/a/b/c/d/e', + '/momomo/1111/2222', + '/momomo/2222', + ])('matching %s', (s) => { + const originalMatch = originalMatcher(s) + const buildMatch = buildMatcher(parsePathname, s) + console.log( + `matching: ${s}, originalMatch: ${originalMatch?.[0]}, buildMatch: ${buildMatch?.[0]}`, + ) + expect(buildMatch).toEqual(originalMatch) + }) + + test.each([ + '/users/profile/settings/hello', + '/a/b/c/d/e/f/g', + '/foo/bar/baz', + '/foo/bar/baz/qux', + ])('fuzzy matching %s', (s) => { + const originalMatch = originalMatcher(s, true) + const buildMatch = buildMatcher(parsePathname, s, true) + console.log( + `fuzzy matching: ${s}, originalMatch: ${originalMatch?.[0]}, buildMatch: ${buildMatch?.[0]} ${JSON.stringify(buildMatch?.[1])}`, + ) + expect(buildMatch).toEqual(originalMatch) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f72812ba0be..1b6d76b5812 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -626,6 +626,64 @@ importers: specifier: 6.3.5 version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) + e2e/react-router/compiled-matcher: + dependencies: + '@tanstack/react-router': + specifier: workspace:* + version: link:../../../packages/react-router + '@tanstack/react-router-devtools': + specifier: workspace:^ + version: link:../../../packages/react-router-devtools + '@tanstack/router-plugin': + specifier: workspace:* + version: link:../../../packages/router-plugin + '@tanstack/zod-adapter': + specifier: workspace:* + version: link:../../../packages/zod-adapter + autoprefixer: + specifier: ^10.4.20 + version: 10.4.20(postcss@8.5.3) + postcss: + specifier: ^8.5.1 + version: 8.5.3 + react: + specifier: ^19.0.0 + version: 19.0.0 + react-dom: + specifier: ^19.0.0 + version: 19.0.0(react@19.0.0) + redaxios: + specifier: ^0.5.1 + version: 0.5.1 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.17 + zod: + specifier: ^3.24.2 + version: 3.25.57 + devDependencies: + '@playwright/test': + specifier: ^1.52.0 + version: 1.52.0 + '@tanstack/router-e2e-utils': + specifier: workspace:^ + version: link:../../e2e-utils + '@types/react': + specifier: ^19.0.8 + version: 19.0.8 + '@types/react-dom': + specifier: ^19.0.3 + version: 19.0.3(@types/react@19.0.8) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.6.0(vite@6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0)) + combinate: + specifier: ^1.1.11 + version: 1.1.11 + vite: + specifier: 6.3.5 + version: 6.3.5(@types/node@22.13.4)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.37.0)(tsx@4.20.3)(yaml@2.7.0) + e2e/react-router/generator-cli-only: dependencies: '@tanstack/react-router':