diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d9fb753fc0d9..976929e1ca4b 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -7,4 +7,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'; +export { reactRouterV6Instrumentation, withSentryReactRouterV6Routing, wrapUseRoutes } from './reactrouterv6'; diff --git a/packages/react/src/reactrouterv6.tsx b/packages/react/src/reactrouterv6.tsx index a7950ce0b12f..9706eb57e9fe 100644 --- a/packages/react/src/reactrouterv6.tsx +++ b/packages/react/src/reactrouterv6.tsx @@ -20,6 +20,8 @@ type Params = { readonly [key in Key]: string | undefined; }; +type UseRoutes = (routes: RouteObject[], locationArg?: Partial | string) => React.ReactElement | null; + // https://github.com/remix-run/react-router/blob/9fa54d643134cd75a0335581a75db8100ed42828/packages/react-router/lib/router.ts#L114-L134 interface RouteMatch { /** @@ -141,6 +143,45 @@ function getNormalizedName( return [location.pathname, 'url']; } +function updatePageloadTransaction(location: Location, routes: RouteObject[]): void { + if (activeTransaction) { + const [name, source] = getNormalizedName(routes, location, _matchRoutes); + activeTransaction.setName(name); + activeTransaction.setMetadata({ source }); + } +} + +function handleNavigation( + location: Location, + routes: RouteObject[], + navigationType: Action, + isBaseLocation: boolean, +): void { + if (isBaseLocation) { + if (activeTransaction) { + activeTransaction.finish(); + } + + return; + } + + if (_startTransactionOnLocationChange && (navigationType === 'PUSH' || navigationType === 'POP')) { + if (activeTransaction) { + activeTransaction.finish(); + } + + const [name, source] = getNormalizedName(routes, location, _matchRoutes); + activeTransaction = _customStartTransaction({ + name, + op: 'navigation', + tags: SENTRY_TAGS, + metadata: { + source, + }, + }); + } +} + export function withSentryReactRouterV6Routing

, R extends React.FC

>(Routes: R): R { if ( !_useEffect || @@ -169,39 +210,12 @@ export function withSentryReactRouterV6Routing

, R routes = _createRoutesFromChildren(props.children); isBaseLocation = true; - if (activeTransaction) { - const [name, source] = getNormalizedName(routes, location, _matchRoutes); - activeTransaction.setName(name); - activeTransaction.setMetadata({ source }); - } - + updatePageloadTransaction(location, routes); // 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(); - } - - const [name, source] = getNormalizedName(routes, location, _matchRoutes); - activeTransaction = _customStartTransaction({ - name, - op: 'navigation', - tags: SENTRY_TAGS, - metadata: { - source, - }, - }); - } + handleNavigation(location, routes, navigationType, isBaseLocation); }, [props.children, location, navigationType, isBaseLocation]); isBaseLocation = false; @@ -217,3 +231,42 @@ export function withSentryReactRouterV6Routing

, R // will break advanced type inference done by react router params return SentryRoutes; } + +export function wrapUseRoutes(origUseRoutes: UseRoutes): UseRoutes { + if (!_useEffect || !_useLocation || !_useNavigationType || !_matchRoutes || !_customStartTransaction) { + __DEBUG_BUILD__ && + logger.warn( + 'reactRouterV6Instrumentation was unable to wrap `useRoutes` because of one or more missing parameters.', + ); + + return origUseRoutes; + } + + let isBaseLocation: boolean = false; + + return (routes: RouteObject[], location?: Partial | string): React.ReactElement | null => { + const SentryRoutes: React.FC = (props: unknown) => { + const Routes = origUseRoutes(routes, location); + + const locationArgObject = typeof location === 'string' ? { pathname: location } : location; + const locationObject = (locationArgObject as Location) || _useLocation(); + const navigationType = _useNavigationType(); + + _useEffect(() => { + isBaseLocation = true; + + updatePageloadTransaction(locationObject, routes); + }, [props]); + + _useEffect(() => { + handleNavigation(locationObject, routes, navigationType, isBaseLocation); + }, [props, locationObject, navigationType, isBaseLocation]); + + isBaseLocation = false; + + return Routes; + }; + + return ; + }; +} diff --git a/packages/react/test/reactrouterv6.test.tsx b/packages/react/test/reactrouterv6.test.tsx index 0058f257aee5..60031f7eed29 100644 --- a/packages/react/test/reactrouterv6.test.tsx +++ b/packages/react/test/reactrouterv6.test.tsx @@ -10,10 +10,11 @@ import { Routes, useLocation, useNavigationType, + useRoutes, } from 'react-router-6'; import { reactRouterV6Instrumentation } from '../src'; -import { withSentryReactRouterV6Routing } from '../src/reactrouterv6'; +import { withSentryReactRouterV6Routing, wrapUseRoutes } from '../src/reactrouterv6'; describe('React Router v6', () => { function createInstrumentation(_opts?: { @@ -43,191 +44,489 @@ describe('React Router v6', () => { return [mockStartTransaction, { mockSetName, mockFinish, mockSetMetadata }]; } - 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' }, - metadata: { source: 'url' }, + describe('withSentryReactRouterV6Routing', () => { + 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' }, + metadata: { source: 'url' }, + }); }); - }); - it('skips pageload transaction with `startTransactionOnPageLoad: false`', () => { - const [mockStartTransaction] = createInstrumentation({ startTransactionOnPageLoad: false }); - const SentryRoutes = withSentryReactRouterV6Routing(Routes); + it('skips pageload transaction with `startTransactionOnPageLoad: false`', () => { + const [mockStartTransaction] = createInstrumentation({ startTransactionOnPageLoad: false }); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); - render( - - - Home} /> - - , - ); + render( + + + Home} /> + + , + ); - expect(mockStartTransaction).toHaveBeenCalledTimes(0); - }); + 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' }, - metadata: { source: 'url' }, + 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' }, + metadata: { source: 'url' }, + }); }); - }); - 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' }, - metadata: { source: 'route' }, + 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' }, + metadata: { source: 'route' }, + }); }); - }); - 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' }, - metadata: { source: 'route' }, + 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' }, + metadata: { source: 'route' }, + }); }); - }); - 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' }, - metadata: { source: 'route' }, + 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' }, + metadata: { source: 'route' }, + }); }); - }); - it('works with paths with multiple parameters', () => { - const [mockStartTransaction] = createInstrumentation(); - const SentryRoutes = withSentryReactRouterV6Routing(Routes); + it('works with paths with multiple parameters', () => { + const [mockStartTransaction] = createInstrumentation(); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); - render( - - - Stores}> - Store}> - Product} /> + render( + + + Stores}> + Store}> + Product} /> + - - } /> - - , - ); - - expect(mockStartTransaction).toHaveBeenCalledTimes(2); - expect(mockStartTransaction).toHaveBeenLastCalledWith({ - name: '/stores/:storeId/products/:productId', - op: 'navigation', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + } /> + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/stores/:storeId/products/:productId', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v6' }, + metadata: { source: 'route' }, + }); }); - }); - it('works with nested paths with parameters', () => { - const [mockStartTransaction] = createInstrumentation(); - const SentryRoutes = withSentryReactRouterV6Routing(Routes); - - render( - - - } /> - Account Page} /> - - Project Index} /> - Project Page}> - Project Page Root} /> - Editor}> - View Canvas} /> - Space Canvas} /> + it('works with nested paths with parameters', () => { + const [mockStartTransaction] = createInstrumentation(); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + render( + + + } /> + Account Page} /> + + Project Index} /> + Project Page}> + Project Page Root} /> + Editor}> + View Canvas} /> + Space Canvas} /> + - - - No Match Page} /> - - , - ); - - expect(mockStartTransaction).toHaveBeenCalledTimes(2); - expect(mockStartTransaction).toHaveBeenLastCalledWith({ - name: '/projects/:projectId/views/:viewId', - op: 'navigation', - tags: { 'routing.instrumentation': 'react-router-v6' }, - metadata: { source: 'route' }, + + No Match Page} /> + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/projects/:projectId/views/:viewId', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v6' }, + metadata: { source: 'route' }, + }); + }); + }); + + describe('wrapUseRoutes', () => { + it('starts a pageload transaction', () => { + const [mockStartTransaction] = createInstrumentation(); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element:

Home
, + }, + ]); + + render( + + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(1); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/', + op: 'pageload', + tags: { 'routing.instrumentation': 'react-router-v6' }, + metadata: { source: 'url' }, + }); + }); + + it('skips pageload transaction with `startTransactionOnPageLoad: false`', () => { + const [mockStartTransaction] = createInstrumentation({ startTransactionOnPageLoad: false }); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element:
Home
, + }, + ]); + + render( + + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(0); + }); + + it('skips navigation transaction, with `startTransactionOnLocationChange: false`', () => { + const [mockStartTransaction] = createInstrumentation({ startTransactionOnLocationChange: false }); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element: , + }, + { + path: '/about', + element:
About
, + }, + ]); + + render( + + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(1); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/', + op: 'pageload', + tags: { 'routing.instrumentation': 'react-router-v6' }, + metadata: { source: 'url' }, + }); + }); + + it('starts a navigation transaction', () => { + const [mockStartTransaction] = createInstrumentation(); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element: , + }, + { + path: '/about', + element:
About
, + }, + ]); + + render( + + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/about', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v6' }, + metadata: { source: 'route' }, + }); + }); + + it('works with nested routes', () => { + const [mockStartTransaction] = createInstrumentation(); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element: , + }, + { + path: '/about', + element:
About
, + children: [ + { + path: '/about/us', + element:
us
, + }, + ], + }, + ]); + + render( + + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/about/us', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v6' }, + metadata: { source: 'route' }, + }); + }); + + it('works with paramaterized paths', () => { + const [mockStartTransaction] = createInstrumentation(); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element: , + }, + { + path: '/about', + element:
About
, + children: [ + { + path: '/about/:page', + element:
page
, + }, + ], + }, + ]); + + render( + + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/about/:page', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v6' }, + metadata: { source: 'route' }, + }); + }); + + it('works with paths with multiple parameters', () => { + const [mockStartTransaction] = createInstrumentation(); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + path: '/', + element: , + }, + { + path: '/stores', + element:
Stores
, + children: [ + { + path: '/stores/:storeId', + element:
Store
, + children: [ + { + path: '/stores/:storeId/products/:productId', + element:
Product
, + }, + ], + }, + ], + }, + ]); + + render( + + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/stores/:storeId/products/:productId', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v6' }, + metadata: { source: 'route' }, + }); + }); + + it('works with nested paths with parameters', () => { + const [mockStartTransaction] = createInstrumentation(); + const wrappedUseRoutes = wrapUseRoutes(useRoutes); + + const Routes = () => + wrappedUseRoutes([ + { + index: true, + element: , + }, + { + path: 'account', + element:
Account Page
, + }, + { + path: 'projects', + children: [ + { + index: true, + element:
Project Index
, + }, + { + path: ':projectId', + element:
Project Page
, + children: [ + { + index: true, + element:
Project Page Root
, + }, + { + element:
Editor
, + children: [ + { + path: 'views/:viewId', + element:
View Canvas
, + }, + { + path: 'spaces/:spaceId', + element:
Space Canvas
, + }, + ], + }, + ], + }, + ], + }, + { + path: '*', + element:
No Match Page
, + }, + ]); + + render( + + + , + ); + + expect(mockStartTransaction).toHaveBeenCalledTimes(2); + expect(mockStartTransaction).toHaveBeenLastCalledWith({ + name: '/projects/:projectId/views/:viewId', + op: 'navigation', + tags: { 'routing.instrumentation': 'react-router-v6' }, + metadata: { source: 'route' }, + }); }); }); });