Skip to content

fix(react): Parameterize React Router transactions with encoded params - #25639

Merged
s1gr1d merged 3 commits into
getsentry:developfrom
seongwon030:fix/react-router-encoded-pathname
Oct 23, 2026
Merged

s1gr1d merged 3 commits into
getsentry:developfrom
seongwon030:fix/react-router-encoded-pathname

Conversation

@seongwon030

@seongwon030 seongwon030 commented Oct 22, 2026 •

Copy link
Copy Markdown

Closes #25638

  • If you've added code that should be tested, please add tests.
  • Ensure your code lints and the test suite passes (pnpm run lint) & (pnpm run test).
  • Link an issue if there is one related to your pull request. If no issue is linked, one will be auto-generated and linked.

location.pathname is percent-encoded, but React Router decodes the pathname before matching, so RouteMatch.pathname and pathnameBase are decoded. The React Router 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. Every non-ASCII parameter value became its own transaction name. Descendant routes (<Routes> nested under a /* route) produced the raw URL with source route.

Decoding never changes the number of / segments (an encoded slash stays %2F), so this compares segment counts instead of strings: getNormalizedName checks that the branch spans as many segments as the location, and the descendant-route lookups strip the parent pathnameBase from the raw location.pathname by segment count, the way useRoutes does. The remainder stays encoded, so React Router decodes it exactly once when matching it again. This works whichever side is encoded (older 6.x routers matched the raw pathname or used decodeURI), and values like %2541 or 100%25 are not decoded twice. Since the check no longer relies on string equality, a basename ending in / ('/', '/app/') or differing in case from the URL now yields a parameterized name as well; the basename is prefixed without its trailing slash so the name has no //.

Validation (in packages/react):

  • vitest run on the React Router suites (react-router, reactrouterv6, reactrouter-descendant-routes, reactrouter-cross-usage, reactrouter-compat-utils/*): 277 tests passed; the whole package: 400 passed
  • 11 of these tests fail on develop; the %2541, trailing-slash basename and descendant 100%25 tests also fail on the previous (decoding) revision of this PR
  • pnpm run lint and pnpm run lint:types clean; oxfmt --check passed on the changed files
  • Browser integration tests (Playwright) were not run

`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.
@seongwon030
seongwon030 requested a review from a team as a code owner October 22, 2026 03:18
@seongwon030
seongwon030 requested review from chargome and s1gr1d and removed request for a team October 22, 2026 03:18
@github-actions github-actions Bot added the external PR from an external contributor label Oct 22, 2026

@s1gr1d s1gr1d left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the looking into this and tests are great!

Instead of decoding, could we compare segment counts? Decoding never changes the number of / segments, so we don't need decodePathname. That also avoids two issues: /clubs/%2541 still doesn't match, because match.pathname gets decoded twice. And descendant routes now pass decoded paths back into matchRoutes, so /clubs/100%25 logs a "could not be decoded" warning.

Your tests already pass with the segment approach. Could you add those two URLs as cases too?

* 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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads-up: react-router 6.22.1+ already decodes match.pathname, so this decodes it a second time. A literal %41 in a param then doesn't match.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, that double decode is gone with the segment-count approach. Resolved in the follow-up commit.

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.
@seongwon030

seongwon030 commented Oct 23, 2026 •

Copy link
Copy Markdown
Author

Thanks, that's much simpler. I removed decodePathname: getNormalizedName now compares segment counts, and the descendant lookups strip the parent pathnameBase from the raw pathname by segment count, so React Router decodes the remainder only once.

/clubs/%2541 and /clubs/100%25 are added as unit and pageload cases, plus a descendant case asserting the "could not be decoded" warning is gone. Those fail on the previous revision.

One side effect worth a look: a basename ending in / ('/', '/app/') always failed the old string comparison and fell back to the URL. With segment counts it now matches, so I prefix the basename without its trailing slash to avoid //users (covered by a test). For the same reason a URL that differs from the basename only in case (/APP/clubs/abc under /app) is now parameterized as /app/clubs/:clubId instead of falling back to the raw URL.

@seongwon030
seongwon030 requested a review from s1gr1d October 23, 2026 10:54
@s1gr1d

s1gr1d commented Oct 23, 2026

Copy link
Copy Markdown
Member

Regarding the side-effect syou mentioned, they are fine: a basename with a trailing slash now gets parameterized like any other, and the case-insensitive match is the same as what React Router does.

@s1gr1d
s1gr1d enabled auto-merge (squash) October 23, 2026 11:36
@s1gr1d
s1gr1d merged commit 8758c57 into getsentry:develop Oct 23, 2026
128 checks passed
nicohrubec pushed a commit that referenced this pull request Oct 23, 2026
This PR adds the external contributor to the CHANGELOG.md file, so that
they are credited for their contribution. See #25639

Co-authored-by: s1gr1d <32902192+s1gr1d@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

external PR from an external contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

React Router transactions are not parameterized when the URL has a percent-encoded parameter

2 participants