| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
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. @@ 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.
|
Sorry, something went wrong.
There was a problem hiding this comment.
👍🏼
Sorry, something went wrong.
| Back | FazBrowse Home | New Git URL |
Summary
Take react-router and react-router-dom to ^6.30.6 together, plus the two changes that bump requires. Supersedes #935 (renovate), which bumps only react-router-dom and is red on CI. Separately, #1952 (dependabot) proposes react-router → 8.3.0; that's a v7/v8 migration and a different conversation.
What 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 ~ . : punctuation opaque-keys' ALLOWED_ID_CHARS permits, 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 DecodePageRoute change, six of those URL shapes stop normalizing — e.g. /course/course-v1%3AedX%2BDemoX%2BDemo_Course/home would 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 %2F can 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: decodeUrl corrupts deprecated course keys containing a literal % (reproducible standalone, now pinned on both versions), and useParams decoding is untested because RedirectPage.test.jsx:13 mocks it out.
Testing
npm run lint ✓, npm run types ✓. Full suite on 6.30.6: 1092 passed — the single failure is the known Course.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 logDecisions — react-router 6.15.0 → 6.30.6
Working log for taking PR #935 (renovate, react-router-dom 6.15.0 → 6.30.6) to green.
Context
Two bots are fighting over these two packages:
package.json pins both packages separately and exactly, so either PR on its own splits the
versions. #935's CI is red: 9 failures across 3 suites.
Decision 1 — bump both pins together, as caret ranges
react-router: ^6.30.6 and react-router-dom: ^6.30.6. 6.30.6 is the newest 6.x for both.
npm install then produces a flat lock — one react-router, one react-router-dom, one
@remix-run/router@1.23.4, no nesting.
Why
#935 bumps react-router-dom while react-router stays at 6.15.0, so npm nests a second copy at
node_modules/react-router-dom/node_modules/react-router@6.30.6. Two copies means two distinct
React 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 using react-router-dom.
That accounted for 7 of the 9 failures in #935:
resolve 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 6
assertions miss their testids.
reads useParams from react-router → courserun_key: undefined in the tracking event.
Bumping both pins in lockstep removes the nesting and all 7 failures.
Decision 2 — DecodePageRoute matches on the raw pathname
src/decode-page-route/index.jsx, three lines: useMatch(route) → matchPath(route, location.pathname), with location from useLocation(). Everything from if (computedMatch)
down is untouched.
Why
@remix-run/router 1.13 (react-router 6.20) changed matching to decode the pathname first:
useMatch is exactly useLocation + matchPath, with a decodePath wedged between them since
6.20. matchPath itself never decoded the pathname, and still doesn't.
The component's guard, newUrl !== pathname (index.jsx:42), asks "is the URL in the address bar
different from the fully-decoded URL?" On 6.15 computedMatch.pathname was the raw pathname —
useMatch passes end: true, so the match spans the whole path and the field is just the pathname
it was given. On 6.30 that same field is the decoded pathname, so the comparison collapses to
newUrl === computedMatch.pathname and the redirect silently stops firing.
Feeding matchPath the raw pathname restores that field, so the guard keeps reading
computedMatch.pathname exactly as it did on 6.15 — matchPath compiles string patterns with
end: true, so the match spans the whole path and computedMatch.pathname is the pathname it was
given. The fix sits entirely upstream of the comparison; the comparison itself is not edited.
params do differ between versions (6.15 matchPath decoded them once; 6.30 returns them raw bar
%2F), but decodeUrl's recurse-to-fixed-point washes that out — generatePath's output is
byte-identical across both versions.
Side effect, accepted
useMatch memoised each match; a plain matchPath recompiles the pattern regex every render,
×13 root patterns. Cheap, and it removes a react-hooks/rules-of-hooks irregularity — the old code
called a hook inside ROUTES.forEach, safe only because ROUTES is module-level.
Rejected alternative — re-baseline the tests instead
Leave the component alone and accept the new behavior: matchRoutes decodes too
(router.js:527), so <Routes> matches encoded URLs and useParams() yields decoded values
without 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, records
where 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.
~ . : punctuation ALLOWED_ID_CHARS permits, deprecated, and deprecated carrying a literal
%; each unencoded / encoded once / encoded six times, except the deprecated pair which has no
unencoded 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:
expectation by a rule. A rule would either be circular (mirroring the implementation) or would
paper over the deprecated-% corruption below.
grammar, 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.
templates) 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 plausible
reason to differ, since decodePath decodes each segment and then re-escapes slashes, which is
aimed squarely at %2F-bearing keys.
Without Decision 2, six of the URL shapes stop normalizing, e.g.
/course/course-v1%3AedX%2BDemoX%2BDemo_Course/home stays encoded instead of redirecting to
/course/course-v1:edX+DemoX+Demo_Course/home.
Harness caveat
MemoryRouter stores 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 one
exception: 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 against ALLOWED_ID_RE, serialized as
"+".join([org, course, run]) behind course-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 extra
colons, tildes and periods — not currently exercised.
Deprecated keys (org/course/run) may contain a literal %, and their / separators must
travel as %2F within 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 to
repair.
Confirmed bug — decodeUrl corrupts deprecated keys containing a literal %. It recurses
until 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 harness
involved. 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 existing describe('decodeUrl')
block only covers keys that decode cleanly.
Decision 3 — renderPage mounts the component on a catch-all route
src/decode-page-route/index.test.jsx, one line inside renderPage:
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 url fails on 6.30.6, and no component change
can fix it. renderPage mounted the component under props.pattern.path, which for that test is
/course/:courseId/some%2Bthing/:unitId. Under 6.20+ <Routes> matches against the decoded
pathname, so a pattern literal spelled with a percent-escape can never match, and
DecodePageRoute never mounts — zero <Navigate> renders, zero mockNavigate calls. The broken
link 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
52235ebc there was no outer <Route> at all — the component took its match as a prop, per
react-router v5's computedMatch convention:
The v6 upgrade (b788b969) rewrote the component to compute its own match with useMatch, so the
injected 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 surviving props fixture for both the URL and the
route 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 its
own matching — and drops an incidental coupling the v6 upgrade introduced.
Rejected alternative — respell the fixture's literal as %2F
decodePath decodes each segment then re-escapes slashes, so some%2Fthing round-trips and is the
one percent-escape a route can still declare. Changing MOCK_ROUTE_2 and the test's inline pattern
to use it would keep all three assertions passing.
Rejected because it changes what the test exercises. The point of the test is that generatePath
substitutes 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 shows
that a real route pattern can mount the component. That is what decodeRoutes.test.tsx covers,
against the real DECODE_ROUTES — and it is the right split, since this file mocks DECODE_ROUTES
entirely 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: generatePath
copies 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. %2F is the sole exception that round-trips, because
decodePath decodes 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_ROUTES spells a literal with a percent-escape, so nothing in this app
regresses — the mocked pattern at index.test.jsx:24 is the only one in the repo. Downstream
consumers declaring such a route are affected; see the gaps below.
Gaps this work surfaced, not addressed here
useParams() hands RedirectPage, since that string is baked into a
global.location.assign() (RedirectPage.tsx:35). RedirectPage.test.jsx:13 mocks useParams
to a hardcoded plain course id, so the decoding is stubbed out. 37 non-test modules call
useParams.
subscription, wired to createRoot, so tests reconstruct it — CoursewareContainer.test.jsx:108
and ProductTours.test.jsx:278 do the same. The reconstruction is exact for the 13 absolute
patterns and got the 2 relative ones wrong.
under ROUTES.REDIRECT and RedirectPage leaves via global.location.assign, so no landing
pathname is observable in a MemoryRouter.
other than %2F breaks. Belongs in the PR description.
Verification
6.30.6 with Decision 2 applied.
four original tests in index.test.jsx.
Course.test.jsx "displays learner tools when screen is wide enough (browser)", the known
pre-existing flake — it passes in isolation (119 ms here, 306 ms earlier) and is unrelated to
this work. No decode-page-route failures remain.
index.test.jsx test 4.
component change (Decision 2) and the harness change (Decision 3).
🤖 Generated with Claude Code