Skip to content

Commit b0ae4c9

Browse files
committed
fix(react): Add React Router Descendant Routes support.
1 parent bdbdcc8 commit b0ae4c9

File tree

2 files changed

+118
-12
lines changed

2 files changed

+118
-12
lines changed

packages/react/src/reactrouterv6.tsx

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable complexity */
12
/* eslint-disable max-lines */
23
// Inspired from Donnie McNeal's solution:
34
// https://gist.github.com/wontondon/e8c4bdf2888875e4c755712e99279536
@@ -157,24 +158,49 @@ function sendIndexPath(pathBuilder: string, pathname: string, basename: string):
157158
return [formattedPath, 'route'];
158159
}
159160

160-
function pathEndsWithWildcard(path: string, branch: RouteMatch<string>): boolean {
161-
return (path.slice(-2) === '/*' && branch.route.children && branch.route.children.length > 0) || false;
161+
function pathEndsWithWildcard(path: string): boolean {
162+
return path.endsWith('*');
162163
}
163164

164165
function pathIsWildcardAndHasChildren(path: string, branch: RouteMatch<string>): boolean {
165-
return (path === '*' && branch.route.children && branch.route.children.length > 0) || false;
166+
return (pathEndsWithWildcard(path) && branch.route.children && branch.route.children.length > 0) || false;
167+
}
168+
169+
function pathIsWildcardWithNoChildren(path: string, branch: RouteMatch<string>): boolean {
170+
return (pathEndsWithWildcard(path) && (!branch.route.children || branch.route.children.length === 0)) || false;
166171
}
167172

168173
function getNormalizedName(
169174
routes: RouteObject[],
170175
location: Location,
171176
branches: RouteMatch[],
172177
basename: string = '',
178+
allRoutes: RouteObject[] = routes,
173179
): [string, TransactionSource] {
174180
if (!routes || routes.length === 0) {
175181
return [_stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url'];
176182
}
177183

184+
const matchedRoutes = _matchRoutes(routes, location);
185+
186+
if (matchedRoutes) {
187+
const wildCardRoutes: RouteMatch[] = matchedRoutes.filter(
188+
(match: RouteMatch) => match.route.path && pathIsWildcardWithNoChildren(match.route.path, match),
189+
);
190+
191+
for (const wildCardRoute of wildCardRoutes) {
192+
const wildCardRouteMatch = _matchRoutes(allRoutes, location, wildCardRoute.pathnameBase);
193+
194+
if (wildCardRouteMatch) {
195+
const [name, source] = getNormalizedName(wildCardRoutes, location, wildCardRouteMatch, basename, allRoutes);
196+
197+
if (wildCardRoute.pathnameBase && name) {
198+
return [wildCardRoute.pathnameBase + name, source];
199+
}
200+
}
201+
}
202+
}
203+
178204
let pathBuilder = '';
179205
if (branches) {
180206
for (const branch of branches) {
@@ -192,7 +218,10 @@ function getNormalizedName(
192218
pathBuilder += newPath;
193219

194220
// If the path matches the current location, return the path
195-
if (basename + branch.pathname === location.pathname) {
221+
if (
222+
location.pathname.endsWith(basename + branch.pathname) ||
223+
location.pathname.endsWith(`${basename}${branch.pathname}/`)
224+
) {
196225
if (
197226
// If the route defined on the element is something like
198227
// <Route path="/stores/:storeId/products/:productId" element={<div>Product</div>} />
@@ -201,13 +230,13 @@ function getNormalizedName(
201230
// eslint-disable-next-line deprecation/deprecation
202231
getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) &&
203232
// We should not count wildcard operators in the url segments calculation
204-
pathBuilder.slice(-2) !== '/*'
233+
!pathEndsWithWildcard(pathBuilder)
205234
) {
206235
return [(_stripBasename ? '' : basename) + newPath, 'route'];
207236
}
208237

209238
// if the last character of the pathbuilder is a wildcard and there are children, remove the wildcard
210-
if (pathEndsWithWildcard(pathBuilder, branch)) {
239+
if (pathIsWildcardAndHasChildren(pathBuilder, branch)) {
211240
pathBuilder = pathBuilder.slice(0, -1);
212241
}
213242

@@ -227,13 +256,14 @@ function updatePageloadTransaction(
227256
routes: RouteObject[],
228257
matches?: AgnosticDataRouteMatch,
229258
basename?: string,
259+
allRoutes?: RouteObject[],
230260
): void {
231261
const branches = Array.isArray(matches)
232262
? matches
233263
: (_matchRoutes(routes, location, basename) as unknown as RouteMatch[]);
234264

235265
if (branches) {
236-
const [name, source] = getNormalizedName(routes, location, branches, basename);
266+
const [name, source] = getNormalizedName(routes, location, branches, basename, allRoutes);
237267

238268
getCurrentScope().setTransactionName(name);
239269

@@ -250,6 +280,7 @@ function handleNavigation(
250280
navigationType: Action,
251281
matches?: AgnosticDataRouteMatch,
252282
basename?: string,
283+
allRoutes?: RouteObject[],
253284
): void {
254285
const branches = Array.isArray(matches) ? matches : _matchRoutes(routes, location, basename);
255286

@@ -259,7 +290,7 @@ function handleNavigation(
259290
}
260291

261292
if ((navigationType === 'PUSH' || navigationType === 'POP') && branches) {
262-
const [name, source] = getNormalizedName(routes, location, branches, basename);
293+
const [name, source] = getNormalizedName(routes, location, branches, basename, allRoutes);
263294

264295
startBrowserTracingNavigationSpan(client, {
265296
name,
@@ -272,6 +303,20 @@ function handleNavigation(
272303
}
273304
}
274305

306+
const getChildRoutesRecursively = (route: RouteObject): RouteObject[] => {
307+
const routes: RouteObject[] = [];
308+
309+
if (route.children) {
310+
route.children.forEach(child => {
311+
routes.push(...getChildRoutesRecursively(child));
312+
});
313+
}
314+
315+
routes.push(route);
316+
317+
return routes;
318+
};
319+
275320
// eslint-disable-next-line @typescript-eslint/no-explicit-any
276321
export function withSentryReactRouterV6Routing<P extends Record<string, any>, R extends React.FC<P>>(Routes: R): R {
277322
if (!_useEffect || !_useLocation || !_useNavigationType || !_createRoutesFromChildren || !_matchRoutes) {
@@ -283,6 +328,7 @@ export function withSentryReactRouterV6Routing<P extends Record<string, any>, R
283328
return Routes;
284329
}
285330

331+
const allRoutes: RouteObject[] = [];
286332
let isMountRenderPass: boolean = true;
287333

288334
const SentryRoutes: React.FC<P> = (props: P) => {
@@ -293,11 +339,15 @@ export function withSentryReactRouterV6Routing<P extends Record<string, any>, R
293339
() => {
294340
const routes = _createRoutesFromChildren(props.children) as RouteObject[];
295341

342+
routes.forEach(route => {
343+
allRoutes.push(...getChildRoutesRecursively(route));
344+
});
345+
296346
if (isMountRenderPass) {
297-
updatePageloadTransaction(getActiveRootSpan(), location, routes);
347+
updatePageloadTransaction(getActiveRootSpan(), location, routes, undefined, undefined, allRoutes);
298348
isMountRenderPass = false;
299349
} else {
300-
handleNavigation(location, routes, navigationType);
350+
handleNavigation(location, routes, navigationType, undefined, undefined, allRoutes);
301351
}
302352
},
303353
// `props.children` is purposely not included in the dependency array, because we do not want to re-run this effect
@@ -328,6 +378,7 @@ export function wrapUseRoutes(origUseRoutes: UseRoutes): UseRoutes {
328378
}
329379

330380
let isMountRenderPass: boolean = true;
381+
const allRoutes: RouteObject[] = [];
331382

332383
const SentryRoutes: React.FC<{
333384
children?: React.ReactNode;
@@ -351,11 +402,15 @@ export function wrapUseRoutes(origUseRoutes: UseRoutes): UseRoutes {
351402
const normalizedLocation =
352403
typeof stableLocationParam === 'string' ? { pathname: stableLocationParam } : stableLocationParam;
353404

405+
routes.forEach(route => {
406+
allRoutes.push(...getChildRoutesRecursively(route));
407+
});
408+
354409
if (isMountRenderPass) {
355-
updatePageloadTransaction(getActiveRootSpan(), normalizedLocation, routes);
410+
updatePageloadTransaction(getActiveRootSpan(), normalizedLocation, routes, undefined, undefined, allRoutes);
356411
isMountRenderPass = false;
357412
} else {
358-
handleNavigation(normalizedLocation, routes, navigationType);
413+
handleNavigation(normalizedLocation, routes, navigationType, undefined, undefined, allRoutes);
359414
}
360415
}, [navigationType, stableLocationParam]);
361416

packages/react/test/reactrouterv6.test.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,57 @@ describe('reactRouterV6BrowserTracingIntegration', () => {
491491
});
492492
});
493493

494+
it('works with descendant wildcard routes', () => {
495+
const client = createMockBrowserClient();
496+
setCurrentClient(client);
497+
498+
client.addIntegration(
499+
reactRouterV6BrowserTracingIntegration({
500+
useEffect: React.useEffect,
501+
useLocation,
502+
useNavigationType,
503+
createRoutesFromChildren,
504+
matchRoutes,
505+
}),
506+
);
507+
const SentryRoutes = withSentryReactRouterV6Routing(Routes);
508+
509+
const ProjectsRoutes = () => (
510+
<SentryRoutes>
511+
<Route path=":projectId" element={<div>Project Page</div>}>
512+
<Route index element={<div>Project Page Root</div>} />
513+
<Route element={<div>Editor</div>}>
514+
<Route path="*" element={<Outlet />}>
515+
<Route path="views/:viewId" element={<div>View Canvas</div>} />
516+
</Route>
517+
</Route>
518+
</Route>
519+
<Route path="*" element={<div>No Match Page</div>} />
520+
</SentryRoutes>
521+
);
522+
523+
render(
524+
<MemoryRouter initialEntries={['/']}>
525+
<SentryRoutes>
526+
<Route index element={<Navigate to="/projects/123/views/234" />} />
527+
<Route path="projects/*" element={<ProjectsRoutes />}></Route>
528+
</SentryRoutes>
529+
</MemoryRouter>,
530+
);
531+
532+
// Fixme: Check why it's called twice
533+
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2);
534+
535+
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), {
536+
name: '/projects/:projectId/views/:viewId',
537+
attributes: {
538+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
539+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
540+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6',
541+
},
542+
});
543+
});
544+
494545
it("updates the scope's `transactionName` on a navigation", () => {
495546
const client = createMockBrowserClient();
496547
setCurrentClient(client);

0 commit comments

Comments
 (0)