From 564a651d00d2e27b97cee93098d4d9bdf4a38d93 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 23 May 2026 23:33:54 +0200 Subject: [PATCH 01/20] feat(tanstackstart-react): Add server-side route parametrization Extracts route patterns from routeTree.gen.ts at build time and matches URLs at runtime to parametrize server transaction names (e.g., `GET /users/123` becomes `GET /users/$id`). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/routes/param.$id.tsx | 14 ++++ .../tests/route-parametrization.test.ts | 23 ++++++ .../src/server/routeParametrization.ts | 70 +++++++++++++++++++ .../src/server/wrapFetchWithSentry.ts | 7 ++ .../src/vite/routePatterns.ts | 64 +++++++++++++++++ .../src/vite/sentryTanstackStart.ts | 11 ++- .../test/server/routeParametrization.test.ts | 44 ++++++++++++ .../test/vite/routePatterns.test.ts | 49 +++++++++++++ .../test/vite/sentryTanstackStart.test.ts | 49 ++++++++++--- 9 files changed, 319 insertions(+), 12 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/param.$id.tsx create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts create mode 100644 packages/tanstackstart-react/src/server/routeParametrization.ts create mode 100644 packages/tanstackstart-react/src/vite/routePatterns.ts create mode 100644 packages/tanstackstart-react/test/server/routeParametrization.test.ts create mode 100644 packages/tanstackstart-react/test/vite/routePatterns.test.ts diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/param.$id.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/param.$id.tsx new file mode 100644 index 000000000000..43d32823168e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/param.$id.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/param/$id')({ + component: ParamPage, +}); + +function ParamPage() { + const { id } = Route.useParams(); + return ( +
+

Param: {id}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts new file mode 100644 index 000000000000..2cf86abc75e5 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; +import { waitForTransaction } from '@sentry-internal/test-utils'; + +const usesManagedTunnelRoute = + (process.env.E2E_TEST_TUNNEL_ROUTE_MODE ?? 'off') !== 'off' || process.env.E2E_TEST_CUSTOM_TUNNEL_ROUTE === '1'; + +test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); + +test('should parametrize server transaction names for dynamic routes', async ({ page }) => { + const serverTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + typeof transactionEvent?.transaction === 'string' && + transactionEvent.transaction.includes('/param/') + ); + }); + + await page.goto('/param/42'); + + const serverTx = await serverTxPromise; + + expect(serverTx.transaction).toBe('GET /param/$id'); +}); diff --git a/packages/tanstackstart-react/src/server/routeParametrization.ts b/packages/tanstackstart-react/src/server/routeParametrization.ts new file mode 100644 index 000000000000..3626d2e2d991 --- /dev/null +++ b/packages/tanstackstart-react/src/server/routeParametrization.ts @@ -0,0 +1,70 @@ +import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; +import { getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, updateSpanName } from '@sentry/core'; + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function patternToRegex(pattern: string): RegExp { + const segments = pattern + .split('/') + .map(segment => { + if (segment.startsWith('$')) { + return '[^/]+'; + } + return escapeRegex(segment); + }) + .join('/'); + return new RegExp(`^${segments}$`); +} + +/** + * Matches a URL pathname against a list of TanStack Start route patterns. + * Patterns use `$param` syntax for dynamic segments (e.g., `/users/$id`). + * + * Patterns are sorted by specificity: more segments first, static segments before dynamic. + */ +export function matchUrlToRoutePattern(pathname: string, patterns: string[]): string | undefined { + const sorted = [...patterns].sort((a, b) => { + const aSegments = a.split('/'); + const bSegments = b.split('/'); + if (bSegments.length !== aSegments.length) { + return bSegments.length - aSegments.length; + } + const aDynamic = aSegments.filter(s => s.startsWith('$')).length; + const bDynamic = bSegments.filter(s => s.startsWith('$')).length; + return aDynamic - bDynamic; + }); + + for (const pattern of sorted) { + if (patternToRegex(pattern).test(pathname)) { + return pattern; + } + } + return undefined; +} + +/** + * Updates the active root span with a parametrized route name. + */ +export function updateSpanWithRouteParametrization(method: string, pathname: string, patterns: string[]): void { + const matchedPattern = matchUrlToRoutePattern(pathname, patterns); + if (!matchedPattern) { + return; + } + + const activeSpan = getActiveSpan(); + if (!activeSpan) { + return; + } + + const rootSpan = getRootSpan(activeSpan); + const rootSpanData = spanToJSON(rootSpan).data; + if (rootSpanData?.[ATTR_HTTP_ROUTE]) { + return; + } + + updateSpanName(rootSpan, `${method} ${matchedPattern}`); + rootSpan.setAttribute(ATTR_HTTP_ROUTE, matchedPattern); + rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); +} diff --git a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts index 516604a94db1..73ea5604959e 100644 --- a/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts +++ b/packages/tanstackstart-react/src/server/wrapFetchWithSentry.ts @@ -5,8 +5,11 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, } from '@sentry/node'; +import { updateSpanWithRouteParametrization } from './routeParametrization'; import { extractServerFunctionSha256 } from './utils'; +declare const __SENTRY_ROUTE_PATTERNS__: string[] | undefined; + export type ServerEntry = { fetch: (request: Request, opts?: unknown) => Promise | Response; }; @@ -161,6 +164,10 @@ export function wrapFetchWithSentry(serverEntry: ServerEntry): ServerEntry { ); } + if (typeof __SENTRY_ROUTE_PATTERNS__ !== 'undefined') { + updateSpanWithRouteParametrization(method, url.pathname, __SENTRY_ROUTE_PATTERNS__); + } + return injectMetaTagsInResponse(await target.apply(thisArg, args)); } finally { await flushIfServerless(); diff --git a/packages/tanstackstart-react/src/vite/routePatterns.ts b/packages/tanstackstart-react/src/vite/routePatterns.ts new file mode 100644 index 000000000000..6bc296a8ebc7 --- /dev/null +++ b/packages/tanstackstart-react/src/vite/routePatterns.ts @@ -0,0 +1,64 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { Plugin } from 'vite'; + +/** + * Extracts route patterns from TanStack Start's generated routeTree.gen.ts + * and replaces `__SENTRY_ROUTE_PATTERNS__` references with the extracted patterns. + * + * Reads the route tree lazily during `transform` to ensure it exists after TanStack Start generates it. + */ +export function makeRoutePatternPlugin(): Plugin { + let resolvedRoot = ''; + + return { + name: 'sentry-tanstackstart-route-patterns', + enforce: 'post', + + configResolved(config) { + resolvedRoot = config.root || process.cwd(); + }, + + transform(code, id) { + if (!code.includes('__SENTRY_ROUTE_PATTERNS__')) { + return null; + } + + const routeTreePath = path.resolve(resolvedRoot, 'src/routeTree.gen.ts'); + let patterns: string[] = ['/']; + try { + if (fs.existsSync(routeTreePath)) { + patterns = extractRoutePatterns(fs.readFileSync(routeTreePath, 'utf-8')); + } + } catch { + // skip + } + + return { + code: code.replace(/__SENTRY_ROUTE_PATTERNS__/g, JSON.stringify(patterns)), + map: null, + }; + }, + }; +} + +/** + * Extracts route path patterns from the content of routeTree.gen.ts. + * + * Matches patterns like: `path: '/page-b/$id'` + * + * Only exported for testing. + */ +export function extractRoutePatterns(content: string): string[] { + const patterns: string[] = []; + const regex = /path:\s*['"]([^'"]+)['"]/g; + let match; + while ((match = regex.exec(content)) !== null) { + const pattern = match[1]; + if (pattern && pattern !== '/') { + patterns.push(pattern); + } + } + patterns.push('/'); + return [...new Set(patterns)]; +} diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index 5682b67050ae..ccb041f700f1 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -1,6 +1,7 @@ import type { BuildTimeOptionsBase } from '@sentry/core'; import type { Plugin } from 'vite'; import { makeAutoInstrumentMiddlewarePlugin } from './autoInstrumentMiddleware'; +import { makeRoutePatternPlugin } from './routePatterns'; import { makeAddSentryVitePlugin, makeEnableSourceMapsVitePlugin } from './sourceMaps'; import type { TunnelRouteOptions } from './tunnelRoute'; import { makeTunnelRoutePlugin } from './tunnelRoute'; @@ -86,12 +87,16 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { const tunnelRoutePlugin = options.tunnelRoute ? makeTunnelRoutePlugin(options.tunnelRoute, options.debug) : undefined; - // only add build-time plugins in production builds + // In development, only add route patterns plugin and tunnel route if (process.env.NODE_ENV === 'development') { - return tunnelRoutePlugin ? [tunnelRoutePlugin] : []; + const devPlugins: Plugin[] = [makeRoutePatternPlugin()]; + if (tunnelRoutePlugin) { + devPlugins.push(tunnelRoutePlugin); + } + return devPlugins; } - const plugins: Plugin[] = [...makeAddSentryVitePlugin(options)]; + const plugins: Plugin[] = [makeRoutePatternPlugin(), ...makeAddSentryVitePlugin(options)]; if (tunnelRoutePlugin) { plugins.push(tunnelRoutePlugin); diff --git a/packages/tanstackstart-react/test/server/routeParametrization.test.ts b/packages/tanstackstart-react/test/server/routeParametrization.test.ts new file mode 100644 index 000000000000..a52f1cd2d0c7 --- /dev/null +++ b/packages/tanstackstart-react/test/server/routeParametrization.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { matchUrlToRoutePattern } from '../../src/server/routeParametrization'; + +describe('matchUrlToRoutePattern', () => { + const patterns = ['/', '/page-a', '/page-b/$id', '/users/$userId/posts/$postId', '/api/health']; + + it('matches the root route', () => { + expect(matchUrlToRoutePattern('/', patterns)).toBe('/'); + }); + + it('matches a static route', () => { + expect(matchUrlToRoutePattern('/page-a', patterns)).toBe('/page-a'); + }); + + it('matches a single-param route', () => { + expect(matchUrlToRoutePattern('/page-b/42', patterns)).toBe('/page-b/$id'); + }); + + it('matches a multi-param route', () => { + expect(matchUrlToRoutePattern('/users/123/posts/456', patterns)).toBe('/users/$userId/posts/$postId'); + }); + + it('matches a static API route', () => { + expect(matchUrlToRoutePattern('/api/health', patterns)).toBe('/api/health'); + }); + + it('returns undefined for unmatched paths', () => { + expect(matchUrlToRoutePattern('/unknown', patterns)).toBeUndefined(); + }); + + it('returns undefined for partially matched paths', () => { + expect(matchUrlToRoutePattern('/page-b', patterns)).toBeUndefined(); + }); + + it('prefers static over dynamic matches', () => { + const patternsWithOverlap = ['/page-b/$id', '/page-b/special']; + expect(matchUrlToRoutePattern('/page-b/special', patternsWithOverlap)).toBe('/page-b/special'); + }); + + it('prefers more specific routes (more segments)', () => { + const patternsNested = ['/users/$id', '/users/$id/profile']; + expect(matchUrlToRoutePattern('/users/123/profile', patternsNested)).toBe('/users/$id/profile'); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/routePatterns.test.ts b/packages/tanstackstart-react/test/vite/routePatterns.test.ts new file mode 100644 index 000000000000..ba825a8a8577 --- /dev/null +++ b/packages/tanstackstart-react/test/vite/routePatterns.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { extractRoutePatterns } from '../../src/vite/routePatterns'; + +describe('extractRoutePatterns', () => { + it('extracts route patterns from routeTree.gen.ts content', () => { + const content = ` +const PageARoute = PageARouteImport.update({ + id: '/page-a', + path: '/page-a', + getParentRoute: () => rootRouteImport, +}) +const IndexRoute = IndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRouteImport, +}) +const PageBIdRoute = PageBIdRouteImport.update({ + id: '/page-b/$id', + path: '/page-b/$id', + getParentRoute: () => rootRouteImport, +}) +`; + const patterns = extractRoutePatterns(content); + expect(patterns).toContain('/page-a'); + expect(patterns).toContain('/page-b/$id'); + expect(patterns).toContain('/'); + }); + + it('always includes the root route', () => { + const patterns = extractRoutePatterns(''); + expect(patterns).toEqual(['/']); + }); + + it('handles nested routes', () => { + const content = ` +const UsersIdRoute = UsersIdRouteImport.update({ + id: '/users/$userId', + path: '/users/$userId', +}) +const UsersIdPostsRoute = UsersIdPostsRouteImport.update({ + id: '/users/$userId/posts/$postId', + path: '/users/$userId/posts/$postId', +}) +`; + const patterns = extractRoutePatterns(content); + expect(patterns).toContain('/users/$userId'); + expect(patterns).toContain('/users/$userId/posts/$postId'); + }); +}); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 516edadd0bb0..83ded5a9724d 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -35,6 +35,16 @@ const mockTunnelRoutePlugin: Plugin = { transform: vi.fn(), }; +const mockRoutePatternPlugin: Plugin = { + name: 'sentry-tanstackstart-route-patterns', + enforce: 'pre', + config: vi.fn(), +}; + +vi.mock('../../src/vite/routePatterns', () => ({ + makeRoutePatternPlugin: vi.fn(() => mockRoutePatternPlugin), +})); + vi.mock('../../src/vite/sourceMaps', () => ({ makeAddSentryVitePlugin: vi.fn(() => [mockSourceMapsConfigPlugin, mockSentryVitePlugin]), makeEnableSourceMapsVitePlugin: vi.fn(() => [mockEnableSourceMapsPlugin]), @@ -62,7 +72,12 @@ describe('sentryTanstackStart()', () => { it('returns source maps plugins in production mode', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); + expect(plugins).toEqual([ + mockRoutePatternPlugin, + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockEnableSourceMapsPlugin, + ]); }); it('returns no plugins in development mode when tunnelRoute is not configured', () => { @@ -70,7 +85,7 @@ describe('sentryTanstackStart()', () => { const plugins = sentryTanstackStart({ autoInstrumentMiddleware: false }); - expect(plugins).toEqual([]); + expect(plugins).toEqual([mockRoutePatternPlugin]); }); it('returns only the tunnel route plugin in development mode when tunnelRoute is configured', () => { @@ -81,7 +96,7 @@ describe('sentryTanstackStart()', () => { tunnelRoute: { allowedDsns: ['https://public@o0.ingest.sentry.io/0'] }, }); - expect(plugins).toEqual([mockTunnelRoutePlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockTunnelRoutePlugin]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is true', () => { @@ -90,7 +105,7 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: true }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin]); }); it('returns Sentry Vite plugins but not enable source maps plugin when sourcemaps.disable is "disable-upload"', () => { @@ -99,7 +114,7 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: 'disable-upload' }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin]); }); it('returns Sentry Vite plugins and enable source maps plugin when sourcemaps.disable is false', () => { @@ -108,7 +123,12 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: false }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockEnableSourceMapsPlugin]); + expect(plugins).toEqual([ + mockRoutePatternPlugin, + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockEnableSourceMapsPlugin, + ]); }); }); @@ -116,7 +136,12 @@ describe('sentryTanstackStart()', () => { it('includes middleware plugin by default', () => { const plugins = sentryTanstackStart({ sourcemaps: { disable: true } }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockMiddlewarePlugin]); + expect(plugins).toEqual([ + mockRoutePatternPlugin, + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockMiddlewarePlugin, + ]); }); it('includes middleware plugin when autoInstrumentMiddleware is true', () => { @@ -125,7 +150,12 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: true }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockMiddlewarePlugin]); + expect(plugins).toEqual([ + mockRoutePatternPlugin, + mockSourceMapsConfigPlugin, + mockSentryVitePlugin, + mockMiddlewarePlugin, + ]); }); it('does not include middleware plugin when autoInstrumentMiddleware is false', () => { @@ -134,7 +164,7 @@ describe('sentryTanstackStart()', () => { sourcemaps: { disable: true }, }); - expect(plugins).toEqual([mockSourceMapsConfigPlugin, mockSentryVitePlugin]); + expect(plugins).toEqual([mockRoutePatternPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin]); }); it('passes correct options to makeAutoInstrumentMiddlewarePlugin', () => { @@ -161,6 +191,7 @@ describe('sentryTanstackStart()', () => { }); expect(plugins).toEqual([ + mockRoutePatternPlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, mockTunnelRoutePlugin, From 9ccee873603211c12d88201462cdd1a79515dfeb Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sat, 23 May 2026 23:57:12 +0200 Subject: [PATCH 02/20] fix: Extract route patterns from fullPaths type union instead of path properties Nested routes have relative `path:` values (e.g., `/$userId`) but the `fullPaths` type union has the resolved full paths (e.g., `/users/$userId`). Also handles multi-line union format. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/vite/routePatterns.ts | 26 ++++--- .../test/vite/routePatterns.test.ts | 71 ++++++++++++------- 2 files changed, 64 insertions(+), 33 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/routePatterns.ts b/packages/tanstackstart-react/src/vite/routePatterns.ts index 6bc296a8ebc7..38abe50eb402 100644 --- a/packages/tanstackstart-react/src/vite/routePatterns.ts +++ b/packages/tanstackstart-react/src/vite/routePatterns.ts @@ -43,22 +43,32 @@ export function makeRoutePatternPlugin(): Plugin { } /** - * Extracts route path patterns from the content of routeTree.gen.ts. + * Extracts full route path patterns from the content of routeTree.gen.ts. * - * Matches patterns like: `path: '/page-b/$id'` + * Parses the `fullPaths` type union which contains the resolved full paths + * (e.g., `fullPaths: '/' | '/page-a' | '/users/$userId'`). + * This is more reliable than `path:` properties which can be relative for nested routes. * * Only exported for testing. */ export function extractRoutePatterns(content: string): string[] { + const fullPathsMatch = content.match(/fullPaths:\s*([\s\S]*?)(?:\n\s*\w|\n\})/); + if (!fullPathsMatch) { + return ['/']; + } + const patterns: string[] = []; - const regex = /path:\s*['"]([^'"]+)['"]/g; + const pathRegex = /'([^']+)'/g; let match; - while ((match = regex.exec(content)) !== null) { - const pattern = match[1]; - if (pattern && pattern !== '/') { - patterns.push(pattern); + while ((match = pathRegex.exec(fullPathsMatch[1] || '')) !== null) { + if (match[1]) { + patterns.push(match[1]); } } - patterns.push('/'); + + if (!patterns.includes('/')) { + patterns.push('/'); + } + return [...new Set(patterns)]; } diff --git a/packages/tanstackstart-react/test/vite/routePatterns.test.ts b/packages/tanstackstart-react/test/vite/routePatterns.test.ts index ba825a8a8577..1952bd686466 100644 --- a/packages/tanstackstart-react/test/vite/routePatterns.test.ts +++ b/packages/tanstackstart-react/test/vite/routePatterns.test.ts @@ -2,28 +2,37 @@ import { describe, expect, it } from 'vitest'; import { extractRoutePatterns } from '../../src/vite/routePatterns'; describe('extractRoutePatterns', () => { - it('extracts route patterns from routeTree.gen.ts content', () => { + it('extracts full path patterns from single-line fullPaths union', () => { const content = ` -const PageARoute = PageARouteImport.update({ - id: '/page-a', - path: '/page-a', - getParentRoute: () => rootRouteImport, -}) -const IndexRoute = IndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => rootRouteImport, -}) -const PageBIdRoute = PageBIdRouteImport.update({ - id: '/page-b/$id', - path: '/page-b/$id', - getParentRoute: () => rootRouteImport, -}) +export interface FileRouteTypes { + fullPaths: '/' | '/page-a' | '/page-b/$id' + fileRoutesByTo: FileRoutesByTo +} `; const patterns = extractRoutePatterns(content); + expect(patterns).toContain('/'); expect(patterns).toContain('/page-a'); expect(patterns).toContain('/page-b/$id'); + expect(patterns).toHaveLength(3); + }); + + it('extracts full path patterns from multi-line fullPaths union', () => { + const content = ` +export interface FileRouteTypes { + fullPaths: + | '/' + | '/page-a' + | '/page-b/$id' + | '/api/error' + fileRoutesByTo: FileRoutesByTo +} +`; + const patterns = extractRoutePatterns(content); expect(patterns).toContain('/'); + expect(patterns).toContain('/page-a'); + expect(patterns).toContain('/page-b/$id'); + expect(patterns).toContain('/api/error'); + expect(patterns).toHaveLength(4); }); it('always includes the root route', () => { @@ -31,19 +40,31 @@ const PageBIdRoute = PageBIdRouteImport.update({ expect(patterns).toEqual(['/']); }); - it('handles nested routes', () => { + it('extracts nested route full paths correctly', () => { const content = ` -const UsersIdRoute = UsersIdRouteImport.update({ - id: '/users/$userId', - path: '/users/$userId', -}) -const UsersIdPostsRoute = UsersIdPostsRouteImport.update({ - id: '/users/$userId/posts/$postId', - path: '/users/$userId/posts/$postId', -}) +export interface FileRouteTypes { + fullPaths: + | '/' + | '/users' + | '/users/$userId' + | '/users/$userId/posts/$postId' + fileRoutesByTo: FileRoutesByTo +} `; const patterns = extractRoutePatterns(content); + expect(patterns).toContain('/users'); expect(patterns).toContain('/users/$userId'); expect(patterns).toContain('/users/$userId/posts/$postId'); }); + + it('deduplicates patterns', () => { + const content = ` +export interface FileRouteTypes { + fullPaths: '/' | '/page-a' | '/page-a' + fileRoutesByTo: FileRoutesByTo +} +`; + const patterns = extractRoutePatterns(content); + expect(patterns.filter(p => p === '/page-a')).toHaveLength(1); + }); }); From 28c63fa559a5575a9173fb884738cbf8c11b940e Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sun, 24 May 2026 00:03:36 +0200 Subject: [PATCH 03/20] test: Add E2E test for nested parametrized routes Verifies that /users/123 is correctly parametrized to GET /users/$userId, covering the nested route case where path: values are relative. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/routes/users.$userId.tsx | 14 ++++++++++++++ .../tanstackstart-react/src/routes/users.tsx | 13 +++++++++++++ .../tests/route-parametrization.test.ts | 16 ++++++++++++++++ 3 files changed, 43 insertions(+) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/users.$userId.tsx create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/users.tsx diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/users.$userId.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/users.$userId.tsx new file mode 100644 index 000000000000..60f16f5cfece --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/users.$userId.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/users/$userId')({ + component: UserPage, +}); + +function UserPage() { + const { userId } = Route.useParams(); + return ( +
+

User: {userId}

+
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/users.tsx b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/users.tsx new file mode 100644 index 000000000000..97684c05df19 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/users.tsx @@ -0,0 +1,13 @@ +import { Outlet, createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/users')({ + component: UsersLayout, +}); + +function UsersLayout() { + return ( +
+ +
+ ); +} diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts index 2cf86abc75e5..1e384a54484d 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts @@ -21,3 +21,19 @@ test('should parametrize server transaction names for dynamic routes', async ({ expect(serverTx.transaction).toBe('GET /param/$id'); }); + +test('should parametrize server transaction names for nested dynamic routes', async ({ page }) => { + const serverTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + typeof transactionEvent?.transaction === 'string' && + transactionEvent.transaction.includes('/users/') + ); + }); + + await page.goto('/users/123'); + + const serverTx = await serverTxPromise; + + expect(serverTx.transaction).toBe('GET /users/$userId'); +}); From 51600a3206781c57ddbdc67c744559c6b37f81f1 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sun, 24 May 2026 00:16:37 +0200 Subject: [PATCH 04/20] fix: Handle splat/catch-all routes in URL pattern matching A bare `$` segment matches multiple path segments (e.g., `/files/$` matches `/files/a/b/c`). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/server/routeParametrization.ts | 3 +++ .../test/server/routeParametrization.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/packages/tanstackstart-react/src/server/routeParametrization.ts b/packages/tanstackstart-react/src/server/routeParametrization.ts index 3626d2e2d991..c064ff865352 100644 --- a/packages/tanstackstart-react/src/server/routeParametrization.ts +++ b/packages/tanstackstart-react/src/server/routeParametrization.ts @@ -9,6 +9,9 @@ function patternToRegex(pattern: string): RegExp { const segments = pattern .split('/') .map(segment => { + if (segment === '$') { + return '.+'; + } if (segment.startsWith('$')) { return '[^/]+'; } diff --git a/packages/tanstackstart-react/test/server/routeParametrization.test.ts b/packages/tanstackstart-react/test/server/routeParametrization.test.ts index a52f1cd2d0c7..0a6055ded405 100644 --- a/packages/tanstackstart-react/test/server/routeParametrization.test.ts +++ b/packages/tanstackstart-react/test/server/routeParametrization.test.ts @@ -41,4 +41,16 @@ describe('matchUrlToRoutePattern', () => { const patternsNested = ['/users/$id', '/users/$id/profile']; expect(matchUrlToRoutePattern('/users/123/profile', patternsNested)).toBe('/users/$id/profile'); }); + + it('matches splat/catch-all routes across multiple segments', () => { + const patternsWithSplat = ['/', '/files/$']; + expect(matchUrlToRoutePattern('/files/a/b/c', patternsWithSplat)).toBe('/files/$'); + expect(matchUrlToRoutePattern('/files/readme.txt', patternsWithSplat)).toBe('/files/$'); + }); + + it('prefers specific routes over splat routes', () => { + const patternsWithSplat = ['/files/$', '/files/upload']; + expect(matchUrlToRoutePattern('/files/upload', patternsWithSplat)).toBe('/files/upload'); + expect(matchUrlToRoutePattern('/files/a/b', patternsWithSplat)).toBe('/files/$'); + }); }); From 85619b379e2b068be802128a8f48ed73f4e27abd Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sun, 24 May 2026 00:20:04 +0200 Subject: [PATCH 05/20] perf: Cache compiled route pattern regexes across requests The pattern list is static at build time. Sort once and compile regexes once instead of on every request. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/server/routeParametrization.ts | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/tanstackstart-react/src/server/routeParametrization.ts b/packages/tanstackstart-react/src/server/routeParametrization.ts index c064ff865352..4b8e127ad9c0 100644 --- a/packages/tanstackstart-react/src/server/routeParametrization.ts +++ b/packages/tanstackstart-react/src/server/routeParametrization.ts @@ -21,26 +21,43 @@ function patternToRegex(pattern: string): RegExp { return new RegExp(`^${segments}$`); } +type CompiledPattern = { pattern: string; regex: RegExp }; + +let compiledPatterns: CompiledPattern[] | undefined; +let compiledFromPatterns: string[] | undefined; + +function getCompiledPatterns(patterns: string[]): CompiledPattern[] { + if (compiledPatterns && compiledFromPatterns === patterns) { + return compiledPatterns; + } + + compiledFromPatterns = patterns; + compiledPatterns = [...patterns] + .sort((a, b) => { + const aSegments = a.split('/'); + const bSegments = b.split('/'); + if (bSegments.length !== aSegments.length) { + return bSegments.length - aSegments.length; + } + const aDynamic = aSegments.filter(s => s.startsWith('$')).length; + const bDynamic = bSegments.filter(s => s.startsWith('$')).length; + return aDynamic - bDynamic; + }) + .map(pattern => ({ pattern, regex: patternToRegex(pattern) })); + + return compiledPatterns; +} + /** * Matches a URL pathname against a list of TanStack Start route patterns. * Patterns use `$param` syntax for dynamic segments (e.g., `/users/$id`). * * Patterns are sorted by specificity: more segments first, static segments before dynamic. + * Compiled regexes are cached across calls since the pattern list is static at build time. */ export function matchUrlToRoutePattern(pathname: string, patterns: string[]): string | undefined { - const sorted = [...patterns].sort((a, b) => { - const aSegments = a.split('/'); - const bSegments = b.split('/'); - if (bSegments.length !== aSegments.length) { - return bSegments.length - aSegments.length; - } - const aDynamic = aSegments.filter(s => s.startsWith('$')).length; - const bDynamic = bSegments.filter(s => s.startsWith('$')).length; - return aDynamic - bDynamic; - }); - - for (const pattern of sorted) { - if (patternToRegex(pattern).test(pathname)) { + for (const { pattern, regex } of getCompiledPatterns(patterns)) { + if (regex.test(pathname)) { return pattern; } } From 51020a4a09b938ba123264224170e29ee3526222 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Sun, 24 May 2026 00:21:42 +0200 Subject: [PATCH 06/20] fix: Remove unnecessary caching complexity from route matching Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/server/routeParametrization.ts | 43 ++++++------------- 1 file changed, 13 insertions(+), 30 deletions(-) diff --git a/packages/tanstackstart-react/src/server/routeParametrization.ts b/packages/tanstackstart-react/src/server/routeParametrization.ts index 4b8e127ad9c0..c064ff865352 100644 --- a/packages/tanstackstart-react/src/server/routeParametrization.ts +++ b/packages/tanstackstart-react/src/server/routeParametrization.ts @@ -21,43 +21,26 @@ function patternToRegex(pattern: string): RegExp { return new RegExp(`^${segments}$`); } -type CompiledPattern = { pattern: string; regex: RegExp }; - -let compiledPatterns: CompiledPattern[] | undefined; -let compiledFromPatterns: string[] | undefined; - -function getCompiledPatterns(patterns: string[]): CompiledPattern[] { - if (compiledPatterns && compiledFromPatterns === patterns) { - return compiledPatterns; - } - - compiledFromPatterns = patterns; - compiledPatterns = [...patterns] - .sort((a, b) => { - const aSegments = a.split('/'); - const bSegments = b.split('/'); - if (bSegments.length !== aSegments.length) { - return bSegments.length - aSegments.length; - } - const aDynamic = aSegments.filter(s => s.startsWith('$')).length; - const bDynamic = bSegments.filter(s => s.startsWith('$')).length; - return aDynamic - bDynamic; - }) - .map(pattern => ({ pattern, regex: patternToRegex(pattern) })); - - return compiledPatterns; -} - /** * Matches a URL pathname against a list of TanStack Start route patterns. * Patterns use `$param` syntax for dynamic segments (e.g., `/users/$id`). * * Patterns are sorted by specificity: more segments first, static segments before dynamic. - * Compiled regexes are cached across calls since the pattern list is static at build time. */ export function matchUrlToRoutePattern(pathname: string, patterns: string[]): string | undefined { - for (const { pattern, regex } of getCompiledPatterns(patterns)) { - if (regex.test(pathname)) { + const sorted = [...patterns].sort((a, b) => { + const aSegments = a.split('/'); + const bSegments = b.split('/'); + if (bSegments.length !== aSegments.length) { + return bSegments.length - aSegments.length; + } + const aDynamic = aSegments.filter(s => s.startsWith('$')).length; + const bDynamic = bSegments.filter(s => s.startsWith('$')).length; + return aDynamic - bDynamic; + }); + + for (const pattern of sorted) { + if (patternToRegex(pattern).test(pathname)) { return pattern; } } From 29a49de1abf44ea3127ccf1e0ac5e0aa5b1e80b7 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 11:19:52 +0200 Subject: [PATCH 07/20] fix: Use escapeStringForRegex from core, deprioritize splat routes in sort Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/server/routeParametrization.ts | 22 +++++++++++++------ .../test/server/routeParametrization.test.ts | 5 +++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/tanstackstart-react/src/server/routeParametrization.ts b/packages/tanstackstart-react/src/server/routeParametrization.ts index c064ff865352..3227b6cf0335 100644 --- a/packages/tanstackstart-react/src/server/routeParametrization.ts +++ b/packages/tanstackstart-react/src/server/routeParametrization.ts @@ -1,9 +1,12 @@ import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; -import { getActiveSpan, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, updateSpanName } from '@sentry/core'; - -function escapeRegex(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} +import { + escapeStringForRegex, + getActiveSpan, + getRootSpan, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + spanToJSON, + updateSpanName, +} from '@sentry/core'; function patternToRegex(pattern: string): RegExp { const segments = pattern @@ -15,7 +18,7 @@ function patternToRegex(pattern: string): RegExp { if (segment.startsWith('$')) { return '[^/]+'; } - return escapeRegex(segment); + return escapeStringForRegex(segment); }) .join('/'); return new RegExp(`^${segments}$`); @@ -25,7 +28,7 @@ function patternToRegex(pattern: string): RegExp { * Matches a URL pathname against a list of TanStack Start route patterns. * Patterns use `$param` syntax for dynamic segments (e.g., `/users/$id`). * - * Patterns are sorted by specificity: more segments first, static segments before dynamic. + * Patterns are sorted by specificity: more segments first, static before dynamic, splat last. */ export function matchUrlToRoutePattern(pathname: string, patterns: string[]): string | undefined { const sorted = [...patterns].sort((a, b) => { @@ -34,6 +37,11 @@ export function matchUrlToRoutePattern(pathname: string, patterns: string[]): st if (bSegments.length !== aSegments.length) { return bSegments.length - aSegments.length; } + const aSplat = aSegments.filter(s => s === '$').length; + const bSplat = bSegments.filter(s => s === '$').length; + if (aSplat !== bSplat) { + return aSplat - bSplat; + } const aDynamic = aSegments.filter(s => s.startsWith('$')).length; const bDynamic = bSegments.filter(s => s.startsWith('$')).length; return aDynamic - bDynamic; diff --git a/packages/tanstackstart-react/test/server/routeParametrization.test.ts b/packages/tanstackstart-react/test/server/routeParametrization.test.ts index 0a6055ded405..35f2ebcfa7d3 100644 --- a/packages/tanstackstart-react/test/server/routeParametrization.test.ts +++ b/packages/tanstackstart-react/test/server/routeParametrization.test.ts @@ -53,4 +53,9 @@ describe('matchUrlToRoutePattern', () => { expect(matchUrlToRoutePattern('/files/upload', patternsWithSplat)).toBe('/files/upload'); expect(matchUrlToRoutePattern('/files/a/b', patternsWithSplat)).toBe('/files/$'); }); + + it('prefers named param routes over splat routes with same segment count', () => { + const patternsWithBoth = ['/files/$', '/files/$name']; + expect(matchUrlToRoutePattern('/files/readme.txt', patternsWithBoth)).toBe('/files/$name'); + }); }); From aa0ac2e4c67b5564462f9cd9bd1b4a9404295880 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 11:54:07 +0200 Subject: [PATCH 08/20] test: Remove redundant static route matching test Co-Authored-By: Claude Opus 4.6 (1M context) --- .../test/server/routeParametrization.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/tanstackstart-react/test/server/routeParametrization.test.ts b/packages/tanstackstart-react/test/server/routeParametrization.test.ts index 35f2ebcfa7d3..f3d9499d05a8 100644 --- a/packages/tanstackstart-react/test/server/routeParametrization.test.ts +++ b/packages/tanstackstart-react/test/server/routeParametrization.test.ts @@ -20,10 +20,6 @@ describe('matchUrlToRoutePattern', () => { expect(matchUrlToRoutePattern('/users/123/posts/456', patterns)).toBe('/users/$userId/posts/$postId'); }); - it('matches a static API route', () => { - expect(matchUrlToRoutePattern('/api/health', patterns)).toBe('/api/health'); - }); - it('returns undefined for unmatched paths', () => { expect(matchUrlToRoutePattern('/unknown', patterns)).toBeUndefined(); }); From e02a164ed6572f4eb69086bdf7d99e0a7866ac8b Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 12:11:01 +0200 Subject: [PATCH 09/20] test: Strengthen route parametrization tests - Assert transaction_info.source is 'route' on all tests - Assert client-side parametrization on page and nested routes - Add API route parametrization test - Remove redundant static route unit test Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/routes/api.user.$id.ts | 13 ++++ .../tests/route-parametrization.test.ts | 61 +++++++++++++++++-- 2 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.user.$id.ts diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.user.$id.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.user.$id.ts new file mode 100644 index 000000000000..ce20e2f74ce1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.user.$id.ts @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/api/user/$id')({ + server: { + handlers: { + GET: async ({ params }) => { + return new Response(JSON.stringify({ id: params.id }), { + headers: { 'Content-Type': 'application/json' }, + }); + }, + }, + }, +}); diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts index 1e384a54484d..32955baf0d6f 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts @@ -6,7 +6,7 @@ const usesManagedTunnelRoute = test.skip(usesManagedTunnelRoute, 'Default e2e suites run only in the proxy variant'); -test('should parametrize server transaction names for dynamic routes', async ({ page }) => { +test('should parametrize server and client transaction names for dynamic routes', async ({ page }) => { const serverTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => { return ( transactionEvent?.contexts?.trace?.op === 'http.server' && @@ -15,14 +15,31 @@ test('should parametrize server transaction names for dynamic routes', async ({ ); }); + const clientTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'pageload' && + typeof transactionEvent?.transaction === 'string' && + transactionEvent.transaction.includes('/param/') + ); + }); + await page.goto('/param/42'); const serverTx = await serverTxPromise; + const clientTx = await clientTxPromise; + + expect(serverTx).toMatchObject({ + transaction: 'GET /param/$id', + transaction_info: { source: 'route' }, + }); - expect(serverTx.transaction).toBe('GET /param/$id'); + expect(clientTx).toMatchObject({ + transaction: '/param/$id', + transaction_info: { source: 'route' }, + }); }); -test('should parametrize server transaction names for nested dynamic routes', async ({ page }) => { +test('should parametrize server and client transaction names for nested dynamic routes', async ({ page }) => { const serverTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => { return ( transactionEvent?.contexts?.trace?.op === 'http.server' && @@ -31,9 +48,45 @@ test('should parametrize server transaction names for nested dynamic routes', as ); }); + const clientTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'pageload' && + typeof transactionEvent?.transaction === 'string' && + transactionEvent.transaction.includes('/users/') + ); + }); + await page.goto('/users/123'); const serverTx = await serverTxPromise; + const clientTx = await clientTxPromise; - expect(serverTx.transaction).toBe('GET /users/$userId'); + expect(serverTx).toMatchObject({ + transaction: 'GET /users/$userId', + transaction_info: { source: 'route' }, + }); + + expect(clientTx).toMatchObject({ + transaction: '/users/$userId', + transaction_info: { source: 'route' }, + }); +}); + +test('should parametrize API route transaction names', async ({ baseURL }) => { + const serverTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => { + return ( + transactionEvent?.contexts?.trace?.op === 'http.server' && + typeof transactionEvent?.transaction === 'string' && + transactionEvent.transaction.includes('/api/user/') + ); + }); + + await fetch(`${baseURL}/api/user/456`); + + const serverTx = await serverTxPromise; + + expect(serverTx).toMatchObject({ + transaction: 'GET /api/user/$id', + transaction_info: { source: 'route' }, + }); }); From 2d1fa357f36952547fe19017dc3d4b357f2988b0 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 12:21:12 +0200 Subject: [PATCH 10/20] ref: Remove splat route handling to simplify matching logic Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/server/routeParametrization.ts | 10 +--------- .../test/server/routeParametrization.test.ts | 17 ----------------- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/packages/tanstackstart-react/src/server/routeParametrization.ts b/packages/tanstackstart-react/src/server/routeParametrization.ts index 3227b6cf0335..d6b861611308 100644 --- a/packages/tanstackstart-react/src/server/routeParametrization.ts +++ b/packages/tanstackstart-react/src/server/routeParametrization.ts @@ -12,9 +12,6 @@ function patternToRegex(pattern: string): RegExp { const segments = pattern .split('/') .map(segment => { - if (segment === '$') { - return '.+'; - } if (segment.startsWith('$')) { return '[^/]+'; } @@ -28,7 +25,7 @@ function patternToRegex(pattern: string): RegExp { * Matches a URL pathname against a list of TanStack Start route patterns. * Patterns use `$param` syntax for dynamic segments (e.g., `/users/$id`). * - * Patterns are sorted by specificity: more segments first, static before dynamic, splat last. + * Patterns are sorted by specificity: more segments first, static before dynamic. */ export function matchUrlToRoutePattern(pathname: string, patterns: string[]): string | undefined { const sorted = [...patterns].sort((a, b) => { @@ -37,11 +34,6 @@ export function matchUrlToRoutePattern(pathname: string, patterns: string[]): st if (bSegments.length !== aSegments.length) { return bSegments.length - aSegments.length; } - const aSplat = aSegments.filter(s => s === '$').length; - const bSplat = bSegments.filter(s => s === '$').length; - if (aSplat !== bSplat) { - return aSplat - bSplat; - } const aDynamic = aSegments.filter(s => s.startsWith('$')).length; const bDynamic = bSegments.filter(s => s.startsWith('$')).length; return aDynamic - bDynamic; diff --git a/packages/tanstackstart-react/test/server/routeParametrization.test.ts b/packages/tanstackstart-react/test/server/routeParametrization.test.ts index f3d9499d05a8..2f24a570031d 100644 --- a/packages/tanstackstart-react/test/server/routeParametrization.test.ts +++ b/packages/tanstackstart-react/test/server/routeParametrization.test.ts @@ -37,21 +37,4 @@ describe('matchUrlToRoutePattern', () => { const patternsNested = ['/users/$id', '/users/$id/profile']; expect(matchUrlToRoutePattern('/users/123/profile', patternsNested)).toBe('/users/$id/profile'); }); - - it('matches splat/catch-all routes across multiple segments', () => { - const patternsWithSplat = ['/', '/files/$']; - expect(matchUrlToRoutePattern('/files/a/b/c', patternsWithSplat)).toBe('/files/$'); - expect(matchUrlToRoutePattern('/files/readme.txt', patternsWithSplat)).toBe('/files/$'); - }); - - it('prefers specific routes over splat routes', () => { - const patternsWithSplat = ['/files/$', '/files/upload']; - expect(matchUrlToRoutePattern('/files/upload', patternsWithSplat)).toBe('/files/upload'); - expect(matchUrlToRoutePattern('/files/a/b', patternsWithSplat)).toBe('/files/$'); - }); - - it('prefers named param routes over splat routes with same segment count', () => { - const patternsWithBoth = ['/files/$', '/files/$name']; - expect(matchUrlToRoutePattern('/files/readme.txt', patternsWithBoth)).toBe('/files/$name'); - }); }); From eed3d20cca9977d2e34217d38ccb7b2ea1a054f5 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 12:36:59 +0200 Subject: [PATCH 11/20] ref: Clean up plugin assembly in sentryTanstackStart Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/vite/sentryTanstackStart.ts | 20 ++++++++----------- .../test/vite/sentryTanstackStart.test.ts | 2 +- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts index ccb041f700f1..a440e791e242 100644 --- a/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts +++ b/packages/tanstackstart-react/src/vite/sentryTanstackStart.ts @@ -85,23 +85,19 @@ export interface SentryTanstackStartOptions extends BuildTimeOptionsBase { * @returns An array of Vite plugins */ export function sentryTanstackStart(options: SentryTanstackStartOptions = {}): Plugin[] { - const tunnelRoutePlugin = options.tunnelRoute ? makeTunnelRoutePlugin(options.tunnelRoute, options.debug) : undefined; + const plugins: Plugin[] = [makeRoutePatternPlugin()]; - // In development, only add route patterns plugin and tunnel route - if (process.env.NODE_ENV === 'development') { - const devPlugins: Plugin[] = [makeRoutePatternPlugin()]; - if (tunnelRoutePlugin) { - devPlugins.push(tunnelRoutePlugin); - } - return devPlugins; + if (options.tunnelRoute) { + plugins.push(makeTunnelRoutePlugin(options.tunnelRoute, options.debug)); } - const plugins: Plugin[] = [makeRoutePatternPlugin(), ...makeAddSentryVitePlugin(options)]; - - if (tunnelRoutePlugin) { - plugins.push(tunnelRoutePlugin); + // only add build-time plugins in production builds + if (process.env.NODE_ENV === 'development') { + return plugins; } + plugins.push(...makeAddSentryVitePlugin(options)); + // middleware auto-instrumentation if (options.autoInstrumentMiddleware !== false) { plugins.push(makeAutoInstrumentMiddlewarePlugin({ enabled: true, debug: options.debug })); diff --git a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts index 83ded5a9724d..cba4508de1a1 100644 --- a/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts +++ b/packages/tanstackstart-react/test/vite/sentryTanstackStart.test.ts @@ -192,9 +192,9 @@ describe('sentryTanstackStart()', () => { expect(plugins).toEqual([ mockRoutePatternPlugin, + mockTunnelRoutePlugin, mockSourceMapsConfigPlugin, mockSentryVitePlugin, - mockTunnelRoutePlugin, mockMiddlewarePlugin, ]); }); From f8dd9ee2b69002e801892de0652b442853239100 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 12:40:09 +0200 Subject: [PATCH 12/20] docs: Clarify routePatterns plugin JSDoc Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/tanstackstart-react/src/vite/routePatterns.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/tanstackstart-react/src/vite/routePatterns.ts b/packages/tanstackstart-react/src/vite/routePatterns.ts index 38abe50eb402..6a993db91ee2 100644 --- a/packages/tanstackstart-react/src/vite/routePatterns.ts +++ b/packages/tanstackstart-react/src/vite/routePatterns.ts @@ -6,7 +6,8 @@ import type { Plugin } from 'vite'; * Extracts route patterns from TanStack Start's generated routeTree.gen.ts * and replaces `__SENTRY_ROUTE_PATTERNS__` references with the extracted patterns. * - * Reads the route tree lazily during `transform` to ensure it exists after TanStack Start generates it. + * The route tree file is read during `transform` rather than `config` because + * TanStack Start generates it during the build. */ export function makeRoutePatternPlugin(): Plugin { let resolvedRoot = ''; From ad5b1d6d5e0f029deea9cf9d4a56653775cfd1c1 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 12:54:25 +0200 Subject: [PATCH 13/20] fix: Fall back to empty array instead of ['/'] when route tree is unavailable Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tanstackstart-react/src/vite/routePatterns.ts | 13 +++++-------- .../test/vite/routePatterns.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/tanstackstart-react/src/vite/routePatterns.ts b/packages/tanstackstart-react/src/vite/routePatterns.ts index 6a993db91ee2..93bf23858426 100644 --- a/packages/tanstackstart-react/src/vite/routePatterns.ts +++ b/packages/tanstackstart-react/src/vite/routePatterns.ts @@ -21,12 +21,15 @@ export function makeRoutePatternPlugin(): Plugin { }, transform(code, id) { + // this is set in the `wrapFetchWithSentry` where the paths are getting replaced by their parametrized counterparts + // so this extraction should only happen once during the build (for the `wrapFetchWithSentry` file) if (!code.includes('__SENTRY_ROUTE_PATTERNS__')) { return null; } + // extract the patterns from the route tree file const routeTreePath = path.resolve(resolvedRoot, 'src/routeTree.gen.ts'); - let patterns: string[] = ['/']; + let patterns: string[] = []; try { if (fs.existsSync(routeTreePath)) { patterns = extractRoutePatterns(fs.readFileSync(routeTreePath, 'utf-8')); @@ -49,13 +52,11 @@ export function makeRoutePatternPlugin(): Plugin { * Parses the `fullPaths` type union which contains the resolved full paths * (e.g., `fullPaths: '/' | '/page-a' | '/users/$userId'`). * This is more reliable than `path:` properties which can be relative for nested routes. - * - * Only exported for testing. */ export function extractRoutePatterns(content: string): string[] { const fullPathsMatch = content.match(/fullPaths:\s*([\s\S]*?)(?:\n\s*\w|\n\})/); if (!fullPathsMatch) { - return ['/']; + return []; } const patterns: string[] = []; @@ -67,9 +68,5 @@ export function extractRoutePatterns(content: string): string[] { } } - if (!patterns.includes('/')) { - patterns.push('/'); - } - return [...new Set(patterns)]; } diff --git a/packages/tanstackstart-react/test/vite/routePatterns.test.ts b/packages/tanstackstart-react/test/vite/routePatterns.test.ts index 1952bd686466..af14ed2f5f6c 100644 --- a/packages/tanstackstart-react/test/vite/routePatterns.test.ts +++ b/packages/tanstackstart-react/test/vite/routePatterns.test.ts @@ -35,9 +35,9 @@ export interface FileRouteTypes { expect(patterns).toHaveLength(4); }); - it('always includes the root route', () => { + it('returns empty array when fullPaths is not found', () => { const patterns = extractRoutePatterns(''); - expect(patterns).toEqual(['/']); + expect(patterns).toEqual([]); }); it('extracts nested route full paths correctly', () => { From 756d14d11298657e8c9ed8ecee30cf8256f568ee Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 13:15:27 +0200 Subject: [PATCH 14/20] ref: Move pattern sorting to build time, add setTransactionName Sorting now happens once in extractRoutePatterns instead of on every request. Also adds getCurrentScope().setTransactionName() so errors captured during a request get the parametrized name. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/server/routeParametrization.ts | 20 ++++++------------- .../src/vite/routePatterns.ts | 11 +++++++++- .../test/server/routeParametrization.test.ts | 11 +++++----- .../test/vite/routePatterns.test.ts | 11 ++++++++++ 4 files changed, 33 insertions(+), 20 deletions(-) diff --git a/packages/tanstackstart-react/src/server/routeParametrization.ts b/packages/tanstackstart-react/src/server/routeParametrization.ts index d6b861611308..cbabe7b41552 100644 --- a/packages/tanstackstart-react/src/server/routeParametrization.ts +++ b/packages/tanstackstart-react/src/server/routeParametrization.ts @@ -2,6 +2,7 @@ import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; import { escapeStringForRegex, getActiveSpan, + getCurrentScope, getRootSpan, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, spanToJSON, @@ -25,21 +26,10 @@ function patternToRegex(pattern: string): RegExp { * Matches a URL pathname against a list of TanStack Start route patterns. * Patterns use `$param` syntax for dynamic segments (e.g., `/users/$id`). * - * Patterns are sorted by specificity: more segments first, static before dynamic. + * Patterns are expected to be pre-sorted by specificity (more segments first, static before dynamic). */ export function matchUrlToRoutePattern(pathname: string, patterns: string[]): string | undefined { - const sorted = [...patterns].sort((a, b) => { - const aSegments = a.split('/'); - const bSegments = b.split('/'); - if (bSegments.length !== aSegments.length) { - return bSegments.length - aSegments.length; - } - const aDynamic = aSegments.filter(s => s.startsWith('$')).length; - const bDynamic = bSegments.filter(s => s.startsWith('$')).length; - return aDynamic - bDynamic; - }); - - for (const pattern of sorted) { + for (const pattern of patterns) { if (patternToRegex(pattern).test(pathname)) { return pattern; } @@ -67,7 +57,9 @@ export function updateSpanWithRouteParametrization(method: string, pathname: str return; } - updateSpanName(rootSpan, `${method} ${matchedPattern}`); + const transactionName = `${method} ${matchedPattern}`; + updateSpanName(rootSpan, transactionName); rootSpan.setAttribute(ATTR_HTTP_ROUTE, matchedPattern); rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); + getCurrentScope().setTransactionName(transactionName); } diff --git a/packages/tanstackstart-react/src/vite/routePatterns.ts b/packages/tanstackstart-react/src/vite/routePatterns.ts index 93bf23858426..97e9678d7be1 100644 --- a/packages/tanstackstart-react/src/vite/routePatterns.ts +++ b/packages/tanstackstart-react/src/vite/routePatterns.ts @@ -68,5 +68,14 @@ export function extractRoutePatterns(content: string): string[] { } } - return [...new Set(patterns)]; + return [...new Set(patterns)].sort((a, b) => { + const aSegments = a.split('/'); + const bSegments = b.split('/'); + if (bSegments.length !== aSegments.length) { + return bSegments.length - aSegments.length; + } + const aDynamic = aSegments.filter(s => s.startsWith('$')).length; + const bDynamic = bSegments.filter(s => s.startsWith('$')).length; + return aDynamic - bDynamic; + }); } diff --git a/packages/tanstackstart-react/test/server/routeParametrization.test.ts b/packages/tanstackstart-react/test/server/routeParametrization.test.ts index 2f24a570031d..bc79beb1bbf2 100644 --- a/packages/tanstackstart-react/test/server/routeParametrization.test.ts +++ b/packages/tanstackstart-react/test/server/routeParametrization.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'; import { matchUrlToRoutePattern } from '../../src/server/routeParametrization'; describe('matchUrlToRoutePattern', () => { - const patterns = ['/', '/page-a', '/page-b/$id', '/users/$userId/posts/$postId', '/api/health']; + // Pre-sorted by specificity: more segments first, static before dynamic + const patterns = ['/users/$userId/posts/$postId', '/api/health', '/page-a', '/page-b/$id', '/']; it('matches the root route', () => { expect(matchUrlToRoutePattern('/', patterns)).toBe('/'); @@ -28,13 +29,13 @@ describe('matchUrlToRoutePattern', () => { expect(matchUrlToRoutePattern('/page-b', patterns)).toBeUndefined(); }); - it('prefers static over dynamic matches', () => { - const patternsWithOverlap = ['/page-b/$id', '/page-b/special']; + it('prefers static over dynamic matches when pre-sorted', () => { + const patternsWithOverlap = ['/page-b/special', '/page-b/$id']; expect(matchUrlToRoutePattern('/page-b/special', patternsWithOverlap)).toBe('/page-b/special'); }); - it('prefers more specific routes (more segments)', () => { - const patternsNested = ['/users/$id', '/users/$id/profile']; + it('prefers more specific routes when pre-sorted', () => { + const patternsNested = ['/users/$id/profile', '/users/$id']; expect(matchUrlToRoutePattern('/users/123/profile', patternsNested)).toBe('/users/$id/profile'); }); }); diff --git a/packages/tanstackstart-react/test/vite/routePatterns.test.ts b/packages/tanstackstart-react/test/vite/routePatterns.test.ts index af14ed2f5f6c..5c70dabacbd7 100644 --- a/packages/tanstackstart-react/test/vite/routePatterns.test.ts +++ b/packages/tanstackstart-react/test/vite/routePatterns.test.ts @@ -57,6 +57,17 @@ export interface FileRouteTypes { expect(patterns).toContain('/users/$userId/posts/$postId'); }); + it('sorts patterns by specificity: more segments first, static before dynamic', () => { + const content = ` +export interface FileRouteTypes { + fullPaths: '/' | '/page-b/$id' | '/page-b/special' | '/users/$id/profile' | '/users/$id' + fileRoutesByTo: FileRoutesByTo +} +`; + const patterns = extractRoutePatterns(content); + expect(patterns).toEqual(['/users/$id/profile', '/page-b/special', '/page-b/$id', '/users/$id', '/']); + }); + it('deduplicates patterns', () => { const content = ` export interface FileRouteTypes { From fa5a6b6861b77a21842a23d5154f19068a4ba629 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 13:25:12 +0200 Subject: [PATCH 15/20] ci: trigger CI From 5f4daf7768396b39516af4b9b28f317e082dbd89 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 13:59:42 +0200 Subject: [PATCH 16/20] test: Use toBe instead of toMatchObject in E2E assertions Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tests/route-parametrization.test.ts | 30 +++++++------------ 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts index 32955baf0d6f..39058a45bf29 100644 --- a/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts +++ b/dev-packages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts @@ -28,15 +28,11 @@ test('should parametrize server and client transaction names for dynamic routes' const serverTx = await serverTxPromise; const clientTx = await clientTxPromise; - expect(serverTx).toMatchObject({ - transaction: 'GET /param/$id', - transaction_info: { source: 'route' }, - }); + expect(serverTx.transaction).toBe('GET /param/$id'); + expect(serverTx.transaction_info?.source).toBe('route'); - expect(clientTx).toMatchObject({ - transaction: '/param/$id', - transaction_info: { source: 'route' }, - }); + expect(clientTx.transaction).toBe('/param/$id'); + expect(clientTx.transaction_info?.source).toBe('route'); }); test('should parametrize server and client transaction names for nested dynamic routes', async ({ page }) => { @@ -61,15 +57,11 @@ test('should parametrize server and client transaction names for nested dynamic const serverTx = await serverTxPromise; const clientTx = await clientTxPromise; - expect(serverTx).toMatchObject({ - transaction: 'GET /users/$userId', - transaction_info: { source: 'route' }, - }); + expect(serverTx.transaction).toBe('GET /users/$userId'); + expect(serverTx.transaction_info?.source).toBe('route'); - expect(clientTx).toMatchObject({ - transaction: '/users/$userId', - transaction_info: { source: 'route' }, - }); + expect(clientTx.transaction).toBe('/users/$userId'); + expect(clientTx.transaction_info?.source).toBe('route'); }); test('should parametrize API route transaction names', async ({ baseURL }) => { @@ -85,8 +77,6 @@ test('should parametrize API route transaction names', async ({ baseURL }) => { const serverTx = await serverTxPromise; - expect(serverTx).toMatchObject({ - transaction: 'GET /api/user/$id', - transaction_info: { source: 'route' }, - }); + expect(serverTx.transaction).toBe('GET /api/user/$id'); + expect(serverTx.transaction_info?.source).toBe('route'); }); From 3db2379c159af2c37434342a55aba786e3dc0562 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 14:07:56 +0200 Subject: [PATCH 17/20] fix: Support double-quoted paths in routeTree.gen.ts TanStack Router's generator has a quoteStyle config that can be set to "double". The regex now matches both single and double quotes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tanstackstart-react/src/vite/routePatterns.ts | 2 +- .../test/vite/routePatterns.test.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/tanstackstart-react/src/vite/routePatterns.ts b/packages/tanstackstart-react/src/vite/routePatterns.ts index 97e9678d7be1..1c1b07fe0036 100644 --- a/packages/tanstackstart-react/src/vite/routePatterns.ts +++ b/packages/tanstackstart-react/src/vite/routePatterns.ts @@ -60,7 +60,7 @@ export function extractRoutePatterns(content: string): string[] { } const patterns: string[] = []; - const pathRegex = /'([^']+)'/g; + const pathRegex = /['"]([^'"]+)['"]/g; let match; while ((match = pathRegex.exec(fullPathsMatch[1] || '')) !== null) { if (match[1]) { diff --git a/packages/tanstackstart-react/test/vite/routePatterns.test.ts b/packages/tanstackstart-react/test/vite/routePatterns.test.ts index 5c70dabacbd7..07b1c2803bff 100644 --- a/packages/tanstackstart-react/test/vite/routePatterns.test.ts +++ b/packages/tanstackstart-react/test/vite/routePatterns.test.ts @@ -68,6 +68,19 @@ export interface FileRouteTypes { expect(patterns).toEqual(['/users/$id/profile', '/page-b/special', '/page-b/$id', '/users/$id', '/']); }); + it('handles double-quoted paths (quoteStyle: "double")', () => { + const content = ` +export interface FileRouteTypes { + fullPaths: "/" | "/page-a" | "/page-b/$id" + fileRoutesByTo: FileRoutesByTo +} +`; + const patterns = extractRoutePatterns(content); + expect(patterns).toContain('/'); + expect(patterns).toContain('/page-a'); + expect(patterns).toContain('/page-b/$id'); + }); + it('deduplicates patterns', () => { const content = ` export interface FileRouteTypes { From 17c32afe64adc78d012b1b78e973a19dfbc6bbc6 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 15:11:23 +0200 Subject: [PATCH 18/20] ci: retrigger From b24b1e78850021f0abe859b23bf9869082ebf212 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 15:17:29 +0200 Subject: [PATCH 19/20] docs: Add changelog entry for route parametrization Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5caa55f6c4e..356bc6fa31c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Server and client traces are now automatically connected, allowing you to see the full request lifecycle from server-side rendering through client-side hydration in a single trace. +- **feat(tanstackstart-react): Add server-side route parametrization ([#21147](https://github.com/getsentry/sentry-javascript/pull/21147))** + + Server transaction names are now parametrized automatically (e.g., `GET /users/123` becomes `GET /users/$userId`), improving transaction grouping in Sentry. + ## 10.54.0 ### Important Changes From a6f5a111fd70c7f59706c3e02c492b578521d6f7 Mon Sep 17 00:00:00 2001 From: Nicolas Hrubec Date: Tue, 26 May 2026 15:39:12 +0200 Subject: [PATCH 20/20] fix: Strip trailing slashes before route pattern matching Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tanstackstart-react/src/server/routeParametrization.ts | 3 ++- .../test/server/routeParametrization.test.ts | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/tanstackstart-react/src/server/routeParametrization.ts b/packages/tanstackstart-react/src/server/routeParametrization.ts index cbabe7b41552..bd1d1d904770 100644 --- a/packages/tanstackstart-react/src/server/routeParametrization.ts +++ b/packages/tanstackstart-react/src/server/routeParametrization.ts @@ -29,8 +29,9 @@ function patternToRegex(pattern: string): RegExp { * Patterns are expected to be pre-sorted by specificity (more segments first, static before dynamic). */ export function matchUrlToRoutePattern(pathname: string, patterns: string[]): string | undefined { + const normalizedPathname = pathname.length > 1 ? pathname.replace(/\/$/, '') : pathname; for (const pattern of patterns) { - if (patternToRegex(pattern).test(pathname)) { + if (patternToRegex(pattern).test(normalizedPathname)) { return pattern; } } diff --git a/packages/tanstackstart-react/test/server/routeParametrization.test.ts b/packages/tanstackstart-react/test/server/routeParametrization.test.ts index bc79beb1bbf2..88fd2643dc9b 100644 --- a/packages/tanstackstart-react/test/server/routeParametrization.test.ts +++ b/packages/tanstackstart-react/test/server/routeParametrization.test.ts @@ -38,4 +38,9 @@ describe('matchUrlToRoutePattern', () => { const patternsNested = ['/users/$id/profile', '/users/$id']; expect(matchUrlToRoutePattern('/users/123/profile', patternsNested)).toBe('/users/$id/profile'); }); + + it('handles URLs with trailing slashes', () => { + expect(matchUrlToRoutePattern('/page-a/', patterns)).toBe('/page-a'); + expect(matchUrlToRoutePattern('/page-b/42/', patterns)).toBe('/page-b/$id'); + }); });