refactor: derive sequence status from the sequence query - #2070
Draft
brian-smith-tcril wants to merge 1 commit into
Draft
brian-smith-tcril wants to merge 1 commit into
brian-smith-tcril wants to merge 1 commit into
Conversation
useSequenceMetadata absorbs the route-derived preview flag (every caller
passed pathname.startsWith('/preview'), threaded down from the container),
and the sequence-status readers — Sequence, both sequence-alerts hooks, and
SequenceNavigation (whose courseId also moves to useParams) — consume the
query directly instead of the Redux sequenceStatus/sequenceMightBeUnit
mirrors, gating on isPending/isSuccess/isError. The Redux status-string
vocabulary is dropped rather than ported, matching the converted
course-home readers; the 422-means-a-unit translation becomes the exported
sequenceMightBeUnit predicate. The bridge keeps writing the slice for the
remaining readers until the teardown layer.
Also fixes a latent Rules-of-Hooks violation this surfaced:
SequenceNavigation called GetCourseExitNavigation (which reads models via
hooks) inside renderNextButton, which only runs once the sequence has
loaded — harmless while the status never flipped within a mounted life,
but a hook-order error once it derives from the query. The call is hoisted
to the component top level.
Part of #1976.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
brian-smith-tcril
added this pull request to stack #2062
September 16, 2026 06:23
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## bsmith/courseware-route-id-reads #2070 +/- ##
====================================================================
+ Coverage 93.74% 93.75% +0.01%
====================================================================
Files 368 368
Lines 6023 6033 +10
Branches 1427 1392 -35
====================================================================
+ Hits 5646 5656 +10
- 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
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
Move the sequence-status readers off the Redux
sequenceStatus/sequenceMightBeUnitmirrors and onto the sequence metadata query itself.useSequenceMetadataabsorbs the route-derived preview flag (every caller passedpathname.startsWith('/preview'), threaded down from the container), readers gate on the query's ownisPending/isSuccess/isError, and the 422-means-not-a-sequence translation becomes the exportedsequenceMightBeUnit(sequenceQuery)predicate. The Redux status-string vocabulary is dropped, not ported — matching how every converted course-home reader already works. This is layer A2 (of six) of the courseware slice teardown #1976 (plan), stacked on the route-id layer #2069. Part of #1976 — the teardown's final layer closes it.The transitional status bridge keeps writing the slice for the remaining readers (container, breadcrumbs, sequence-navigation hooks) until the later layers.
What changed
courseware/data/apiHooks.ts:useSequenceMetadata(sequenceId)derivesisPreviewfrom the pathname itself and loses the param — one derivation, one query-key shape, every subscriber shares the container's cache entry. NewsequenceMightBeUnit(sequenceQuery)predicate (a 422 means the requested id is not a sequence — it may be a unit id). No wrapper hook, no status strings.Sequence.jsx(loading = sequenceQuery.isPending || sequenceMightBeUnit(sequenceQuery)— the old failed-and-might-be-unit arm folds in since the predicate is only true on a 422 error; loaded gates on.isSuccess), bothalerts/sequence-alertshooks (gate on.isSuccess), andSequenceNavigation.jsx(render/lock gates on.isSuccess; itscourseIdmoves touseParams; itsLOADEDconstant import dies).react-reduxleaves all three files.SequenceNavigationcalledGetCourseExitNavigation(a hook — it reads two models) insiderenderNextButton(), which only runs once the sequence is loaded. Harmless while the status never flipped within a mounted life (the slice was pre-seeded andSequenceunmounts the nav during loads); a hook-order error once the status derives from the query in place. The call is hoisted to the component top level.UnitNavigationinvokes the same function unconditionally and is untouched.useSequenceStatusBridgeloses itsisPreviewpass-through (the container call updates) since the query hook now derives it.SequenceNavigation.testrenders under a real route (/course/:courseId/:sequenceId/*— the splat keeps the route matched when unit-buttonLinks navigate mid-test) with a fresh store per test;test-utils.jsxextractsseedDiscussionTopics, which scopes its own axios adapter (the old inline adapter starved the sequence query after it);Course.testawaits the unit iframe (testIDs.contentIFrame) before postingloadUnit()'s window message (the listener mounts with the unit); theuseSequenceMetadatadescribe gains the missing-id, preview-param, and predicate cases.statusBridge.teststays green alongside until the bridge dies in the teardown layer.Testing
npm run types(0 errors),npm run lint(clean), full jest suite green at head (111 suites, 1125 passed / 3 pre-existing skips). Manual pass on tutor local in the details block below.Decisions
Full decision log
Decisions — sequence readers onto the sequence query (#1976, layer A2)
Readers consume
useSequenceMetadatadirectly — no wrapper hook, nostatus strings. Two review rounds shaped this. A first draft added a
useSequenceStatushook returning the Redux'loading'/'loaded'/'failed'strings for drop-in minimalism; rejected — every convertedcourse-home reader already speaks query flags, nothing outside the dying
slices uses those constants, and porting the vocabulary would just
schedule a second sweep to remove it. A second draft had the wrapper
return
{ sequenceQuery, sequenceMightBeUnit }; also rejected — gettingthe query out of a hook named "status" was a smell, and the wrapper's
only other job (deriving
isPreview) belongs lower (see 2). End state:readers call the query hook and gate on
isPending/isSuccess/isError(1:1 with the old string comparisons), and the 422→might-be-a-unit
translation (error-as-data) is a plain exported predicate,
sequenceMightBeUnit(sequenceQuery). A missingsequenceIdreproducesthe slice's initial loading state (
enabled: !!sequenceIdkeeps the querypending, no fetch), matching the bridge's bail-without-dispatch. No
dispatch, no effect — the one-effect-tick lag is gone.
useSequenceMetadataabsorbs the route-derived preview flag. Everycaller passed
pathname.startsWith('/preview'), threaded from thecontainer — preview-ness is a property of the route, so the hook derives
it itself and the
isPreviewparam drops. One derivation, one query keyshape, every subscriber shares the container's cache entry. The
transitional
useSequenceStatusBridgeloses its pass-through param alongthe way (container call updated).
Readers converted:
Sequence.jsx(itsloading = 'loading' || ('failed' && mightBeUnit)becomessequenceQuery.isPending || sequenceMightBeUnit(sequenceQuery)— thepredicate is only true on a 422 error, so the failed arm folds in), both
alerts/sequence-alertshooks (gate on.isSuccess), andSequenceNavigation.jsx— the destructurer deferred from A1 — whosecourseIdmoves touseParamsin the same touch and whoseLOADEDconstant import dies with the string comparison.
react-reduxleaves allthree files.
A latent Rules-of-Hooks bug surfaced and is fixed here.
SequenceNavigationcalledGetCourseExitNavigation(a hook — it readstwo models via
useModel) insiderenderNextButton(), which only runswhen
sequenceStatus === LOADED. That never manifested because the slicestatus was already
loadedbefore the nav ever mounted, andSequenceunmounts the nav during loads — the status never flipped within a mounted
life. With query-derived status the flip happens in place (loading →
loaded), the hook count changed between renders, and React threw straight
into the AppProvider error boundary. Fix: the call is hoisted to the
component top level (unconditional — the only legal shape; gating it any
later still varies the hook order). Running it pre-load is safe: its
useModelreads hit models that are populated before any sequence renders(course metadata gates the page), and its result is only consumed in the
loaded branch.
UnitNavigationhas the same call but invokes itunconditionally on every render, so it's left alone.
Test-infrastructure findings (these shaped most of the diff):
initializeTestStorecall replaces the shared axios adapter —new MockAdapter(client)clobbers the previous adapter's handlers. Thatwas invisible while components read seeded Redux state; now that they
fetch, any test rendering against an earlier store's mocks starves.
SequenceNavigation.testmoves its shared store frombeforeAlltobeforeEach;test-utils.jsxextractsseedDiscussionTopics, whichscopes its own adapter (create → mock the two discussion endpoints →
prefetch →
restore()), so the render afterwards runs againstinitializeTestStore's fully-mocked adapter instead of a lingeringdiscussion-only one.
SequenceNavigation.testrenders under a real route(
/course/:courseId/:sequenceId/*via arenderNavhelper) — thecomponent needs
useParams, and the splat keeps the route matched whenunit-button
Links actually navigate mid-test (under the oldroute-less
BrowserRouterclicks changed nothing).Course.test'sloadUnit()calls fire before the iframe exists nowthat
Sequenceloads asynchronously — the loaded-postMessage went tonobody and the unit spinner never cleared. Those tests await the iframe
before posting. Gotcha for future debugging: the unit iframe loader
shares the exact "Loading learning sequence..." message with
Sequence'sown gate, so "spinner still present" can implicate the wrong component.
statusBridge.test.ts(which stays green alongside until the bridge dies in B) plus a
preview-route case asserting the
preview=1request param. JSdefault-param gotcha: an explicit
undefinedargument triggers thedefault, so the missing-id case passes ids explicitly.
Behavior deltas: the standard conversion posture — later-mounting
subscribers (Sequence, alerts) can trigger a background refetch of
sequence metadata where the bridge was the sole subscriber (
staleTime0); results land in the model store through the same bridge. The
Rules-of-Hooks fix means
GetCourseExitNavigation's model reads now runduring the loading render too (result unobserved until loaded). Otherwise
faithful: same decision points, same 422 semantics — expressed as query
flags instead of the retired status strings.
Manual testing
Manual testing — sequence readers onto the sequence query (#1976, layer A2)
In-browser verification for layer A2, against tutor local
(
http://apps.local.openedx.io:2000/learning, DemoXcourse-v1:OpenedX+DemoX+DemoCourse). This layer claims zero user-facingchange:
Sequence, the sequence-alerts hooks, andSequenceNavigationderive the sequence status from the sequence metadata query instead of the
Redux mirror. The things to watch are the loading/failed gates and the
sequence-navigation next-button states (its course-exit lookup was hoisted
for the Rules-of-Hooks fix).
Verify by hand
Sequence's status gate) — open a unit: briefsequence spinner, then content; navigate between sequences via the outline
tray (each shows spinner → content, no error page, no console errors).
sequenceMightBeUnitkeeps the spinnerwhile the container redirects) — paste a unit id into the sequence slot of
the URL (
/course/{courseId}/{unitId}): spinner (no error flash), then theredirect lands on the proper
/course/{courseId}/{sequenceId}/{unitId}.course-exit lookup) — with the default nav re-injected via
env.config.jsx(slot
org.openedx.frontend.learning.sequence_navigation.v1, per itsREADME): tabs render after load, prev/next work, and on the last unit of
the course the Next button shows its end-of-course state (disabled, or
"Next (end of course)" / "Complete the course" per cert state).
bannerText(staff-authored notice); the info alert renders above theunit. If no local sequence has one, note it and lean on the alerts suite.
isPreviewfrom the pathname keys the same query) —open a unit under
/preview/course/...as staff: loads normally, Networktab shows the sequence metadata GET with
preview=1.Results
Env: tutor local (DemoX), 2026-09-16, run against the local branch @
f8db3d73(before any push), sequence-navigation check with the default nav re-injected
via
env.config.jsx.Four of five passed as described: unit pages load through the query gate
(spinner → content, no console errors), the unit-id-as-sequence URL shows the
spinner and redirects to the proper unit path (the
sequenceMightBeUnitpath), the sequence navigation bar renders with working prev/next and the
correct last-unit Next state, and the preview route loads with
preview=1onthe sequence metadata GET.
The banner text alert was not run by hand — no local sequence carries a
bannerText— and rests on the alerts suite, which pins the converted gate(
sequenceQuery.isSuccess && sequence.bannerText) at the hook level.🤖 Generated with Claude Code