refactor: read courseware route ids from useParams, not the Redux slice - #2069
Merged
Merged
Conversation
brian-smith-tcril
added this pull request to stack #2062
September 16, 2026 03:24
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2069 +/- ##
==========================================
- Coverage 93.74% 93.74% -0.01%
==========================================
Files 368 368
Lines 6029 6023 -6
Branches 1428 1391 -37
==========================================
- Hits 5652 5646 -6
- Misses 360 361 +1
+ Partials 17 16 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This was referenced Sep 16, 2026
brian-smith-tcril
marked this pull request as ready for review
September 17, 2026 01:23
arbrandes
force-pushed
the
bsmith/courseware-route-id-reads
branch
from
September 18, 2026 14:58
bc82c73 to
8f25f81
Compare
arbrandes
force-pushed
the
bsmith/courseware-route-id-reads
branch
from
September 18, 2026 16:13
8f25f81 to
d3e7e17
Compare
brian-smith-tcril
force-pushed
the
bsmith/courseware-route-id-reads
branch
from
September 18, 2026 18:05
d3e7e17 to
e91ac8e
Compare
brian-smith-tcril
force-pushed
the
bsmith/courseware-route-id-reads
branch
from
September 18, 2026 18:18
e91ac8e to
00acfb4
Compare
brian-smith-tcril
force-pushed
the
bsmith/courseware-route-id-reads
branch
from
September 18, 2026 18:35
00acfb4 to
87c5b4c
Compare
brian-smith-tcril
force-pushed
the
bsmith/courseware-route-id-reads
branch
from
September 18, 2026 18:40
87c5b4c to
41f402b
Compare
brian-smith-tcril
force-pushed
the
bsmith/courseware-route-id-reads
branch
from
September 18, 2026 18:48
41f402b to
e003644
Compare
brian-smith-tcril
force-pushed
the
bsmith/courseware-route-id-reads
branch
from
September 18, 2026 19:00
e003644 to
11576f5
Compare
brian-smith-tcril
force-pushed
the
bsmith/courseware-route-id-reads
branch
2 times, most recently
from
September 18, 2026 19:45
1d78d90 to
c31b21b
Compare
Base automatically changed from
bsmith/react-query-discussion-topics
to
master
September 18, 2026 19:57
The courseware slice's courseId/sequenceId are written only by the statusBridge hooks, which dispatch the route params verbatim — so the readers that consume just those id mirrors can read the route directly. Converts useIFrameBehavior, the outline-sidebar hook (also dropping its unread sequenceStatus return), UnitButton, useContextId (whose dead courseHome fallback goes), and the course-exit readers (CourseCelebration, CourseInProgress, CourseNonPassing, CatalogSuggestion, UpgradeFootnote, CourseRecommendations). Status reads are untouched; the bridge keeps writing the slice for them until the teardown layer. Part of #1976. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
bsmith/courseware-route-id-reads
branch
from
September 18, 2026 19:57
c31b21b to
a441958
Compare
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
Read the courseware route ids from
useParamsinstead of the Redux slice's mirrors. Thecoursewareslice'scourseId/sequenceIdare written only by the transitionalstatusBridge.tshooks, which dispatch the route params verbatim — so every reader that consumes just those id fields can read the route directly. This is layer A1 (of six) of the courseware slice teardown #1976 (plan) in the Redux → React Query migration (#1946, Stage 1), stacked on the discussion-topics conversion #2068. Part of #1976 — the teardown's final layer closes it.Status reads are untouched: the bridge keeps writing the slice for them until the later layers.
What changed
useParams:useIFrameBehavior, the outline-sidebar hook (useCourseOutlineSidebar),UnitButton(which builds unit hrefs from them; itsRootStateimport goes), and the course-exit readers —CourseRecommendations,CourseCelebration,CourseInProgress,CourseNonPassing,CatalogSuggestion,UpgradeFootnote(allcourseIdunder/course/:courseId/course-end).useContextId(src/data/hooks.ts) becomesuseParams().courseId. Itsstate.courseHome.courseIdfallback was dead —fetchTabFailure, the only writer, has had no dispatcher since the tab conversions. Every course-home and courseware route carries:courseId.sequenceStatus— a dead field: no component consumed it.CourseOutline.tsxresolves the active section viaactiveSequenceId && Object.keys(sections).find(...)— the same guarded-scan idiomcourseOutline.tsuses for the identical parent-section lookup — because the id is now honestly typedstring | undefined(route param) instead of the untyped selector'sany.CourseOutlineTray.testrenders under/course/:courseId/:sequenceId/:unitId(the sidebar lives on unit pages),UnitButton.test's preview case gains a realRoute(its bareMemoryRoutergaveuseParamsnothing),CourseExit.testdrops itsfetchCourseSuccessslice seeding, anduseIFrameBehavior.testloses a deadreact-reduxmock.Testing
npm run types(0 errors),npm run lint(clean), full jest suite green at head (111 suites, 1120 passed / 3 pre-existing skips). Manual pass on tutor local in the details block below.Decisions
Full decision log
Decisions — courseware route ids from useParams (#1976, layer A1)
Route params replace the slice's id mirrors. The
coursewareslice'scourseId/sequenceIdare written only by thestatusBridge.tshooks,which dispatch the route params verbatim — so
useParams()is the faithfulreplacement, minus a one-effect-tick lag (the bridge sets the slice fields
in a post-render effect; the route has them immediately). No status fields
convert in this layer; the bridge keeps writing the slice for the remaining
readers until layer B.
The plan's reader inventory had a grep gap, found while running this
layer's tests. The original sweep matched
state.courseware.(with atrailing dot) and missed eight destructuring readers of the form
const { courseId } = useSelector(state => state.courseware). Six areid-only and joined this layer:
CourseCelebration,CourseInProgress,CourseNonPassing,CatalogSuggestion,UpgradeFootnote(all course-exit,all
courseIdunder/course/:courseId), andUnitButton(
courseId+sequenceId, rendered on unit routes).SequenceNavigationreads
courseIdandsequenceStatus, so it moves whole to layer A2(one touch per file);
TabPage's destructure (errorMessage) was alreadyassigned to B-prep/B. A follow-up sweep with broader patterns
(
getState().courseware, bracket access, bare.courseware) found nothingelse.
The outline-sidebar hook stops returning
sequenceStatus— a deadfield. No component consumes it (
CourseOutline, the tray, the trigger,and the
Sidebar*components read onlyactiveSequenceIdand friends);it was plumbed through
useCourseOutlineSidebar's return object and readby nothing. Dropped rather than converted.
CourseOutline.tsxgains an explicitundefinedguard instead of acast.
activeSequenceIdis now typedstring | undefined(route param)where the untyped selector was
any. The old runtime behavior on thecourse root was
sequenceIds.includes(undefined)→false; the guard(
activeSequenceId !== undefined && …includes(activeSequenceId))preserves exactly that without a non-null assertion that would state a lie.
useContextIdbecomesuseParams().courseId. Itsstate.courseHome.courseIdfallback was dead —fetchTabFailure, the onlywriter of
courseHome.courseId, has had no dispatcher since the tabconversions — and the slice half mirrored the route. Every course-home and
courseware route carries
:courseId. Return type narrows from the untypedselector result to
string | undefined; the sole caller(
DashboardFootnoteLinkPluginSlot) feedsuseModel/logClick, both finewith it. The
RootStateimport leavessrc/data/hooks.ts.Tests move to runtime-faithful routes instead of slice seeding.
CourseOutlineTray.test.jsxrendered under/course/:courseId, relyingon the slice for the active sequence; at runtime the sidebar renders on
unit pages, so the test now renders under
/course/:courseId/:sequenceId/:unitIdwith the seeded sequence/unit idsin the entry URL. (Without this,
CourseOutlineresolves no section andrenders no sequence rows.)
UnitButton.test.jsx's preview case rendered inside aMemoryRouterwith no
Route, souseParams()was empty; it now mounts under/preview/course/:courseId/:sequenceId/:unitId. The other UnitButtoncases don't assert hrefs and stay as-is.
CourseExit.test.jsxdrops itsfetchCourseSuccess({ courseId })dispatch + slice import — it existed to seed slice
courseIdfor thecourse-exit readers, all converted here; the tests already render under
/course/:courseId.useIFrameBehavior.test.jsloses itsreact-reduxmock (the hook nolonger touches react-redux; nothing else in the mocked tree does) and
gains an explicit
useParamsentry in itsreact-router-dommock.state.courseware.courseId(fivesuites use it just to learn the id) still work — the slice lives until
layer B, which re-sources them.
Behavior deltas: one accepted corner case, confined to the outline
sidebar. In healthy flows the values are identical: ids arrive one
effect-tick earlier and are
undefined(notnull) when the route lacksthem, and every route that renders the touched components carries the
params they read (section-id and unit-as-sequence URLs populate the
:sequenceIdposition with the same value the slice held).The corner (raised in review): slice
sequenceIdis "the last:sequenceIdthat was ever in the URL" — the bridge dispatches routeparams verbatim but bails when the param is absent, and nothing resets the
field. So after an in-app
navigate('/course/:courseId')— reachable onlythrough the redirect fallback paths (invalid sequence with no parent,
empty section) — the slice keeps the old id while the route has none.
That is the same window where the container's ids-match guard bails
forever (the latent stale-bail bug layer B removes), leaving the old
sequence rendered under the bare course URL. There, the sidebar previously
highlighted the stale sequence (consistent with the stale content,
inconsistent with the URL); post-A1 it resolves no active section and
renders an empty sequence list. Blast radius is the sidebar only, and the
state is hard to reach with the tray involved (the fallbacks fire on
sequence load failures, where
sequenceStatusisfailedand mostloaded-sequence UI doesn't render). Accepted rather than keeping the
sidebar on the slice until B: it preserves fidelity only to a state that
is itself the bug being removed, and B's guard deletion makes the state
self-heal (the resume redirect fires and navigates to a real unit URL).
The benign inverse also changes: during sequence→sequence navigation the
slice lagged the route by one effect tick, so the sidebar briefly
highlighted the previous sequence; the params version highlights the
current one immediately.
Manual testing
Manual testing — courseware route ids from useParams (#1976, layer A1)
In-browser verification for layer A1, against tutor local
(
http://apps.local.openedx.io:2000/learning, DemoXcourse-v1:OpenedX+DemoX+DemoCourse). This layer claims zero user-facingchange: nine readers swap the slice's
courseId/sequenceIdmirrors foruseParams(), and the values are identical on every route that renders them.The things to watch are the places those ids feed URLs or lookups.
Verify by hand
UnitButtonbuilds/course/{courseId}/{sequenceId}/{unitId}from route params now) —UnitButtononly renders insideSequenceNavigation, whose slot(
org.openedx.frontend.learning.sequence_navigation.v1) renders nothing bydefault: re-inject the default nav via
env.config.jsxper the slot'sREADME first. Then open a unit with several units in the sequence; the
per-unit icon tabs link to sibling units correctly (hover/copy a link — no
undefinedsegments), and clicking navigates.UnitButton's/previewprefix) — sameenv.config.jsxsetup; open the same unit under/preview/course/...(staff): unit buttons keep the
/previewprefix and work.useCourseOutlineSidebaractive ids from params)— open the outline tray on a unit page: the active section's sequences
show, the active sequence is expanded/highlighted; clicking a unit in the
tray navigates and (mobile width) closes the tray.
useIFrameBehavioractive sequence from params) —unit content loads and resizes normally; navigating between units keeps
working.
CourseCelebration/CourseInProgress/CourseNonPassing/CatalogSuggestion/UpgradeFootnote/CourseRecommendationscourseId from params) — open/course/{courseId}/course-end: the celebration (orin-progress/non-passing) content renders with its links (dashboard
footnote, progress/dates links, recommendations or catalog suggestion);
no blank sections.
Results
All five checks passed (2026-09-15, tutor local, DemoX; sequence-navigation
checks with the default nav re-injected via
env.config.jsx). NoundefinedURL segments, active section/sequence resolved in the tray, course-end
content and links intact.
🤖 Generated with Claude Code