Skip to content

Commit

Permalink
fix(react): Add React Router Descendant Routes support.
Browse files Browse the repository at this point in the history
  • Loading branch information
onurtemizkan committed Nov 14, 2024
1 parent f4c5900 commit d7e6a87
Show file tree
Hide file tree
Showing 2 changed files with 118 additions and 12 deletions.
79 changes: 67 additions & 12 deletions packages/react/src/reactrouterv6.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable complexity */
/* eslint-disable max-lines */
// Inspired from Donnie McNeal's solution:
// https://gist.github.com/wontondon/e8c4bdf2888875e4c755712e99279536
Expand Down Expand Up @@ -157,24 +158,49 @@ function sendIndexPath(pathBuilder: string, pathname: string, basename: string):
return [formattedPath, 'route'];
}

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

function pathIsWildcardAndHasChildren(path: string, branch: RouteMatch<string>): boolean {
return (path === '*' && branch.route.children && branch.route.children.length > 0) || false;
return (pathEndsWithWildcard(path) && branch.route.children && branch.route.children.length > 0) || false;
}

function pathIsWildcardWithNoChildren(path: string, branch: RouteMatch<string>): boolean {
return (pathEndsWithWildcard(path) && (!branch.route.children || branch.route.children.length === 0)) || false;
}

function getNormalizedName(
routes: RouteObject[],
location: Location,
branches: RouteMatch[],
basename: string = '',
allRoutes: RouteObject[] = routes,
): [string, TransactionSource] {
if (!routes || routes.length === 0) {
return [_stripBasename ? stripBasenameFromPathname(location.pathname, basename) : location.pathname, 'url'];
}

const matchedRoutes = _matchRoutes(routes, location);

if (matchedRoutes) {
const wildCardRoutes: RouteMatch[] = matchedRoutes.filter(
(match: RouteMatch) => match.route.path && pathIsWildcardWithNoChildren(match.route.path, match),
);

for (const wildCardRoute of wildCardRoutes) {
const wildCardRouteMatch = _matchRoutes(allRoutes, location, wildCardRoute.pathnameBase);

if (wildCardRouteMatch) {
const [name, source] = getNormalizedName(wildCardRoutes, location, wildCardRouteMatch, basename, allRoutes);

if (wildCardRoute.pathnameBase && name) {
return [wildCardRoute.pathnameBase + name, source];
}
}
}
}

let pathBuilder = '';
if (branches) {
for (const branch of branches) {
Expand All @@ -192,20 +218,23 @@ function getNormalizedName(
pathBuilder += newPath;

// If the path matches the current location, return the path
if (basename + branch.pathname === location.pathname) {
if (
location.pathname.endsWith(basename + branch.pathname) ||
location.pathname.endsWith(`${basename}${branch.pathname}/`)
) {
if (
// If the route defined on the element is something like
// <Route path="/stores/:storeId/products/:productId" element={<div>Product</div>} />
// We should check against the branch.pathname for the number of / separators
getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) &&
// We should not count wildcard operators in the url segments calculation
pathBuilder.slice(-2) !== '/*'
!pathEndsWithWildcard(pathBuilder)
) {
return [(_stripBasename ? '' : basename) + newPath, 'route'];
}

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

Expand All @@ -225,13 +254,14 @@ function updatePageloadTransaction(
routes: RouteObject[],
matches?: AgnosticDataRouteMatch,
basename?: string,
allRoutes?: RouteObject[],
): void {
const branches = Array.isArray(matches)
? matches
: (_matchRoutes(routes, location, basename) as unknown as RouteMatch[]);

if (branches) {
const [name, source] = getNormalizedName(routes, location, branches, basename);
const [name, source] = getNormalizedName(routes, location, branches, basename, allRoutes);

getCurrentScope().setTransactionName(name);

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

Expand All @@ -257,7 +288,7 @@ function handleNavigation(
}

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

startBrowserTracingNavigationSpan(client, {
name,
Expand All @@ -270,6 +301,20 @@ function handleNavigation(
}
}

const getChildRoutesRecursively = (route: RouteObject): RouteObject[] => {
const routes: RouteObject[] = [];

if (route.children) {
route.children.forEach(child => {
routes.push(...getChildRoutesRecursively(child));
});
}

routes.push(route);

return routes;
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function withSentryReactRouterV6Routing<P extends Record<string, any>, R extends React.FC<P>>(Routes: R): R {
if (!_useEffect || !_useLocation || !_useNavigationType || !_createRoutesFromChildren || !_matchRoutes) {
Expand All @@ -281,6 +326,7 @@ export function withSentryReactRouterV6Routing<P extends Record<string, any>, R
return Routes;
}

const allRoutes: RouteObject[] = [];
let isMountRenderPass: boolean = true;

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

routes.forEach(route => {
allRoutes.push(...getChildRoutesRecursively(route));
});

if (isMountRenderPass) {
updatePageloadTransaction(getActiveRootSpan(), location, routes);
updatePageloadTransaction(getActiveRootSpan(), location, routes, undefined, undefined, allRoutes);
isMountRenderPass = false;
} else {
handleNavigation(location, routes, navigationType);
handleNavigation(location, routes, navigationType, undefined, undefined, allRoutes);
}
},
// `props.children` is purposely not included in the dependency array, because we do not want to re-run this effect
Expand Down Expand Up @@ -326,6 +376,7 @@ export function wrapUseRoutes(origUseRoutes: UseRoutes): UseRoutes {
}

let isMountRenderPass: boolean = true;
const allRoutes: RouteObject[] = [];

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

routes.forEach(route => {
allRoutes.push(...getChildRoutesRecursively(route));
});

if (isMountRenderPass) {
updatePageloadTransaction(getActiveRootSpan(), normalizedLocation, routes);
updatePageloadTransaction(getActiveRootSpan(), normalizedLocation, routes, undefined, undefined, allRoutes);
isMountRenderPass = false;
} else {
handleNavigation(normalizedLocation, routes, navigationType);
handleNavigation(normalizedLocation, routes, navigationType, undefined, undefined, allRoutes);
}
}, [navigationType, stableLocationParam]);

Expand Down
51 changes: 51 additions & 0 deletions packages/react/test/reactrouterv6.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,57 @@ describe('reactRouterV6BrowserTracingIntegration', () => {
});
});

it('works with descendant wildcard routes', () => {
const client = createMockBrowserClient();
setCurrentClient(client);

client.addIntegration(
reactRouterV6BrowserTracingIntegration({
useEffect: React.useEffect,
useLocation,
useNavigationType,
createRoutesFromChildren,
matchRoutes,
}),
);
const SentryRoutes = withSentryReactRouterV6Routing(Routes);

const ProjectsRoutes = () => (
<SentryRoutes>
<Route path=":projectId" element={<div>Project Page</div>}>
<Route index element={<div>Project Page Root</div>} />
<Route element={<div>Editor</div>}>
<Route path="*" element={<Outlet />}>
<Route path="views/:viewId" element={<div>View Canvas</div>} />
</Route>
</Route>
</Route>
<Route path="*" element={<div>No Match Page</div>} />
</SentryRoutes>
);

render(
<MemoryRouter initialEntries={['/']}>
<SentryRoutes>
<Route index element={<Navigate to="/projects/123/views/234" />} />
<Route path="projects/*" element={<ProjectsRoutes />}></Route>
</SentryRoutes>
</MemoryRouter>,
);

// Fixme: Check why it's called twice
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(2);

expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), {
name: '/projects/:projectId/views/:viewId',
attributes: {
[SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'navigation',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter_v6',
},
});
});

it("updates the scope's `transactionName` on a navigation", () => {
const client = createMockBrowserClient();
setCurrentClient(client);
Expand Down

0 comments on commit d7e6a87

Please sign in to comment.