Skip to content

Commit d7e6a87

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

File tree

2 files changed

+118
-12
lines changed

2 files changed

+118
-12
lines changed

packages/react/src/reactrouterv6.tsx

+67-12
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,20 +218,23 @@ 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>} />
199228
// We should check against the branch.pathname for the number of / separators
200229
getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) &&
201230
// We should not count wildcard operators in the url segments calculation
202-
pathBuilder.slice(-2) !== '/*'
231+
!pathEndsWithWildcard(pathBuilder)
203232
) {
204233
return [(_stripBasename ? '' : basename) + newPath, 'route'];
205234
}
206235

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

@@ -225,13 +254,14 @@ function updatePageloadTransaction(
225254
routes: RouteObject[],
226255
matches?: AgnosticDataRouteMatch,
227256
basename?: string,
257+
allRoutes?: RouteObject[],
228258
): void {
229259
const branches = Array.isArray(matches)
230260
? matches
231261
: (_matchRoutes(routes, location, basename) as unknown as RouteMatch[]);
232262

233263
if (branches) {
234-
const [name, source] = getNormalizedName(routes, location, branches, basename);
264+
const [name, source] = getNormalizedName(routes, location, branches, basename, allRoutes);
235265

236266
getCurrentScope().setTransactionName(name);
237267

@@ -248,6 +278,7 @@ function handleNavigation(
248278
navigationType: Action,
249279
matches?: AgnosticDataRouteMatch,
250280
basename?: string,
281+
allRoutes?: RouteObject[],
251282
): void {
252283
const branches = Array.isArray(matches) ? matches : _matchRoutes(routes, location, basename);
253284

@@ -257,7 +288,7 @@ function handleNavigation(
257288
}
258289

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

262293
startBrowserTracingNavigationSpan(client, {
263294
name,
@@ -270,6 +301,20 @@ function handleNavigation(
270301
}
271302
}
272303

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

329+
const allRoutes: RouteObject[] = [];
284330
let isMountRenderPass: boolean = true;
285331

286332
const SentryRoutes: React.FC<P> = (props: P) => {
@@ -291,11 +337,15 @@ export function withSentryReactRouterV6Routing<P extends Record<string, any>, R
291337
() => {
292338
const routes = _createRoutesFromChildren(props.children) as RouteObject[];
293339

340+
routes.forEach(route => {
341+
allRoutes.push(...getChildRoutesRecursively(route));
342+
});
343+
294344
if (isMountRenderPass) {
295-
updatePageloadTransaction(getActiveRootSpan(), location, routes);
345+
updatePageloadTransaction(getActiveRootSpan(), location, routes, undefined, undefined, allRoutes);
296346
isMountRenderPass = false;
297347
} else {
298-
handleNavigation(location, routes, navigationType);
348+
handleNavigation(location, routes, navigationType, undefined, undefined, allRoutes);
299349
}
300350
},
301351
// `props.children` is purposely not included in the dependency array, because we do not want to re-run this effect
@@ -326,6 +376,7 @@ export function wrapUseRoutes(origUseRoutes: UseRoutes): UseRoutes {
326376
}
327377

328378
let isMountRenderPass: boolean = true;
379+
const allRoutes: RouteObject[] = [];
329380

330381
const SentryRoutes: React.FC<{
331382
children?: React.ReactNode;
@@ -349,11 +400,15 @@ export function wrapUseRoutes(origUseRoutes: UseRoutes): UseRoutes {
349400
const normalizedLocation =
350401
typeof stableLocationParam === 'string' ? { pathname: stableLocationParam } : stableLocationParam;
351402

403+
routes.forEach(route => {
404+
allRoutes.push(...getChildRoutesRecursively(route));
405+
});
406+
352407
if (isMountRenderPass) {
353-
updatePageloadTransaction(getActiveRootSpan(), normalizedLocation, routes);
408+
updatePageloadTransaction(getActiveRootSpan(), normalizedLocation, routes, undefined, undefined, allRoutes);
354409
isMountRenderPass = false;
355410
} else {
356-
handleNavigation(normalizedLocation, routes, navigationType);
411+
handleNavigation(normalizedLocation, routes, navigationType, undefined, undefined, allRoutes);
357412
}
358413
}, [navigationType, stableLocationParam]);
359414

packages/react/test/reactrouterv6.test.tsx

+51
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)