diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/.gitignore b/dev-packages/e2e-tests/test-applications/react-router-7-spa/.gitignore new file mode 100644 index 000000000000..84634c973eeb --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/.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-spa/.npmrc b/dev-packages/e2e-tests/test-applications/react-router-7-spa/.npmrc new file mode 100644 index 000000000000..070f80f05092 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/.npmrc @@ -0,0 +1,2 @@ +@sentry:registry=http://127.0.0.1:4873 +@sentry-internal:registry=http://127.0.0.1:4873 diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/index.html b/dev-packages/e2e-tests/test-applications/react-router-7-spa/index.html new file mode 100644 index 000000000000..e4b78eae1230 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json new file mode 100644 index 000000000000..e9009b1c2aa3 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/package.json @@ -0,0 +1,60 @@ +{ + "name": "react-router-7-spa", + "version": "0.1.0", + "private": true, + "dependencies": { + "@sentry/react": "latest || *", + "@types/react": "18.3.1", + "@types/react-dom": "18.3.1", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router": "^7.0.1" + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@sentry-internal/test-utils": "link:../../../test-utils", + "vite": "^6.0.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "4.9.5" + }, + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview", + "test": "playwright test", + "clean": "npx rimraf node_modules pnpm-lock.yaml", + "test:build": "pnpm install && npx playwright install && pnpm build", + "test:build-ts3.8": "pnpm install && pnpm add typescript@3.8 && npx playwright install && pnpm build", + "test:build-canary": "pnpm install && pnpm add react@canary react-dom@canary && npx playwright install && pnpm build", + "test:assert": "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" + }, + "sentryTest": { + "variants": [ + { + "build-command": "test:build-ts3.8", + "label": "react-router-7-spa (TS 3.8)" + } + ] + } +} diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/playwright.config.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-spa/playwright.config.mjs new file mode 100644 index 000000000000..7fda76df18ae --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/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-spa/src/globals.d.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/globals.d.ts new file mode 100644 index 000000000000..ffa61ca49acc --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/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-spa/src/main.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/main.tsx new file mode 100644 index 000000000000..a49c2c35de9d --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/main.tsx @@ -0,0 +1,55 @@ +import * as Sentry from '@sentry/react'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { + BrowserRouter, + Route, + Routes, + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router'; +import Index from './pages/Index'; +import SSE from './pages/SSE'; +import User from './pages/User'; + +const replay = Sentry.replayIntegration(); + +Sentry.init({ + environment: 'qa', // dynamic sampling bias to keep transactions + dsn: import.meta.env.PUBLIC_E2E_TEST_DSN, + integrations: [ + Sentry.reactRouterV7BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + trackFetchStreamPerformance: true, + }), + replay, + ], + // We recommend adjusting this value in production, or using tracesSampler + // for finer control + tracesSampleRate: 1.0, + release: 'e2e-test', + + // Always capture replays, so we can test this properly + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + tunnel: 'http://localhost:3031', +}); + +const SentryRoutes = Sentry.withSentryReactRouterV7Routing(Routes); + +const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); +root.render( + + + } /> + } /> + } /> + + , +); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/pages/Index.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/pages/Index.tsx new file mode 100644 index 000000000000..3f660ed4f4f7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/pages/Index.tsx @@ -0,0 +1,23 @@ +// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX +import * as React from 'react'; +import { Link } from 'react-router'; + +const Index = () => { + return ( + <> + { + throw new Error('I am an error!'); + }} + /> + + navigate + + + ); +}; + +export default Index; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/pages/SSE.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/pages/SSE.tsx new file mode 100644 index 000000000000..64a9f5717114 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/pages/SSE.tsx @@ -0,0 +1,59 @@ +import * as Sentry from '@sentry/react'; +// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX +import * as React from 'react'; + +const fetchSSE = async ({ timeout, abort = false }: { timeout: boolean; abort?: boolean }) => { + Sentry.startSpanManual({ name: 'sse stream using fetch' }, async span => { + const controller = new AbortController(); + + const res = await Sentry.startSpan({ name: 'sse fetch call' }, async () => { + const endpoint = `http://localhost:8080/${timeout ? 'sse-timeout' : 'sse'}`; + + const signal = controller.signal; + return await fetch(endpoint, { signal }); + }); + + const stream = res.body; + const reader = stream?.getReader(); + + const readChunk = async () => { + if (abort) { + controller.abort(); + } + const readRes = await reader?.read(); + if (readRes?.done) { + return; + } + + new TextDecoder().decode(readRes?.value); + + await readChunk(); + }; + + try { + await readChunk(); + } catch (error) { + console.error('Could not fetch sse', error); + } + + span.end(); + }); +}; + +const SSE = () => { + return ( + <> + + + + + ); +}; + +export default SSE; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/pages/User.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/pages/User.tsx new file mode 100644 index 000000000000..62f0c2d17533 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/src/pages/User.tsx @@ -0,0 +1,8 @@ +// biome-ignore lint/nursery/noUnusedImports: Need React import for JSX +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-spa/start-event-proxy.mjs b/dev-packages/e2e-tests/test-applications/react-router-7-spa/start-event-proxy.mjs new file mode 100644 index 000000000000..cd75ae3ae830 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/start-event-proxy.mjs @@ -0,0 +1,6 @@ +import { startEventProxyServer } from '@sentry-internal/test-utils'; + +startEventProxyServer({ + port: 3031, + proxyServerName: 'react-router-7-spa', +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/errors.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/errors.test.ts new file mode 100644 index 000000000000..e31d3c4066d4 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/errors.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from '@playwright/test'; +import { waitForError, waitForTransaction } from '@sentry-internal/test-utils'; + +test('Sends correct error event', async ({ page, baseURL }) => { + const errorEventPromise = waitForError('react-router-7-spa', 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 transactionPromise = waitForTransaction('react-router-7-spa', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const errorEventPromise = waitForError('react-router-7-spa', event => { + return !event.type && event.exception?.values?.[0]?.value === 'I am an error!'; + }); + + await page.goto('/'); + const transactionEvent = await transactionPromise; + + // Only capture error once transaction 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: transactionEvent.contexts?.trace?.trace_id, + span_id: expect.not.stringContaining(transactionEvent.contexts?.trace?.span_id || ''), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/transactions.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/transactions.test.ts new file mode 100644 index 000000000000..c915d3694742 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tests/transactions.test.ts @@ -0,0 +1,99 @@ +import { expect, test } from '@playwright/test'; +import { waitForEnvelopeItem, waitForTransaction } from '@sentry-internal/test-utils'; + +test('sends a pageload transaction with a parameterized URL', async ({ page }) => { + const transactionPromise = waitForTransaction('react-router-7-spa', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + await page.goto(`/`); + + const rootSpan = await transactionPromise; + + expect(rootSpan).toMatchObject({ + contexts: { + trace: { + op: 'pageload', + origin: 'auto.pageload.react.reactrouter_v7', + }, + }, + transaction: '/', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends a navigation transaction with a parameterized URL', async ({ page }) => { + page.on('console', msg => console.log(msg.text())); + const pageloadTxnPromise = waitForTransaction('react-router-7-spa', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'pageload'; + }); + + const navigationTxnPromise = waitForTransaction('react-router-7-spa', async transactionEvent => { + return !!transactionEvent?.transaction && transactionEvent.contexts?.trace?.op === 'navigation'; + }); + + await page.goto(`/`); + await pageloadTxnPromise; + + const linkElement = page.locator('id=navigation'); + + const [_, navigationTxn] = await Promise.all([linkElement.click(), navigationTxnPromise]); + + expect(navigationTxn).toMatchObject({ + contexts: { + trace: { + op: 'navigation', + origin: 'auto.navigation.react.reactrouter_v7', + }, + }, + transaction: '/user/:id', + transaction_info: { + source: 'route', + }, + }); +}); + +test('sends an INP span', async ({ page }) => { + const inpSpanPromise = waitForEnvelopeItem('react-router-7-spa', item => { + return item[0].type === 'span'; + }); + + await page.goto(`/`); + + await page.click('#exception-button'); + + await page.waitForTimeout(500); + + // Page hide to trigger INP + await page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); + + const inpSpan = await inpSpanPromise; + + expect(inpSpan[1]).toEqual({ + data: { + 'sentry.origin': 'auto.http.browser.inp', + 'sentry.op': 'ui.interaction.click', + release: 'e2e-test', + environment: 'qa', + transaction: '/', + 'sentry.exclusive_time': expect.any(Number), + replay_id: expect.any(String), + 'user_agent.original': expect.stringContaining('Chrome'), + }, + description: 'body > div#root > input#exception-button[type="button"]', + op: 'ui.interaction.click', + parent_span_id: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: 'auto.http.browser.inp', + exclusive_time: expect.any(Number), + measurements: { inp: { unit: 'millisecond', value: expect.any(Number) } }, + segment_id: expect.any(String), + }); +}); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-spa/tsconfig.json b/dev-packages/e2e-tests/test-applications/react-router-7-spa/tsconfig.json new file mode 100644 index 000000000000..60051df13cbf --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/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": "node", + "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-spa/vite.config.ts b/dev-packages/e2e-tests/test-applications/react-router-7-spa/vite.config.ts new file mode 100644 index 000000000000..63c2c4317df7 --- /dev/null +++ b/dev-packages/e2e-tests/test-applications/react-router-7-spa/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/docs/migration/draft-v9-migration-guide.md b/docs/migration/draft-v9-migration-guide.md index b70b314d8f98..3b915d45dd30 100644 --- a/docs/migration/draft-v9-migration-guide.md +++ b/docs/migration/draft-v9-migration-guide.md @@ -99,6 +99,11 @@ - Deprecated `autoInstrumentRemix: false`. The next major version will default to behaving as if this option were `true` and the option itself will be removed. +## `@sentry/react` + +- Deprecated `wrapUseRoutes`. Use `wrapUseRoutesV6` or `wrapUseRoutesV7` instead. +- Deprecated `wrapCreateBrowserRouter`. Use `wrapCreateBrowserRouterV6` or `wrapCreateBrowserRouterV7` instead. + ## `@sentry/opentelemetry` - Deprecated `generateSpanContextForPropagationContext` in favor of doing this manually - we do not need this export anymore. diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 5fe088ec448b..e72eb09645ec 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -16,6 +16,16 @@ export { export { reactRouterV6BrowserTracingIntegration, withSentryReactRouterV6Routing, + // eslint-disable-next-line deprecation/deprecation wrapUseRoutes, + wrapUseRoutesV6, + // eslint-disable-next-line deprecation/deprecation wrapCreateBrowserRouter, + wrapCreateBrowserRouterV6, } from './reactrouterv6'; +export { + reactRouterV7BrowserTracingIntegration, + withSentryReactRouterV7Routing, + wrapCreateBrowserRouterV7, + wrapUseRoutesV7, +} from './reactrouterv7'; diff --git a/packages/react/src/reactrouterv6-compat-utils.tsx b/packages/react/src/reactrouterv6-compat-utils.tsx new file mode 100644 index 000000000000..7752e49d69a1 --- /dev/null +++ b/packages/react/src/reactrouterv6-compat-utils.tsx @@ -0,0 +1,436 @@ +/* eslint-disable max-lines */ +// Inspired from Donnie McNeal's solution: +// https://gist.github.com/wontondon/e8c4bdf2888875e4c755712e99279536 + +import { + WINDOW, + browserTracingIntegration, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, +} from '@sentry/browser'; +import type { Client, Integration, Span, TransactionSource } from '@sentry/core'; +import { + SEMANTIC_ATTRIBUTE_SENTRY_OP, + SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, + SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, + getActiveSpan, + getClient, + getCurrentScope, + getNumberOfUrlSegments, + getRootSpan, + logger, + spanToJSON, +} from '@sentry/core'; +import * as React from 'react'; + +import hoistNonReactStatics from 'hoist-non-react-statics'; +import { DEBUG_BUILD } from './debug-build'; +import type { + Action, + AgnosticDataRouteMatch, + CreateRouterFunction, + CreateRoutesFromChildren, + Location, + MatchRoutes, + RouteMatch, + RouteObject, + Router, + RouterState, + UseEffect, + UseLocation, + UseNavigationType, + UseRoutes, +} from './types'; + +let _useEffect: UseEffect; +let _useLocation: UseLocation; +let _useNavigationType: UseNavigationType; +let _createRoutesFromChildren: CreateRoutesFromChildren; +let _matchRoutes: MatchRoutes; +let _stripBasename: boolean = false; + +const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); + +export interface ReactRouterOptions { + useEffect: UseEffect; + useLocation: UseLocation; + useNavigationType: UseNavigationType; + createRoutesFromChildren: CreateRoutesFromChildren; + matchRoutes: MatchRoutes; + stripBasename?: boolean; +} + +type V6CompatibleVersion = '6' | '7'; + +/** + * Creates a wrapCreateBrowserRouter function that can be used with all React Router v6 compatible versions. + */ +export function createV6CompatibleWrapCreateBrowserRouter< + TState extends RouterState = RouterState, + TRouter extends Router = Router, +>( + createRouterFunction: CreateRouterFunction, + version: V6CompatibleVersion, +): CreateRouterFunction { + if (!_useEffect || !_useLocation || !_useNavigationType || !_matchRoutes) { + DEBUG_BUILD && + logger.warn( + `reactRouterV${version}Instrumentation was unable to wrap the \`createRouter\` function because of one or more missing parameters.`, + ); + + return createRouterFunction; + } + + // `opts` for createBrowserHistory and createMemoryHistory are different, but also not relevant for us at the moment. + // `basename` is the only option that is relevant for us, and it is the same for all. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function (routes: RouteObject[], opts?: Record & { basename?: string }): TRouter { + const router = createRouterFunction(routes, opts); + const basename = opts && opts.basename; + + const activeRootSpan = getActiveRootSpan(); + + // The initial load ends when `createBrowserRouter` is called. + // This is the earliest convenient time to update the transaction name. + // Callbacks to `router.subscribe` are not called for the initial load. + if (router.state.historyAction === 'POP' && activeRootSpan) { + updatePageloadTransaction(activeRootSpan, router.state.location, routes, undefined, basename); + } + + router.subscribe((state: RouterState) => { + const location = state.location; + if (state.historyAction === 'PUSH' || state.historyAction === 'POP') { + handleNavigation(location, routes, state.historyAction, version, undefined, basename); + } + }); + + return router; + }; +} + +/** + * Creates a browser tracing integration that can be used with all React Router v6 compatible versions. + */ +export function createReactRouterV6CompatibleTracingIntegration( + options: Parameters[0] & ReactRouterOptions, + version: V6CompatibleVersion, +): Integration { + const integration = browserTracingIntegration({ + ...options, + instrumentPageLoad: false, + instrumentNavigation: false, + }); + + const { + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + stripBasename, + instrumentPageLoad = true, + instrumentNavigation = true, + } = options; + + return { + ...integration, + setup() { + _useEffect = useEffect; + _useLocation = useLocation; + _useNavigationType = useNavigationType; + _matchRoutes = matchRoutes; + _createRoutesFromChildren = createRoutesFromChildren; + _stripBasename = stripBasename || false; + }, + afterAllSetup(client) { + integration.afterAllSetup(client); + + const initPathName = WINDOW && WINDOW.location && WINDOW.location.pathname; + if (instrumentPageLoad && initPathName) { + startBrowserTracingPageLoadSpan(client, { + name: initPathName, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.pageload.react.reactrouter_v${version}`, + }, + }); + } + + if (instrumentNavigation) { + CLIENTS_WITH_INSTRUMENT_NAVIGATION.add(client); + } + }, + }; +} + +export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, version: V6CompatibleVersion): UseRoutes { + if (!_useEffect || !_useLocation || !_useNavigationType || !_matchRoutes) { + DEBUG_BUILD && + logger.warn( + 'reactRouterV6Instrumentation was unable to wrap `useRoutes` because of one or more missing parameters.', + ); + + return origUseRoutes; + } + + let isMountRenderPass: boolean = true; + + const SentryRoutes: React.FC<{ + children?: React.ReactNode; + routes: RouteObject[]; + locationArg?: Partial | string; + }> = (props: { children?: React.ReactNode; routes: RouteObject[]; locationArg?: Partial | string }) => { + const { routes, locationArg } = props; + + const Routes = origUseRoutes(routes, locationArg); + + const location = _useLocation(); + const navigationType = _useNavigationType(); + + // A value with stable identity to either pick `locationArg` if available or `location` if not + const stableLocationParam = + typeof locationArg === 'string' || (locationArg && locationArg.pathname) + ? (locationArg as { pathname: string }) + : location; + + _useEffect(() => { + const normalizedLocation = + typeof stableLocationParam === 'string' ? { pathname: stableLocationParam } : stableLocationParam; + + if (isMountRenderPass) { + updatePageloadTransaction(getActiveRootSpan(), normalizedLocation, routes); + isMountRenderPass = false; + } else { + handleNavigation(normalizedLocation, routes, navigationType, version); + } + }, [navigationType, stableLocationParam]); + + return Routes; + }; + + // eslint-disable-next-line react/display-name + return (routes: RouteObject[], locationArg?: Partial | string): React.ReactElement | null => { + return ; + }; +} + +export function handleNavigation( + location: Location, + routes: RouteObject[], + navigationType: Action, + version: V6CompatibleVersion, + matches?: AgnosticDataRouteMatch, + basename?: string, +): void { + const branches = Array.isArray(matches) ? matches : _matchRoutes(routes, location, basename); + + const client = getClient(); + if (!client || !CLIENTS_WITH_INSTRUMENT_NAVIGATION.has(client)) { + return; + } + + if ((navigationType === 'PUSH' || navigationType === 'POP') && branches) { + const [name, source] = getNormalizedName(routes, location, branches, basename); + + startBrowserTracingNavigationSpan(client, { + name, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, + [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: `auto.navigation.react.reactrouter_v${version}`, + }, + }); + } +} + +/** + * Strip the basename from a pathname if exists. + * + * Vendored and modified from `react-router` + * https://github.com/remix-run/react-router/blob/462bb712156a3f739d6139a0f14810b76b002df6/packages/router/utils.ts#L1038 + */ +function stripBasenameFromPathname(pathname: string, basename: string): string { + if (!basename || basename === '/') { + return pathname; + } + + if (!pathname.toLowerCase().startsWith(basename.toLowerCase())) { + return pathname; + } + + // We want to leave trailing slash behavior in the user's control, so if they + // specify a basename with a trailing slash, we should support it + const startIndex = basename.endsWith('/') ? basename.length - 1 : basename.length; + const nextChar = pathname.charAt(startIndex); + if (nextChar && nextChar !== '/') { + // pathname does not start with basename/ + return pathname; + } + + return pathname.slice(startIndex) || '/'; +} + +function sendIndexPath(pathBuilder: string, pathname: string, basename: string): [string, TransactionSource] { + const reconstructedPath = pathBuilder || _stripBasename ? stripBasenameFromPathname(pathname, basename) : pathname; + + const formattedPath = + // If the path ends with a slash, remove it + reconstructedPath[reconstructedPath.length - 1] === '/' + ? reconstructedPath.slice(0, -1) + : // If the path ends with a wildcard, remove it + reconstructedPath.slice(-2) === '/*' + ? reconstructedPath.slice(0, -1) + : reconstructedPath; + + return [formattedPath, 'route']; +} + +function pathEndsWithWildcard(path: string, branch: RouteMatch): boolean { + return (path.slice(-2) === '/*' && branch.route.children && branch.route.children.length > 0) || false; +} + +function pathIsWildcardAndHasChildren(path: string, branch: RouteMatch): boolean { + return (path === '*' && branch.route.children && branch.route.children.length > 0) || false; +} + +function getNormalizedName( + routes: RouteObject[], + location: Location, + branches: RouteMatch[], + basename: string = '', +): [string, TransactionSource] { + if (!routes || routes.length === 0) { + return [_stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url']; + } + + let pathBuilder = ''; + if (branches) { + for (const branch of branches) { + const route = branch.route; + if (route) { + // Early return if index route + if (route.index) { + return sendIndexPath(pathBuilder, branch.pathname, basename); + } + const path = route.path; + + // If path is not a wildcard and has no child routes, append the path + if (path && !pathIsWildcardAndHasChildren(path, branch)) { + const newPath = path[0] === '/' || pathBuilder[pathBuilder.length - 1] === '/' ? path : `/${path}`; + pathBuilder += newPath; + + // If the path matches the current location, return the path + if (basename + branch.pathname === location.pathname) { + if ( + // If the route defined on the element is something like + // Product} /> + // We should check against the branch.pathname for the number of / separators + // TODO(v9): Put the implementation of `getNumberOfUrlSegments` in this file + // eslint-disable-next-line deprecation/deprecation + getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) && + // We should not count wildcard operators in the url segments calculation + pathBuilder.slice(-2) !== '/*' + ) { + return [(_stripBasename ? '' : basename) + newPath, 'route']; + } + + // if the last character of the pathbuilder is a wildcard and there are children, remove the wildcard + if (pathEndsWithWildcard(pathBuilder, branch)) { + pathBuilder = pathBuilder.slice(0, -1); + } + + return [(_stripBasename ? '' : basename) + pathBuilder, 'route']; + } + } + } + } + } + + return [_stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url']; +} + +function updatePageloadTransaction( + activeRootSpan: Span | undefined, + location: Location, + routes: RouteObject[], + matches?: AgnosticDataRouteMatch, + basename?: string, +): void { + const branches = Array.isArray(matches) + ? matches + : (_matchRoutes(routes, location, basename) as unknown as RouteMatch[]); + + if (branches) { + const [name, source] = getNormalizedName(routes, location, branches, basename); + + getCurrentScope().setTransactionName(name); + + if (activeRootSpan) { + activeRootSpan.updateName(name); + activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); + } + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function createV6CompatibleWithSentryReactRouterRouting

, R extends React.FC

>( + Routes: R, + version: V6CompatibleVersion, +): R { + if (!_useEffect || !_useLocation || !_useNavigationType || !_createRoutesFromChildren || !_matchRoutes) { + DEBUG_BUILD && + logger.warn(`reactRouterV6Instrumentation was unable to wrap Routes because of one or more missing parameters. + useEffect: ${_useEffect}. useLocation: ${_useLocation}. useNavigationType: ${_useNavigationType}. + createRoutesFromChildren: ${_createRoutesFromChildren}. matchRoutes: ${_matchRoutes}.`); + + return Routes; + } + + let isMountRenderPass: boolean = true; + + const SentryRoutes: React.FC

= (props: P) => { + const location = _useLocation(); + const navigationType = _useNavigationType(); + + _useEffect( + () => { + const routes = _createRoutesFromChildren(props.children) as RouteObject[]; + + if (isMountRenderPass) { + updatePageloadTransaction(getActiveRootSpan(), location, routes); + isMountRenderPass = false; + } else { + handleNavigation(location, routes, navigationType, version); + } + }, + // `props.children` is purposely not included in the dependency array, because we do not want to re-run this effect + // when the children change. We only want to start transactions when the location or navigation type change. + [location, navigationType], + ); + + // @ts-expect-error Setting more specific React Component typing for `R` generic above + // will break advanced type inference done by react router params + return ; + }; + + hoistNonReactStatics(SentryRoutes, Routes); + + // @ts-expect-error Setting more specific React Component typing for `R` generic above + // will break advanced type inference done by react router params + return SentryRoutes; +} + +function getActiveRootSpan(): Span | undefined { + const span = getActiveSpan(); + const rootSpan = span ? getRootSpan(span) : undefined; + + if (!rootSpan) { + return undefined; + } + + const op = spanToJSON(rootSpan).op; + + // Only use this root span if it is a pageload or navigation span + return op === 'navigation' || op === 'pageload' ? rootSpan : undefined; +} diff --git a/packages/react/src/reactrouterv6.tsx b/packages/react/src/reactrouterv6.tsx index 1ff2431586a5..f913612c99c7 100644 --- a/packages/react/src/reactrouterv6.tsx +++ b/packages/react/src/reactrouterv6.tsx @@ -1,63 +1,14 @@ -/* eslint-disable max-lines */ -// Inspired from Donnie McNeal's solution: -// https://gist.github.com/wontondon/e8c4bdf2888875e4c755712e99279536 - -import { - WINDOW, - browserTracingIntegration, - startBrowserTracingNavigationSpan, - startBrowserTracingPageLoadSpan, -} from '@sentry/browser'; +import type { browserTracingIntegration } from '@sentry/browser'; +import type { ReactRouterOptions } from './reactrouterv6-compat-utils'; import { - SEMANTIC_ATTRIBUTE_SENTRY_OP, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - getActiveSpan, - getClient, - getCurrentScope, - getRootSpan, - spanToJSON, -} from '@sentry/core'; -import { getNumberOfUrlSegments, logger } from '@sentry/core'; -import type { Client, Integration, Span, TransactionSource } from '@sentry/core'; -import hoistNonReactStatics from 'hoist-non-react-statics'; -import * as React from 'react'; - -import { DEBUG_BUILD } from './debug-build'; -import type { - Action, - AgnosticDataRouteMatch, - CreateRouterFunction, - CreateRoutesFromChildren, - Location, - MatchRoutes, - RouteMatch, - RouteObject, - Router, - RouterState, - UseEffect, - UseLocation, - UseNavigationType, - UseRoutes, -} from './types'; - -let _useEffect: UseEffect; -let _useLocation: UseLocation; -let _useNavigationType: UseNavigationType; -let _createRoutesFromChildren: CreateRoutesFromChildren; -let _matchRoutes: MatchRoutes; -let _stripBasename: boolean = false; - -const CLIENTS_WITH_INSTRUMENT_NAVIGATION = new WeakSet(); + createReactRouterV6CompatibleTracingIntegration, + createV6CompatibleWithSentryReactRouterRouting, + createV6CompatibleWrapCreateBrowserRouter, + createV6CompatibleWrapUseRoutes, +} from './reactrouterv6-compat-utils'; -interface ReactRouterOptions { - useEffect: UseEffect; - useLocation: UseLocation; - useNavigationType: UseNavigationType; - createRoutesFromChildren: CreateRoutesFromChildren; - matchRoutes: MatchRoutes; - stripBasename?: boolean; -} +import type { Integration } from '@sentry/core'; +import type { CreateRouterFunction, Router, RouterState, UseRoutes } from './types'; /** * A browser tracing integration that uses React Router v6 to instrument navigations. @@ -66,358 +17,45 @@ interface ReactRouterOptions { export function reactRouterV6BrowserTracingIntegration( options: Parameters[0] & ReactRouterOptions, ): Integration { - const integration = browserTracingIntegration({ - ...options, - instrumentPageLoad: false, - instrumentNavigation: false, - }); - - const { - useEffect, - useLocation, - useNavigationType, - createRoutesFromChildren, - matchRoutes, - stripBasename, - instrumentPageLoad = true, - instrumentNavigation = true, - } = options; - - return { - ...integration, - setup() { - _useEffect = useEffect; - _useLocation = useLocation; - _useNavigationType = useNavigationType; - _matchRoutes = matchRoutes; - _createRoutesFromChildren = createRoutesFromChildren; - _stripBasename = stripBasename || false; - }, - afterAllSetup(client) { - integration.afterAllSetup(client); - - const initPathName = WINDOW && WINDOW.location && WINDOW.location.pathname; - if (instrumentPageLoad && initPathName) { - startBrowserTracingPageLoadSpan(client, { - name: initPathName, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'url', - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'pageload', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.react.reactrouter_v6', - }, - }); - } - - if (instrumentNavigation) { - CLIENTS_WITH_INSTRUMENT_NAVIGATION.add(client); - } - }, - }; + return createReactRouterV6CompatibleTracingIntegration(options, '6'); } /** - * Strip the basename from a pathname if exists. - * - * Vendored and modified from `react-router` - * https://github.com/remix-run/react-router/blob/462bb712156a3f739d6139a0f14810b76b002df6/packages/router/utils.ts#L1038 + * A wrapper function that adds Sentry routing instrumentation to a React Router v6 useRoutes hook. + * This is used to automatically capture route changes as transactions when using the useRoutes hook. */ -function stripBasenameFromPathname(pathname: string, basename: string): string { - if (!basename || basename === '/') { - return pathname; - } - - if (!pathname.toLowerCase().startsWith(basename.toLowerCase())) { - return pathname; - } - - // We want to leave trailing slash behavior in the user's control, so if they - // specify a basename with a trailing slash, we should support it - const startIndex = basename.endsWith('/') ? basename.length - 1 : basename.length; - const nextChar = pathname.charAt(startIndex); - if (nextChar && nextChar !== '/') { - // pathname does not start with basename/ - return pathname; - } - - return pathname.slice(startIndex) || '/'; -} - -function sendIndexPath(pathBuilder: string, pathname: string, basename: string): [string, TransactionSource] { - const reconstructedPath = pathBuilder || _stripBasename ? stripBasenameFromPathname(pathname, basename) : pathname; - - const formattedPath = - // If the path ends with a slash, remove it - reconstructedPath[reconstructedPath.length - 1] === '/' - ? reconstructedPath.slice(0, -1) - : // If the path ends with a wildcard, remove it - reconstructedPath.slice(-2) === '/*' - ? reconstructedPath.slice(0, -1) - : reconstructedPath; - - return [formattedPath, 'route']; -} - -function pathEndsWithWildcard(path: string, branch: RouteMatch): boolean { - return (path.slice(-2) === '/*' && branch.route.children && branch.route.children.length > 0) || false; -} - -function pathIsWildcardAndHasChildren(path: string, branch: RouteMatch): boolean { - return (path === '*' && branch.route.children && branch.route.children.length > 0) || false; -} - -function getNormalizedName( - routes: RouteObject[], - location: Location, - branches: RouteMatch[], - basename: string = '', -): [string, TransactionSource] { - if (!routes || routes.length === 0) { - return [_stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url']; - } - - let pathBuilder = ''; - if (branches) { - for (const branch of branches) { - const route = branch.route; - if (route) { - // Early return if index route - if (route.index) { - return sendIndexPath(pathBuilder, branch.pathname, basename); - } - const path = route.path; - - // If path is not a wildcard and has no child routes, append the path - if (path && !pathIsWildcardAndHasChildren(path, branch)) { - const newPath = path[0] === '/' || pathBuilder[pathBuilder.length - 1] === '/' ? path : `/${path}`; - pathBuilder += newPath; - - // If the path matches the current location, return the path - if (basename + branch.pathname === location.pathname) { - if ( - // If the route defined on the element is something like - // Product} /> - // We should check against the branch.pathname for the number of / separators - // TODO(v9): Put the implementation of `getNumberOfUrlSegments` in this file - // eslint-disable-next-line deprecation/deprecation - getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) && - // We should not count wildcard operators in the url segments calculation - pathBuilder.slice(-2) !== '/*' - ) { - return [(_stripBasename ? '' : basename) + newPath, 'route']; - } - - // if the last character of the pathbuilder is a wildcard and there are children, remove the wildcard - if (pathEndsWithWildcard(pathBuilder, branch)) { - pathBuilder = pathBuilder.slice(0, -1); - } - - return [(_stripBasename ? '' : basename) + pathBuilder, 'route']; - } - } - } - } - } - - return [_stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url']; -} - -function updatePageloadTransaction( - activeRootSpan: Span | undefined, - location: Location, - routes: RouteObject[], - matches?: AgnosticDataRouteMatch, - basename?: string, -): void { - const branches = Array.isArray(matches) - ? matches - : (_matchRoutes(routes, location, basename) as unknown as RouteMatch[]); - - if (branches) { - const [name, source] = getNormalizedName(routes, location, branches, basename); - - getCurrentScope().setTransactionName(name); - - if (activeRootSpan) { - activeRootSpan.updateName(name); - activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source); - } - } -} - -function handleNavigation( - location: Location, - routes: RouteObject[], - navigationType: Action, - matches?: AgnosticDataRouteMatch, - basename?: string, -): void { - const branches = Array.isArray(matches) ? matches : _matchRoutes(routes, location, basename); - - const client = getClient(); - if (!client || !CLIENTS_WITH_INSTRUMENT_NAVIGATION.has(client)) { - return; - } - - if ((navigationType === 'PUSH' || navigationType === 'POP') && branches) { - const [name, source] = getNormalizedName(routes, location, branches, basename); - - startBrowserTracingNavigationSpan(client, { - name, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source, - [SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6', - }, - }); - } +export function wrapUseRoutesV6(origUseRoutes: UseRoutes): UseRoutes { + return createV6CompatibleWrapUseRoutes(origUseRoutes, '6'); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function withSentryReactRouterV6Routing

, R extends React.FC

>(Routes: R): R { - if (!_useEffect || !_useLocation || !_useNavigationType || !_createRoutesFromChildren || !_matchRoutes) { - DEBUG_BUILD && - logger.warn(`reactRouterV6Instrumentation was unable to wrap Routes because of one or more missing parameters. - useEffect: ${_useEffect}. useLocation: ${_useLocation}. useNavigationType: ${_useNavigationType}. - createRoutesFromChildren: ${_createRoutesFromChildren}. matchRoutes: ${_matchRoutes}.`); - - return Routes; - } - - let isMountRenderPass: boolean = true; - - const SentryRoutes: React.FC

= (props: P) => { - const location = _useLocation(); - const navigationType = _useNavigationType(); - - _useEffect( - () => { - const routes = _createRoutesFromChildren(props.children) as RouteObject[]; - - if (isMountRenderPass) { - updatePageloadTransaction(getActiveRootSpan(), location, routes); - isMountRenderPass = false; - } else { - handleNavigation(location, routes, navigationType); - } - }, - // `props.children` is purposely not included in the dependency array, because we do not want to re-run this effect - // when the children change. We only want to start transactions when the location or navigation type change. - [location, navigationType], - ); - - // @ts-expect-error Setting more specific React Component typing for `R` generic above - // will break advanced type inference done by react router params - return ; - }; - - hoistNonReactStatics(SentryRoutes, Routes); - - // @ts-expect-error Setting more specific React Component typing for `R` generic above - // will break advanced type inference done by react router params - return SentryRoutes; -} - -export function wrapUseRoutes(origUseRoutes: UseRoutes): UseRoutes { - if (!_useEffect || !_useLocation || !_useNavigationType || !_matchRoutes) { - DEBUG_BUILD && - logger.warn( - 'reactRouterV6Instrumentation was unable to wrap `useRoutes` because of one or more missing parameters.', - ); - - return origUseRoutes; - } - - let isMountRenderPass: boolean = true; - - const SentryRoutes: React.FC<{ - children?: React.ReactNode; - routes: RouteObject[]; - locationArg?: Partial | string; - }> = (props: { children?: React.ReactNode; routes: RouteObject[]; locationArg?: Partial | string }) => { - const { routes, locationArg } = props; - - const Routes = origUseRoutes(routes, locationArg); - - const location = _useLocation(); - const navigationType = _useNavigationType(); - - // A value with stable identity to either pick `locationArg` if available or `location` if not - const stableLocationParam = - typeof locationArg === 'string' || (locationArg && locationArg.pathname) - ? (locationArg as { pathname: string }) - : location; - - _useEffect(() => { - const normalizedLocation = - typeof stableLocationParam === 'string' ? { pathname: stableLocationParam } : stableLocationParam; - - if (isMountRenderPass) { - updatePageloadTransaction(getActiveRootSpan(), normalizedLocation, routes); - isMountRenderPass = false; - } else { - handleNavigation(normalizedLocation, routes, navigationType); - } - }, [navigationType, stableLocationParam]); - - return Routes; - }; - - // eslint-disable-next-line react/display-name - return (routes: RouteObject[], locationArg?: Partial | string): React.ReactElement | null => { - return ; - }; -} +/** + * Alias for backwards compatibility + * @deprecated Use `wrapUseRoutesV6` or `wrapUseRoutesV7` instead. + */ +export const wrapUseRoutes = wrapUseRoutesV6; -export function wrapCreateBrowserRouter< +/** + * A wrapper function that adds Sentry routing instrumentation to a React Router v6 createBrowserRouter function. + * This is used to automatically capture route changes as transactions when using the createBrowserRouter API. + */ +export function wrapCreateBrowserRouterV6< TState extends RouterState = RouterState, TRouter extends Router = Router, >(createRouterFunction: CreateRouterFunction): CreateRouterFunction { - if (!_useEffect || !_useLocation || !_useNavigationType || !_matchRoutes) { - DEBUG_BUILD && - logger.warn( - 'reactRouterV6Instrumentation was unable to wrap the `createRouter` function because of one or more missing parameters.', - ); - - return createRouterFunction; - } - - // `opts` for createBrowserHistory and createMemoryHistory are different, but also not relevant for us at the moment. - // `basename` is the only option that is relevant for us, and it is the same for all. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return function (routes: RouteObject[], opts?: Record & { basename?: string }): TRouter { - const router = createRouterFunction(routes, opts); - const basename = opts && opts.basename; - - const activeRootSpan = getActiveRootSpan(); - - // The initial load ends when `createBrowserRouter` is called. - // This is the earliest convenient time to update the transaction name. - // Callbacks to `router.subscribe` are not called for the initial load. - if (router.state.historyAction === 'POP' && activeRootSpan) { - updatePageloadTransaction(activeRootSpan, router.state.location, routes, undefined, basename); - } - - router.subscribe((state: RouterState) => { - const location = state.location; - if (state.historyAction === 'PUSH' || state.historyAction === 'POP') { - handleNavigation(location, routes, state.historyAction, undefined, basename); - } - }); - - return router; - }; + return createV6CompatibleWrapCreateBrowserRouter(createRouterFunction, '6'); } -function getActiveRootSpan(): Span | undefined { - const span = getActiveSpan(); - const rootSpan = span ? getRootSpan(span) : undefined; - - if (!rootSpan) { - return undefined; - } - - const op = spanToJSON(rootSpan).op; +/** + * Alias for backwards compatibility + * @deprecated Use `wrapCreateBrowserRouterV6` or `wrapCreateBrowserRouterV7` instead. + */ +export const wrapCreateBrowserRouter = wrapCreateBrowserRouterV6; - // Only use this root span if it is a pageload or navigation span - return op === 'navigation' || op === 'pageload' ? rootSpan : undefined; +/** + * A higher-order component that adds Sentry routing instrumentation to a React Router v6 Route component. + * This is used to automatically capture route changes as transactions. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function withSentryReactRouterV6Routing

, R extends React.FC

>(routes: R): R { + return createV6CompatibleWithSentryReactRouterRouting(routes, '6'); } diff --git a/packages/react/src/reactrouterv7.tsx b/packages/react/src/reactrouterv7.tsx new file mode 100644 index 000000000000..2703a47d5428 --- /dev/null +++ b/packages/react/src/reactrouterv7.tsx @@ -0,0 +1,50 @@ +// React Router v7 uses the same integration as v6 +import type { browserTracingIntegration } from '@sentry/browser'; + +import type { Integration } from '@sentry/core'; +import type { ReactRouterOptions } from './reactrouterv6-compat-utils'; +import { + createReactRouterV6CompatibleTracingIntegration, + createV6CompatibleWithSentryReactRouterRouting, + createV6CompatibleWrapCreateBrowserRouter, + createV6CompatibleWrapUseRoutes, +} from './reactrouterv6-compat-utils'; +import type { CreateRouterFunction, Router, RouterState, UseRoutes } from './types'; + +/** + * A browser tracing integration that uses React Router v7 to instrument navigations. + * Expects `useEffect`, `useLocation`, `useNavigationType`, `createRoutesFromChildren` and `matchRoutes` to be passed as options. + */ +export function reactRouterV7BrowserTracingIntegration( + options: Parameters[0] & ReactRouterOptions, +): Integration { + return createReactRouterV6CompatibleTracingIntegration(options, '7'); +} + +/** + * A higher-order component that adds Sentry routing instrumentation to a React Router v7 Route component. + * This is used to automatically capture route changes as transactions. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function withSentryReactRouterV7Routing

, R extends React.FC

>(routes: R): R { + return createV6CompatibleWithSentryReactRouterRouting(routes, '7'); +} + +/** + * A wrapper function that adds Sentry routing instrumentation to a React Router v7 createBrowserRouter function. + * This is used to automatically capture route changes as transactions when using the createBrowserRouter API. + */ +export function wrapCreateBrowserRouterV7< + TState extends RouterState = RouterState, + TRouter extends Router = Router, +>(createRouterFunction: CreateRouterFunction): CreateRouterFunction { + return createV6CompatibleWrapCreateBrowserRouter(createRouterFunction, '7'); +} + +/** + * A wrapper function that adds Sentry routing instrumentation to a React Router v7 useRoutes hook. + * This is used to automatically capture route changes as transactions when using the useRoutes hook. + */ +export function wrapUseRoutesV7(origUseRoutes: UseRoutes): UseRoutes { + return createV6CompatibleWrapUseRoutes(origUseRoutes, '7'); +} diff --git a/packages/react/test/reactrouterv6.4.test.tsx b/packages/react/test/reactrouterv6.4.test.tsx index f3b7288872fc..3ae6a69bdf56 100644 --- a/packages/react/test/reactrouterv6.4.test.tsx +++ b/packages/react/test/reactrouterv6.4.test.tsx @@ -94,6 +94,7 @@ describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); const router = sentryCreateBrowserRouter( @@ -135,6 +136,7 @@ describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); const router = sentryCreateBrowserRouter( @@ -168,6 +170,7 @@ describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); const router = sentryCreateBrowserRouter( @@ -213,6 +216,7 @@ describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); const router = sentryCreateBrowserRouter( @@ -264,6 +268,7 @@ describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); const router = sentryCreateBrowserRouter( @@ -315,6 +320,7 @@ describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); const router = sentryCreateBrowserRouter( @@ -378,6 +384,7 @@ describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); const router = sentryCreateBrowserRouter( @@ -419,6 +426,7 @@ describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); const router = sentryCreateBrowserRouter( @@ -471,6 +479,7 @@ describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); const router = sentryCreateBrowserRouter( @@ -528,6 +537,7 @@ describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { stripBasename: true, }), ); + // eslint-disable-next-line deprecation/deprecation const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); const router = sentryCreateBrowserRouter( @@ -585,6 +595,7 @@ describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { stripBasename: true, }), ); + // eslint-disable-next-line deprecation/deprecation const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); const router = sentryCreateBrowserRouter( @@ -637,6 +648,7 @@ describe('reactRouterV6BrowserTracingIntegration (v6.4)', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction); const router = sentryCreateBrowserRouter( diff --git a/packages/react/test/reactrouterv6.test.tsx b/packages/react/test/reactrouterv6.test.tsx index de65159c56e4..b9cf4003c330 100644 --- a/packages/react/test/reactrouterv6.test.tsx +++ b/packages/react/test/reactrouterv6.test.tsx @@ -536,6 +536,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { }), ); + // eslint-disable-next-line deprecation/deprecation const wrappedUseRoutes = wrapUseRoutes(useRoutes); const Routes = () => @@ -577,6 +578,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { }), ); + // eslint-disable-next-line deprecation/deprecation const wrappedUseRoutes = wrapUseRoutes(useRoutes); const Routes = () => @@ -611,6 +613,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { }), ); + // eslint-disable-next-line deprecation/deprecation const wrappedUseRoutes = wrapUseRoutes(useRoutes); const Routes = () => @@ -645,6 +648,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { }), ); + // eslint-disable-next-line deprecation/deprecation const wrappedUseRoutes = wrapUseRoutes(useRoutes); const Routes = () => @@ -682,6 +686,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const wrappedUseRoutes = wrapUseRoutes(useRoutes); const Routes = () => @@ -726,6 +731,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const wrappedUseRoutes = wrapUseRoutes(useRoutes); const Routes = () => @@ -776,6 +782,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const wrappedUseRoutes = wrapUseRoutes(useRoutes); const Routes = () => @@ -826,6 +833,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const wrappedUseRoutes = wrapUseRoutes(useRoutes); const Routes = () => @@ -882,6 +890,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const wrappedUseRoutes = wrapUseRoutes(useRoutes); const Routes = () => @@ -962,6 +971,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const wrappedUseRoutes = wrapUseRoutes(useRoutes); const Routes = () => @@ -1040,6 +1050,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const wrappedUseRoutes = wrapUseRoutes(useRoutes); const Routes = () => @@ -1098,6 +1109,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const wrappedUseRoutes = wrapUseRoutes(useRoutes); const Routes = () => @@ -1155,6 +1167,7 @@ describe('reactRouterV6BrowserTracingIntegration', () => { matchRoutes, }), ); + // eslint-disable-next-line deprecation/deprecation const wrappedUseRoutes = wrapUseRoutes(useRoutes); const Routes = () =>