diff --git a/packages/react/package.json b/packages/react/package.json index 78d3ffc7b606..0ec14a723c11 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -44,6 +44,7 @@ "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.0.0", + "react-router-6": "npm:react-router@6.3.0", "redux": "^4.0.5" }, "scripts": { diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 306f90e4f943..150f4571ef85 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -6,3 +6,4 @@ export { ErrorBoundary, withErrorBoundary } from './errorboundary'; export { createReduxEnhancer } from './redux'; export { reactRouterV3Instrumentation } from './reactrouterv3'; export { reactRouterV4Instrumentation, reactRouterV5Instrumentation, withSentryRouting } from './reactrouter'; +export { reactRouterV6Instrumentation, withSentryReactRouterV6Routing } from './reactrouterv6'; diff --git a/packages/react/src/reactrouterv6.tsx b/packages/react/src/reactrouterv6.tsx new file mode 100644 index 000000000000..a793f8824e1d --- /dev/null +++ b/packages/react/src/reactrouterv6.tsx @@ -0,0 +1,180 @@ +// Inspired from Donnie McNeal's solution: +// https://gist.github.com/wontondon/e8c4bdf2888875e4c755712e99279536 + +import { Transaction, TransactionContext } from '@sentry/types'; +import { getGlobalObject, logger } from '@sentry/utils'; +import hoistNonReactStatics from 'hoist-non-react-statics'; +import React from 'react'; + +import { IS_DEBUG_BUILD } from './flags'; +import { Action, Location } from './types'; + +interface RouteObject { + caseSensitive?: boolean; + children?: RouteObject[]; + element?: React.ReactNode; + index?: boolean; + path?: string; +} + +type Params = { + readonly [key in Key]: string | undefined; +}; + +interface RouteMatch { + params: Params; + pathname: string; + route: RouteObject; +} + +type UseEffect = (cb: () => void, deps: unknown[]) => void; +type UseLocation = () => Location; +type UseNavigationType = () => Action; +type CreateRoutesFromChildren = (children: JSX.Element[]) => RouteObject[]; +type MatchRoutes = (routes: RouteObject[], location: Location) => RouteMatch[] | null; + +let activeTransaction: Transaction | undefined; + +let _useEffect: UseEffect; +let _useLocation: UseLocation; +let _useNavigationType: UseNavigationType; +let _createRoutesFromChildren: CreateRoutesFromChildren; +let _matchRoutes: MatchRoutes; +let _customStartTransaction: (context: TransactionContext) => Transaction | undefined; +let _startTransactionOnLocationChange: boolean; + +const global = getGlobalObject(); + +const SENTRY_TAGS = { + 'routing.instrumentation': 'react-router-v6', +}; + +function getInitPathName(): string | undefined { + if (global && global.location) { + return global.location.pathname; + } + + return undefined; +} + +export function reactRouterV6Instrumentation( + useEffect: UseEffect, + useLocation: UseLocation, + useNavigationType: UseNavigationType, + createRoutesFromChildren: CreateRoutesFromChildren, + matchRoutes: MatchRoutes, +) { + return ( + customStartTransaction: (context: TransactionContext) => Transaction | undefined, + startTransactionOnPageLoad = true, + startTransactionOnLocationChange = true, + ): void => { + const initPathName = getInitPathName(); + if (startTransactionOnPageLoad && initPathName) { + activeTransaction = customStartTransaction({ + name: initPathName, + op: 'pageload', + tags: SENTRY_TAGS, + }); + } + + _useEffect = useEffect; + _useLocation = useLocation; + _useNavigationType = useNavigationType; + _matchRoutes = matchRoutes; + _createRoutesFromChildren = createRoutesFromChildren; + + _customStartTransaction = customStartTransaction; + _startTransactionOnLocationChange = startTransactionOnLocationChange; + }; +} + +const getTransactionName = (routes: RouteObject[], location: Location, matchRoutes: MatchRoutes): string => { + if (!routes || routes.length === 0 || !matchRoutes) { + return location.pathname; + } + + const branches = matchRoutes(routes, location); + + if (branches) { + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let x = 0; x < branches.length; x++) { + if (branches[x].route && branches[x].route.path && branches[x].pathname === location.pathname) { + return branches[x].route.path || location.pathname; + } + } + } + + return location.pathname; +}; + +export function withSentryReactRouterV6Routing

, R extends React.FC

>(Routes: R): R { + if ( + !_useEffect || + !_useLocation || + !_useNavigationType || + !_createRoutesFromChildren || + !_matchRoutes || + !_customStartTransaction + ) { + IS_DEBUG_BUILD && + logger.warn('reactRouterV6Instrumentation was unable to wrap Routes because of one or more missing parameters.'); + + return Routes; + } + + let isBaseLocation: boolean = false; + let routes: RouteObject[]; + + const SentryRoutes: React.FC

= (props: P) => { + const location = _useLocation(); + const navigationType = _useNavigationType(); + + _useEffect(() => { + // Performance concern: + // This is repeated when is rendered. + routes = _createRoutesFromChildren(props.children); + isBaseLocation = true; + + if (activeTransaction) { + activeTransaction.setName(getTransactionName(routes, location, _matchRoutes)); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.children]); + + _useEffect(() => { + if (isBaseLocation) { + if (activeTransaction) { + activeTransaction.finish(); + } + + return; + } + + if (_startTransactionOnLocationChange && (navigationType === 'PUSH' || navigationType === 'POP')) { + if (activeTransaction) { + activeTransaction.finish(); + } + + activeTransaction = _customStartTransaction({ + name: getTransactionName(routes, location, _matchRoutes), + op: 'navigation', + tags: SENTRY_TAGS, + }); + } + }, [props.children, location, navigationType, isBaseLocation]); + + isBaseLocation = false; + + // @ts-ignore 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-ignore Setting more specific React Component typing for `R` generic above + // will break advanced type inference done by react router params + return SentryRoutes; +} diff --git a/packages/react/test/reactrouterv6.test.tsx b/packages/react/test/reactrouterv6.test.tsx new file mode 100644 index 000000000000..5e30d96a83a9 --- /dev/null +++ b/packages/react/test/reactrouterv6.test.tsx @@ -0,0 +1,190 @@ +import { render } from '@testing-library/react'; +import * as React from 'react'; +import { + createRoutesFromChildren, + matchPath, + matchRoutes, + MemoryRouter, + Navigate, + Route, + Routes, + useLocation, + useNavigationType, +} from 'react-router-6'; + +import { reactRouterV6Instrumentation } from '../src'; +import { withSentryReactRouterV6Routing } from '../src/reactrouterv6'; + +describe('React Router v6', () => { + function createInstrumentation(_opts?: { + startTransactionOnPageLoad?: boolean; + startTransactionOnLocationChange?: boolean; + }): [jest.Mock, { mockSetName: jest.Mock; mockFinish: jest.Mock }] { + const options = { + matchPath: _opts ? matchPath : undefined, + startTransactionOnLocationChange: true, + startTransactionOnPageLoad: true, + ..._opts, + }; + const mockFinish = jest.fn(); + const mockSetName = jest.fn(); + const mockStartTransaction = jest.fn().mockReturnValue({ setName: mockSetName, finish: mockFinish }); + + reactRouterV6Instrumentation( + React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + )(mockStartTransaction, options.startTransactionOnPageLoad, options.startTransactionOnLocationChange); + return [mockStartTransaction, { mockSetName, mockFinish }]; + } + + it('starts a pageload transaction', () => { + const [mockStartTransaction] = createInstrumentation(); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + Home} /> + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(1); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/', + op: 'pageload', + tags: { 'routing.instrumentation': 'react-router-v6' }, + }); + }); + + it('skips pageload transaction with `startTransactionOnPageLoad: false`', () => { + const [mockStartTransaction] = createInstrumentation({ startTransactionOnPageLoad: false }); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + Home} /> + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(0); + }); + + it('skips navigation transaction, with `startTransactionOnLocationChange: false`', () => { + const [mockStartTransaction] = createInstrumentation({ startTransactionOnLocationChange: false }); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + About} /> + } /> + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(1); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/', + op: 'pageload', + tags: { 'routing.instrumentation': 'react-router-v6' }, + }); + }); + + it('starts a navigation transaction', () => { + const [mockStartTransaction] = createInstrumentation(); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + About} /> + } /> + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/about', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v6' }, + }); + }); + + it('works with nested routes', () => { + const [mockStartTransaction] = createInstrumentation(); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + About}> + us} /> + + } /> + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/about/us', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v6' }, + }); + }); + + it('works with paramaterized paths', () => { + const [mockStartTransaction] = createInstrumentation(); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + About}> + page} /> + + } /> + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/about/:page', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v6' }, + }); + }); + + it('works with paths with multiple parameters', () => { + const [mockStartTransaction] = createInstrumentation(); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + Stores}> + Store}> + Product} /> + + + } /> + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/stores/:storeId/products/:productId', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v6' }, + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 7f2f1d191960..e5950f2a5cca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2247,6 +2247,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.7.6": + version "7.17.9" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.9.tgz#d19fbf802d01a8cb6cf053a64e472d42c434ba72" + integrity sha512-lSiBBvodq29uShpWGNbgFdKYNiFDo5/HIYsaCEY9ff4sb10x9jizo2+pRrSyF4jKZCXqgzuqBOQKbUm90gQwJg== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.4.tgz#3251996c4200ebc71d1a8fc405fba940f36ba278" @@ -14174,6 +14181,13 @@ history@^4.6.0, history@^4.9.0: tiny-warning "^1.0.0" value-equal "^1.0.1" +history@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b" + integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ== + dependencies: + "@babel/runtime" "^7.7.6" + hmac-drbg@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1" @@ -21101,6 +21115,13 @@ react-refresh@0.8.3: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +"react-router-6@npm:react-router@6.3.0": + version "6.3.0" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" + integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== + dependencies: + history "^5.2.0" + react@^18.0.0: version "18.0.0" resolved "https://registry.yarnpkg.com/react/-/react-18.0.0.tgz#b468736d1f4a5891f38585ba8e8fb29f91c3cb96"