refactor!: read discussion topics from the query, not useModel - #2122
Open
brian-smith-tcril wants to merge 1 commit into
Open
brian-smith-tcril wants to merge 1 commit into
brian-smith-tcril wants to merge 1 commit into
Conversation
The three readers of the `discussionTopics` model — the discussions widget's trigger and panel and the sidebar provider's `unit` — read the current unit's topic through a new hook, `useDiscussionTopic(courseId, unitId)` in `courseware/data/apiHooks.ts`, and the query drops its bridge entry, so nothing writes that model any more. Layer C of the model-store dissolution (#1977); the only `discussionTopics` layer. The hook spreads `discussionTopicsQuery(courseId)` with `enabled: false` and a `select` that finds the unit's topic by `usageKey`. It never fetches: the fetch is `DiscussionsProvider`'s by design (#2113), and a disabled observer subscribes to the same cache entry, so the readers re-render when that fetch lands, which is how the sidebar opens the discussions panel once the topics arrive. `find` returns an element of the cached array, so the provider's `useCallback` dependency on `unit` stays stable across renders; the `select` is inlined because it is one `find` over a short list. `SidebarWidgetContext.unit` becomes `unit?: DiscussionTopic`, the value the hook returns, in place of the `Partial<DiscussionTopic>` that described `useModel`'s `{}`. The bridge's `idField` pass-through now has no user and is left for the teardown (#1977) with its test. Tests: the readers now read the client the component renders under, not the store the seed's bridged client filled. The two widget suites seed a `createTestQueryClient()` through the real query and nest a `QueryClientProvider` for it, and each gains *reads the topics without requesting them*, asserting the adapter's GET history is still the seed's two requests after render; the hook gets its own describe, including *fetches nothing on its own* and a case for the resolve-after-mount path. `initializeTestStore` registers the `course_topics` route from its own `unitBlocks`, with an `enabledInContext` option, beside the discussion config route it already registers: `Course.test`'s sidebar cases render the real `DiscussionsProvider`, whose query had been failing on the `onAny` fallback's empty reply and was tolerated only because the readers read the store. `test-utils.jsx` loses its seed helper and temporary adapter. The three suites that render the provider with no query client mock the hook the way they mock `useCourseHomeMeta`. The `discussionTopicsQuery` tests assert on the cache instead of the model store; they keep a store-backed client only because the app `QueryCache`, whose `onError` logs, still takes one for the bridge. BREAKING CHANGE: `useModel('discussionTopics', unitId)` returns `{}`: the model is no longer written. Read the current unit's topic with `useDiscussionTopic(courseId, unitId).data` from `./src/courseware/data/apiHooks`, populated by `DiscussionsProvider` (see "Accessing Course Data" in `src/courseware/course/sidebar/README.md`). The `unit` field of the context handed to a widget's `isAvailable` is `undefined` (was `{}`) for a unit with no in-context discussion topic; `unit?.id` is unaffected, `unit.id` throws. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
brian-smith-tcril
added this pull request to stack #2121
September 25, 2026 06:42
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## bsmith/sidebar-context-hook #2122 +/- ##
===============================================================
- Coverage 94.85% 94.85% -0.01%
===============================================================
Files 370 370
Lines 5990 5985 -5
Branches 1466 1462 -4
===============================================================
- Hits 5682 5677 -5
Misses 295 295
Partials 13 13 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
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
The three readers of the
discussionTopicsmodel — the discussions widget's trigger and panel, and the sidebar provider'sunit— read the current unit's topic through a new hook,useDiscussionTopic(courseId, unitId)incourseware/data/apiHooks.ts, and the query drops its bridge entry, so nothing writes that model any more. The hook spreadsdiscussionTopicsQuery(courseId)withenabled: falseand aselectthat finds the unit's topic byusageKey: it never fetches, because the fetch isDiscussionsProvider's by design (#2113), and a disabled observer subscribes to the same cache entry, so the readers re-render when that fetch lands — which is how the sidebar opens the discussions panel once the topics arrive.SidebarWidgetContext.unitbecomesunit?: DiscussionTopic, the value the hook returns, in place of thePartial<DiscussionTopic>that describeduseModel's{}. No learner-visible change and no request-count change: one topics request per sidebar mount, the Provider's. Breaking for operators:useModel('discussionTopics', unitId)returns{}andunitisundefinedwhere it was{}— see Operators — breaking. Part of the Redux → React Query migration (#1946, Stage 1); layer C of the model-store dissolution (#1977), the onlydiscussionTopicslayer, built on the three layers below it (#2120, #2113, #2116). Closes #2087.What changed
useDiscussionTopic(courseId, unitId), beside the query incourseware/data/apiHooks.ts:useQuery({ ...discussionTopicsQuery(courseId), enabled: false, select: topics => topics.find(topic => topic.usageKey === unitId) }).enabled: falseis hard-coded, not an option, per theuseIsCourseLoadedreasoning in Stop the courseware gate queries refetching from components under the gate #2098: a caller with no Provider above it stayspending, which fails loudly.findreturns an element of the cached array, so the provider'suseCallbackdependency onunitstays stable across renders; theselectis inlined because it is onefindover a short list (decision 2).widgets/discussions/DiscussionsSidebar.jsx,DiscussionsTrigger.jsx(itsuseSidebar()destructure gainscourseId) andcourseware/course/sidebar/SidebarContext.tsx— takeuseDiscussionTopic(courseId, unitId).datain place ofuseModel('discussionTopics', unitId).SidebarContext.tsxkeepsuseModelforcoursewareMeta(D3, Read sections and coursewareMeta from the courseware queries, not useModel #2089).unit?: DiscussionTopiconSidebarWidgetContext, no sentinel and no comment (decision 1).discussionTopicsQuerydrops itsmeta; the bridge'sidFieldpass-through then has no user and is left for the teardown (Dissolve the model-store normalized cache #1977) with its test (decision 9).useModelholdover line, souseModelappears nowhere in that file; the discussions widget README drops its "bridged into thediscussionTopicsmodel" sentence and names the hook.createTestQueryClient()through the real query and nest aQueryClientProviderfor it insidesetupTest'srender; each gains reads the topics without requesting them, which waits for the rendered trigger or panel and asserts the adapter's GET history is still the seed's two requests.apiHooks.test.tsx: thediscussionTopicsQuerycases assert on the cache instead of the model store, and auseDiscussionTopicdescribe covers the topic for its unit,undefinedfor a unit without one, fetches nothing on its own, and the resolve-after-mount path. Negative check, run: withenabled: truein the hook, exactly the three request-count cases fail.initializeTestStoreregisters thecourse_topicsroute from its ownunitBlocks, with anenabledInContextoption, beside the discussion config route it already registers.Course.test's sidebar cases render the realDiscussionsProvider, whose query had been failing on theonAnyfallback's empty reply and was tolerated only because the readers read the store.test-utils.jsxloses its seed helper and temporary adapter.SidebarContext.test.jsx,UpgradeTrigger.test.jsxandUpgradeWidgetContext.test.jsx, which render the provider with no query client, mock the hook the way they mockuseCourseHomeMeta; theiruseModelmocks stay forcoursewareMetauntil D3.Operators — breaking
useModel('discussionTopics', unitId)returns{}: the model is no longer written. Read the current unit's topic withuseDiscussionTopic(courseId, unitId).datafrom./src/courseware/data/apiHooks, populated byDiscussionsProvider(see Accessing Course Data insrc/courseware/course/sidebar/README.md).unitin theSidebarWidgetContexthanded to a widget'sisAvailableisundefined(was{}) for a unit with no in-context discussion topic.unit?.id, the built-in widget's own check, is unaffected;unit.idthrows. The declaration says so:unit?: DiscussionTopic.SIDEBAR_WIDGETSconfig, theProviderfield and the topics request itself are unchanged.Testing
npm run typesandnpm run lintclean; full suite 116 suites, 1172 passed, 0 skipped, with the two widget suites re-run after the review fixes.git grep "useModel('discussionTopics'" srcis empty. Manual checks: see the checklist below.Decisions
Full decision log
Decisions — read discussion topics from the query, not
useModel(#2087)Layer C of the #1977 model-store dissolution; the fourth layer of the running
stack #2121, on top of #2116 (#2112). Entries 1–6 were settled in the plan
review on 2026-09-24 (the review that first peeled out #2111, #2112 and #2118,
then resumed once those three had PRs out) and posted to #2087; the rest
landed with the code.
unit?: DiscussionTopic, no sentinel.useDiscussionTopicreturns theunit's topic or
undefined, and theSidebarWidgetContextdeclaration insidebar/SidebarContext.tsxsays so, where a widget author's editor showsit and checked against
discussionsIsAvailable's own typed parameter.Every in-repo read is an optional chain, so
{}→undefinedchangesnothing they see. The alternative,
select: topics => topics.find(…) ?? NO_TOPIC,would preserve
useModel's exact{}for a JavaScriptisAvailablewritten as
unit.id, at the cost of a hand-made value that exists onlyfor that contract. This is the line Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111's decision 7 said this layer
would change; settled in the review that peeled Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111 out. No comment on
the declaration (Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111, decision 13: the types carry none).
Inline
select, notuseCallback. The docs' concern with an inlinedselectis compute — "an inlinedselectfunction … will run on everyrender" (Render Optimizations › select)
— and this one is a
findover one course's unit topics. The referencestability the provider's
useCallbackdependency array needs comes fromfindreturning an element of the cached array, unchanged while the datais unchanged and kept by structural sharing across a refetch, not from
memoizing the selector.
useCallbackwould be the only memoized shape,since the selector closes over
unitId, and as the repo's firstselectit would read as a rule to memoize every select; the rule is the docs'
own — memoize a select when it does enough work to matter.
The two widget suites nest a
QueryClientProviderfor a client theyseed. The readers read the client the component renders under, and
setupTest'srenderbuilds a fresh one per call with no way to pass onein.
DiscussionsTrigger.test.jsxandDiscussionsSidebar.test.jsxrunthe real query on a
createTestQueryClient()against their own twodiscussion routes and nest a provider for it inside the
ui— theEnrollmentAlert.test.tsx/LoadedTabPage.test.jsxshape. AqueryClientoption onrenderwas set aside: a shared-harness changetwo callers do not justify, and F is where
render's options changeanyway (it loses
store), so that is the moment to decide them as a set.Request-count cases in both widget suites plus the hook-level test.
fetches nothing on its own (the
useIsCourseLoadedshape) pins theenabled: falseline; reads the topics without requesting them in eachwidget suite pins that the component goes through it — per component,
because each suite is the sole test of its component and the widgets are
untyped
.jsx, where a later rewire to a fetching hook would pass everyother test. Each flushes (
await act(async () => {})) and asserts thewhole GET history as an ordered URL list, the seed's config then topics,
so a fetching reader's second config request is caught too. A composed
count in
Course.testneeds an adapter handle the harness does not have(the adapter is created inside
initializeTestStore); worth adding ifF's test-infrastructure pass provides one. Negative check, run: with
enabled: truein the hook, exactly those three cases fail (fetchStatus'fetching'; three and four requests instead of two) and the twopre-existing fetches nothing on its own cases still pass.
The three suites that render the provider with no query client mock
useDiscussionTopic.SidebarContext.test.jsx,UpgradeTrigger.test.jsxand
UpgradeWidgetContext.test.jsxalready mock the provider's otherquery hook,
useCourseHomeMeta, rather than supply a client (Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111,decision 14); the new hook gets the same treatment,
jest.fn(() => ({ data: undefined }))overjest.requireActual. TheiruseModelmocks stay forcoursewareMetauntil D3 (Read sections and coursewareMeta from the courseware queries, not useModel #2089); that is themoment to give them a client instead, once the provider's last
useModelread is gone, and the
useDiscussionTopicmock is one of the lines D3deletes.
UpgradePanel.test.jsxrenders throughsetupTest'srenderand so has a client; the real hook runs idle there.
The
course_topicsmock route lives ininitializeTestStore. How itworked:
setupDiscussionSidebarstacked three adapters, and the renderran against the second, which
initializeTestStorehad given the configroute (
options.provider) but no topics route; itsonAnyfallbackanswers
200, {}, so the realDiscussionsProvider's query threw on.filterand errored in every one ofCourse.test's eight sidebarrenders — tolerated because the readers read the store the third
adapter's seed had filled. After this layer the readers read the render's
client, so that route has to exist, and
initializeTestStoreis the onlycode with a handle to the adapter. It registers the topics route from its
own
unitBlocksthroughbuildTopicsFromUnits, with a newenabledInContextoption (defaulttrue), beside the config route; theseed helper, its temporary adapter and its
restore()are deleted, andsetupDiscussionSidebarpassesenabledInContextthrough. Every othercaller is unaffected: the default provider is
'legacy', for which thequeryFnnever requests topics. The alternative, a passthrough adapterin
test-utils.jsx, relies on axios-mock-adapter's stacking semanticsthat nothing else in the suite uses. F later deletes the helper's seeding
half and renames what is left; the route is fixture data for the mocking
half that survives, so it rides along. Not renamed or restructured here.
The
discussionTopicsQuerytests keep a store-backed client for onereason: the app
QueryCache. The plan hadinitializeStore()leavingthat describe. It cannot yet:
createTestQueryClient()attaches the appcache (
createAppQueryCache(store), whoseonErroris what callslogError) only when given a store, because the same cache carries themodel-store bridge and takes the store for it. The describe now asserts
on the cache (
getQueryDatais the filtered list,[]for a legacyprovider,
undefinedafter the network error) and builds its client withcreateTestQueryClient(initializeStore()), with a comment naming why.The store argument goes when Dissolve the model-store normalized cache #1977 removes the bridge and the cache stops
taking one. The new
useDiscussionTopicdescribe needs no logging anduses a bare client.
What the three peeled layers left this one. The issue's task list
named
SidebarContextProvider.jsx:35and a prefetch; on the stack thethird reader was
sidebar/SidebarContext.tsx:87(Make useSidebar() the only sidebar context read and test the sidebar suites under the real provider #2112 merged theprovider and context into one module), the fetch was already
DiscussionsProvider's observer (Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111), and both widget files alreadyread the context through
useSidebar()(Make useSidebar() the only sidebar context read and test the sidebar suites under the real provider #2112), so the trigger's contextline only gained
courseId.DiscussionsProvider.test.tsxwas expectedunchanged and is: its
fetchStatus'idle'assertions hold with theprovider's disabled observer also on the key.
idFieldstays for F. With the bridge entry gone the bridge'sidFieldpass-through (Convert getCourseDiscussionTopics to React Query #2016) has no user in production or insetupTest.js; per the issue body it is noted for F (Dissolve the model-store normalized cache #1977) rather thanremoved here, and the bridge test updateModels keys models by idField
instead of id stays with it.
refactor!:with aBREAKING CHANGE:footer. No learner-visiblechange and no request-count change: one topics request per sidebar
mount, the Provider's. For plugins:
useModel('discussionTopics', unitId)returns
{}(nothing writes the model), the replacement isuseDiscussionTopic(courseId, unitId).data, andunitin theisAvailablecontext isundefined(was{}) for a unit with no topic—
unit?.idunaffected,unit.idthrows. The sidebar README'sAccessing Course Data names the hook in place of its
useModelholdover line, and
useModelappears nowhere in that file now.Manual testing
Checklist
Manual testing — read discussion topics from the query, not
useModel(#2087)In-browser verification against a live backend (tutor dev).
What changed: the discussions trigger, the discussions panel and the sidebar
provider's
unitread the current unit's topic throughuseDiscussionTopic(courseId, unitId), a disabled observer of the queryDiscussionsProviderfetches, instead of thediscussionTopicsmodel; thequery no longer writes that model. No request or storage change intended:
still one
v1/courses/{courseId}and onev2/course_topics/{courseId}persidebar mount, both the Provider's.
The bugs this layer could introduce. (1) A reader that never sees the
data — a disabled observer with nothing fetching its query stays
pendingforever, so the trigger and panel would never appear and
unitwould neverbe defined, with no error. Every reader renders inside the provider, where
DiscussionsProvideris mounted, so this should be impossible; a failuremeans that assumption is wrong. (2) A reader that fetches — a reader
mounting after the Provider's fetch settled would refetch at
staleTime: 0,showing as a second
course_topicsrequest pair. (3) The sync path — thereaders must re-render when the Provider's query resolves, which is what
opens a stored
DISCUSSIONSpreference and makes the trigger appear afterload; if the disabled observers did not subscribe, both would stay closed or
hidden until something else re-rendered. (4)
undefinedwhere{}was —an operator widget's
isAvailablewritten asunit.idnow throws(documented as breaking);
unit?.idis unaffected.Setup
A course with the Open edX discussions provider,
DISCUSSIONS_MFE_BASE_URLconfigured, and two units: one with in-context discussions on, one with
them off (Authoring → the unit's discussion setting). A legacy-provider
course if one is handy (optional; see Not run otherwise). Browser devtools
open on the Network tab filtered to
discussion, and on the Console.For bug 4, optionally a probe widget in
env.config.jsx(untracked — checka stale local copy is not carrying other overrides) whose
isAvailablereadsunit?.idand whose panel readsuseDiscussionTopic(courseId, unitId).datathroughuseSidebar(); remove itafterwards.
Checks
Request count (bug 2) — the #2098 protocol
v1/courses/{courseId}1,v2/course_topics/{courseId}1.course_topicsrequest.The readers see the data (bugs 1 and 3)
{DISCUSSIONS_MFE_BASE_URL}/{courseId}/category/{unitId}?inContextSidebar.useDiscussionTopic,useSidebaror.filter.Legacy provider
v1/courses/{courseId}1, nocourse_topicsrequest, no discussions trigger.Operator widget contract (bug 4, optional)
id; no console error.Results
Run 2026-09-25 on tutor dev. 7 of 9 checks run, all passing; 2 not run.
Run
v1/courses/{courseId}andv2/course_topics/{courseId}once each; withthe panel open, the next unit and back added no
course_topicsrequest.the iframe at
{DISCUSSIONS_MFE_BASE_URL}/{courseId}/category/{unitId}?inContextSidebar.the two units flipped the trigger with no new topics request.
the topics request resolved.
resolved; closed, reload — closed.
useDiscussionTopic,useSidebaror.filter.Not run
skips the topics request entirely for a legacy provider in
courseware/data/apiHooks.test.tsx(one request,[]cached), and thereaders then find no topic, the same path the discussions off check
exercised by hand.
undefinedwhere{}was): not tested. The value ispinned by returns undefined for a unit with no topic in the
useDiscussionTopicdescribe, and the contract bynpm run typescheckingdiscussionsIsAvailable's ownSidebarWidgetContextparameter againstunit?: DiscussionTopic.🤖 Generated with Claude Code