fix(deps): update react-router and react-router-dom to ^6.30.6 - #2047
Merged
arbrandes merged 2 commits intoSep 2, 2026
Conversation
Records where a learner ends up for each url shape the absolute DECODE_ROUTES patterns serve, as a matrix of 13 route templates against 10 course-key spellings, plus 4 rows covering variations in something other than the course key. The spellings cover four key values -- modern, modern with the punctuation opaque-keys' ALLOWED_ID_CHARS permits, deprecated, and deprecated carrying a literal percent sign -- each unencoded, encoded once and encoded six times. The deprecated pair has no unencoded spelling because its slash separators cannot sit in one path segment. Each key declares where it lands rather than the test deriving that by a rule, which keeps an existing bug visible instead of papering over it: decodeUrl recurses until the string stops changing, so a deprecated key whose text legitimately contains %2B is over-decoded and lands as a different key. The two relative patterns (REDIRECT_HOME, REDIRECT_SURVEY) are not covered: CoursewareRedirectLandingPage nests them under ROUTES.REDIRECT and RedirectPage leaves via global.location.assign, so no landing pathname is observable in a MemoryRouter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both pins move together. Bumping only one nests a second copy of react-router underneath react-router-dom, giving the app two distinct router context objects, so the three modules importing from bare react-router read a different context than the rest of the tree. react-router 6.20 changed matching to decode the pathname first, so useMatch now hands back a decoded computedMatch.pathname. DecodePageRoute compares its regenerated url against that field to decide whether to redirect, and that comparison collapsed. Feeding matchPath the raw pathname from useLocation restores the field to what it held before, leaving the comparison itself untouched. matchPath is not a hook, so the match also stops running inside a forEach callback. The same decoding applies to <Routes>, so a route pattern whose literal is spelled with a percent-escape can no longer match anything. renderPage mounted DecodePageRoute under the pattern from its own fixture, which is v5-to-v6 migration leftover: before b788b96 the match was injected as a computedMatch prop and there was no outer route at all. Mounting on a catch-all restores that intent and leaves every fixture and assertion in the file unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2047 +/- ##
=======================================
Coverage 93.58% 93.58%
=======================================
Files 367 367
Lines 6003 6004 +1
Branches 1419 1382 -37
=======================================
+ Hits 5618 5619 +1
- Misses 368 369 +1
+ Partials 17 16 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Take
react-routerandreact-router-domto^6.30.6together, plus the two changes that bump requires. Supersedes #935 (renovate), which bumps onlyreact-router-domand is red on CI. Separately, #1952 (dependabot) proposesreact-router→ 8.3.0; that's a v7/v8 migration and a different conversation.What changed
package.json— both pins move as a pair. Bumping one alone nests a secondreact-routerunderneathreact-router-dom, so the app gets two distinct router context objects, and the three modules importing from barereact-router(Unit/index.test.jsx:1,CoursewareSearch.jsx:2,CoursewareResultsFilter.jsx:5) read a different context than the rest of the tree. That accounts for 7 of the 9 failures on fix(deps): update dependency react-router-dom to v6.30.6 - autoclosed #935 —useLocation() may be used only in the context of a <Router>(45 occurrences in that CI log) and acourserun_key: undefinedtracking event.decode-page-route/index.jsx(3 lines) —useMatch(route)→matchPath(route, location.pathname). react-router 6.20 changeduseMatchto decode the pathname before matching, socomputedMatch.pathnamebecame the decoded path andDecodePageRoute's redirect guard silently collapsed.matchPathnever decoded, so feeding it the raw pathname restores that field to what it held on 6.15 — everything fromif (computedMatch)down is untouched.matchPathis also not a hook, so the match stops running inside aforEachcallback.decode-page-route/index.test.jsx(1 line) —renderPagemounts onpath="*". The same decoding applies to<Routes>, so the fixture's%2B-spelled route literal can no longer match and the component never mounted. That outer route was v5→v6 migration leftover: before b788b96 the match was injected as acomputedMatchprop and there was no outer route at all. No fixture value and no assertion changed.Behavior
No user-facing change, pinned by a characterization test added first, on 6.15.0, so the bump had to preserve behavior rather than redefine it.
134 cases: 13 route templates × 10 course-key spellings — modern, modern using the
~ . :punctuationopaque-keys'ALLOWED_ID_CHARSpermits, deprecated, and deprecated carrying a literal%; each unencoded / encoded once / encoded six times, minus the cells where a deprecated key has no unencoded spelling — plus 4 rows for block-key spellings. 134/134 on 6.15.0 and 134/134 on 6.30.6, same landing URL character for character.Without the
DecodePageRoutechange, six of those URL shapes stop normalizing — e.g./course/course-v1%3AedX%2BDemoX%2BDemo_Course/homewould stay encoded instead of redirecting to its decoded form.Worth knowing downstream: on 6.20+, a route pattern whose literal is spelled with a percent-escape other than
%2Fcan never match, because<Routes>matches against the decoded pathname. No route in this repo spells one, but a plugin or downstream MFE might.Two pre-existing bugs surfaced and deliberately left alone:
decodeUrlcorrupts deprecated course keys containing a literal%(reproducible standalone, now pinned on both versions), anduseParamsdecoding is untested becauseRedirectPage.test.jsx:13mocks it out.Testing
npm run lint✓,npm run types✓. Full suite on 6.30.6: 1092 passed — the single failure is the knownCourse.test.jsx"displays learner tools" parallel-run flake, green in isolation.src/decode-page-route/: 138/138 (134 matrix + the 4 original tests, all unchanged).Decisions
Full decision log
Decisions — react-router 6.15.0 → 6.30.6
Working log for taking PR #935 (renovate,
react-router-dom6.15.0 → 6.30.6) to green.Context
Two bots are fighting over these two packages:
react-router-domto 6.30.6.react-routerto 8.3.0.package.jsonpins both packages separately and exactly, so either PR on its own splits theversions. #935's CI is red: 9 failures across 3 suites.
Decision 1 — bump both pins together, as caret ranges
react-router: ^6.30.6andreact-router-dom: ^6.30.6. 6.30.6 is the newest 6.x for both.npm installthen produces a flat lock — onereact-router, onereact-router-dom, one@remix-run/router@1.23.4, no nesting.Why
#935 bumps
react-router-domwhilereact-routerstays at 6.15.0, so npm nests a second copy atnode_modules/react-router-dom/node_modules/react-router@6.30.6. Two copies means two distinctReact context objects, and this repo imports from both entrypoints — three files use bare
react-router(Unit/index.test.jsx:1,CoursewareSearch.jsx:2,CoursewareResultsFilter.jsx:5) against 86 usingreact-router-dom.That accounted for 7 of the 9 failures in #935:
Unit/index.test.jsxwraps inMemoryRouterfromreact-router(6.15.0) while the tree's hooksresolve to the nested 6.30.6 copy →
useLocation() may be used only in the context of a <Router>(45 occurrences in the CI log) → error boundary renders
data-testid="error-page", so all 6assertions miss their testids.
CoursewareSearch.test.jsxprovidesRoute/Routesfromreact-router-domwhile the componentreads
useParamsfromreact-router→courserun_key: undefinedin the tracking event.Bumping both pins in lockstep removes the nesting and all 7 failures.
Decision 2 —
DecodePageRoutematches on the raw pathnamesrc/decode-page-route/index.jsx, three lines:useMatch(route)→matchPath(route, location.pathname), withlocationfromuseLocation(). Everything fromif (computedMatch)down is untouched.
Why
@remix-run/router1.13 (react-router 6.20) changed matching to decode the pathname first:useMatchis exactlyuseLocation+matchPath, with adecodePathwedged between them since6.20.
matchPathitself never decoded the pathname, and still doesn't.The component's guard,
newUrl !== pathname(index.jsx:42), asks "is the URL in the address bardifferent from the fully-decoded URL?" On 6.15
computedMatch.pathnamewas the raw pathname —useMatchpassesend: true, so the match spans the whole path and the field is just the pathnameit was given. On 6.30 that same field is the decoded pathname, so the comparison collapses to
newUrl === computedMatch.pathnameand the redirect silently stops firing.Feeding
matchPaththe raw pathname restores that field, so the guard keeps readingcomputedMatch.pathnameexactly as it did on 6.15 —matchPathcompiles string patterns withend: true, so the match spans the whole path andcomputedMatch.pathnameis the pathname it wasgiven. The fix sits entirely upstream of the comparison; the comparison itself is not edited.
paramsdo differ between versions (6.15matchPathdecoded them once; 6.30 returns them raw bar%2F), butdecodeUrl's recurse-to-fixed-point washes that out —generatePath's output isbyte-identical across both versions.
Side effect, accepted
useMatchmemoised each match; a plainmatchPathrecompiles the pattern regex every render,×13 root patterns. Cheap, and it removes a
react-hooks/rules-of-hooksirregularity — the old codecalled a hook inside
ROUTES.forEach, safe only becauseROUTESis module-level.Rejected alternative — re-baseline the tests instead
Leave the component alone and accept the new behavior:
matchRoutesdecodes too(
router.js:527), so<Routes>matches encoded URLs anduseParams()yields decoded valueswithout any redirect. Rejected because it changes two tests instead of one, and one of those
changes inverts an assertion from "this redirect happens" to "this redirect doesn't happen" —
declaring a user-visible behavior change correct rather than proving it harmless. Measured: six of
nine representative URLs stop normalizing (see below).
Evidence — behavior pinned before the bump
src/decode-page-route/decodeRoutes.test.tsx, committed on 6.15.0 before the bump, recordswhere a learner lands for each URL shape, as a matrix rather than a hand-picked list:
target user, bare course, courseware sequence, courseware unit, preview sequence, preview unit,
discussion topic.
~ . :punctuationALLOWED_ID_CHARSpermits, deprecated, and deprecated carrying a literal%; each unencoded / encoded once / encoded six times, except the deprecated pair which has nounencoded spelling because its
/separators cannot sit in one path segment.13 × 10 = 130 cases, plus 4 explicit rows for the things that vary something other than the
course key (block-key spellings, a unit key containing an encoded slash, and a mangled url with an
encoded space). 134 total, 134 passing on 6.15.0.
Design notes:
landsAs— where it actually ends up — rather than the test deriving theexpectation by a rule. A rule would either be circular (mirroring the implementation) or would
paper over the deprecated-
%corruption below.:unitIdis a UsageKey with its owngrammar, so drawing it from the course key list would assert that a course key is a valid unit
id. Their spelling variations live in the 4 explicit rows.
block-v1:keys (in the courseware/previewtemplates) is not a realistic combination — real deprecated courses have
i4x://usage keys —but the routes pass strings through, and the encoding behavior is what is under test.
134/134 on 6.30.6 as well, with Decision 2 applied — same landing URL, character for
character, including every deprecated-key and literal-
%row. Those are the rows with a plausiblereason to differ, since
decodePathdecodes each segment and then re-escapes slashes, which isaimed squarely at
%2F-bearing keys.Without Decision 2, six of the URL shapes stop normalizing, e.g.
/course/course-v1%3AedX%2BDemoX%2BDemo_Course/homestays encoded instead of redirecting to/course/course-v1:edX+DemoX+Demo_Course/home.Harness caveat
MemoryRouterstores a pushed path verbatim; a browser history may re-encode it. The fixtures'landing strings contain only characters browsers leave alone in a path (
: + / ~ .), with oneexception: the mangled-url row's landing holds a literal space, where a browser would show
%20.That row's expected value is memory-history-specific.
What a course key can actually be
From
openedx/opaque-keys,opaque_keys/edx/locator.py:CourseLocator.__init__validates org/course/run againstALLOWED_ID_RE, serialized as"+".join([org, course, run])behindcourse-v1:. The platform's URL layer is far more permissive(
openedx/core/constants.py:10):Consequences for fixtures:
Modern keys cannot contain
%, space,/, or+inside a part. They can contain extracolons, tildes and periods — not currently exercised.
Deprecated keys (
org/course/run) may contain a literal%, and their/separators musttravel as
%2Fwithin one path segment. So the encoded-slash fixture models a real key shape,and
decodePath's decode-then-re-escape-slashes is aimed squarely at it.The encoded-space fixture is not a valid course key; it models a mangled URL, which is what
PathFixesProvider(src/index.jsx:51,generic/path-fixes/PathFixesProvider.jsx:19) exists torepair.
Confirmed bug —
decodeUrlcorrupts deprecated keys containing a literal%. It recursesuntil the string stops changing, so it cannot distinguish "encoded twice" from "the key contains
a percent sign", and takes one pass too many:
Pure string logic in
index.jsx:14-20— reproducible standalone, with no router or test harnessinvolved. Pre-existing on 6.15.0 and unchanged by the bump, so it is pinned by the matrix rather
than fixed here. Deserves its own issue. Note
index.test.jsx's existingdescribe('decodeUrl')block only covers keys that decode cleanly.
Decision 3 —
renderPagemounts the component on a catch-all routesrc/decode-page-route/index.test.jsx, one line insiderenderPage:No test data, no assertion, and no fixture value changes. All four tests in the file pass.
Why it was failing
should only decode the url params and not the entire urlfails on 6.30.6, and no component changecan fix it.
renderPagemounted the component underprops.pattern.path, which for that test is/course/:courseId/some%2Bthing/:unitId. Under 6.20+<Routes>matches against the decodedpathname, so a pattern literal spelled with a percent-escape can never match, and
DecodePageRoutenever mounts — zero<Navigate>renders, zeromockNavigatecalls. The brokenlink is the harness's outer
<Route>, not the component and not the fixture.Why the outer route was there in the first place
It is v5→v6 migration leftover, not a deliberate assertion about routing. In the introducing commit
52235ebcthere was no outer<Route>at all — the component took its match as a prop, perreact-router v5's
computedMatchconvention:The v6 upgrade (
b788b969) rewrote the component to compute its own match withuseMatch, so theinjected prop had nowhere to go. To get the component rendered under a router at a chosen URL, the
upgrade wrapped it in
<Routes>and reused the survivingpropsfixture for both the URL and theroute path. The pattern was a convenient mounting point, nothing more.
path="*"therefore restores the original intent — put the component at this URL and let it do itsown matching — and drops an incidental coupling the v6 upgrade introduced.
Rejected alternative — respell the fixture's literal as
%2FdecodePathdecodes each segment then re-escapes slashes, sosome%2Fthinground-trips and is theone percent-escape a route can still declare. Changing
MOCK_ROUTE_2and the test's inline patternto use it would keep all three assertions passing.
Rejected because it changes what the test exercises. The point of the test is that
generatePathsubstitutes decoded params into pattern literals it copies verbatim; respelling the literal narrows
that to a demonstration of react-router's slash re-escaping, and amounts to adjusting test data
until it passes rather than fixing the thing that broke.
What this file no longer covers
With a catch-all mount, the outer
<Routes>no longer participates, so this file no longer showsthat a real route pattern can mount the component. That is what
decodeRoutes.test.tsxcovers,against the real
DECODE_ROUTES— and it is the right split, since this file mocksDECODE_ROUTESentirely and is a unit test of the component's own matching, not of app routing.
The property under test still holds after the bump
"Only decode the url params and not the entire url" remains true of the component:
generatePathcopies pattern literals through untouched and substitutes decoded params. What the bump removed is
the ability for a route whose literal is spelled with a percent-escape to match at all.
Route patterns are compiled to a regex from the literal string as written (regex specials escaped,
no URL decoding), so pattern literals must now be spelled decoded. Encoded URLs still route;
only the pattern's spelling moved.
%2Fis the sole exception that round-trips, becausedecodePathdecodes and then re-escapes slashes (router.js:858-865).What the test protects, per the original commit 52235eb (comments dropped by the RTL rewrite
in #1757), all three still asserted verbatim:
No route in
DECODE_ROUTESspells a literal with a percent-escape, so nothing in this appregresses — the mocked pattern at
index.test.jsx:24is the only one in the repo. Downstreamconsumers declaring such a route are affected; see the gaps below.
Gaps this work surfaced, not addressed here
useParamsdecoding is untested. For the redirect routes the bump-sensitive question is whatuseParams()handsRedirectPage, since that string is baked into aglobal.location.assign()(RedirectPage.tsx:35).RedirectPage.test.jsx:13mocksuseParamsto a hardcoded plain course id, so the decoding is stubbed out. 37 non-test modules call
useParams.index.jsx:41-142builds it inline inside theAPP_READYsubscription, wired to
createRoot, so tests reconstruct it —CoursewareContainer.test.jsx:108and
ProductTours.test.jsx:278do the same. The reconstruction is exact for the 13 absolutepatterns and got the 2 relative ones wrong.
REDIRECT_HOME/REDIRECT_SURVEYare uncovered.CoursewareRedirectLandingPagenests themunder
ROUTES.REDIRECTandRedirectPageleaves viaglobal.location.assign, so no landingpathname is observable in a
MemoryRouter.other than
%2Fbreaks. Belongs in the PR description.Verification
6.30.6 with Decision 2 applied.
src/decode-page-route/on 6.30.6 after Decision 3: 138/138 — the 134 matrix cases plus allfour original tests in
index.test.jsx.Course.test.jsx"displays learner tools when screen is wide enough (browser)", the knownpre-existing flake — it passes in isolation (119 ms here, 306 ms earlier) and is unrelated to
this work. No
decode-page-routefailures remain.index.test.jsxtest 4.npm run types✓ andnpm run lint✓ on 6.30.6 against the full working tree — the matrix, thecomponent change (Decision 2) and the harness change (Decision 3).
.nvmrcpins 24; all runs on v24.13.0.🤖 Generated with Claude Code