Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react/src/reactrouter-compat-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export {
resolveRouteNameAndSource,
getNormalizedName,
getRootRoutes,
decodePathname,
locationIsInsideDescendantRoute,
prefixWithSlash,
rebuildRoutePathFromAllRoutes,
Expand Down
35 changes: 31 additions & 4 deletions packages/react/src/reactrouter-compat-utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,26 @@ export function prefixWithSlash(path: string): string {
return path[0] === '/' ? path : `/${path}`;
}

/**
* Decodes a pathname the way React Router does before matching (`decodePath`): segment by segment, so an
* encoded slash stays `%2F`. Returns the whole pathname unchanged if any segment is not valid percent-encoding.
*
* Browsers hand us `location.pathname` percent-encoded, while React Router decodes the pathname before matching,
* so `pathname` and `pathnameBase` on a `RouteMatch` are decoded (older 6.x routers decoded only partially, or not
* at all). Decoding both sides before comparing them keeps routes with an encoded parameter (non-ASCII, spaces,
* reserved characters) matchable regardless of which side is still encoded.
*/
export function decodePathname(pathname: string): string {
try {
return pathname
.split('/')
.map(segment => decodeURIComponent(segment).replace(/\//g, '%2F'))
.join('/');
} catch {
return pathname;
}
}

/**
* Rebuilds the route path from all available routes by matching against the current location.
*/
Expand All @@ -154,9 +174,13 @@ export function rebuildRoutePathFromAllRoutes(
for (const match of matchedRoutes) {
if (match.route.path && match.route.path !== '*') {
const path = pickPath(match);
const strippedPath = stripBasenameFromPathname(location.pathname, prefixWithSlash(match.pathnameBase));
const decodedPathname = decodePathname(location.pathname);
const strippedPath = stripBasenameFromPathname(
decodedPathname,
decodePathname(prefixWithSlash(match.pathnameBase)),
);

if (location.pathname === strippedPath) {
if (decodedPathname === strippedPath) {
return trimSlash(strippedPath);
}

Expand Down Expand Up @@ -221,7 +245,10 @@ function reconstructNameFromDescendantParent(
}

const remainingPathname =
stripBasenameFromPathname(location.pathname, prefixWithSlash(parentMatch.pathnameBase)) || '/';
stripBasenameFromPathname(
decodePathname(location.pathname),
decodePathname(prefixWithSlash(parentMatch.pathnameBase)),
) || '/';
const remainingName = rebuildRoutePathFromAllRoutes(
allRoutes.filter(route => route !== parentMatch.route),
{ pathname: remainingPathname },
Expand Down Expand Up @@ -303,7 +330,7 @@ export function getNormalizedName(
pathBuilder = trimSlash(pathBuilder) + prefixWithSlash(newPath);

// Check if this path matches the current location
if (trimSlash(location.pathname) !== trimSlash(basename + branch.pathname)) {
if (trimSlash(decodePathname(location.pathname)) !== trimSlash(decodePathname(basename + branch.pathname))) {
continue;
}

Expand Down
62 changes: 62 additions & 0 deletions packages/react/test/react-router.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,68 @@ describe('@sentry/react/react-router', () => {
expect(getCurrentScope().getScopeData().transactionName).toEqual('/about');
});

it('parameterizes a pageload whose pathname has a percent-encoded parameter', () => {
const client = createMockBrowserClient();
setCurrentClient(client);

client.addIntegration(reactRouterBrowserTracingIntegration());

const SentryRoutes = wrapReactRouterRouting(Routes);

render(
// What a browser's `location.pathname` holds for `/clubs/한글`
<MemoryRouter initialEntries={['/clubs/%ED%95%9C%EA%B8%80']}>
<SentryRoutes>
<Route path="/" element={<div>Home</div>} />
<Route path="/clubs/:clubId" element={<div>Club</div>} />
</SentryRoutes>
</MemoryRouter>,
);

expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1);
expect(getCurrentScope().getScopeData().transactionName).toEqual('/clubs/:clubId');
});

it('parameterizes a navigation whose pathname has a percent-encoded parameter', () => {
const client = createMockBrowserClient();
setCurrentClient(client);

client.addIntegration(reactRouterBrowserTracingIntegration());

const SentryRoutes = wrapReactRouterRouting(Routes);

function Home(): React.ReactElement {
const navigate = useNavigate();
return (
<button type="button" onClick={() => navigate('/clubs/%ED%95%9C%EA%B8%80')}>
to club
</button>
);
}

const { getByText } = render(
<MemoryRouter initialEntries={['/']}>
<SentryRoutes>
<Route path="/" element={<Home />} />
<Route path="/clubs/:clubId" element={<div>Club</div>} />
</SentryRoutes>
</MemoryRouter>,
);

fireEvent.click(getByText('to club'));

expect(mockStartBrowserTracingNavigationSpan).toHaveBeenCalledTimes(1);
expect(mockStartBrowserTracingNavigationSpan).toHaveBeenLastCalledWith(expect.any(BrowserClient), {
name: '/clubs/:clubId',
attributes: {
[SENTRY_SEGMENT_NAME_SOURCE]: 'route',
[URL_TEMPLATE]: '/clubs/:clubId',
[SENTRY_OP]: 'navigation',
[SENTRY_ORIGIN]: 'auto.navigation.react.reactrouter',
},
});
});

it('reactRouterBrowserTracingIntegration() instruments a navigation without passing router hooks', () => {
const client = createMockBrowserClient();
setCurrentClient(client);
Expand Down
99 changes: 99 additions & 0 deletions packages/react/test/reactrouter-compat-utils/utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
clearNavigationContext,
decodePathname,
getNavigationContext,
getNormalizedName,
getNumberOfUrlSegments,
Expand Down Expand Up @@ -184,7 +185,54 @@ describe('reactrouter-compat-utils/utils', () => {
});
});

describe('decodePathname', () => {
it('should decode percent-encoded segments', () => {
expect(decodePathname('/clubs/%ED%95%9C%EA%B8%80')).toBe('/clubs/한글');
expect(decodePathname('/users/a%40b.com')).toBe('/users/a@b.com');
});

it('should keep an encoded slash inside a segment encoded', () => {
expect(decodePathname('/files/a%2Fb')).toBe('/files/a%2Fb');
});

it('should not touch a plus sign', () => {
expect(decodePathname('/tags/a+b')).toBe('/tags/a+b');
});

it('should return the whole pathname unchanged when a segment is malformed', () => {
expect(decodePathname('/a/%E0%A4%A/b')).toBe('/a/%E0%A4%A/b');
});

it('should leave an already decoded pathname as is', () => {
expect(decodePathname('/clubs/한글')).toBe('/clubs/한글');
expect(decodePathname('/')).toBe('/');
expect(decodePathname('')).toBe('');
});
});

describe('rebuildRoutePathFromAllRoutes', () => {
it('should match a percent-encoded pathname against the decoded pathnameBase', () => {
const allRoutes: RouteObject[] = [{ path: ':clubId', element: null }];

// What a browser's `location.pathname` holds for `/한글`
const location: Location = { pathname: '/%ED%95%9C%EA%B8%80' };

const mockMatches: RouteMatch[] = [
{
route: { path: ':clubId', element: null },
params: { clubId: '한글' },
pathname: '/한글',
pathnameBase: '/한글',
},
];

// The recursive call matches the remaining routes (none) against the stripped pathname
mockMatchRoutes.mockImplementation((routes: RouteObject[]) => (routes.length ? mockMatches : []));

const result = rebuildRoutePathFromAllRoutes(allRoutes, location, mockMatchRoutes as MatchRoutes);
expect(result).toBe(':clubId');
});

it('should return pathname when it matches stripped path', () => {
const allRoutes: RouteObject[] = [{ path: '/users', element: null }];

Expand Down Expand Up @@ -423,6 +471,57 @@ describe('reactrouter-compat-utils/utils', () => {
});

describe('getNormalizedName', () => {
it('should parameterize a percent-encoded pathname whose branch pathname is decoded', () => {
const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }];
// What a browser's `location.pathname` holds for `/clubs/한글`
const location: Location = { pathname: '/clubs/%ED%95%9C%EA%B8%80' };
const branches: RouteMatch[] = [
{
route: { path: '/clubs/:clubId', element: null },
params: { clubId: '한글' },
pathname: '/clubs/한글',
pathnameBase: '/clubs/한글',
},
];

const result = getNormalizedName(routes, location, branches, '');
expect(result).toEqual(['/clubs/:clubId', 'route']);
});

it('should keep matching when the basename itself is percent-encoded', () => {
const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }];
// React Router strips the basename from the raw pathname, so an encoded basename is passed encoded
const basename = '/%ED%95%9C%EA%B5%AD';
const location: Location = { pathname: `${basename}/clubs/abc` };
const branches: RouteMatch[] = [
{
route: { path: '/clubs/:clubId', element: null },
params: { clubId: 'abc' },
pathname: '/clubs/abc',
pathnameBase: '/clubs/abc',
},
];

expect(getNormalizedName(routes, location, branches, basename)).toEqual([`${basename}/clubs/:clubId`, 'route']);
expect(getNormalizedName(routes, location, branches, basename, true)).toEqual(['/clubs/:clubId', 'route']);
});

it('should keep matching when the router left the branch pathname encoded', () => {
// react-router < 6.5 matched against the raw pathname, 6.5 - 6.22 used `decodeURI` (keeps `%40`)
const routes: RouteObject[] = [{ path: '/users/:email', element: null }];
const location: Location = { pathname: '/users/a%40b.com' };
const branches: RouteMatch[] = [
{
route: { path: '/users/:email', element: null },
params: { email: 'a%40b.com' },
pathname: '/users/a%40b.com',
pathnameBase: '/users/a%40b.com',
},
];

expect(getNormalizedName(routes, location, branches, '')).toEqual(['/users/:email', 'route']);
});

it('should return pathname with url source when no routes provided', () => {
const routes: RouteObject[] = [];
const location: Location = { pathname: '/test' };
Expand Down
35 changes: 35 additions & 0 deletions packages/react/test/reactrouter-descendant-routes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,41 @@ describe('React Router Descendant Routes', () => {
});

describe('withSentryReactRouterV6Routing', () => {
it('keeps the parent path prefix for a descendant route with a percent-encoded parameter', () => {
const client = createMockBrowserClient();
setCurrentClient(client);

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

const ChildRouter = () => (
<SentryRoutes>
<Route path=":id" element={<div id="child">Child</div>} />
</SentryRoutes>
);

const { container } = render(
// What a browser's `location.pathname` holds for `/child/한글`
<MemoryRouter initialEntries={['/child/%ED%95%9C%EA%B8%80']}>
<SentryRoutes>
<Route path="child/*" element={<ChildRouter />} />
</SentryRoutes>
</MemoryRouter>,
);

expect(container.innerHTML).toContain('Child');
expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1);
expect(getCurrentScope().getScopeData().transactionName).toEqual('/child/:id');
});

it('keeps the parent path prefix for descendant routes with non-wildcard nested children - pageload', () => {
const client = createMockBrowserClient();
setCurrentClient(client);
Expand Down
Loading