Skip to content

Commit

Permalink
feat(react): Add react-router-v6 integration (#5042)
Browse files Browse the repository at this point in the history
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
onurtemizkan authored and AbhiPrasad committed May 30, 2022
1 parent 98a7789 commit 80041b2
Show file tree
Hide file tree
Showing 5 changed files with 393 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
180 changes: 180 additions & 0 deletions packages/react/src/reactrouterv6.tsx
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;
}
190 changes: 190 additions & 0 deletions packages/react/test/reactrouterv6.test.tsx
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' },
});
});
});
Loading

0 comments on commit 80041b2

Please sign in to comment.