-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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]`.
- Loading branch information
1 parent
98a7789
commit 80041b2
Showing
5 changed files
with
393 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -44,6 +44,7 @@ | |
"react-router-3": "npm:[email protected]", | ||
"react-router-4": "npm:[email protected]", | ||
"react-router-5": "npm:[email protected]", | ||
"react-router-6": "npm:[email protected]", | ||
"redux": "^4.0.5" | ||
}, | ||
"scripts": { | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<Key extends string = string> = { | ||
readonly [key in Key]: string | undefined; | ||
}; | ||
|
||
interface RouteMatch<ParamKey extends string = string> { | ||
params: Params<ParamKey>; | ||
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<Window>(); | ||
|
||
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<P extends Record<string, any>, R extends React.FC<P>>(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<P> = (props: P) => { | ||
const location = _useLocation(); | ||
const navigationType = _useNavigationType(); | ||
|
||
_useEffect(() => { | ||
// Performance concern: | ||
// This is repeated when <Routes /> 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 <Routes {...props} />; | ||
}; | ||
|
||
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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( | ||
<MemoryRouter initialEntries={['/']}> | ||
<SentryRoutes> | ||
<Route path="/" element={<div>Home</div>} /> | ||
</SentryRoutes> | ||
</MemoryRouter>, | ||
); | ||
|
||
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( | ||
<MemoryRouter initialEntries={['/']}> | ||
<SentryRoutes> | ||
<Route path="/" element={<div>Home</div>} /> | ||
</SentryRoutes> | ||
</MemoryRouter>, | ||
); | ||
|
||
expect(mockStartTransaction).toHaveBeenCalledTimes(0); | ||
}); | ||
|
||
it('skips navigation transaction, with `startTransactionOnLocationChange: false`', () => { | ||
const [mockStartTransaction] = createInstrumentation({ startTransactionOnLocationChange: false }); | ||
const SentryRoutes = withSentryReactRouterV6Routing(Routes); | ||
|
||
render( | ||
<MemoryRouter initialEntries={['/']}> | ||
<SentryRoutes> | ||
<Route path="/about" element={<div>About</div>} /> | ||
<Route path="/" element={<Navigate to="/about" />} /> | ||
</SentryRoutes> | ||
</MemoryRouter>, | ||
); | ||
|
||
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( | ||
<MemoryRouter initialEntries={['/']}> | ||
<SentryRoutes> | ||
<Route path="/about" element={<div>About</div>} /> | ||
<Route path="/" element={<Navigate to="/about" />} /> | ||
</SentryRoutes> | ||
</MemoryRouter>, | ||
); | ||
|
||
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( | ||
<MemoryRouter initialEntries={['/']}> | ||
<SentryRoutes> | ||
<Route path="/about" element={<div>About</div>}> | ||
<Route path="/about/us" element={<div>us</div>} /> | ||
</Route> | ||
<Route path="/" element={<Navigate to="/about/us" />} /> | ||
</SentryRoutes> | ||
</MemoryRouter>, | ||
); | ||
|
||
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( | ||
<MemoryRouter initialEntries={['/']}> | ||
<SentryRoutes> | ||
<Route path="/about" element={<div>About</div>}> | ||
<Route path="/about/:page" element={<div>page</div>} /> | ||
</Route> | ||
<Route path="/" element={<Navigate to="/about/us" />} /> | ||
</SentryRoutes> | ||
</MemoryRouter>, | ||
); | ||
|
||
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( | ||
<MemoryRouter initialEntries={['/']}> | ||
<SentryRoutes> | ||
<Route path="/stores" element={<div>Stores</div>}> | ||
<Route path="/stores/:storeId" element={<div>Store</div>}> | ||
<Route path="/stores/:storeId/products/:productId" element={<div>Product</div>} /> | ||
</Route> | ||
</Route> | ||
<Route path="/" element={<Navigate to="/stores/foo/products/234" />} /> | ||
</SentryRoutes> | ||
</MemoryRouter>, | ||
); | ||
|
||
expect(mockStartTransaction).toHaveBeenCalledTimes(2); | ||
expect(mockStartTransaction).toHaveBeenLastCalledWith({ | ||
name: '/stores/:storeId/products/:productId', | ||
op: 'navigation', | ||
tags: { 'routing.instrumentation': 'react-router-v6' }, | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.