refactor!: read courseHomeMeta from the query in the courseware, course-end pages and widgets - #2110
Merged
brian-smith-tcril merged 1 commit intoSep 24, 2026
Conversation
brian-smith-tcril
added this pull request to stack #2080
September 23, 2026 19:54
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2110 +/- ##
==========================================
+ Coverage 94.28% 94.59% +0.30%
==========================================
Files 368 368
Lines 5951 5952 +1
Branches 1447 1418 -29
==========================================
+ Hits 5611 5630 +19
+ Misses 326 309 -17
+ Partials 14 13 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
brian-smith-tcril
force-pushed
the
bsmith/course-home-meta-courseware-reads
branch
2 times, most recently
from
September 23, 2026 21:06
ad4ba1e to
bab22a3
Compare
brian-smith-tcril
marked this pull request as ready for review
September 23, 2026 21:16
Base automatically changed from
bsmith/course-home-meta-query-reads
to
master
September 24, 2026 02:50
…se-end pages and widgets The course-home metadata has been query-backed since #2010, and #2085 moved its tab-page, alert and course-home-tab readers onto the query. This moves the remaining 22 reads the same way: the courseware subtree, the course-end pages, the sidebar provider and upgrade widget, the product tours and the streak modal read `useCourseHomeMeta(courseId, { enabled: false }).data ?? {}` where they destructure, `.data?.field` for a single field, and `.data` alone where the whole object feeds a memo. Every site renders under a page that already fetches the query, so nothing fetches differently. The two celebration writers marked a celebration as shown by dispatching `updateModel` into the store; they now write the query's cache entry with `queryClient.setQueryData`, spreading the same `celebrations` they spread before, and `useQueryClient()` replaces `useDispatch()` in `Course`, the first-section modal and the streak modal. The server-side celebration POST is unchanged and stays outside React Query; #1973 owns making it a mutation. The three tab readers in this set take the tab-URL hooks from #2085, and `HiddenAfterDue` drops its `courseId` prop in favour of the route param. With no reader or writer left on the store, `useCourseHomeMeta` drops its bridge `meta` entry, so nothing writes the `courseHomeMeta` model any more. The sidebar README's "Accessing Course Data" section is rewritten around the query hooks so it no longer suggests `useModel` returns this data. Tests: suites that rendered these components against the seeded store mount the owner's query hooks beside them (`Course`, `Sequence`, `LockPaywall`, the `CourseExit` bodies) or, where the suite's own axios adapter precludes a fetch, seed the query (`HonorCode`, the streak modal); `UpgradePanel` and `SidebarContextProvider` mock the hook instead of `useModel`. The celebration utils are tested against a real query cache, the streak modal suite covers the close-time cache write, and a new `CelebrationModal` suite covers the first-section modal's open-time POST and cache write, which no unskipped test exercised. BREAKING CHANGE: `useModel('courseHomeMeta', courseId)` from ./src/generic/model-store no longer returns the course-home metadata; it returns `{}`. Read `useCourseHomeMeta(courseId, { enabled: false }).data` from ./src/course-home/data/apiHooks instead. Under any course page it returns what that page fetched; see src/courseware/course/sidebar/README.md. Part of #1946 (Stage 1). Closes #2086. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
bsmith/course-home-meta-courseware-reads
branch
from
September 24, 2026 02:50
bab22a3 to
34ccf2b
Compare
brian-smith-tcril
deleted the
bsmith/course-home-meta-courseware-reads
branch
September 24, 2026 02:56
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 course-home metadata has been query-backed since #2010, and #2085 moved its tab-page, alert and course-home-tab readers onto
useCourseHomeMeta. This moves the remaining 22 reads — the courseware subtree, the course-end pages, the sidebar provider and upgrade widget, the product tours and the streak modal — the same way, moves the two celebration writers off the Redux model onto the query cache, and drops thecourseHomeMetabridge entry, so nothing writes that model any more (the lastcourseHomeMetalayer of the #1977 model-store dissolution). No user-facing change and no request-count change: readers subscribe with{ enabled: false }under the page that already fetches the query, the writers patch the cache entry with the same value they wrote to the store, and the server-side celebration POST is untouched. Breaking for plugins:useModel('courseHomeMeta', courseId)now returns{}— see Plugins — breaking below. Part of the Redux → React Query migration (#1946, Stage 1). Closes #2086. Stacked above #2109.What changed
.data?.field; thirteen keep their destructure over.data ?? {};SidebarContextProviderreads.datawith no default (the object sits in auseCallbackdependency array);HiddenAfterDuetakesuseProgressTabUrl().CourseInProgressandCourseNonPassingtake B2's tab-URL hooks too, andHiddenAfterDuedrops itscourseIdprop in favour of the route param.dispatch(updateModel(…))becomesqueryClient.setQueryData(courseHomeQueryKeys.metadata(courseId), (meta) => ({ ...meta, celebrations: { ...celebrations, <flag> } }))with every signature kept;useQueryClient()replacesuseDispatch()inCourse,CelebrationModalandStreakCelebrationModal.postCelebrationCompletestays a fire-and-forget POST outside React Query; Convert remaining small / api-only features to React Query #1973 owns making it a mutation, and carries a note about the two branches where the POST and the patch are not paired.useCourseHomeMetadropsmeta: { modelType: 'courseHomeMeta', courseId }; the bridge itself stays fordates,outlineandprogressuntil Source the access-expiration masquerade banner from the tab query, not useModel(tab) #1999 and the teardown.src/courseware/course/sidebar/README.md's "Accessing Course Data" is rewritten queries-first: thecoursecontext object widgets already receive, thenuseCourseHomeMetaanduseCoursewareMetadatawith{ enabled: false }, with one holdover line for the per-unitdiscussionTopicsread that has no hook until Read discussion topics from the query, not useModel #2087.Course,Sequence,LockPaywall, theCourseExitbodies) or, where the suite's own axios adapter precludes a fetch, seed the query (HonorCode, the streak modal);UpgradePanelandSidebarContextProvidermock the hook instead ofuseModel. The celebration utils are tested against a real query cache instead of a dispatch spy, the streak modal suite covers the close-time cache write, and a newCelebrationModal.test.tsxcovers the first-section modal's open-time POST and cache write (the only cases that opened it were skipped).Plugins — breaking
useModel('courseHomeMeta', courseId)from./src/generic/model-storeno longer returns the course-home metadata. With the bridge entry gone nothing writes that model, so the call returns{}— the model store's value for a model it does not have — and every field read off it isundefined. Nothing errors; a widget that readsverifiedMode,org,isStaff,usernameorcourseModesthis way silently sees none of them. This affects sidebar widgets registered throughSIDEBAR_WIDGETSandPluginSlotplugins that import this repo's internals: the sidebar README documented theuseModelread for widget authors, which is why it changed.The replacement is the query hook, as a non-fetching reader:
Under any course page — a courseware unit, the course-home tabs, the course-end page — that page has already fetched the metadata, so the disabled observer reads the cached result and adds no request; it re-renders when the page's fetch lands, exactly as the
useModelread did when the bridge wrote the store. Rendered anywhere else,.datastaysundefineduntil something on that page fetches the metadata. As with #2106, the query cache is shared, so one fetching observer on the page feeds every reader; the simplest shape is the widget mounting the fetch itself,useCourseHomeMeta(courseId)without the option, beside its read.Unaffected: the widget config functions,
isAvailableandprefetch, still receive the mergedcourseobject as an argument with the same fields as before.useModel('coursewareMeta', courseId)anduseModel('discussionTopics', unitId)still return their models for now; #2089 and #2087 do to them what this layer does tocourseHomeMeta, and the README already namesuseCoursewareMetadataas the hook to movecoursewareMetareads onto. The rewritten section is Accessing Course Data insrc/courseware/course/sidebar/README.md.Testing
npm run typesandnpm run lintclean; full suite 115 suites, 1166 passed, 3 skipped. Negative checks, one reader per owner flipped toenabled: true:Sequence.jsxmakes theCoursewareContainercount read two,CourseCelebration.jsxtheCourseExitcount (once #2109's count case renders the celebration body, a fix folded into that PR),LaunchCourseHomeTourButton.jsxthe outline count, andProductTours.jsxthe outline, courseware and dates counts. Manual testing per the checklist below, on tutor dev: 11 of 29 checks run, all passing — the four request counts (one per page, none added by in-courseware navigation), the staff-flag preview checks, the hidden-after-due link, the celebration page's visible readers and the Launch tour button; 17 not run because they need course or account state the environment was not set up for (marked not run, not failed, each naming the unit case that covers the read), one skipped. Noorg_keywas observable: every event this layer touches goes throughsendTrackEvent, which is inert without aSEGMENT_KEY.Decisions
Full decision log
Decisions — read
courseHomeMetafrom the query: courseware, shared, and widgets (#2086)The last
courseHomeMetalayer of the #1977 model-store dissolution, on top of#2085 (PR #2109). Entries 1–8 were settled in the plan review on 2026-09-23 and
posted to #2086; the rest landed with the code.
Scope is the issue body plus its comment. The body lists the 22 remaining
useModel('courseHomeMeta', courseId)sites, the two celebration writersand the bridge
metaentry; the comment sends the three tab readers(
CourseInProgress,CourseNonPassing,HiddenAfterDue) through B2'stab-URL hooks and asks whether
HiddenAfterDuecan drop itscourseIdprop. All of it is here.
git grep "useModel('courseHomeMeta'" srcisempty after this layer.
Four read shapes, chosen per site as B2 did. Seven single-field sites
read
useCourseHomeMeta(courseId, { enabled: false }).data?.field(
CatalogSuggestion,UpgradeFootnote,DashboardFootnoteLink,LaunchCourseHomeTourButton,WeeklyGoalCelebrationModal,GetCourseExitNavigation); thirteen keep their destructure over.data ?? {};SidebarContextProviderreads.datawith no defaultbecause the object sits in a
useCallbackdependency array and is spread(
decisions-2083.mdentry 8);HiddenAfterDuetakesuseProgressTabUrl().One deviation from the plan's table:
ProductTourswas listed as asingle-field read of
username, but the component already destructures ausernamefromgetAuthenticatedUser(), so a property read would haveneeded a new name. It keeps the whole object —
const courseHomeMeta = useCourseHomeMeta(courseId, { enabled: false }).data— and the one usebecomes
courseHomeMeta?.username. Two tokens changed, no new name.The writers are a literal port. Every signature stays;
dispatchbecomesqueryClientin the same slot (useQueryClient()replaces
useDispatch()inCourse,CelebrationModalandStreakCelebrationModal); eachdispatch(updateModel(…))block becomesone
queryClient.setQueryData(courseHomeQueryKeys.metadata(courseId), (meta) => ({ ...meta, celebrations: { ...celebrations, <flag> } })),spreading the same render-time
celebrationsthe store version spread;the two identical first-section writes stay inline in
celebration/utils.jsx;recordModalClosing's unusedorgparameterstays;
postCelebrationCompleteis untouched. The first draft recommendedthe updater form reading the cache's own
celebrations(dropping thatparameter and the dead
org) and one private helper for the two writes.Rejected in review on a principle: doing the cache write by hand beside a
fire-and-forget POST is React Query's documented pattern
(Updates from Mutation Responses)
but the untidy shape, and Convert remaining small / api-only features to React Query #1973 already owns turning that POST into a
useMutation, so this layer does the minimal change and the cleanuphappens there. A note went to Convert remaining small / api-only features to React Query #1973 the same day: the POST and the patch
are not paired one to one in
shouldCelebrateOnSectionLoad(one branchposts without patching, one patches without posting), and the signature
cleanup was deferred. Value semantics are unchanged in every reachable
case, including
celebrations: null({ ...null, firstSection: false }is
{ firstSection: false }both ways); with no cache entry the spread ofan undefined
metacreates a bare{ celebrations }entry, which is whatupdateModeldid with a missing model.CourseHomeMetagains no fields. B2's log expected B3 to addcelebrations.firstSection/weeklyGoalandverifiedMode's fields.The readers converted here are JavaScript except
DashboardFootnoteLink,which reads
org, already named. Fields are named when a TypeScriptreader needs them; until then the index signature returns
unknown,which fails
npm run typesat the point of use and is the cue.CourseExitContentreads through a disabled observer.CourseExitholds the query one component up and passes it to
TabWithTimer; B2's"query in hand" shape applied within one component, and a prop through
TabWithTimer's children for one read buys nothing — two observers onone key are one cache entry.
HiddenAfterDuedropscourseId.Sequencerenders only underCoursewareContainer's:courseIdroutes, so the prop was always theroute param, and
useProgressTabUrl()readsuseParams()itself. Theprop, its
propTypesandSequence.jsx'scourseId={courseId}go. Theone
Sequence.test.jsxcase asserting the notice's progress link rendersunder a
MemoryRouterroute (/course/:courseId/:sequenceId/*, theUnitNavigation.test.jsxshape), becausesetupTest'swrapWithRouteris frontend-platform's
AppProvideraround a bareBrowserRouterwith noRoute, souseParams()is empty there.The bridge entry goes; the bridge stays.
useCourseHomeMetadropsmeta: { modelType: 'courseHomeMeta', courseId }.bridgeToModelStore's{ modelType, courseId }branch is still used bydates,outlineandprogress(Source the access-expiration masquerade banner from the tab query, not useModel(tab) #1999 and F). Nothing in production writes thecourseHomeMetamodel any more;setupTest.jsstill seeds it for tests,which F removes.
useModel('courseHomeMeta', courseId)therefore returns{}everywhere: the breaking change.The sidebar README is rewritten queries-first. "Accessing Course
Data" told widget authors to call
useModeland listedcourseHomeMeta.The requirement set in review: the README must never suggest
useModelgives more than it does. The section now leads with the
coursecontextobject widgets already receive, then the two metadata queries that work
today (
useCourseHomeMetaanduseCoursewareMetadata, both with{ enabled: false }and the Stop the progress tab data refetching from components under the tab #2103 sentence: under the courseware pagethey return what
CoursewareContainerfetched), and keeps one holdoverline for the per-unit
discussionTopicsread, which has no query hookuntil C (Read discussion topics from the query, not useModel #2087).
courseHomeMetagets no disclaimer; it simply left theREADME. The two "merged … models" lines describing the
courseobject nowsay metadata, since half of it is a query result. C's README change is
the deletion of the holdover line; D3 needs none.
Tests: mount where the suite can fetch, seed where its adapter precludes
it, mock-swap where the suite mocks module boundaries. The
decisions-2098.mdentry 3 rule plus two exceptions.MountCourseQueryHooksbeside the component:Course.test.jsx(a
renderCourse(testData, testStore)helper replaces 19 near-identicalrender(<Course …/>)calls),test-utils.jsx'ssetupDiscussionSidebar,Sequence.test.jsx'sSidebarWrapper,LockPaywall.test.jsx(arenderPaywallhelper), andCourseExit.test.jsx'sfetchAndRenderfor the 24 body renders.
Course.test.jsxpassed with or without themount — every case reads
undefinedhappily — which is the reason tomount rather than leave it: the suite would otherwise claim more than it
checks.
QueryClientProvider:HonorCode.test.jsxandStreakCelebrationModal.test.jsx, whosenew MockAdapterafterinitializeTestStorereplaces the axios adapter and itscourse_metadatahandler, so a mounted hook would 404. Both seed withstore.getState().models.courseHomeMeta[courseId]rather thancamelCaseObject(factory):normalizeCourseHomeCourseMetadatais notexported,
HonorCode's masquerade case reads theisMasqueradingitderives, and the store copy is exactly that normalizer's output for the
case's factory input. F, which stops seeding the store, reworks these
two seeds along with the rest.
UpgradePanel.test.jsxandSidebarContextProvider.test.jsxreplace the
courseHomeMetabranch of theiruseModelmock with auseCourseHomeMetamock returning{ data }(theCoursewareSearch.test.jsxshape).UpgradePanelfailed without it;SidebarContextProviderpassed onundefinedand keeps its{ tabs: [] }fixture for fidelity.
LockPaywall.test.jsx's cases were synchronous against a store seededbefore render; with a mounted fetch the metadata arrives after the first
render, so
getBy*becameawait findBy*and three cases becameasync. Does not display anything if course does not have verifiedmode still passes before the fetch settles (a negative assertion with
no wait) — pre-existing shape, left as is.
CourseExit.test.jsx'sfetchAndRendermounts the hooks only beside abody component, not beside
<CourseExit />, so the count case countsthe owner alone; it then waits for
queryClient.isFetching()to reachzero before returning, since the bodies render with
undefineduntil thefetch lands.
LoadedTabPage.test.jsx's streak case dropped its second store: the realmodal reads the query the helper already seeds.
OutlineTab.test.jsx'sdenied-learner case waited on the store model the bridge filled; it now
waits on the query through the client
fetchAndRenderreturns.celebration/utils.test.jsx'sdispatchMockcase (which asserted onlythat something was dispatched) became two cases on a seeded
QueryClient:recordFirstSectionCelebrationflipsfirstSectionandleaves the rest of the entry alone;
shouldCelebrateOnSectionLoad'sclearing branch does the same write.
StreakCelebrationModal.test.jsxgained clears the streak in the course metadata query when the modal
closes: click the close button,
streakLengthToCelebrateisnull,orgstill there — theuseQueryClient→recordModalClosingwiring.CelebrationModal.test.tsxis new, for codecov's patch check: thefirst-section modal's effect (
useQueryClient→recordFirstSectionCelebration) ran in no unskipped test, because theonly cases that open the modal are the two skipped
Course.test.jsxcelebration cases (
Course.test.jsxtests passing when they shouldn't #1669). Two cases in theEnrollmentAlert.test.tsxshape on a seeded query: opened, it POSTs
first_section: false, flipsthe flag in the cache and tracks
org_keyfrom the query; closed, itdoes none of that.
The
CourseExitcount case rendered no body; the fix went into refactor: read courseHomeMeta from the query in the tab page, alerts and course-home tabs #2109,not here. B2's case called
fetchAndRender(<CourseExit />)with thesuite's default metadata, whose
enrollment.is_activeisnull, sogetCourseExitModereturneddisabledand the page rendered a redirectwith no body. It counted the owner and
TabPage's readers correctly (B2'sown readers on that page) but nothing under them, so the plan's claim
that it covered this layer's course-end readers was wrong: the first
negative check here (flipping
CourseCelebrationtoenabled: true)still read one. The fix — set an active enrolment and a downloadable
certificate so the celebration body renders, asserted by
Congratulations! — first landed in this layer and was pulled out in
review: nothing this layer does to the source changes what that case
sees, so a reviewer would read the test change as caused by this PR when
it is B2's test being corrected. It is folded into refactor: read courseHomeMeta from the query in the tab page, alerts and course-home tabs #2109 (still open, one
layer down;
decisions-2085.mdentry 13), and this layer's negative checkfor the course-end page runs against it there.
No hook-suite case for "a disabled reader re-renders on
setQueryData".The plan promised to pin that mechanism with a test instead of a docs
quote, and a first version landed in
apiHooks.test.tsx. Dropped inreview:
useCourseHomeMetahas nothing special in it, so the case testedReact Query, not our code, and its name said nothing about why. The one
thing such a case would catch beyond the utils suite's
getQueryDataassertions is an updater that mutates the cached object in place — same
flag in the cache, no new reference, no observer re-render, the
first-section modal showing twice — and that guard was judged not worth
adding to writers Convert remaining small / api-only features to React Query #1973 rewrites into a mutation with tests of its own.
Recorded while it was fresh: the notification is asynchronous (React
Query's batched notifier, which a synchronous
actdoes not flush), soany future version of the case needs
waitFor.Commit type is
refactor!:with aBREAKING CHANGE:footer, theStop the progress tab data refetching from components under the tab #2103 (PR refactor!: stop the progress tab data refetching from components under the tab #2106) shape: what stopped working, what to call instead, where
the README is. One commit: the writers, the readers and the bridge entry
are one change, since the entry can go only once both are off the store.
Negative checks, one reader per owner flipped to
enabled: true.Sequence.jsx→ theCoursewareContainercount reads two;CourseCelebration.jsx→ theCourseExitcount reads two (after entry10);
LaunchCourseHomeTourButton.jsx→ the outline count reads two;ProductTours.jsx→ outline, courseware and dates each read two. Everyflip was reversed by the inverse substitution, never by a checkout.
Manual testing
Checklist
Manual testing — read
courseHomeMetafrom the query: courseware, shared, and widgets (#2086)In-browser verification against a live backend (tutor dev).
What changed: the last 22 readers of the course-home metadata that went
through the model store now read the metadata query directly, as disabled
observers under the page that fetches it (
CoursewareContainerfor thecourseware subtree,
CourseExitfor the course-end page, every tab page forProductTours, the streak modal and the tour button). The two celebrationwriters patch the query cache instead of the Redux model. The bridge entry is
gone, so
useModel('courseHomeMeta')returns{}for anyone still calling it.Nothing fetches differently and the server-side celebration POSTs are
untouched.
The three bugs this layer could introduce. (1) A reader that fetches: a
forgotten
{ enabled: false }shows up only as a secondcourse_metadatarequest on that reader's page. (2) A reader that never sees data: a disabled
observer with no owner above it reads
undefinedforever, so its componentsits in its no-data branch without erroring — a paywall that never renders, a
link that never appears, an
org_keyofundefinedin a tracking event. (3)A celebration that repeats: if the cache write misses, the first-section modal
shows again at the next section boundary in the same session, or the streak
modal reopens on the next tab navigation. Everything below is one of those
three checks.
Setup
An ordinary course with a verified mode, at least two sections with
graded content, a gated (verified-only) unit if content gating is
enabled, and a subsection with hide after due set with a past due date.
Two accounts: an enrolled learner and a staff user who can masquerade.
For the celebrations: a fresh enrolment in a course with celebrations
enabled (the
course_metadatapayload showscelebrations.first_section: true), and, optionally, a learning goal set and met for the weekly-goalmodal. A three-day streak is unlikely to be producible; mark it not run.
For the course-end page: the learner has completed the course (or staff visits
/course/:iddirectly).Verify by hand
org_keyin tracking events is not observable on this setup. Every eventthis layer touches goes through frontend-platform's
sendTrackEvent, whichreturns without doing anything when
SEGMENT_KEYis unset(
SegmentAnalyticsService.js:233), so no request appears in the Network tab.The
org_keysub-checks below are therefore recorded as not observable; theorgreads behind them are the same one-line swap as every other reader andare covered by the unit suites. (To observe them: set any
SEGMENT_KEYinenv.config.jsx; the Segment stub then queues eachtrackcall onwindow.analytics, inspectable in the console.)Request count — one per page (the #2098 protocol)
Hard reload, wait for the Network tab to go idle, count
/api/course_home/course_metadata/:/course/:id): 1click a sequence title, wait for idle: 1
0 further
course_metadatarequestsCourseware readers (
CoursewareContainerowns the query)The staff flags (
Course.jsx,Sequence.jsxreadisStaffandoriginalUserIsStaff):/preview/...unit URL redirects to the plain/course/...URL/preview/...unit URL, the unit iframe'ssrccarriespreview=1(learner view:preview=0)ENABLE_JUMPNAV): notenabled on this setup and not worth turning on for this;
isStaffreaches
BreadcrumbItemunchanged throughCourseBreadcrumbsSlotComponents with their own read:
LockPaywall: needs content gating and a gated unit, not set uphere; its read is covered by
LockPaywall.test.jsx, which mounts theowner's hooks and finds the price link once the fetch lands
HonorCode: needs a graded unit for a learner who has not signedthe integrity agreement; its read is covered by
HonorCode.test.jsx,which seeds the query and posts (or, masquerading, does not)
HiddenAfterDue: a hidden-after-due subsection shows the notice with aworking progress page link (the course id now comes from the route,
not a prop)
UpgradePanel: needs theUPGRADEsidebar widget registered inenv.config.jsx(it is not on this setup) plus an audit learner in acourse with a verified mode; its read is covered by
UpgradePanel.test.jsx, which mocks the hook with the same fixture itmocked
useModelwithSidebarContextProvider: its read is observable only through awidget whose
isAvailablereadscourse, and the upgrade widget is theonly one; same setup as above
Course-end readers (
CourseExitowns the query)On the celebration page (
/course/:idafter completing the course):CourseExitContent: the page shows the celebration body rather thanredirecting to the outline (reads
canViewCertificate,isMasquerading)CourseCelebration: the Congratulations! header and the image; thebrowser tab title is
Congratulations! | <course title> | <site name>(
titlefrom the query); its visit event'sorg_key: not observableCatalogSuggestion: Looking to learn more? with the search ourcatalog link (its click event,
edx.ui.lms.course_exit.catalog_search.clickedwithorg_key: notobservable, see above)
Branches this setup does not show:
CourseCelebration's certificate block (needs certificate data)DashboardFootnoteLink: the dashboard footnote under thecelebration content renders only in the certificate branches; its click
would send
edx.ui.lms.course_exit.dashboard_footnote.clickedwithorg_keySocialIcons(needs amarketing_urlon the course)UpgradeFootnote(an audit learner on a course with an upgradedeadline)
CourseRecommendations(needs discovery recommendations; thecatalog suggestion is its fallback)
CourseNonPassing: the View grades button goes to the progresstab (an ended course, not passing)
CourseInProgress: the View course schedule button goes to thedates tab (scheduled content, not passing)
Tab-page readers (every tab owner)
(tours enabled for the account) and clicking it starts the course-home
tour; its click event's
org_key: not observablefor a user with tours enabled (not set up for this pass;
ProductTours'read is
usernamefor the proctoring-info query, covered byProductTours.test.jsxunder the outline and courseware owners)Celebrations (the cache writes)
first section's last unit. The Congratulations! modal shows once; the
Network tab shows one POST to
/api/courseware/celebration/:idwithfirst_section: false; closing it and crossing the next sectionboundary in the same session shows no second modal; a reload shows
none either. (Needs a fresh enrolment in a course with celebrations
enabled — not set up for this pass. The write is covered by
celebration/utils.test.jsxagainst a real query cache.)marketing_url); its openevent's
org_key: not observableyour goal! modal shows once with the goal's days per week; its POST
sends
weekly_goal: false(needs a goal set and met)closing it does not reopen it on the next tab navigation (needs a
three-day streak; the close-time write is covered by
StreakCelebrationModal.test.jsx)Plugins
useModel('courseHomeMeta', courseId)gets{}(needs a sidebar widgetor
PluginSlotplugin wired intoenv.config.jsxwith that call; thebehaviour is exactly what dropping the bridge entry does, and the README
section plus the BREAKING CHANGE footer are the mitigation)
Results
Run on tutor dev, 2026-09-23, on the B3 layer. 11 checks run, all passing: the
four request counts (a courseware unit, the course-end page, the outline →
courseware crossing, and in-courseware navigation adding none), the two
preview checks from the staff flags, the hidden-after-due notice's progress
link, the three readers visible on the celebration page, and the Launch tour
button on the outline. 17 checks marked not run: each needs course or account
state this environment was not set up for (content gating, an unsigned honor
code, the upgrade sidebar widget, certificate data, a
marketing_url, anupgrade deadline, discovery recommendations, a not-passing or in-progress
course end, tours end to end, a fresh enrolment for the celebration modals, a
weekly goal, a three-day streak, an out-of-repo
useModelcaller); each namesthe unit case that covers its read. One check skipped (jump-navigation
breadcrumbs, not enabled and not worth enabling). No
org_keywas observable:every event this layer touches is a
sendTrackEvent, which is inert without aSEGMENT_KEY. Nothing failed; the not-run checks were not exercised.Not covered
ProductTours, thestreak modal) are the ones every other tab exercises; their once-per-load
counts are pinned by their suites.
useModelreads ofcoursewareMeta,sequences,sections,unitsanddiscussionTopicsin the converted files — D and C layers.🤖 Generated with Claude Code