diff --git a/MIGRATION.md b/MIGRATION.md index b653c67d9be9..6d4b806333b4 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1303,6 +1303,41 @@ Affected SDKs: `@sentry/remix`. The plugin now also applies the build-time instrumentation transform. If you added `sentryOrchestrionPlugin()` from `@sentry/server-utils/orchestrion/vite` to your Vite config manually, remove it. Opt out with `sentryRemixVitePlugin({ buildTimeInstrumentation: false })`. +### React: Simpler React Router setup via `@sentry/react/react-router` + +Affected SDKs: `@sentry/react`. + +`@sentry/react` gained a new `@sentry/react/react-router` entry point that pulls the required React Router hooks (`useLocation`, `useNavigationType`, `matchRoutes`, `createRoutesFromChildren`) from `react-router` for you, so you no longer have to thread them through `reactRouterBrowserTracingIntegration` yourself: + +```diff +- import * as Sentry from '@sentry/react'; +- import { useEffect } from 'react'; +- import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from 'react-router'; ++ import * as Sentry from '@sentry/react'; ++ import { reactRouterBrowserTracingIntegration } from '@sentry/react/react-router'; + + Sentry.init({ + integrations: [ +- Sentry.reactRouterBrowserTracingIntegration({ +- useEffect, +- useLocation, +- useNavigationType, +- createRoutesFromChildren, +- matchRoutes, +- }), ++ reactRouterBrowserTracingIntegration(), + ], + }); +``` + +The `wrapReactRouterRouting`, `wrapUseRoutes`, `wrapCreateBrowserRouter` and `wrapCreateMemoryRouter` helpers are re-exported from `@sentry/react/react-router` as well. + +This entry requires `react-router` to be resolvable — it is declared as an optional peer dependency and supports React Router v6, v7 and v8. If you are on React Router v6 with only `react-router-dom` installed, either add `react-router` as a dependency or keep importing `reactRouterBrowserTracingIntegration` from `@sentry/react` and pass the hooks explicitly. + +The existing `@sentry/react` API is unchanged and keeps working; passing the hooks there is now optional too (`useEffect` in particular is no longer used and can be omitted). + +Additionally — for **every** `@sentry/react` routing setup, not just the new entry — the order in which you add the browser tracing integration and wrap your routes no longer matters. + ## 3. Removed APIs ### `@sentry/core` / All SDKs diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/.gitignore @@ -0,0 +1,29 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/index.html b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/index.html new file mode 100644 index 000000000000..e4b78eae1230 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/package.json b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/package.json new file mode 100644 index 000000000000..244f3233a1d2 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/package.json @@ -0,0 +1,52 @@ +{ + "name": "react-router-6-router-entry", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "file:../../packed/sentry-react-packed.tgz", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "^6.30.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "vite": "^6.4.2", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.0.0" + }, + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "typecheck": "tsc --noEmit", + "test:build": "pnpm install && pnpm build", + "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && pnpm build", + "test:assert": "pnpm typecheck && pnpm test" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/playwright.config.mjs new file mode 100644 index 000000000000..7fda76df18ae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm preview --port 3030`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/globals.d.ts @@ -0,0 +1,5 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; + sentryReplayId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/main.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/main.tsx new file mode 100644 index 000000000000..4fa587170992 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/main.tsx @@ -0,0 +1,35 @@ +import * as Sentry from '@sentry/react'; +// The `@sentry/react/react-router` entry pulls the required router hooks from `react-router` itself, so +// `reactRouterBrowserTracingIntegration()` needs no arguments. On React Router v6 the DOM bindings +// (`BrowserRouter`, `Link`) come from `react-router-dom`. Note this app depends only on +// `react-router-dom` (not `react-router` directly) - the entry's `react-router` import still resolves +// via the copy `react-router-dom` pulls in, which is the common real-world v6 setup. +import { reactRouterBrowserTracingIntegration, wrapReactRouterRouting } from '@sentry/react/react-router'; +import * as React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import Index from './pages/Index'; +import Products from './pages/Products'; +import User from './pages/User'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, + integrations: [reactRouterBrowserTracingIntegration()], + tracesSampleRate: 1.0, + release: 'e2e-test', + tunnel: 'http://localhost:3031', +}); + +const SentryRoutes = wrapReactRouterRouting(Routes); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render( + + + } /> + } /> + } /> + + , +); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/pages/Index.tsx new file mode 100644 index 000000000000..9a5b5483354d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/pages/Index.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { Link } from 'react-router-dom'; + +const Index = () => { + return ( + <> + { + throw new Error('I am an error!'); + }} + /> + + navigate + + + products + + + ); +}; + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/pages/Products.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/pages/Products.tsx new file mode 100644 index 000000000000..fb0768b264ff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/pages/Products.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; + +const Products = () => { + // Fired on mount, i.e. while navigating to /products. This mirrors a typical + // route component that loads its data in an effect. The request is same-origin, + // so the SDK attaches `sentry-trace`/`baggage` headers by default. + React.useEffect(() => { + fetch('/api/products').catch(() => { + // ignore network errors in the test environment + }); + }, []); + + return
Products
; +}; + +export default Products; diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/pages/User.tsx new file mode 100644 index 000000000000..671455a92fff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/src/pages/User.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const User = () => { + return

I am a blank page :)

; +}; + +export default User; diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/start-event-proxy.mjs new file mode 100644 index 000000000000..4163849952c9 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-6-router-entry', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/errors.test.ts new file mode 100644 index 000000000000..569ad71e1483 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ page }) => { + const errorEventPromise = waitForError('react-router-6-router-entry', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.request).toEqual({ + headers: expect.any(Object), + url: 'http://localhost:3030/', + }); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); + +test('Sets correct transactionName', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const errorEventPromise = waitForError('react-router-6-router-entry', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + // Only capture error once the pageload span was sent + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/navigation-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/navigation-trace-propagation.test.ts new file mode 100644 index 000000000000..59f49984caf6 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/navigation-trace-propagation.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('propagates the navigation trace (not the stale pageload trace) for a fetch in a route mount effect', async ({ + page, +}) => { + // Intercept the /products data fetch and capture the tracing header the SDK attached. + let productsRequestSentryTrace: string | undefined; + await page.route('**/api/products', async route => { + productsRequestSentryTrace = route.request().headers()['sentry-trace']; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '[]', + }); + }); + + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-router-entry', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/products'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + await page.locator('id=navigation-products').click(); + const navigationSpan = await navigationSpanPromise; + + const pageloadTraceId = pageloadSpan.trace_id; + const navigationTraceId = navigationSpan.trace_id; + const propagatedTraceId = productsRequestSentryTrace?.split('-')[0]; + + expect(pageloadTraceId).toBeDefined(); + expect(navigationTraceId).toBeDefined(); + expect(propagatedTraceId).toBeDefined(); + expect(navigationTraceId).not.toEqual(pageloadTraceId); + + // The fetch fired on /products must carry the navigation trace, not the stale pageload trace. + expect(propagatedTraceId).toEqual(navigationTraceId); + expect(propagatedTraceId).not.toEqual(pageloadTraceId); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/spans.test.ts new file mode 100644 index 000000000000..7d1b81748c29 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tests/spans.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized route name (no hooks passed to the integration)', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-6-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/user/5`); + + const span = await spanPromise; + + expect(span.name).toBe('/user/:id'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized route name', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-6-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-6-router-entry', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tsconfig.json new file mode 100644 index 000000000000..bd5b8e2eeb98 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react", + "types": ["vite/client"] + }, + "include": ["src", "tests"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/vite.config.ts new file mode 100644 index 000000000000..63c2c4317df7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-6-router-entry/vite.config.ts @@ -0,0 +1,8 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + envPrefix: 'PUBLIC_', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/.gitignore @@ -0,0 +1,29 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/index.html b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/index.html new file mode 100644 index 000000000000..e4b78eae1230 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/package.json new file mode 100644 index 000000000000..d1aea1d5bb67 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/package.json @@ -0,0 +1,52 @@ +{ + "name": "react-router-7-router-entry", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "file:../../packed/sentry-react-packed.tgz", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router": "^7.13.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "vite": "^6.4.2", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "~5.0.0" + }, + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "typecheck": "tsc --noEmit", + "test:build": "pnpm install && pnpm build", + "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && pnpm build", + "test:assert": "pnpm typecheck && pnpm test" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/playwright.config.mjs new file mode 100644 index 000000000000..7fda76df18ae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm preview --port 3030`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/globals.d.ts @@ -0,0 +1,5 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; + sentryReplayId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/main.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/main.tsx new file mode 100644 index 000000000000..7ca3058da608 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/main.tsx @@ -0,0 +1,31 @@ +import * as Sentry from '@sentry/react'; +import { reactRouterBrowserTracingIntegration } from '@sentry/react/react-router'; +import * as React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter, Route } from 'react-router'; +// Importing this evaluates `sentry-routes.tsx` (which calls `wrapReactRouterRouting`) BEFORE the +// `Sentry.init()` call below runs - i.e. the routes are wrapped before Sentry is initialized. +import { SentryRoutes } from './sentry-routes'; +import Index from './pages/Index'; +import Products from './pages/Products'; +import User from './pages/User'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, + integrations: [reactRouterBrowserTracingIntegration()], + tracesSampleRate: 1.0, + release: 'e2e-test', + tunnel: 'http://localhost:3031', +}); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render( + + + } /> + } /> + } /> + + , +); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/pages/Index.tsx new file mode 100644 index 000000000000..7a6832307834 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/pages/Index.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { Link } from 'react-router'; + +const Index = () => { + return ( + <> + { + throw new Error('I am an error!'); + }} + /> + + navigate + + + products + + + ); +}; + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/pages/Products.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/pages/Products.tsx new file mode 100644 index 000000000000..fb0768b264ff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/pages/Products.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; + +const Products = () => { + // Fired on mount, i.e. while navigating to /products. This mirrors a typical + // route component that loads its data in an effect. The request is same-origin, + // so the SDK attaches `sentry-trace`/`baggage` headers by default. + React.useEffect(() => { + fetch('/api/products').catch(() => { + // ignore network errors in the test environment + }); + }, []); + + return
Products
; +}; + +export default Products; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/pages/User.tsx new file mode 100644 index 000000000000..671455a92fff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/pages/User.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const User = () => { + return

I am a blank page :)

; +}; + +export default User; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/sentry-routes.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/sentry-routes.tsx new file mode 100644 index 000000000000..a0504ffd6bc4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/src/sentry-routes.tsx @@ -0,0 +1,8 @@ +import { wrapReactRouterRouting } from '@sentry/react/react-router'; +import { Routes } from 'react-router'; + +// `wrapReactRouterRouting` runs here, at this module's evaluation time. Because `main.tsx` imports +// this module, that happens BEFORE `main.tsx` calls `Sentry.init()`. This deliberately exercises the +// order-independence of the setup: wrapping the routes before Sentry is initialized still instruments +// navigations once the app renders (the wrapper reads its config at render time, after init). +export const SentryRoutes = wrapReactRouterRouting(Routes); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/start-event-proxy.mjs new file mode 100644 index 000000000000..f8424f618609 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-router-entry', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/errors.test.ts new file mode 100644 index 000000000000..41e7e7dbe3aa --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ page }) => { + const errorEventPromise = waitForError('react-router-7-router-entry', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.request).toEqual({ + headers: expect.any(Object), + url: 'http://localhost:3030/', + }); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); + +test('Sets correct transactionName', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const errorEventPromise = waitForError('react-router-7-router-entry', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + // Only capture error once the pageload span was sent + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/navigation-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/navigation-trace-propagation.test.ts new file mode 100644 index 000000000000..3c2bb4cc35da --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/navigation-trace-propagation.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('propagates the navigation trace (not the stale pageload trace) for a fetch in a route mount effect', async ({ + page, +}) => { + // Intercept the /products data fetch and capture the tracing header the SDK attached. + let productsRequestSentryTrace: string | undefined; + await page.route('**/api/products', async route => { + productsRequestSentryTrace = route.request().headers()['sentry-trace']; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '[]', + }); + }); + + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-7-router-entry', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/products'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + await page.locator('id=navigation-products').click(); + const navigationSpan = await navigationSpanPromise; + + const pageloadTraceId = pageloadSpan.trace_id; + const navigationTraceId = navigationSpan.trace_id; + const propagatedTraceId = productsRequestSentryTrace?.split('-')[0]; + + expect(pageloadTraceId).toBeDefined(); + expect(navigationTraceId).toBeDefined(); + expect(propagatedTraceId).toBeDefined(); + expect(navigationTraceId).not.toEqual(pageloadTraceId); + + // The fetch fired on /products must carry the navigation trace, not the stale pageload trace. + expect(propagatedTraceId).toEqual(navigationTraceId); + expect(propagatedTraceId).not.toEqual(pageloadTraceId); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/spans.test.ts new file mode 100644 index 000000000000..fdc6ce54c3a4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tests/spans.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +// This app wraps its routes (in `src/sentry-routes.tsx`) BEFORE `Sentry.init()` runs. That these +// pageload/navigation spans are still emitted with parameterized route names proves the +// `@sentry/react/react-router` setup is order-independent w.r.t. init - see MIGRATION.md. + +test('sends a pageload span with a parameterized route name (no hooks passed to the integration)', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-7-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/user/5`); + + const span = await spanPromise; + + expect(span.name).toBe('/user/:id'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized route name', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-7-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-7-router-entry', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tsconfig.json new file mode 100644 index 000000000000..bd5b8e2eeb98 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react", + "types": ["vite/client"] + }, + "include": ["src", "tests"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/vite.config.ts new file mode 100644 index 000000000000..63c2c4317df7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-router-entry/vite.config.ts @@ -0,0 +1,8 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + envPrefix: 'PUBLIC_', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/.gitignore @@ -0,0 +1,29 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +/test-results/ +/playwright-report/ +/playwright/.cache/ + +!*.d.ts diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/index.html b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/index.html new file mode 100644 index 000000000000..e4b78eae1230 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/package.json b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/package.json new file mode 100644 index 000000000000..ecd3dd54fcda --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/package.json @@ -0,0 +1,52 @@ +{ + "name": "react-router-8-router-entry", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "file:../../packed/sentry-react-packed.tgz", + "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", + "react": "19.2.7", + "react-dom": "19.2.7", + "react-router": "^8.0.0" + }, + "devDependencies": { + "@playwright/test": "~1.56.0", + "@sentry-internal/test-utils": "link:../../../test-utils", + "vite": "^7.3.2", + "@vitejs/plugin-react": "^5.2.0", + "typescript": "^5.6.3" + }, + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "typecheck": "tsc --noEmit", + "test:build": "pnpm install && pnpm build", + "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && pnpm build", + "test:assert": "pnpm typecheck && pnpm test" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "volta": { + "extends": "../../package.json" + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/playwright.config.mjs new file mode 100644 index 000000000000..7fda76df18ae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/playwright.config.mjs @@ -0,0 +1,8 @@ +import { getPlaywrightConfig } from '@sentry-internal/test-utils'; + +const config = getPlaywrightConfig({ + startCommand: `pnpm preview --port 3030`, + port: 3030, +}); + +export default config; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/globals.d.ts @@ -0,0 +1,5 @@ +interface Window { + recordedTransactions?: string[]; + capturedExceptionId?: string; + sentryReplayId?: string; +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/main.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/main.tsx new file mode 100644 index 000000000000..bdae72f76ba1 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/main.tsx @@ -0,0 +1,33 @@ +import * as Sentry from '@sentry/react'; +// The `@sentry/react/react-router` entry pulls the required router hooks from `react-router` itself, so +// `reactRouterBrowserTracingIntegration()` needs no arguments. On React Router v8 everything +// (`BrowserRouter`, `Link`, `Routes`, `Route`) is exported from `react-router`. +import { reactRouterBrowserTracingIntegration, wrapReactRouterRouting } from '@sentry/react/react-router'; +import * as React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter, Route, Routes } from 'react-router'; +import Index from './pages/Index'; +import Products from './pages/Products'; +import User from './pages/User'; + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, + integrations: [reactRouterBrowserTracingIntegration()], + tracesSampleRate: 1.0, + release: 'e2e-test', + tunnel: 'http://localhost:3031', +}); + +const SentryRoutes = wrapReactRouterRouting(Routes); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render( + + + } /> + } /> + } /> + + , +); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/pages/Index.tsx new file mode 100644 index 000000000000..7a6832307834 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/pages/Index.tsx @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { Link } from 'react-router'; + +const Index = () => { + return ( + <> + { + throw new Error('I am an error!'); + }} + /> + + navigate + + + products + + + ); +}; + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/pages/Products.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/pages/Products.tsx new file mode 100644 index 000000000000..fb0768b264ff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/pages/Products.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; + +const Products = () => { + // Fired on mount, i.e. while navigating to /products. This mirrors a typical + // route component that loads its data in an effect. The request is same-origin, + // so the SDK attaches `sentry-trace`/`baggage` headers by default. + React.useEffect(() => { + fetch('/api/products').catch(() => { + // ignore network errors in the test environment + }); + }, []); + + return
Products
; +}; + +export default Products; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/pages/User.tsx new file mode 100644 index 000000000000..671455a92fff --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/src/pages/User.tsx @@ -0,0 +1,7 @@ +import * as React from 'react'; + +const User = () => { + return

I am a blank page :)

; +}; + +export default User; diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/start-event-proxy.mjs new file mode 100644 index 000000000000..26e08fa7faf0 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-8-router-entry', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/errors.test.ts new file mode 100644 index 000000000000..2514d8136f83 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForError, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ page }) => { + const errorEventPromise = waitForError('react-router-8-router-entry', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.request).toEqual({ + headers: expect.any(Object), + url: 'http://localhost:3030/', + }); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: expect.any(String), + span_id: expect.any(String), + }); +}); + +test('Sets correct transactionName', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const errorEventPromise = waitForError('react-router-8-router-entry', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + // Only capture error once the pageload span was sent + const exceptionButton = page.locator('id=exception-button'); + await exceptionButton.click(); + + const errorEvent = await errorEventPromise; + + expect(errorEvent.exception?.values).toHaveLength(1); + expect(errorEvent.exception?.values?.[0]?.value).toBe('I am an error!'); + + expect(errorEvent.transaction).toEqual('/'); + + expect(errorEvent.contexts?.trace).toEqual({ + trace_id: pageloadSpan.trace_id, + span_id: expect.not.stringContaining(pageloadSpan.span_id), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/navigation-trace-propagation.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/navigation-trace-propagation.test.ts new file mode 100644 index 000000000000..8d3778571e3e --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/navigation-trace-propagation.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('propagates the navigation trace (not the stale pageload trace) for a fetch in a route mount effect', async ({ + page, +}) => { + // Intercept the /products data fetch and capture the tracing header the SDK attached. + let productsRequestSentryTrace: string | undefined; + await page.route('**/api/products', async route => { + productsRequestSentryTrace = route.request().headers()['sentry-trace']; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: '[]', + }); + }); + + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-8-router-entry', span => { + return getSpanOp(span) === 'navigation' && span.is_segment && span.name === '/products'; + }); + + await page.goto('/'); + const pageloadSpan = await pageloadSpanPromise; + + await page.locator('id=navigation-products').click(); + const navigationSpan = await navigationSpanPromise; + + const pageloadTraceId = pageloadSpan.trace_id; + const navigationTraceId = navigationSpan.trace_id; + const propagatedTraceId = productsRequestSentryTrace?.split('-')[0]; + + expect(pageloadTraceId).toBeDefined(); + expect(navigationTraceId).toBeDefined(); + expect(propagatedTraceId).toBeDefined(); + expect(navigationTraceId).not.toEqual(pageloadTraceId); + + // The fetch fired on /products must carry the navigation trace, not the stale pageload trace. + expect(propagatedTraceId).toEqual(navigationTraceId); + expect(propagatedTraceId).not.toEqual(pageloadTraceId); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/spans.test.ts b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/spans.test.ts new file mode 100644 index 000000000000..f1b488bd21a7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tests/spans.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from '@playwright/test'; +import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils'; + +test('sends a pageload span with a parameterized route name (no hooks passed to the integration)', async ({ page }) => { + const spanPromise = waitForStreamedSpan('react-router-8-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + await page.goto(`/user/5`); + + const span = await spanPromise; + + expect(span.name).toBe('/user/:id'); + expect(span.attributes).toMatchObject({ + 'sentry.op': { value: 'pageload', type: 'string' }, + 'sentry.origin': { value: 'auto.pageload.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); + +test('sends a navigation span with a parameterized route name', async ({ page }) => { + const pageloadSpanPromise = waitForStreamedSpan('react-router-8-router-entry', span => { + return getSpanOp(span) === 'pageload' && span.is_segment; + }); + + const navigationSpanPromise = waitForStreamedSpan('react-router-8-router-entry', span => { + return getSpanOp(span) === 'navigation' && span.is_segment; + }); + + await page.goto(`/`); + await pageloadSpanPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationSpan] = await Promise.all([linkElement.click(), navigationSpanPromise]); + + expect(navigationSpan.name).toBe('/user/:id'); + expect(navigationSpan.attributes).toMatchObject({ + 'sentry.op': { value: 'navigation', type: 'string' }, + 'sentry.origin': { value: 'auto.navigation.react.reactrouter', type: 'string' }, + 'sentry.segment.name.source': { value: 'route', type: 'string' }, + 'url.template': { value: '/user/:id', type: 'string' }, + 'url.path': { value: '/user/5', type: 'string' }, + 'url.full': { value: expect.stringMatching(/^https?:\/\/localhost:\d+\/user\/5$/), type: 'string' }, + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tsconfig.json new file mode 100644 index 000000000000..bd5b8e2eeb98 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2018", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react", + "types": ["vite/client"] + }, + "include": ["src", "tests"] +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/vite.config.ts new file mode 100644 index 000000000000..63c2c4317df7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-8-router-entry/vite.config.ts @@ -0,0 +1,8 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + envPrefix: 'PUBLIC_', +}); diff --git a/packages/react/package.json b/packages/react/package.json index 6718f491759d..02ace40cdc28 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -30,6 +30,27 @@ "types": "./build/types/index.d.ts", "default": "./build/cjs/index.js" } + }, + "./react-router": { + "react-native": { + "types": "./build/types/react-router.d.ts", + "default": "./build/esm/react-router.js" + }, + "import": { + "types": "./build/types/react-router.d.ts", + "default": "./build/esm/react-router.js" + }, + "require": { + "types": "./build/types/react-router.d.ts", + "default": "./build/cjs/react-router.js" + } + } + }, + "typesVersions": { + "*": { + "react-router": [ + "build/types/react-router.d.ts" + ] } }, "publishConfig": { @@ -41,7 +62,13 @@ "@sentry/conventions": "^0.20.0" }, "peerDependencies": { - "react": "17.x || 18.x || 19.x" + "react": "17.x || 18.x || 19.x", + "react-router": "6.x || 7.x || 8.x" + }, + "peerDependenciesMeta": { + "react-router": { + "optional": true + } }, "devDependencies": { "@testing-library/react": "^15.0.5", @@ -56,6 +83,7 @@ "history-5": "npm:history@4.9.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-router": "^7.18.3", "react-router-3": "npm:react-router@3.2.0", "react-router-4": "npm:react-router@4.1.0", "react-router-5": "npm:react-router@5.3.4", @@ -71,7 +99,7 @@ "build:dev:watch": "yarn build:watch", "build:transpile:watch": "rollup -c rollup.npm.config.mjs --watch", "build:tarball": "npm pack", - "circularDepCheck": "madge --circular src/index.ts", + "circularDepCheck": "madge --circular src/index.ts && madge --circular src/react-router.ts", "clean": "rimraf build coverage sentry-react-*.tgz", "lint:fix": "oxlint . --fix --type-aware", "lint": "oxlint . --type-aware", diff --git a/packages/react/rollup.npm.config.mjs b/packages/react/rollup.npm.config.mjs index 66c3b16aba58..2692df6c8fee 100644 --- a/packages/react/rollup.npm.config.mjs +++ b/packages/react/rollup.npm.config.mjs @@ -6,6 +6,7 @@ import { makeBaseNPMConfig, makeNPMConfigVariants } from '@sentry-internal/rollu // https://legacy.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html export default makeNPMConfigVariants( makeBaseNPMConfig({ + entrypoints: ['src/index.ts', 'src/react-router.ts'], packageSpecificConfig: { external: ['react', 'react/jsx-runtime'], }, diff --git a/packages/react/src/react-router.ts b/packages/react/src/react-router.ts new file mode 100644 index 000000000000..5519f1850e60 --- /dev/null +++ b/packages/react/src/react-router.ts @@ -0,0 +1,48 @@ +import type { browserTracingIntegration } from '@sentry/browser'; +import type { Integration } from '@sentry/core'; +import { createRoutesFromChildren, matchRoutes, useLocation, useNavigationType } from 'react-router'; +import type { ReactRouterOptions } from './reactrouter-compat-utils'; +import { reactRouterBrowserTracingIntegration as reactRouterBrowserTracingIntegrationBase } from './reactrouter.compat'; + +// The routing wrappers (`wrapReactRouterRouting`, `wrapUseRoutes`, `wrapCreateBrowserRouter`, +// `wrapCreateMemoryRouter`) do not need the hooks - they read the config the integration below stored on +// the client - so they are re-exported unchanged from the main entry point. +export { + wrapReactRouterRouting, + wrapCreateBrowserRouter, + wrapCreateMemoryRouter, + wrapUseRoutes, +} from './reactrouter.compat'; + +type BrowserTracingOptions = Parameters[0]; + +/** + * A browser tracing integration for React Router v6, v7 and v8. + * + * Unlike {@link reactRouterBrowserTracingIntegration} exported from `@sentry/react`, this variant pulls the + * required router hooks (`useLocation`, `useNavigationType`, `createRoutesFromChildren` and `matchRoutes`) + * directly from `react-router`, so you don't have to pass them in: + * + * ```ts + * import { reactRouterBrowserTracingIntegration } from '@sentry/react/react-router'; + * + * Sentry.init({ integrations: [reactRouterBrowserTracingIntegration()] }); + * ``` + * + * Any of the hooks can still be overridden via `options` (e.g. to supply the `react-router-dom` versions in v6). + * + * This requires `react-router` to be resolvable (it is declared as an optional peer dependency). If you are on + * React Router v6 with only `react-router-dom` installed, either add `react-router` as a dependency or import + * `reactRouterBrowserTracingIntegration` from `@sentry/react` and pass the hooks explicitly. + */ +export function reactRouterBrowserTracingIntegration( + options: BrowserTracingOptions & Partial = {}, +): Integration { + return reactRouterBrowserTracingIntegrationBase({ + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + ...options, + }); +} diff --git a/packages/react/src/reactrouter-compat-utils/index.ts b/packages/react/src/reactrouter-compat-utils/index.ts index 968abd9ecae6..76b01c413615 100644 --- a/packages/react/src/reactrouter-compat-utils/index.ts +++ b/packages/react/src/reactrouter-compat-utils/index.ts @@ -18,7 +18,6 @@ export { export { resolveRouteNameAndSource, getNormalizedName, - initializeRouterUtils, locationIsInsideDescendantRoute, prefixWithSlash, rebuildRoutePathFromAllRoutes, diff --git a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx index 954ee16b30f3..a62598c77b14 100644 --- a/packages/react/src/reactrouter-compat-utils/instrumentation.tsx +++ b/packages/react/src/reactrouter-compat-utils/instrumentation.tsx @@ -12,6 +12,7 @@ import type { Client, Integration, Span } from '@sentry/core'; import { addNonEnumerableProperty, debug, + extendIntegration, getClient, getCurrentScope, hasSpanStreamingEnabled, @@ -30,6 +31,7 @@ import type { CreateRoutesFromChildren, Location, MatchRoutes, + ReactRouterConfig, RouteMatch, RouteObject, Router, @@ -43,7 +45,6 @@ import { checkRouteForAsyncHandler } from './lazy-routes'; import { clearNavigationContext, getActiveRootSpan, - initializeRouterUtils, resolveRouteNameAndSource, setNavigationContext, transactionNameHasWildcard, @@ -51,18 +52,11 @@ import { import { SENTRY_SEGMENT_NAME_SOURCE, SENTRY_OP, URL_TEMPLATE } from '@sentry/conventions/attributes'; import { NAVIGATION, PAGELOAD } from '@sentry/conventions/op'; -let _useEffect: UseEffect; -let _useLocation: UseLocation; -let _useNavigationType: UseNavigationType; -let _createRoutesFromChildren: CreateRoutesFromChildren; -let _matchRoutes: MatchRoutes; +const reactRouterConfigByClient = new WeakMap(); -let _enableAsyncRouteHandlers: boolean = false; -let _lazyRouteTimeout = 3000; -let _lazyRouteManifest: string[] | undefined; -let _basename: string = ''; - -const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); +function getRouterConfig(client: Client | undefined): ReactRouterConfig | undefined { + return client ? reactRouterConfigByClient.get(client) : undefined; +} // Detect navigations in a layout effect so the navigation trace is set up before child route components' // passive mount effects fire requests (else they propagate the stale pageload trace). @@ -175,7 +169,11 @@ export function shouldSkipNavigation( } export interface ReactRouterOptions { - useEffect: UseEffect; + /** + * @deprecated This is no longer used - the instrumentation relies on React's own effect hook. It is kept + * as an optional field for backwards compatibility and can safely be omitted. + */ + useEffect?: UseEffect; useLocation: UseLocation; useNavigationType: UseNavigationType; createRoutesFromChildren: CreateRoutesFromChildren; @@ -302,6 +300,7 @@ function resolveDeferredLazyRoutePromise(span: Span): void { */ export function processResolvedRoutes( resolvedRoutes: RouteObject[], + config: ReactRouterConfig, parentRoute?: RouteObject, currentLocation: Location | null = null, capturedSpan?: Span, @@ -309,8 +308,8 @@ export function processResolvedRoutes( resolvedRoutes.forEach(child => { allRoutes.add(child); // Only check for async handlers if the feature is enabled - if (_enableAsyncRouteHandlers) { - checkRouteForAsyncHandler(child, processResolvedRoutes); + if (config.enableAsyncRouteHandlers) { + checkRouteForAsyncHandler(child, (r, p, l, s) => processResolvedRoutes(r, config, p, l, s)); } }); @@ -353,10 +352,11 @@ export function processResolvedRoutes( location: { pathname: location.pathname }, routes: Array.from(allRoutes), allRoutes: Array.from(allRoutes), + config, }); } else if (spanOp === 'navigation') { // For navigation spans, update the name with the newly loaded routes - updateNavigationSpan(targetSpan, location, Array.from(allRoutes), false, _matchRoutes); + updateNavigationSpan(targetSpan, location, Array.from(allRoutes), false, config); } } } @@ -370,7 +370,7 @@ export function updateNavigationSpan( location: Location, allRoutes: RouteObject[], forceUpdate = false, - matchRoutes: MatchRoutes, + config: ReactRouterConfig, ): void { const { name: currentName, end_timestamp, attributes } = spanToJSON(activeRootSpan); @@ -379,15 +379,13 @@ export function updateNavigationSpan( const shouldUpdate = !hasBeenNamed || forceUpdate || currentNameHasWildcard; if (shouldUpdate && !end_timestamp) { - const currentBranches = matchRoutes(allRoutes, location); + const currentBranches = config.matchRoutes(allRoutes, location); const [name, source] = resolveRouteNameAndSource( location, allRoutes, allRoutes, (currentBranches as RouteMatch[]) || [], - _basename, - _lazyRouteManifest, - _enableAsyncRouteHandlers, + config, ); const currentSource = attributes[SENTRY_SEGMENT_NAME_SOURCE]; @@ -419,8 +417,8 @@ function setupRouterSubscription( router: Router, routes: RouteObject[], version: V6CompatibleVersion, - basename: string | undefined, activeRootSpan: Span | undefined, + config: ReactRouterConfig, ): void { let isInitialPageloadComplete = false; let hasSeenPageloadSpan = !!activeRootSpan && spanToJSON(activeRootSpan).attributes[SENTRY_OP] === 'pageload'; @@ -463,8 +461,8 @@ function setupRouterSubscription( routes, navigationType: state.historyAction, version, - basename, allRoutes: Array.from(allRoutes), + config, }); }; @@ -501,21 +499,25 @@ export function createV6CompatibleWrapCreateBrowserRouter< createRouterFunction: CreateRouterFunction, version: V6CompatibleVersion, ): CreateRouterFunction { - if (!_useEffect || !_useLocation || !_useNavigationType || !_matchRoutes) { - DEBUG_BUILD && - debug.warn( - `reactRouter${version ? `V${version}` : ''}Instrumentation was unable to wrap the \`createRouter\` function because of one or more missing parameters.`, - ); + return function (routes: RouteObject[], opts?: Record & { basename?: string }): TRouter { + const base = getRouterConfig(getClient()); + if (!base) { + DEBUG_BUILD && + debug.warn( + `reactRouter${version ? `V${version}` : ''}Instrumentation was unable to wrap the \`createRouter\` function because the React Router browser tracing integration was not set up. Make sure \`Sentry.init()\` runs before the router is created.`, + ); + + return createRouterFunction(routes, opts); + } - return createRouterFunction; - } + // Copy per-router so the router's basename doesn't leak into other routers sharing the client config. + const config: ReactRouterConfig = { ...base, basename: opts?.basename || '' }; - return function (routes: RouteObject[], opts?: Record & { basename?: string }): TRouter { addRoutesToAllRoutes(routes); - if (_enableAsyncRouteHandlers) { + if (config.enableAsyncRouteHandlers) { for (const route of routes) { - checkRouteForAsyncHandler(route, processResolvedRoutes); + checkRouteForAsyncHandler(route, (r, p, l, s) => processResolvedRoutes(r, config, p, l, s)); } } @@ -536,24 +538,20 @@ export function createV6CompatibleWrapCreateBrowserRouter< // Pass the captured span to wrapPatchRoutesOnNavigation so it uses the same span // even if the span has ended by the time patchRoutesOnNavigation is called. - const wrappedOpts = wrapPatchRoutesOnNavigation(opts, false, activeRootSpan); + const wrappedOpts = wrapPatchRoutesOnNavigation(opts, false, activeRootSpan, config); const router = createRouterFunction(routes, wrappedOpts); - const basename = opts?.basename; if (router.state.historyAction === 'POP' && activeRootSpan) { updatePageloadTransaction({ activeRootSpan, location: router.state.location, routes, - basename, allRoutes: Array.from(allRoutes), + config, }); } - // Store basename for use in updateNavigationSpan - _basename = basename || ''; - - setupRouterSubscription(router, routes, version, basename, activeRootSpan); + setupRouterSubscription(router, routes, version, activeRootSpan, config); return router; }; @@ -569,15 +567,6 @@ export function createV6CompatibleWrapCreateMemoryRouter< createRouterFunction: CreateRouterFunction, version: V6CompatibleVersion, ): CreateRouterFunction { - if (!_useEffect || !_useLocation || !_useNavigationType || !_matchRoutes) { - DEBUG_BUILD && - debug.warn( - `reactRouter${version ? `V${version}` : ''}Instrumentation was unable to wrap the \`createMemoryRouter\` function because of one or more missing parameters.`, - ); - - return createRouterFunction; - } - return function ( routes: RouteObject[], opts?: Record & { @@ -586,11 +575,24 @@ export function createV6CompatibleWrapCreateMemoryRouter< initialIndex?: number; }, ): TRouter { + const base = getRouterConfig(getClient()); + if (!base) { + DEBUG_BUILD && + debug.warn( + `reactRouter${version ? `V${version}` : ''}Instrumentation was unable to wrap the \`createMemoryRouter\` function because the React Router browser tracing integration was not set up. Make sure \`Sentry.init()\` runs before the router is created.`, + ); + + return createRouterFunction(routes, opts); + } + + // Copy per-router so the router's basename doesn't leak into other routers sharing the client config. + const config: ReactRouterConfig = { ...base, basename: opts?.basename || '' }; + addRoutesToAllRoutes(routes); - if (_enableAsyncRouteHandlers) { + if (config.enableAsyncRouteHandlers) { for (const route of routes) { - checkRouteForAsyncHandler(route, processResolvedRoutes); + checkRouteForAsyncHandler(route, (r, p, l, s) => processResolvedRoutes(r, config, p, l, s)); } } @@ -606,10 +608,9 @@ export function createV6CompatibleWrapCreateMemoryRouter< createDeferredLazyRoutePromise(memoryActiveRootSpanEarly); } - const wrappedOpts = wrapPatchRoutesOnNavigation(opts, true, memoryActiveRootSpanEarly); + const wrappedOpts = wrapPatchRoutesOnNavigation(opts, true, memoryActiveRootSpanEarly, config); const router = createRouterFunction(routes, wrappedOpts); - const basename = opts?.basename; let initialEntry = undefined; @@ -638,15 +639,12 @@ export function createV6CompatibleWrapCreateMemoryRouter< activeRootSpan: memoryActiveRootSpan, location, routes, - basename, allRoutes: Array.from(allRoutes), + config, }); } - // Store basename for use in updateNavigationSpan - _basename = basename || ''; - - setupRouterSubscription(router, routes, version, basename, memoryActiveRootSpan); + setupRouterSubscription(router, routes, version, memoryActiveRootSpan, config); return router; }; @@ -662,7 +660,6 @@ export function createReactRouterV6CompatibleTracingIntegration( const integration = browserTracingIntegration({ ...options, instrumentPageLoad: false, instrumentNavigation: false }); const { - useEffect, useLocation, useNavigationType, createRoutesFromChildren, @@ -675,18 +672,16 @@ export function createReactRouterV6CompatibleTracingIntegration( lazyRouteManifest, } = options; - return { - ...integration, + return extendIntegration(integration, { setup(client) { - integration.setup(client); - const finalTimeout = options.finalTimeout ?? 30000; const defaultMaxWait = (options.idleTimeout ?? 1000) * 3; const configuredMaxWait = lazyRouteTimeout ?? defaultMaxWait; + let resolvedLazyRouteTimeout: number; // Cap Infinity at finalTimeout to prevent indefinite hangs if (configuredMaxWait === Infinity) { - _lazyRouteTimeout = finalTimeout; + resolvedLazyRouteTimeout = finalTimeout; DEBUG_BUILD && debug.log( '[React Router] lazyRouteTimeout set to Infinity, capping at finalTimeout:', @@ -696,7 +691,7 @@ export function createReactRouterV6CompatibleTracingIntegration( } else if (Number.isNaN(configuredMaxWait)) { DEBUG_BUILD && debug.warn('[React Router] lazyRouteTimeout must be a number, falling back to default:', defaultMaxWait); - _lazyRouteTimeout = defaultMaxWait; + resolvedLazyRouteTimeout = defaultMaxWait; } else if (configuredMaxWait < 0) { DEBUG_BUILD && debug.warn( @@ -705,25 +700,42 @@ export function createReactRouterV6CompatibleTracingIntegration( 'falling back to:', defaultMaxWait, ); - _lazyRouteTimeout = defaultMaxWait; + resolvedLazyRouteTimeout = defaultMaxWait; } else { - _lazyRouteTimeout = configuredMaxWait; + resolvedLazyRouteTimeout = configuredMaxWait; } - _useEffect = useEffect; - _useLocation = useLocation; - _useNavigationType = useNavigationType; - _matchRoutes = matchRoutes; - _createRoutesFromChildren = createRoutesFromChildren; - _enableAsyncRouteHandlers = enableAsyncRouteHandlers; - _lazyRouteManifest = lazyRouteManifest; - - // Initialize the router utils with the required dependencies - initializeRouterUtils(matchRoutes, stripBasename || false); + // Only store a config when every hook the wrappers call is present. Storing a partial config would + // make the wrappers take the instrumented branch and invoke a missing hook (e.g. `config.useLocation`) + // at render time, crashing the host app. Without a config the wrappers fall back to uninstrumented + // routes instead. The `@sentry/react/react-router` entry supplies these automatically. + if ( + typeof useLocation === 'function' && + typeof useNavigationType === 'function' && + typeof createRoutesFromChildren === 'function' && + typeof matchRoutes === 'function' + ) { + reactRouterConfigByClient.set(client, { + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + stripBasename: stripBasename || false, + enableAsyncRouteHandlers, + instrumentNavigation, + lazyRouteTimeout: resolvedLazyRouteTimeout, + lazyRouteManifest, + basename: '', + }); + } else { + DEBUG_BUILD && + debug.warn( + '[React Router] Skipping route instrumentation because `useLocation`, `useNavigationType`, `createRoutesFromChildren` or `matchRoutes` was not provided. ' + + 'Pass them to `reactRouterBrowserTracingIntegration`, or import it from `@sentry/react/react-router` to have them supplied automatically.', + ); + } }, afterAllSetup(client) { - integration.afterAllSetup(client); - const initPathName = WINDOW.location?.pathname; if (instrumentPageLoad && initPathName) { startBrowserTracingPageLoadSpan(client, { @@ -737,36 +749,25 @@ export function createReactRouterV6CompatibleTracingIntegration( }, }); } - - if (instrumentNavigation) { - CLIENTS_WITH_INSTRUMENT_NAVIGATION.add(client); - } }, - }; + }); } export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, version: V6CompatibleVersion): UseRoutes { - if (!_useEffect || !_useLocation || !_useNavigationType || !_matchRoutes) { - DEBUG_BUILD && - debug.warn( - 'reactRouterV6Instrumentation was unable to wrap `useRoutes` because of one or more missing parameters.', - ); - - return origUseRoutes; - } - - const SentryRoutes: React.FC<{ - children?: React.ReactNode; + // Null-rendering reporter that owns every config-dependent hook. It is mounted as a *sibling* of the + // routes element (never wrapping it) and only once a client config exists, so the routes element always + // keeps its position across the `Sentry.init()` transition and is never remounted - remounting would wipe + // form state and in-flight work in the host app. As a freshly mounted component, its own hook sequence + // stays self-consistent for its whole lifetime, so this is Rules-of-Hooks safe. + const RouteReporter: React.FC<{ + config: ReactRouterConfig; routes: RouteObject[]; locationArg?: Partial | string; - }> = (props: { children?: React.ReactNode; routes: RouteObject[]; locationArg?: Partial | string }) => { + }> = ({ config, routes, locationArg }) => { const isMountRenderPass = React.useRef(true); - const { routes, locationArg } = props; - const Routes = origUseRoutes(routes, locationArg); - - const location = _useLocation(); - const navigationType = _useNavigationType(); + const location = config.useLocation(); + const navigationType = config.useNavigationType(); // A value with stable identity to either pick `locationArg` if available or `location` if not const stableLocationParam = @@ -791,6 +792,7 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio location: normalizedLocation, routes, allRoutes: Array.from(allRoutes), + config, }); isMountRenderPass.current = false; } else { @@ -803,22 +805,45 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio navigationType, version, allRoutes: Array.from(allRoutes), + config, }); } }, [navigationType, stableLocationParam]); - return Routes; + return null; + }; + + // Reads the client config at *render* time (so wrapping before `Sentry.init()` still instruments once the + // app renders). `origUseRoutes` is called unconditionally (a stable hook) and its element is always + // rendered; instrumentation lives in the sibling `RouteReporter`, which mounts only when config exists - + // so config appearing after the first paint toggles a null-rendering sibling instead of swapping the + // wrapper's type and remounting the routes. + const SentryRoutesWrapper: React.FC<{ routes: RouteObject[]; locationArg?: Partial | string }> = ({ + routes, + locationArg, + }) => { + const config = getRouterConfig(getClient()); + const routesElement = origUseRoutes(routes, locationArg); + return ( + <> + {routesElement} + {/* Rendered after the routes so the reporter's layout effects run *after* the (descendant) route + subtree has registered into `allRoutes`, matching the pre-refactor parent-after-child order. */} + {config ? : null} + + ); }; // eslint-disable-next-line react/display-name return (routes: RouteObject[], locationArg?: Partial | string): React.ReactElement | null => { - return ; + return ; }; } function wrapPatchRoutesOnNavigation( opts: Record | undefined, - isMemoryRouter = false, - capturedSpan?: Span, + isMemoryRouter: boolean, + capturedSpan: Span | undefined, + config: ReactRouterConfig, ): Record { if (!opts || !('patchRoutesOnNavigation' in opts) || typeof opts.patchRoutesOnNavigation !== 'function') { return opts || {}; @@ -886,7 +911,7 @@ function wrapPatchRoutesOnNavigation( { pathname: targetPath, search: '', hash: '', state: null, key: 'default' }, Array.from(allRoutes), true, - _matchRoutes, + config, ); } return originalPatch(routeId, children); @@ -928,7 +953,7 @@ function wrapPatchRoutesOnNavigation( { pathname, search: '', hash: '', state: null, key: 'default' }, Array.from(allRoutes), false, - _matchRoutes, + config, ); } } @@ -951,15 +976,17 @@ export function handleNavigation(opts: { routes: RouteObject[]; navigationType: Action; version: V6CompatibleVersion; + config: ReactRouterConfig; matches?: AgnosticDataRouteMatch; - basename?: string; allRoutes?: RouteObject[]; }): void { - const { location, routes, navigationType, version, matches, basename, allRoutes } = opts; - const branches = Array.isArray(matches) ? matches : _matchRoutes(allRoutes || routes, location, basename); + const { location, routes, navigationType, version, config, matches, allRoutes } = opts; + const branches = Array.isArray(matches) + ? matches + : config.matchRoutes(allRoutes || routes, location, config.basename); const client = getClient(); - if (!client || !CLIENTS_WITH_INSTRUMENT_NAVIGATION.has(client)) { + if (!client || !config.instrumentNavigation) { return; } @@ -974,9 +1001,7 @@ export function handleNavigation(opts: { allRoutes || routes, allRoutes || routes, branches as RouteMatch[], - basename, - _lazyRouteManifest, - _enableAsyncRouteHandlers, + config, ); const locationKey = computeLocationKey(location); @@ -1060,7 +1085,7 @@ export function handleNavigation(opts: { pathname: location.pathname, locationKey, }); - patchSpanEnd(navigationSpan, location, routes, basename, 'navigation'); + patchSpanEnd(navigationSpan, location, routes, 'navigation', config); } else { // If no span was created, remove the placeholder activeNavigationSpans.delete(client); @@ -1116,20 +1141,20 @@ function updatePageloadTransaction({ activeRootSpan, location, routes, + config, matches, - basename, allRoutes, }: { activeRootSpan: Span | undefined; location: Location; routes: RouteObject[]; + config: ReactRouterConfig; matches?: AgnosticDataRouteMatch; - basename?: string; allRoutes?: RouteObject[]; }): void { const branches = Array.isArray(matches) ? matches - : (_matchRoutes(allRoutes || routes, location, basename) as unknown as RouteMatch[]); + : (config.matchRoutes(allRoutes || routes, location, config.basename) as unknown as RouteMatch[]); if (branches) { const [name, source] = resolveRouteNameAndSource( @@ -1137,9 +1162,7 @@ function updatePageloadTransaction({ allRoutes || routes, allRoutes || routes, branches, - basename, - _lazyRouteManifest, - _enableAsyncRouteHandlers, + config, ); getCurrentScope().setTransactionName(name || '/'); @@ -1155,13 +1178,13 @@ function updatePageloadTransaction({ } // Patch span.end() to ensure we update the name one last time before the span is sent - patchSpanEnd(activeRootSpan, location, routes, basename, 'pageload'); + patchSpanEnd(activeRootSpan, location, routes, 'pageload', config); } } else if (activeRootSpan) { // Even if branches is null (can happen when lazy routes haven't loaded yet), // we still need to patch span.end() so that when lazy routes load and the span ends, // we can update the transaction name correctly. - patchSpanEnd(activeRootSpan, location, routes, basename, 'pageload'); + patchSpanEnd(activeRootSpan, location, routes, 'pageload', config); } } @@ -1214,9 +1237,9 @@ function tryUpdateSpanNameBeforeEnd( currentName: string | undefined, location: Location, routes: RouteObject[], - basename: string | undefined, spanType: 'pageload' | 'navigation', allRoutes: Set, + config: ReactRouterConfig, ): void { try { const currentSource = spanJson.attributes[SENTRY_SEGMENT_NAME_SOURCE] as string | undefined; @@ -1227,21 +1250,13 @@ function tryUpdateSpanNameBeforeEnd( const currentAllRoutes = Array.from(allRoutes); const routesToUse = currentAllRoutes.length > 0 ? currentAllRoutes : routes; - const branches = _matchRoutes(routesToUse, location, basename) as unknown as RouteMatch[]; + const branches = config.matchRoutes(routesToUse, location, config.basename) as unknown as RouteMatch[]; if (!branches) { return; } - const [name, source] = resolveRouteNameAndSource( - location, - routesToUse, - routesToUse, - branches, - basename, - _lazyRouteManifest, - _enableAsyncRouteHandlers, - ); + const [name, source] = resolveRouteNameAndSource(location, routesToUse, routesToUse, branches, config); const isImprovement = shouldUpdateWildcardSpanName(currentName, currentSource, name, source, true); const spanNotEnded = spanType === 'pageload' || !spanJson.end_timestamp; @@ -1270,8 +1285,8 @@ function patchSpanEnd( span: Span, location: Location, routes: RouteObject[], - basename: string | undefined, spanType: 'pageload' | 'navigation', + config: ReactRouterConfig, ): void { const patchedPropertyName = `__sentry_${spanType}_end_patched__` as const; const hasEndBeenPatched = (span as unknown as Record)?.[patchedPropertyName]; @@ -1324,8 +1339,8 @@ function patchSpanEnd( (transactionNameHasWildcard(currentName) || currentSource !== 'route'); if (shouldWaitForLazyRoutes) { - if (_lazyRouteTimeout === 0) { - tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutes); + if (config.lazyRouteTimeout === 0) { + tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, spanType, allRoutes, config); cleanupNavigationSpan(); originalEnd(endTimestamp); return; @@ -1334,12 +1349,12 @@ function patchSpanEnd( // If we have pending promises, wait for them. Otherwise, just wait for the timeout. // This handles the case where we know lazy routes might load but patchRoutesOnNavigation // hasn't been called yet. - const timeoutPromise = new Promise(r => setTimeout(r, _lazyRouteTimeout)); + const timeoutPromise = new Promise(r => setTimeout(r, config.lazyRouteTimeout)); let waitPromise: Promise; if (pendingPromises && pendingPromises.size > 0) { const allSettled = Promise.allSettled(pendingPromises).then(() => {}); - waitPromise = _lazyRouteTimeout === Infinity ? allSettled : Promise.race([allSettled, timeoutPromise]); + waitPromise = config.lazyRouteTimeout === Infinity ? allSettled : Promise.race([allSettled, timeoutPromise]); } else { // No pending promises yet, but we know lazy routes might load // Wait for the timeout to give React Router time to call patchRoutesOnNavigation @@ -1355,9 +1370,9 @@ function patchSpanEnd( updatedSpanJson.name, location, routes, - basename, spanType, allRoutes, + config, ); cleanupNavigationSpan(); originalEnd(endTimestamp); @@ -1369,7 +1384,7 @@ function patchSpanEnd( return; } - tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, basename, spanType, allRoutes); + tryUpdateSpanNameBeforeEnd(span, spanJson, currentName, location, routes, spanType, allRoutes, config); cleanupNavigationSpan(); originalEnd(endTimestamp); }; @@ -1382,22 +1397,23 @@ export function createV6CompatibleWithSentryReactRouterRouting

= (props: P) => { + // Null-rendering reporter that owns every config-dependent hook. It is mounted as a *sibling* of the + // routes (never wrapping them) and only once a client config exists, so the route subtree always keeps + // the same component type across the `Sentry.init()` transition and is never remounted - remounting + // would wipe form state and in-flight work in the host app. As a freshly mounted component, its own hook + // sequence stays self-consistent for its whole lifetime, so this is Rules-of-Hooks safe. + const RouteReporter: React.FC<{ config: ReactRouterConfig; routeChildren: React.ReactNode }> = ({ + config, + routeChildren, + }) => { const isMountRenderPass = React.useRef(true); - const location = _useLocation(); - const navigationType = _useNavigationType(); + const location = config.useLocation(); + const navigationType = config.useNavigationType(); - const routes = _createRoutesFromChildren(props.children) as RouteObject[]; + const routes = config.createRoutesFromChildren( + routeChildren as Parameters[0], + ) as RouteObject[]; // Register this ``'s routes in the shared set for as long as it is mounted, removing them on // unmount so they don't leak into later unrelated navigations (#22782). Tying add and remove to the @@ -1416,22 +1432,48 @@ export function createV6CompatibleWithSentryReactRouterRouting

; + return null; + }; + + // Reads the client config at *render* time (so wrapping before `Sentry.init()` still instruments once + // the app renders). The routes are always rendered with the same component type; instrumentation lives in + // the sibling `RouteReporter`, which mounts only when config exists - so config appearing after the first + // paint toggles a null-rendering sibling instead of swapping the routes' type and remounting them. + const SentryRoutes: React.FC

= (props: P) => { + const config = getRouterConfig(getClient()); + return ( + <> + { + // @ts-expect-error Setting more specific React Component typing for `R` generic above + // will break advanced type inference done by react router params + + } + {/* Rendered after the routes so the reporter's layout effects run *after* the (descendant) route + subtree has registered into `allRoutes`, matching the pre-refactor parent-after-child order. */} + {config ? : null} + + ); }; hoistNonReactStatics(SentryRoutes, Routes); diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index c983e6f76dfb..9f33127c8ce1 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -1,14 +1,10 @@ import type { Span, TransactionSource } from '@sentry/core'; import { debug, getActiveSpan, getRootSpan, spanToJSON } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; -import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../types'; +import type { Location, MatchRoutes, ReactRouterConfig, RouteMatch, RouteObject } from '../types'; import { matchRouteManifest, stripBasenameFromPathname } from './route-manifest'; import { SENTRY_OP } from '@sentry/conventions/attributes'; -// Global variables that these utilities depend on -let _matchRoutes: MatchRoutes; -let _stripBasename: boolean = false; - // Navigation context stack for nested/concurrent patchRoutesOnNavigation calls. // Required because window.location hasn't updated yet when handlers are invoked. interface NavigationContext { @@ -54,15 +50,6 @@ export function getNavigationContext(): NavigationContext | null { return length > 0 ? (_navigationContextStack[length - 1] ?? null) : null; } -/** - * Initialize function to set dependencies that the router utilities need. - * Must be called before using any of the exported utility functions. - */ -export function initializeRouterUtils(matchRoutes: MatchRoutes, stripBasename: boolean = false): void { - _matchRoutes = matchRoutes; - _stripBasename = stripBasename; -} - // Helper functions function pickPath(match: RouteMatch): string { return trimWildcard(match.route.path || ''); @@ -104,11 +91,16 @@ export function routeIsDescendant(route: RouteObject): boolean { return !!(!route.children && route.element && route.path?.endsWith('/*')); } -function sendIndexPath(pathBuilder: string, pathname: string, basename: string): [string, TransactionSource] { +function sendIndexPath( + pathBuilder: string, + pathname: string, + basename: string, + stripBasename: boolean, +): [string, TransactionSource] { const reconstructedPath = pathBuilder && pathBuilder.length > 0 ? pathBuilder - : _stripBasename + : stripBasename ? stripBasenameFromPathname(pathname, basename) : pathname; @@ -148,8 +140,12 @@ export function prefixWithSlash(path: string): string { /** * Rebuilds the route path from all available routes by matching against the current location. */ -export function rebuildRoutePathFromAllRoutes(allRoutes: RouteObject[], location: Location): string { - const matchedRoutes = _matchRoutes(allRoutes, location) as RouteMatch[]; +export function rebuildRoutePathFromAllRoutes( + allRoutes: RouteObject[], + location: Location, + matchRoutes: MatchRoutes, +): string { + const matchedRoutes = matchRoutes(allRoutes, location) as RouteMatch[]; if (!matchedRoutes || matchedRoutes.length === 0) { return ''; @@ -172,6 +168,7 @@ export function rebuildRoutePathFromAllRoutes(allRoutes: RouteObject[], location { pathname: strippedPath, }, + matchRoutes, ), ), ); @@ -192,13 +189,14 @@ function reconstructNameFromDescendantParent( location: Location, allRoutes: RouteObject[], currentName: string | undefined, + matchRoutes: MatchRoutes, ): string | undefined { const descendantParents = allRoutes.filter(routeIsDescendant); if (!descendantParents.length) { return undefined; } - const matchedParents = _matchRoutes(descendantParents, location) as RouteMatch[] | null; + const matchedParents = matchRoutes(descendantParents, location) as RouteMatch[] | null; const parentMatch = matchedParents?.[matchedParents.length - 1]; if (!parentMatch || !pickSplat(parentMatch)) { return undefined; @@ -227,6 +225,7 @@ function reconstructNameFromDescendantParent( const remainingName = rebuildRoutePathFromAllRoutes( allRoutes.filter(route => route !== parentMatch.route), { pathname: remainingPathname }, + matchRoutes, ); return remainingName ? prefixWithSlash(`${parentTemplate}${prefixWithSlash(remainingName)}`) : undefined; @@ -235,8 +234,12 @@ function reconstructNameFromDescendantParent( /** * Checks if the current location is inside a descendant route (route with splat parameter). */ -export function locationIsInsideDescendantRoute(location: Location, routes: RouteObject[]): boolean { - const matchedRoutes = _matchRoutes(routes, location) as RouteMatch[]; +export function locationIsInsideDescendantRoute( + location: Location, + routes: RouteObject[], + matchRoutes: MatchRoutes, +): boolean { + const matchedRoutes = matchRoutes(routes, location) as RouteMatch[]; if (matchedRoutes) { for (const match of matchedRoutes) { @@ -252,8 +255,8 @@ export function locationIsInsideDescendantRoute(location: Location, routes: Rout /** * Returns a fallback transaction name from location pathname. */ -function getFallbackTransactionName(location: Location, basename: string): string { - return _stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname || ''; +function getFallbackTransactionName(location: Location, basename: string, stripBasename: boolean): string { + return stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname || ''; } /** @@ -264,13 +267,14 @@ export function getNormalizedName( location: Location, branches: RouteMatch[], basename: string = '', + stripBasename: boolean = false, ): [string, TransactionSource] { if (!routes || routes.length === 0) { - return [_stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url']; + return [stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url']; } if (!branches) { - return [getFallbackTransactionName(location, basename), 'url']; + return [getFallbackTransactionName(location, basename, stripBasename), 'url']; } let pathBuilder = ''; @@ -283,7 +287,7 @@ export function getNormalizedName( // Early return for index routes if (route.index) { - return sendIndexPath(pathBuilder, branch.pathname, basename); + return sendIndexPath(pathBuilder, branch.pathname, basename, stripBasename); } const path = route.path; @@ -305,7 +309,7 @@ export function getNormalizedName( getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) && !pathEndsWithWildcard(pathBuilder) ) { - return [(_stripBasename ? '' : basename) + newPath, 'route']; + return [(stripBasename ? '' : basename) + newPath, 'route']; } // Handle wildcard routes with children - strip trailing wildcard @@ -313,11 +317,11 @@ export function getNormalizedName( pathBuilder = pathBuilder.slice(0, -1); } - return [(_stripBasename ? '' : basename) + pathBuilder, 'route']; + return [(stripBasename ? '' : basename) + pathBuilder, 'route']; } // Fallback when no matching route found - return [getFallbackTransactionName(location, basename), 'url']; + return [getFallbackTransactionName(location, basename, stripBasename), 'url']; } /** @@ -328,15 +332,15 @@ export function resolveRouteNameAndSource( routes: RouteObject[], allRoutes: RouteObject[], branches: RouteMatch[], - basename: string = '', - lazyRouteManifest?: string[], - enableAsyncRouteHandlers?: boolean, + config: ReactRouterConfig, ): [string, TransactionSource] { + const { matchRoutes, stripBasename, basename, lazyRouteManifest, enableAsyncRouteHandlers } = config; + // When lazy route manifest is provided, use it as the primary source for transaction names if (enableAsyncRouteHandlers && lazyRouteManifest && lazyRouteManifest.length > 0) { const manifestMatch = matchRouteManifest(location.pathname, lazyRouteManifest, basename); if (manifestMatch) { - return [(_stripBasename ? '' : basename) + manifestMatch, 'route']; + return [(stripBasename ? '' : basename) + manifestMatch, 'route']; } } @@ -344,20 +348,20 @@ export function resolveRouteNameAndSource( let name: string | undefined; let source: TransactionSource = 'url'; - const isInDescendantRoute = locationIsInsideDescendantRoute(location, allRoutes); + const isInDescendantRoute = locationIsInsideDescendantRoute(location, allRoutes, matchRoutes); if (isInDescendantRoute) { - name = prefixWithSlash(rebuildRoutePathFromAllRoutes(allRoutes, location)); + name = prefixWithSlash(rebuildRoutePathFromAllRoutes(allRoutes, location, matchRoutes)); source = 'route'; } if (!isInDescendantRoute || !name) { - [name, source] = getNormalizedName(routes, location, branches, basename); + [name, source] = getNormalizedName(routes, location, branches, basename, stripBasename); } // Guard against orphaned descendant subtrees stealing the transaction name: if the location is // anchored by a descendant-parent route (`.../*`) whose prefix was dropped, reconstruct with it. - const anchoredName = reconstructNameFromDescendantParent(location, allRoutes, name); + const anchoredName = reconstructNameFromDescendantParent(location, allRoutes, name, matchRoutes); if (anchoredName) { return [anchoredName, 'route']; } diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index c25ee5df1ae3..cde3342bcd14 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -77,6 +77,24 @@ export type MatchRoutes = ( basename?: string, ) => RouteMatchAlias[] | null; +/** + * The resolved React Router instrumentation config for a given client, captured during `Sentry.init()`. + * Stored per-client and threaded through the instrumentation so nothing depends on module-scope state. + */ +export interface ReactRouterConfig { + useLocation: UseLocation; + useNavigationType: UseNavigationType; + createRoutesFromChildren: CreateRoutesFromChildren; + matchRoutes: MatchRoutes; + stripBasename: boolean; + enableAsyncRouteHandlers: boolean; + instrumentNavigation: boolean; + lazyRouteTimeout: number; + lazyRouteManifest?: string[]; + // The active router's basename. Unknown at setup time; filled in by the data-router wrappers. + basename: string; +} + // Types for react-router >= 6.4.2 export type ShouldRevalidateFunction = (args: any) => boolean; diff --git a/packages/react/test/react-router.test.tsx b/packages/react/test/react-router.test.tsx new file mode 100644 index 000000000000..cb100633002f --- /dev/null +++ b/packages/react/test/react-router.test.tsx @@ -0,0 +1,165 @@ +/** + * @vitest-environment jsdom + * + * Tests for the `@sentry/react/react-router` entry point, which pulls the required React Router hooks + * directly from `react` / `react-router` so `reactRouterBrowserTracingIntegration()` can be used + * without passing them in. + */ +import { + createTransport, + getCurrentScope, + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + setCurrentClient, +} from '@sentry/core'; +import { SENTRY_SEGMENT_NAME_SOURCE, URL_TEMPLATE } from '@sentry/conventions/attributes'; +import { fireEvent, render } from '@testing-library/react'; +import * as React from 'react'; +import { MemoryRouter, Route, Routes, useNavigate } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BrowserClient } from '../src'; +import { allRoutes } from '../src/reactrouter-compat-utils/instrumentation'; +import { reactRouterBrowserTracingIntegration, wrapReactRouterRouting } from '../src/react-router'; + +const mockStartBrowserTracingPageLoadSpan = vi.fn(); +const mockStartBrowserTracingNavigationSpan = vi.fn(); + +vi.mock('@sentry/browser', async requireActual => { + const actual = (await requireActual()) as any; + return { + ...actual, + startBrowserTracingNavigationSpan: (...args: unknown[]) => { + mockStartBrowserTracingNavigationSpan(...args); + return actual.startBrowserTracingNavigationSpan(...args); + }, + startBrowserTracingPageLoadSpan: (...args: unknown[]) => { + mockStartBrowserTracingPageLoadSpan(...args); + return actual.startBrowserTracingPageLoadSpan(...args); + }, + }; +}); + +function createMockBrowserClient(): BrowserClient { + return new BrowserClient({ + integrations: [], + tracesSampleRate: 1, + transport: () => createTransport({ recordDroppedEvent: () => undefined }, _ => Promise.resolve({})), + stackParser: () => [], + }); +} + +describe('@sentry/react/react-router', () => { + beforeEach(() => { + vi.clearAllMocks(); + getCurrentScope().setClient(undefined); + allRoutes.clear(); + }); + + it('reactRouterBrowserTracingIntegration() instruments a pageload without passing router hooks', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + // No arguments - the hooks are pulled from `react` / `react-router` by the entry point. + client.addIntegration(reactRouterBrowserTracingIntegration()); + + const SentryRoutes = wrapReactRouterRouting(Routes); + + render( + + + Home} /> + About} /> + + , + ); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: 'Pageload', + attributes: { + [SENTRY_SEGMENT_NAME_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + // version-agnostic origin (no `_v6`/`_v7` suffix) + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter', + }, + }); + expect(getCurrentScope().getScopeData().transactionName).toEqual('/about'); + }); + + it('reactRouterBrowserTracingIntegration() instruments a navigation without passing router hooks', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration(reactRouterBrowserTracingIntegration()); + + const SentryRoutes = wrapReactRouterRouting(Routes); + + function Home(): React.ReactElement { + const navigate = useNavigate(); + return ( + + ); + } + + const { getByText } = render( + + + } /> + About} /> + + , + ); + + fireEvent.click(getByText('to about')); + + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1); + expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), { + name: '/about', + attributes: { + [SENTRY_SEGMENT_NAME_SOURCE]: 'route', + [URL_TEMPLATE]: '/about', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter', + }, + }); + }); + + it('forwards options, e.g. `instrumentPageLoad: false`', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration(reactRouterBrowserTracingIntegration({ instrumentPageLoad: false })); + + const SentryRoutes = wrapReactRouterRouting(Routes); + + render( + + + Home} /> + + , + ); + + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(0); + }); + + it('renders uninstrumented (no spans, no crash) when the integration is not set up', () => { + // No client / integration - the wrapper has no client config to read, so it must fall back to + // rendering the plain routes without instrumenting. + const SentryRoutes = wrapReactRouterRouting(Routes); + + const { getByText } = render( + + + Home} /> + + , + ); + + expect(getByText('Home')).toBeDefined(); + expect(mockStartBrowserTracingPageLoadSpan).not.toHaveBeenCalled(); + expect(mockStartBrowserTracingNavigationSpan).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx index 82b0a4bb575d..efe7228d778b 100644 --- a/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx +++ b/packages/react/test/reactrouter-compat-utils/instrumentation.test.tsx @@ -17,7 +17,24 @@ import { shouldSkipNavigation, } from '../../src/reactrouter-compat-utils/instrumentation'; import { resolveRouteNameAndSource, transactionNameHasWildcard } from '../../src/reactrouter-compat-utils/utils'; -import type { Location, RouteObject } from '../../src/types'; +import type { Location, ReactRouterConfig, RouteObject } from '../../src/types'; + +/** Builds a `ReactRouterConfig` for exercising the internal helpers that now receive it explicitly. */ +function makeMockConfig(overrides: Partial = {}): ReactRouterConfig { + return { + useLocation: vi.fn(), + useNavigationType: vi.fn(), + createRoutesFromChildren: vi.fn(), + matchRoutes: vi.fn(() => []), + stripBasename: false, + enableAsyncRouteHandlers: false, + instrumentNavigation: true, + lazyRouteTimeout: 3000, + lazyRouteManifest: undefined, + basename: '', + ...overrides, + }; +} const mockUpdateName = vi.fn(); const mockSetAttribute = vi.fn(); @@ -91,7 +108,13 @@ describe('reactrouter-compat-utils/instrumentation', () => { describe('updateNavigationSpan', () => { it('should update navigation span name and source when not already named', () => { - updateNavigationSpan(mockSpan, sampleLocation, sampleRoutes, false, mockMatchRoutes); + updateNavigationSpan( + mockSpan, + sampleLocation, + sampleRoutes, + false, + makeMockConfig({ matchRoutes: mockMatchRoutes }), + ); expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); expect(mockSetAttribute).toHaveBeenCalledWith('sentry.segment.name.source', 'route'); @@ -101,7 +124,13 @@ describe('reactrouter-compat-utils/instrumentation', () => { it('should not update when span already has name set', () => { const spanWithNameSet = { ...mockSpan, __sentry_navigation_name_set__: true }; - updateNavigationSpan(spanWithNameSet as any, sampleLocation, sampleRoutes, false, mockMatchRoutes); + updateNavigationSpan( + spanWithNameSet as any, + sampleLocation, + sampleRoutes, + false, + makeMockConfig({ matchRoutes: mockMatchRoutes }), + ); expect(mockUpdateName).not.toHaveBeenCalled(); }); @@ -407,7 +436,13 @@ describe('updateNavigationSpan with wildcard detection', () => { it('should call updateName when provided with valid routes', () => { const testSpan = { ...mockSpan }; - updateNavigationSpan(testSpan, sampleLocation, sampleRoutes, false, mockMatchRoutes); + updateNavigationSpan( + testSpan, + sampleLocation, + sampleRoutes, + false, + makeMockConfig({ matchRoutes: mockMatchRoutes }), + ); expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); expect(mockSetAttribute).toHaveBeenCalledWith('sentry.segment.name.source', 'route'); @@ -415,7 +450,13 @@ describe('updateNavigationSpan with wildcard detection', () => { it('should handle forced updates', () => { const testSpan = { ...mockSpan, __sentry_navigation_name_set__: true }; - updateNavigationSpan(testSpan, sampleLocation, sampleRoutes, true, mockMatchRoutes); + updateNavigationSpan( + testSpan, + sampleLocation, + sampleRoutes, + true, + makeMockConfig({ matchRoutes: mockMatchRoutes }), + ); // Should update even though already named because forceUpdate=true expect(mockUpdateName).toHaveBeenCalledWith('Test Route'); @@ -452,7 +493,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, [{ path: '/users/:id', element:

}], false, - vi.fn(() => [{ route: { path: '/users/:id' } }]), + makeMockConfig({ matchRoutes: vi.fn(() => [{ route: { path: '/users/:id' } }]) }), ); // Should upgrade from URL to route source @@ -484,7 +525,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { { pathname: '/users/456', search: '', hash: '', state: null, key: 'test' }, [{ path: '/users/:id', element:
}], false, - vi.fn(() => [{ route: { path: '/users/:id' } }]), + makeMockConfig({ matchRoutes: vi.fn(() => [{ route: { path: '/users/:id' } }]) }), ); // Should not update because span is already named @@ -522,7 +563,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, [{ path: '/users/:id', element:
}], false, - vi.fn(() => [{ route: { path: '/users/:id' } }]), + makeMockConfig({ matchRoutes: vi.fn(() => [{ route: { path: '/users/:id' } }]) }), ); // Should upgrade from wildcard to specific @@ -559,7 +600,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, [{ path: '/users/*', element:
}], false, - vi.fn(() => [{ route: { path: '/users/*' } }]), + makeMockConfig({ matchRoutes: vi.fn(() => [{ route: { path: '/users/*' } }]) }), ); // Should not update - keep wildcard route instead of downgrading to URL @@ -591,7 +632,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, [{ path: '/users/:id', element:
}], false, - vi.fn(() => [{ route: { path: '/users/:id' } }]), + makeMockConfig({ matchRoutes: vi.fn(() => [{ route: { path: '/users/:id' } }]) }), ); // Should set initial name @@ -622,7 +663,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { { pathname: '/users/123', search: '', hash: '', state: null, key: 'test' }, [{ path: '/users/:id', element:
}], false, - vi.fn(() => [{ route: { path: '/users/:id' } }]), + makeMockConfig({ matchRoutes: vi.fn(() => [{ route: { path: '/users/:id' } }]) }), ); // Note: updateNavigationSpan always updates if not already named @@ -936,6 +977,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { routes: [{ path: '/search', element:
}], navigationType: 'PUSH', version: '6' as const, + config: makeMockConfig(), matches: matches as any, }); @@ -981,6 +1023,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { routes: [{ path: '/search', element:
}], navigationType: 'PUSH', version: '6' as const, + config: makeMockConfig(), matches: matches as any, }); @@ -993,6 +1036,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { routes: [{ path: '/search', element:
}], navigationType: 'PUSH', version: '6' as const, + config: makeMockConfig(), matches: matches as any, }); @@ -1028,6 +1072,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { routes: [{ path: '/search', element:
}], navigationType: 'PUSH', version: '6' as const, + config: makeMockConfig(), matches: matches as any, }); @@ -1048,6 +1093,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { routes: [{ path: '/search', element:
}], navigationType: 'PUSH', version: '6' as const, + config: makeMockConfig(), matches: matches as any, }); @@ -1083,6 +1129,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { routes: [{ path: '/page', element:
}], navigationType: 'PUSH', version: '6' as const, + config: makeMockConfig(), matches: matches as any, }); @@ -1103,6 +1150,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { routes: [{ path: '/page', element:
}], navigationType: 'PUSH', version: '6' as const, + config: makeMockConfig(), matches: matches as any, }); @@ -1146,6 +1194,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { routes: [{ path: '/users/*', element:
}], navigationType: 'PUSH', version: '6' as const, + config: makeMockConfig(), matches: matches as any, }); @@ -1166,6 +1215,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { routes: [{ path: '/users/:id', element:
}], navigationType: 'PUSH', version: '6' as const, + config: makeMockConfig(), matches: matches as any, }); @@ -1212,6 +1262,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { routes: [{ path: '/users', element:
}], navigationType: 'PUSH', version: '6' as const, + config: makeMockConfig(), matches: matches as any, }); @@ -1235,6 +1286,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { routes: [{ path: '/users', element:
}], navigationType: 'PUSH', version: '6' as const, + config: makeMockConfig(), matches: matches as any, }); @@ -1413,7 +1465,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { { pathname: '/test', search: '', hash: '', state: null, key: 'test' }, [], false, - vi.fn(() => []), + makeMockConfig({ matchRoutes: vi.fn(() => []) }), ); // eslint-disable-next-line @typescript-eslint/unbound-method @@ -1436,7 +1488,7 @@ describe('tryUpdateSpanNameBeforeEnd - source upgrade logic', () => { { pathname: '/captured/path', search: '', hash: '', state: null, key: 'test' }, [], false, - vi.fn(() => []), + makeMockConfig({ matchRoutes: vi.fn(() => []) }), ); // eslint-disable-next-line @typescript-eslint/unbound-method diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts index 401ea648b0fc..e2a1e7f9a256 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -4,7 +4,6 @@ import { getNavigationContext, getNormalizedName, getNumberOfUrlSegments, - initializeRouterUtils, locationIsInsideDescendantRoute, pathEndsWithWildcard, pathIsWildcardAndHasChildren, @@ -14,7 +13,24 @@ import { setNavigationContext, transactionNameHasWildcard, } from '../../src/reactrouter-compat-utils'; -import type { Location, MatchRoutes, RouteMatch, RouteObject } from '../../src/types'; +import type { Location, MatchRoutes, ReactRouterConfig, RouteMatch, RouteObject } from '../../src/types'; + +/** Builds a `ReactRouterConfig` for the `resolveRouteNameAndSource` calls that now receive it. */ +function makeConfig(overrides: Partial = {}): ReactRouterConfig { + return { + useLocation: vi.fn(), + useNavigationType: vi.fn(), + createRoutesFromChildren: vi.fn(), + matchRoutes: mockMatchRoutes, + stripBasename: false, + enableAsyncRouteHandlers: false, + instrumentNavigation: true, + lazyRouteTimeout: 3000, + lazyRouteManifest: undefined, + basename: '', + ...overrides, + }; +} vi.mock('@sentry/browser', async requireActual => { const actual = await requireActual(); @@ -36,29 +52,6 @@ const mockMatchRoutes = vi.fn(); describe('reactrouter-compat-utils/utils', () => { beforeEach(() => { vi.clearAllMocks(); - initializeRouterUtils(mockMatchRoutes as MatchRoutes, false); - }); - - describe('initializeRouterUtils', () => { - it('should initialize with matchRoutes function', () => { - expect(() => { - initializeRouterUtils(mockMatchRoutes as MatchRoutes, false); - }).not.toThrow(); - }); - - it('should handle custom matchRoutes function with dev mode true', () => { - const customMatchRoutes = vi.fn(); - expect(() => { - initializeRouterUtils(customMatchRoutes as MatchRoutes, true); - }).not.toThrow(); - }); - - it('should handle custom matchRoutes function without dev mode flag', () => { - const customMatchRoutes = vi.fn(); - expect(() => { - initializeRouterUtils(customMatchRoutes as MatchRoutes); - }).not.toThrow(); - }); }); describe('prefixWithSlash', () => { @@ -207,7 +200,7 @@ describe('reactrouter-compat-utils/utils', () => { mockMatchRoutes.mockReturnValue(mockMatches); - const result = rebuildRoutePathFromAllRoutes(allRoutes, location); + const result = rebuildRoutePathFromAllRoutes(allRoutes, location, mockMatchRoutes as MatchRoutes); expect(result).toBe('/users'); }); @@ -218,7 +211,7 @@ describe('reactrouter-compat-utils/utils', () => { mockMatchRoutes.mockReturnValue([]); - const result = rebuildRoutePathFromAllRoutes(allRoutes, location); + const result = rebuildRoutePathFromAllRoutes(allRoutes, location, mockMatchRoutes as MatchRoutes); expect(result).toBe(''); }); @@ -229,7 +222,7 @@ describe('reactrouter-compat-utils/utils', () => { mockMatchRoutes.mockReturnValue(null); - const result = rebuildRoutePathFromAllRoutes(allRoutes, location); + const result = rebuildRoutePathFromAllRoutes(allRoutes, location, mockMatchRoutes as MatchRoutes); expect(result).toBe(''); }); @@ -249,7 +242,7 @@ describe('reactrouter-compat-utils/utils', () => { mockMatchRoutes.mockReturnValue(mockMatches); - const result = rebuildRoutePathFromAllRoutes(allRoutes, location); + const result = rebuildRoutePathFromAllRoutes(allRoutes, location, mockMatchRoutes as MatchRoutes); expect(result).toBe(''); }); }); @@ -281,7 +274,7 @@ describe('reactrouter-compat-utils/utils', () => { mockMatchRoutes.mockReturnValue(mockMatches); - const result = locationIsInsideDescendantRoute(location, routes); + const result = locationIsInsideDescendantRoute(location, routes, mockMatchRoutes as MatchRoutes); expect(result).toBe(true); }); @@ -311,7 +304,7 @@ describe('reactrouter-compat-utils/utils', () => { mockMatchRoutes.mockReturnValue(mockMatches); - const result = locationIsInsideDescendantRoute(location, routes); + const result = locationIsInsideDescendantRoute(location, routes, mockMatchRoutes as MatchRoutes); expect(result).toBe(false); }); @@ -341,7 +334,7 @@ describe('reactrouter-compat-utils/utils', () => { mockMatchRoutes.mockReturnValue(mockMatches); - const result = locationIsInsideDescendantRoute(location, routes); + const result = locationIsInsideDescendantRoute(location, routes, mockMatchRoutes as MatchRoutes); expect(result).toBe(false); }); @@ -371,7 +364,7 @@ describe('reactrouter-compat-utils/utils', () => { mockMatchRoutes.mockReturnValue(mockMatches); - const result = locationIsInsideDescendantRoute(location, routes); + const result = locationIsInsideDescendantRoute(location, routes, mockMatchRoutes as MatchRoutes); expect(result).toBe(false); }); @@ -401,7 +394,7 @@ describe('reactrouter-compat-utils/utils', () => { mockMatchRoutes.mockReturnValue(mockMatches); - const result = locationIsInsideDescendantRoute(location, routes); + const result = locationIsInsideDescendantRoute(location, routes, mockMatchRoutes as MatchRoutes); expect(result).toBe(false); }); @@ -412,7 +405,7 @@ describe('reactrouter-compat-utils/utils', () => { mockMatchRoutes.mockReturnValue(null); - const result = locationIsInsideDescendantRoute(location, routes); + const result = locationIsInsideDescendantRoute(location, routes, mockMatchRoutes as MatchRoutes); expect(result).toBe(false); }); }); @@ -512,9 +505,6 @@ describe('reactrouter-compat-utils/utils', () => { }); it('should handle basename stripping', () => { - // Initialize with stripBasename = true - initializeRouterUtils(mockMatchRoutes as MatchRoutes, true); - const routes: RouteObject[] = [{ path: '/users', element: null }]; const location: Location = { pathname: '/app/users' }; const branches: RouteMatch[] = [ @@ -526,7 +516,8 @@ describe('reactrouter-compat-utils/utils', () => { }, ]; - const result = getNormalizedName(routes, location, branches, '/app'); + // stripBasename = true + const result = getNormalizedName(routes, location, branches, '/app', true); // Function falls back to url when basename stripping doesn't match exact logic expect(result).toEqual(['/users', 'url']); }); @@ -542,11 +533,6 @@ describe('reactrouter-compat-utils/utils', () => { }); describe('resolveRouteNameAndSource', () => { - beforeEach(() => { - // Reset to default stripBasename = false - initializeRouterUtils(mockMatchRoutes as MatchRoutes, false); - }); - it('should use descendant route when location is inside one', () => { const location: Location = { pathname: '/users/123/profile' }; const routes: RouteObject[] = [{ path: '/users', element: null }]; @@ -587,7 +573,7 @@ describe('reactrouter-compat-utils/utils', () => { .mockReturnValueOnce(descendantMatches) // First call for descendant check .mockReturnValueOnce(rebuildMatches); // Second call for path rebuild - const result = resolveRouteNameAndSource(location, routes, allRoutes, branches, ''); + const result = resolveRouteNameAndSource(location, routes, allRoutes, branches, makeConfig()); // Since locationIsInsideDescendantRoute returns true, it uses route source expect(result).toEqual(['/users/123/profile', 'route']); }); @@ -617,7 +603,7 @@ describe('reactrouter-compat-utils/utils', () => { mockMatchRoutes.mockReturnValue(normalMatches); - const result = resolveRouteNameAndSource(location, routes, allRoutes, branches, ''); + const result = resolveRouteNameAndSource(location, routes, allRoutes, branches, makeConfig()); expect(result).toEqual(['/users', 'route']); }); @@ -629,7 +615,7 @@ describe('reactrouter-compat-utils/utils', () => { mockMatchRoutes.mockReturnValue(null); - const result = resolveRouteNameAndSource(location, routes, allRoutes, branches, ''); + const result = resolveRouteNameAndSource(location, routes, allRoutes, branches, makeConfig()); expect(result).toEqual(['/unknown', 'url']); }); }); diff --git a/packages/react/test/reactrouterv6.test.tsx b/packages/react/test/reactrouterv6.test.tsx index ab06605718ca..b4b94eb6c5ca 100644 --- a/packages/react/test/reactrouterv6.test.tsx +++ b/packages/react/test/reactrouterv6.test.tsx @@ -94,6 +94,88 @@ describe('reactRouterV6BrowserTracingIntegration', () => { allRoutes.clear(); }); + it('falls back to uninstrumented routes when the required hooks are omitted (does not crash the app)', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + // A plain-JS consumer that skips the required hooks. TypeScript declares them as required, but nothing + // enforces that at runtime - so the wrappers must fall back to rendering the original routes rather than + // storing a partial config and later invoking an undefined hook, which would crash the host app. + client.addIntegration( + reactRouterV6BrowserTracingIntegration({} as Parameters[0]), + ); + + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const { getByText } = render( + + + About Page
} /> + + , + ); + + expect(getByText('About Page')).toBeDefined(); + // Uninstrumented: no route-based span name update or navigation instrumentation ran. + expect(mockRootSpan.updateName).not.toHaveBeenCalled(); + expect(mockStartBrowserTracingNavigationSpan).not.toHaveBeenCalled(); + }); + + it('does not remount the route tree when config appears after the first render (preserves child state)', () => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + let mountCount = 0; + function StatefulChild(): React.ReactElement { + React.useEffect(() => { + mountCount += 1; + }, []); + const [value, setValue] = React.useState(''); + return setValue(e.target.value)} />; + } + + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + function App(): React.ReactElement { + const [, forceRender] = React.useReducer((x: number) => x + 1, 0); + return ( + + + + } /> + + + ); + } + + const { getByLabelText, getByText } = render(); + + // No integration yet: config is absent and the routes render uninstrumented. + expect(mountCount).toBe(1); + + // The user interacts with the form before Sentry is initialized. + fireEvent.change(getByLabelText('field'), { target: { value: 'hello' } }); + expect((getByLabelText('field') as HTMLInputElement).value).toBe('hello'); + + // Sentry initializes after the first paint - config now exists. + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + + // A re-render happens, as it would post-init. The route subtree must NOT remount: swapping the + // route component's type (the pre-refactor behavior) would wipe the child's state. + fireEvent.click(getByText('rerender')); + + expect(mountCount).toBe(1); + expect((getByLabelText('field') as HTMLInputElement).value).toBe('hello'); + }); + it('wrapCreateMemoryRouterV6 starts and updates a pageload transaction - single initialEntry', () => { const client = createMockBrowserClient(); setCurrentClient(client); diff --git a/yarn.lock b/yarn.lock index e5fe1ef8f534..2450dd56c796 100644 --- a/yarn.lock +++ b/yarn.lock @@ -23758,10 +23758,10 @@ react-router@6.30.4: dependencies: "@remix-run/router" "1.23.3" -react-router@^7.18.0: - version "7.18.0" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.18.0.tgz#e7d94b54745277aabe3cf93fac938cbebc9c1c5e" - integrity sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ== +react-router@^7.18.0, react-router@^7.18.3: + version "7.18.3" + resolved "https://sfw.security.sentry.io/npm/react-router/-/react-router-7.18.3.tgz#2a3257aa7c5edd5a71f878063e4c7f3fcfc4b76a" + integrity sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA== dependencies: cookie "^1.0.1" set-cookie-parser "^2.6.0"