-
-
Notifications
You must be signed in to change notification settings - Fork 1.8k
feat(tanstackstart-react): Add server-side route parametrization #21147
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
nicohrubec
merged 20 commits into
develop
from
feat/tanstack-start-route-parametrization
May 26, 2026
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
564a651
feat(tanstackstart-react): Add server-side route parametrization
nicohrubec 9ccee87
fix: Extract route patterns from fullPaths type union instead of path…
nicohrubec 28c63fa
test: Add E2E test for nested parametrized routes
nicohrubec 51600a3
fix: Handle splat/catch-all routes in URL pattern matching
nicohrubec 85619b3
perf: Cache compiled route pattern regexes across requests
nicohrubec 51020a4
fix: Remove unnecessary caching complexity from route matching
nicohrubec 29a49de
fix: Use escapeStringForRegex from core, deprioritize splat routes in…
nicohrubec aa0ac2e
test: Remove redundant static route matching test
nicohrubec e02a164
test: Strengthen route parametrization tests
nicohrubec 2d1fa35
ref: Remove splat route handling to simplify matching logic
nicohrubec eed3d20
ref: Clean up plugin assembly in sentryTanstackStart
nicohrubec f8dd9ee
docs: Clarify routePatterns plugin JSDoc
nicohrubec ad5b1d6
fix: Fall back to empty array instead of ['/'] when route tree is una…
nicohrubec 756d14d
ref: Move pattern sorting to build time, add setTransactionName
nicohrubec fa5a6b6
ci: trigger CI
nicohrubec 5f4daf7
test: Use toBe instead of toMatchObject in E2E assertions
nicohrubec 3db2379
fix: Support double-quoted paths in routeTree.gen.ts
nicohrubec 17c32af
ci: retrigger
nicohrubec b24b1e7
docs: Add changelog entry for route parametrization
nicohrubec a6f5a11
fix: Strip trailing slashes before route pattern matching
nicohrubec File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
13 changes: 13 additions & 0 deletions
13
dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/api.user.$id.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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' }, | ||
| }); | ||
| }, | ||
| }, | ||
| }, | ||
| }); |
14 changes: 14 additions & 0 deletions
14
dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/param.$id.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div> | ||
| <p id="param-value">Param: {id}</p> | ||
| </div> | ||
| ); | ||
| } |
14 changes: 14 additions & 0 deletions
14
dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/users.$userId.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div> | ||
| <p id="user-id">User: {userId}</p> | ||
| </div> | ||
| ); | ||
| } |
13 changes: 13 additions & 0 deletions
13
dev-packages/e2e-tests/test-applications/tanstackstart-react/src/routes/users.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { Outlet, createFileRoute } from '@tanstack/react-router'; | ||
|
|
||
| export const Route = createFileRoute('/users')({ | ||
| component: UsersLayout, | ||
| }); | ||
|
|
||
| function UsersLayout() { | ||
| return ( | ||
| <div> | ||
| <Outlet /> | ||
| </div> | ||
| ); | ||
| } |
82 changes: 82 additions & 0 deletions
82
...kages/e2e-tests/test-applications/tanstackstart-react/tests/route-parametrization.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| 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 and client 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/') | ||
| ); | ||
| }); | ||
|
|
||
| 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.transaction).toBe('GET /param/$id'); | ||
| expect(serverTx.transaction_info?.source).toBe('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 }) => { | ||
| const serverTxPromise = waitForTransaction('tanstackstart-react', transactionEvent => { | ||
| return ( | ||
| transactionEvent?.contexts?.trace?.op === 'http.server' && | ||
| typeof transactionEvent?.transaction === 'string' && | ||
| transactionEvent.transaction.includes('/users/') | ||
| ); | ||
| }); | ||
|
|
||
| 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.transaction_info?.source).toBe('route'); | ||
|
|
||
| expect(clientTx.transaction).toBe('/users/$userId'); | ||
| expect(clientTx.transaction_info?.source).toBe('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.transaction).toBe('GET /api/user/$id'); | ||
| expect(serverTx.transaction_info?.source).toBe('route'); | ||
| }); |
66 changes: 66 additions & 0 deletions
66
packages/tanstackstart-react/src/server/routeParametrization.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import { ATTR_HTTP_ROUTE } from '@opentelemetry/semantic-conventions'; | ||
| import { | ||
| escapeStringForRegex, | ||
| getActiveSpan, | ||
| getCurrentScope, | ||
| getRootSpan, | ||
| SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, | ||
| spanToJSON, | ||
| updateSpanName, | ||
| } from '@sentry/core'; | ||
|
|
||
| function patternToRegex(pattern: string): RegExp { | ||
| const segments = pattern | ||
| .split('/') | ||
| .map(segment => { | ||
| if (segment.startsWith('$')) { | ||
| return '[^/]+'; | ||
| } | ||
|
nicohrubec marked this conversation as resolved.
|
||
| return escapeStringForRegex(segment); | ||
| }) | ||
| .join('/'); | ||
| return new RegExp(`^${segments}$`); | ||
| } | ||
|
sentry[bot] marked this conversation as resolved.
|
||
|
|
||
| /** | ||
| * 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 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(normalizedPathname)) { | ||
| return pattern; | ||
| } | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| 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; | ||
| } | ||
|
|
||
| const transactionName = `${method} ${matchedPattern}`; | ||
| updateSpanName(rootSpan, transactionName); | ||
| rootSpan.setAttribute(ATTR_HTTP_ROUTE, matchedPattern); | ||
| rootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, 'route'); | ||
| getCurrentScope().setTransactionName(transactionName); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| 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. | ||
| * | ||
| * 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 = ''; | ||
|
|
||
| return { | ||
| name: 'sentry-tanstackstart-route-patterns', | ||
| enforce: 'post', | ||
|
|
||
| configResolved(config) { | ||
| resolvedRoot = config.root || process.cwd(); | ||
| }, | ||
|
|
||
| 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[] = []; | ||
| 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)), | ||
|
nicohrubec marked this conversation as resolved.
|
||
| map: null, | ||
| }; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Extracts full route path patterns from the content of routeTree.gen.ts. | ||
| * | ||
| * 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. | ||
| */ | ||
| 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 pathRegex = /['"]([^'"]+)['"]/g; | ||
| let match; | ||
| while ((match = pathRegex.exec(fullPathsMatch[1] || '')) !== null) { | ||
| if (match[1]) { | ||
| patterns.push(match[1]); | ||
| } | ||
| } | ||
|
|
||
| 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; | ||
| }); | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.