Skip to content

refactor: convert the courseware metadata fetch to React Query - #2023

Draft
brian-smith-tcril wants to merge 2 commits into
bsmith/react-query-courseware-container-typescriptfrom
bsmith/react-query-courseware-metadata
Draft

refactor: convert the courseware metadata fetch to React Query#2023
brian-smith-tcril wants to merge 2 commits into
bsmith/react-query-courseware-container-typescriptfrom
bsmith/react-query-courseware-metadata

Conversation

@brian-smith-tcril

@brian-smith-tcril brian-smith-tcril commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Convert the courseware metadata fetch (fetchCourse) to React Query, and extend the model-store bridge to mirror collection results. Part of the Redux → React Query migration (#1946), the #1976 courseware decomposition (Target 1); stacked on the CoursewareContainer de-class (#2020) and TypeScript (#2021) layers as the new top of the stack. Two commits — #2009 (bridge → collections) and #2010 (courseware metadata). Closes #2009, #2010.

fetchCourse fetched four things (course metadata, the learning-sequences outline, course-home metadata, sidebar toggles) and derived courseStatus. This moves the three data fetches to query hooks (mirrored into the model store via the bridge so the existing useModel readers keep working), moves the status derivation into transitional bridge hooks, and thins fetchCourse to just the un-converted sidebar-toggles fetch.

What changed

  • courseware/data/apiHooks.ts / queryKeys.ts (new) — useCoursewareMetadata (getCourseMetadata) and useCoursewareOutline (getLearningSequencesOutline), tagged with meta.models so the bridge fans each result into the right model(s). courseId is string | undefined (from useParams) with an enabled guard; key factories stay strict string.
  • course-home/data/modelStoreBridge.ts (Peel: extend the model-store bridge to collection dispatches #2009) — the bridge's meta gains a models: [{ modelType, strategy, source? }] list form, so one query result can fan out to several model targets with the right add-vs-merge semantics (the outline writes three: coursewareMeta/sections/sequences).
  • courseware/data/statusBridge.ts (new) — useCourseStatusBridge (container) and useCourseExitStatusBridge (CourseExit) run the queries and mirror their combined state into state.courseware.courseStatus/courseId, so the still-Redux readers (redirect helpers, selectors, TabPage's string status, the exit-page children) keep working. Transitional — removed when those readers move to React Query.
  • CoursewareContainer.tsx — calls useCourseStatusBridge(routeCourseId); the memoized checkFetchCourse guard is byte-identical to base (it now dispatches the thinned fetchCourse).
  • CourseExit.jsx — self-wrapping on the query hooks via useCourseExitStatusBridge, rendering TabPage itself.
  • courseware/data/thunks.jsfetchCourse thinned to just the sidebar-toggles fetch (its full conversion is Convert the courseware outline sidebar to React Query + context #2013); its model writes move to the bridge and its status derivation to the status bridges.
  • queryClient.ts / data/http-error.ts — the app QueryCache's onError logs query failures; a query overrides the level per HTTP status via meta.logStatusAs: { <status>: <level> } (the outline's expected 403 → logInfo). meta is typed globally via a Register.queryMeta augmentation, so onError and the bridge read it cast-free. Implements the last piece of Restore dropped query error logging via a global QueryCache.onError #2022.
  • useIFrameBehavior.ts — the post-event refetch invalidates the courseware queries instead of dispatching fetchCourse.
  • index.jsx — the courseware route drops the <TabContainer fetch={fetchCourse}> wrapper for a bare <CourseExit />.

Behavior

No user-facing change. Metadata/outline/courseHomeMeta now load via React Query and populate the model store through the bridge; courseStatus/courseId are still written (transitionally, by the status bridges) so the redirects, gating, and TabPage behave as before. The sidebar toggles still load via the thinned fetchCourse. Query error logging is preserved, including the outline's 403 → logInfo (a logged-out learner's expected redirect stays a New Relic page action, not a noticed error).

Testing

npm run types, npm run lint, and the full npm test suite pass (109 suites, 912 passing, 3 pre-existing skips). New queryClient.test.ts covers onError (default logError, logStatusAs override) and the model-store bridge; modelStoreBridge.test.ts covers the list-form fan-out; the container / CourseExit / useIFrameBehavior / ProductTours tests render through the bridged query client; setupTest's seedCoursewareModels replaces the executeThunk(fetchCourse) seed. Manual browser verification is still pending — keeping this PR in draft until that's done.

Decisions

Full decision log

Decisions — courseware metadata → React Query (+ bridge collections) (#2009 + #2010)

Working notes for this PR (part of the wider Redux → React Query migration,
#1946). Not checked in — referenced when opening the PR. Part of the #1976
courseware decomposition (Target 1). Stacked on the de-class (#2020) and the TS
conversion (#2021).

Why #2009 and #2010 are one PR (two commits). #2009 (the bridge extension) has
no runtime effect on its own — nothing uses the new meta form until #2010 wires
a query to it — and we expect to tweak the bridge while doing #2010. Landing them
together ships a "real" chunk (courseware metadata actually on React Query) instead of
a dormant infra PR followed by its only consumer. Two commits keep the concerns
legible: commit 1 = bridge, commit 2 = the metadata conversion.


Part 1 — extend the model-store bridge to collections (#2009, commit 1)

Extend the transitional bridge (src/course-home/data/modelStoreBridge.ts) so a query
can mirror collection results (and several model targets) into the models store,
not just a single addModel.

Why: the courseware producers write collections, and one fetch → many models

fetchCourse writes four model types with mixed strategies; the outline endpoint alone
writes three from one response:

source model type action
getCourseMetadata coursewareMeta addModel (data has its own id)
getCourseHomeCourseMetadata courseHomeMeta addModel keyed by courseId
getLearningSequencesOutline.courses coursewareMeta updateModelsMap (merge sectionIds)
getLearningSequencesOutline.sections sections addModelsMap
getLearningSequencesOutline.sequences sequences updateModelsMap (merge)

The bridge runs in the QueryCache onSuccess, which receives the query's raw
result, so a query whose result is { courses, sections, sequences } must fan that one
result out to three mirrors.

Contract: keep the single form, add a models list

  • { modelType, courseId } (unchanged) — mirror the whole result as one model keyed
    by courseId. The course-home tabs use this; byte-compatible.
  • { models: [{ modelType, strategy, source? }] } (new) — one or more mirrors.
    strategy is a model-store action (addModel / updateModel / addModelsMap /
    updateModelsMap / updateModels); source selects a key of the result (omitted =
    the whole result). Lets one query populate several targets with the right add-vs-merge
    semantics; source is what avoids splitting the outline into 3 fetches.

Alternatives considered

  • Single-target + a collection flag. Rejected: the bridge sees the raw result and
    meta is one-target-per-query, so the outline's 3-in-1 shape couldn't be expressed
    without 3 separate fetches (3× network) or a bespoke onSuccess.
  • Bespoke per-query handlers. Same objection — abandons the shared bridge.

North star

The bridge is throwaway scaffolding, deleted with the model store in #1977. After that,
readers stop calling useModel(...) and read from the RQ hooks directly. A couple of
models are assembled from two endpoints (sequences = outline shallow +
getSequenceMetadata full; coursewareMeta = getCourseMetadata + outline
sectionIds), so those readers combine the relevant hooks — a #2011/#2013/#1977
concern, not this PR.

Tests

modelStoreBridge.test.ts drives real queries through createModelStoreQueryCache(store)
and asserts the resulting models state: single form, list-form fan-out via source,
updateModelsMap merging (not clobbering), updateModels over an array, and the no-op
when meta is absent.


Part 2 — convert courseware metadata to React Query (#2010, commit 2)

Convert the courseware metadata fetch (fetchCourse) to React Query, mirroring into the
model store via the Part-1 bridge so the ~14 useModel readers keep working.

Scope: convert the metadata/outline/courseHomeMeta fetches; thin fetchCourse in place

fetchCourse did four fetches (metadata, outline, courseHomeMeta, sidebar toggles) + set
courseStatus. Its consumers: CoursewareContainer (player), the CourseExit route
(<TabContainer fetch={fetchCourse}>), useIFrameBehavior (refetch on an iframe event), and
setupTest's initializeTestStore. Decision: move the three data fetches to RQ hooks and
the status derivation into the container, then thin fetchCourse in place
so it does only
the un-converted remainder (the sidebar toggles) — rather than deleting it and adding a new
fetchCoursewareOutlineSidebarToggles thunk.

  • Why thin, not delete+recreate: Convert the courseware outline sidebar to React Query + context #2013 removes the toggles (its last responsibility) and
    deletes fetchCourse then. Renaming/replacing it now is churn for a transitional step —
    CoursewareContainer's guard (checkFetchCoursedispatch(fetchCourse(id))) stays
    byte-identical to the base, and fetchCourse visibly shrinks (4 fetches → 1) across
    layers until it's gone. Transitional cost: fetchCourse is briefly a misnomer (it only
    fetches toggles now).
  • Converting all consumers (not just the player) avoids the query hooks and
    useIFrameBehavior double-populating models on the player.

The query hooks (new courseware/data/apiHooks.ts + queryKeys.ts)

  • useCoursewareMetadata(courseId)getCourseMetadata,
    meta: { models: [{ modelType: 'coursewareMeta', strategy: 'addModel' }] } (result has its own id).
    • Why the array form here and not the single { modelType, courseId } form (as
      useCourseHomeMeta uses)?
      The single form (bridge line 44) does
      addModel({ model: { id: courseId, ...data } }) — its purpose is to inject courseId
      as the id
      , which courseHomeMeta needs because its payload has no id of its own.
      coursewareMeta's payload has its own id, and fetchCourse stored it with
      addModel({ model: metadata }) (no injection). The array form + strategy: 'addModel'
      (no source) maps exactly to that. The two happen to produce the same stored value here
      (in { id: courseId, ...data } the spread wins, and data.id === courseId on this
      route), so the single form wouldn't break — but the array form (a) expresses the real
      intent ("store by the model's own id") instead of relying on the spread-override
      coincidence, (b) is the byte-analog of fetchCourse's call, and (c) keeps both courseware
      hooks on one contract (the outline hook must use the array form to fan out to 3 targets).
  • useCoursewareOutline(courseId)getLearningSequencesOutline,
    meta: { models: [ courses→coursewareMeta/updateModelsMap, sections→addModelsMap, sequences→updateModelsMap ] }.
  • Reuse useCourseHomeMeta for courseHomeMeta — don't re-fetch it.
    • Faithfulness note: useCourseHomeMeta fetches the 'outline' rootSlug variant;
      fetchCourse used 'courseware'. rootSlug only renames the courseware tab's slug
      in the normalized tabs; courseAccess (what the gating reads) is identical. So
      reuse is faithful for gating; the only difference is that tab's slug.

courseId typing: guarded hooks, strict keys (a migration-wide convention)

useParams() types every route param as string | undefined — React Router can't prove
which route a component renders under — even though :courseId is always present on the
courseware/course-exit routes. This bit only surfaces now because #2010 adds the first
typed (.tsx) caller
of these hooks (CoursewareContainer); the existing course-home
callers are all .jsx, so the argument was never type-checked. Every future .tsx
conversion hits the same thing, so we picked one convention:

  • Hooks take string | undefined + enabled: !!courseId. The hook honestly tolerates
    the useParams type by not firing when the id is absent (the standard React Query
    idiom for "param may not be ready"). Call sites pass useParams straight through — no
    casts or guards proliferating across the migration. No-op for the 6 existing .jsx
    callers, since courseId is never actually undefined there.
  • Key factories take string (never undefined). A query key is a real identity; a
    key on undefined is meaningless. So queryKeys.ts stays strict.
  • The seam is a ! at the key call inside the hookcoursewareQueryKeys.metadata(courseId!).
    This is deliberate, not sloppy: queryKey is evaluated eagerly (React Query computes
    it every render regardless of enabled), so the factory is still called when the query
    is disabled. The ! says "keys are built from real ids"; the adjacent enabled: !!courseId
    is what actually makes the never-happens undefined case safe (no fetch). Runtime-wise the
    ! is purely type-level.

Alternative considered — narrow courseId to string once at the .tsx boundary
(then keys and hooks are string, no enabled, no !). Rejected: it relocates the
useParams undefined into a guard/assertion at every typed boundary — the
cast-in-the-wrong-place friction from #2019 — instead of handling it once, idiomatically,
in the hook. (This also reverts a stringstring | undefined widening of
useCourseHomeMeta/courseHomeQueryKeys.metadata that #2010 briefly introduced before we
settled on this convention.)

Error logging: global QueryCache.onError + the outline's 403 nuance

fetchCourse logged each endpoint independently: logError on failed metadata/courseHomeMeta/
toggles, and for the outline a 403 ? logInfo : logError split (a 403 there is the expected
access-denied case — the learner is redirected — so it's a New Relic page action, not a
noticed error that pollutes the error dashboard).

React Query v5 removed onError from useQuery (it's only on useMutation and the
QueryCache), so per-hook query logging isn't possible. The home for query error logging is the
global QueryCache.onError — permanent app infra, introduced with the QueryCache in #1987
and tracked by #2022. By default it logErrors.

What #2010 adds — the courseware outline is the one query that surfaces a 403 as an error
(its getter throws it; the course-home tab getters swallow 401/403/404 → {}, so their query
errors are only genuine failures). So:

  • useCoursewareOutline tags meta: { logStatusAs: { 403: 'info' } }.
  • onError reads query.meta.logStatusAs — a status → LogLevel map, defaulting to
    error
    (it says how to log each status, not a "quiet" flag). So an outline 403 →
    logInfo, restoring fetchCourse's behavior; anything else → logError. LogLevel
    ('error' | 'info') derives from a { error: logError, info: logInfo } map — the only two
    loggers platform exposes.
  • The toggles keep their logging via the thinned fetchCourse's catch → logError.

Typed meta, no casts. meta is typed globally via a Register.queryMeta augmentation
(ModelStoreMeta & { logStatusAs?: Record<number, LogLevel> }), so both onError and the
model-store bridge read query.meta without a cast, and meta literals are checked at the
write site. getResponseStatus (data/http-error.ts) reads the error's status. At #1977 the
augmentation drops its ModelStoreMeta half along with the bridge.

Migration-wide context (#2022): other converted queries dropped their thunks' logError too,
but their getters swallow 401/403/404, so the global onError (plain logError) already covers
their genuine failures without per-query meta. The outline is the exception that needs the
logStatusAs tag.

Access-gating + status: a transitional useCourseStatusBridge

The old fetchCourse derived courseStatus (request → success/denied/failure) from
courseAccess.hasAccess + outline success and dispatched fetchCourse{Request,Success, Denied,Failure}. That derivation moves out of the thunk into a transitional bridge hook,
useCourseStatusBridge (courseware/data/statusBridge.ts): it runs the three query hooks and
mirrors their combined state into state.courseware.courseStatus via the same status actions,
so the still-Redux readers (the container's redirect helpers/selectors, TabPage's string
status, useContextId) keep working. CoursewareContainer just calls
useCourseStatusBridge(routeCourseId).

Why a bridge, and why a component hook (not the model-store one): it's the same "keep Redux
populated from RQ transitionally" idea as the model-store bridge, but courseStatus is a
derivation across all three queries — which a per-query QueryCache.onSuccess can't express —
so it's a component-level hook, not part of the centralized bridge. Deleted when those readers
move to RQ.

The status reducers stay. fetchCourse is thinned, not deleted (see the scope section), so
checkFetchCourse stays too — it just dispatches the thinned fetchCourse (toggles only). RQ
auto-fetches on courseId change, so no separate metadata-fetch guard is needed.

CourseExit route → self-wrapping (+ transitional slice write for the exit children)

<TabContainer tab="courseware" fetch={fetchCourse}> becomes CourseExit self-wrapping
on the query hooks (rendering TabPage itself), matching the course-home tab pattern.

Regression found & fixed: the course-exit children (CourseCelebration,
CourseNonPassing, CourseInProgress, and the recommendation/upgrade helpers) read
courseId from state.courseware — which fetchCourse used to set on this route. With the
thunk gone, that slice field is unset on the CourseExit route, so useModel('courseHomeMeta', undefined) returns {} and tabs.find(...) throws. Rather than convert all ~7 children off
the slice (that's #1976's job), CourseExit writes courseId/courseStatus to the slice
transitionally via useCourseExitStatusBridge (courseware/data/statusBridge.ts) — the
CourseExit sibling of useCourseStatusBridge (2 queries, no outline). CourseExit owns the
two queries (it also feeds them to its own TabPage gating), so it passes them into the
bridge rather than the bridge returning them. The children keep reading the slice until they
convert.

useIFrameBehavior refetch → query invalidation

The iframe POST_EVENT handler's postEvent.mutate onSuccess did
dispatch(fetchCourse(courseId)); it now queryClient.invalidateQueries the three query keys
(coursewareQueryKeys.metadata, coursewareQueryKeys.outline, courseHomeQueryKeys.metadata)
via useQueryClient, so the refetch goes through RQ.

fetchCourse refetched a fourth thing — the sidebar toggles — but we deliberately don't
invalidate those here, and nothing else is needed for them: enableCompletionTracking is a
static course-level setting, and this trigger is a unit-level learner action (POST_EVENT)
that can't change it, so refetching it on every event was redundant. The three invalidated
queries cover exactly the data such an event can change (completion / gating / access), and
courseStatus re-derives via useCourseStatusBridge once those queries resettle.

Sidebar toggles peeled aside (to #2013)

getCoursewareOutlineSidebarTogglessetCoursewareOutlineSidebarToggles feeds only
the outline sidebar. It's the one fetch left in the thinned fetchCourse (which the
container still dispatches via the unchanged checkFetchCourse guard), so the setting keeps
loading until the sidebar layer (#2013) converts it and deletes fetchCourse. Not folded
into the query hooks here.

Align the toggle + outline-data peels with #1920's data split

Upstream PR #1920 ("Course outline restructure") splits the outline sidebar hook into
useCourseOutlineData() (the data: sections/sequences/units/courseOutlineStatus/
activeSequenceId/sequenceStatus, plus isEnabledCompletionTracking and
isActiveEntranceExam) and useCourseOutlineSidebar() (just open/collapse UI state). Notably
it moves isEnabledCompletionTracking from useCourseOutlineSidebar() to
useCourseOutlineData()
— i.e. the completion-tracking toggle is treated as outline data,
not sidebar chrome.

Implication for our decomposition (not #2010 — the transitional thunk just writes the slice,
which #1920 still reads via useSelector, so there's no conflict): when we convert these to
React Query, group the toggle conversion (#2013) with the outline-data conversion
(sections/sequences/units/status) so both feed useCourseOutlineData. Don't structure the
toggle as its own sidebar-flavored peel. Aligned that way, the eventual rebase over #1920 is
just "point useCourseOutlineData at the query hooks." (#1920 is UI-only — no data//thunk/
slice files — and currently stalled/red, so we align the split now and rebase whenever it moves.)

Test seeding (setupTest.js)

initializeTestStore (used by ~37 test files) seeds via executeThunk(fetchCourse).
Replace that with direct model-store dispatches replicating fetchCourse's writes
(coursewareMeta / courseHomeMeta / sections / sequences) from the same mocked data, so
the 37 callers keep working unchanged. Contained to setupTest.js.

@brian-smith-tcril brian-smith-tcril changed the title bsmith/react query courseware metadata refactor: convert the courseware metadata fetch to React Query Aug 21, 2026
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.49%. Comparing base (4706a9a) to head (a2eb96d).

Additional details and impacted files
@@                                  Coverage Diff                                   @@
##           bsmith/react-query-courseware-container-typescript    #2023      +/-   ##
======================================================================================
- Coverage                                               93.53%   93.49%   -0.04%     
======================================================================================
  Files                                                     363      367       +4     
  Lines                                                    5905     5946      +41     
  Branches                                                 1367     1374       +7     
======================================================================================
+ Hits                                                     5523     5559      +36     
- Misses                                                    367      371       +4     
- Partials                                                   15       16       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@brian-smith-tcril brian-smith-tcril linked an issue Aug 21, 2026 that may be closed by this pull request
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch 2 times, most recently from 54b53a9 to b9eb81e Compare August 21, 2026 14:48
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch 2 times, most recently from 27fc4d9 to bd4c718 Compare August 21, 2026 18:24
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from bd4c718 to 11de7ef Compare August 21, 2026 18:32
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from 11de7ef to e145ef9 Compare August 21, 2026 18:34
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from e145ef9 to b72083e Compare August 21, 2026 18:40
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from b72083e to fb407a8 Compare August 21, 2026 18:55
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from fb407a8 to 8d9a956 Compare August 21, 2026 19:05
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from 8d9a956 to e861457 Compare August 21, 2026 19:15
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from e861457 to 29b1274 Compare August 21, 2026 19:23
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from 29b1274 to 462f871 Compare August 21, 2026 21:19
brian-smith-tcril and others added 2 commits August 22, 2026 02:58
Extend the transitional QueryCache bridge so a query can mirror collection
results (and several model targets) into the models store, not just a single
addModel. Adds a `meta.models` list — each entry runs a model-store action
(addModel/updateModel/addModelsMap/updateModelsMap/updateModels) with an optional
`source` key into the result — while keeping the existing `{ modelType, courseId }`
single form. No runtime effect until a query opts in (wired up in the courseware
metadata conversion that follows in this PR).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Convert fetchCourse's metadata/outline/courseHomeMeta fetches to React Query, mirroring
results into the model store via the bridge so the existing useModel readers keep working.

- Query hooks: useCoursewareMetadata + useCoursewareOutline (courseware/data/apiHooks +
  queryKeys); reuse useCourseHomeMeta (now typed — enabled guard + courseAccess). courseId is
  string|undefined from useParams with an `enabled` guard; key factories stay strict string.
- Status: CoursewareContainer and CourseExit call transitional bridges
  (courseware/data/statusBridge) that mirror the combined query state into
  state.courseware.courseStatus/courseId, so the redirect helpers/selectors, TabPage, and the
  exit-page children keep working until they move to React Query.
- fetchCourse is thinned to just the sidebar-toggles fetch (its full conversion is #2013); its
  model writes move to the model-store bridge and its status derivation to the status bridges.
- Query error logging: the app QueryCache onError logs failures; a query can override the level
  per HTTP status via meta.logStatusAs (the outline's expected 403 -> logInfo). meta is typed
  globally via a Register.queryMeta augmentation, so onError and the bridge read it cast-free.
- useIFrameBehavior's post-event refetch invalidates the courseware queries instead of
  dispatching fetchCourse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-courseware-metadata branch from 462f871 to a2eb96d Compare August 22, 2026 07:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Convert courseware metadata to React Query Peel: extend the model-store bridge to collection dispatches

1 participant