refactor!: convert getCourseDiscussionTopics to React Query - #2068
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2068 +/- ##
==========================================
+ Coverage 93.72% 93.74% +0.01%
==========================================
Files 369 368 -1
Lines 6028 6029 +1
Branches 1428 1391 -37
==========================================
+ Hits 5650 5652 +2
Misses 361 361
+ Partials 17 16 -1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
1ed4aa2 to
160a8e5
Compare
160a8e5 to
c3e63ca
Compare
arbrandes
left a comment
There was a problem hiding this comment.
Pre-approved with one minor suggestion.
| export const prefetchDiscussionTopics = (queryClient: QueryClient, courseId: string) => ( | ||
| queryClient.prefetchQuery({ | ||
| queryKey: coursewareQueryKeys.discussionTopics(courseId), | ||
| queryFn: async () => { | ||
| const config: { provider: string } = await getCourseDiscussionConfig(courseId); | ||
| // Only load topics for the openedx provider, the legacy provider uses | ||
| // the xblock | ||
| if (config.provider !== 'openedx') { | ||
| return []; | ||
| } | ||
| const topics: { usageKey: string | null }[] = await getCourseTopics(courseId); | ||
| return topics.filter(topic => topic.usageKey); | ||
| }, | ||
| meta: { models: [{ modelType: 'discussionTopics', strategy: 'updateModels', idField: 'usageKey' }] }, | ||
| }) |
There was a problem hiding this comment.
It turns out prefetchQuery is now @deprecated in the version of query-core we're installing. The deprecation message suggests using queryClient.query({ ... }).catch(() => {}).
There was a problem hiding this comment.
used an imported noop as the examples in https://tanstack.com/query/latest/docs/framework/react/guides/prefetching do, but went with () => {} for the README example to avoid recommending extra imports.
c3e63ca to
92ff069
Compare
d6e3ed4 to
90a08c2
Compare
90a08c2 to
d79ba96
Compare
d79ba96 to
36a715c
Compare
36a715c to
a2a6754
Compare
a2a6754 to
8cf6b42
Compare
The last courseware thunk becomes prefetchDiscussionTopics in courseware/data/apiHooks.ts — a plain prefetchQuery wrapper, not a hook, since its only consumer is the imperative widget-registry prefetch effect in SidebarContextProvider. The provider gate (topics only for the openedx provider) moves inside queryFn, the model-store bridge learns idField so the discussionTopics model stays keyed by usageKey, and useDispatch leaves SidebarContextProvider. The useModel readers are untouched (#1977), and courseware/data/thunks.js is deleted. BREAKING CHANGE: the sidebar widget prefetch contract no longer receives dispatch — the context object is now { courseId, course, queryClient }. External SIDEBAR_WIDGETS whose prefetch dispatched a Redux thunk must fetch via the provided React Query client instead. Closes #2016 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8cf6b42 to
34eb3d8
Compare
Summary
Convert the last courseware thunk to React Query:
getCourseDiscussionTopicsbecomesprefetchDiscussionTopics, an imperativequeryClient.query(...).catch(noop)wrapper driven by the sidebar widget-registry prefetch, andcourseware/data/thunks.jsis deleted. This completes Target 5 (leaf) of the courseware decomposition (plan) in the Redux → React Query migration (#1946, Stage 1), stacked on the remaining-writers conversion #2067. Closes #2016.Breaking change
The sidebar widget
prefetchcontract no longer receivesdispatch— the context object is now{ courseId, course, queryClient }. External widgets registered via theSIDEBAR_WIDGETSconfig key whoseprefetchdispatched a Redux thunk must fetch through the provided React Query client instead. The commit carries arefactor!:subject and aBREAKING CHANGE:footer (semantic-release major). Why break now rather than at the Redux teardown: the registry mechanism is ~5 months old (#1885/#1897), its only in-repoprefetchuser was this thunk, a code search finds no external consumer, and #1976 makes the break inevitable — one contract change instead of two.What changed
courseware/data/apiHooks.ts:prefetchDiscussionTopics(queryClient, courseId)— a plain function, not a hook (the sole consumer is imperative). The thunk's structure ports 1:1 intoqueryFn: the config fetch, the openedx-provider gate (original comment included), and theusageKeyfilter; a non-openedx provider resolves to[], which bridges as a no-op — the thunk's dispatch-nothing path. The.catch(noop)means the call never rejects; failures still log through the app QueryCacheonError→logError, the thunk's catch behavior.data/modelStoreBridge.ts:idFieldpass-through. ThediscussionTopicsmodel is keyed byusageKey(the unit id), whichModelMirrorcouldn't express. The keying itself is pre-existing model-store code (model[idField ?? 'id']in the slice helpers; the thunk already dispatchedidField: 'usageKey') — the bridge now forwards the field from querymetaonto the dispatched action payloads.SidebarContextProviderpasses{ courseId, course, queryClient }to widget prefetches;useDispatchand thereact-reduximport leave the file.discussionsPrefetchkeeps itsDISCUSSIONS_MFE_BASE_URL+ discussion-tab gate and calls the new function.SidebarContextProvider/DiscussionsSidebar/DiscussionsTriggerkeep theiruseModel('discussionTopics', unitId)reads via the bridge; read conversions are Dissolve the model-store normalized cache #1977's home.courseware/data/thunks.js— this was the last thunk, andcourseware/data/index.jsalready had no./thunksexports.executeThunkfor the real converted path (prefetchDiscussionTopicson a bridge-wired test client), keeping their endpoint mocks meaningful. New cases: theusageKeyfilter (previously untested), the legacy-provider skip (no topics request), the config-failure path, and a bridgeidFieldcase.SidebarContextProvider.test.jsxgains aQueryClientProviderwrapper and loses itsreact-reduxmock, which only fed the provider'suseDispatch.Testing
npm run types(0 errors),npm run lint(clean), full jest suite green at head (111 suites, 1120 passed / 3 pre-existing skips). Manual pass on tutor local in the details block below; the legacy-provider skip rests on its hook-level case (mapped in the manual-testing results).Decisions
Full decision log
Decisions — getCourseDiscussionTopics → React Query (#2016)
A prefetch function, not a hook. No reader converts in this issue
(useModel → query-result read conversions are Dissolve the model-store normalized cache #1977's home), and the
thunk's only dispatcher is imperative — the widget-registry prefetch effect
in
SidebarContextProvider— socourseware/data/apiHooks.tsgainsprefetchDiscussionTopics(queryClient, courseId)wrappingqueryClient.query(...).catch(noop)rather than auseQueryhook. The threeuseModel('discussionTopics', unitId)readers (SidebarContextProvider,DiscussionsSidebar,DiscussionsTrigger) are untouched and keep workingthrough the bridge.
The provider gate lives inside
queryFn. Same two-call sequence asthe thunk: fetch the discussion config, and only for
config.provider === 'openedx'fetch topics (original comment andusageKeyfilter preserved). Non-openedx resolves to[]— the bridge'supdateModelsover an empty array is a no-op, the same end state as thethunk's dispatch-nothing path (
models.discussionTopicsstays unset eitherway; returning
nullinstead would crash the bridge'sforEach).The
.catch(noop)means the call never rejects; failures still logthrough the app QueryCache
onError→logError, the thunk's catch behavior.The bridge gained an
idFieldpass-through — the keying behavior itselfis pre-existing Redux code, not something this layer added. The
model-store slice has always keyed models by
model[idField ?? 'id'](theadd/updatehelpers ingeneric/model-store/slice.js, withidFieldaccepted on every add/update action payload), and the old thunk already
dispatched
updateModels({ …, idField: 'usageKey' })— thediscussionTopicsmodel is keyed byusageKey(the unit id; each topickeeps its own
id, the discussion topic id, which readers also check).What was missing was only the bridge link:
ModelMirrorcouldn't express anon-
idkey, so this layer forwardsidFieldfrom the querymetaontothe dispatched action payloads — uniformly across all five strategies
rather than special-casing
updateModels. The slice is untouched.The widget prefetch contract is broken deliberately:
dispatch→queryClient.prefetch({ courseId, course, dispatch })is a documentedplugin contract (
sidebar/README.md,ARCHITECTURE.md) for externalwidgets registered via the
SIDEBAR_WIDGETSconfig key, so this is abreaking change and the commit advertises it twice: a
refactor!:subjectand a
BREAKING CHANGE:footer (semantic-release major). It breaks now rather than at the Redux teardown(Tear down the courseware Redux slice + replace useContextId #1976) because: the registry mechanism is ~5 months old (PRs feat: decouple notifications panel using widget registry mechanism #1885/feat: move discussion topic prefetch from trigger to widget config lifecycle #1897);
its only in-repo
prefetchuser was this thunk; a GitHub code search findsno external
SIDEBAR_WIDGETSconsumer (caveat: operatorenv.config.jsxfiles are untracked and unsearchable); and the teardown makes the break
inevitable regardless — deferring would mean touching the same contract
line and the same three docs twice for one break's worth of change. The
context object is now
{ courseId, course, queryClient }(queryClientfrom
useQueryClient(), a stable reference in the effect deps), anduseDispatch+ thereact-reduximport leaveSidebarContextProviderentirely. The two sidebar docs and the discussions widget README were
updated in the same layer.
courseware/data/thunks.jsdeleted —getCourseDiscussionTopicswasthe last thunk left after Convert saveIntegritySignature + saveSequencePosition to React Query mutations #2015, and
courseware/data/index.jsalready hadno
./thunksexports, so only the file itself goes.Light structural types instead of
any. The discussion api functions(
api.js) are untyped, so the queryFn annotates locally:config: { provider: string }andtopics: { usageKey: string | null }[](course-wide topics carry a null usage key — that's what the filter drops).
No
as any.Tests seed through the real converted path. The three files that
seeded via
executeThunk(getCourseDiscussionTopics(...))(
DiscussionsSidebar.test.jsx,DiscussionsTrigger.test.jsx,courseware/course/test-utils.jsx) now runprefetchDiscussionTopics(createTestQueryClient(store), courseId)— thebridge-wired client populates the model store exactly as production does,
and the existing endpoint mocks keep exercising the provider gate. New
apiHooks.test.tsxcases: openedx success with the usage-key filter(previously untested), the legacy-provider skip (no topics request), and
the config-failure path (
logError, nothing written); plus a bridgeidFieldcase inmodelStoreBridge.test.ts.SidebarContextProvider.test.jsxgained aQueryClientProviderwrapper and lost its
react-reduxmock. That suite renders the providerwith raw RTL
renderin a fully mocked environment (the model store isjest-mocked), so the new
useQueryClient()call needed a plainnew QueryClient()wrapper — consistent with the file's isolated-unitstyle; no store/bridge wiring needed since the mocked widgets define no
prefetch. Thereact-reduxjest mock existed only to feed the provider'suseDispatch, which is gone.Behavior deltas are the standard query-conversion posture. The thunk
fetched once per effect fire; the query gets the app default
shouldRetryQuery(up to 3 retries on 5xx/network) andqueryClient.querydedupes an in-flight fetch. With the default
staleTime: 0, effectre-fires still refetch — effectively the thunk's fire-every-effect
behavior.
queryClient.query(...).catch(noop), notprefetchQuery(review,arbrandes). The installed
@tanstack/query-core(5.102.8, via^5.90.19) marksprefetchQuery@deprecated: "Use queryClient.query(options)instead. You can swallow errors with
.catch(noop). This method will beremoved in the next major version." The two are the same call —
prefetchQuery(options)is literallyfetchQuery(options).then(noop).catch(noop)and
query(options)isfetchQuery(options)— so the replacement unwrapsthe deprecated helper without changing the cache build,
staleTimecheck,retry defaults, or the
metabridge.noopis the library's own export(
import { noop } from '@tanstack/react-query'), the idiom the TanStackprefetching guide shows; it reads as "deliberately discarded" where an
empty arrow reads as unhandled. The function now resolves with the topics
instead of
undefined; the one production caller (discussionsPrefetch)ignores the result and the five test call sites only
awaitit. Thesidebar README's widget
prefetchexample switched toqueryClient.query(...)too, with a self-contained.catch(() => {})rather than an extra import for a six-line snippet.
Manual testing
Manual testing — getCourseDiscussionTopics → React Query (#2016)
In-browser verification for the discussion-topics layer, run against a live
backend (tutor local). This layer claims zero user-facing change: the
thunk becomes
prefetchDiscussionTopics(same config → provider gate →topics sequence, bridged into the same
discussionTopicsmodel keyed byusage key), and every reader keeps reading the model. The things to watch are
the old semantics: the prefetch firing from
SidebarContextProvider'spost-mount effect, the trigger/sidebar appearing only for units with an
in-context topic, and legacy-provider courses staying untouched.
Getting real IDs (DemoX on tutor local)
Course id:
course-v1:OpenedX+DemoX+DemoCourse; basehttp://apps.local.openedx.io:2000/learning.and forum plugin) — confirm via the config GET below reporting
"provider": "openedx"./api/discussion/v1/courses/{courseId}followed (openedx provider only) by a GET to
/api/discussion/v2/course_topics/{courseId}, fired once per course oncourseware mount.
Verify by hand
On a unit with discussions enabled in context (openedx provider):
the
v1/coursesconfig GET, then thev2/course_topicsGET; no consoleerrors.
renders in the right rail; clicking it opens the sidebar iframe at
{DISCUSSIONS_MFE_BASE_URL}/{courseId}/category/{unitId}?inContextSidebar.discussion topic (or discussions disabled in context), the trigger does
not render.
URL to the new unit without refetching topics (the query is cached per
course; a remount refetches).
Legacy-provider course (env permitting; if no local legacy-provider course
exists, the automated coverage below carries this half):
provider and no
course_topicsrequest follows; no trigger renders andno console errors.
Left to the automated suite (not re-done by hand)
apiHooks.test.tsx(
prefetchDiscussionTopics): openedx success keyed by usage key with thecourse-wide-topic filter, the legacy-provider skip (no topics request), and
the config-failure path (
logError, nothing written).usageKey-keyed bridge write —modelStoreBridge.test.ts(idField).DiscussionsSidebar.test.jsx/DiscussionsTrigger.test.jsx(render with a topic, nothing without), now seeded through the real
prefetch + bridged test client.
SidebarContextProvider.test.jsx,Course.test.jsx(viasetupDiscussionSidebar),CoursewareContainer.test.jsx.Results
Env: tutor local, run against the local branch @
1ed4aa20(before any PR).The four openedx-provider items passed as described on DemoX (prefetch pair
on mount, trigger + iframe category URL, no trigger without an in-context
topic, no refetch on unit navigation).
The legacy-provider skip was not run by hand — no local course uses the
legacy provider. A course with discussions disabled entirely (no
discussiontab) was checked instead: no discussion requests fire at all, no trigger, no
console errors — that exercises
discussionsPrefetch's tab gate inwidgetConfig.js, not the provider gate insidequeryFn. The provider gaterests on the automated legacy-provider case in
apiHooks.test.tsx(configGET only, no
course_topicsrequest, nothing written).🤖 Generated with Claude Code