Skip to content

Commit 0d1093d

Browse files
MicahLylelforst
andauthored
feat(react): Add TanStack Router integration (#12095)
Co-authored-by: Luca Forstner <[email protected]>
1 parent bbe7be5 commit 0d1093d

File tree

16 files changed

+1458
-0
lines changed

16 files changed

+1458
-0
lines changed

.github/workflows/build.yml

+1
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,7 @@ jobs:
10171017
'sveltekit',
10181018
'sveltekit-2',
10191019
'sveltekit-2-svelte-5',
1020+
'tanstack-router',
10201021
'generic-ts3.8',
10211022
'node-fastify',
10221023
'node-hapi',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
pnpm-debug.log*
8+
lerna-debug.log*
9+
10+
node_modules
11+
dist
12+
dist-ssr
13+
*.local
14+
15+
# Editor directories and files
16+
.vscode/*
17+
!.vscode/extensions.json
18+
.idea
19+
.DS_Store
20+
*.suo
21+
*.ntvs*
22+
*.njsproj
23+
*.sln
24+
*.sw?
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
@sentry:registry=http://127.0.0.1:4873
2+
@sentry-internal:registry=http://127.0.0.1:4873
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Tanstack Example</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/src/main.tsx"></script>
11+
</body>
12+
</html>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
{
2+
"name": "tanstack-router-e2e-test-application",
3+
"private": true,
4+
"version": "0.0.1",
5+
"type": "module",
6+
"scripts": {
7+
"build": "vite build",
8+
"start": "vite preview",
9+
"test": "playwright test",
10+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
11+
"test:build": "pnpm install && npx playwright install && pnpm build",
12+
"test:assert": "pnpm test"
13+
},
14+
"dependencies": {
15+
"@sentry/react": "latest || *",
16+
"@tanstack/react-router": "1.34.5",
17+
"react": "^18.2.0",
18+
"react-dom": "^18.2.0"
19+
},
20+
"devDependencies": {
21+
"@types/react": "^18.2.66",
22+
"@types/react-dom": "^18.2.22",
23+
"@typescript-eslint/eslint-plugin": "^7.2.0",
24+
"@typescript-eslint/parser": "^7.2.0",
25+
"@vitejs/plugin-react-swc": "^3.5.0",
26+
"typescript": "^5.2.2",
27+
"vite": "^5.2.0",
28+
"@playwright/test": "^1.41.1",
29+
"@sentry-internal/event-proxy-server": "link:../../../event-proxy-server"
30+
},
31+
"volta": {
32+
"extends": "../../package.json"
33+
}
34+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { PlaywrightTestConfig } from '@playwright/test';
2+
import { devices } from '@playwright/test';
3+
4+
const appPort = 3030;
5+
const eventProxyPort = 3031;
6+
7+
/**
8+
* See https://playwright.dev/docs/test-configuration.
9+
*/
10+
const config: PlaywrightTestConfig = {
11+
testDir: './tests',
12+
/* Maximum time one test can run for. */
13+
timeout: 150_000,
14+
expect: {
15+
/**
16+
* Maximum time expect() should wait for the condition to be met.
17+
* For example in `await expect(locator).toHaveText();`
18+
*/
19+
timeout: 5000,
20+
},
21+
/* Run tests in files in parallel */
22+
fullyParallel: true,
23+
/* Fail the build on CI if you accidentally left test.only in the source code. */
24+
forbidOnly: !!process.env.CI,
25+
/* Retry on CI only */
26+
retries: 0,
27+
/* Opt out of parallel tests on CI. */
28+
workers: 1,
29+
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
30+
reporter: 'list',
31+
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
32+
use: {
33+
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
34+
actionTimeout: 0,
35+
36+
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
37+
trace: 'on-first-retry',
38+
39+
baseURL: `http://localhost:${appPort}`,
40+
},
41+
42+
/* Configure projects for major browsers */
43+
projects: [
44+
{
45+
name: 'chromium',
46+
use: {
47+
...devices['Desktop Chrome'],
48+
},
49+
},
50+
],
51+
52+
/* Run your local dev server before starting the tests */
53+
54+
webServer: [
55+
{
56+
command: 'node start-event-proxy.mjs',
57+
port: eventProxyPort,
58+
},
59+
{
60+
command: 'pnpm start',
61+
port: appPort,
62+
},
63+
],
64+
};
65+
66+
export default config;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import * as Sentry from '@sentry/react';
2+
import { Link, Outlet, RouterProvider, createRootRoute, createRoute, createRouter } from '@tanstack/react-router';
3+
import { StrictMode } from 'react';
4+
import ReactDOM from 'react-dom/client';
5+
6+
const rootRoute = createRootRoute({
7+
component: () => (
8+
<>
9+
<ul>
10+
<li>
11+
<Link to="/">Home</Link>
12+
</li>
13+
<li>
14+
<Link to="/posts/$postId" params={{ postId: '1' }}>
15+
Post 1
16+
</Link>
17+
</li>
18+
<li>
19+
<Link to="/posts/$postId" params={{ postId: '2' }} id="nav-link">
20+
Post 2
21+
</Link>
22+
</li>
23+
</ul>
24+
<hr />
25+
<Outlet />
26+
</>
27+
),
28+
});
29+
30+
const indexRoute = createRoute({
31+
getParentRoute: () => rootRoute,
32+
path: '/',
33+
component: function Index() {
34+
return (
35+
<div>
36+
<h3>Welcome Home!</h3>
37+
</div>
38+
);
39+
},
40+
});
41+
42+
const postsRoute = createRoute({
43+
getParentRoute: () => rootRoute,
44+
path: 'posts/',
45+
});
46+
47+
const postIdRoute = createRoute({
48+
getParentRoute: () => postsRoute,
49+
path: '$postId',
50+
shouldReload() {
51+
return true;
52+
},
53+
loader: ({ params }) => {
54+
return Sentry.startSpan({ name: `loading-post-${params.postId}` }, async () => {
55+
await new Promise(resolve => setTimeout(resolve, 1000));
56+
});
57+
},
58+
component: function Post() {
59+
const { postId } = postIdRoute.useParams();
60+
return <div>Post ID: {postId}</div>;
61+
},
62+
});
63+
64+
const routeTree = rootRoute.addChildren([indexRoute, postsRoute.addChildren([postIdRoute])]);
65+
66+
const router = createRouter({ routeTree });
67+
68+
declare const __APP_DSN__: string;
69+
70+
Sentry.init({
71+
environment: 'qa', // dynamic sampling bias to keep transactions
72+
dsn: __APP_DSN__,
73+
integrations: [Sentry.tanstackRouterBrowserTracingIntegration(router)],
74+
// We recommend adjusting this value in production, or using tracesSampler
75+
// for finer control
76+
tracesSampleRate: 1.0,
77+
release: 'e2e-test',
78+
tunnel: 'http://localhost:3031/', // proxy server
79+
80+
// Always capture replays, so we can test this properly
81+
replaysSessionSampleRate: 1.0,
82+
replaysOnErrorSampleRate: 0.0,
83+
});
84+
85+
declare module '@tanstack/react-router' {
86+
interface Register {
87+
router: typeof router;
88+
}
89+
}
90+
91+
const rootElement = document.getElementById('root')!;
92+
if (!rootElement.innerHTML) {
93+
const root = ReactDOM.createRoot(rootElement);
94+
root.render(
95+
<StrictMode>
96+
<RouterProvider router={router} />
97+
</StrictMode>,
98+
);
99+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { startEventProxyServer } from '@sentry-internal/event-proxy-server';
2+
3+
startEventProxyServer({
4+
port: 3031,
5+
proxyServerName: 'tanstack-router',
6+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { expect, test } from '@playwright/test';
2+
import { waitForTransaction } from '@sentry-internal/event-proxy-server';
3+
4+
test('sends a pageload transaction with a parameterized URL', async ({ page }) => {
5+
const transactionPromise = waitForTransaction('tanstack-router', async transactionEvent => {
6+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
7+
});
8+
9+
await page.goto(`/posts/456`);
10+
11+
const rootSpan = await transactionPromise;
12+
13+
expect(rootSpan).toMatchObject({
14+
contexts: {
15+
trace: {
16+
data: {
17+
'sentry.source': 'route',
18+
'sentry.origin': 'auto.pageload.react.tanstack_router',
19+
'sentry.op': 'pageload',
20+
'url.path.params.postId': '456',
21+
},
22+
op: 'pageload',
23+
origin: 'auto.pageload.react.tanstack_router',
24+
},
25+
},
26+
transaction: '/posts/$postId',
27+
transaction_info: {
28+
source: 'route',
29+
},
30+
spans: expect.arrayContaining([
31+
expect.objectContaining({
32+
description: 'loading-post-456',
33+
}),
34+
]),
35+
});
36+
});
37+
38+
test('sends a navigation transaction with a parameterized URL', async ({ page }) => {
39+
const pageloadTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => {
40+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload';
41+
});
42+
43+
const navigationTxnPromise = waitForTransaction('tanstack-router', async transactionEvent => {
44+
return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation';
45+
});
46+
47+
await page.goto(`/`);
48+
await pageloadTxnPromise;
49+
50+
await page.waitForTimeout(5000);
51+
await page.locator('#nav-link').click();
52+
53+
const navigationTxn = await navigationTxnPromise;
54+
55+
expect(navigationTxn).toMatchObject({
56+
contexts: {
57+
trace: {
58+
data: {
59+
'sentry.source': 'route',
60+
'sentry.origin': 'auto.navigation.react.tanstack_router',
61+
'sentry.op': 'navigation',
62+
'url.path.params.postId': '2',
63+
},
64+
op: 'navigation',
65+
origin: 'auto.navigation.react.tanstack_router',
66+
},
67+
},
68+
transaction: '/posts/$postId',
69+
transaction_info: {
70+
source: 'route',
71+
},
72+
spans: expect.arrayContaining([
73+
expect.objectContaining({
74+
description: 'loading-post-2',
75+
}),
76+
]),
77+
});
78+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"useDefineForClassFields": true,
5+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
6+
"module": "ESNext",
7+
"skipLibCheck": true,
8+
9+
/* Bundler mode */
10+
"moduleResolution": "bundler",
11+
"allowImportingTsExtensions": true,
12+
"resolveJsonModule": true,
13+
"isolatedModules": true,
14+
"noEmit": true,
15+
"jsx": "react-jsx",
16+
17+
/* Linting */
18+
"strict": true,
19+
"noUnusedLocals": true,
20+
"noUnusedParameters": true,
21+
"noFallthroughCasesInSwitch": true
22+
},
23+
"include": ["src"],
24+
"references": [{ "path": "./tsconfig.node.json" }]
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"compilerOptions": {
3+
"composite": true,
4+
"skipLibCheck": true,
5+
"module": "ESNext",
6+
"moduleResolution": "bundler",
7+
"allowSyntheticDefaultImports": true,
8+
"strict": true
9+
},
10+
"include": ["vite.config.ts"]
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import react from '@vitejs/plugin-react-swc';
2+
import { defineConfig } from 'vite';
3+
4+
// https://vitejs.dev/config/
5+
export default defineConfig({
6+
plugins: [react()],
7+
define: {
8+
__APP_DSN__: JSON.stringify(process.env.E2E_TEST_DSN),
9+
},
10+
preview: {
11+
port: 3030,
12+
},
13+
});

0 commit comments

Comments
 (0)