refactor!: read the sidebar context through useSidebar() and test the sidebar suites under the real provider - #2116
Open
brian-smith-tcril wants to merge 1 commit into
Conversation
brian-smith-tcril
added this pull request to stack #2117
September 24, 2026 13:47
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## bsmith/sidebar-provider-react-query #2116 +/- ##
=======================================================================
- Coverage 94.86% 94.85% -0.01%
=======================================================================
Files 370 370
Lines 5995 5990 -5
Branches 1465 1466 +1
=======================================================================
- Hits 5687 5682 -5
Misses 295 295
Partials 13 13 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
brian-smith-tcril
force-pushed
the
bsmith/sidebar-context-hook
branch
from
September 24, 2026 20:50
b5d9e23 to
1c675de
Compare
brian-smith-tcril
removed this pull request from stack #2117
September 24, 2026 20:50
brian-smith-tcril
added this pull request to stack #2121
September 24, 2026 20:51
… sidebar suites under the real provider Every consumer of the courseware sidebar context reads it through the `useSidebar()` hook #2113 introduced, and the context object is private to one module, `courseware/course/sidebar/SidebarContext.tsx`, which holds the types, the `createContext` call, `SidebarProvider` and the hook, the shape of the repo's four other typed contexts. Ten files swap `useContext(SidebarContext)` for the hook and change nothing else. Merging the provider and the hook put the provider's import of the built-in widget list on an import cycle (`SidebarContext.tsx -> defaultWidgets.js -> widgetConfig.ts -> DiscussionsProvider.tsx -> SidebarContext.tsx`). The provider stops importing the built-ins: `SidebarProvider` takes `widgets` as a required prop and `Course.jsx`, the composition root, passes `getEnabledWidgets()`, memoised once per mount. `buildSidebarsRegistry` and `getSidebarOrder` move into the module with their tests, since they operate on the provider's input. Two guards go with the tests that existed to hit them: `!SIDEBARS` in `Sidebar.jsx` and `!SIDEBAR_ORDER` in `SidebarTriggers.jsx` checked for a value the provider cannot produce and only a faked context could supply. The real conditions each keep one test under the provider. Tests: the eighteen sidebar suites that rendered a raw `SidebarContext.Provider` with a hand-rolled value render the real `SidebarProvider` with stub widgets, or the real registry where the registry is the subject, and set viewport width, the stored preference and the closed-by-user flag instead of a literal `currentSidebar`. A probe component, `src/tests/SidebarState.tsx`, renders the current sidebar as text for the suites whose tree does not show it. `Sequence.test`'s wrapper and its upgrade-panel cases, which #2118 put on the course route and the real provider, pass the provider its `widgets`. `test-utils.jsx` drops a context wrapper around `Course`, which renders the provider itself. Docs name `SidebarProvider` and `SidebarContext.tsx`; the use case verification's code-location links point at the hooks that hold each fix instead of line numbers in a file gone since the hooks extraction. BREAKING CHANGE: `SidebarContext` is no longer exported from `courseware/course/sidebar/SidebarContext` or `courseware/course/sidebar`; read the context with `useSidebar()` from the same module. `SidebarProvider` requires a `widgets` prop (`getEnabledWidgets()`). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
bsmith/sidebar-context-hook
branch
from
September 25, 2026 02:44
1c675de to
ec71240
Compare
brian-smith-tcril
marked this pull request as ready for review
September 25, 2026 02:55
This was referenced Sep 25, 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
Every sidebar consumer now reads the context through
useSidebar(), the hook #2113 introduced, and the context object itself is private to one module,courseware/course/sidebar/SidebarContext.tsx, which holds the types, thecreateContextcall,SidebarProviderand the hook — the shape of the repo's four other typed contexts (ToastContext,PluginOverridesContext,CoursewareSearchContext,TourContext). Merging the provider and the hook into one module put the provider's import of the built-in widget list on an import cycle, soSidebarProvidernow takeswidgetsas a prop andCourse.jsx, the composition root, passesgetEnabledWidgets(); the framework no longer knows which widgets exist. The eighteen sidebar test suites that rendered a rawSidebarContext.Providerwith a hand-rolledvaluerender the realSidebarProviderand express the state they need through what it reads (viewport width, stored preference, the widget list), which is how the other providers are tested and what turned up, in the outline suites, the narrow-viewport refresh bug #2113 fixes, and inSequence.testthe un-awaited assertions #2118 fixes below this layer. Two guards for a value the provider cannot produce go with the tests that existed to hit them. No runtime behaviour change beyond the guards. Breaking for operators:SidebarContextis no longer exported — see Operators — breaking. Part of the Redux → React Query migration (#1946, Stage 1); a wide layer, the follow-up #2111 filed. Closes #2112.What changed
useSidebar().LockPaywall.jsx,sequence-navigation/hooks.js,Sidebar.jsx,SidebarTriggers.jsx,common/SidebarBase.jsx,course-outline/hooks.js,DiscussionsSidebar.jsx,DiscussionsTrigger.jsx,UpgradePanel.jsx,UpgradeTrigger.jsx. Nothing else in those files changes.SidebarContextProvider.tsxandSidebarContext.tsmerge intoSidebarContext.tsx;const SidebarContext = createContext<SidebarContextValue | null>(null)is not exported.sidebar/index.jsre-exportsSidebarProvider,useSidebar,buildSidebarsRegistryandgetSidebarOrderfrom it.SidebarProvidertakeswidgets. The merge madeSidebarContext.tsx → defaultWidgets.js → widgetConfig.ts → DiscussionsProvider.tsx → SidebarContext.tsxa cycle (import/no-cycle). The provider stops importing the built-ins;Course.jsxpassesuseMemo(() => getEnabledWidgets(), []).buildSidebarsRegistryandgetSidebarOrdermove fromdefaultWidgets.jsinto the module with their tests, since they operate on the provider's input (decision 4).Sidebar.jsx:!currentSidebar || !SIDEBARS[currentSidebar];SidebarTriggers.jsx:SIDEBAR_ORDER.length === 0. The dropped conditions (!SIDEBARS,!SIDEBAR_ORDER) checked for a value only a faked context could supply. The four tests that existed to hit them go; the real conditions keep one test each under the provider (decision 2).sidebar.{courseId}and sessionStoragesidebarClosedByUserinstead of a literalcurrentSidebar. A probe componentsrc/tests/SidebarState.tsxrenders the current sidebar as text for the suites whose tree does not show it (decisions 6–7). No test-only seam:DiscussionsProvider.test.tsxandUpgradeWidgetContext.test.jsxrender<SidebarProvider widgets={[]}>(decision 5).Sequence.testswaps its provider. Its un-awaited upgrade-panel cases, and the nine other un-awaitedwaitForcalls the conversion then exposed, are fixed in Un-awaitedwaitForandactcalls inCourse.test.jsxandSequence.test.jsxlet tests pass without asserting #2118 below this layer; here the wrapper rendersSidebarProviderwith awidgetsprop and the upgrade cases passgetEnabledWidgets()(decision 8).test-utils.jsxdrops aSidebarContext.Providerwrapper aroundCourse, which renders the provider itself.SidebarContextProvider→SidebarProvider,SidebarContext.ts→SidebarContext.tsxin the sidebar README,ARCHITECTURE.md, the course-outline and upgrade READMEs.USE_CASE_VERIFICATION.md's "Code Location" links, which anchored line numbers in a file gone since the hooks extraction, now point at the hook holding each fix (decision 12).Operators — breaking
SidebarContextis no longer exported fromcourseware/course/sidebar/SidebarContextor fromcourseware/course/sidebar. A widget that diduseContext(SidebarContext)reads the same value withuseSidebar()from the same module; outside the provider it throws "useSidebar must be used within a SidebarProvider" instead of returningnull. refactor!: clean up SidebarContextProvider for React Query and convert it to TypeScript #2113 already documented the hook as the way to read the context; this layer removes the object.SidebarProviderrequires awidgetsprop. OnlyCourse.jsxrenders it in this repo. An operator rendering the provider themselves passesgetEnabledWidgets()fromcourseware/course/sidebar.SIDEBAR_WIDGETSconfig, the widget contract (SidebarWidget,SidebarWidgetContext) and theProviderfield are unchanged.Testing
npm run typesandnpm run lintclean; full suite 116 suites, 1166 passed, 0 skipped.SidebarContext.tsx,Sidebar.jsxandSidebarTriggers.jsxat 100% lines and branches.git grep "useContext(SidebarContext)" srcandgit grep "SidebarContext.Provider" srceach return onlySidebarContext.tsx. Manual checks: see the checklist below.Decisions
Full decision log
Decisions —
useSidebar()everywhere,SidebarContextprivate, sidebar suites on the real provider (#2112)The follow-up #2111 filed (its decision 15); the second layer of the running
stack, on top of #2113. A wide layer: many files, each touched lightly.
Entries 1–3 were settled in the plan review on 2026-09-24 and posted to
#2112; the rest landed with the code.
The ten
useContext(SidebarContext)reads becomeuseSidebar().LockPaywall.jsx,sequence-navigation/hooks.js,Sidebar.jsx,SidebarTriggers.jsx,common/SidebarBase.jsx,course-outline/hooks.js,DiscussionsSidebar.jsx,DiscussionsTrigger.jsx,UpgradePanel.jsx,UpgradeTrigger.jsx. Under the provider the hook returns the same value;outside it the hook throws its own message instead of a
nulldestructureerror. No other change in those files.
The
!SIDEBARS/!SIDEBAR_ORDERguards go with their tests.Sidebar.jsx's guard is!currentSidebar || !SIDEBARS[currentSidebar];SidebarTriggers.jsx's isSIDEBAR_ORDER.length === 0. The droppedconditions guarded a value the provider never produces —
buildSidebarsRegistryandgetSidebarOrderalways return an object andan array, and the types say so — that only a test's hand-rolled context
could supply. The four cases that existed to hit them go; the real
conditions each keep one test under the provider: a stored preference for
a widget that is no longer registered renders no panel, and an empty
widget list renders no triggers.
Sidebar.test.jsxgoes from twelve tests to six. Two tested theimpossible state; the rest were consolidated where they are the same
case under the provider (settled in review, 2026-09-25):
mastertest!SIDEBARS[currentSidebar]COURSE_OUTLINEOne module,
SidebarContext.tsx, the repo's shape. The four typedcontexts (
ToastContext.tsx,PluginOverridesContext.tsx,CoursewareSearchContext.tsx,TourContext.tsx) each keep the contextobject, the provider and the hook in one file and export the provider, the
hook and the types; nothing imports the objects.
SidebarContextProvider.tsxand
SidebarContext.tsmerge intoSidebarContext.tsx, named like thefour;
const SidebarContext = createContext<SidebarContextValue | null>(null)is module-private.
sidebar/index.jsre-exportsSidebarProvideranduseSidebar. The plan's first draft keptSidebarContextexported withonly the provider importing it, a pattern held together by review;
rejected for the pattern itself.
The merge surfaced an import cycle; the provider takes
widgetsas aprop. The old provider imported
getEnabledWidgetsfromdefaultWidgets.js, which imports the two built-in widget configs, whosecomponents import the hook. With the hook in the same module as the
provider, that is
SidebarContext.tsx → defaultWidgets.js → widgetConfig.ts → DiscussionsProvider.tsx → SidebarContext.tsx, andimport/no-cyclefails. Three ways out were weighed: keep the object and the provider in
two files (the split feat: decouple notifications panel using widget registry mechanism #1885 chose, kept only to dodge the cycle); make the
widget-side imports type-only (not possible —
DiscussionsProviderandUpgradeWidgetContextcalluseSidebar); or stop the provider importingthe built-ins at all. The third is right on its own terms: the framework
has no reason to know which widgets exist, and the composition root
already does.
SidebarProvidernow requireswidgets: SidebarWidget[],and
Course.jsxpassesuseMemo(() => getEnabledWidgets(), [])— theconfig is read once per mount, as the provider's own
useMemoon theregistry did before.
buildSidebarsRegistryandgetSidebarOrdermovefrom
defaultWidgets.jsintoSidebarContext.tsxwith their tests: theyoperate on the provider's input and the provider needs them without
importing the file that lists the built-ins.
defaultWidgets.jskeepsDEFAULT_WIDGETSandgetEnabledWidgets.No test-only seam. The issue anticipated that
DiscussionsProvider.test.tsxand
UpgradeWidgetContext.test.jsxcould not render the provider withoutcircularity, because the provider mounts those components. With
widgetsa prop, each suite renders
<SidebarProvider widgets={[]}>around thecomponent under test: the provider mounts no widget
Providerof its own,and the component is an ordinary child. The test-only export from the
issue is not needed. The provider still has its two bridge reads,
useModel('coursewareMeta')anduseModel('discussionTopics'), and thoseare
useSelectorcalls that need a Redux store above them. The suitesthat render without one —
DiscussionsProvider.test.tsx, which isolatesone query observer's requests on a bare
QueryClientProvider, and thetwo upgrade-widget suites — mock
useModelto return{}, as theprovider's own suite and
UpgradePanel.test.jsxalready did onmaster;the provider only feeds those values to
isAvailable, which nothing callswith
widgets={[]}. Giving those suites a store instead would pull theapp bootstrap and its axios routes into request-count assertions. The
mocks go when Read discussion topics from the query, not useModel #2087 and the
coursewareMetalayer move the two readsonto queries.
Suites express sidebar state through what the provider reads. The
provider's inputs are the widget list,
window.innerWidthagainstbreakpoints.extraLarge.minWidth(Paragon'suseWindowSizemeasures itin a layout effect; jsdom's default of 1024 is narrow), the
sidebarsearch param, localStorage
sidebar.{courseId}(a JSON string) andsessionStorage
sidebarClosedByUser. Every converted suite sets thoseand lets the provider derive
currentSidebar; none passes a literalvalue. Where nothing in the rendered tree shows the current sidebar (the
trigger strip,
SidebarBase,CourseOutlineTrigger,SidebarUnit), aprobe component
src/tests/SidebarState.tsxrenders it as text for theassertion — the same role
MountCourseQueryHooksplays for the queries.Stub widgets in framework suites; the real registry where the registry
is the subject.
Sidebar.test,SidebarTriggers.test,SidebarBase.testand the provider suite build two or three stub widgets inline (
id,priority, aSidebarandTriggerthat render a test id,isAvailable: () => true).SidebarTriggers's external-widget cases andSequence.test'supgrade-panel cases pass
getEnabledWidgets(), somergeConfig({ SIDEBAR_WIDGETS })and the built-inisAvailablechecks run for real.The widget suites (
UpgradePanel.test,UpgradeTrigger.test,CourseOutlineTray.test, …) pass their own widget's config.Sequence.testonly swaps its provider. Its two upgrade-panel caseswere the ones that could not fail (an un-awaited
waitForandact), andconverting them here first surfaced that, then the suite's broken
special-exams mock, then the un-awaited
waitForin nine more of itstests. That is its own problem with its own history, so it became Un-awaited
waitForandactcalls inCourse.test.jsxandSequence.test.jsxlet tests pass without asserting #2118,a test-only layer below refactor!: clean up SidebarContextProvider for React Query and convert it to TypeScript #2113 that awaits everything, puts the wrapper on
the course route and the upgrade cases on the real provider. On top of
it, this layer's change to the suite is the provider swap: the wrapper
renders
SidebarProviderwith awidgetsprop instead of the raw contextwith a literal, and the upgrade cases pass
getEnabledWidgets().Two assertions for "closed", because two components close two ways.
Sidebar.jsxreturnsnullwhen nothing is open, so a closed panel isnot.toBeInTheDocument().SidebarBaseinside a mounted widget hides anon-matching id with
d-none, so an open panel isnot.toHaveClass('d-none')and a panel for another id
toHaveClass('d-none'). The suites use theone that matches the component they render.
CourseOutlineTrayis thefirst kind: it returns
nullunless the outline is the current sidebar,so its suite asserts a collapse as the absence of the tray's toggle
button (the trigger, which shares that button's name, is not in the
suite's tree).
tracks the last window width across resize eventsisremoved (settled in review, 2026-09-25). It came from fix: navigation sidebar scrolling on mobile devices #1927 (Agrendalath,
merged 2026-09-10, fixing Broken sidebar on mobile devices. #1935: mobile browsers fire
resizewhen theURL bar hides on scroll, and the sidebar closed while the learner
scrolled it), the same commit that added the
lastWindowWidthref andthe
if (!widthChanged) returnguard touseCourseOutlineSidebar. Thetest was the ref's direct unit test in three steps: a resize that stays
above the breakpoint still writes the ref, a later resize is measured
against that write, and after a collapsing resize a repeat of the same
width does not collapse again. The third step read the ref after the
collapse, which only worked because the faked context kept the tray
mounted; under the real provider the collapse unmounts the tray, and the
hook, its listener and its ref with it, so nothing is left for a repeated
width to reach. Without that step the first two prove nothing about the
ref (1100 differs from the initial 1300 as much as from 1250) and
duplicate the two tests above them. The guard's behaviour keeps its
coverage in
does not collapse sidebar when only the window height changes. A direct test of the ref belongs at hook level, withrenderHookand a mockedtoggleSidebar, where the hook stays mountedafter a collapse the way the original assumed; not added here. The fix: navigation sidebar scrolling on mobile devices #1927
review thread has no discussion of the test.
CourseOutlineTrigger.test's three tests are kept as they were, with theprovider replacing the mocked
toggleSidebar(noted in review,2026-09-25). The first two open the outline from the trigger, on desktop
and on mobile; the third,
changes current sidebar value on click,starts with the outline open (stored in localStorage) and closes it. That
direction exists only on mobile: the trigger is shown there whether or
not the outline is open (
isDisplayForMobileView), while on desktop itunmounts once the outline is open (
currentSidebar !== ID), which thedesktop test's last assertion checks. The name does not say that; it is
left as it is.
The narrow-viewport stored panel surfaced here and was fixed in refactor!: clean up SidebarContextProvider for React Query and convert it to TypeScript #2113.
CourseOutlineTray.testandCourseOutlineTrigger.teststoreCOURSE_OUTLINEon a narrow viewport and expect it open; under the realprovider
currentSidebarwasnull. The first render'suseWindowSize().widthisundefined, so both breakpoint comparisonsare
false,useInitialSidebarreturnsnull, and the two mobileearly-returns in the hooks never correct it — the same path a learner
hits refreshing with a panel open on a phone, on
mastersince feat: decouple notifications panel using widget registry mechanism #1885.Fixed in the layer below (
useWindowSize().width ?? window.innerWidth,refactor!: clean up SidebarContextProvider for React Query and convert it to TypeScript #2113's decision 10) and carried here by the stack rebase; the outline
suites then pass unchanged.
test-utils.jsxdrops its wrapper.setupDiscussionSidebarwrappedCoursein a rawSidebarContext.Providerwith a literal value;Courserenders
SidebarProvideritself, so the wrapper never reached a consumer.Docs name the provider and the module, not the old file.
SidebarContextProvider→SidebarProvider;SidebarContext.ts→SidebarContext.tsx.USE_CASE_VERIFICATION.md's "Code Location" linkscarried line anchors into
SidebarContextProvider.jsx, a file that hasnot existed since the hooks extraction; each now links to the hook that
holds the fix it describes (
useUnitShiftBehavior.ts,useInitialSidebar.ts,useResponsiveBehavior.ts) or toSidebarContext.tsxfor the refs it declares, without anchors, and theprotection-flags table's "Lines" column says where each ref is declared.
Breaking, for operators.
SidebarContextis no longer exported fromSidebarContext.tsxorsidebar/index.js; a widget reads the contextwith
useSidebar().SidebarProviderrequireswidgets; nothing outsideCourse.jsxrenders it.buildSidebarsRegistryandgetSidebarOrderkeep their
sidebar/index.jsexports from the new module.Manual testing
Checklist
Manual testing —
useSidebar()everywhere,SidebarContextprivate, sidebar suites on the real provider (#2112)In-browser verification against a live backend (tutor dev).
What changed: every sidebar consumer reads the context through
useSidebar();the context object is private to
SidebarContext.tsx;SidebarProvidertakesthe widget list from
Course.jsxinstead of importing it; two dead guards aregone. No data flow, request or storage change.
The bugs this layer could introduce. (1) A consumer renders outside the
provider — now a thrown "useSidebar must be used within a SidebarProvider"
instead of an empty render; in the app every consumer is under
Course, sothis would show as a crash on the courseware page. (2) The widget list reaches
the provider wrong — no triggers, or an operator's
SIDEBAR_WIDGETSwidgetmissing — since
Course.jsxnow passesgetEnabledWidgets(). (3) The droppedguards mattered — a panel or the trigger strip failing where it used to render
quietly.
Setup
A course with the Open edX discussions provider and a verified mode, a
unit with in-context discussions on. The
env.config.jsxprobe widget from#2111's testing can stay for the operator-widget check (remove it afterwards). Browser devtools
open on the Console.
Checks
Nothing renders outside the provider (bug 1)
useSidebaron a unit with discussions on: the discussions trigger is in the sidebar strip and the course outline tray is on the left.The widget list reaches the provider (bug 2)
env.config.jsx, its trigger is in the strip and its panel opens.The dropped guards did not matter (bug 3)
master.master, and (the refactor!: clean up SidebarContextProvider for React Query and convert it to TypeScript #2113 fix) an open panel survives a refresh.Results
Run 2026-09-25 on tutor dev. 5 of 7 checks run, all passing; 2 not run.
Run
useSidebar; the discussions trigger in the strip, the outline tray on the left.master— open where the unit has discussions, closed where it does not.master; an open panel survived a refresh (the refactor!: clean up SidebarContextProvider for React Query and convert it to TypeScript #2113 fix).Not run
LockPaywall.test.jsx,SequenceNavigation.test.jsxandSequenceNavigationTabs.test.jsx, which render those consumers under the real provider.env.config.jsx: the probe widget from Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111's testing had already been removed. Covered by the external widget integration cases inSidebarTriggers.test.jsx, which register a widget throughmergeConfig({ SIDEBAR_WIDGETS })and rendergetEnabledWidgets()under the provider.🤖 Generated with Claude Code