refactor: convert sequence data (fetchSequence) to React Query - #2061
Merged
Merged
Conversation
brian-smith-tcril
added this pull request to stack #2062
September 15, 2026 03:34
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2061 +/- ##
=======================================
Coverage 93.66% 93.67%
=======================================
Files 368 368
Lines 6015 6019 +4
Branches 1417 1420 +3
=======================================
+ Hits 5634 5638 +4
Misses 364 364
Partials 17 17 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
brian-smith-tcril
marked this pull request as ready for review
September 15, 2026 05:01
This was referenced Sep 15, 2026
Base automatically changed from
bsmith/course-test-waitfor-hygiene
to
master
September 18, 2026 14:58
Convert the fetchSequence thunk to a useSequenceMetadata query plus a
transitional useSequenceStatusBridge, mirroring the pattern established for the
course metadata/outline fetches.
- useSequenceMetadata (apiHooks.ts): retry:false so the expected 422
unit-detection fails fast; meta.models mirrors the sequence and its units into
the model store; the expected 422 is tagged logStatusAs: { 422: 'silent' }
(a new no-op logger tier in queryClient.ts) so it stays unlogged, matching the
old thunk.
- useSequenceStatusBridge (statusBridge.ts): writes sequenceId / sequenceStatus /
sequenceMightBeUnit from the query state so the still-Redux readers keep working.
- CoursewareContainer: drop the checkFetchSequence guard and call the bridge.
- Delete the fetchSequence thunk and its re-export; migrate the test seed to
seedSequenceModels in setupTest.js.
Part of #1946 (Convert Learning from Redux to Context + React Query). Resolves #2011.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
arbrandes
force-pushed
the
bsmith/react-query-sequence-data
branch
from
September 18, 2026 14:58
42377a3 to
4e6f76b
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
Convert the
fetchSequencethunk to auseSequenceMetadataReact Query hook plus a transitionaluseSequenceStatusBridge, mirroring the pattern #2023 established for the courseware metadata/outline fetches. No user-facing change — the bridge keeps the Reduxsequences/unitsmodel store and thesequenceStatus/sequenceId/sequenceMightBeUnitslice fields written, so the not-yet-converted readers (the container's redirect helpers,Sequence, breadcrumbs, sequence-navigation, sequence-alerts, the outline sidebar) behave exactly as before.Part of the Redux → React Query migration (#1946, Stage 1), stacked above #2060 (the
Course.test.jsxwaitForfix this branch depends on). Resolves #2011.What changed
useSequenceMetadata(apiHooks.ts) — the RQ query;meta.modelsmirrors the sequence + its units into the model store via the QueryCacheonSuccessbridge.retry: falseso the expected 422 unit-detection fails fast (the parent-sequence redirect only fires oncesequenceStatus === 'failed'; the default ~7s backoff would stall it).silentlogger tier (queryClient.ts) — a new no-op log level, so the expected 422 is taggedlogStatusAs: { 422: 'silent' }and stays unlogged, matching the old thunk (which logged nothing on 422). Reusable for any future "surface as error to callers, but don't log" status.useSequenceStatusBridge(statusBridge.ts) — a transitional effect hook that writes the still-Redux slice status fields from the query state.CoursewareContainer— drops thecheckFetchSequenceguard and calls the bridge; the redirect helpers/selectors are untouched.isPreviewin the query key — the preview view can return different data for the same sequence, so keeping it in the key faithfully reproduces the old refetch-on-mode-change (omitting it would serve stale other-mode data).fetchSequencethunk and its re-export are deleted; the test seed moves toseedSequenceModelsinsetupTest.js.The decision log covers the full rationale — the
silenttier,retry: false, the non-sequential throw, theisPreviewkey, why there's nodeniedstate, the seed split, and the test-altitude choices.Testing
npm run types(0 errors),npm run lint(clean), and the full suite (112 suites, 1103 passed, 3 pre-existing skips, 0 failures) all green. Manual smoke on tutor local (DemoX) confirmed the two #2011-unique behaviors — the expected 422 fails fast (no stall) and logs nothing, and the sequence fetch goes out withpreview=1in preview mode. Two unrelated pre-existing issues surfaced and were ruled out (filed #2059; an LMS problem-render error reproduces on the pre-RQ baseline).Decisions
Full decision log
Decisions — #2011 (convert sequence data to React Query)
Working log; folded into the PR's Full decision log at push time.
Two-part bridge pattern (reused from #2023)
The sequence fetch converts the same way metadata/outline did:
useSequenceMetadata(apiHooks.ts) — the RQ query. Itsmeta.modelsmirrors theresult into the model store via the
bridgeToModelStoreQueryCacheonSuccess:sequence→sequences(updateModel, merge) andunits→units(updateModels,merge). These are exactly the two dispatches the old
fetchSequencethunk made.useSequenceStatusBridge(statusBridge.ts) — a transitional effect hook thatwrites the still-Redux slice fields (
sequenceId/sequenceStatus/sequenceMightBeUnit) from the query state, so the many not-yet-converted readers (thecontainer redirect helpers,
Sequence.jsx, breadcrumbs, sequence-navigation,sequence-alerts, the outline sidebar) keep working unchanged.
CoursewareContainerdrops itscheckFetchSequencememoize-guard +dispatch(fetchSequence)and instead calls
useSequenceStatusBridge(routeSequenceId, isPreview)next touseCourseStatusBridge. The redirect helpers/selectors are untouched.Why
checkFetchSequenceis fully removed butcheckFetchCoursesurvives. Samestatus-bridge mechanism, different conversion completeness.
fetchSequencehad noresponsibility beyond the fetch + model mirroring + status fields, all of which the bridge +
meta.modelsnow cover — so the thunk and its container guard are deleted outright.fetchCourse,by contrast, was only thinned by #2023:
useCourseStatusBridgetook over its status derivation,but the thunk still owns one un-converted responsibility — the sidebar-toggles fetch
(
getCoursewareOutlineSidebarToggles→setCoursewareOutlineSidebarToggles) — socheckFetchCoursestill dispatches the thinned thunk alongside the bridge. That residue is #2013'sjob; when it lands,
checkFetchCoursedisappears and the two cases look identical. So the "samepattern" claim is about the bridge, not about the thunk being gone —
fetchSequenceis simply acomplete one-step conversion, whereas
fetchCourseis mid-conversion.The sequence bridge has no
deniedstate (faithful, not an omission)useSequenceStatusBridgedispatches onlyfetchSequenceRequest/Success/Failure— nodenied, unlikeuseCourseStatusBridge(which hasfetchCourseDenied→DENIED). This is faithful:the slice has no
fetchSequenceDeniedaction, the oldfetchSequencethunk never dispatched a denied,and no reader checks for a denied
sequenceStatus. Adding one would invent a status nothing producesor consumes.
The asymmetry is intentional and comes from where each kind of access-gating lives:
courseHomeMeta.courseAccess.hasAccess(
fetchCourseDenied→DENIED); whether you may enter the course is known at metadata time.sequentialblock → success) or fails (422 / error → failure); gated/locked content and prerequisites ride
inside the payload (
gatedContentand friends) and are rendered by theSequencecomponent, sothere's no denied fetch state for the bridge to map.
Sequence query key includes
isPreviewcoursewareQueryKeys.sequence(sequenceId, isPreview)putsisPreviewin the key because it changesthe request: the
queryFncallsgetSequenceMetadata(sequenceId, { preview: isPreview ? '1' : '0' }),and the
previewview can return different sequence/unit data for the samesequenceId. (Themetadata/outlinekeys have noisPreviewbecause those requests never took a preview flag — onlythe sequence fetch did.)
Including
isPreviewis the faithful choice, not an enhancement — omitting it would be theregression. Redux had no query-key cache:
fetchSequence(sequenceId, isPreview)usedisPreviewonly to shape the request and wrote the result to
models.sequences[sequenceId]viaupdateModel,which keys purely by the sequence's
id— a single slot persequenceId, refetched-and-overwrittenon every dispatch (so a mode change gave you the new mode's data). To reproduce that in RQ, the mode
must be part of the key: with it, flipping preview changes the key and refetches; without it, the
key wouldn't change on a mode flip and RQ would serve the cached other-mode data (e.g. the preview
route showing non-preview content) until it went stale — a bug Redux never had.
No behavior change now, none post-bridge. Today the model-store bridge (
meta.models) mirrorsboth variants back into the same
models.sequences[sequenceId]slot (keyed by id), so currentmodel-store readers see the old single-slot behavior. Post-#1977, when readers consume
useSequenceMetadata().datadirectly, a correct reader always reads its current mode's entry —exactly what Redux's slot held for the current mode; the other-mode entry is inert. The only genuinely
new thing is that such a reader must supply
isPreviewto select an entry (a Redux reader got thesingle slot for free) — but that's explicit, not silent:
isPreviewis a required parameter ofthe hook, and every sequence reader derives preview from the same source (the route prefix) the
container already uses. In practice it's moot day-to-day anyway — preview is a whole-session mode, not
something toggled for one sequence mid-session.
retry: falseon the sequence queryThe old thunk was a single axios call with no retry. The app's production query client
(
src/queryClient.ts) sets noretry, so RQ's default (3× exponential backoff, ~7s) wouldotherwise apply. Two reasons this must be
false:actually a unit" from the sequence fetch returning 422 (
sequenceMightBeUnit); theparent-sequence redirect only fires once
sequenceStatus === 'failed'. Under the defaultretry, that status would stay pending for ~7s → a visible navigation stall.
retry: falseis the one setting whose absence would regress behavior.
Interaction with #2024 (smart retry): when that lands (retry 5xx/network, skip 4xx globally),
a per-query
retry: falsestill wins. Revisit then whether sequence should opt into 5xxretry; for now no-retry is the safe, faithful default.
Expected 422 telemetry: kept silent via a new
silenttierThe old thunk logged nothing on the expected 422 (
if (!sequenceMightBeUnit) logError(error)skipped it) — because it's routine control flow, not a failure: the container requests a maybe-unit
as a sequence and reads the 422 as
sequenceMightBeUnit. That happens on a normal path (bare-unit /section+unit redirect resolution), so it must not enter error telemetry.
In the RQ model the query genuinely ends in error state (it must — the container reads
sequenceStatus === 'failed'to drive the redirect), soonErrorfires for it. Originallyloggersin
queryClient.tsoffered onlyerror/info, so the only way to keep 422 out of error loggingwas
info— a real behavior change (silent → info) accepted only because no silent level existed.Rather than accept that change for a faithful port, added a third
silentlevel tologgers(atyped no-op) and tagged the 422
meta.logStatusAs: { 422: 'silent' }. Net: the 422 is trulysilent again, matching the thunk exactly — no telemetry change. The tier is reusable for any future
"surface as error to callers, but don't log" status. Covered by a
queryClient.test.tscase.Not to be confused with the outline 403. The 403 was already logged at
infoin the pre-#2023thunk (explicit
logInfoon 403,logErrorotherwise), so #2023'slogStatusAs: { 403: 'info' }faithfully preserved it — the 403 is the precedent for the info-via-
logStatusAsmechanism, not fora silent→info change. Only the 422 was silent, which is why it (and not the 403) needed the new tier.
Non-sequential block → throw (not a separate failure dispatch)
The sequence API can return a non-
sequentialblock (e.g. achapter); the old thunklogError'd and dispatchedfetchSequenceFailure(nomightBeUnit). In the hook thequeryFnthrows instead, so: (a) the QueryCacheonSuccessbridge does not mirror anon-sequential block, (b) the query goes to error state → the status bridge sets
sequenceStatus: 'failed'withsequenceMightBeUnit: false(thrown Error has no 422), and(c) the single
onErrorpath logs it once vialogError(thrown Error has no status → defaulterrorlevel).The error message text is a verbatim port of the thunk's
logErrorstring(
Requested sequence '…' has block type '…'; expected block type 'sequential'.) — character-identical.The old thunk split it across two concatenated template literals to satisfy max-len; the hook writes
it as one template literal (it fits) and hands it to
throw new Error(...)instead oflogError(...).So the same text still reaches
logError, just routed throughonErrorrather than called inline.Nothing about the wording was invented for the conversion.
Error handling: throw and let
onErrorlog, no inlinelogError/try-catchGeneral pattern behind the two cases above. The old thunk logged imperatively inside a
try/catch:its
catchdidif (!sequenceMightBeUnit) logError(error)— i.e.logErrorfor any non-422failure, silent for the expected 422 — then dispatched
fetchSequenceFailure. The hook has notry/catchand no inlinelogError: thequeryFneither returns{ sequence, units }or throws(its own non-sequential
Error, or the axios error propagating), and all logging is centralized inthe query client's single
onError, which picks the level frommeta.logStatusAs. The mapping isfaithful:
Error, no status →onErrordefaulterrorlevel (was inlinelogError(message)).logStatusAs→errorlevel (wasinline
logError(error)).logStatusAs: { 422: 'silent' }→ not logged (matches the old silent behavior; seethe telemetry note above for the
silenttier).Why: it removes hand-rolled error plumbing from each hook and makes logging a declarative property of
the query (
meta.logStatusAs), consistent withuseCoursewareOutline's 403 handling. With thesilenttier added, all three paths are faithful to the thunk — no telemetry change.isPreviewdropped fromCoursewareContainer'slatestreflatest.current.isPreviewwas read only by the deletedcheckFetchSequence; removed as dead.The
isPreviewvariable is still used (redirect helpers + the new bridge call).Full cleanup:
fetchSequencethunk deleted, test seed migratedThe thunk had no production caller after the container change, so it's deleted (from
thunks.jsand theindex.jsre-export) along with its now-unused imports(
getSequenceMetadata,fetchSequence{Request,Success,Failure}). The slice actions stay —now dispatched by
useSequenceStatusBridge, andSequence.test.jsxstill usesfetchSequenceFailuredirectly.Seed helper, not folded into
seedCoursewareModels. The test harness previously seededsequences suite-wide via
executeThunk(fetchSequence(...)). I added a separateseedSequenceModels(store, sequenceIds)tosetupTest.js(dispatchesupdateModelsequenceupdateModelsunits +fetchSequenceSuccess, the same shape the bridge produces) ratherthan folding it into
seedCoursewareModels. Reason: the two seeds are deliberately distinct —seedCoursewareModelspopulates the outline's partial sequence data (nogatedContent/activeUnitIndex/ units); the sequence seed adds the full metadata. Keeping them separatepreserves that two-phase distinction (and matches how the runtime has an outline query and a
separate sequence query).
The
setupTest.jsimport delta is just "inline the thunk's body":seedSequenceModelsdispatchesthe same three actions
fetchSequencedid on success, so the four things the thunk used toencapsulate are now imported directly —
getSequenceMetadata(the fetch),updateModel/updateModels(the model-store writes), andfetchSequenceSuccess(the status dispatch) — whilefetchSequenceitself is dropped from the imports. (Each seed also mirrors its query'smeta.modelsaction variants: courseware's map-shaped payloads use
*ModelsMap, the sequence's object+array usesupdateModel+updateModels.)Tests
redux.test.js— removed theTest fetchSequencedescribe (networkError / non-sequential /normalize+mirror) and the consts only it used (
learningSequencesUrlRegExp,courseUrl,courseHomeMetadataUrl,courseHomeMetadata, theseedCoursewareModels/buildOutlineFromBlocksimports). Reseated the "Thunks that require fetched sequences"
beforeEachontoseedSequenceModels.statusBridge.test.ts— added auseSequenceStatusBridgedescribe (mocked hook +useDispatch, same pattern as the course bridge): no-id → nothing, pending → request,success → success, 422 → failure with
sequenceMightBeUnit: true, non-422 → failure withfalse.apiHooks.test.tsx— added auseSequenceMetadatadescribe that tests the kept hook's owncontract: success asserts
result.current.datadeep-equals the normalized{ sequence, units }; a non-sequential block asserts the query errors with the thrown"expected block type 'sequential'" message; a 422 asserts the query errors and
getResponseStatus(error) === 422(which also makes it distinct from the non-sequential case);and a retrying client (
retry: 3, retryDelay: 0) still issues exactly one GET —proving the hook's own
retry: false(the harness'screateTestQueryClientdisables retryglobally, so a dedicated client is needed to observe the hook-level setting).
Every removed
fetchSequencetest has an equivalent (split by responsibility)The deleted thunk did two jobs at once — fetch/mirror and status dispatch — so each old test's
assertions were verified against the new split coverage, not dropped. The mapping:
redux.test.js)logError+failedisErrortests)statusBridge.test.ts"non-422 failure" →fetchSequenceFailuresetsfailedqueryClient.test.ts"reports query errors through onError" (generic error →logError)logError+failedapiHooks.test.tsx"throws for a non-sequential block type" (isError + message)statusBridge.test.ts"non-422 failure" (mightBeUnit: false)onErrortest (thrown Error →errorlevel)loaded/idapiHooks.test.tsx"fetches and normalizes…" (data= full normalized; stronger than the oldobjectContaining)statusBridge.test.ts"succeeds when the query resolves" →fetchSequenceSuccessOne assertion changed altitude — flag for review. The old success test seeded the outline first
(partial sequence in the store), then fetched, and asserted the partial got enriched
(
objectContaining gatedContent/activeUnitIndex) — implicitly testing thatupdateModelmergesrather than replaces. The new
apiHookstest renders the hook cold and asserts full normalizeddata; it does not re-test merge-into-partial. That merge is now covered by composition —modelStoreBridge.test.ts(bridge →updateModel) plus the model store's ownupdateModelmergesemantics — rather than by a single sequence-specific before/after integration test. Consistent with
the altitude split below, but it's the one behavior no longer asserted end-to-end in a sequence test.
Test altitude: the hook tests assert the hook, not the bridge
The first draft of the
apiHooks.test.tsxtests rendereduseSequenceMetadatathrough the bridgedquery client and asserted the model-store result (
models.sequences/models.units). Thatcoupled the tests for a hook we intend to keep to the model-store bridge we intend to remove
(#1977): once the bridge goes, those assertions lose meaning and the kept hook would be left with
almost no coverage. It also duplicated
src/data/modelStoreBridge.test.ts, which already covers themirroring mechanism generically.
Reworked so each layer is tested at its own altitude:
apiHooks.test.tsx) asserts only its durable output —result.current.data,the thrown-error message, the surfaced 422 status, and
retry: false. These survive the bridge'sremoval untouched.
modelStoreBridge.test.ts; no per-hookmirror assertions.
sequenceStatus/sequenceMightBeUnit/logError) stays instatusBridge.test.ts, which is correctly a transitional bridge test — it lives and dies withuseSequenceStatusBridge.This maps cleanly onto the responsibility split: the old
fetchSequencethunk did fetch+mirrorand status dispatch at once; the conversion separates those, and the tests follow — durable
behavior tested at the durable layer, transitional behavior at the transitional layer.
Course.test.jsx— fixed four latent un-awaitedwaitForleaks (surfaced, not caused, by this change)Split out as the bottom stack layer. Because this fix is unrelated to sequences (test hygiene
the conversion merely surfaced), it lives in its own PR below #2011 in the stack, so the #2011
diff stays sequence-only. It's a prerequisite: this branch's suite is red without it. Kept
documented here for context; the details below explain the bug and fix it carries.
The full suite went red on my branch (green on base) in a test unrelated to sequences:
Course › displays learner tools. Root cause: fourfire-and-forget
waitFor(...)calls (noawait) elsewhere inCourse.test.jsxwhosebackground polls throw after their own test ends, so Jest misattributes the rejection to
whichever test runs next.
passes handlers to the sequence) — un-awaitedwaitFor→ leaked a"cannot find /previous/i" error.
Sequence alerts display) — worse:waitFor(() => expect(screen.findByText(X)).toBeInTheDocument()).findByTextreturns a Promise, sothe matcher ran against a promise (
received value {}) — and un-awaited, so these threetests asserted nothing and passed vacuously.
These are pre-existing bugs (the file already documents this class — see its
it.skipcomments about "improper waitFor use", #1669); base only passed because the leaks' timing
happened not to collide. This conversion shifts seed timing (async
getSequenceMetadatainstead of a thunk), which makes them collide reliably.
Fix:
awaitthe line-351waitFor; rewrite the three alert assertions asexpect(await screen.findByText(X)).toBeInTheDocument(). All four now genuinely assert andpass in isolation, and the full suite is green twice consecutively. No production
sequence-nav regression — verified: with the line-351
waitForproperly awaited, "passeshandlers to the sequence" passes (Previous/Next links render, handlers fire as expected).
Left untouched: three un-awaited
waitFors insideit.skipblocks (they don't run).Verification
npm run types✓ ·npm run lint✓ · full suite green ×2 (111suites, 936 passed, 3 pre-existing skips). Base full-suite run (changes stashed) also green,
confirming the only behavioral delta is the intended conversion.
apiHooks.test.tsxhook-altitude rework, and thesilenttier (queryClient.ts+apiHooks.tslogStatusAs: { 422: 'silent' }+ aqueryClient.test.tscase):npm run types✓ ·npm run lint✓.npm run types✓ ·npm run lint✓ · full suitegreen — 112 suites, 1103 passed, 3 pre-existing skips, 0 failures.
tutorsuper— see the manual-testing notes): the twoConvert sequence data to React Query #2011-unique behaviors confirmed in the browser — the expected 422 fails fast (no ~7s stall on
the unit-detection redirects) and logs nothing (the
silenttier), and the sequence fetch goesout with
preview=1in preview mode. The non-sequential (chapter) case logs aterrorasexpected/faithful. Two unrelated issues surfaced and ruled out: a pre-existing
course-block infolog (
normalizeOutlineBlocks, from [FC-0056] Course outline sidebar #1375 — filed as Spurious info log for the rootcourseblock in normalizeOutlineBlocks #2059), and an LMS problem-render error thatreproduces on the pre-RQ baseline
db2134c(env/content, not the migration).Manual testing
Manual testing — sequence data (
fetchSequence) → React Query (#2011)In-browser verification for the top-of-stack PR, run against a live backend (tutor
local). This conversion claims no user-facing change: one sequence's metadata + its
units now load via
useSequenceMetadata(React Query) instead of thefetchSequencethunk, are mirrored into the Redux
models.sequences/models.unitsstore through thebridge, and
useSequenceStatusBridgekeeps writingstate.courseware.sequenceStatus/sequenceId/sequenceMightBeUnitso the still-Redux readers (the container's redirecthelpers,
Sequence, breadcrumbs, sequence-navigation, sequence-alerts, the outlinesidebar) behave exactly as before.
Two things are unique to this conversion and can't be fully exercised by jest — verify
them with real redirects and real telemetry:
actually a unit, not a sequence" from the sequence fetch returning 422
(
sequenceMightBeUnit), and the parent-sequence redirect only fires oncesequenceStatus === 'failed'. The hook setsretry: false, so the 422 resolvesimmediately; without it React Query's default backoff (~7s) would stall the redirect.
meta.logStatusAs: { 422: 'silent' }(a no-op logger) — matching the old thunk, whichlogged nothing on 422. Confirm no error and no info page-action fires for it.
Routes involved (
DECODE_ROUTES):/course/:courseId/:sequenceId/:unitId(+ shorter forms +/preview/...)Getting real IDs (DemoX on tutor local)
Course id:
course-v1:OpenedX+DemoX+DemoCourse. The URLs below are app-relative —prefix with your learning MFE origin (whatever host is in the address bar, e.g.
http://apps.local.openedx.io/learning). The:and+in the course id go in the URLliterally.
/course/course-v1:OpenedX+DemoX+DemoCourse/<SEQ>/<UNIT>. Copy the two block ids(
…type@sequential+block@…= sequence,…type@vertical+block@…= unit).Network, filter
course_outline; in the response JSON eachoutline.sections[]has.id(the…type@chapter+block@…section id) and.sequence_ids— grab a section.idand note its.sequence_ids[0](where it should redirect).Verify by hand
Highest-risk (the two #2011-unique behaviors) first. Each item has a click-ready URL for
this instance (base
http://apps.local.openedx.io:2000/learning, coursecourse-v1:OpenedX+DemoX+DemoCourse). Redirects usereplace, so after each one theaddress bar shows the normalized URL — to retest, paste the source URL fresh instead of
using the back button.
the sequence fetch 422s (
sequenceMightBeUnit), and the container looks up the unit'sparent sequence and redirects there. Must land on
/course/…/f5ac527b…/04f99514…promptly (well under a second), not after a multi-second blank/loading stall.
A stall here means
retry: falseregressed.http://apps.local.openedx.io:2000/learning/course/course-v1:OpenedX+DemoX+DemoCourse/block-v1:OpenedX+DemoX+DemoCourse+type@vertical+block@04f99514e09342e8a35b9fe5d6c0f500the section and resolves the unit to its real parent sequence (via the same 422 path).
Expect →
/course/…/f5ac527b…/04f99514…, again promptly.http://apps.local.openedx.io:2000/learning/course/course-v1:OpenedX+DemoX+DemoCourse/block-v1:OpenedX+DemoX+DemoCourse+type@chapter+block@7281f869d5f44704b56d6fe6ee96d886/block-v1:OpenedX+DemoX+DemoCourse+type@vertical+block@04f99514e09342e8a35b9fe5d6c0f500Network tab showing the
…/api/courseware/sequence/<UNIT>request returning 422),run either unit-detection URL above. The 422 shows in the network tab, but there must
be no console error and no
logInfopage-action for it (logStatusAs: { 422: 'silent' }). Before this change it emitted an info page-action; now it's silent.EXPECTED — a chapter id in the sequence slot returns a
chapterblock (HTTP 200,not 422), so the hook throws (non-sequential) →
sequenceStatus: 'failed',sequenceMightBeUnit: false→ redirects to the section's first sequence, then itsactive unit. Distinct from the 422 case: this one does log at error level (a thrown
Error, no status → defaulterror), which is faithful to the old thunk'slogError— do not flag that error as a regression. Use Module 4: expect it toland in
…462452ab…("Discussions").http://apps.local.openedx.io:2000/learning/course/course-v1:OpenedX+DemoX+DemoCourse/block-v1:OpenedX+DemoX+DemoCourse+type@chapter+block@7281f869d5f44704b56d6fe6ee96d886the sequence + its units into
models.sequences/models.unitsand setssequenceStatus: 'loaded'; the page renders fully (unit content, sequence nav,breadcrumbs) with no flash of missing sequence structure.
http://apps.local.openedx.io:2000/learning/course/course-v1:OpenedX+DemoX+DemoCourse/block-v1:OpenedX+DemoX+DemoCourse+type@sequential+block@f5ac527b7c4c4684a5df6da5aa6f8a7b/block-v1:OpenedX+DemoX+DemoCourse+type@vertical+block@04f99514e09342e8a35b9fe5d6c0f500sequence boundaries; the sequence-navigation bar (unit tiles, prev/next) is driven by
the bridged
sequences/unitsmodels. Structure, ordering, and active-unit highlightmatch the outline.
most-recently-active unit, or unit 1 if none (
checkSequenceToSequenceUnitRedirect,driven by the loaded sequence's
activeUnitIndex/unitIds). Expect it to land on a…/<SEQ>/<UNIT>URL.http://apps.local.openedx.io:2000/learning/course/course-v1:OpenedX+DemoX+DemoCourse/block-v1:OpenedX+DemoX+DemoCourse+type@sequential+block@f5ac527b7c4c4684a5df6da5aa6f8a7bfirst— lands on the first unit of the sequence:http://apps.local.openedx.io:2000/learning/course/course-v1:OpenedX+DemoX+DemoCourse/block-v1:OpenedX+DemoX+DemoCourse+type@sequential+block@f5ac527b7c4c4684a5df6da5aa6f8a7b/firstlast— lands on the last unit of the sequence:http://apps.local.openedx.io:2000/learning/course/course-v1:OpenedX+DemoX+DemoCourse/block-v1:OpenedX+DemoX+DemoCourse+type@sequential+block@f5ac527b7c4c4684a5df6da5aa6f8a7b/lastthen re-enter the bare sequence URL: it should resume at that unit
(
activeUnitIndex/saveUnitPositionread from the bridged sequence metadata), notreset to unit 1. (The
saveSequencePositionwrite is still a Redux thunk — out ofscope, Convert saveIntegritySignature + saveSequencePosition to React Query mutations #2015 — but the read path uses the converted sequence data.)
preview=1(isPreviewis part of the query key), and/previewredirects keep theprefix. On fully-released DemoX it looks identical to the normal URL, which is
expected.
http://apps.local.openedx.io:2000/learning/preview/course/course-v1:OpenedX+DemoX+DemoCourse/block-v1:OpenedX+DemoX+DemoCourse+type@sequential+block@f5ac527b7c4c4684a5df6da5aa6f8a7b/block-v1:OpenedX+DemoX+DemoCourse+type@vertical+block@04f99514e09342e8a35b9fe5d6c0f500Left to the automated suite (not re-done by hand)
useSequenceMetadatafetch/normalize/throw/retry: false—apiHooks.test.tsx.useSequenceStatusBridgestate derivation (request/success/422-failure/non-422-failure)—
statusBridge.test.ts.silentlogger tier +QueryCacheonErrorlevel selection —queryClient.test.ts.CoursewareContainer.test.jsx.seedSequenceModels) and the surviving thunk tests —redux.test.js.sequences/units(meta.models) —modelStoreBridge.test.ts.Results
Env: tutor local,
course-v1:OpenedX+DemoX+DemoCourse, usertutorsuper.(fill in items as checked — note anything surprising, especially any stall on the 422 path
or any log emitted for the expected 422)
🤖 Generated with Claude Code