Repository navigation
refactor!: read sections and coursewareMeta from the courseware queries, not useModel - #2140
Merged
Merged
Conversation
brian-smith-tcril
added this pull request to stack #2141
September 29, 2026 15:18
11 tasks done
brian-smith-tcril
force-pushed
the
bsmith/coursewaremeta-query-reads
branch
2 times, most recently
from
September 30, 2026 05:58
3d958fb to
105ba57
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2140 +/- ##
=========================================
Coverage ? 95.10%
=========================================
Files ? 373
Lines ? 6087
Branches ? 1506
=========================================
Hits ? 5789
Misses ? 286
Partials ? 12 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
brian-smith-tcril
force-pushed
the
bsmith/coursewaremeta-query-reads
branch
2 times, most recently
from
September 30, 2026 07:54
1efd583 to
970aaaf
Compare
This was referenced Sep 30, 2026
brian-smith-tcril
marked this pull request as ready for review
September 30, 2026 11:24
…es, not useModel Layer D3 of the model-store dissolution (#1977). The readers of the `coursewareMeta` and `sections` models read the query that owns each field: `useCoursewareMetadata(courseId, { enabled: false })` for the metadata, and `useMinimalCourseOutline(courseId, { enabled: false })` for the outline's course entry (`id`, `title`, `sectionIds`, `hasScheduledContent`) and its sections. The integrity-signature write patches the cached metadata. - Readers: `Course`, `Sequence`, `CourseBreadcrumbs`, `useSequenceIds`, `CourseExit`, `GetCourseExitNavigation`, `CourseCelebration`, `LockPaywall`, the sequence-navigation and outline-sidebar hooks, `UnitSuspense`, `useShouldDisplayHonorCode`, `SocialIcons`, `UpgradePanel`, the entrance-exam alert and `CertificateStatus`. A local that holds the metadata query's result is named `coursewareMetadata`, after its hook, where it used to hold the merged store entry. `modelKeys` leaves `Unit/constants.ts`. - The course `title` comes from the outline at every reader. The store entry took it from whichever of the two queries responded last since #2023 split `fetchCourse`; the metadata endpoint's `name` is the HTML-escaped `display_name_with_default_escaped`, so a course named "R&D" could show "R&D" in the page title. Before #2023 the outline's unescaped title always won; this restores that. The metadata normalizer no longer maps `name` to `title` at all, so the escaped value has no reader to reach (#2137 records what is known and what is open). - `SidebarProvider` builds the widgets' `course` from the metadata, the outline's course entry and the course-home metadata, in that order, as the store entry was, and asks every widget with whatever is loaded, as it has since #1885. `isAvailable` receives that spread's own type, each field optional; the built-in widgets type themselves from the slot. `SidebarWidgetContext.course` gains the outline's fields as optional. - `useSequenceIds` reads the course's sections through a new `useCourseSections(courseId)`, whose `select` does the lookup `useModels('sections', …)` did; the outline's query options move into a `minimalCourseOutlineQuery(courseId)` factory that both outline hooks spread. - `useSaveIntegritySignature` sets `userNeedsIntegritySignature: false` on the cached metadata with `setQueryData` (a no-op when nothing is cached); `useDispatch` leaves `courseware/data/apiHooks.ts`. - `CourseBreadcrumbs` builds its sections only once the outline is there, so it renders while the outline is pending. - Tests: suites that read the seeded store for these models mock or seed the queries. A test-only `CourseQueryGate` renders a component only while the course's queries are in the states a test names, by default the two `TabPage` waits for having succeeded; `LoadedCourse` (now in `course/test-utils.jsx`) and the unit and sequence navigation suites render through it, and `initializeTestStore` gains `preventSequenceLoad` for the sequence navigation's loading case. The integrity writer suite asserts on the cache. `CoursewareContainer` and `CourseExit` each count the courseware metadata and the learning-sequences outline once per load. Negative check: with one reader per page fetching, exactly the matching count case fails. BREAKING CHANGE: the `coursewareMeta` store model, a mirror of the courseware metadata query until #2089's layer B removes it, no longer picks up the integrity-signature save: `userNeedsIntegritySignature` stays `true` there until the next fetch. Read it from `useCoursewareMetadata(courseId, { enabled: false }).data` in `courseware/data/apiHooks`. `useCoursewareMetadata(courseId).data` no longer has `title` (see #2137); read the course title from `useMinimalCourseOutline(courseId, { enabled: false }).data?.courses[courseId].title` or `useCourseHomeMeta(courseId, { enabled: false }).data?.title`. The page title and the course-end page's title use the outline's unescaped course title. A sidebar widget's `isAvailable` typed from `SidebarWidget['isAvailable']` now sees every `course` field as optional (type-only; widgets are asked as before). Part of #1946. Part of #2089. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
bsmith/coursewaremeta-query-reads
branch
from
October 2, 2026 03:28
970aaaf to
8db10ed
Compare
This was referenced Oct 2, 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
The readers of the
coursewareMetaandsectionsmodels read the query that owns each field:useCoursewareMetadata(courseId, { enabled: false })for the courseware metadata, anduseMinimalCourseOutline(courseId, { enabled: false })for the outline's course entry (id,title,sectionIds,hasScheduledContent) and its sections; the integrity-signature writer patches the cached metadata instead of the store. The coursetitlecomes from the outline at every reader, so a course whose name contains&,<,>or quotes no longer shows the metadata endpoint's HTML-escaped name in the page title (#2137) — the pre-#2023 behaviour. The sidebar asks every widget with whatever course data is loaded, as it has since #1885, withisAvailable's parameter typed as the spread that buildscourse. No request change, and no learner-visible change beyond the escaped-title fix. Breaking for operators — see below. Part of the Redux → React Query migration (#1946, Stage 1); layer D3 of the model-store dissolution (#1977), #2089's layer A, on top of #2139 in stack #2141. Part of #2089 (layer B, D4, closes it).What changed
Course,Sequence,CourseBreadcrumbs,useSequenceIds,CourseExit,GetCourseExitNavigation,CourseCelebration,LockPaywall, the sequence-navigation and outline-sidebar hooks,UnitSuspense,useShouldDisplayHonorCode,SocialIcons,UpgradePanel, the entrance-exam alert andCertificateStatus. A local holding the metadata query's result iscoursewareMetadata, after its hook.{}fromuseModelbecomesundefinedfrom the queries; each reader handles it (decision 3).nametotitle, so the escaped value has no reader.getAvailableWidgetsbuildscoursewith a localbuildWidgetCourse(coursewareMetadata, minimalCourseMetadata, courseHomeMeta)and asks every widget — built-in or fromSIDEBAR_WIDGETS— whatever is loaded.isAvailabletakes a localSidebarAvailabilityContextwhosecourseisReturnType<typeof buildWidgetCourse>; the built-ins type themselves from the slot (NonNullable<SidebarWidget['isAvailable']>). The exportedSidebarWidgetContext.coursegains& Partial<MinimalCourseMetadata>.useSequenceIds(decision 6): reads through a newuseCourseSections(courseId), whoseselectdoes the lookupuseModels('sections', …)did; the outline's options move into aminimalCourseOutlineQuery(courseId)queryOptionsfactory that both outline hooks spread.useSaveIntegritySignature(decision 9):setQueryDataon the cached metadata, a no-op when nothing is cached;useDispatchleavescourseware/data/apiHooks.ts.CourseQueryGaterenders a component only while the course's queries are in the states a test names;LoadedCoursemoves tocourse/test-utils.jsx;CoursewareContainerandCourseExiteach count the courseware metadata and the outline once per load;SidebarContext.testgains when a metadata query has no data;model-store/hooks.test.tsxandcourse-exit/utils.test.tscoveruseModelsandgetCourseExitMode'scanImmediatelyViewCertificatedefault, which the converted suites no longer reached (decision 12).Operators — breaking
coursewareMetastore model, a mirror of the courseware metadata query until Read sections and coursewareMeta from the courseware queries, not useModel #2089's layer B removes it, no longer picks up the integrity-signature save:userNeedsIntegritySignaturestaystruethere until the next fetch. Read it fromuseCoursewareMetadata(courseId, { enabled: false }).data(./src/courseware/data/apiHooks).useCoursewareMetadata(courseId).datahas notitle(see Investigate courseware metadata API sending course name HTML-escaped #2137). Read the course title fromuseMinimalCourseOutline(courseId, { enabled: false }).data?.courses[courseId].titleoruseCourseHomeMeta(courseId, { enabled: false }).data?.title. The page title and the course-end page's title use the outline's unescaped title.isAvailabletyped fromSidebarWidget['isAvailable']sees everycoursefield as optional. Runtime is unchanged — every widget is asked as before, including JavaScript widgets registered throughSIDEBAR_WIDGETSinenv.config.jsx.SidebarWidgetContext.coursegains the outline'ssectionIdsandhasScheduledContentas optional fields.Testing
npm run typesandnpm run lintclean; full suite 120 suites, 1214 passed, 0 skipped. Manual checks on tutor local, 8 of 14 run, all passing: see the checklist.Decisions
Full decision log
Decisions — read sections and coursewareMeta from the courseware queries, not
useModel(#2089, layer A)Layer D3 of the #1977 model-store dissolution; PR #2140 in stack #2141, on
top of #2139 (#2138: the courseware and course-end pages before the outline
has loaded), after #2088's layers landed with stack #2121.
Part of #2089 (layer B, D4, closes it). Entry 1 was settled in the #2089 plan
review (2026-09-28); entries 2–11 were decided during implementation, and
entry 4 revises the posted plan's A3.
coursewareMeta.titleis read from the outline query, at every reader.The
coursewareMeta[courseId]model is a merge (Dissolve the model-store normalized cache #1977, fact 4): themetadata query writes the whole
normalizeCoursewareMetashape, whosetitleis the endpoint'sname, and the outline query'scoursesmapwrites
{ id, title, sectionIds, hasScheduledContent }. Dissolve the model-store normalized cache #1977 recorded thetitlecollision as looking inert, since no reader was found. There are two,and both build a page
<title>:Course.jsx(the Helmet title frompageTitleBreadCrumbs) andCourseCelebration.jsx(the course-end page's<title>).SidebarContext.tsxalso passes the merged model to eachwidget's
isAvailableascourse.The two values are not the same field. In openedx-platform the
courseware metadata serializer's
nameisdisplay_name_with_default_escaped(
course_overviews/models.py: "Return html escaped reasonable display namefor the course", marked DEPRECATED), while the learning-sequences outline's
titleis written at publish fromcourse.display_name_with_default(
cms/djangoapps/contentstore/outlines.py), unescaped. They agree for mostnames; for a name containing
&,<,>or quotes the metadata value isHTML-escaped, and the page
<title>renders it as text, so a course named"R&D" shows "R&D" in the browser tab.
Before refactor: convert the courseware metadata fetch to React Query #2023 the outline always won.
fetchCourseincourseware/data/thunks.jsfetched the metadata, the outline, thecourse-home metadata and the sidebar toggles in one
Promise.allSettled,and dispatched only after all four settled, in a fixed order:
addModelof the metadata first (a wholesale replace), thenupdateModelsMapof the outline'scourses(a merge). The outline'stitlewas therefore always the one on the model, and the escapednamenever reached a reader.
refactor: convert the courseware metadata fetch to React Query #2023 made the winner the network's choice. Splitting the fetch into
independent queries, each mirrored into the store by the bridge from the
QueryCache'sonSuccessas its own response arrives, made the dispatchorder the arrival order. Both mirrors end in the slice's
update(
{ ...existing, ...model }), so whichever response lands second setstitle.CoursewareContainerstarts both fetches in one render, andCourseExitmounts fetching observers of both, so the race is re-run onthe course-end page as well. refactor: convert the courseware metadata fetch to React Query #2023 saw the other half of the
ordering dependency: it changed the metadata mirror from
addModeltoupdateModelso a late metadata response could not drop the outline'ssectionIds, pinned by keeps coursewareMeta.sectionIds (and the sequenceorder nav needs) when metadata resolves after the outline in
apiHooks.test.tsx. The same case assertstitleiscourseMetadata.namein that order — its evidence that the late metadata write landed — which
also pinned the metadata-last value rather than the pre-refactor: convert the courseware metadata fetch to React Query #2023 one.
So the outline's
titleis the faithful conversion, and fixes aregression from our own migration.
CourseCelebration.jsxreadsuseMinimalCourseOutline(courseId, { enabled: false }).data ?.courses[courseId].title;Course.jsxputs the outline's course entry,minimalCourseMetadata, in the page-title breadcrumbs beside theoutline's sequence and section.
SidebarContext.tsxrebuilds the merge as{ ...coursewareMetadata, ...minimalCourseMetadata, ...courseHomeMeta },the order
fetchCoursewrote in, withcourseHomeMetastill spread lastas it is today. The learner-visible effect, a course name with
HTML-special characters shown unescaped on every load, goes in the commit
body as a restoration of pre-refactor: convert the courseware metadata fetch to React Query #2023 behavior, not as a new fix. The
apiHooks.test.tsxbridge suite goes in layer B with the mirrors it tests.And
normalizeCoursewareMetastops mappingnametotitle(settledin review). With
titleon both results, every reader of the outline'slooked like it had picked one of two sources, and a comment at each site
would be needed to say why. Nothing in
srcreads the metadata'stitleafter the conversion, so
titleleavesCoursewareMetaandnameleavesCoursewareMetadataResponse, which names only the fields the normalizerreads; each field of the course now has one source, and the escaped value
cannot be picked up by accident. The normalizer carries a one-line note
pointing at Investigate courseware metadata API sending course name HTML-escaped #2137, the issue that records what is known — three
course-level endpoints carry the course name, the courseware metadata's
nameis the only escaped one, the deprecated property it reads — and theopen questions (why that property, who else consumes it, whether the MFE
should standardize on one title source); the detail lives there rather
than inline. The course-home metadata's
titleis unescaped too and iswhat
TabPage,SocialIconsand the sidebar merge already read; the twopage titles keep the outline, the pre-refactor: convert the courseware metadata fetch to React Query #2023 source. Nothing points plugin
authors at
useCoursewareMetadata(...).data.title, though refactor!: read courseHomeMeta from the query in the courseware, course-end pages and widgets #2110's PRnamed
useCoursewareMetadataas wherecoursewareMetareads move, solayer B's plugin notes name
useMinimalCourseOutlinefor the outline'sfields. Until layer B the bridge still writes the store entry, whose
titlenow comes from the outline alone. The bridge case inapiHooks.test.tsxkeeps its subject,sectionIdssurviving a latemetadata write; with no
titlefrom the metadata, its evidence that thewrite landed becomes a field only the metadata carries,
language(
courseMetadata.language), and the comment is unchanged.A local that holds the metadata query's result is named
coursewareMetadata, after the hook it comes from. Before this layer thesites that kept a name for the read —
coursewareMetainSidebarContext.tsx,courseinCourse.jsx, the entrance-exam alert andthe outline sidebar hook,
metainUnitSuspense.tsx— held the mergedstore entry, the metadata plus the outline's course fields. After it they
hold the metadata query's result alone. Keeping the old names left that
change invisible in review; naming the local after its type
(
coursewareMeta, Read units and sequences from the courseware queries, not useModel #2088 layer B's rule) was weighed and not taken, sincethe type's name is the store model's name and so reads as unchanged too.
useCoursewareMetadata(...).databecomescoursewareMetadataat every oneof those sites; the sites that destructure or read one field keep no
local. The outline-side locals keep their types' names
(
minimalCourseOutline,minimalCourseMetadata), which are new and carryno old meaning.
undefinedwhere{}was.useModelreturned{}for a missingmodel; a query's
dataisundefineduntil it has a result. Each readertakes the form the earlier layers settled for its shape:
.data ?? {}inline (CertificateStatus,LockPaywall, the sequence-navigation hook,UpgradePanel,Sequence,CourseExit,GetCourseExitNavigation,CourseCelebration);LockPaywallandUpgradePanelhad named acourseonly todestructure it, so the destructure moves onto the read, the form of the
course-home read beside it in each file;
?.at its uses (coursewareMetadata?.entranceExamDatain the entrance-exam alert and the outline sidebar hook,
coursewareMetadata?.contentTypeGatingEnabledinUnitSuspense); the|| {}afterentranceExamDatastays, for metadata without the field;.data?.marketingUrlinSocialIcons,.data?.userNeedsIntegritySignatureinuseShouldDisplayHonorCode.The
sectionsreads change the same way:useModel('sections', id)returned
{}for a section not in the store, and the outline'ssections[id]isundefined. Two consequences, both only while theoutline is pending.
Course.jsxandSequence.jsxpasssection ? section.id : nulltoCourseBreadcrumbsSlotandCourseOutlineSidebarTriggerSlot; with{}that wasundefined, and itis now the
nullthe expression names — both slots passsectionIdonin their
pluginProps, so a plugin in either seesnullrather thanundefinedin that window (the set ofpluginPropsis unchanged), andCourseBreadcrumbs' own default isnull. And the page title's breadcrumbs drop segments whose entry is notthere yet, as Read units and sequences from the courseware queries, not useModel #2088 layer B's did for the sequence: before the outline
lands, the course segment came from the store entry's metadata
title,and now it is left out until the outline's
titlearrives. On this stackthat window no longer reaches the tab: fix: handle a slow or failed outline on the courseware and course-end pages #2139 renders
Course's<Helmet>only once the outline query has succeeded, leaving
LoadedTabPage's titlein place until then.
getAvailableWidgetsasks every widget with whatever is loaded, as it has sincefeat: decouple notifications panel using widget registry mechanism #1885;
isAvailable's parameter is typed as the spread that buildscourse—revised from the posted plan and from this layer's first draft. The plan (A3) had
SidebarWidgetContext.courseextended with the outline's fields. The first draftinstead returned
[]fromgetAvailableWidgetswhen either metadata query had nodata, so that
{ ...coursewareMetadata, ...minimalCourseMetadata, ...courseHomeMeta }type-checked against the declared
course: CourseHomeMeta & CoursewareMeta. Reviewrejected that: it was the first change since the widget registry to whether widgets
are asked, made only to satisfy the compiler.
How
isAvailablehas been fed. Before feat: decouple notifications panel using widget registry mechanism #1885 availability was coded in theprovider and triggers (discussions: the unit's topic; notifications:
verifiedModefrom
useModel('courseHomeMeta')). feat: decouple notifications panel using widget registry mechanism #1885 introduced the registry andisAvailable,handed
course: { ...coursewareMeta, ...courseHomeMeta }from twouseModelreads,each
{}when missing, and filtered every time: a widget withoutisAvailablewasalways available, and each widget decided from whatever was loaded. feat: move discussion topic prefetch from trigger to widget config lifecycle #1897, feat: make widget registry to backward compatible #1899 and
refactor!: convert getCourseDiscussionTopics to React Query #2068 left that alone; refactor!: read courseHomeMeta from the query in the courseware, course-end pages and widgets #2110 made
courseHomeMetaquery data (undefinedwhenmissing, which a spread treats like
{}); refactor!: clean up SidebarContextProvider for React Query and convert it to TypeScript #2113 typed it —isAvailable?: (context: SidebarWidgetContext) => boolean,course: CourseHomeMeta & CoursewareMeta, "notPartial: the provider renders under the gate" (Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111, decision 6) — whilecoursewareMetawas stilluseModel'sany, so the declaration was never checked.This layer's typed
coursewareMetadatais the first time it is. The widgets are thetwo built-ins (discussions reads
unit; upgrade readscourse.verifiedMode, acourse-home field) and every operator widget in
getConfig().SIDEBAR_WIDGETS,usually from a JavaScript
env.config.jsx; the sidebar README documents the contractas "the first availability check runs before the data arrives, and when the query
resolves the framework re-evaluates availability", with examples that guard
course?.and one that checks onlycourseId.Reachability.
SidebarProviderrenders only inCourse, whichTabPagerendersonly once
CoursewareContainer's two queries — the course-home metadata and thecourseware metadata — have succeeded, and nothing removes or resets them afterwards
(only
invalidateQueries, which keeps the data). So in the app both are alwaysthere, and the first draft's
[]never fired; it was still a different function —with the course-home metadata missing it hid a widget that ignores
course, whereevery version since feat: decouple notifications panel using widget registry mechanism #1885 showed it. The outline is not in that gate: on the
courseware page
coursehas nosectionIdsorhasScheduledContentuntil theoutline lands (since refactor: tear down the courseware Redux slice #2074; fix: handle a slow or failed outline on the courseware and course-end pages #2139 made only the course-end page wait).
So: no early return;
getAvailableWidgetsbuildscoursewith a localbuildWidgetCourse(coursewareMetadata, minimalCourseMetadata, courseHomeMeta), thespread, and the parameter
isAvailablereceives is a local, unexportedSidebarAvailabilityContextwhosecourseisReturnType<typeof buildWidgetCourse>— the type is the spread's, so it cannot drift from the object:each field optional, and a field both endpoints carry typed as either endpoint's
(in the row with the course-home metadata missing,
course.celebrationsis thecourseware metadata's value,
unknowntoday; it tightens when a layer typesCoursewareMeta.celebrationsfor its reader,CoursewareContainer'scelebrations.firstSection).SidebarWidget.isAvailableandSidebarRegistryEntry.isAvailabletake it. The built-ins type themselves from theslot,
NonNullable<SidebarWidget['isAvailable']>, and drop theirSidebarWidgetContextimport:@edx/typescript-configsetsstrictFunctionTypes: false, so a built-in still annotated with the complete context would compileagainst the looser slot while claiming more than it is handed, losing Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111
decision 9's check. In the diff the annotation moves from the parameter to
the variable —
({ unit }: SidebarWidgetContext) => …becomesdiscussionsIsAvailable: NonNullable<SidebarWidget['isAvailable']> = ({ unit }) => …— so the function declares itself to be the slot's function type(
SidebarWidget['isAvailable'], an indexed access type, is that property'stype;
NonNullabledrops theundefinedits?adds, since the built-in isa function) and its destructured parameter takes the slot's parameter type
by contextual typing; that is also how the built-ins reach the unexported
SidebarAvailabilityContextwithout importing it. The exportedSidebarWidgetContextis not loosened: it gains& Partial<MinimalCourseMetadata>, naming the outline's fields as optional (additive;the shared
idandtitlestaystringfrom the course-home metadata). Rejected: acast of the spread to the declared type (it would state completeness exactly where
it does not hold);
Partial<SidebarWidgetContext>(shallow —course?must still becomplete when present, and
npm run typesstill fails);Partialof the two-typeintersection (fails on
celebrations, which the intersection types as thecourse-home shape); typing
CoursewareMeta.celebrationsnow to make that pass (itrests on the two endpoints agreeing, and its reader converts in layer B). Type-only
for TypeScript plugins typed from the slot; runtime identical to
masterfor everywidget. Tests: when a metadata query has no data in
SidebarContext.test, one caseper missing query, with a widget without
isAvailable, one that checks onlycourseIdand the realupgradeIsAvailable. Negative checks, run: with the earlyreturn restored, both fail; a temporary
const org: string = course.orginupgradeIsAvailablefailsnpm run types. The sidebar README's "Context Object"section had named the parameter
SidebarWidgetContext; it now names it as theparameter of
SidebarWidget['isAvailable'], with everycoursefield optional, sothe plugin-facing doc describes what a widget is handed.
Readers that render before the queries resolve. In production every
converted reader renders behind
TabPage's gate or underCourseExit,so the metadata is there on first render; several suites render the
reader beside
MountCourseQueryHooksinstead, where it is pending, andpassed until now only because
initializeTestStoreseeds the storedirectly. Each site is handled by what it depends on:
GetCourseExitNavigation(course-exit/utils.js) keeps itsdestructure as it was,
entranceExamData: { entranceExamPassed }withno default, and its two callers' suites mount them behind the gate
(settled in review over a first version that added
= {}). It throwswhile the metadata is pending, and a default would have turned that
into
undefined, whichgetCourseExitModereads as not failed(
entranceExamPassed = null, and only=== falsereturnsentranceExamFail): an unknown exam status would enable the nextbutton rather than fail loudly. The sibling in
sequence-navigation/hooks.jshas had that default since refactor: derive the courseware loaded gate and sequence ids from queries #2071(
decisions-1976A3.mdentry 4) and was the first version's precedent,but it is safe there and would not be here: that hook returns early
unless
useIsCourseLoaded, which includes the metadata's success, soits
undefinedis never used for a decision, whileGetCourseExitNavigationpasses the value straight togetCourseExitMode. WithuseModelthe value was neverundefinedeither: the callers,
UnitNavigation'srenderNextButtonandSequenceNavigation, render underSequenceinsideTabPage, whosederiveViewrenders children only once the metadata query hassucceeded; the bridge wrote the store in that query's
onSuccess,before observers re-rendered; and the endpoint always sends
entrance_exam_datawith a booleanentrance_exam_passed. So it was aboolean, and the throw —
TypeError: Cannot read properties of undefined (reading 'entranceExamPassed')inGetCourseExitNavigation,surfacing as the app's error page — was unreachable; the navigation
suites passed on the seeded store. The query read keeps both: under the
gate
datais the successful result on the same render, and outside itthe failure is the same function, line, message and error page (seen in
this layer's first run, 44 times, before the suites were gated).
CourseBreadcrumbsreads the outline once and builds its section listonly when the outline is there; the breadcrumb suite renders it beside
the owner, so it handles the pending state itself (Read units and sequences from the courseware queries, not useModel #2088 layer A's
Unitshape). The outline read destructurescourses,sectionsandsequences, andcourseSectionsis[]withoutcourses. Thatreplaces the
course.sectionIds ?? []guard fix: handle a slow or failed outline on the courseware and course-end pages #2139 added below thislayer for the same pending state (the rebase's one conflict here); its
slot case, inserted breadcrumbs do not error while the outline is
loading, passes on this layer. The
sequences = {}default Read units and sequences from the courseware queries, not useModel #2088 layer B added goes: it covered the storeand the query disagreeing — sections read from the store through
useModels('sections', course.sectionIds), sequences from the query —which production never reaches, since the bridge writes the store in
the query's
onSuccess, but the breadcrumb suite did, rendering withthe seeded store's sections while the outline query was pending. With
all three read from one result, either all are there or
courseSectionsis empty and
sequencesis never read. That holds because a defineddatais always a normalizer result: the query function returnsnormalizeMinimalCourseOutline(data)(api.js), which starts from{ courses: {}, sections: {}, sequences: {} }, only adds entries, andreturns that object, so all three maps are present even for an empty
course; and nothing else writes the outline's cache entry (the only
other
coursewareQueryKeys.outline(...)uses insrcare two testassertions on its status). The assumption the component does make is
the neighbouring one, that
courses[courseId]exists: a routecourseIdthat differed from the outline'scourse_keywould throw on.sectionIds. The old read made the same one —useModel('coursewareMeta', courseId)would have returned{}, anduseModels('sections', undefined)threw — so this layer neither adds nor removes it. Thetable's
sequences[id]line stays as it was (settled in review over aversion that reached through the whole outline object at each use).
Coursedepends on its parent's contract, so the suites mount it behindthe gate rather than teach it to render empty.
LoadedCoursecame inwith test: await every waitFor and act in Course.test and Sequence.test #2120, in
Course.test.jsxonly:Coursereadscelebrationsfrom the course-home metadata in the weekly-goal modal's
useStateinitializer, so with that query pending on first render the
celebration modals could never open.
setupDiscussionSidebarincourseware/course/test-utils.jsxrenderedCoursewith no gate anddid not need one — its cases do not depend on
celebrations, and thecourseware metadata came from the seeded store, present on first
render. This layer makes
Coursedereference the metadata query onfirst render (
coursewareMetadata.enrollmentModeforLearnerToolsSlot, andcourse.notes.enabledinsideContentTools),so every render before it resolves throws, the helper's included (the
sixteen
enrollmentModeerrors of the first run).LoadedCoursetherefore moves into
test-utils.jsx, where both render paths use it,and gates on what
deriveViewwaits for: the course-home metadata andthe courseware metadata. The test: await every waitFor and act in Course.test and Sequence.test #2120 version gated on the course-home
query alone, through a fetching
useCourseHomeMeta(courseId); theshared one reads every query with
{ enabled: false }, asLoadedTabPagedoes, so the gate adds no request.Courseis meant torequire the metadata, as its parent's gate provides it; the one guarded
read,
coursewareMetadata?.courseGoals, ismaster'scourse?.courseGoalsrenamed, kept rather than tightened for thefaithful diff.
src/tests/CourseQueryGate.tsx(settled in review overa fixed two-query version). It renders its children only while the
course's queries — course-home metadata, courseware metadata,
learning-sequences outline, sequence metadata — are in the states a
test names, each
'success'or'pending', defaulting to the twoTabPagewaits for having succeeded;LoadedCourseisCourseinsideit, and the two navigation suites'
renderNavrender their componentinside it. The test puts each query in its state through the mocks; the
gate keeps the component from rendering outside that state. When open
it renders a hidden
data-testid="course-query-gate-open"marker, forcases whose component renders nothing.
SequenceNavigation.test's is empty while loading keeps testingthe component's branch. It pins
SequenceNavigation'ssequenceQuery.isSuccess ? … : null. Behind a plain gate it would passwhatever the component did — nothing mounts on the render it asserts
on — and bare it would pass because the component throws. It now
passes
preventSequenceLoadtoinitializeTestStore, a new optionbeside
preventOutlineSidebarLoadthat holds the sequence requestpending (paired with
excludeFetchSequence, whose seeding would waiton it), renders behind the gate with
{ sequence: 'pending' }, andwaits for the marker before asserting. It also renders its own store's
sequence: it had rendered
mockData.sequenceIdfrom thebeforeEachstore, whose request its second
initializeTestStoreno longer mocks,so
logUnhandledRequestsanswered200 {}and the sequence queryerrored — on
masterthe case had passed with a failed sequence, nota loading one. Negative check, run: with the pending branch rendering
the navigation container instead of
null, the case fails.UnitNavigation.test's renders correctly without units awaits itstwo controls (
findAllByRole) now that the component mounts once thegate opens; since fix: handle a slow or failed outline on the courseware and course-end pages #2139 below this layer they are disabled buttons, not
links, so it awaits
buttonand keeps fix: handle a slow or failed outline on the courseware and course-end pages #2139's disabled assertions.useSequenceIdsreads the course's sections through aselect;CertificateStatusstays as the plan had it.useSequenceIdsreads the course's sections through a newuseCourseSections(courseId), a disabled observer of the outline query(Stop the courseware gate queries refetching from components under the gate #2098, decision 4: it is non-fetching by design) whose
selectmaps thecourse entry's
sectionIdsover itssections— the lookupuseModels('sections', sectionIds)did, now in theselect, whose resultstructural sharing keeps stable as
useModels'shallowEqualdid. Theoutline's options move into a
minimalCourseOutlineQuery(courseId)queryOptionsfactory thatuseMinimalCourseOutlineanduseCourseSectionsboth spread, so every observer of the key carries thebridge
meta(sequenceMetadataQuery/useUnit's shape); itsqueryFndeclares
Promise<MinimalCourseOutline>, the type the hook'suseQuerygeneric used to state, since
getLearningSequencesOutlineis JavaScript.The
useIsCourseLoadedgate stays outside the memo, as it was:sectionsis
undefineduntil the course is loaded — stable across renders, unlike afresh
[]— and the memo issections?.flatMap(…) ?? []on[sections].The hook is called on every render and the gate applied to its result
(
isCourseLoaded ? useCourseSections(…).data : undefinedwould call itconditionally;
react-hooks/rules-of-hooksis off in.eslintrc.js, solint does not catch that).
useIsCourseLoadedimplies the outline hassucceeded, so the course entry is read without a fallback.
CertificateStatuskeeps{ enabled: false }: nothing on the progress tab fetches the coursewaremetadata, so its
entranceExamDataisundefinedthere, andentranceExamData?.entranceExamPassed ?? nullisnull, as it was with{}.Suite mocks follow the reads.
UnitSuspense.test,useShouldDisplayHonorCode.testandUpgradePanel.testmockuseCoursewareMetadata({ data }) where they mockeduseModel, andtheir "reads … the courseware metadata" cases assert
(courseId, { enabled: false }).SidebarContext.test,UpgradeTrigger.testandUpgradeWidgetContext.testrender the providerwith no
QueryClientProviderand mock each query hook it calls, so theyadd
useCoursewareMetadata({ data: {} }, the{}theiruseModelmockreturned) and
useMinimalCourseOutline({ data: undefined }).Sidebar.testandSidebarTriggers.testrender the real provider undersetupTest'sQueryClientProviderwith no course data and mock no queryhook: the disabled observers return
undefined, which the spread in entry4 treats as
{}. (This layer's first draft mocked the two gated hooksthere with
{ data: {} }, for the early return entry 4 removed; the mocksoutlived it until review.) The
model-storemocks left with nothing tomock go from those five suites,
UpgradePanel.testandDiscussionsProvider.test.LockPaywall's analytics case sets up its own mocks. It renderedagainst the store seeded in
beforeAlland passed whileLockPaywallread
offerfrom that store. Reading the query instead, it fetchedagainst the axios mocks the previous case registered — a course metadata
response with an
offer— and rendered the discounted link. The case nowcalls
initializeTestStore({}, false)and renders with that store, theform its neighbouring cases use.
The integrity-signature writer suite asserts on the cache. It seeds a
minimal entry at
coursewareQueryKeys.metadata(courseId),{ userNeedsIntegritySignature: true }— the hook touches only thatfield, Read units and sequences from the courseware queries, not useModel #2088 layer A's bookmark-suite precedent — and reads it back with
getQueryData; the suite drops its store. A fourth case, writes nothingfor a course with no cache entry, takes the sibling writers' name and
their flush after the request.
Request counts cover both queries on both owner pages. Stop the courseware gate queries refetching from components under the gate #2098's cases
counted only the course-home metadata on
CoursewareContainerandCourseExit. Each page gains requests the courseware metadata once perload and requests the learning-sequences outline once per load;
CourseExit.test's three count cases share aloadCelebrationPagehelper, the setup its one case had inline. Negative check, run: with
CourseCelebration's outline read andSequence's metadata read flippedto fetching, exactly two cases fail — the course-end page's outline count
and the courseware page's metadata count.
Commit:
refactor!:with aBREAKING CHANGE:footer. The bridgestill writes both models in this layer, so
useModel('coursewareMeta', …)and
useModel('sections', …)keep returning data until layer B; whatchanges for plugins is that saving the integrity signature no longer sets
userNeedsIntegritySignature: falseon the store copy, so a pluginreading it through
useModelseestrueuntil the next metadata fetch.And
titleleavesuseCoursewareMetadata(...).data, while the storecopy's
titleis always the outline's. Learner-visible: entry 1's title.Codecov after submit: two behaviour tests for code the layer stopped
reaching indirectly. The project check fell 0.07% with no uncovered
patch line.
generic/model-store/hooks.js'suseModelslost its lastin-repo callers (
useSequenceIds,CourseBreadcrumbs) — the model storehas no suite of its own, so it had been covered only through them; it stays
exported (plugins can reach
@src/generic/model-store) until Dissolve the model-store normalized cache #1977 removesthe store, and gains
model-store/hooks.test.tsx: each id's model in orderwith
{}for a missing id, and{}for every id of a model type not in thestore.
getCourseExitMode'scanImmediatelyViewCertificate = falsedefaulthad been hit only by navigation suites that rendered before the course-home
query had data; rendering through
CourseQueryGate, as the app does, everycall passes a defined value.
course-exit/utils.test.tspins the default:omitted, a not-passing learner with no certificate data gets
celebration;passed
true,nonPassing. The exit-page flag is passed asundefinedthere:
utils.jsis JavaScript, so its parameter types come from thedefaults, and
courseExitPageIsActive = nulltypes that parameternull.Plan: sidebar widget availability
#2140: make
getAvailableWidgetsask every widget againContext
#2140 (
bsmith/coursewaremeta-query-reads) switchesSidebarProvider(
src/courseware/course/sidebar/SidebarContext.tsx) fromuseModel('coursewareMeta')tothe typed
useCoursewareMetadata(...).data, and added:It added the guard only to satisfy
npm run types, and it changes what the functiondoes: with either metadata query missing, no widget is asked. We want a faithful port
instead.
What
getAvailableWidgets/isAvailableare, and who uses themWidgets:
Course.jsxpassesgetEnabledWidgets()(sidebar/defaultWidgets.js).That's the two built-ins (discussions, upgrade) plus every operator widget in
getConfig().SIDEBAR_WIDGETS, usually fromenv.config.jsx, which is plainJavaScript.
getAvailableWidgetsfilters them: a widget with noisAvailableis alwaysavailable; otherwise it calls
isAvailable({ courseId, unitId, course, unit }).Its callers (
sidebar/hooks/):useInitialSidebaruses it for the stored preference and the priority cascade.useUnitShiftBehavioruses it on each unit change. Its CASE 2 keeps a panel openprovisionally because "data might still be loading".
availableSidebarIdsrenders the triggers.All of them re-run when its inputs change. The framework is built for availability
that changes as data arrives.
The documented contract (
sidebar/README.md):query resolves the framework re-evaluates availability".
course?.someField.courseId.requires."
History
isAvailablereceivedtopic.id && enabledInContext; notificationsverifiedModefromuseModel('courseHomeMeta')isAvailable; built-ins: discussions readsunit, upgrade readscourse.verifiedMode(course-home field)course: { ...coursewareMeta, ...courseHomeMeta }, bothuseModel{}→ every widget still askedf34f95e5, #2068courseHomeMetafrom the query (undefinedwhen missing)undefinedadds nothing → unchangedisAvailable?: (ctx: SidebarWidgetContext) => boolean,course: CourseHomeMeta & CoursewareMeta(#2111 decision 6: "notPartial: the provider renders under the gate")coursewareMetastillany, so the declaration was never checkedunittyped, then from the querycoursewareMetadatatyped → the declaration is checked for the first time → guard addedTwo things run through every row.
isAvailablewas always handed whatever metadata wasloaded, and each widget decided for itself. And the outline's fields (
title,sectionIds,hasScheduledContent) reachedcoursethrough the store's merge:minimalCourseMetadata.Reachability
SidebarProviderrenders only inCourse.jsx, under TabPage's gate on both metadataqueries, in every era.
invalidateQueriestouches them(
useIFrameBehavior.ts), and it keeps the data.reached only by something rendering
SidebarProviderdirectly (tests today).course(README Example 1) would be hidden in those rows, where feat: decouple notifications panel using widget registry mechanism #1885 → master showed it.
Approach
Remove the early return, so the runtime matches feat: decouple notifications panel using widget registry mechanism #1885 → master exactly. Every
widget, built-in or from
SIDEBAR_WIDGETS, is asked with whatever is loaded:Spreading
undefinedadds nothing, as{}did.Type: loosen only the parameter
isAvailablereceives; leave the exportedSidebarWidgetContextunchanged.Add a local, unexported named type in
SidebarContext.tsxfor whatisAvailableis handed. Its
courseis the type of the spread itself, derived from a localfunction the provider also calls, so the type and the object can't drift apart:
Each field is optional, and a field both endpoints carry is typed as either
endpoint's.
Partialof the intersection fails oncelebrations: the intersectiontypes it as the course-home shape, but with the course-home metadata missing the
value is the courseware metadata's, which is
unknown. A shallowPartial<SidebarWidgetContext>fails too (course?must still be complete). Bothwere tried.
The exported
SidebarWidgetContext.coursegains& Partial<MinimalCourseMetadata>,naming the outline's fields as optional. This is additive, and the shared
idandtitlestaystring.SidebarWidget.isAvailableandSidebarRegistryEntry.isAvailable(
SidebarContext.tsx:45) take(context: SidebarAvailabilityContext) => boolean.The two built-ins type themselves from the slot they fill, through the exported
SidebarWidget, and drop theirSidebarWidgetContextimport:discussionsIsAvailable(widgets/discussions/widgetConfig.ts) becomesdiscussionsIsAvailable: NonNullable<SidebarWidget['isAvailable']> = ({ unit }) => …;upgradeIsAvailable(widgets/upgrade/src/utils.ts) the same way.This matters because
@edx/typescript-configsetsstrictFunctionTypes: false(bivariant parameters). Built-ins still annotated
(ctx: SidebarWidgetContext)would compile against the looser slot while claiming a complete
course. Takingthe slot's type keeps Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111 decision 9's check: a built-in's own function is typed
against what it's handed.
Otherwise unchanged:
SidebarWidgetContextstays exported, with only theadditive outline fields, for TypeScript plugins that import it. It's now the base of
the local type and has no in-repo importers. A TypeScript plugin whose
isAvailableis annotated(ctx: SidebarWidgetContext)still compiles, because of the same bivariance.JavaScript
env.config.jsxwidgets never saw the type.This departs from Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111 decision 6 ("not
Partial: the provider renders underthe gate"). That argument describes the page. The contract
isAvailabledocuments(README line 23: "the first availability check runs before the data arrives")
describes the function, and the parameter type follows the function.
Tests in
SidebarContext.test, rendering the real provider with one metadataquery left empty:
isAvailable, and anoperator-style widget checking only
courseId, are still available. Upgrade isn't,because
verifiedModeis a course-home field.included, since no built-in reads the courseware metadata.
Negative check: with the guard restored, both cases fail.
Decision doc: rewrite decisions-2089A entry 4 with:
isAvailable's parameter loosens (the local type, the built-ins typedfrom the slot,
strictFunctionTypes: false);Add a type-only line to refactor!: read sections and coursewareMeta from the courseware queries, not useModel #2140's operators-breaking notes: in a TypeScript plugin
typed from
SidebarWidget['isAvailable'],course's fields become optional. Re-syncrefactor!: read sections and coursewareMeta from the courseware queries, not useModel #2140's PR body later.
Out of scope, to raise separately: operator widgets whose
isAvailablereads theoutline's fields have seen them missing on the courseware page while the outline is
pending since #2074. In the thunk era they were always present. They're re-evaluated
when the outline lands. This is #2138's family, but the sidebar isn't covered by #2139.
Verification
nvm use && npm run types,npm run lint, andnpm run test(the full suite).SidebarContext.testcases pass, and fail with the guard restored.upgradeIsAvailable, a temporaryconst org: string = course.orgfailsnpm run types(string | undefined), whichconfirms the slot's partial
coursereaches the built-ins.(upgrade for a learner with
verifiedMode, discussions on a unit with a topic).Manual testing
Checklist
Manual testing — read sections and coursewareMeta from the courseware queries, not
useModel(#2089, layer A)In-browser verification against a live backend (tutor local).
What changed: the readers of the
coursewareMetaandsectionsmodels read thequery that owns each field. That's the courseware metadata query for the metadata,
and the learning-sequences outline query for the course entry (
title,sectionIds,hasScheduledContent) and its sections. The integrity-signaturewriter patches the cached metadata, and the course title comes from the outline
everywhere. The sidebar asks every widget with whatever is loaded, and
useSequenceIdsreads throughuseCourseSections. No request change intended.The bugs this layer could introduce.
is
undefined: a missing crumb, a wrong course-end mode, a paywall or upgradepanel without its dates or offer, social icons missing.
from the wrong place, or show the metadata's HTML-escaped name.
Next, is wrong if
useSequenceIdsreturns the wrong ids.agreeing, or comes back.
Setup
units, and the course exit page active.
&, e.g. "R&D Demo".Change it in Studio's Advanced settings, Course display name, and publish.
env.config.jsxinsertingCourseBreadcrumbsinto the breadcrumbs slot, as forfix: handle a slow or failed outline on the courseware and course-end pages #2139.
courseware/course|course_outline|course_metadata,Console open.
Checks
Request count (unchanged)
api/courseware/course/{id}1,learning_sequences/v1/course_outline/{id}1,course_home/course_metadata/{id}1. Navigating within and across sequences sends no new ones.Title source (#2137)
&and not&.Readers
/course/{courseId}/course-endwhen the exit page is active.master: celebration, not passing, in progress, or the redirect fordisabled.master.Optional readers (where the instance supports them)
🤖 Generated with Claude Code