refactor: stop the courseware gate queries refetching from components under the gate - #2102
Merged
Merged
Conversation
brian-smith-tcril
added this pull request to stack #2080
September 23, 2026 02:54
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2102 +/- ##
==========================================
+ Coverage 93.93% 93.94% +0.01%
==========================================
Files 366 367 +1
Lines 5916 5933 +17
Branches 1427 1390 -37
==========================================
+ Hits 5557 5574 +17
- Misses 345 346 +1
+ Partials 14 13 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This was referenced Sep 23, 2026
brian-smith-tcril
force-pushed
the
bsmith/courseware-gate-readers
branch
from
September 23, 2026 11:32
9ee4e53 to
7ac1126
Compare
Base automatically changed from
bsmith/course-loaded-tests-assert
to
master
September 23, 2026 11:38
… under the gate One outline-to-courseware navigation requested `/api/course_home/course_metadata/`, `/api/courseware/course/` and `/api/learning_sequences/v1/course_outline/` three times each: exactly the three queries `useIsCourseLoaded` observes. `CoursewareContainer` fetches them once. Its own calls, including the one inside `useCoursewareRedirects`, mount in one render and dedupe. The other two requests came from `UnitNavigation` and `UnitNavigationEffortEstimate`, both through `useSequenceIds` → `useIsCourseLoaded`. That hook is a gate check, but it was also a fetching observer of all three queries, called from components that render only once the gate is open, so each one mounted on data that had just landed, found it stale at the default `staleTime` of 0, and refetched. `CourseBreadcrumbs` and `useSequenceNavigationMetadata` call it directly too. They happened to be deduped against a fetch already in flight, which is why the count depended on mount timing rather than on structure. This is the defect #2083 fixed on the course-home tabs, and it gets the same fix: the owner fetches, and the readers under it subscribe to the cache entry without fetching. Here no caller of `useIsCourseLoaded` needs it to fetch. Its two default callers, `useCoursewareRedirects` and `CoursewareContainer` itself (through `useSequenceIds`), run inside a render that already calls the three query hooks directly. So `useIsCourseLoaded` never fetches, passing `{ enabled: false }` to `useCourseHomeMeta`, `useCoursewareMetadata` and `useCoursewareOutline`, which take that option now. `useSequenceIds` has no query of its own, so it follows with no change of its own, and the gated call sites are untouched. A disabled observer returns the query's current state and re-renders when the owner's fetch lands, so every caller reads the same value as before, and invalidation still refetches through the owner's observers. A `staleTime` would have hidden the extra fetches without deciding who owns them, and would also have delayed the owner's refetch on navigation, which `course_metadata` uses to record the learner's daily streak. The courseware hooks module declares its own `QueryOptions`, matching the course-home one, since `src/` has no shared module for it to live in. Tests that render a gated component, or the gate hooks, without `CoursewareContainer` now mount its query hooks themselves: component suites render `MountCourseQueryHooks`, a new test helper in `src/tests/`, and the hook tests call the three query hooks alongside the gate hook. Part of #1946 (Stage 1). Closes #2098. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
bsmith/courseware-gate-readers
branch
from
September 23, 2026 11:38
7ac1126 to
c2cb412
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
One outline → courseware navigation was requesting
/api/course_home/course_metadata/,/api/courseware/course/and/api/learning_sequences/v1/course_outline/three times each. Those are exactly the three queriesuseIsCourseLoadedobserves.CoursewareContainerfetches them once; the other two requests came from components under it —UnitNavigationandUnitNavigationEffortEstimate, throughuseSequenceIds→useIsCourseLoaded— mounting after the data had landed, finding it stale at the defaultstaleTimeof 0, and refetching.useIsCourseLoadedis a gate check, but it was also a fetching observer, so every component that asked "is the course loaded?" quietly became a fetcher.This is the defect #2083 fixed on the course-home tabs, and it gets the same fix: the owner fetches, the readers under it only read. No caller of
useIsCourseLoadedneeds it to fetch — its two default callers run insideCoursewareContainer's own render, which already calls the three query hooks directly — so it now never fetches. Measured on tutor dev, the crossing goes from 3 / 3 / 3 to 1 / 1 / 1 requests. No other user-facing change.Part of the Redux → React Query migration (#1946, Stage 1). Closes #2098. Stacked above #2101 (#2100), which fixes four
useIsCourseLoadedtests this change exposed as never having asserted anything.What changed
useIsCourseLoadednever fetches. It passes{ enabled: false }touseCourseHomeMeta,useCoursewareMetadataanduseCoursewareOutline, which take that option now. A disabled observer returns the query's current state and re-renders when the owner's fetch lands, so every caller reads the same value as before, anduseIFrameBehavior's invalidation of the three keys still refetches through the owner's observers.useSequenceIdsfollows with no change of its own —useIsCourseLoadedis its only link to React Query. The four gated call sites (CourseBreadcrumbs,useSequenceNavigationMetadatatwice,UnitNavigationEffortEstimate) are untouched.staleTime. It would have hidden the extra fetches without deciding who owns them, and would also have delayed the owner's refetch on navigation — which is wherecourse_metadatarecords the learner's daily streak.CourseBreadcrumbs,SequenceNavigation,UnitNavigation) render the newsrc/tests/MountCourseQueryHooks.tsxbeside it; the hook tests incourseware/data/apiHooks.test.tsxcall the three query hooks alongside the gate hook. New hook tests pin that the gate fetches nothing on its own and reads an owner's result without fetching again — both fail if the gate is made to fetch.QueryOptionsis declared locally incourseware/data/apiHooks.ts, matching the course-home one, sincesrc/has no shared module for it.Testing
npm run types,npm run lintand the full suite (113 suites, 1140 passed, 3 skipped) are green.Manually verified on tutor dev, with
CourseBreadcrumbsandSequenceNavigationinserted through their slots (both render nothing by default). Entering courseware six ways (including a hard reload on a unit URL and the three redirect shapes), the breadcrumbs and their dropdowns, and every unit-navigation check across all three navigation surfaces behave as before. The request count on the outline → courseware crossing is 1 / 1 / 1 for the gate endpoints (3 / 3 / 3 before) with the control endpoint unchanged at 1, and moving across a sequence boundary requests none of them. The preview route was not run, and console output was not compared against a before capture; video auto-advance and the first-section celebration are left out with their reasons.Decisions
Working notes for this layer, kept out of the tree:
Full decision log
Decisions — stop the courseware gate queries refetching from components under the gate (#2098)
Layer on top of #2084. The diagnosis and the ownership fix were settled in the
plan on issue #2098; entries here record choices made after it.
QueryOptionsis declared locally incourseware/data/apiHooks.ts, notshared.
course-home/data/apiHooks.ts:28-30already declaresinterface QueryOptions { enabled?: boolean; }as a module-private type, andthis layer needs the same shape for
useCoursewareMetadataanduseCoursewareOutline(useIsCourseLoadedanduseSequenceIdstake nooption; see entry 4). The courseware module declares a matching local copy.
Sharing it was considered. The best home would be a new
src/data/query-options.ts, besidehttp-error.ts, whoseRequestErrorisalready a type both feature hook modules import from
@src/data/. It wasrejected because no such module exists yet:
src/has no shared types moduleat all, so sharing would mean creating a file for a one-field interface that
mirrors React Query's own option name and is unlikely to drift. If a suitable
shared module appears later, importing from it becomes the right move and
both copies should fold into it.
The commit type is
refactor:. The change restructures who fetches thecourseware gate queries — the owner fetches, readers under the gate
subscribe — and nothing is broken today, so
fix:would imply a bug thatisn't there.
perf:describes the outcome (three requests per gate querybecome one) and is allowed by the shared openedx commitlint config, but this
repo has not used it, while the rest of the React Query migration landed as
refactor:; staying consistent with those commits wins.Suites that render a gated component on its own mount the owner, not a
seeded cache — the plan was wrong to expect
seedQueryData. Three suites(
CourseBreadcrumbs,SequenceNavigation,UnitNavigation; 13 cases)relied on the component under test fetching the gate queries itself, against
the endpoints
initializeTestStorealready mocks. Seeding did not fit: thetwo navigation suites use
setupTest'srender, which builds its clientinternally with no way to reach it, and seeding would mean writing each
query's normalized result shape by hand, while the components read only
isSuccessandcourseAccess.hasAccess— a stand-in shape would let thosetests claim more than they check. Instead
src/tests/MountCourseQueryHooks.tsxexports a component that calls
useCoursewareMetadata,useCoursewareOutlineanduseCourseHomeMetafor the course, and eachsuite renders it beside the component under test. That is the production
shape —
CoursewareContainerfetches, the components under it read — usingthe real hooks and the existing mocks.
apiHooks.test.tsxcalls the threehooks directly inside
renderHook, alongside the gate hook, so each testshows what it mounts; a shared hook for that was tried and read worse than
the three calls it hid.
Naming. The helper is named for what it does — it mounts query hooks —
not for the state it produces or the hook that reads its results. Names
built on
useIsCourseLoaded(CourseLoadedQueries) pointed at the readerrather than the fetcher and read as "the queries of a loaded course";
Courseware…claimed the wrong set (one of the three is course-home's, andcourseware has sequence and sidebar queries this does not mount); verb-led
hook names (
useFetch…,usePopulate…) read awkwardly. A genericMountHook(one element per hook, the hook passed as a value) was sketchedand set aside as more machinery than three call sites need, but it gave the
vocabulary: mount for the action, query hooks for what is mounted, and
course for the course-level queries as opposed to the sequence query
CoursewareContaineralso mounts.Home. It lives in its own TypeScript file beside
src/tests/MockedPluginSlot.jsx, the existing place for render-tree testhelpers.
setupTest.jswas the first home, but it is JavaScript, soreact/prop-typesrequired apropTypesdeclaration forcourseId—more PropTypes to remove later.
src/courseware/course/test-utils.jsx(the courseware test helpers, closer in scope) has the same problem, and
converting it is out of scope. A prop-less local copy in each suite would
have avoided the rule too, at the cost of three copies. In a
.tsxfilethe prop is typed and the rule is satisfied, which is why no
.tsxfile inthe repo declares
propTypes; a one-component file was judged better thaneither new PropTypes or duplication.
useIsCourseLoadednever fetches;useSequenceIdsfollows, and neithertakes an option. The plan gave both an
{ enabled }option, with thegated components passing
false— the per-call-site shape Read the dates and outline tab data from their queries, not useModel #2083 uses forthe tab readers. But no caller wants it on. The two that took the default
true,redirects.ts:248andCoursewareContainer.tsx:46(throughuseSequenceIds), run insideCoursewareContainer's own render, whichalready fetches all three queries directly (
CoursewareContainer.tsx:32-33,redirects.ts:249-250), so their observers only deduped. Three shapes wereweighed:
true. Clearest at the call site, but anoption nobody needs, and forgetting
{ enabled: false }silently bringsback the duplicate requests this change removes — nothing fails, and the
trace showed it only turns up when someone counts requests.
false. Still an option nobody needs, and it invertsReact Query's own default, so
{ enabled: true }at a call site wouldread as the surprise.
loudly: a caller with no owner above it stays
falseforever, whichbreaks the page and any test of that component. The name already fits a
read — it asks whether the course is loaded.
It carries no comment. A draft named
CoursewareContaineras the owner, but"never fetches" restated the
{ enabled: false }below it, "every callerruns inside
CoursewareContainer" read as a restriction when any fetchingobserver of the same queries will do, and a trimmed version ("stays false
until something else fetches these queries") still only described what a
disabled query does. Read the dates and outline tab data from their queries, not useModel #2083's disabled readers carry none either, since
{ enabled: false }is React Query's own vocabulary; the owner is recordedhere and in
MountCourseQueryHooks' comment, where the tests mirror it.useSequenceIdshas no query of its own —useIsCourseLoadedis its onlylink to React Query, and its other reads are model-store selectors — so it
became non-fetching with no change of its own, and its pass-through option
went with it. The four gated call sites are back to their original
argument lists. The three query hooks keep
{ enabled }:useIsCourseLoadeduses it internally, and B2 (Read courseHomeMeta from the query: tab-page, alerts, and course-home tabs #2085) and B3 (Read courseHomeMeta from the query: courseware, shared, and widgets #2086) needit on
useCourseHomeMetafor their gated readers.For Read sections and coursewareMeta from the courseware queries, not useModel #2089 (D3): it converts
useSequenceIds' model-store reads onto theoutline query, which gives the hook a query observer of its own. By the
same reasoning that observer should read without fetching —
CoursewareContainerowns the outline fetch, throughuseCoursewareRedirects.The hook tests were checked by making the gate fetch again: with the three
internal
{ enabled: false }flipped totrue, fetches nothing on itsown (both the
useIsCourseLoadedand theuseSequenceIdscase) andreads the owner's result without fetching again fail — 0 requests
becoming 3, and 3 becoming 6, one refetch of each gate endpoint.
Four refactor: derive the courseware loaded gate and sequence ids from queries #2071 tests got their own layer, Make the
useIsCourseLoadedtests actually assert: replaced mocks and unsettled checks from #2071 #2100 (PR test: make the useIsCourseLoaded tests actually assert #2101), below this one.With the owner/reader split, four
useIsCourseLoadedtests from refactor: derive the courseware loaded gate and sequence ids from queries #2071(pending, lacks access, outline fails, a query fails) failed. The
hook was not at fault: each registered its special handler and then called
mockHappyPath(), which replaced it, and asserted before any responsesettled, so they had never tested the states they name — deleting the
special handlers left them green. That repair is Make the
useIsCourseLoadedtests actually assert: replaced mocks and unsettled checks from #2071 #2100's, split out the wayMake the courseware redirect-rule tests actually assert: un-awaited
waitForand unwired mocks from #1501 #2078 was so this layer stays about who fetches. This layer builds on itsversion of the block:
renderLoadedkeeps Make theuseIsCourseLoadedtests actually assert: replaced mocks and unsettled checks from #2071 #2100's optionalqueryClientargument and additionally calls the three query hooks, and
makeWrapperlets the reader test mount a second hook on the same client.Manual testing
Checklist
Manual testing — stop the courseware gate queries refetching from components under the gate (#2098)
In-browser verification against a live backend (tutor dev).
What changed:
useIsCourseLoadedno longer fetches, and neither doesuseSequenceIds, which reads through it.CoursewareContainer(together withuseCoursewareRedirects, which it calls) is now the only thing fetching the threegate queries —
/api/course_home/course_metadata/,/api/courseware/course/and/api/learning_sequences/v1/course_outline/. Everything below it only reads.The bug this layer could introduce is a reader that never sees loaded data. A
disabled observer with nothing fetching its query stays
pending, so the gate readsfalseforever and the component sits in its "not loaded yet" state withouterroring. That is what this doc is mostly for: click through every page and every
component that now only reads, and confirm each behaves as if the course is loaded.
In the app nothing should be in that position — every reader renders under
CoursewareContainer— so any failure here means that assumption is wrong somewhere.What "stuck unloaded" looks like, per reader:
CourseBreadcrumbs(slot-inserted, see Setup)useIsCourseLoadeduseSequenceNavigationMetadata→UnitNavigation(beside the unit title and at the bottom, by default) andSequenceNavigation(the unit-tab bar, slot-inserted)useIsCourseLoaded,useSequenceIdsuseSequenceNavigationMetadata→useIFrameBehaviorUnitNavigationEffortEstimate(inside Next)useSequenceIdsCoursewareContaineritselfuseSequenceIdsuseCoursewareRedirectsuseIsCourseLoadedSetup
An ordinary course with several sections, each with a few subsections and
units, covers most of it. Test as an enrolled learner unless a check says
otherwise.
Two of the readers render only when a plugin slot inserts them, so add both to
env.config.jsx(it is untracked — check a stale local copy is not carrying otheroverrides). Both inserts are the "Replace with default … component" examples from the
slots' READMEs:
org.openedx.frontend.learning.course_breadcrumbs.v1—CourseBreadcrumbsSlotrenders nothing by default; insert
CourseBreadcrumbsfrom./src/courseware/course/breadcrumbs, passing throughcourseId,sectionId,sequenceId,unitIdandisStaff(src/plugin-slots/CourseBreadcrumbsSlot/README.md).org.openedx.frontend.learning.sequence_navigation.v1—SequenceNavigationSlotalso renders nothing by default (
Sequence.jsxsays so above it); insertSequenceNavigationfrom./src/courseware/course/sequence/sequence-navigationas in
src/plugin-slots/SequenceNavigationSlot/README.md.Without them, the breadcrumb checks and the unit-tab bar checks below have nothing
to look at. The two
UnitNavigationinstances render by default.Extra state for one check:
/preview/course/:courseId/:sequenceId/:unitId(e.g. Preview from the authoringMFE).
Verify by hand
Entering courseware
Each of these mounts the readers in a different order relative to the owner's fetch.
fill in, the navigation works.
/course/:courseId/:sequenceId/:unitId). Thereaders mount while the owner's requests are still in flight, which is the case
most likely to expose a reader that never updates.
/course/:courseIdredirects to the resume unit (or the first unit on afresh enrollment) and the page settles as above.
/course/:courseId/:sequenceIdredirects to a unit in that sequence./course/:courseId/<section id>)redirects to that section's first sequence.
second entry reuses cached data rather than fetching from scratch, and should
settle the same way.
Breadcrumbs
and after moving to another sequence.
navigates there.
Unit navigation
Three navigation surfaces read the same metadata:
UnitNavigationbeside the unittitle,
UnitNavigationat the bottom of the unit, and theSequenceNavigationunit-tab bar (slot-inserted). Run each check in all three.
unit.
sequence; Previous on a sequence's first unit lands on the last unit of the
previous one. Include a section boundary.
(
/course/:courseId/course-end), and that page renders.stale first/last state from a previous sequence.
Preview route
/preview/course/…as staff: breadcrumbs (with/previewin their links), the navigation and unit-to-unit movement behave as on the
normal route.
Request count
Protocol, same as #2084's: hard reload the outline, wait for the Network tab to go
idle, clear the log, click a sequence title, wait for idle again, count each
endpoint.
/api/course_home/course_metadata//api/courseware/course//api/learning_sequences/v1/course_outline//api/course_home/v1/navigation/(control)"Before" was measured while diagnosing this issue:
course_metadataover five runs(3 every time), the other three once.
the three gate endpoints. 0 on this layer. Not measured on the layer below
(test: make the useIsCourseLoaded tests actually assert #2101), so there is no before number to compare against.
General
out while testing, but the console was not checked against a before capture.
Not covered:
useIFrameBehaviortakesisLastUnitandnextLinkfromthe same
useSequenceNavigationMetadata(sequenceId, unitId)call the navigationsurfaces use, so the unit-navigation checks above — Next resolving within a
sequence, across sequence and section boundaries, and to course-end on the last
unit — exercise the values it would act on. What is specific to it is the video
block's
plugin.autoAdvancemessage, which this layer does not touch. Setting itup needs the platform's
ENABLE_AUTOADVANCE_VIDEOSflag (off by default, and ithides the course's Enable video auto-advance Advanced Setting while off), the
course setting, and the learner's player toggle.
CoursewareContainer's ownuseSequenceIdscall, in the owner's render alongside the fetchinguseCoursewareMetadata/useCourseHomeMetacalls anduseCoursewareRedirects,so it cannot be left without a fetch the way a component further down could; the
sequence-boundary Next checks above already show
useSequenceIdsreturning theloaded sequence list. The modal is also off by default: openedx-platform creates the
enrollment's celebration row only when the enrollment is created while the
courseware.mfe_progress_milestonescourse waffle flag is on, and it then needs Nextfrom a section's last unit into the next section, with no streak celebration due.
useIFrameBehaviorinvalidation after apost_event(the"Shift due dates" button in a past-due problem). It refetches through the owner's
observers, which are unchanged by this layer, and producing a missed deadline takes
a self-paced course with the
relative_datesflag and a backdated schedule. Worthrunning only if something above suggests the owner itself is misbehaving.
🤖 Generated with Claude Code