Skip to content

Commit cd71864

Browse files
authored
feat(react): Add react-router-v6 integration (#5042)
Tracing integration for [`[email protected]`](https://reactrouter.com/docs/en/v6) This implementation will provide a HoC that wraps [`<Routes>`](https://reactrouter.com/docs/en/v6/api#routes-and-route) which replaced `<Switch>` from `[email protected]`.
1 parent 6536eb6 commit cd71864

File tree

5 files changed

+393
-0
lines changed

5 files changed

+393
-0
lines changed

packages/react/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"react-router-3": "npm:[email protected]",
4545
"react-router-4": "npm:[email protected]",
4646
"react-router-5": "npm:[email protected]",
47+
"react-router-6": "npm:[email protected]",
4748
"redux": "^4.0.5"
4849
},
4950
"scripts": {

packages/react/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export { ErrorBoundary, withErrorBoundary } from './errorboundary';
66
export { createReduxEnhancer } from './redux';
77
export { reactRouterV3Instrumentation } from './reactrouterv3';
88
export { reactRouterV4Instrumentation, reactRouterV5Instrumentation, withSentryRouting } from './reactrouter';
9+
export { reactRouterV6Instrumentation, withSentryReactRouterV6Routing } from './reactrouterv6';

packages/react/src/reactrouterv6.tsx

+180
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Inspired from Donnie McNeal's solution:
2+
// https://gist.github.com/wontondon/e8c4bdf2888875e4c755712e99279536
3+
4+
import { Transaction, TransactionContext } from '@sentry/types';
5+
import { getGlobalObject, logger } from '@sentry/utils';
6+
import hoistNonReactStatics from 'hoist-non-react-statics';
7+
import React from 'react';
8+
9+
import { IS_DEBUG_BUILD } from './flags';
10+
import { Action, Location } from './types';
11+
12+
interface RouteObject {
13+
caseSensitive?: boolean;
14+
children?: RouteObject[];
15+
element?: React.ReactNode;
16+
index?: boolean;
17+
path?: string;
18+
}
19+
20+
type Params<Key extends string = string> = {
21+
readonly [key in Key]: string | undefined;
22+
};
23+
24+
interface RouteMatch<ParamKey extends string = string> {
25+
params: Params<ParamKey>;
26+
pathname: string;
27+
route: RouteObject;
28+
}
29+
30+
type UseEffect = (cb: () => void, deps: unknown[]) => void;
31+
type UseLocation = () => Location;
32+
type UseNavigationType = () => Action;
33+
type CreateRoutesFromChildren = (children: JSX.Element[]) => RouteObject[];
34+
type MatchRoutes = (routes: RouteObject[], location: Location) => RouteMatch[] | null;
35+
36+
let activeTransaction: Transaction | undefined;
37+
38+
let _useEffect: UseEffect;
39+
let _useLocation: UseLocation;
40+
let _useNavigationType: UseNavigationType;
41+
let _createRoutesFromChildren: CreateRoutesFromChildren;
42+
let _matchRoutes: MatchRoutes;
43+
let _customStartTransaction: (context: TransactionContext) => Transaction | undefined;
44+
let _startTransactionOnLocationChange: boolean;
45+
46+
const global = getGlobalObject<Window>();
47+
48+
const SENTRY_TAGS = {
49+
'routing.instrumentation': 'react-router-v6',
50+
};
51+
52+
function getInitPathName(): string | undefined {
53+
if (global && global.location) {
54+
return global.location.pathname;
55+
}
56+
57+
return undefined;
58+
}
59+
60+
export function reactRouterV6Instrumentation(
61+
useEffect: UseEffect,
62+
useLocation: UseLocation,
63+
useNavigationType: UseNavigationType,
64+
createRoutesFromChildren: CreateRoutesFromChildren,
65+
matchRoutes: MatchRoutes,
66+
) {
67+
return (
68+
customStartTransaction: (context: TransactionContext) => Transaction | undefined,
69+
startTransactionOnPageLoad = true,
70+
startTransactionOnLocationChange = true,
71+
): void => {
72+
const initPathName = getInitPathName();
73+
if (startTransactionOnPageLoad && initPathName) {
74+
activeTransaction = customStartTransaction({
75+
name: initPathName,
76+
op: 'pageload',
77+
tags: SENTRY_TAGS,
78+
});
79+
}
80+
81+
_useEffect = useEffect;
82+
_useLocation = useLocation;
83+
_useNavigationType = useNavigationType;
84+
_matchRoutes = matchRoutes;
85+
_createRoutesFromChildren = createRoutesFromChildren;
86+
87+
_customStartTransaction = customStartTransaction;
88+
_startTransactionOnLocationChange = startTransactionOnLocationChange;
89+
};
90+
}
91+
92+
const getTransactionName = (routes: RouteObject[], location: Location, matchRoutes: MatchRoutes): string => {
93+
if (!routes || routes.length === 0 || !matchRoutes) {
94+
return location.pathname;
95+
}
96+
97+
const branches = matchRoutes(routes, location);
98+
99+
if (branches) {
100+
// eslint-disable-next-line @typescript-eslint/prefer-for-of
101+
for (let x = 0; x < branches.length; x++) {
102+
if (branches[x].route && branches[x].route.path && branches[x].pathname === location.pathname) {
103+
return branches[x].route.path || location.pathname;
104+
}
105+
}
106+
}
107+
108+
return location.pathname;
109+
};
110+
111+
export function withSentryReactRouterV6Routing<P extends Record<string, any>, R extends React.FC<P>>(Routes: R): R {
112+
if (
113+
!_useEffect ||
114+
!_useLocation ||
115+
!_useNavigationType ||
116+
!_createRoutesFromChildren ||
117+
!_matchRoutes ||
118+
!_customStartTransaction
119+
) {
120+
IS_DEBUG_BUILD &&
121+
logger.warn('reactRouterV6Instrumentation was unable to wrap Routes because of one or more missing parameters.');
122+
123+
return Routes;
124+
}
125+
126+
let isBaseLocation: boolean = false;
127+
let routes: RouteObject[];
128+
129+
const SentryRoutes: React.FC<P> = (props: P) => {
130+
const location = _useLocation();
131+
const navigationType = _useNavigationType();
132+
133+
_useEffect(() => {
134+
// Performance concern:
135+
// This is repeated when <Routes /> is rendered.
136+
routes = _createRoutesFromChildren(props.children);
137+
isBaseLocation = true;
138+
139+
if (activeTransaction) {
140+
activeTransaction.setName(getTransactionName(routes, location, _matchRoutes));
141+
}
142+
143+
// eslint-disable-next-line react-hooks/exhaustive-deps
144+
}, [props.children]);
145+
146+
_useEffect(() => {
147+
if (isBaseLocation) {
148+
if (activeTransaction) {
149+
activeTransaction.finish();
150+
}
151+
152+
return;
153+
}
154+
155+
if (_startTransactionOnLocationChange && (navigationType === 'PUSH' || navigationType === 'POP')) {
156+
if (activeTransaction) {
157+
activeTransaction.finish();
158+
}
159+
160+
activeTransaction = _customStartTransaction({
161+
name: getTransactionName(routes, location, _matchRoutes),
162+
op: 'navigation',
163+
tags: SENTRY_TAGS,
164+
});
165+
}
166+
}, [props.children, location, navigationType, isBaseLocation]);
167+
168+
isBaseLocation = false;
169+
170+
// @ts-ignore Setting more specific React Component typing for `R` generic above
171+
// will break advanced type inference done by react router params
172+
return <Routes {...props} />;
173+
};
174+
175+
hoistNonReactStatics(SentryRoutes, Routes);
176+
177+
// @ts-ignore Setting more specific React Component typing for `R` generic above
178+
// will break advanced type inference done by react router params
179+
return SentryRoutes;
180+
}
+190
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import { render } from '@testing-library/react';
2+
import * as React from 'react';
3+
import {
4+
createRoutesFromChildren,
5+
matchPath,
6+
matchRoutes,
7+
MemoryRouter,
8+
Navigate,
9+
Route,
10+
Routes,
11+
useLocation,
12+
useNavigationType,
13+
} from 'react-router-6';
14+
15+
import { reactRouterV6Instrumentation } from '../src';
16+
import { withSentryReactRouterV6Routing } from '../src/reactrouterv6';
17+
18+
describe('React Router v6', () => {
19+
function createInstrumentation(_opts?: {
20+
startTransactionOnPageLoad?: boolean;
21+
startTransactionOnLocationChange?: boolean;
22+
}): [jest.Mock, { mockSetName: jest.Mock; mockFinish: jest.Mock }] {
23+
const options = {
24+
matchPath: _opts ? matchPath : undefined,
25+
startTransactionOnLocationChange: true,
26+
startTransactionOnPageLoad: true,
27+
..._opts,
28+
};
29+
const mockFinish = jest.fn();
30+
const mockSetName = jest.fn();
31+
const mockStartTransaction = jest.fn().mockReturnValue({ setName: mockSetName, finish: mockFinish });
32+
33+
reactRouterV6Instrumentation(
34+
React.useEffect,
35+
useLocation,
36+
useNavigationType,
37+
createRoutesFromChildren,
38+
matchRoutes,
39+
)(mockStartTransaction, options.startTransactionOnPageLoad, options.startTransactionOnLocationChange);
40+
return [mockStartTransaction, { mockSetName, mockFinish }];
41+
}
42+
43+
it('starts a pageload transaction', () => {
44+
const [mockStartTransaction] = createInstrumentation();
45+
const SentryRoutes = withSentryReactRouterV6Routing(Routes);
46+
47+
render(
48+
<MemoryRouter initialEntries={['/']}>
49+
<SentryRoutes>
50+
<Route path="/" element={<div>Home</div>} />
51+
</SentryRoutes>
52+
</MemoryRouter>,
53+
);
54+
55+
expect(mockStartTransaction).toHaveBeenCalledTimes(1);
56+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
57+
name: '/',
58+
op: 'pageload',
59+
tags: { 'routing.instrumentation': 'react-router-v6' },
60+
});
61+
});
62+
63+
it('skips pageload transaction with `startTransactionOnPageLoad: false`', () => {
64+
const [mockStartTransaction] = createInstrumentation({ startTransactionOnPageLoad: false });
65+
const SentryRoutes = withSentryReactRouterV6Routing(Routes);
66+
67+
render(
68+
<MemoryRouter initialEntries={['/']}>
69+
<SentryRoutes>
70+
<Route path="/" element={<div>Home</div>} />
71+
</SentryRoutes>
72+
</MemoryRouter>,
73+
);
74+
75+
expect(mockStartTransaction).toHaveBeenCalledTimes(0);
76+
});
77+
78+
it('skips navigation transaction, with `startTransactionOnLocationChange: false`', () => {
79+
const [mockStartTransaction] = createInstrumentation({ startTransactionOnLocationChange: false });
80+
const SentryRoutes = withSentryReactRouterV6Routing(Routes);
81+
82+
render(
83+
<MemoryRouter initialEntries={['/']}>
84+
<SentryRoutes>
85+
<Route path="/about" element={<div>About</div>} />
86+
<Route path="/" element={<Navigate to="/about" />} />
87+
</SentryRoutes>
88+
</MemoryRouter>,
89+
);
90+
91+
expect(mockStartTransaction).toHaveBeenCalledTimes(1);
92+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
93+
name: '/',
94+
op: 'pageload',
95+
tags: { 'routing.instrumentation': 'react-router-v6' },
96+
});
97+
});
98+
99+
it('starts a navigation transaction', () => {
100+
const [mockStartTransaction] = createInstrumentation();
101+
const SentryRoutes = withSentryReactRouterV6Routing(Routes);
102+
103+
render(
104+
<MemoryRouter initialEntries={['/']}>
105+
<SentryRoutes>
106+
<Route path="/about" element={<div>About</div>} />
107+
<Route path="/" element={<Navigate to="/about" />} />
108+
</SentryRoutes>
109+
</MemoryRouter>,
110+
);
111+
112+
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
113+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
114+
name: '/about',
115+
op: 'navigation',
116+
tags: { 'routing.instrumentation': 'react-router-v6' },
117+
});
118+
});
119+
120+
it('works with nested routes', () => {
121+
const [mockStartTransaction] = createInstrumentation();
122+
const SentryRoutes = withSentryReactRouterV6Routing(Routes);
123+
124+
render(
125+
<MemoryRouter initialEntries={['/']}>
126+
<SentryRoutes>
127+
<Route path="/about" element={<div>About</div>}>
128+
<Route path="/about/us" element={<div>us</div>} />
129+
</Route>
130+
<Route path="/" element={<Navigate to="/about/us" />} />
131+
</SentryRoutes>
132+
</MemoryRouter>,
133+
);
134+
135+
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
136+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
137+
name: '/about/us',
138+
op: 'navigation',
139+
tags: { 'routing.instrumentation': 'react-router-v6' },
140+
});
141+
});
142+
143+
it('works with paramaterized paths', () => {
144+
const [mockStartTransaction] = createInstrumentation();
145+
const SentryRoutes = withSentryReactRouterV6Routing(Routes);
146+
147+
render(
148+
<MemoryRouter initialEntries={['/']}>
149+
<SentryRoutes>
150+
<Route path="/about" element={<div>About</div>}>
151+
<Route path="/about/:page" element={<div>page</div>} />
152+
</Route>
153+
<Route path="/" element={<Navigate to="/about/us" />} />
154+
</SentryRoutes>
155+
</MemoryRouter>,
156+
);
157+
158+
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
159+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
160+
name: '/about/:page',
161+
op: 'navigation',
162+
tags: { 'routing.instrumentation': 'react-router-v6' },
163+
});
164+
});
165+
166+
it('works with paths with multiple parameters', () => {
167+
const [mockStartTransaction] = createInstrumentation();
168+
const SentryRoutes = withSentryReactRouterV6Routing(Routes);
169+
170+
render(
171+
<MemoryRouter initialEntries={['/']}>
172+
<SentryRoutes>
173+
<Route path="/stores" element={<div>Stores</div>}>
174+
<Route path="/stores/:storeId" element={<div>Store</div>}>
175+
<Route path="/stores/:storeId/products/:productId" element={<div>Product</div>} />
176+
</Route>
177+
</Route>
178+
<Route path="/" element={<Navigate to="/stores/foo/products/234" />} />
179+
</SentryRoutes>
180+
</MemoryRouter>,
181+
);
182+
183+
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
184+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
185+
name: '/stores/:storeId/products/:productId',
186+
op: 'navigation',
187+
tags: { 'routing.instrumentation': 'react-router-v6' },
188+
});
189+
});
190+
});

0 commit comments

Comments
 (0)