You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Part of #1946 — Redux → React Query migration (Stage 1). Part of the #1977 model-store dissolution (plan) — Layer C. Independent — can slot anywhere in the stack.
Goal: read discussion topics from the query instead of the discussionTopics model, and drop its bridge entry.
Context.#2016 converted the fetch to prefetchDiscussionTopics(queryClient, courseId) but deliberately left the three readers on useModel, since read conversions are this issue's home. The query resolves to a list of topics filtered to those with a usageKey; the bridge writes them into the model store keyed by usageKey (idField: 'usageKey'), which is why the readers look them up by unit id.
Tasks
Convert the three useModel('discussionTopics', unitId) readers — widgets/discussions/DiscussionsSidebar.jsx:22, widgets/discussions/DiscussionsTrigger.jsx:21, courseware/course/sidebar/SidebarContextProvider.jsx:35 — to a lookup by usageKey against coursewareQueryKeys.discussionTopics(courseId).
Verify: on an openedx-provider course the discussions trigger and sidebar still appear for units with a topic and stay hidden for units without one; legacy-provider courses unaffected; git grep discussionTopics src finds no model-store reference.
Note
This issue was authored by Claude (Claude Code) and reviewed before posting.
Findings that shape the task list
The readers key by unit id, the query holds a list. The conversion is a find(topic => topic.usageKey === unitId) over the query result, not a direct index. Each topic also carries its own id (the discussion topic id) which the render gates check (topic?.id && topic?.enabledInContext) — keep both fields straight.
The data is prefetched, not observed.prefetchDiscussionTopics is fired once from SidebarContextProvider's widget-registry effect, so the readers must subscribe to the cache entry without re-fetching it themselves — the same non-fetching read layer A introduces for the cross-tab outline alerts.
Single source, so no merge to untangle — unlike sequences/coursewareMeta, this model has exactly one writer, which is why it is independent of the rest of the stack.
Plan
Note
The plan below was generated by Claude (Claude Code) and reviewed before posting.
Decisions settled in review, refining the task list above:
useDiscussionTopic(courseId, unitId), a non-fetching per-unit read beside the query in courseware/data/apiHooks.ts: useQuery({ ...discussionTopicsQuery(courseId), enabled: false, select: topics => topics.find(topic => topic.usageKey === unitId) }). enabled: false is hard-coded, not an option, per the useIsCourseLoaded reasoning in Stop the courseware gate queries refetching from components under the gate #2098: the fetch is the Provider's by design, and a caller with no Provider above it stays pending, which fails loudly. Every reader takes .data by property access.
unit?: DiscussionTopic, no sentinel. The hook returns the topic or undefined, and the SidebarWidgetContext declaration says so where a widget author's editor shows it, checked against discussionsIsAvailable's own typed parameter. Every in-repo read is an optional chain. A ?? NO_TOPIC sentinel in select would preserve useModel's exact {} for a JavaScript isAvailable written as unit.id, at the cost of a hand-made value that exists only for that contract. No comment on the declaration, per Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111's decision that the types carry none.
Inline select, not useCallback. The docs' concern with an inlined select is compute ("will run on every render"), and this one is a find over one course's unit topics. The reference stability the provider's useCallback dependency array needs comes from find returning an element of the cached array, unchanged while the data is unchanged and kept by structural sharing across a refetch, not from memoizing the selector. As the repo's first select, useCallback here would read as a rule to memoize every select; the rule is the docs' own, memoize when the work matters.
The three readers read the render's client, which changes how the suites seed. Today's seed runs the query on a bridged throwaway client so the bridge fills the store; after this layer that client is never rendered under. The two widget suites (DiscussionsTrigger.test, DiscussionsSidebar.test) fill a createTestQueryClient() the same way and nest a QueryClientProvider for it inside setupTest's render, the EnrollmentAlert.test.tsx / LoadedTabPage.test.jsx shape; a queryClient option on render was set aside as a shared-harness change two callers do not justify, and F is where render's options change anyway.
Course.test's sidebar cases need the course_topics route on the adapter the render uses, and that route goes into initializeTestStore. Today initializeTestStore registers the discussion config route from options.provider but no topics route, and its onAny fallback answers 200, {}, so the real DiscussionsProvider's query throws on .filter and errors in every one of those renders, tolerated only because the readers read the store the seed filled. initializeTestStore is the only code with a handle to that adapter, so it registers the topics route from its own unitBlocks with a new enabledInContext option (default true), beside the config route; test-utils.jsx's seedDiscussionTopics, its temporary adapter and its restore() go. The route is fixture data for the mocking half of that helper, which survives F's removal of its seeding half; the helper is not renamed or restructured here.
Three suites render the provider with no query client and mock useDiscussionTopic.SidebarContext.test.jsx, UpgradeTrigger.test.jsx and UpgradeWidgetContext.test.jsx already mock the provider's other query hook (useCourseHomeMeta) rather than supply a client (Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111's decision 14); the new hook gets the same treatment, jest.fn(() => ({ data: undefined })). Their useModel mocks stay for coursewareMeta until D3 (Read sections and coursewareMeta from the courseware queries, not useModel #2089), which is the moment to give them a client instead, once the provider's last useModel read is gone.
Request-count cases in both widget suites plus the hook-level test. The hook case (fetches nothing on its own, the useIsCourseLoaded shape) pins the enabled: false line; a case in each widget 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. Each flushes before asserting and asserts the whole GET history as an ordered URL list, so a fetching reader's second config request is caught too, and the negative check (enabled: true makes it fail) is part of writing it. A composed count in Course.test needs an adapter handle the harness does not have; worth adding if F's test-infrastructure pass provides one.
The bridge entry goes; idField stays for F.discussionTopicsQuery drops its meta; nothing writes the discussionTopics model after this layer, and useModel('discussionTopics', unitId) returns {}. The bridge's idField pass-through then has no user, in production or in setupTest.js, and is noted for F rather than removed here, with its bridge test.
refactor!: with a BREAKING CHANGE: footer.useModel('discussionTopics', unitId) returns {}; unit in the isAvailable context is undefined (was {}) for a unit with no topic, so unit.id throws where unit?.id did not; the replacement is useDiscussionTopic(courseId, unitId).data, populated by DiscussionsProvider, and the sidebar README's Accessing Course Data names it in place of its useModel holdover line. No learner-visible change and no request-count change.
Full plan
Plan: #2087 — Read discussion topics from the query, not useModel (C)
The next layer of the running stack #2121, on top of #2116 (#2112, useSidebar() everywhere). Below it: #2113 (#2111, the provider's React
Query cleanup + TypeScript conversion) and #2120 (#2118, the awaited waitFors). All three were peeled out of this issue on 2026-09-24 and have
PRs out; this layer builds on that work. Layer C of the #1977 model-store
dissolution; the only discussionTopics layer. C is a wide layer: three
readers swapped lightly on ground the three layers below prepared. Nothing
later depends on it.
Scope is the issue body. It has no comments. Re-read 2026-09-24 against bsmith/sidebar-context-hook (the tip of #2116) after the three layers
landed on the stack; what they now own is marked below. The issue body names courseware/course/sidebar/SidebarContextProvider.jsx:35 as the third
reader; on the stack that read is sidebar/SidebarContext.tsx:87.
One query definition, one observer.courseware/data/apiHooks.ts exports discussionTopicsQuery(courseId), a queryOptions object whose queryFn
fetches the discussion config, stops with [] for a non-openedx provider,
otherwise fetches the course topics and keeps those with a usageKey
(#2111, decision 8). It still carries the bridge entry:
widgets/discussions/DiscussionsProvider.tsx observes it with enabled: !!getConfig().DISCUSSIONS_MFE_BASE_URL && hasDiscussionTab(tabs),
mounted by the framework for every enabled widget inside the sidebar
context (#2111, decisions 4–5). One request per sidebar mount; no refetch
on metadata writes. The old prefetchDiscussionTopics / discussionsPrefetch and the provider's prefetch effect are gone.
The query resolves to a list of DiscussionTopic ({ id, usageKey, enabledInContext, [key]: unknown }, typed in #2111 beside the query); the
bridge writes them into the store one model per topic keyed by usageKey
(idField), which is why the readers look a topic up by unit id while
each topic also carries its own id, the discussion topic id.
One module, sidebar/SidebarContext.tsx (#2112, decision 3): the
context object (module-private), SidebarProvider, useSidebar(), the
types. SidebarProvider takes widgets: SidebarWidget[] as a prop
(#2112, decision 4); Course.jsx passes getEnabledWidgets(). Every
consumer reads the context through useSidebar() (#2112, decision 1).
The three readers, all still useModel('discussionTopics', unitId):
widgets/discussions/DiscussionsSidebar.jsx:21 and DiscussionsTrigger.jsx:20 — render gate: if (!topic?.id || !topic?.enabledInContext) { return null; }. The
sidebar takes unitId, courseId and shouldDisplayFullScreen from useSidebar(); the trigger takes only unitId.
courseware/course/sidebar/SidebarContext.tsx:87 — unit, handed to
every widget's isAvailable as the unit field of SidebarWidgetContext,
declared at SidebarContext.tsx:26 as unit: Partial<DiscussionTopic>
(Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111, decision 7: the honest type of "a topic or {}", and the line
this layer changes), and sitting in getAvailableWidgets' useCallback
dependency array. The discussions widget's own check is discussionsIsAvailable = ({ unit }: SidebarWidgetContext) => !!(unit?.id && unit?.enabledInContext)
(widgets/discussions/widgetConfig.ts:6).
useModel returns a referentially stable {} when the model is absent
(generic/model-store/hooks.js:6-11), so a unit with no topic reads {}
and every ?. check falls through to hidden.
How the readers learn the data arrived. The bridge dispatches updateModels on the query's success; each reader's useSelector
re-renders; in the provider, unit changes, getAvailableWidgets gets a new
identity, useInitialSidebar recomputes and useSidebarSync opens the
discussions panel if the cascade says so.
The discussionTopicsQuery describe in apiHooks.test.tsx runs the query
on a bridged test client and asserts on the model store.
The two widget suites (DiscussionsSidebar.test.jsx, DiscussionsTrigger.test.jsx) seed the same way — createTestQueryClient(store).query(discussionTopicsQuery(courseId))
against their own two mocks — and then render the widget alone under <SidebarProvider widgets={[]}> through setupTest's render, which
builds its own, unbridged client (setupTest.js:296-309). That works
because the readers read the store the bridge filled, not the client that
fetched.
courseware/course/test-utils.jsx (setupDiscussionSidebar) seeds the
store the same way through a temporary adapter it restore()s; Make useSidebar() the only sidebar context read and test the sidebar suites under the real provider #2112
(decision 11) dropped its raw-context wrapper, so Course renders the
real SidebarProvider, which mounts the real DiscussionsProvider. Course.test.jsx's sidebar behavior describe reuses that store across
eight renders after cleanup(), each behind the isSuccess gate Un-awaited waitFor and act calls in Course.test.jsx and Sequence.test.jsx let tests pass without asserting #2118
added. What the Provider's fetch does in those renders today: initializeTestStore registers the config URL with options.provider
(setupTest.js:230,236; setupDiscussionSidebar passes 'openedx'),
but no course_topics route, and its onAny fallback answers 200, {}
(logUnhandledRequests, setupTest.js:160-166), so the queryFn's topics.filter throws on {} and the query errors (logged through the
QueryCache onError). Tolerated only because the readers read the store.
Every other suite that renders SidebarProvider does so under a client: setupTest's render (Sidebar.test, SidebarTriggers.test, SidebarBase.test, LockPaywall.test, Sequence.test, UpgradePanel.test,
the two widget suites) or an explicit QueryClientProvider
(CourseOutlineTray.test, CourseOutlineTrigger.test, DiscussionsProvider.test).
What changes structurally
The readers key by unit id; the query holds a list. The conversion is
a lookup, topics.find(topic => topic.usageKey === unitId), not an index.
The data is observed once, by the Provider. The readers need to
subscribe to the same cache entry without fetching it themselves —
otherwise three more observers mounting after the Provider's fetch settles
would each refetch at staleTime: 0, the Read the dates and outline tab data from their queries, not useModel #2083 problem (decisions-2083.md
entry 3).
The provider gains a query hook call. Today its only query hook is useCourseHomeMeta(courseId, { enabled: false }), which three suites
mock so they can render it with no client. After this layer it calls a
second one, so those suites need the same treatment for it (Tests, and
open question 5).
Simpler than the other layers: single writer, single query, no merge.
The change
1. useDiscussionTopic(courseId, unitId) — a non-fetching, per-unit read
Beside discussionTopicsQuery in courseware/data/apiHooks.ts:
// Reads the topic DiscussionsProvider loaded for the unit; never fetches.exportconstuseDiscussionTopic=(courseId: string,unitId: string)=>useQuery({
...discussionTopicsQuery(courseId),enabled: false,select: (topics)=>topics.find(topic=>topic.usageKey===unitId),});
Three choices in that, each with the docs behind it:
If the query has cached data, then the query will be initialized in the status === 'success' or isSuccess state. If the query does not have
cached data, then the query will start in the status === 'pending' and fetchStatus === 'idle' state. The query will not automatically fetch on
mount. The query will not automatically refetch in the background.
— Disabling/Pausing Queries › When enabled is false
Disabled observers still subscribe to the entry, so when the Provider's
query fills it they re-render — the sync mechanism above, pinned by a test
(below).
select does the unit lookup, so the reader gets one topic or undefined.
The select option lets you extract a specific portion of cached data
that your component should monitor. … A component using this hook will
only re-render if the data subset actually changes — not when unrelated
properties shift.
— Render Optimizations › select
find returns an element of the cached array, so the reference is stable
across renders while the data is unchanged, and undefined is stable by
nature — what getAvailableWidgets' dependency needs. The same page notes
an inlined select re-runs every render; it is a find over a short list
(open question 2).
Property access at every reader — useDiscussionTopic(courseId, unitId).data
— the repo's single-value rule (decisions-2085.md entry 2).
DiscussionTopic already types topics (#2111), so select infers DiscussionTopic | undefined. Nothing is added to the type.
Alternatives set aside: a list hook with find at each reader (three
copies of the lookup, and the readers learn a list shape they never knew); queryClient.getQueryData in the readers (non-reactive — "Do not use this
function inside a component, because it won't receive updates", QueryClient › getQueryData).
2. The three readers
DiscussionsSidebar.jsx: const topic = useDiscussionTopic(courseId, unitId).data;
— courseId is already destructured from useSidebar().
DiscussionsTrigger.jsx: the useSidebar() destructure becomes { courseId, unitId }, then the same line.
SidebarContext.tsx:87: const unit = useDiscussionTopic(courseId, unitId).data;
— no ?? {}, because unit sits in a useCallback dependency array
(decisions-2083.md entry 8; the shape the file already uses for courseHomeMeta). undefined is stable; {} per render is not.
Each file swaps its useModel import for useDiscussionTopic from @src/courseware/data/apiHooks (the two .jsx widgets have no other useModel; SidebarContext.tsx keeps it for coursewareMeta, D3). The
widgets already read the context through useSidebar() (#2112), so the
context lines need nothing beyond the trigger's added courseId.
3. The declared type of unit
SidebarContext.tsx:26 declares unit: Partial<DiscussionTopic> (#2111).
With the hook, the value is the topic or undefined, so the line becomes:
unit?: DiscussionTopic;
No trailing comment: #2111 (decision 13) settled that the declarations
carry no comments, and the ? says what the old {} comment would have.
This is where the old "undefined or a stable {}?" question lives: as a
type an operator's editor shows, checked against discussionsIsAvailable's
own parameter (widgetConfig.ts, typed in #2111), rather than as a README
comment. Inside the repo every read is an optional chain, so {}.id === undefined?.id and nothing else changes. #2111's decision 7
already states this is the line and the shape; open question 1 records the
sentinel alternative for the record.
4. The bridge entry goes
discussionTopicsQuery drops its meta. After this layer nothing writes
the discussionTopics model; useModel('discussionTopics', unitId) returns {} everywhere — the plugin-observable change (Behaviour changes).
idField has no user left, in production or in setupTest.js. Per the
issue body it is noted for F, not removed here; the bridge test updateModels keys models by idField instead of id in data/modelStoreBridge.test.ts stays for the same reason.
5. README
Most of the README work moved to #2111 and #2112 (the contract sections
link the declarations; the Provider section replaced the prefetch section;
the docs name SidebarProvider and SidebarContext.tsx). What is left for
C:
sidebar/README.md:158, Accessing Course Data: the holdover line for
the per-unit read becomes useDiscussionTopic(courseId, unitId)
(@src/courseware/data/apiHooks) beside the two metadata hooks, stating
what it returns and that it reads what DiscussionsProvider loaded (so a
custom widget using it renders the topic only where that widget is
enabled). useModel then appears nowhere in the file.
widgets/discussions/README.md:30: drop "The query is bridged into the discussionTopics model for the widget's useModel readers
(transitional, Dissolve the model-store normalized cache #1977)."; README.md:32: "reads the discussionTopics
model" → reads the current unit's topic through useDiscussionTopic.
Nothing in SidebarContext.tsx (§3) and nothing in ARCHITECTURE.md
(its only discussionTopics mention is the Provider code sample, which
does not change).
The seam every affected suite shares: the readers now read the client the
component renders under, not the store the seed's bridged client filled.
Today's seed (createTestQueryClient(store).query(discussionTopicsQuery(courseId)))
fills a throwaway client whose bridge writes the store; setupTest's render then builds a fresh, empty client. After this layer that render
sees no topics. Two shapes, chosen per suite:
Mount the real tree and mock the endpoints. Where DiscussionsProvider
is in the rendered tree, no seeding is needed: with the two discussion
routes on the adapter the render uses, the observer fetches into the
render's client, the production path.
Seed the render's client. Where a widget component renders alone
(<SidebarProvider widgets={[]}> mounts no DiscussionsProvider): createTestQueryClient(), await queryClient.query(discussionTopicsQuery(courseId))
against the suite's mocks, and a nested <QueryClientProvider client={queryClient}>
inside setupTest's render — the EnrollmentAlert.test.tsx / LoadedTabPage.test.jsx shape (open question 3).
A third shape, new since the plan was written, for the suites with no
client: mock the hook, the way they already mock useCourseHomeMeta
(open question 5).
Per suite:
widgets/discussions/DiscussionsTrigger.test.jsx and DiscussionsSidebar.test.jsx: seed the render's client (the component
renders alone). The initializeTestStore call stays — it seeds the units
model whose ids buildTopicsFromUnits turns into topics, and the global
store the render needs. The seed line drops its store argument
(createTestQueryClient()), and the client it fills is the one the nested
provider supplies. The four existing cases (shown for a unit with a topic,
hidden for 'no-discussion' / 'has-no-discussion') stay as written. One
new case in each, reads the topics without requesting them: after
render, axiosMock.history.get holds exactly one request to the course_topics URL (the seed's). Negative check: enabled: true in the
hook makes it read two.
courseware/course/test-utils.jsx and Course.test.jsxsidebar
behavior: the real tree — Course mounts SidebarProvider, which mounts DiscussionsProvider, whose observer fetches into the render's client. This is required, not a check: with the topics route missing, the onAny200, {} reply makes the query error (Context), so the four
cases that expect sidebar-DISCUSSIONS would fail under the converted
readers. The route has to be on the adapter initializeTestStore creates
(setupTest.js:212), which nothing outside that function can reach, so initializeTestStore registers course_topics itself, from the unitBlocks it already builds, honouring a new enabledInContext option
(default true) through buildTopicsFromUnits — beside the config route
it already registers from options.provider (open question 6). seedDiscussionTopics, its temporary adapter and its restore() are
deleted; setupDiscussionSidebar passes enabledInContext through to initializeTestStore with provider and courseHomeMetadata. The eight
cases' waitFors already wait for the sidebar state the fetch produces;
the keeps the sidebar closed case's comment ("Discussions prefetch
resolves") is reworded to name the Provider's query. The course_topics
route is harmless for every other caller of initializeTestStore: the
default provider is 'legacy', for which the queryFn never requests it.
courseware/data/apiHooks.test.tsx, the discussionTopicsQuery
describe: its cases assert on the cache instead of the store — queryClient.getQueryData(coursewareQueryKeys.discussionTopics(courseId))
is the filtered list, [] for the legacy provider (same one-request
history assertion), undefined after the network error (logError still
called). The bridged client and initializeStore leave the describe. New describe('courseware apiHooks — useDiscussionTopic'), on renderHook
with the useIsCourseLoaded describe's makeWrapper shape:
returns the topic for its unit — seed, render for 'unit-1'.
returns undefined for a unit with no topic — same seed, 'unit-2'.
fetches nothing on its own — empty client; fetchStatus'idle', dataundefined, no requests — the useIsCourseLoaded case's shape.
sees the topics when the query resolves after it mounted — render
first, then await queryClient.query(discussionTopicsQuery(courseId)); waitFordata to be the topic. The sidebar-sync mechanism, pinned.
sidebar/SidebarContext.test.jsx, widgets/upgrade/src/UpgradeTrigger.test.jsx, UpgradeWidgetContext.test.jsx: no client, so the provider's new useQuery would throw "No QueryClient set". Each adds a mock of @src/courseware/data/apiHooks (spread jest.requireActual, as their useCourseHomeMeta mocks do) with useDiscussionTopic: jest.fn(() => ({ data: undefined })).
Their useModel mock stays for coursewareMeta until D3 (Make useSidebar() the only sidebar context read and test the sidebar suites under the real provider #2112,
decision 5 said both mocks go as the two reads convert; this is the first
half). In SidebarContext.test.jsx nothing rendered reads unit (its
stub widgets' isAvailable ignore the argument); the mock exists so the
provider renders.
widgets/discussions/DiscussionsProvider.test.tsx (Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111): expected
unchanged — it has a client, so the provider's disabled observer joins the
topics key; does not load the topics when … asserts fetchStatus 'idle' on that key, which a disabled observer also produces, and the
request-count assertions do not see it. Verify rather than assume.
widgets/upgrade/src/UpgradePanel.test.jsx: renders through setupTest's render (a client), so the real hook runs idle with no
request; its useModel mock stays for coursewareMeta. Expected
unchanged.
data/modelStoreBridge.test.ts: unchanged (§4).
Expected unchanged, under a client, no reader of unit: Sidebar.test.jsx, SidebarTriggers.test.jsx, SidebarBase.test.jsx, LockPaywall.test.jsx, Sequence.test.jsx (its upgrade cases pass getEnabledWidgets(), so DiscussionsProvider mounts and requests the
config — 'legacy' from initializeTestStore — as it does today), defaultWidgets.test.js, CourseOutlineTray.test.jsx, CourseOutlineTrigger.test.jsx, the sidebar hooks suites.
Behaviour changes
None a learner sees, and no request-count change. The trigger and
panel appear for units with an enabled topic and stay hidden otherwise; the
topics request is the Provider's alone.
Plugins: useModel('discussionTopics', unitId) returns {} — the
breaking change, the B3 shape. A widget component reads useDiscussionTopic(courseId, unitId).data instead; the sidebar README
says so. SidebarWidgetContext.unit is undefined (was {}) for a unit
with no topic — declared in the type, so a TypeScript widget sees it at
compile time; unit?.id, the built-in widget's own check, is unaffected; unit.id throws. Named in the footer.
Commit type
refactor!: with a BREAKING CHANGE: footer: what stopped working
(useModel('discussionTopics', unitId) returns {}; unit in the isAvailable context is undefined for a unit without a topic), what to
call instead (useDiscussionTopic(courseId, unitId).data from ./src/courseware/data/apiHooks, populated by DiscussionsProvider), and
where the README is. One commit: the hook, the readers, the type line and
the bridge entry are one change.
setupTest.js's model seeding (F). The course_topics route added
to initializeTestStore is a mock route, not a seed.
A queryClient option on setupTest's render (open question 3; F's
test-infrastructure pass if wanted).
Converting the two widget .jsx files to TypeScript: a wide layer
touches lightly.
Open questions
Question 1 was settled in the review that peeled out the three layers;
2–4 were still open when it started; 5–6 are new, from re-reading the plan
against the stack.
unit?: DiscussionTopic, or keep Partial<DiscussionTopic> with a
stable {} sentinel in select?Settled 2026-09-24, in the review
that peeled out Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111: unit?: DiscussionTopic (§3). Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111's issue
body and decision 7 record it as the line and the shape this layer
produces; it is what select returns, it is the shape the
provider uses for courseHomeMeta, every in-repo read is an optional
chain, and the type now says it where a widget author will see it. The
alternative, select: topics => topics.find(...) ?? NO_TOPIC, preserves useModel's exact value for a JavaScript isAvailable written as unit.id, at the cost of a hand-made sentinel that exists only for that
contract.
Inline select or useCallback?Settled 2026-09-24: inline.
The docs' concern is compute ("an inlined select function … will run on
every render"), and this one is a find over one course's unit topics.
The stability the provider's dependency array needs comes from find
returning an element of the cached array (the same reference while the
data is unchanged, and structural sharing keeps unchanged topics at their
old references across a refetch), not from memoizing the selector. useCallback would be the only memoized shape, since the selector closes
over unitId, and as the repo's first select it 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.
Nested QueryClientProvider or a queryClient option on setupTest's render, for the two widget suites?Settled 2026-09-24: nested
provider, the established shape — EnrollmentAlert.test.tsx and LoadedTabPage.test.jsx render a component through setupTest's render with a pre-filled client nested inside the ui, and React
resolves the nearest provider. The change stays inside the two suites;
a render option is a shared-harness change that two callers do not
justify, and F is where render's options change anyway (it loses store), so that is the moment to decide them as a set. Fewer suites
need a seeded client than before Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111, since the Course suite now
mounts the real Provider and mocks endpoints instead.
Where the request-count case lives.Settled 2026-09-24: both
widget suites plus the hook-level fetches nothing on its own. The
hook case pins the enabled: false line; the widget cases pin that each
component goes through it, per component rather than one representative
because each suite is the sole test of its component, and because the
widgets are untyped .jsx where a later rewire to a fetching hook would
pass every other test. The composed homes were weighed and set aside: Course.test's adapter is created inside initializeTestStore and not
returned, so a count there needs a harness handle (F's test-infra pass,
if wanted; a composed count would be worth adding then), and DiscussionsProvider.test.tsx would need the real widget config, SidebarTriggers and Sidebar in its tree, changing its subject. The
provider's own read has no request count; it uses the same hook and is
typed against the contract. Each widget case flushes before asserting
and asserts the whole GET history as an ordered URL list (the seed's
config then topics), so a fetching reader's second config request is
caught too; the negative check (enabled: true → the case fails) is
part of writing it, given Make the courseware redirect-rule tests actually assert: un-awaited waitFor and unwired mocks from #1501 #2078, Make the useIsCourseLoaded tests actually assert: replaced mocks and unsettled checks from #2071 #2100 and Make the LoadedTabPage streak test actually assert: an unconditional mock from #354 and a fixture inert since AA-1018 #2107.
The three no-client suites: mock useDiscussionTopic, or give them a QueryClientProvider?Settled 2026-09-24: mock the hook. It is the
shape those suites already use for the provider's other query hook
(useCourseHomeMeta), and Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111's decision 14 chose mocking over the
wrapper for SidebarContext.test.jsx on the grounds that nothing it
renders should touch a query client. A client instead would be one more
wrapper in three suites for a hook that returns undefined in all of
them, and would leave the useCourseHomeMeta mock as the odd one out.
The client is the right move if these suites are to stop faking the
provider's data layer altogether, and the time for that is D3, when the
last useModel read leaves the provider and its mocks have to go anyway;
the useDiscussionTopic mock added here is one of the lines D3 deletes.
Where the course_topics mock route lives for Course.test. Settled 2026-09-24: in initializeTestStore, beside the config route it
already registers from options.provider, built from its own unitBlocks
with a new enabledInContext option — the only place with a handle to
the adapter the render uses. The alternative is a second adapter in test-utils.jsx constructed with onNoMatch: 'passthrough', which
forwards unmatched requests to the adapter beneath it; it works, but it
keeps the seed helper alive and depends on axios-mock-adapter's stacking
semantics, which nothing else in the suite relies on. F (Dissolve the model-store normalized cache #1977) later
deletes initializeTestStore's seeding half and renames what is left;
the route belongs to the mocking half that survives, beside the config
route options.provider already registers, so it rides along. This
layer does not rename or restructure the helper; that is F's.
Verification
nvm use && npm run types, npm run lint, full suite (output to a file).
Negative checks: enabled: true in useDiscussionTopic → the two without requesting them cases and fetches nothing on its own fail; the
bridge entry restored → nothing fails (the readers no longer look at the
store), which is expected and is why git grep is part of the check; unit removed from the provider's context literal → npm run types fails
(the Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111 check, still holding).
git grep "useModel('discussionTopics'" src empty; git grep discussionTopics src finds only queryKeys.ts, apiHooks.ts (the query
and the hook), DiscussionsProvider.tsx, the Provider code samples in sidebar/README.md and ARCHITECTURE.md, the factory, the new route in setupTest.js, and tests.
v1/courses/{courseId} then v2/course_topics/{courseId}: 1 each.
A unit with an in-context topic: the discussions trigger shows; opening
it loads {DISCUSSIONS_MFE_BASE_URL}/{courseId}/category/{unitId}?inContextSidebar.
A unit with in-context discussions turned off in Authoring: trigger and
panel hidden; navigating between the two units flips them without a new
topics request.
Stored preference DISCUSSIONS in localStorage: the panel auto-opens
once the query resolves (the sync path).
Legacy-provider course: no course_topics request, no trigger; if no
local legacy course exists, the query-level case carries it — note in
the manual-testing doc.
A widget in env.config.jsx still reading useModel('discussionTopics')
gets {} — the breaking change is real and documented.
Stack
One layer on top of bsmith/sidebar-context-hook (#2116, the tip of stack
#2121), added with gh stack add; closes #2087. D1/D2 (#2088) follows.
Part of #1946 — Redux → React Query migration (Stage 1). Part of the #1977 model-store dissolution (plan) — Layer C. Independent — can slot anywhere in the stack.
Goal: read discussion topics from the query instead of the
discussionTopicsmodel, and drop its bridge entry.Context. #2016 converted the fetch to
prefetchDiscussionTopics(queryClient, courseId)but deliberately left the three readers onuseModel, since read conversions are this issue's home. The query resolves to a list of topics filtered to those with ausageKey; the bridge writes them into the model store keyed byusageKey(idField: 'usageKey'), which is why the readers look them up by unit id.Tasks
useModel('discussionTopics', unitId)readers —widgets/discussions/DiscussionsSidebar.jsx:22,widgets/discussions/DiscussionsTrigger.jsx:21,courseware/course/sidebar/SidebarContextProvider.jsx:35— to a lookup byusageKeyagainstcoursewareQueryKeys.discussionTopics(courseId).meta: { models: [{ modelType: 'discussionTopics', … }] }fromprefetchDiscussionTopics.idFieldpass-through (added in Convert getCourseDiscussionTopics to React Query #2016) has no other user left, note it for layer F rather than removing it here.Verify: on an openedx-provider course the discussions trigger and sidebar still appear for units with a topic and stay hidden for units without one; legacy-provider courses unaffected;
git grep discussionTopics srcfinds no model-store reference.Note
This issue was authored by Claude (Claude Code) and reviewed before posting.
Findings that shape the task list
find(topic => topic.usageKey === unitId)over the query result, not a direct index. Each topic also carries its ownid(the discussion topic id) which the render gates check (topic?.id && topic?.enabledInContext) — keep both fields straight.prefetchDiscussionTopicsis fired once fromSidebarContextProvider's widget-registry effect, so the readers must subscribe to the cache entry without re-fetching it themselves — the same non-fetching read layer A introduces for the cross-tab outline alerts.sequences/coursewareMeta, this model has exactly one writer, which is why it is independent of the rest of the stack.Plan
Note
The plan below was generated by Claude (Claude Code) and reviewed before posting.
Decisions settled in review, refining the task list above:
SidebarWidget,SidebarWidgetContext,DiscussionTopic), replacedprefetchDiscussionTopicswithdiscussionTopicsQuery(courseId)observed by aDiscussionsProvider, and declaredunit: Partial<DiscussionTopic>as the honest type of today's{}-or-topic value. Make useSidebar() the only sidebar context read and test the sidebar suites under the real provider #2112 (PR refactor!: read the sidebar context through useSidebar() and test the sidebar suites under the real provider #2116) merged the provider and context intosidebar/SidebarContext.tsx, madeuseSidebar()the only read, and put the sidebar suites on the realSidebarProvider. Un-awaitedwaitForandactcalls inCourse.test.jsxandSequence.test.jsxlet tests pass without asserting #2118 (PR test: await every waitFor and act in Course.test and Sequence.test #2120) awaited everywaitForinCourse.testandSequence.test. So the third reader in the task list is nowSidebarContext.tsx:87, the "prefetched, not observed" note is now "observed once by the Provider", and the two widget files already read the context throughuseSidebar().useDiscussionTopic(courseId, unitId), a non-fetching per-unit read 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: the fetch is the Provider's by design, and a caller with no Provider above it stayspending, which fails loudly. Every reader takes.databy property access.unit?: DiscussionTopic, no sentinel. The hook returns the topic orundefined, and theSidebarWidgetContextdeclaration says so where a widget author's editor shows it, checked againstdiscussionsIsAvailable's own typed parameter. Every in-repo read is an optional chain. A?? NO_TOPICsentinel inselectwould preserveuseModel's exact{}for a JavaScriptisAvailablewritten asunit.id, at the cost of a hand-made value that exists only for that contract. No comment on the declaration, per Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111's decision that the types carry none.select, notuseCallback. The docs' concern with an inlinedselectis compute ("will run on every render"), and this one is afindover one course's unit topics. The reference stability the provider'suseCallbackdependency array needs comes fromfindreturning an element of the cached array, unchanged while the data is unchanged and kept by structural sharing across a refetch, not from memoizing the selector. As the repo's firstselect,useCallbackhere would read as a rule to memoize every select; the rule is the docs' own, memoize when the work matters.DiscussionsTrigger.test,DiscussionsSidebar.test) fill acreateTestQueryClient()the same way and nest aQueryClientProviderfor it insidesetupTest'srender, theEnrollmentAlert.test.tsx/LoadedTabPage.test.jsxshape; aqueryClientoption onrenderwas set aside as a shared-harness change two callers do not justify, and F is whererender's options change anyway.Course.test's sidebar cases need thecourse_topicsroute on the adapter the render uses, and that route goes intoinitializeTestStore. TodayinitializeTestStoreregisters the discussion config route fromoptions.providerbut no topics route, and itsonAnyfallback answers200, {}, so the realDiscussionsProvider's query throws on.filterand errors in every one of those renders, tolerated only because the readers read the store the seed filled.initializeTestStoreis the only code with a handle to that adapter, so it registers the topics route from its ownunitBlockswith a newenabledInContextoption (defaulttrue), beside the config route;test-utils.jsx'sseedDiscussionTopics, its temporary adapter and itsrestore()go. The route is fixture data for the mocking half of that helper, which survives F's removal of its seeding half; the helper is not renamed or restructured here.useDiscussionTopic.SidebarContext.test.jsx,UpgradeTrigger.test.jsxandUpgradeWidgetContext.test.jsxalready mock the provider's other query hook (useCourseHomeMeta) rather than supply a client (Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111's decision 14); the new hook gets the same treatment,jest.fn(() => ({ data: undefined })). TheiruseModelmocks stay forcoursewareMetauntil D3 (Read sections and coursewareMeta from the courseware queries, not useModel #2089), which is the moment to give them a client instead, once the provider's lastuseModelread is gone.fetches nothing on its own, theuseIsCourseLoadedshape) pins theenabled: falseline; a case in each widget 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. Each flushes before asserting and asserts the whole GET history as an ordered URL list, so a fetching reader's second config request is caught too, and the negative check (enabled: truemakes it fail) is part of writing it. A composed count inCourse.testneeds an adapter handle the harness does not have; worth adding if F's test-infrastructure pass provides one.idFieldstays for F.discussionTopicsQuerydrops itsmeta; nothing writes thediscussionTopicsmodel after this layer, anduseModel('discussionTopics', unitId)returns{}. The bridge'sidFieldpass-through then has no user, in production or insetupTest.js, and is noted for F rather than removed here, with its bridge test.refactor!:with aBREAKING CHANGE:footer.useModel('discussionTopics', unitId)returns{};unitin theisAvailablecontext isundefined(was{}) for a unit with no topic, sounit.idthrows whereunit?.iddid not; the replacement isuseDiscussionTopic(courseId, unitId).data, populated byDiscussionsProvider, and the sidebar README's Accessing Course Data names it in place of itsuseModelholdover line. No learner-visible change and no request-count change.Full plan
Plan: #2087 — Read discussion topics from the query, not
useModel(C)The next layer of the running stack #2121, on top of #2116 (#2112,
useSidebar()everywhere). Below it: #2113 (#2111, the provider's ReactQuery cleanup + TypeScript conversion) and #2120 (#2118, the awaited
waitFors). All three were peeled out of this issue on 2026-09-24 and havePRs out; this layer builds on that work. Layer C of the #1977 model-store
dissolution; the only
discussionTopicslayer. C is a wide layer: threereaders swapped lightly on ground the three layers below prepared. Nothing
later depends on it.
Scope is the issue body. It has no comments. Re-read 2026-09-24 against
bsmith/sidebar-context-hook(the tip of #2116) after the three layerslanded on the stack; what they now own is marked below. The issue body names
courseware/course/sidebar/SidebarContextProvider.jsx:35as the thirdreader; on the stack that read is
sidebar/SidebarContext.tsx:87.Context
How it works after #2111, #2112 and #2118
One query definition, one observer.
courseware/data/apiHooks.tsexportsdiscussionTopicsQuery(courseId), aqueryOptionsobject whosequeryFnfetches the discussion config, stops with
[]for a non-openedxprovider,otherwise fetches the course topics and keeps those with a
usageKey(#2111, decision 8). It still carries the bridge entry:
widgets/discussions/DiscussionsProvider.tsxobserves it withenabled: !!getConfig().DISCUSSIONS_MFE_BASE_URL && hasDiscussionTab(tabs),mounted by the framework for every enabled widget inside the sidebar
context (#2111, decisions 4–5). One request per sidebar mount; no refetch
on metadata writes. The old
prefetchDiscussionTopics/discussionsPrefetchand the provider'sprefetcheffect are gone.The query resolves to a list of
DiscussionTopic({ id, usageKey, enabledInContext, [key]: unknown }, typed in #2111 beside the query); thebridge writes them into the store one model per topic keyed by
usageKey(
idField), which is why the readers look a topic up by unit id whileeach topic also carries its own
id, the discussion topic id.One module,
sidebar/SidebarContext.tsx(#2112, decision 3): thecontext object (module-private),
SidebarProvider,useSidebar(), thetypes.
SidebarProvidertakeswidgets: SidebarWidget[]as a prop(#2112, decision 4);
Course.jsxpassesgetEnabledWidgets(). Everyconsumer reads the context through
useSidebar()(#2112, decision 1).The three readers, all still
useModel('discussionTopics', unitId):widgets/discussions/DiscussionsSidebar.jsx:21andDiscussionsTrigger.jsx:20— render gate:if (!topic?.id || !topic?.enabledInContext) { return null; }. Thesidebar takes
unitId,courseIdandshouldDisplayFullScreenfromuseSidebar(); the trigger takes onlyunitId.courseware/course/sidebar/SidebarContext.tsx:87—unit, handed toevery widget's
isAvailableas theunitfield ofSidebarWidgetContext,declared at
SidebarContext.tsx:26asunit: Partial<DiscussionTopic>(Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111, decision 7: the honest type of "a topic or
{}", and the linethis layer changes), and sitting in
getAvailableWidgets'useCallbackdependency array. The discussions widget's own check is
discussionsIsAvailable = ({ unit }: SidebarWidgetContext) => !!(unit?.id && unit?.enabledInContext)(
widgets/discussions/widgetConfig.ts:6).useModelreturns a referentially stable{}when the model is absent(
generic/model-store/hooks.js:6-11), so a unit with no topic reads{}and every
?.check falls through to hidden.How the readers learn the data arrived. The bridge dispatches
updateModelson the query's success; each reader'suseSelectorre-renders; in the provider,
unitchanges,getAvailableWidgetsgets a newidentity,
useInitialSidebarrecomputes anduseSidebarSyncopens thediscussions panel if the cascade says so.
Tests after the three layers.
DiscussionsProvider.test.tsx(Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111) covers the observer's gates and theno-refetch-on-metadata-write case as request counts on a
MockAdapter,under a
QueryClientProvider, rendering<SidebarProvider widgets={[]}>around the component; it mocks
useModelto{}(Make useSidebar() the only sidebar context read and test the sidebar suites under the real provider #2112, decision 5).discussionTopicsQuerydescribe inapiHooks.test.tsxruns the queryon a bridged test client and asserts on the model store.
DiscussionsSidebar.test.jsx,DiscussionsTrigger.test.jsx) seed the same way —createTestQueryClient(store).query(discussionTopicsQuery(courseId))against their own two mocks — and then render the widget alone under
<SidebarProvider widgets={[]}>throughsetupTest'srender, whichbuilds its own, unbridged client (
setupTest.js:296-309). That worksbecause the readers read the store the bridge filled, not the client that
fetched.
courseware/course/test-utils.jsx(setupDiscussionSidebar) seeds thestore the same way through a temporary adapter it
restore()s; Make useSidebar() the only sidebar context read and test the sidebar suites under the real provider #2112(decision 11) dropped its raw-context wrapper, so
Courserenders thereal
SidebarProvider, which mounts the realDiscussionsProvider.Course.test.jsx's sidebar behavior describe reuses that store acrosseight renders after
cleanup(), each behind theisSuccessgate Un-awaitedwaitForandactcalls inCourse.test.jsxandSequence.test.jsxlet tests pass without asserting #2118added. What the Provider's fetch does in those renders today:
initializeTestStoreregisters the config URL withoptions.provider(
setupTest.js:230,236;setupDiscussionSidebarpasses'openedx'),but no
course_topicsroute, and itsonAnyfallback answers200, {}(
logUnhandledRequests,setupTest.js:160-166), so thequeryFn'stopics.filterthrows on{}and the query errors (logged through theQueryCache
onError). Tolerated only because the readers read the store.SidebarProviderwith no query client at all andmock the provider's two data hooks instead (
useModel→{},useCourseHomeMeta→{ data: … }):sidebar/SidebarContext.test.jsx(Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111, decision 14 dropped its
QueryClientProviderwhen the prefetcheffect's
useQueryClient()went),widgets/upgrade/src/UpgradeTrigger.test.jsxand
UpgradeWidgetContext.test.jsx(both render with@testing-library/reactdirectly). Make useSidebar() the only sidebar context read and test the sidebar suites under the real provider #2112 (decision 5) recorded that theuseModelmocks go "when Read discussion topics from the query, not useModel #2087 and thecoursewareMetalayer move thetwo reads onto queries".
SidebarProviderdoes so under a client:setupTest'srender(Sidebar.test,SidebarTriggers.test,SidebarBase.test,LockPaywall.test,Sequence.test,UpgradePanel.test,the two widget suites) or an explicit
QueryClientProvider(
CourseOutlineTray.test,CourseOutlineTrigger.test,DiscussionsProvider.test).What changes structurally
a lookup,
topics.find(topic => topic.usageKey === unitId), not an index.subscribe to the same cache entry without fetching it themselves —
otherwise three more observers mounting after the Provider's fetch settles
would each refetch at
staleTime: 0, the Read the dates and outline tab data from their queries, not useModel #2083 problem (decisions-2083.mdentry 3).
useCourseHomeMeta(courseId, { enabled: false }), which three suitesmock so they can render it with no client. After this layer it calls a
second one, so those suites need the same treatment for it (Tests, and
open question 5).
Simpler than the other layers: single writer, single query, no merge.
The change
1.
useDiscussionTopic(courseId, unitId)— a non-fetching, per-unit readBeside
discussionTopicsQueryincourseware/data/apiHooks.ts:Three choices in that, each with the docs behind it:
enabled: false, hard-coded — no{ enabled }option. The Stop the courseware gate queries refetching from components under the gate #2098reasoning for
useIsCourseLoaded(decisions-2098.mdentry 4): no callerwants it on, because the fetch is the Provider's by design (Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111), and a
caller with no Provider above it stays
pendingforever, which failsloudly.
Disabled observers still subscribe to the entry, so when the Provider's
query fills it they re-render — the sync mechanism above, pinned by a test
(below).
selectdoes the unit lookup, so the reader gets one topic orundefined.findreturns an element of the cached array, so the reference is stableacross renders while the data is unchanged, and
undefinedis stable bynature — what
getAvailableWidgets' dependency needs. The same page notesan inlined
selectre-runs every render; it is afindover a short list(open question 2).
Property access at every reader —
useDiscussionTopic(courseId, unitId).data— the repo's single-value rule (
decisions-2085.mdentry 2).DiscussionTopicalready typestopics(#2111), soselectinfersDiscussionTopic | undefined. Nothing is added to the type.Alternatives set aside: a list hook with
findat each reader (threecopies of the lookup, and the readers learn a list shape they never knew);
queryClient.getQueryDatain the readers (non-reactive — "Do not use thisfunction inside a component, because it won't receive updates",
QueryClient › getQueryData).
2. The three readers
DiscussionsSidebar.jsx:const topic = useDiscussionTopic(courseId, unitId).data;—
courseIdis already destructured fromuseSidebar().DiscussionsTrigger.jsx: theuseSidebar()destructure becomes{ courseId, unitId }, then the same line.SidebarContext.tsx:87:const unit = useDiscussionTopic(courseId, unitId).data;— no
?? {}, becauseunitsits in auseCallbackdependency array(
decisions-2083.mdentry 8; the shape the file already uses forcourseHomeMeta).undefinedis stable;{}per render is not.Each file swaps its
useModelimport foruseDiscussionTopicfrom@src/courseware/data/apiHooks(the two.jsxwidgets have no otheruseModel;SidebarContext.tsxkeeps it forcoursewareMeta, D3). Thewidgets already read the context through
useSidebar()(#2112), so thecontext lines need nothing beyond the trigger's added
courseId.3. The declared type of
unitSidebarContext.tsx:26declaresunit: Partial<DiscussionTopic>(#2111).With the hook, the value is the topic or
undefined, so the line becomes:No trailing comment: #2111 (decision 13) settled that the declarations
carry no comments, and the
?says what the old{}comment would have.This is where the old "
undefinedor a stable{}?" question lives: as atype an operator's editor shows, checked against
discussionsIsAvailable'sown parameter (
widgetConfig.ts, typed in #2111), rather than as a READMEcomment. Inside the repo every read is an optional chain, so
{}.id === undefined?.idand nothing else changes. #2111's decision 7already states this is the line and the shape; open question 1 records the
sentinel alternative for the record.
4. The bridge entry goes
discussionTopicsQuerydrops itsmeta. After this layer nothing writesthe
discussionTopicsmodel;useModel('discussionTopics', unitId)returns{}everywhere — the plugin-observable change (Behaviour changes).idFieldhas no user left, in production or insetupTest.js. Per theissue body it is noted for F, not removed here; the bridge test
updateModels keys models by idField instead of id in
data/modelStoreBridge.test.tsstays for the same reason.5. README
Most of the README work moved to #2111 and #2112 (the contract sections
link the declarations; the Provider section replaced the prefetch section;
the docs name
SidebarProviderandSidebarContext.tsx). What is left forC:
sidebar/README.md:158, Accessing Course Data: the holdover line forthe per-unit read becomes
useDiscussionTopic(courseId, unitId)(
@src/courseware/data/apiHooks) beside the two metadata hooks, statingwhat it returns and that it reads what
DiscussionsProviderloaded (so acustom widget using it renders the topic only where that widget is
enabled).
useModelthen appears nowhere in the file.widgets/discussions/README.md:30: drop "The query is bridged into thediscussionTopicsmodel for the widget'suseModelreaders(transitional, Dissolve the model-store normalized cache #1977).";
README.md:32: "reads thediscussionTopicsmodel" → reads the current unit's topic through
useDiscussionTopic.SidebarContext.tsx(§3) and nothing inARCHITECTURE.md(its only
discussionTopicsmention is the Provider code sample, whichdoes not change).
Wording is for code review.
6. What does not change
discussionsIsAvailable, the render gates, the iframe URL.coursewareMetaread inSidebarContext.tsx:86(D3, Read sections and coursewareMeta from the courseware queries, not useModel #2089).useSidebar()and the module shape (Make useSidebar() the only sidebar context read and test the sidebar suites under the real provider #2112).Tests
The seam every affected suite shares: the readers now read the client the
component renders under, not the store the seed's bridged client filled.
Today's seed (
createTestQueryClient(store).query(discussionTopicsQuery(courseId)))fills a throwaway client whose bridge writes the store;
setupTest'srenderthen builds a fresh, empty client. After this layer that rendersees no topics. Two shapes, chosen per suite:
DiscussionsProvideris in the rendered tree, no seeding is needed: with the two discussion
routes on the adapter the render uses, the observer fetches into the
render's client, the production path.
(
<SidebarProvider widgets={[]}>mounts noDiscussionsProvider):createTestQueryClient(),await queryClient.query(discussionTopicsQuery(courseId))against the suite's mocks, and a nested
<QueryClientProvider client={queryClient}>inside
setupTest'srender— theEnrollmentAlert.test.tsx/LoadedTabPage.test.jsxshape (open question 3).A third shape, new since the plan was written, for the suites with no
client: mock the hook, the way they already mock
useCourseHomeMeta(open question 5).
Per suite:
widgets/discussions/DiscussionsTrigger.test.jsxandDiscussionsSidebar.test.jsx: seed the render's client (the componentrenders alone). The
initializeTestStorecall stays — it seeds theunitsmodel whose ids
buildTopicsFromUnitsturns into topics, and the globalstore the render needs. The seed line drops its
storeargument(
createTestQueryClient()), and the client it fills is the one the nestedprovider supplies. The four existing cases (shown for a unit with a topic,
hidden for
'no-discussion'/'has-no-discussion') stay as written. Onenew case in each, reads the topics without requesting them: after
render,
axiosMock.history.getholds exactly one request to thecourse_topicsURL (the seed's). Negative check:enabled: truein thehook makes it read two.
courseware/course/test-utils.jsxandCourse.test.jsxsidebarbehavior: the real tree —
CoursemountsSidebarProvider, which mountsDiscussionsProvider, whose observer fetches into the render's client.This is required, not a check: with the topics route missing, the
onAny200, {}reply makes the query error (Context), so the fourcases that expect
sidebar-DISCUSSIONSwould fail under the convertedreaders. The route has to be on the adapter
initializeTestStorecreates(
setupTest.js:212), which nothing outside that function can reach, soinitializeTestStoreregisterscourse_topicsitself, from theunitBlocksit already builds, honouring a newenabledInContextoption(default
true) throughbuildTopicsFromUnits— beside the config routeit already registers from
options.provider(open question 6).seedDiscussionTopics, its temporary adapter and itsrestore()aredeleted;
setupDiscussionSidebarpassesenabledInContextthrough toinitializeTestStorewithproviderandcourseHomeMetadata. The eightcases'
waitFors already wait for the sidebar state the fetch produces;the keeps the sidebar closed case's comment ("Discussions prefetch
resolves") is reworded to name the Provider's query. The
course_topicsroute is harmless for every other caller of
initializeTestStore: thedefault provider is
'legacy', for which thequeryFnnever requests it.courseware/data/apiHooks.test.tsx, thediscussionTopicsQuerydescribe: its cases assert on the cache instead of the store —
queryClient.getQueryData(coursewareQueryKeys.discussionTopics(courseId))is the filtered list,
[]for the legacy provider (same one-requesthistory assertion),
undefinedafter the network error (logErrorstillcalled). The bridged client and
initializeStoreleave the describe. Newdescribe('courseware apiHooks — useDiscussionTopic'), onrenderHookwith the
useIsCourseLoadeddescribe'smakeWrappershape:'unit-1'.undefinedfor a unit with no topic — same seed,'unit-2'.fetchStatus'idle',dataundefined, no requests — theuseIsCourseLoadedcase's shape.first, then
await queryClient.query(discussionTopicsQuery(courseId));waitFordatato be the topic. The sidebar-sync mechanism, pinned.sidebar/SidebarContext.test.jsx,widgets/upgrade/src/UpgradeTrigger.test.jsx,UpgradeWidgetContext.test.jsx: no client, so the provider's newuseQuerywould throw "No QueryClient set". Each adds a mock of@src/courseware/data/apiHooks(spreadjest.requireActual, as theiruseCourseHomeMetamocks do) withuseDiscussionTopic: jest.fn(() => ({ data: undefined })).Their
useModelmock stays forcoursewareMetauntil D3 (Make useSidebar() the only sidebar context read and test the sidebar suites under the real provider #2112,decision 5 said both mocks go as the two reads convert; this is the first
half). In
SidebarContext.test.jsxnothing rendered readsunit(itsstub widgets'
isAvailableignore the argument); the mock exists so theprovider renders.
widgets/discussions/DiscussionsProvider.test.tsx(Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111): expectedunchanged — it has a client, so the provider's disabled observer joins the
topics key; does not load the topics when … asserts
fetchStatus'idle'on that key, which a disabled observer also produces, and therequest-count assertions do not see it. Verify rather than assume.
widgets/upgrade/src/UpgradePanel.test.jsx: renders throughsetupTest'srender(a client), so the real hook runs idle with norequest; its
useModelmock stays forcoursewareMeta. Expectedunchanged.
data/modelStoreBridge.test.ts: unchanged (§4).unit:Sidebar.test.jsx,SidebarTriggers.test.jsx,SidebarBase.test.jsx,LockPaywall.test.jsx,Sequence.test.jsx(its upgrade cases passgetEnabledWidgets(), soDiscussionsProvidermounts and requests theconfig —
'legacy'frominitializeTestStore— as it does today),defaultWidgets.test.js,CourseOutlineTray.test.jsx,CourseOutlineTrigger.test.jsx, the sidebar hooks suites.Behaviour changes
panel appear for units with an enabled topic and stay hidden otherwise; the
topics request is the Provider's alone.
useModel('discussionTopics', unitId)returns{}— thebreaking change, the B3 shape. A widget component reads
useDiscussionTopic(courseId, unitId).datainstead; the sidebar READMEsays so.
SidebarWidgetContext.unitisundefined(was{}) for a unitwith no topic — declared in the type, so a TypeScript widget sees it at
compile time;
unit?.id, the built-in widget's own check, is unaffected;unit.idthrows. Named in the footer.Commit type
refactor!:with aBREAKING CHANGE:footer: what stopped working(
useModel('discussionTopics', unitId)returns{};unitin theisAvailablecontext isundefinedfor a unit without a topic), what tocall instead (
useDiscussionTopic(courseId, unitId).datafrom./src/courseware/data/apiHooks, populated byDiscussionsProvider), andwhere the README is. One commit: the hook, the readers, the type line and
the bridge entry are one change.
Not in scope
coursewareMetainSidebarContext.tsx(D3, Read sections and coursewareMeta from the courseware queries, not useModel #2089), and with it theuseModelmocks in the four suites that keep one.idFieldfrom the bridge and its test (F, Dissolve the model-store normalized cache #1977).setupTest.js's model seeding (F). Thecourse_topicsroute addedto
initializeTestStoreis a mock route, not a seed.queryClientoption onsetupTest'srender(open question 3; F'stest-infrastructure pass if wanted).
.jsxfiles to TypeScript: a wide layertouches lightly.
Open questions
Question 1 was settled in the review that peeled out the three layers;
2–4 were still open when it started; 5–6 are new, from re-reading the plan
against the stack.
unit?: DiscussionTopic, or keepPartial<DiscussionTopic>with astable
{}sentinel inselect? Settled 2026-09-24, in the reviewthat peeled out Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111:
unit?: DiscussionTopic(§3). Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111's issuebody and decision 7 record it as the line and the shape this layer
produces; it is what
selectreturns, it is the shape theprovider uses for
courseHomeMeta, every in-repo read is an optionalchain, and the type now says it where a widget author will see it. The
alternative,
select: topics => topics.find(...) ?? NO_TOPIC, preservesuseModel's exact value for a JavaScriptisAvailablewritten asunit.id, at the cost of a hand-made sentinel that exists only for thatcontract.
selectoruseCallback? Settled 2026-09-24: inline.The docs' concern is compute ("an inlined
selectfunction … will run onevery render"), and this one is a
findover one course's unit topics.The stability the provider's dependency array needs comes from
findreturning an element of the cached array (the same reference while the
data is unchanged, and structural sharing keeps unchanged topics at their
old references across a refetch), not from memoizing the selector.
useCallbackwould be the only memoized shape, since the selector closesover
unitId, and as the repo's firstselectit would read as a ruleto memoize every select; the rule is the docs' own — memoize a select
when it does enough work to matter.
QueryClientProvideror aqueryClientoption onsetupTest'srender, for the two widget suites? Settled 2026-09-24: nestedprovider, the established shape —
EnrollmentAlert.test.tsxandLoadedTabPage.test.jsxrender a component throughsetupTest'srenderwith a pre-filled client nested inside theui, and Reactresolves the nearest provider. The change stays inside the two suites;
a
renderoption is a shared-harness change that two callers do notjustify, and F is where
render's options change anyway (it losesstore), so that is the moment to decide them as a set. Fewer suitesneed a seeded client than before Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111, since the
Coursesuite nowmounts the real Provider and mocks endpoints instead.
widget suites plus the hook-level fetches nothing on its own. The
hook case pins the
enabled: falseline; the widget cases pin that eachcomponent goes through it, per component rather than one representative
because each suite is the sole test of its component, and because the
widgets are untyped
.jsxwhere a later rewire to a fetching hook wouldpass every other test. The composed homes were weighed and set aside:
Course.test's adapter is created insideinitializeTestStoreand notreturned, so a count there needs a harness handle (F's test-infra pass,
if wanted; a composed count would be worth adding then), and
DiscussionsProvider.test.tsxwould need the real widget config,SidebarTriggersandSidebarin its tree, changing its subject. Theprovider's own read has no request count; it uses the same hook and is
typed against the contract. Each widget case flushes before asserting
and asserts the whole GET history as an ordered URL list (the seed's
config then topics), so a fetching reader's second config request is
caught too; the negative check (
enabled: true→ the case fails) ispart of writing it, given Make the courseware redirect-rule tests actually assert: un-awaited
waitForand unwired mocks from #1501 #2078, Make theuseIsCourseLoadedtests actually assert: replaced mocks and unsettled checks from #2071 #2100 and Make theLoadedTabPagestreak test actually assert: an unconditional mock from #354 and a fixture inert since AA-1018 #2107.useDiscussionTopic, or give them aQueryClientProvider? Settled 2026-09-24: mock the hook. It is theshape those suites already use for the provider's other query hook
(
useCourseHomeMeta), and Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111's decision 14 chose mocking over thewrapper for
SidebarContext.test.jsxon the grounds that nothing itrenders should touch a query client. A client instead would be one more
wrapper in three suites for a hook that returns
undefinedin all ofthem, and would leave the
useCourseHomeMetamock as the odd one out.The client is the right move if these suites are to stop faking the
provider's data layer altogether, and the time for that is D3, when the
last
useModelread leaves the provider and its mocks have to go anyway;the
useDiscussionTopicmock added here is one of the lines D3 deletes.course_topicsmock route lives forCourse.test.Settled 2026-09-24: in
initializeTestStore, beside the config route italready registers from
options.provider, built from its ownunitBlockswith a new
enabledInContextoption — the only place with a handle tothe adapter the render uses. The alternative is a second adapter in
test-utils.jsxconstructed withonNoMatch: 'passthrough', whichforwards unmatched requests to the adapter beneath it; it works, but it
keeps the seed helper alive and depends on axios-mock-adapter's stacking
semantics, which nothing else in the suite relies on. F (Dissolve the model-store normalized cache #1977) later
deletes
initializeTestStore's seeding half and renames what is left;the route belongs to the mocking half that survives, beside the config
route
options.provideralready registers, so it rides along. Thislayer does not rename or restructure the helper; that is F's.
Verification
nvm use && npm run types,npm run lint, full suite (output to a file).enabled: trueinuseDiscussionTopic→ the twowithout requesting them cases and fetches nothing on its own fail; the
bridge entry restored → nothing fails (the readers no longer look at the
store), which is expected and is why
git grepis part of the check;unitremoved from the provider's context literal →npm run typesfails(the Clean up SidebarContextProvider for React Query and convert it to TypeScript #2111 check, still holding).
git grep "useModel('discussionTopics'" srcempty;git grep discussionTopics srcfinds onlyqueryKeys.ts,apiHooks.ts(the queryand the hook),
DiscussionsProvider.tsx, the Provider code samples insidebar/README.mdandARCHITECTURE.md, the factory, the new route insetupTest.js, and tests.Stop the courseware gate queries refetching from components under the gate #2098 protocol — hard reload a unit, wait for the Network tab to go idle:
v1/courses/{courseId}thenv2/course_topics/{courseId}: 1 each.it loads
{DISCUSSIONS_MFE_BASE_URL}/{courseId}/category/{unitId}?inContextSidebar.panel hidden; navigating between the two units flips them without a new
topics request.
DISCUSSIONSin localStorage: the panel auto-opensonce the query resolves (the sync path).
course_topicsrequest, no trigger; if nolocal legacy course exists, the query-level case carries it — note in
the manual-testing doc.
env.config.jsxstill readinguseModel('discussionTopics')gets
{}— the breaking change is real and documented.Stack
One layer on top of
bsmith/sidebar-context-hook(#2116, the tip of stack#2121), added with
gh stack add; closes #2087. D1/D2 (#2088) follows.