Skip to content

Commit f0ab0e0

Browse files
authored
fix(react): Fix React Router v6 paramaterization (#5515)
Make sure paramaterization occurs in scenarios where the full path is not available after all the branches are walked. In those scenarios, we have to construct the paramaterized name based on the previous branches that were analyzed. This patch also creates a new file in `@sentry/utils` - `url.ts`, where we store some url / string related utils. This was made so that the react package would get access to the `getNumberOfUrlSegments` util.
1 parent 31fd072 commit f0ab0e0

File tree

9 files changed

+164
-82
lines changed

9 files changed

+164
-82
lines changed

packages/react/src/reactrouterv6.tsx

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// https://gist.github.com/wontondon/e8c4bdf2888875e4c755712e99279536
33

44
import { Transaction, TransactionContext, TransactionSource } from '@sentry/types';
5-
import { getGlobalObject, logger } from '@sentry/utils';
5+
import { getGlobalObject, getNumberOfUrlSegments, logger } from '@sentry/utils';
66
import hoistNonReactStatics from 'hoist-non-react-statics';
77
import React from 'react';
88

@@ -20,9 +20,23 @@ type Params<Key extends string = string> = {
2020
readonly [key in Key]: string | undefined;
2121
};
2222

23+
// https://github.com/remix-run/react-router/blob/9fa54d643134cd75a0335581a75db8100ed42828/packages/react-router/lib/router.ts#L114-L134
2324
interface RouteMatch<ParamKey extends string = string> {
25+
/**
26+
* The names and values of dynamic parameters in the URL.
27+
*/
2428
params: Params<ParamKey>;
29+
/**
30+
* The portion of the URL pathname that was matched.
31+
*/
2532
pathname: string;
33+
/**
34+
* The portion of the URL pathname that was matched before child routes.
35+
*/
36+
pathnameBase: string;
37+
/**
38+
* The route object that was used to match.
39+
*/
2640
route: RouteObject;
2741
}
2842

@@ -94,13 +108,31 @@ function getNormalizedName(
94108

95109
const branches = matchRoutes(routes, location);
96110

111+
let pathBuilder = '';
97112
if (branches) {
98113
// eslint-disable-next-line @typescript-eslint/prefer-for-of
99114
for (let x = 0; x < branches.length; x++) {
100-
if (branches[x].route && branches[x].route.path && branches[x].pathname === location.pathname) {
101-
const path = branches[x].route.path;
115+
const branch = branches[x];
116+
const route = branch.route;
117+
if (route) {
118+
// Early return if index route
119+
if (route.index) {
120+
return [branch.pathname, 'route'];
121+
}
122+
123+
const path = route.path;
102124
if (path) {
103-
return [path, 'route'];
125+
const newPath = path[0] === '/' ? path : `/${path}`;
126+
pathBuilder += newPath;
127+
if (branch.pathname === location.pathname) {
128+
// If the route defined on the element is something like
129+
// <Route path="/stores/:storeId/products/:productId" element={<div>Product</div>} />
130+
// We should check against the branch.pathname for the number of / seperators
131+
if (getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname)) {
132+
return [newPath, 'route'];
133+
}
134+
return [pathBuilder, 'route'];
135+
}
104136
}
105137
}
106138
}

packages/react/test/reactrouterv6.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,4 +196,38 @@ describe('React Router v6', () => {
196196
metadata: { source: 'route' },
197197
});
198198
});
199+
200+
it('works with nested paths with parameters', () => {
201+
const [mockStartTransaction] = createInstrumentation();
202+
const SentryRoutes = withSentryReactRouterV6Routing(Routes);
203+
204+
render(
205+
<MemoryRouter initialEntries={['/']}>
206+
<SentryRoutes>
207+
<Route index element={<Navigate to="/projects/123/views/234" />} />
208+
<Route path="account" element={<div>Account Page</div>} />
209+
<Route path="projects">
210+
<Route index element={<div>Project Index</div>} />
211+
<Route path=":projectId" element={<div>Project Page</div>}>
212+
<Route index element={<div>Project Page Root</div>} />
213+
<Route element={<div>Editor</div>}>
214+
<Route path="views/:viewId" element={<div>View Canvas</div>} />
215+
<Route path="spaces/:spaceId" element={<div>Space Canvas</div>} />
216+
</Route>
217+
</Route>
218+
</Route>
219+
220+
<Route path="*" element={<div>No Match Page</div>} />
221+
</SentryRoutes>
222+
</MemoryRouter>,
223+
);
224+
225+
expect(mockStartTransaction).toHaveBeenCalledTimes(2);
226+
expect(mockStartTransaction).toHaveBeenLastCalledWith({
227+
name: '/projects/:projectId/views/:viewId',
228+
op: 'navigation',
229+
tags: { 'routing.instrumentation': 'react-router-v6' },
230+
metadata: { source: 'route' },
231+
});
232+
});
199233
});

packages/tracing/src/integrations/node/express.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
/* eslint-disable max-lines */
22
import { Integration, Transaction } from '@sentry/types';
3-
import { CrossPlatformRequest, extractPathForTransaction, isRegExp, logger } from '@sentry/utils';
3+
import {
4+
CrossPlatformRequest,
5+
extractPathForTransaction,
6+
getNumberOfUrlSegments,
7+
isRegExp,
8+
logger,
9+
} from '@sentry/utils';
410

511
type Method =
612
| 'all'
@@ -384,15 +390,6 @@ function getNumberOfArrayUrlSegments(routesArray: RouteType[]): number {
384390
}, 0);
385391
}
386392

387-
/**
388-
* Returns number of URL segments of a passed URL.
389-
* Also handles URLs of type RegExp
390-
*/
391-
function getNumberOfUrlSegments(url: string): number {
392-
// split at '/' or at '\/' to split regex urls correctly
393-
return url.split(/\\?\//).filter(s => s.length > 0 && s !== ',').length;
394-
}
395-
396393
/**
397394
* Extracts and returns the stringified version of the layers route path
398395
* Handles route arrays (by joining the paths together) as well as RegExp and normal

packages/utils/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ export * from './envelope';
2525
export * from './clientreport';
2626
export * from './ratelimit';
2727
export * from './baggage';
28+
export * from './url';

packages/utils/src/misc.ts

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -41,40 +41,6 @@ export function uuid4(): string {
4141
);
4242
}
4343

44-
/**
45-
* Parses string form of URL into an object
46-
* // borrowed from https://tools.ietf.org/html/rfc3986#appendix-B
47-
* // intentionally using regex and not <a/> href parsing trick because React Native and other
48-
* // environments where DOM might not be available
49-
* @returns parsed URL object
50-
*/
51-
export function parseUrl(url: string): {
52-
host?: string;
53-
path?: string;
54-
protocol?: string;
55-
relative?: string;
56-
} {
57-
if (!url) {
58-
return {};
59-
}
60-
61-
const match = url.match(/^(([^:/?#]+):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?$/);
62-
63-
if (!match) {
64-
return {};
65-
}
66-
67-
// coerce to undefined values to empty string so we don't get 'undefined'
68-
const query = match[6] || '';
69-
const fragment = match[8] || '';
70-
return {
71-
host: match[4],
72-
path: match[5],
73-
protocol: match[2],
74-
relative: match[5] + query + fragment, // everything minus origin
75-
};
76-
}
77-
7844
function getFirstException(event: Event): Exception | undefined {
7945
return event.exception && event.exception.values ? event.exception.values[0] : undefined;
8046
}
@@ -197,17 +163,6 @@ export function addContextToFrame(lines: string[], frame: StackFrame, linesOfCon
197163
.map((line: string) => snipLine(line, 0));
198164
}
199165

200-
/**
201-
* Strip the query string and fragment off of a given URL or path (if present)
202-
*
203-
* @param urlPath Full URL or path, including possible query string and/or fragment
204-
* @returns URL or path without query string or fragment
205-
*/
206-
export function stripUrlQueryAndFragment(urlPath: string): string {
207-
// eslint-disable-next-line no-useless-escape
208-
return urlPath.split(/[\?#]/, 1)[0];
209-
}
210-
211166
/**
212167
* Checks whether or not we've already captured the given exception (note: not an identical exception - the very object
213168
* in question), and marks it captured if not.

packages/utils/src/requestdata.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
import { Event, ExtractedNodeRequestData, Transaction, TransactionSource } from '@sentry/types';
1616

1717
import { isPlainObject, isString } from './is';
18-
import { stripUrlQueryAndFragment } from './misc';
1918
import { normalize } from './normalize';
19+
import { stripUrlQueryAndFragment } from './url';
2020

2121
const DEFAULT_INCLUDES = {
2222
ip: false,

packages/utils/src/url.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Parses string form of URL into an object
3+
* // borrowed from https://tools.ietf.org/html/rfc3986#appendix-B
4+
* // intentionally using regex and not <a/> href parsing trick because React Native and other
5+
* // environments where DOM might not be available
6+
* @returns parsed URL object
7+
*/
8+
export function parseUrl(url: string): {
9+
host?: string;
10+
path?: string;
11+
protocol?: string;
12+
relative?: string;
13+
} {
14+
if (!url) {
15+
return {};
16+
}
17+
18+
const match = url.match(/^(([^:/?#]+):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?$/);
19+
20+
if (!match) {
21+
return {};
22+
}
23+
24+
// coerce to undefined values to empty string so we don't get 'undefined'
25+
const query = match[6] || '';
26+
const fragment = match[8] || '';
27+
return {
28+
host: match[4],
29+
path: match[5],
30+
protocol: match[2],
31+
relative: match[5] + query + fragment, // everything minus origin
32+
};
33+
}
34+
35+
/**
36+
* Strip the query string and fragment off of a given URL or path (if present)
37+
*
38+
* @param urlPath Full URL or path, including possible query string and/or fragment
39+
* @returns URL or path without query string or fragment
40+
*/
41+
export function stripUrlQueryAndFragment(urlPath: string): string {
42+
// eslint-disable-next-line no-useless-escape
43+
return urlPath.split(/[\?#]/, 1)[0];
44+
}
45+
46+
/**
47+
* Returns number of URL segments of a passed string URL.
48+
*/
49+
export function getNumberOfUrlSegments(url: string): number {
50+
// split at '/' or at '\/' to split regex urls correctly
51+
return url.split(/\\?\//).filter(s => s.length > 0 && s !== ',').length;
52+
}

packages/utils/test/misc.test.ts

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
addExceptionMechanism,
66
checkOrSetAlreadyCaught,
77
getEventDescription,
8-
stripUrlQueryAndFragment,
98
uuid4,
109
} from '../src/misc';
1110

@@ -187,27 +186,6 @@ describe('addContextToFrame', () => {
187186
});
188187
});
189188

190-
describe('stripQueryStringAndFragment', () => {
191-
const urlString = 'http://dogs.are.great:1231/yay/';
192-
const queryString = '?furry=yes&funny=very';
193-
const fragment = '#adoptnotbuy';
194-
195-
it('strips query string from url', () => {
196-
const urlWithQueryString = `${urlString}${queryString}`;
197-
expect(stripUrlQueryAndFragment(urlWithQueryString)).toBe(urlString);
198-
});
199-
200-
it('strips fragment from url', () => {
201-
const urlWithFragment = `${urlString}${fragment}`;
202-
expect(stripUrlQueryAndFragment(urlWithFragment)).toBe(urlString);
203-
});
204-
205-
it('strips query string and fragment from url', () => {
206-
const urlWithQueryStringAndFragment = `${urlString}${queryString}${fragment}`;
207-
expect(stripUrlQueryAndFragment(urlWithQueryStringAndFragment)).toBe(urlString);
208-
});
209-
});
210-
211189
describe('addExceptionMechanism', () => {
212190
const defaultMechanism = { type: 'generic', handled: true };
213191

packages/utils/test/url.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { getNumberOfUrlSegments, stripUrlQueryAndFragment } from '../src/url';
2+
3+
describe('stripQueryStringAndFragment', () => {
4+
const urlString = 'http://dogs.are.great:1231/yay/';
5+
const queryString = '?furry=yes&funny=very';
6+
const fragment = '#adoptnotbuy';
7+
8+
it('strips query string from url', () => {
9+
const urlWithQueryString = `${urlString}${queryString}`;
10+
expect(stripUrlQueryAndFragment(urlWithQueryString)).toBe(urlString);
11+
});
12+
13+
it('strips fragment from url', () => {
14+
const urlWithFragment = `${urlString}${fragment}`;
15+
expect(stripUrlQueryAndFragment(urlWithFragment)).toBe(urlString);
16+
});
17+
18+
it('strips query string and fragment from url', () => {
19+
const urlWithQueryStringAndFragment = `${urlString}${queryString}${fragment}`;
20+
expect(stripUrlQueryAndFragment(urlWithQueryStringAndFragment)).toBe(urlString);
21+
});
22+
});
23+
24+
describe('getNumberOfUrlSegments', () => {
25+
test.each([
26+
['regular path', '/projects/123/views/234', 4],
27+
['single param paramaterized path', '/users/:id/details', 3],
28+
['multi param paramaterized path', '/stores/:storeId/products/:productId', 4],
29+
['regex path', String(/\/api\/post[0-9]/), 2],
30+
])('%s', (_: string, input, output) => {
31+
expect(getNumberOfUrlSegments(input)).toEqual(output);
32+
});
33+
});

0 commit comments

Comments
 (0)