refactor!: read the redirects and container from the courseware queries, not the model store - #2144
Open
brian-smith-tcril wants to merge 1 commit into
Conversation
…es, not the model store `CoursewareContainer` reads `celebrations` from its own courseware metadata query, and `useCoursewareRedirects` reads the sections and the first section from the outline query it already holds, in place of the four `useSelector` reads of `state.models` through `modelReader.ts`, which is deleted. With no reader left, the metadata and outline queries stop mirroring `coursewareMeta` and `sections` into the model store. `CoursewareMeta.celebrations` names the one field read, `firstSection`. No behaviour or request change: the store entries were these queries' results, written by the bridge in the query cache's `onSuccess` before observers re-rendered, so every render saw what the query results now provide directly. The redirects' `shallowEqual` / `defaultMemoize` guards stay for #2135. The bridge suite in `apiHooks.test.tsx`, which pinned the merge of the two mirrors, is replaced by a `useSequenceIds` suite covering what it also tested: empty until the course is loaded, then the sequences in section order, and no request of its own. Closes #2089. Part of #1946 (layer D4 of #1977). BREAKING CHANGE: nothing writes the `coursewareMeta` or `sections` models into the model store any more, so `useModel('coursewareMeta', courseId)` and `useModel('sections', sectionId)` return `{}`, and a direct `state.models.coursewareMeta[courseId]` read is `undefined`. Read the courseware metadata from `useCoursewareMetadata(courseId, { enabled: false }).data`, and the outline's course entry (`id`, `title`, `sectionIds`, `hasScheduledContent`) and `sections` from `useMinimalCourseOutline(courseId, { enabled: false }).data`. The `org.openedx.frontend.learning.upgrade_panel.v1` slot keeps passing `model: 'coursewareMeta'`; a plugin that read the store through that prop switches to the query — see the PR description for the migration. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
brian-smith-tcril
added this pull request to stack #2141
September 30, 2026 10:41
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## bsmith/coursewaremeta-query-reads #2144 +/- ##
=====================================================================
- Coverage 95.10% 95.09% -0.01%
=====================================================================
Files 373 372 -1
Lines 6087 6078 -9
Branches 1455 1505 +50
=====================================================================
- Hits 5789 5780 -9
Misses 286 286
Partials 12 12 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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 last two readers of the model store in
src/courseware/read the queries they already hold:CoursewareContainertakescelebrations.firstSectionfrom its courseware metadata query, anduseCoursewareRedirectstakes the section-by-sequence-id lookup, the course entry'ssectionIdsand the first section from the outline query.modelReader.tsgoes with them, and with no reader left the metadata and outline queries stop mirroringcoursewareMetaandsectionsinto the store.CoursewareMeta.celebrationsnames the one field read. No behaviour or request change: the store entries were these queries' results, written by the bridge in the query cache'sonSuccessbefore observers re-rendered. Breaking for operators — see below. Part of the Redux → React Query migration (#1946, Stage 1); layer D4 of the model-store dissolution (#1977), #2089's layer B, on top of #2140 in stack #2141. Closes #2089.What changed
CoursewareContainer(decision 3):celebrateFirstSectioniscoursewareMetadataQuery.data?.celebrations.firstSection, read insidehandleNextSequenceClick; the query local is renamed frommetadataQuerybecauseTabPagetakes the course-home query in itsmetadataQueryslot.celebrationsis typed{ firstSection: boolean }— not nullable, since openedx-platform'sget_celebrations_dictreturns a dict on both paths — and thecourseMetadatafactory sends the platform's not-enrolled dict instead ofnull.useCoursewareRedirects(decision 4):sectionViaSequenceId, the course entry (minimalCourseMetadata) andfirstSectionreadoutlineQuery.data, each keeping its ternary and?? nullso the rule arguments and memo keys are unchanged. TheshallowEqual/defaultMemoizeguards stay for Make the courseware redirects declarative and drop their fire-once guards #2135 (decision 2).useCoursewareMetadataloses itsmeta;minimalCourseOutlineQuerykeeps onlylogStatusAs;modelReader.tsis deleted;useSelectorandRootStateleavesrc/courseware/. The bridge keeps both of its forms until the teardown.apiHooks.test.tsxbecomes auseSequenceIdssuite (empty until the course is loaded, then the sequences in section order; no request of its own).CoursewareContainer.testgains does not resume-redirect from the course root and does not redirect a section URL while the outline is pending, each failing when its rule'sisCourseLoadedgate is removed.Operators — breaking
coursewareMetaorsectionsmodels into the model store any more.useModel('coursewareMeta', courseId)anduseModel('sections', sectionId)return{}, and a directstate.models.coursewareMeta[courseId]read isundefined. Read the courseware metadata fromuseCoursewareMetadata(courseId, { enabled: false }).data(./src/courseware/data/apiHooks), and the outline's course entry (id,title,sectionIds,hasScheduledContent) andsectionsfromuseMinimalCourseOutline(courseId, { enabled: false }).data.org.openedx.frontend.learning.upgrade_panel.v1slot keeps passingmodel: 'coursewareMeta'(decision 1). A plugin that read the store through that prop switches to the query; the migration is in the decision log's "For plugin authors" section below.Testing
npm run typesandnpm run lintclean; full suite 120 suites, 1216 passed, 0 skipped. Negative checks run:celebrationsleftunknownfails types at the container's guard;sectionViaSequenceIdread fromsequencesfails types at both rule calls and one container case; each new pending-outline case fails alone when its rule's gate is removed. Manual checks on tutor local, 8 of 9 run, all passing (the first-section celebration not run); the outline-failure redirect lands ~7 s after the page renders, because a 5xx is retried and a retrying outline is a pending one (#2138) — accepted, with #2145 asking whether the page needs the request at all. See the checklist.Decisions
Full decision log
Decisions — read the courseware models' direct selectors from the queries and drop the courseware mirrors (#2089, layer B)
Layer D4 of the #1977 model-store dissolution, on top of #2089's layer A
(D3, PR #2140). Closes #2089. Entries 1 and 2 were settled in the #2089 plan
review (2026-09-28); entries 3–8 were planned in
plan-2089B.mdandconfirmed by the implementation (2026-09-30).
The slots'
modelprop stays; removing the mirror it points at is aBREAKING CHANGE:, not a deprecation. fix: pass extra prop to plugin slot #1494 (SONIC-717, 2024-10) addedmodelto the three upgrade-messaging slots, "passing model as a prop toplugin slot for dynamic model selection":
'outline'on the outline tab'sslot and
'coursewareMeta'on the two courseware notification trays. Eachvalue names the model-store entry the slot's parent read its upgrade data
from, so one plugin component can be put in every slot and read the
right entry through
useModel(model, courseId). The slots' defaultcontent,
UpgradeNotificationin all three, received the mergedpluginProps(FPF merges them into default children) but never readmodel; it used onlycourseId. feat: removes Upgrade Notification as default content #1675 then removed that default content,and feat: decouple notifications panel using widget registry mechanism #1885 removed the new-sidebar slot
(
org.openedx.frontend.learning.notification_widget.v1) outright. Twoslots pass
modeltoday:org.openedx.frontend.learning.upgrade_panel.v1(
'coursewareMeta') andorg.openedx.frontend.learning.course_outline_tab_notifications.v1(
'outline'). The plugin fix: pass extra prop to plugin slot #1494 was written for is not public, and neitherthe ticket nor the PR describes it.
FPF ADR 0003 (Plugin Slot Naming and Life Cycle, decision 2) defines a
slot's API contract as its location, the type of content it wraps, and
"the specific set of
pluginPropsit exposes"; only a change to one ofthose calls for a version bump and a deprecation of the old version
(decision 3). This layer changes none of them: both slots keep passing
modelwith the same value. What changes is what the value reaches —after this layer nothing writes
coursewareMetainto the model store, souseModel('coursewareMeta', courseId)returns{}. That is a pluginreaching into this app's internals, not the slot's contract, which is the
footing every earlier layer's
useModelchange was released on. Removingmodelwould change the set ofpluginPropsand is not done here. Theoutlineentry keeps being written until Source the access-expiration masquerade banner from the tab query, not useModel(tab) #1999, which drops its mirror.The commit carries the
BREAKING CHANGE:footer, and the PR descriptiongives plugin authors the migration below.
The redirects' fire-once guards stay as they are here; replacing them is
its own layer (Make the courseware redirects declarative and drop their fire-once guards #2135). This layer removes the store-coupled imports from
src/courseware/(useSelectorfromredirects.tsandCoursewareContainer.tsx,useDispatchfromapiHooks.ts), which iswhat the issue's check is for. Left behind are
defaultMemoizefromreselectandshallowEqualfromreact-redux, the guards that keep eachredirect rule from re-firing on every render. The end state of the
migration has no Redux-ecosystem dependency at all, so these go too, but
not by writing local copies: the plan review found the guards are
dependency arrays by another name (
CoursewareContainerdoes not remountbetween courseware routes, and
navigatein every rule's argumentschanges on every navigation and mount), and the tentative replacement is a
declarative resolver rendered as
<Navigate replace>with the two lookupsas queries. That depends on the redirect cases rather than on the model
store, and it works on the inputs this layer settles, so it is stacked
above this one. The issue's check becomes "no
useSelector/useDispatchinsrc/courseware/". (Layer A already tookuseDispatchout of
apiHooks.ts; the fouruseSelectorreads are this layer's.)CoursewareContainerreadscelebrationsfrom its own metadata query,and
CoursewareMeta.celebrationsnames the one field it reads. Thecontainer already held the query as
metadataQuery, renamedcoursewareMetadataQueryin review:TabPagetakes the course-homequery in its
metadataQueryslot and this one astabDataQuery, so theold name read as the wrong query on the
courseStatusline. The read isinside
handleNextSequenceClick, the one place the data is used:const celebrateFirstSection = coursewareMetadataQuery.data?.celebrations?.firstSection;(settled in review over a first version that kept a component-level
coursewareMetadata = coursewareMetadataQuery.datalocal and the oldcourse && course.celebrations && course.celebrations.firstSectionchainwith the name swapped — a local for one read, in the pre-optional-chaining
idiom). The name matches the same guard in
Course.jsx. TheuseSelectorgave
nullfor a missing entry anddataisundefined; theifteststruthiness as the chain did, and the celebration is only recorded on a Next
click, which fix: handle a slow or failed outline on the courseware and course-end pages #2139 disables until the outline has loaded, well after the
metadata.
celebrationsgoes fromunknownto{ firstSection: boolean }on
CoursewareMetaand{ first_section: boolean }onCoursewareMetadataResponse, following the file's rule of naming only thefields this repo's readers use; the other celebration fields are read from
the course-home metadata's
celebrations, typed incourse-home/data/apiHooks.ts, so they are not named here. Notnullable (settled in review over a first version with
| null): Type the courseware normalizers on the functions that produce them (convert courseware/data/utils.js to TypeScript) #2129'srule is that nullability comes from the platform, not from fixtures, and in
openedx-platform
get_celebrations_dict(
openedx/core/djangoapps/courseware_api/utils.py) returns a dict on bothof its paths — the not-enrolled defaults or the computed values — and the
serializer declares
celebrations = serializers.DictField(). Thenullinthe first version came from the
courseMetadatafactory'scelebrations: null, the fixture typing Type the courseware normalizers on the functions that produce them (convert courseware/data/utils.js to TypeScript) #2129 entry 6 rejected. So theread is
coursewareMetadataQuery.data?.celebrations.firstSection, and thefactory now sends the platform's not-enrolled dict (
first_section: false,streak_length_to_celebrate: null,streak_discount_enabled: false,weekly_goal: false);CoursewareContainer.test's celebration case keepsits
{ first_section: true }override. Negative check, run: withcelebrationsleftunknown,npm run typesfails at the container'sguard (
Property 'firstSection' does not exist on type '{}').useCoursewareRedirectsreads the sections and the first section idfrom the outline query it already holds.
outlineQueryis the hook'sfetching observer of
useMinimalCourseOutline(courseId). The threeselectors became reads of
outlineQuery.data, each keeping its ternaryand its
?? null, so the rule arguments keep thenulltheir typesdeclare (
section: { sequenceIds?: string[] } | null) and the memo keyssee the same values.
sectionViaSequenceIdis still a section lookedup by the route's sequence id — how a section-shaped URL is detected; the
name carries that, so the line has no comment, as the
useSelectoritreplaces had none. The course entry is
minimalCourseMetadata,after its type (
courseheld the merged store entry);firstSectionIdand
firstSequenceIdare unchanged. The argument types are not touched;they are Make the courseware redirects declarative and drop their fire-once guards #2135's inputs. Behaviour is the same because the store entries
were the query's results: the bridge wrote
sectionsand the course entryfrom this same
queryFn's result in theQueryCache'sonSuccess, beforeobservers re-rendered, so on every render the store held exactly what
outlineQuery.dataholds now, or nothing while it was pending — whenisCourseLoadedis false and every rule that reads a section returnsearly. Negative check, run: with
sectionViaSequenceIdread fromsequencesinstead ofsections,npm run typesfails at both rulecalls that take it (
MinimalSequenceMetadatahas nosequenceIds) andCoursewareContainer.test's should choose a unit within the section'sfirst sequence fails.
The mirrors and
modelReader.tsgo.useCoursewareMetadatalost itsmetaentirely;minimalCourseOutlineQuerykeepsmeta: { logStatusAs: { 403: 'info' } }.courseware/data/modelReader.tsis deleted with its
CoursewareModelstype;readModels,useSelectorand the
RootStateimport leftCoursewareContainer.tsxandredirects.ts. Nothing insrc/courseware/imports@src/storeorreact-reduxnow exceptredirects.ts'shallowEqual(entry 2).The bridge keeps both forms until the teardown. After entry 5 no query
carries
meta.models, so the list-form branch ofbridgeToModelStore(
models?.forEach,ModelMirror,MirrorStrategy,ModelStoreMeta.models)has no producer left; the single form still serves the course-home
dates,outlineandprogressqueries until Source the access-expiration masquerade banner from the tab query, not useModel(tab) #1999. Removing the listform here was weighed and not taken: the teardown (Dissolve the model-store normalized cache #1977 layer F) deletes
modelStoreBridge.ts, its test and theonSuccess/storewiring inqueryClient.tswholesale, and that deletion is the same size whether thefile holds one form or two, so a trim now would be a diff in two files this
layer otherwise does not touch, for no saving later. This also matches
refactor!: read discussion topics from the query, not useModel #2122, refactor!: read units from the sequence query, not useModel #2128 and refactor!: read sequences from the courseware queries, not useModel #2134, which left the strategies they orphaned in place.
modelStoreBridge.test.tskeeps its list-form cases; they test code thatis still shipped, just unused.
The bridge suite in
apiHooks.test.tsxis replaced by auseSequenceIdssuite. "courseware apiHooks — coursewareMeta bridge" pinned the merge of
the two mirrors entry 5 removes (layer A's decision 1 had already moved its
evidence field to
language). Both its cases also testeduseSequenceIds,and that stays as "courseware apiHooks — useSequenceIds": is empty until
the course is loaded, then lists the sequences in section order (the same
outline-first, metadata-last setup, waiting on the outline query's status
instead of the store, with the store assertions dropped) and fetches
nothing on its own. The suite takes the file's per-suite
makeWrappershape over the bridge suite's
AppProvider+ store wrapper, soAppProviderleaves the imports;initializeStorestays for the suitesthat still seed a store.
Coverage: the redirect destinations are tested end to end; the one
timing property this layer's reads take part in gains two cases; the
other ways a store read and a query read could differ are left to
reasoning, for the reasons given.
CoursewareContainer.test.jsxdrivessix of the seven rules through real fetches to their destinations (resume,
section → first sequence, empty section → course root,
/firstand/last, sequence → unit, the course with no sections) and covers entry 3'sguard; the unit-as-sequence path (a unit id in the sequence slot, a 422,
then
getSequenceForUnitDeprecated) is covered only at the rule level inredirects.test.ts, withsection: nullpassed by hand — as before thislayer. Neither suite reads the store, so the store staying empty changes
nothing for them;
redirects.test.tspasses arguments directly.Where a store read and a query read could differ, and what covers each:
query cache's
onSuccessand react-redux notifieduseSelectoron itsown schedule, so a render could see the outline in the store while
isCourseLoadedwas still false; nowdataandisSuccesscome from onequery result. The rules were and are gated on
isCourseLoaded, and thatgate is the one property this layer's reads take part in, so it gains
two cases in the suite's "while the learning-sequences outline is
pending" block: does not resume-redirect from the course root (the
resume endpoint mocked to name a unit, so a rule that ran would navigate;
asserts the URL and that the resume request was never made) and does not
redirect a section URL (asserts the URL and that no block lookup was
requested). Negative checks, run: with
isCourseLoaded &&removed fromresumeRedirect's condition the first fails alone; removed fromunitToSequenceUnitRedirect's, the second fails alone.sections?.[id]needed its?.becausestate.models.sectionsdid not exist until the outline's mirror wroteit. A defined outline result always carries all three maps
(
normalizeMinimalCourseOutlinestarts from the three empty maps), sothat state cannot occur; the
?? nullthat keeps the rules'nullisenforced by
npm run types, sinceundefinedis not assignable to theargument types. Nothing to test.
query entry can be removed, reset or garbage-collected. Nothing in
srccalls
removeQueries,resetQueriesorclear; both owners are fetchingobservers, so garbage collection never applies while they are mounted;
invalidateQuerieskeeps data; and on a refetch error TanStack keeps thelast data, as the store did. A test would have to invent a cache
operation the app does not perform.
every write, so a refetch re-fired the section rules through
shallowEqualeven when nothing changed; structural sharing keepssections[id]referentially stable when its content is unchanged, so thiscan only fire less. Not tested before either, and Make the courseware redirects declarative and drop their fire-once guards #2135 replaces the
firing mechanism, so a count assertion here would pin behaviour that
layer removes.
firstSection, whilerecordFirstSectionCelebrationpatches thecourse-home metadata's copy, so a second section crossing re-records the
localStorage marker and the course-home read then declines the modal.
Pre-existing: the store copy was never patched either. Not this layer's
to test or change.
Full suite on this layer: 120 suites, 1216 tests (layer A's 1214 plus the
two cases). Left for the teardown:
seedCoursewareModelsandgetTestStoreIdsinsetupTest.js;CourseExit.test.jsx'sfetchAndRender, which still dispatchescoursewareMetaandcourseHomeMetainto a store nothing underCourseExitreads since layerA and refactor!: read courseHomeMeta from the query in the courseware, course-end pages and widgets #2110;
Course.test.jsx's page-title case, which reads its expectedcourse.titlefrom the seeded store. Sogit grep "modelType: 'coursewareMeta'\|modelType: 'sections'" srcreturnssetupTest.js,CourseExit.test.jsx,modelStoreBridge.test.tsandgeneric/model-store/hooks.test.tsx, all test seeding or bridge cases.For plugin authors
#1494 added a
modelprop to the upgrade-messaging plugin slots, "passingmodel as a prop to plugin slot for dynamic model selection". It names the
model-store entry holding the course data for the page the slot is on. Two
slots still pass it:
modelorg.openedx.frontend.learning.upgrade_panel.v1(aliasesnotification_tray.v1,notification_tray_slot)'coursewareMeta'org.openedx.frontend.learning.course_outline_tab_notifications.v1(aliasoutline_tab_notifications_slot)'outline'Both slots keep passing the same values. After this PR nothing writes
coursewareMetainto the model store, souseModel('coursewareMeta', courseId)returns{}. Theoutlineentry keeps being written until#1999. Both queries exist today, so a plugin can switch both reads now.
A plugin that picked its model with the prop:
reads the query that owns that model instead:
Manual testing
Checklist
Manual testing — read the redirects and container from the courseware queries, not the model store (#2089, layer B)
In-browser verification against a live backend (tutor local).
What changed:
useCoursewareRedirectsreads the sections and the first sectionfrom the outline query instead of the model store,
CoursewareContainerreads thefirst-section celebration flag from the courseware metadata query, and the two
queries stop mirroring into the store. No request change intended.
The bugs this layer could introduce.
sequence slot, or the course root landing somewhere else, or not redirecting.
before the outline lands, to the wrong place or in a loop.
change.
Setup
units, and the course exit page active.
courseware/course|course_outline|course_metadata|resume|blocks,Console open.
sleepatthe top of
getLearningSequencesOutlineinsrc/courseware/data/api.js, as forThe courseware and course-end pages render before the learning-sequences outline has loaded #2138; remove it afterwards.
throwin the same place), as for fix: handle a slow or failed outline on the courseware and course-end pages #2139.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.Redirect rules
/course/{courseId}: lands on the resume unit (oneapi/courseware/resume/{id}request), or the first sequence's unit for a learner with no resume block./course/{courseId}/{sectionId}: lands on the section's first sequence and its active unit./course/{courseId}/{sectionId}/{unitId}: lands on that unit under its sequence.http://apps.local.openedx.io:2000/learning/course/course-v1:OpenedX+DemoX+DemoCourse/block-v1:OpenedX+DemoX+DemoCourse+type@chapter+block@30b3fbb840024953b2d4b2e700a53002/block-v1:OpenedX+DemoX+DemoCourse+type@vertical+block@2a1f276a2b964eb6b137ed56abfe9052 -> http://apps.local.openedx.io:2000/learning/course/course-v1:OpenedX+DemoX+DemoCourse/block-v1:OpenedX+DemoX+DemoCourse+type@sequential+block@4e1de5e13fc3422997fe246b40a43aa1/block-v1:OpenedX+DemoX+DemoCourse+type@vertical+block@2a1f276a2b964eb6b137ed56abfe9052
Unit → sequence/unit,
/course/{courseId}/{unitId}: lands on the unit under its parent sequence (oneapi/courses/v2/blocks/request).Markers,
/course/{courseId}/{sequenceId}/firstand/last: land on the sequence's first and last unit.Outline failure (500): the courseware page redirects to
/course/{courseId}/home.Observed: the page renders first and the redirect lands about 7 s later — the query client retries a 5xx three times, and while it retries the query is pending, which The courseware and course-end pages render before the learning-sequences outline has loaded #2138 decided renders the page. Accepted for the conversion; whether the page needs this request at all is Decide whether the courseware page needs the learning-sequences outline #2145.
While the outline is delayed (8 s)
resumeorblocksrequest before the outline lands.First-section celebration
first_sectiontrue inapi/courseware/course/{id}): on the last unit of the first section, Next moves into the next section and the celebration modal shows on the new page. Moving between sequences within a section shows nothing.🤖 Generated with Claude Code