Skip to content

Commit b67d9b4

Browse files
authored
feat(react): Add stripBasename option for React Router 6. (#10314)
This PR adds a new option for React Router 6 integration, `stripBasename` for leaving out the `basename` from transaction names.
1 parent 656b737 commit b67d9b4

File tree

2 files changed

+125
-5
lines changed

2 files changed

+125
-5
lines changed

packages/react/src/reactrouterv6.tsx

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ let _createRoutesFromChildren: CreateRoutesFromChildren;
3535
let _matchRoutes: MatchRoutes;
3636
let _customStartTransaction: (context: TransactionContext) => Transaction | undefined;
3737
let _startTransactionOnLocationChange: boolean;
38+
let _stripBasename: boolean = false;
3839

3940
const SENTRY_TAGS = {
4041
'routing.instrumentation': 'react-router-v6',
@@ -46,6 +47,7 @@ export function reactRouterV6Instrumentation(
4647
useNavigationType: UseNavigationType,
4748
createRoutesFromChildren: CreateRoutesFromChildren,
4849
matchRoutes: MatchRoutes,
50+
stripBasename?: boolean,
4951
) {
5052
return (
5153
customStartTransaction: (context: TransactionContext) => Transaction | undefined,
@@ -70,20 +72,48 @@ export function reactRouterV6Instrumentation(
7072
_useNavigationType = useNavigationType;
7173
_matchRoutes = matchRoutes;
7274
_createRoutesFromChildren = createRoutesFromChildren;
75+
_stripBasename = stripBasename || false;
7376

7477
_customStartTransaction = customStartTransaction;
7578
_startTransactionOnLocationChange = startTransactionOnLocationChange;
7679
};
7780
}
7881

82+
/**
83+
* Strip the basename from a pathname if exists.
84+
*
85+
* Vendored and modified from `react-router`
86+
* https://github.com/remix-run/react-router/blob/462bb712156a3f739d6139a0f14810b76b002df6/packages/router/utils.ts#L1038
87+
*/
88+
function stripBasenameFromPathname(pathname: string, basename: string): string {
89+
if (!basename || basename === '/') {
90+
return pathname;
91+
}
92+
93+
if (!pathname.toLowerCase().startsWith(basename.toLowerCase())) {
94+
return pathname;
95+
}
96+
97+
// We want to leave trailing slash behavior in the user's control, so if they
98+
// specify a basename with a trailing slash, we should support it
99+
const startIndex = basename.endsWith('/') ? basename.length - 1 : basename.length;
100+
const nextChar = pathname.charAt(startIndex);
101+
if (nextChar && nextChar !== '/') {
102+
// pathname does not start with basename/
103+
return pathname;
104+
}
105+
106+
return pathname.slice(startIndex) || '/';
107+
}
108+
79109
function getNormalizedName(
80110
routes: RouteObject[],
81111
location: Location,
82112
branches: RouteMatch[],
83113
basename: string = '',
84114
): [string, TransactionSource] {
85115
if (!routes || routes.length === 0) {
86-
return [location.pathname, 'url'];
116+
return [_stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url'];
87117
}
88118

89119
let pathBuilder = '';
@@ -95,7 +125,7 @@ function getNormalizedName(
95125
if (route) {
96126
// Early return if index route
97127
if (route.index) {
98-
return [branch.pathname, 'route'];
128+
return [_stripBasename ? stripBasenameFromPathname(branch.pathname, basename) : branch.pathname, 'route'];
99129
}
100130

101131
const path = route.path;
@@ -112,16 +142,16 @@ function getNormalizedName(
112142
// We should not count wildcard operators in the url segments calculation
113143
pathBuilder.slice(-2) !== '/*'
114144
) {
115-
return [basename + newPath, 'route'];
145+
return [(_stripBasename ? '' : basename) + newPath, 'route'];
116146
}
117-
return [basename + pathBuilder, 'route'];
147+
return [(_stripBasename ? '' : basename) + pathBuilder, 'route'];
118148
}
119149
}
120150
}
121151
}
122152
}
123153

124-
return [location.pathname, 'url'];
154+
return [_stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url'];
125155
}
126156

127157
function updatePageloadTransaction(

packages/react/test/reactrouterv6.4.test.tsx

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe('React Router v6.4', () => {
2626
function createInstrumentation(_opts?: {
2727
startTransactionOnPageLoad?: boolean;
2828
startTransactionOnLocationChange?: boolean;
29+
stripBasename?: boolean;
2930
}): [jest.Mock, { mockUpdateName: jest.Mock; mockFinish: jest.Mock; mockSetAttribute: jest.Mock }] {
3031
const options = {
3132
matchPath: _opts ? matchPath : undefined,
@@ -46,6 +47,7 @@ describe('React Router v6.4', () => {
4647
useNavigationType,
4748
createRoutesFromChildren,
4849
matchRoutes,
50+
options.stripBasename,
4951
)(mockStartTransaction, options.startTransactionOnPageLoad, options.startTransactionOnLocationChange);
5052
return [mockStartTransaction, { mockUpdateName, mockFinish, mockSetAttribute }];
5153
}
@@ -359,5 +361,93 @@ describe('React Router v6.4', () => {
359361
metadata: { source: 'route' },
360362
});
361363
});
364+
365+
it('strips `basename` from transaction names of parameterized paths', () => {
366+
const [mockStartTransaction] = createInstrumentation({
367+
stripBasename: true,
368+
});
369+
const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction);
370+
371+
const router = sentryCreateBrowserRouter(
372+
[
373+
{
374+
path: '/',
375+
element: <Navigate to="/some-org-id/users/some-user-id" />,
376+
},
377+
{
378+
path: ':orgId',
379+
children: [
380+
{
381+
path: 'users',
382+
children: [
383+
{
384+
path: ':userId',
385+
element: <div>User</div>,
386+
},
387+
],
388+
},
389+
],
390+
},
391+
],
392+
{
393+
initialEntries: ['/admin'],
394+
basename: '/admin',
395+
},
396+
);
397+
398+
// @ts-expect-error router is fine
399+
render(<RouterProvider router={router} />);
400+
401+
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
402+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
403+
name: '/:orgId/users/:userId',
404+
op: 'navigation',
405+
origin: 'auto.navigation.react.reactrouterv6',
406+
tags: { 'routing.instrumentation': 'react-router-v6' },
407+
metadata: { source: 'route' },
408+
});
409+
});
410+
411+
it('strips `basename` from transaction names of non-parameterized paths', () => {
412+
const [mockStartTransaction] = createInstrumentation({
413+
stripBasename: true,
414+
});
415+
const sentryCreateBrowserRouter = wrapCreateBrowserRouter(createMemoryRouter as CreateRouterFunction);
416+
417+
const router = sentryCreateBrowserRouter(
418+
[
419+
{
420+
path: '/',
421+
element: <Navigate to="/about/us" />,
422+
},
423+
{
424+
path: 'about',
425+
element: <div>About</div>,
426+
children: [
427+
{
428+
path: 'us',
429+
element: <div>Us</div>,
430+
},
431+
],
432+
},
433+
],
434+
{
435+
initialEntries: ['/app'],
436+
basename: '/app',
437+
},
438+
);
439+
440+
// @ts-expect-error router is fine
441+
render(<RouterProvider router={router} />);
442+
443+
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
444+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
445+
name: '/about/us',
446+
op: 'navigation',
447+
origin: 'auto.navigation.react.reactrouterv6',
448+
tags: { 'routing.instrumentation': 'react-router-v6' },
449+
metadata: { source: 'route' },
450+
});
451+
});
362452
});
363453
});

0 commit comments

Comments
 (0)