diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index ce0a0699352a..b243ad4bf421 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -137,6 +137,31 @@ export function prefixWithSlash(path: string): string { return path[0] === '/' ? path : `/${path}`; } +/** + * Removes as many leading segments from `pathname` as `pathnameBase` has, the way `useRoutes` strips the parent + * `pathnameBase` for descendant routes. + * + * `location.pathname` is percent-encoded as the browser holds it, while React Router decodes the pathname before + * matching, so `pathnameBase` on a `RouteMatch` is decoded (older 6.x routers decoded only partially, or not at all). + * Stripping by segment count works regardless of which side is encoded, and keeps the remainder encoded so that + * matching it again decodes it exactly once. + */ +function stripPathnameBaseSegments(pathname: string, pathnameBase: string): string { + const baseSegmentCount = countPathnameSegments(pathnameBase); + if (baseSegmentCount === 0) { + return pathname; + } + return prefixWithSlash(pathname.split('/').filter(Boolean).slice(baseSegmentCount).join('/')); +} + +/** + * Counts the segments of a pathname with a plain split (`getNumberOfUrlSegments` also drops `,` segments, which a + * route parameter can legitimately be). + */ +function countPathnameSegments(pathname: string): number { + return pathname.split('/').filter(Boolean).length; +} + /** * Rebuilds the route path from all available routes by matching against the current location. */ @@ -154,7 +179,7 @@ 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 strippedPath = stripPathnameBaseSegments(location.pathname, prefixWithSlash(match.pathnameBase)); if (location.pathname === strippedPath) { return trimSlash(strippedPath); @@ -220,8 +245,7 @@ function reconstructNameFromDescendantParent( return undefined; } - const remainingPathname = - stripBasenameFromPathname(location.pathname, prefixWithSlash(parentMatch.pathnameBase)) || '/'; + const remainingPathname = stripPathnameBaseSegments(location.pathname, prefixWithSlash(parentMatch.pathnameBase)); const remainingName = rebuildRoutePathFromAllRoutes( allRoutes.filter(route => route !== parentMatch.route), { pathname: remainingPathname }, @@ -302,8 +326,10 @@ export function getNormalizedName( const newPath = path[0] === '/' || pathBuilder[pathBuilder.length - 1] === '/' ? path : `/${path}`; pathBuilder = trimSlash(pathBuilder) + prefixWithSlash(newPath); - // Check if this path matches the current location - if (trimSlash(location.pathname) !== trimSlash(basename + branch.pathname)) { + // Check if this branch spans the whole location. Compare segment counts rather than strings: the location + // pathname is percent-encoded while React Router decoded the branch pathname, and decoding never changes the + // number of segments. + if (countPathnameSegments(location.pathname) !== countPathnameSegments(basename + branch.pathname)) { continue; } @@ -312,7 +338,7 @@ export function getNormalizedName( getNumberOfUrlSegments(pathBuilder) !== getNumberOfUrlSegments(branch.pathname) && !pathEndsWithWildcard(pathBuilder) ) { - return [(stripBasename ? '' : basename) + newPath, 'route']; + return [(stripBasename ? '' : trimSlash(basename)) + newPath, 'route']; } // Handle wildcard routes with children - strip trailing wildcard @@ -320,7 +346,7 @@ export function getNormalizedName( pathBuilder = pathBuilder.slice(0, -1); } - return [(stripBasename ? '' : basename) + pathBuilder, 'route']; + return [(stripBasename ? '' : trimSlash(basename)) + pathBuilder, 'route']; } // Fallback when no matching route found diff --git a/packages/react/test/react-router.test.tsx b/packages/react/test/react-router.test.tsx index b25323e691c2..23ee153c2bd8 100644 --- a/packages/react/test/react-router.test.tsx +++ b/packages/react/test/react-router.test.tsx @@ -80,6 +80,71 @@ describe('@sentry/react/react-router', () => { expect(getCurrentScope().getScopeData().transactionName).toEqual('/about'); }); + it.each([ + ['/clubs/%ED%95%9C%EA%B8%80', 'non-ASCII, what a browser holds for `/clubs/한글`'], + ['/clubs/%2541', 'still looks percent-encoded after decoding (`%41`)'], + ['/clubs/100%25', 'not valid percent-encoding after decoding (`100%`)'], + ])('parameterizes a pageload whose pathname has a percent-encoded parameter (%s: %s)', pathname => { + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration(reactRouterBrowserTracingIntegration()); + + const SentryRoutes = wrapReactRouterRouting(Routes); + + render( + + + Home} /> + Club} /> + + , + ); + + 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 ( + + ); + } + + const { getByText } = render( + + + } /> + Club} /> + + , + ); + + 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); diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts index 7e1435521dc7..a5cca4030fac 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -185,6 +185,28 @@ describe('reactrouter-compat-utils/utils', () => { }); 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 }]; @@ -423,6 +445,112 @@ 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 not double the slash when the basename ends with one', () => { + const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }]; + const branches: RouteMatch[] = [ + { + route: { path: '/clubs/:clubId', element: null }, + params: { clubId: 'abc' }, + pathname: '/clubs/abc', + pathnameBase: '/clubs/abc', + }, + ]; + + // e.g. Vite's `import.meta.env.BASE_URL` always ends with a slash + expect(getNormalizedName(routes, { pathname: '/clubs/abc' }, branches, '/')).toEqual(['/clubs/:clubId', 'route']); + expect(getNormalizedName(routes, { pathname: '/app/clubs/abc' }, branches, '/app/')).toEqual([ + '/app/clubs/:clubId', + 'route', + ]); + expect(getNormalizedName(routes, { pathname: '/app/clubs/abc' }, branches, '/app/', 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 parameterize a pathname whose parameter still looks percent-encoded after decoding', () => { + // `%2541` decodes to the literal `%41`; a second decode would turn it into `A` + const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }]; + const location: Location = { pathname: '/clubs/%2541' }; + const branches: RouteMatch[] = [ + { + route: { path: '/clubs/:clubId', element: null }, + params: { clubId: '%41' }, + pathname: '/clubs/%41', + pathnameBase: '/clubs/%41', + }, + ]; + + expect(getNormalizedName(routes, location, branches, '')).toEqual(['/clubs/:clubId', 'route']); + }); + + it('should parameterize a pathname whose decoded parameter is not valid percent-encoding', () => { + // `100%25` decodes to `100%`, which cannot be decoded again + const routes: RouteObject[] = [{ path: '/clubs/:clubId', element: null }]; + const location: Location = { pathname: '/clubs/100%25' }; + const branches: RouteMatch[] = [ + { + route: { path: '/clubs/:clubId', element: null }, + params: { clubId: '100%' }, + pathname: '/clubs/100%', + pathnameBase: '/clubs/100%', + }, + ]; + + expect(getNormalizedName(routes, location, branches, '')).toEqual(['/clubs/:clubId', 'route']); + }); + it('should return pathname with url source when no routes provided', () => { const routes: RouteObject[] = []; const location: Location = { pathname: '/test' }; diff --git a/packages/react/test/reactrouter-descendant-routes.test.tsx b/packages/react/test/reactrouter-descendant-routes.test.tsx index 7f712ad0f5d7..64b810c6f7ab 100644 --- a/packages/react/test/reactrouter-descendant-routes.test.tsx +++ b/packages/react/test/reactrouter-descendant-routes.test.tsx @@ -84,6 +84,81 @@ 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 = () => ( + + Child} /> + + ); + + const { container } = render( + // What a browser's `location.pathname` holds for `/child/한글` + + + } /> + + , + ); + + expect(container.innerHTML).toContain('Child'); + expect(mockStartBrowserTracingPageLoadSpan).toHaveBeenCalledTimes(1); + expect(getCurrentScope().getScopeData().transactionName).toEqual('/child/:id'); + }); + + it('re-matches the descendant remainder without decoding it twice', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const client = createMockBrowserClient(); + setCurrentClient(client); + + client.addIntegration( + reactRouterV6BrowserTracingIntegration({ + useEffect: React.useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ); + const SentryRoutes = withSentryReactRouterV6Routing(Routes); + + const ChildRouter = () => ( + + Child} /> + + ); + + try { + const { container } = render( + // `100%25` decodes to `100%`, which React Router cannot decode a second time + + + } /> + + , + ); + + expect(container.innerHTML).toContain('Child'); + expect(getCurrentScope().getScopeData().transactionName).toEqual('/child/:id'); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('could not be decoded')); + } finally { + warnSpy.mockRestore(); + } + }); + it('keeps the parent path prefix for descendant routes with non-wildcard nested children - pageload', () => { const client = createMockBrowserClient(); setCurrentClient(client);