Skip to content

Commit 077b041

Browse files
committed
fix(react): Add React Router Descendant Routes support.
1 parent fcd2935 commit 077b041

File tree

4 files changed

+188
-21
lines changed

4 files changed

+188
-21
lines changed

packages/react/src/reactrouterv6-compat-utils.tsx

Lines changed: 134 additions & 20 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
@@ -174,13 +175,14 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio
174175
return origUseRoutes;
175176
}
176177

177-
let isMountRenderPass: boolean = true;
178+
const allRoutes: RouteObject[] = [];
178179

179180
const SentryRoutes: React.FC<{
180181
children?: React.ReactNode;
181182
routes: RouteObject[];
182183
locationArg?: Partial<Location> | string;
183184
}> = (props: { children?: React.ReactNode; routes: RouteObject[]; locationArg?: Partial<Location> | string }) => {
185+
const isMountRenderPass = React.useRef(true);
184186
const { routes, locationArg } = props;
185187

186188
const Routes = origUseRoutes(routes, locationArg);
@@ -198,11 +200,15 @@ export function createV6CompatibleWrapUseRoutes(origUseRoutes: UseRoutes, versio
198200
const normalizedLocation =
199201
typeof stableLocationParam === 'string' ? { pathname: stableLocationParam } : stableLocationParam;
200202

201-
if (isMountRenderPass) {
202-
updatePageloadTransaction(getActiveRootSpan(), normalizedLocation, routes);
203-
isMountRenderPass = false;
203+
routes.forEach(route => {
204+
allRoutes.push(...getChildRoutesRecursively(route));
205+
});
206+
207+
if (isMountRenderPass.current) {
208+
updatePageloadTransaction(getActiveRootSpan(), normalizedLocation, routes, undefined, undefined, allRoutes);
209+
isMountRenderPass.current = false;
204210
} else {
205-
handleNavigation(normalizedLocation, routes, navigationType, version);
211+
handleNavigation(normalizedLocation, routes, navigationType, version, undefined, undefined, allRoutes);
206212
}
207213
}, [navigationType, stableLocationParam]);
208214

@@ -222,6 +228,7 @@ export function handleNavigation(
222228
version: V6CompatibleVersion,
223229
matches?: AgnosticDataRouteMatch,
224230
basename?: string,
231+
allRoutes?: RouteObject[],
225232
): void {
226233
const branches = Array.isArray(matches) ? matches : _matchRoutes(routes, location, basename);
227234

@@ -233,8 +240,14 @@ export function handleNavigation(
233240
if ((navigationType === 'PUSH' || navigationType === 'POP') && branches) {
234241
const [name, source] = getNormalizedName(routes, location, branches, basename);
235242

243+
let txnName = name;
244+
245+
if (locationIsInsideDescendantRoute(location, allRoutes || routes)) {
246+
txnName = prefixWithSlash(rebuildRoutePathFromAllRoutes(allRoutes || routes, location));
247+
}
248+
236249
startBrowserTracingNavigationSpan(client, {
237-
name,
250+
name: txnName,
238251
attributes: {
239252
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: source,
240253
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
@@ -286,12 +299,93 @@ function sendIndexPath(pathBuilder: string, pathname: string, basename: string):
286299
return [formattedPath, 'route'];
287300
}
288301

289-
function pathEndsWithWildcard(path: string, branch: RouteMatch<string>): boolean {
290-
return (path.slice(-2) === '/*' && branch.route.children && branch.route.children.length > 0) || false;
302+
function pathEndsWithWildcard(path: string): boolean {
303+
return path.endsWith('*');
291304
}
292305

293306
function pathIsWildcardAndHasChildren(path: string, branch: RouteMatch<string>): boolean {
294-
return (path === '*' && branch.route.children && branch.route.children.length > 0) || false;
307+
return (pathEndsWithWildcard(path) && branch.route.children && branch.route.children.length > 0) || false;
308+
}
309+
310+
// function pathIsWildcardWithNoChildren(path: string, branch: RouteMatch<string>): boolean {
311+
// return (pathEndsWithWildcard(path) && (!branch.route.children || branch.route.children.length === 0)) || false;
312+
// }
313+
314+
function routeIsDescendant(route: RouteObject): boolean {
315+
return !!(!route.children && route.element && route.path && route.path.endsWith('/*'));
316+
}
317+
318+
function locationIsInsideDescendantRoute(location: Location, routes: RouteObject[]): boolean {
319+
const matchedRoutes = _matchRoutes(routes, location) as RouteMatch[];
320+
321+
if (matchedRoutes) {
322+
for (const match of matchedRoutes) {
323+
if (routeIsDescendant(match.route) && pickSplat(match)) {
324+
return true;
325+
}
326+
}
327+
}
328+
329+
return false;
330+
}
331+
332+
function getChildRoutesRecursively(route: RouteObject, allRoutes: RouteObject[] = []): RouteObject[] {
333+
if (route.children && !route.index) {
334+
route.children.forEach(child => {
335+
allRoutes.push(...getChildRoutesRecursively(child, allRoutes));
336+
});
337+
}
338+
339+
allRoutes.push(route);
340+
341+
return allRoutes;
342+
}
343+
344+
function pickPath(match: RouteMatch): string {
345+
return trimWildcard(match.route.path || '');
346+
}
347+
348+
function pickSplat(match: RouteMatch): string {
349+
return match.params['*'] || '';
350+
}
351+
352+
function trimWildcard(path: string): string {
353+
return path[path.length - 1] === '*' ? path.slice(0, -1) : path;
354+
}
355+
356+
function trimSlash(path: string): string {
357+
return path[path.length - 1] === '/' ? path.slice(0, -1) : path;
358+
}
359+
360+
function prefixWithSlash(path: string): string {
361+
return path[0] === '/' ? path : `/${path}`;
362+
}
363+
364+
function rebuildRoutePathFromAllRoutes(allRoutes: RouteObject[], location: Location): string {
365+
const matchedRoutes = _matchRoutes(allRoutes, location) as RouteMatch[];
366+
367+
if (matchedRoutes) {
368+
for (const match of matchedRoutes) {
369+
if (match.route.path && match.route.path !== '*') {
370+
const path = pickPath(match);
371+
const strippedPath = stripBasenameFromPathname(location.pathname, prefixWithSlash(match.pathnameBase));
372+
373+
return trimSlash(
374+
trimSlash(path || '') +
375+
prefixWithSlash(
376+
rebuildRoutePathFromAllRoutes(
377+
allRoutes.filter(route => route !== match.route),
378+
{
379+
pathname: strippedPath,
380+
},
381+
),
382+
),
383+
);
384+
}
385+
}
386+
}
387+
388+
return '';
295389
}
296390

297391
function getNormalizedName(
@@ -321,7 +415,10 @@ function getNormalizedName(
321415
pathBuilder += newPath;
322416

323417
// If the path matches the current location, return the path
324-
if (basename + branch.pathname === location.pathname) {
418+
if (
419+
location.pathname.endsWith(basename + branch.pathname) ||
420+
location.pathname.endsWith(`${basename}${branch.pathname}/`)
421+
) {
325422
if (
326423
// If the route defined on the element is something like
327424
// <Route path="/stores/:storeId/products/:productId" element={<div>Product</div>} />
@@ -330,13 +427,13 @@ function getNormalizedName(
330427
// eslint-disable-next-line deprecation/deprecation
331428
getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) &&
332429
// We should not count wildcard operators in the url segments calculation
333-
pathBuilder.slice(-2) !== '/*'
430+
!pathEndsWithWildcard(pathBuilder)
334431
) {
335432
return [(_stripBasename ? '' : basename) + newPath, 'route'];
336433
}
337434

338435
// if the last character of the pathbuilder is a wildcard and there are children, remove the wildcard
339-
if (pathEndsWithWildcard(pathBuilder, branch)) {
436+
if (pathIsWildcardAndHasChildren(pathBuilder, branch)) {
340437
pathBuilder = pathBuilder.slice(0, -1);
341438
}
342439

@@ -347,7 +444,11 @@ function getNormalizedName(
347444
}
348445
}
349446

350-
return [_stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url'];
447+
const fallbackTransactionName = _stripBasename
448+
? stripBasenameFromPathname(location.pathname, basename)
449+
: location.pathname || '/';
450+
451+
return [fallbackTransactionName, 'url'];
351452
}
352453

353454
function updatePageloadTransaction(
@@ -356,6 +457,7 @@ function updatePageloadTransaction(
356457
routes: RouteObject[],
357458
matches?: AgnosticDataRouteMatch,
358459
basename?: string,
460+
allRoutes?: RouteObject[],
359461
): void {
360462
const branches = Array.isArray(matches)
361463
? matches
@@ -364,10 +466,16 @@ function updatePageloadTransaction(
364466
if (branches) {
365467
const [name, source] = getNormalizedName(routes, location, branches, basename);
366468

367-
getCurrentScope().setTransactionName(name);
469+
let txnName = name;
470+
471+
if (locationIsInsideDescendantRoute(location, allRoutes || routes)) {
472+
txnName = prefixWithSlash(rebuildRoutePathFromAllRoutes(allRoutes || routes, location));
473+
}
474+
475+
getCurrentScope().setTransactionName(txnName);
368476

369477
if (activeRootSpan) {
370-
activeRootSpan.updateName(name);
478+
activeRootSpan.updateName(txnName);
371479
activeRootSpan.setAttribute(SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, source);
372480
}
373481
}
@@ -387,21 +495,27 @@ export function createV6CompatibleWithSentryReactRouterRouting<P extends Record<
387495
return Routes;
388496
}
389497

390-
let isMountRenderPass: boolean = true;
498+
const allRoutes: RouteObject[] = [];
391499

392500
const SentryRoutes: React.FC<P> = (props: P) => {
501+
const isMountRenderPass = React.useRef(true);
502+
393503
const location = _useLocation();
394504
const navigationType = _useNavigationType();
395505

396506
_useEffect(
397507
() => {
398508
const routes = _createRoutesFromChildren(props.children) as RouteObject[];
399509

400-
if (isMountRenderPass) {
401-
updatePageloadTransaction(getActiveRootSpan(), location, routes);
402-
isMountRenderPass = false;
510+
routes.forEach(route => {
511+
allRoutes.push(...getChildRoutesRecursively(route));
512+
});
513+
514+
if (isMountRenderPass.current) {
515+
updatePageloadTransaction(getActiveRootSpan(), location, routes, undefined, undefined, allRoutes);
516+
isMountRenderPass.current = false;
403517
} else {
404-
handleNavigation(location, routes, navigationType, version);
518+
handleNavigation(location, routes, navigationType, version, undefined, undefined, allRoutes);
405519
}
406520
},
407521
// `props.children` is purposely not included in the dependency array, because we do not want to re-run this effect

packages/react/src/reactrouterv6.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
/* eslint-disable complexity */
2+
/* eslint-disable max-lines */
3+
// Inspired from Donnie McNeal's solution:
4+
// https://gist.github.com/wontondon/e8c4bdf2888875e4c755712e99279536
15
import type { browserTracingIntegration } from '@sentry/browser';
26
import type { Integration } from '@sentry/core';
37
import type { ReactRouterOptions } from './reactrouterv6-compat-utils';

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
matchRoutes,
1818
useLocation,
1919
useNavigationType,
20-
} from 'react-router-6.4';
20+
} from 'react-router-6';
2121

2222
import { BrowserClient, wrapCreateBrowserRouter } from '../src';
2323
import { reactRouterV6BrowserTracingIntegration } from '../src/reactrouterv6';

packages/react/test/reactrouterv6.test.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,55 @@ 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+
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
533+
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), {
534+
name: '/projects/:projectId/views/:viewId',
535+
attributes: {
536+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
537+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
538+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6',
539+
},
540+
});
541+
});
542+
494543
it("updates the scope's `transactionName` on a navigation", () => {
495544
const client = createMockBrowserClient();
496545
setCurrentClient(client);

0 commit comments

Comments
 (0)