From 4884c7f6b0bc1321ce2985eead19e94f5d609d57 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Thu, 22 Oct 2026 12:16:54 +0900 Subject: [PATCH 1/2] fix(react): Parameterize React Router transactions with encoded params `location.pathname` is percent-encoded, but React Router decodes the pathname before matching, so `RouteMatch.pathname` and `pathnameBase` are decoded. The instrumentation compared the two with plain string equality, so a route like `/clubs/:clubId` visited as `/clubs/%ED%95%9C%EA%B8%80` never matched and the transaction fell back to the raw URL with source `url`. Decode both sides, segment by segment like React Router's `decodePath`, before comparing in `getNormalizedName` and before stripping `pathnameBase` for descendant routes. --- .../src/reactrouter-compat-utils/index.ts | 1 + .../src/reactrouter-compat-utils/utils.ts | 35 ++++++- packages/react/test/react-router.test.tsx | 62 ++++++++++++ .../reactrouter-compat-utils/utils.test.ts | 99 +++++++++++++++++++ .../reactrouter-descendant-routes.test.tsx | 35 +++++++ 5 files changed, 228 insertions(+), 4 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/index.ts b/packages/react/src/reactrouter-compat-utils/index.ts index 426c6747eff7..3c1093621dcc 100644 --- a/packages/react/src/reactrouter-compat-utils/index.ts +++ b/packages/react/src/reactrouter-compat-utils/index.ts @@ -19,6 +19,7 @@ export { resolveRouteNameAndSource, getNormalizedName, getRootRoutes, + decodePathname, locationIsInsideDescendantRoute, prefixWithSlash, rebuildRoutePathFromAllRoutes, diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index ce0a0699352a..a56fd2ce6b43 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -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. */ @@ -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); } @@ -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 }, @@ -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; } diff --git a/packages/react/test/react-router.test.tsx b/packages/react/test/react-router.test.tsx index b25323e691c2..fd94c9fc7cc0 100644 --- a/packages/react/test/react-router.test.tsx +++ b/packages/react/test/react-router.test.tsx @@ -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/한글` + + + 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..7f3b9d6799f1 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { clearNavigationContext, + decodePathname, getNavigationContext, getNormalizedName, getNumberOfUrlSegments, @@ -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 }]; @@ -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' }; diff --git a/packages/react/test/reactrouter-descendant-routes.test.tsx b/packages/react/test/reactrouter-descendant-routes.test.tsx index 7f712ad0f5d7..aa99d65a3dec 100644 --- a/packages/react/test/reactrouter-descendant-routes.test.tsx +++ b/packages/react/test/reactrouter-descendant-routes.test.tsx @@ -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 = () => ( + + 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('keeps the parent path prefix for descendant routes with non-wildcard nested children - pageload', () => { const client = createMockBrowserClient(); setCurrentClient(client); From 234b9eaa3a3fd4fa43524905866808eb0f8c0110 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Fri, 23 Oct 2026 19:43:21 +0900 Subject: [PATCH 2/2] fix(react): Compare segment counts instead of decoding pathnames Drop decodePathname. getNormalizedName now checks that a branch spans as many segments as the location, and the descendant route lookups strip the parent pathnameBase from the raw pathname by segment count, so React Router decodes the remainder only once when matching it again. Prefix the basename without its trailing slash, since a basename such as '/' or '/app/' now reaches the route name return paths. Add /clubs/%2541 and /clubs/100%25 cases. --- .../src/reactrouter-compat-utils/index.ts | 1 - .../src/reactrouter-compat-utils/utils.ts | 55 +++++++------ packages/react/test/react-router.test.tsx | 9 ++- .../reactrouter-compat-utils/utils.test.ts | 81 +++++++++++++------ .../reactrouter-descendant-routes.test.tsx | 40 +++++++++ 5 files changed, 128 insertions(+), 58 deletions(-) diff --git a/packages/react/src/reactrouter-compat-utils/index.ts b/packages/react/src/reactrouter-compat-utils/index.ts index 3c1093621dcc..426c6747eff7 100644 --- a/packages/react/src/reactrouter-compat-utils/index.ts +++ b/packages/react/src/reactrouter-compat-utils/index.ts @@ -19,7 +19,6 @@ export { resolveRouteNameAndSource, getNormalizedName, getRootRoutes, - decodePathname, locationIsInsideDescendantRoute, prefixWithSlash, rebuildRoutePathFromAllRoutes, diff --git a/packages/react/src/reactrouter-compat-utils/utils.ts b/packages/react/src/reactrouter-compat-utils/utils.ts index a56fd2ce6b43..b243ad4bf421 100644 --- a/packages/react/src/reactrouter-compat-utils/utils.ts +++ b/packages/react/src/reactrouter-compat-utils/utils.ts @@ -138,23 +138,28 @@ export function prefixWithSlash(path: string): string { } /** - * 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. + * Removes as many leading segments from `pathname` as `pathnameBase` has, the way `useRoutes` strips the parent + * `pathnameBase` for descendant routes. * - * 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. + * `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. */ -export function decodePathname(pathname: string): string { - try { - return pathname - .split('/') - .map(segment => decodeURIComponent(segment).replace(/\//g, '%2F')) - .join('/'); - } catch { +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; } /** @@ -174,13 +179,9 @@ export function rebuildRoutePathFromAllRoutes( for (const match of matchedRoutes) { if (match.route.path && match.route.path !== '*') { const path = pickPath(match); - const decodedPathname = decodePathname(location.pathname); - const strippedPath = stripBasenameFromPathname( - decodedPathname, - decodePathname(prefixWithSlash(match.pathnameBase)), - ); + const strippedPath = stripPathnameBaseSegments(location.pathname, prefixWithSlash(match.pathnameBase)); - if (decodedPathname === strippedPath) { + if (location.pathname === strippedPath) { return trimSlash(strippedPath); } @@ -244,11 +245,7 @@ function reconstructNameFromDescendantParent( return undefined; } - const remainingPathname = - stripBasenameFromPathname( - decodePathname(location.pathname), - decodePathname(prefixWithSlash(parentMatch.pathnameBase)), - ) || '/'; + const remainingPathname = stripPathnameBaseSegments(location.pathname, prefixWithSlash(parentMatch.pathnameBase)); const remainingName = rebuildRoutePathFromAllRoutes( allRoutes.filter(route => route !== parentMatch.route), { pathname: remainingPathname }, @@ -329,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(decodePathname(location.pathname)) !== trimSlash(decodePathname(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; } @@ -339,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 @@ -347,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 fd94c9fc7cc0..23ee153c2bd8 100644 --- a/packages/react/test/react-router.test.tsx +++ b/packages/react/test/react-router.test.tsx @@ -80,7 +80,11 @@ describe('@sentry/react/react-router', () => { expect(getCurrentScope().getScopeData().transactionName).toEqual('/about'); }); - it('parameterizes a pageload whose pathname has a percent-encoded parameter', () => { + 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); @@ -89,8 +93,7 @@ describe('@sentry/react/react-router', () => { const SentryRoutes = wrapReactRouterRouting(Routes); render( - // What a browser's `location.pathname` holds for `/clubs/한글` - + Home} /> Club} /> diff --git a/packages/react/test/reactrouter-compat-utils/utils.test.ts b/packages/react/test/reactrouter-compat-utils/utils.test.ts index 7f3b9d6799f1..a5cca4030fac 100644 --- a/packages/react/test/reactrouter-compat-utils/utils.test.ts +++ b/packages/react/test/reactrouter-compat-utils/utils.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { clearNavigationContext, - decodePathname, getNavigationContext, getNormalizedName, getNumberOfUrlSegments, @@ -185,31 +184,6 @@ 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 }]; @@ -506,6 +480,29 @@ describe('reactrouter-compat-utils/utils', () => { 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 }]; @@ -522,6 +519,38 @@ describe('reactrouter-compat-utils/utils', () => { 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 aa99d65a3dec..64b810c6f7ab 100644 --- a/packages/react/test/reactrouter-descendant-routes.test.tsx +++ b/packages/react/test/reactrouter-descendant-routes.test.tsx @@ -119,6 +119,46 @@ describe('React Router Descendant Routes', () => { 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);