Skip to content

Commit a5aabf9

Browse files
committed
fix(react): Support wildcard routes on React Router 6
1 parent 8c2aa1b commit a5aabf9

File tree

2 files changed

+213
-3
lines changed

2 files changed

+213
-3
lines changed

packages/react/src/reactrouterv6.tsx

+35-3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* eslint-disable max-lines */
12
// Inspired from Donnie McNeal's solution:
23
// https://gist.github.com/wontondon/e8c4bdf2888875e4c755712e99279536
34

@@ -141,6 +142,29 @@ function stripBasenameFromPathname(pathname: string, basename: string): string {
141142
return pathname.slice(startIndex) || '/';
142143
}
143144

145+
function sendIndexPath(pathBuilder: string, pathname: string, basename: string): [string, TransactionSource] {
146+
const reconstructedPath = pathBuilder || _stripBasename ? stripBasenameFromPathname(pathname, basename) : pathname;
147+
148+
const formattedPath =
149+
// If the path ends with a slash, remove it
150+
reconstructedPath[reconstructedPath.length - 1] === '/'
151+
? reconstructedPath.slice(0, -1)
152+
: // If the path ends with a wildcard, remove it
153+
reconstructedPath.slice(-2) === '/*'
154+
? reconstructedPath.slice(0, -1)
155+
: reconstructedPath;
156+
157+
return [formattedPath, 'route'];
158+
}
159+
160+
function pathEndsWithWildcard(path: string, branch: RouteMatch<string>): boolean {
161+
return (path.slice(-2) === '/*' && branch.route.children && branch.route.children.length > 0) || false;
162+
}
163+
164+
function pathIsWildcardAndHasChildren(path: string, branch: RouteMatch<string>): boolean {
165+
return (path === '*' && branch.route.children && branch.route.children.length > 0) || false;
166+
}
167+
144168
function getNormalizedName(
145169
routes: RouteObject[],
146170
location: Location,
@@ -158,14 +182,16 @@ function getNormalizedName(
158182
if (route) {
159183
// Early return if index route
160184
if (route.index) {
161-
return [_stripBasename ? stripBasenameFromPathname(branch.pathname, basename) : branch.pathname, 'route'];
185+
return sendIndexPath(pathBuilder, branch.pathname, basename);
162186
}
163-
164187
const path = route.path;
165-
if (path) {
188+
189+
// If path is not a wildcard and has no child routes, append the path
190+
if (path && !pathIsWildcardAndHasChildren(path, branch)) {
166191
const newPath = path[0] === '/' || pathBuilder[pathBuilder.length - 1] === '/' ? path : `/${path}`;
167192
pathBuilder += newPath;
168193

194+
// If the path matches the current location, return the path
169195
if (basename + branch.pathname === location.pathname) {
170196
if (
171197
// If the route defined on the element is something like
@@ -177,6 +203,12 @@ function getNormalizedName(
177203
) {
178204
return [(_stripBasename ? '' : basename) + newPath, 'route'];
179205
}
206+
207+
// if the last character of the pathbuilder is a wildcard and there are children, remove the wildcard
208+
if (pathEndsWithWildcard(pathBuilder, branch)) {
209+
pathBuilder = pathBuilder.slice(0, -1);
210+
}
211+
180212
return [(_stripBasename ? '' : basename) + pathBuilder, 'route'];
181213
}
182214
}

packages/react/test/reactrouterv6.test.tsx

+178
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,106 @@ describe('reactRouterV6BrowserTracingIntegration', () => {
391391
});
392392
});
393393

394+
it('works with wildcard routes', () => {
395+
const client = createMockBrowserClient();
396+
setCurrentClient(client);
397+
398+
client.addIntegration(
399+
reactRouterV6BrowserTracingIntegration({
400+
useEffect: React.useEffect,
401+
useLocation,
402+
useNavigationType,
403+
createRoutesFromChildren,
404+
matchRoutes,
405+
}),
406+
);
407+
const SentryRoutes = withSentryReactRouterV6Routing(Routes);
408+
409+
render(
410+
<MemoryRouter initialEntries={['/']}>
411+
<SentryRoutes>
412+
<Route path="*" element={<Outlet />}>
413+
<Route index element={<Navigate to="/projects/123/views/234" />} />
414+
<Route path="account" element={<div>Account Page</div>} />
415+
<Route path="projects">
416+
<Route path="*" element={<Outlet />}>
417+
<Route path=":projectId" element={<div>Project Page</div>}>
418+
<Route index element={<div>Project Page Root</div>} />
419+
<Route element={<div>Editor</div>}>
420+
<Route path="views/:viewId" element={<div>View Canvas</div>} />
421+
<Route path="spaces/:spaceId" element={<div>Space Canvas</div>} />
422+
</Route>
423+
</Route>
424+
</Route>
425+
</Route>
426+
<Route path="*" element={<div>No Match Page</div>} />
427+
</Route>
428+
</SentryRoutes>
429+
</MemoryRouter>,
430+
);
431+
432+
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
433+
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), {
434+
name: '/projects/:projectId/views/:viewId',
435+
attributes: {
436+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
437+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
438+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6',
439+
},
440+
});
441+
});
442+
443+
it('works with nested wildcard routes', () => {
444+
const client = createMockBrowserClient();
445+
setCurrentClient(client);
446+
447+
client.addIntegration(
448+
reactRouterV6BrowserTracingIntegration({
449+
useEffect: React.useEffect,
450+
useLocation,
451+
useNavigationType,
452+
createRoutesFromChildren,
453+
matchRoutes,
454+
}),
455+
);
456+
const SentryRoutes = withSentryReactRouterV6Routing(Routes);
457+
458+
render(
459+
<MemoryRouter initialEntries={['/']}>
460+
<SentryRoutes>
461+
<Route path="*" element={<Outlet />}>
462+
<Route index element={<Navigate to="/projects/123/views/234" />} />
463+
<Route path="account" element={<div>Account Page</div>} />
464+
<Route path="projects">
465+
<Route path="*" element={<Outlet />}>
466+
<Route path=":projectId" element={<div>Project Page</div>}>
467+
<Route index element={<div>Project Page Root</div>} />
468+
<Route element={<div>Editor</div>}>
469+
<Route path="*" element={<Outlet />}>
470+
<Route path="views/:viewId" element={<div>View Canvas</div>} />
471+
<Route path="spaces/:spaceId" element={<div>Space Canvas</div>} />
472+
</Route>
473+
</Route>
474+
</Route>
475+
</Route>
476+
</Route>
477+
<Route path="*" element={<div>No Match Page</div>} />
478+
</Route>
479+
</SentryRoutes>
480+
</MemoryRouter>,
481+
);
482+
483+
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
484+
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), {
485+
name: '/projects/:projectId/views/:viewId',
486+
attributes: {
487+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
488+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
489+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6',
490+
},
491+
});
492+
});
493+
394494
it("updates the scope's `transactionName` on a navigation", () => {
395495
const client = createMockBrowserClient();
396496
setCurrentClient(client);
@@ -849,6 +949,84 @@ describe('reactRouterV6BrowserTracingIntegration', () => {
849949
});
850950
});
851951

952+
it('works with wildcard routes', () => {
953+
const client = createMockBrowserClient();
954+
setCurrentClient(client);
955+
956+
client.addIntegration(
957+
reactRouterV6BrowserTracingIntegration({
958+
useEffect: React.useEffect,
959+
useLocation,
960+
useNavigationType,
961+
createRoutesFromChildren,
962+
matchRoutes,
963+
}),
964+
);
965+
const wrappedUseRoutes = wrapUseRoutes(useRoutes);
966+
967+
const Routes = () =>
968+
wrappedUseRoutes([
969+
{
970+
index: true,
971+
element: <Navigate to="/param-page/1231/details/3212" />,
972+
},
973+
{
974+
path: '*',
975+
element: <></>,
976+
children: [
977+
{
978+
path: 'profile',
979+
element: <></>,
980+
},
981+
{
982+
path: 'param-page',
983+
element: <Outlet />,
984+
children: [
985+
{
986+
path: '*',
987+
element: <Outlet />,
988+
children: [
989+
{
990+
path: ':id',
991+
element: <></>,
992+
children: [
993+
{
994+
element: <></>,
995+
path: 'details',
996+
children: [
997+
{
998+
element: <></>,
999+
path: ':superId',
1000+
},
1001+
],
1002+
},
1003+
],
1004+
},
1005+
],
1006+
},
1007+
],
1008+
},
1009+
],
1010+
},
1011+
]);
1012+
1013+
render(
1014+
<MemoryRouter initialEntries={['/']}>
1015+
<Routes />
1016+
</MemoryRouter>,
1017+
);
1018+
1019+
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
1020+
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), {
1021+
name: '/param-page/:id/details/:superId',
1022+
attributes: {
1023+
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
1024+
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
1025+
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6',
1026+
},
1027+
});
1028+
});
1029+
8521030
it('does not add double slashes to URLS', () => {
8531031
const client = createMockBrowserClient();
8541032
setCurrentClient(client);

0 commit comments

Comments
 (0)