refactor: convert the courseware metadata fetch to React Query - #2023
refactor: convert the courseware metadata fetch to React Query#2023brian-smith-tcril wants to merge 2 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2023 +/- ##
=======================================
Coverage 93.53% 93.53%
=======================================
Files 363 367 +4
Lines 5905 5959 +54
Branches 1367 1411 +44
=======================================
+ Hits 5523 5574 +51
- Misses 367 368 +1
- Partials 15 17 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
54b53a9 to
b9eb81e
Compare
27fc4d9 to
bd4c718
Compare
bd4c718 to
11de7ef
Compare
11de7ef to
e145ef9
Compare
e145ef9 to
b72083e
Compare
b72083e to
fb407a8
Compare
fb407a8 to
8d9a956
Compare
8d9a956 to
e861457
Compare
e861457 to
29b1274
Compare
462f871 to
a2eb96d
Compare
a2eb96d to
332f52e
Compare
arbrandes
left a comment
There was a problem hiding this comment.
The inline comments are distilled from a bigger list from Claude. They're the ones I figured worth suggesting.
None of them are blockers, so I'm approving and trusting your judgement as to what, if anything, is worth modifying before merging.
|
|
||
| const CourseExit = () => { | ||
| const { courseId } = useParams(); | ||
| const metadataQuery = useCoursewareMetadata(courseId); |
There was a problem hiding this comment.
Suggestion: add useCoursewareOutline(courseId) here and feed it through the exit status bridge / TabPage the way useCourseStatusBridge does. (Or source hasScheduledContent from the outline query directly.)
hasScheduledContent is emitted only by the outline response (utils.js:64), which the old <TabContainer fetch={fetchCourse}> fetched on this route and CourseExit no longer does, so on a cold load of /course/:courseId/course-end the inProgress branch of getCourseExitMode (utils.js:63) is unreachable and the learner gets CourseNonPassing instead - arriving from the player still works, since the player's outline query already mirrored the field into the model store.
There was a problem hiding this comment.
- Added a test to cover the outlined scenario
- Added a call to
useCoursewareOutline
Considered the "source hasScheduledContent from the outline query directly" option, but that would increase the scope of this PR. That part will be addressed as part of #1977
| const dispatch = useDispatch(); | ||
| const metadataQuery = useCoursewareMetadata(courseId); | ||
| const outlineQuery = useCoursewareOutline(courseId); | ||
| const courseHomeMetaQuery = useCourseHomeMeta(courseId); |
There was a problem hiding this comment.
Suggestion: parameterize the hook - useCourseHomeMeta(courseId, rootSlug) with rootSlug in the query key - and pass 'courseware' from here and from CourseExit.
normalizeCourseHomeCourseMetadata renames the courseware tab's slug to whatever rootSlug it's handed (api.js:20), so moving these two routes from the thunk's 'courseware' to the hook's 'outline' leaves no tab matching activeTabSlug="courseware" at LoadedTabPage.tsx:46: the "Course" tab loses its active class and the <title> loses its tab prefix.
There was a problem hiding this comment.
🤖 The summary and decision log below were written by Claude Code, which implemented this change.
Summary: useCourseHomeMeta hardcoded rootSlug: 'outline', so on the CoursewareContainer and CourseExit routes (both render activeTabSlug="courseware") the shared course/outline tab was labeled 'outline' and never matched — the "Course" tab lost its active state and the <title> lost its tab prefix. The fix parameterizes useCourseHomeMeta(courseId, rootSlug) (required, and part of the query key), makes every caller pass its slug explicitly ('courseware' from useCourseStatusBridge + CourseExit; 'outline' from the course-home tabs + CourseAccessErrorPage), and points useIFrameBehavior's invalidation at the 'courseware' variant — matching the pre-conversion dispatch(fetchCourse). The per-context fetch is parity with the old thunks; the single-shared-fetch optimization belongs with #1977.
Full decision log
Comment: parameterize useCourseHomeMeta with a rootSlug (courseware vs outline tab slug)
The regression
useCourseHomeMeta hardcodes getCourseHomeCourseMetadata(courseId, 'outline'). The
course-home metadata API returns a "Course" tab with tab_id: 'courseware', and
normalizeCourseHomeCourseMetadata(data, rootSlug) rewrites that one tab's slug to
whatever rootSlug it's handed (every other tab keeps its own id). That rename exists
because the same API tab means the outline tab on course-home pages and the
courseware tab under CoursewareContainer.
LoadedTabPage reads tabs from useModel('courseHomeMeta') and highlights the tab whose
slug === activeTabSlug. Both CoursewareContainer (:399) and CourseExit (:81) render
with activeTabSlug="courseware". With the hook hardcoded to 'outline', that shared tab gets
slug 'outline' on those routes, so nothing matches → the "Course" tab loses its active state
and the <title> loses its tab prefix. Both CoursewareContainer and CourseExit are affected.
Pre-PR this worked because the courseware route's fetchCourse thunk fetched
getCourseHomeCourseMetadata(courseId, 'courseware'); the conversion unified everyone on
'outline' and broke it.
Fix: explicit rootSlug, in the query key
useCourseHomeMeta(courseId, rootSlug)—rootSlugrequired (no default). Every caller
passes its context slug:'courseware'fromuseCourseStatusBridgeandCourseExit;
'outline'from the five course-home tabs (OutlineTab,DatesTab,ProgressTab,
LiveTab,DiscussionTab) andCourseAccessErrorPage. (useCourseStatusBridgeis
CoursewareContainer's course-home-metadata source — it isn't just writing slice status; it
callsuseCourseHomeMeta, so it carries the'courseware'rootSlug on that route's behalf,
which is why the mirrored tab matchesCoursewareContainer'sactiveTabSlug="courseware".)queryFn: () => getCourseHomeCourseMetadata(courseId, rootSlug).rootSlugis part of the query key:courseHomeQueryKeys.metadata(courseId, rootSlug).
Chose explicit everywhere over a = 'outline' default so no call site silently inherits
the wrong slug. CourseAccessErrorPage only reads courseAccess (rootSlug-independent for its
behavior), so its value is purely a cache-sharing choice; it passes 'outline' to preserve
today's behavior and share the course-home entry.
Why rootSlug must be in the query key
rootSlug never reaches the API — the request URL is /api/course_home/course_metadata/{courseId}
regardless; rootSlug only feeds the client-side normalize. But React Query caches the
queryFn's post-normalize return value, and the tab bar reads the mirrored courseHomeMeta
model, which only re-updates when the query actually runs (a cache hit doesn't re-fire the
mirror). So if two contexts shared one key, the first-fetched normalization would be served to
the other context and never re-normalized → the bug persists on navigation. Keying by rootSlug
gives each context its own entry, so each route fetches, re-normalizes, and re-mirrors its own
slug.
The duplicate fetch is parity, not new waste
Keying by rootSlug means the same endpoint is fetched once per context. That matches the
pre-PR Redux behavior exactly: fetchTab fetched it with 'outline' and fetchCourse with
'courseware', both addModel-ing into the same single courseHomeMeta model, and thunks
re-ran on every route entry (no caching). RQ is strictly better within a context (repeat
mounts hit cache). The "fetch once, shared across both contexts, rename per-context at read"
optimization requires getting the tab bar off useModel and reading slugs from the query —
that is #1977 (dissolve the model-store cache), out of scope here.
Invalidation stays explicit 'courseware' (no optional/prefix key)
useIFrameBehavior invalidates course-home metadata after a unit postEvent. Pre-PR that site
did dispatch(fetchCourse(courseId)), which re-fetched with 'courseware' (the unit iframe runs
under CoursewareContainer) and overwrote the single model — it only ever refreshed the
courseware context. Faithful mapping: invalidate courseHomeQueryKeys.metadata(eventCourseId, 'courseware'), an explicit context, not a rootSlug-less prefix that also touches 'outline'.
The stale 'outline' entry (if any) self-heals on the next course-home visit via
refetch-on-mount — same as the thunk.
Consequence: the key builder stays required metadata(courseId, rootSlug) — no optional
param, no falsy check, no empty-string-as-"all-variants" footgun. useIFrameBehavior is just
another explicit 'courseware' caller.
Tests
Hook-level in src/course-home/data/apiHooks.test.tsx (the fix lives in the hook; the
mirror → useModel → LoadedTabPage match is unchanged machinery):
rootSlug: 'courseware'yields the shared tab withslug: 'courseware'(and not'outline'),
and vice-versa — the direct regression.- Rendering both variants against one client keeps each slug distinct — guards the query-key
half of the fix (someone droppingrootSlugfrom the key would still pass the per-slug tests
but reintroduce the navigation bug; only this test catches it).
Invalidation is covered by the existing useIFrameBehavior.test.js post-event case, updated to
assert courseHomeQueryKeys.metadata('course-1', 'courseware') (the explicit context, matching
the pre-PR dispatch(fetchCourse)).
TDD baseline (before the fix)
Written test-first. With only the minimal signature in place (rootSlug param + in the key,
queryFn still hardcoded 'outline', invalidation still passing the rootSlug-less key), the
suite is red exactly where the decisions say it should be, and nowhere else:
- ✕
'courseware'shared-tab slug — queryFn ignoresrootSlug. - ✓
'outline'shared-tab slug — already the hardcoded value. - ✕ query keyed by
rootSlug(two variants share one entry). - ✕
useIFrameBehaviorinvalidates the'courseware'metadata query.
The fix (queryFn threads rootSlug; key builder required; 8 callers explicit; invalidation
passes 'courseware') turns all four green.
Verification
npm run types ✓ · npm run lint ✓ · full suite green (111 suites, 934 passed, 3 pre-existing
skips). The four baseline failures are green, and the eight caller changes (5 course-home tabs +
CourseAccessErrorPage + useCourseStatusBridge + CourseExit) broke nothing downstream.
| meta: { models: [{ modelType: 'coursewareMeta', strategy: 'updateModel' }] }, | ||
| }); | ||
|
|
||
| export const useCoursewareOutline = (courseId: string | undefined) => useQuery({ |
There was a problem hiding this comment.
Suggestion: add a retry policy - e.g. retry: (count, error) => getResponseStatus(error) !== 403 && count < 3 - here, on useCoursewareMetadata, and on useCourseHomeMeta (or as a QueryClient default in queryClient.ts).
Both 403-throwing getters (getLearningSequencesOutline and getCourseHomeCourseMetadata; only the tab-data getters swallow 4xx) now run under React Query's default retry: 3 with exponential backoff, and a retrying query's status stays pending, so statusBridge.ts:26-28 holds courseStatus at loading for ~7 s before the denied/failed redirect that fetchCourse reached at once. The suite can't see it - setupTest sets retry: false.
There was a problem hiding this comment.
Properly handling that meant fully implementing #2024, so it'd be great if you could take a look at this one before I land it.
🤖 The summary and decision log below were written by Claude Code, which implemented this change.
Summary: Under React Query's default retry: 3, a query that throws a 4xx (e.g. the expected 403 on the outline / course-home-metadata calls) stays pending through ~7s of backoff before the denied/failed redirect — a stall the old single-shot fetchCourse never had, and one already live on master for the merged course-home tabs. Rather than a per-query patch, this completes #2024: a global shouldRetryQuery in queryClient.ts that skips 4xx (fast-fail) and retries only 5xx/network. Errors our own queryFn code throws deterministically (mapSearchResponse's schema validation) are tagged NonRetryableError so they fast-fail without giving up network retry, and useCoursewareSearchEnabled's now-redundant retry: false is removed. Covered by predicate unit tests, a wiring test, and an end-to-end integration pass through the real client.
Full decision log
Comment: add a retry policy so expected 4xx (e.g. 403) fail fast instead of ~7s
Decision
Pull #2024 ("Smart query retry: skip 4xx, retry 5xx/network errors") into this PR: set a
global retry policy on the app query client (src/queryClient.ts) instead of a per-query
patch. This closes/subsumes #2024.
Why (not defer, not a per-query half-measure)
Under RQ's default retry: 3 + exponential backoff, a query that throws a 4xx stays pending
for ~7s before settling. The access getters (getLearningSequencesOutline,
getCourseHomeCourseMetadata) throw on 403, so useCourseStatusBridge pins courseStatus: 'loading' ~7s before the denied/failed redirect that the old single-shot fetchCourse reached
at once.
This stall is systemic and already live on master: useCourseHomeMeta (retry-3, throws
403) already backs the merged course-home tab conversions, so denied learners already wait ~7s
there. #2023 would only extend the stalling set to CoursewareContainer + CourseExit.
- A per-query
retry: false(matching the old no-retry thunks) is a half-measure — it's neither
the faithful-forever answer nor the real fix, and Smart query retry: skip 4xx, retry 5xx/network errors #2024's global policy would supersede it. - Deferring entirely leaves refactor: convert the courseware metadata fetch to React Query #2023 knowingly worsening
master's stall. - The proper fix is small, global, and proven (see reference), so we do it here.
Grounding: what the pre-conversion (Redux) code did
The old data layer was Redux thunks — single API calls with no retry:
fetchCourse/fetchTab(course-home/data/thunks.js, pre-conversion):Promise.all/
allSettledof the getters → dispatch → done. No retry loop.searchCourseContent(course-home/data/thunks.js, pre-refactor: convert courseware search from Redux to React Query #1970): one
searchCourseContentFromAPI+mapSearchResponsein atry/catchthat records
errors = e.messageand stops — no retry, for any error (axios or the schema throw).
The per-getter swallow logic (401/403/404 → {}) lives in the shared getters, which both the
old thunks and the new hooks call — so that behavior is unchanged by the conversion.
So the only retry-related delta the conversion introduced is thunk no-retry → RQ default
retry: 3 on everything (the regression, incl. the 4xx stall). Against that baseline:
- skip 4xx restores the old fast-fail (getters that swallow those codes never threw them;
getters that throw now fail fast again). - retry 5xx/network is a new enhancement — the old code never retried anything. Smart query retry: skip 4xx, retry 5xx/network errors #2024
adopts it deliberately as the modern default (per the reference), not as a faithful
reproduction. Every query's 5xx retry is therefore an intentional deviation from old behavior.
Reference implementation
openedx/frontend-app-learner-dashboard (frontend-base branch,
src/data/hooks/queryHooks.ts):
retry: (failureCount, error) => {
// Don't retry client errors (4xx) — they won't resolve on retry
if (error?.response?.status >= 400 && error?.response?.status < 500) return false;
return failureCount < 3;
},(There it's applied per-query alongside masquerade-specific retryOnMount/refetchOnMount,
which are hook-specific and not relevant to a global default.) We apply it globally, reading
status via getResponseStatus, and extend the reference with one thing the reference didn't
need: a check for errors our own queryFn code throws deterministically (see the tag decision
below). Both helpers live in src/data/http-error.ts:
export const shouldRetryQuery = (failureCount, error) => {
if (isNonRetryable(error)) return false; // our deterministic throws
const status = getResponseStatus(error);
if (status !== undefined && status >= 400 && status < 500) return false; // 4xx
return failureCount < 3; // 5xx + network
};Query audit — a global policy is safe for every current query
Every useQuery in the app, and how the "skip 4xx / retry 5xx+network" default treats it:
| Hook → getter | Getter behavior (evidence) | Under the policy |
|---|---|---|
useCoursewareMetadata → getCourseMetadata |
GET + normalize, no catch → throws all (courseware/data/api.js:32) |
4xx fast-fail, 5xx retry — fixes stall |
useCoursewareOutline → getLearningSequencesOutline |
GET + normalize, no catch → throws all incl. expected 403 (courseware/data/api.js:26) |
4xx fast-fail, 5xx retry — fixes stall |
useCourseHomeMeta → getCourseHomeCourseMetadata |
GET + normalize, no catch → throws all incl. 403 (course-home/data/api.js:105) |
4xx fast-fail, 5xx retry — fixes stall |
useDatesTabData → getDatesTabData |
catch: 401→{}, 403→{}, else throw (course-home/data/api.js:116) |
swallowed → no throw (no-op); rest 4xx fast / 5xx retry |
useOutlineTabData → getOutlineTabData |
catch: 403→{}, else throw (course-home/data/api.js:253) |
same |
useProgressTabData → getProgressTabData |
catch: 404→redirect+{}, 401→{}, 403→{}, else throw (course-home/data/api.js:139) |
same |
useLiveTabData → getLiveTabIframe |
catch: 404→{}, else throw (course-home/data/api.js:224) |
same |
useTourData → getTourData |
catch: 401/403/404→{toursEnabled:false}, else throw (product-tours/data/api.js:4) |
same |
useCourseRecommendations → getCourseRecommendations |
[] if no DISCOVERY_API_BASE_URL; else 2 GETs, no catch → throws (course-exit/data/api.js:26) |
non-critical; 4xx fast / 5xx retry |
useCoursewareSearchResults → searchCourseContentFromAPI + mapSearchResponse |
POST, no catch → throws axios; mapSearchResponse throws a plain Error on Joi schema-validation failure (map-search-response.js:27) |
axios: 4xx fast / 5xx retry; schema throw → thrown as NonRetryableError → fast-fail (see per-query section) |
useCoursewareSearchEnabled → getCoursewareSearchEnabled |
GET, no catch → throws all (course-home/data/api.js:366) |
remove its retry: false (no justification) → inherits the policy; see per-query section |
Gotchas considered (and why none breaks the policy)
- Undefined-status errors. The policy retries
undefined-status errors so genuine network
failures retry. The one non-network case ismapSearchResponse(map-search-response.js:27),
which throws on a schema-invalid 200 — a deterministic our-code failure. We throw it as a
NonRetryableErrorso it fast-fails; other undefined-status errors (network) still retry. A
grep 'throw new'acrosssrc/confirms this is the only explicit non-HTTP throw reachable
from a queryFn — every getter otherwise re-throws the axios error (which carries
response.status), and the otherthrow new Errors are React context-provider misuse guards
thrown at render, not in queries. Caveat: an implicit runtime throw inside a queryFn (e.g.
a normalizerTypeErroron malformed data) has no status and can't be tagged, so it would retry
3× — rare, deterministic, and bounded, so accepted. - Status extraction.
getResponseStatusreadserror.response.statusonly. The
frontend-platform client sets bothresponse.statusandcustomAttributes.httpErrorStatus
on HTTP errors, so getters that branch oncustomAttributesstill throw errors that carry
response.status→ classified correctly. - Per-query
retry: falseoverrides. None is genuinely justified under the global policy —
full derivation in the dedicated section below. Net: removeuseCoursewareSearchEnabled's and
add none; deterministic our-code throws are handled by tagging (below), notretry: false. - Mutations unaffected. The default targets
queries.retryonly; RQ mutations default to no
retry (our mutations don't need it). - Tests unaffected.
setupTest'screateTestQueryClientand the per-test wrappers set
retry: false, so the global policy is production-facing; existing suites don't change
(confirmed by the full run — see Verification).
Per-query retry: false: none is justified — tag deterministic throws instead
Derived from first principles. Under the global policy (skip 4xx, retry 5xx/network/undefined),
a per-query retry: false changes behavior in only these cases — 5xx, network, and
undefined-status (plain-Error) throws — turning them from retry into fast-fail. (4xx is already
fast-failed globally, so retry: false adds nothing there.)
So retry: false is justified only if a query must fast-fail on 5xx / network / plain-Error:
- No query needs 5xx/network fast-fail. Those are transient; retrying either recovers (good)
or fails after ~3 attempts (same end state, slightly later). Even the redirect-driving queries
are better off retrying a transient 5xx than fast-failing to an error page. - So the only thing any
retry: falseprotects is "don't retry a plain-Errorthrow" —
andretry: falseis over-broad for that, since it also kills the (fine) 5xx/network retry.
Applied consistently, no per-query retry: false is warranted:
useCoursewareSearchEnabled— no plain-Error throw, no fast-fail need → remove its
retry: false.useCoursewareSearchResults— has a plain-Error throw (mapSearchResponse), but
retry: falseis over-broad → don't add it; handle the throw via the tag below.
The one remaining concern — a plain-Error throw — is handled precisely by tagging. It's our
own code (mapSearchResponse's schema validation), so it's deterministic: re-running
re-fetches the same body and fails identically — retrying is guaranteed-pointless (3 real
round-trips), not a "might recover" transient. Since we own the throw, we mark the error:
- A
NonRetryableErrorclass +isNonRetryable(error)insrc/data/http-error.ts(colocated
withgetResponseStatus).shouldRetryQuerycallsisNonRetryablefirst and fast-fails. mapSearchResponsethrows aNonRetryableErrorinstead ofnew Error.
This fast-fails our deterministic throws without touching the 5xx/network retry — network
errors aren't NonRetryableErrors (undefined status, no flag) → still retry, honoring #2024's
"network" — and needs no per-query retry: false anywhere.
Detect by property, not instanceof. NonRetryableError carries a nonRetryable flag and
isNonRetryable reads that property. We use a class for clean construction (new NonRetryableError)
but deliberately don't detect via instanceof — a property read is simpler, matches how we
already inspect errors (getResponseStatus), and sidesteps instanceof's identity assumptions.
Plan (after this doc)
- Add a
NonRetryableErrorclass +isNonRetryabletosrc/data/http-error.ts. - Add
shouldRetryQuery(checksisNonRetryable, then skip-4xx, thenfailureCount < 3) as a
named, exported function and wire it intocreateQueryClient'sdefaultOptions.queries.retry. - Throw
mapSearchResponse's schema-validation failure as aNonRetryableError. - Remove
useCoursewareSearchEnabled'sretry: false(verify refactor: convert courseware search from Redux to React Query #1970's search tests don't assert
the old no-retry). - TDD in
src/queryClient.test.ts(shouldRetryQueryas a pure predicate):falsefor
400/401/403/404/422 and for aNonRetryableError;truefor 500/502/503 and undefined-status
network errors whilefailureCount < 3;falseoncefailureCountreaches 3. - Full suite + types + lint.
- Smart query retry: skip 4xx, retry 5xx/network errors #2024: close as done-in-refactor: convert the courseware metadata fetch to React Query #2023 (note in the PR / issue).
Verification
npm run types ✓ · npm run lint ✓ · queryClient.test.ts 18 passed, covering three layers:
the shouldRetryQuery predicate (unit), that createQueryClient wires it as the default query
retry, and an integration pass that runs real queries through createQueryClient (with
retryDelay: 0) so React Query invokes the policy with its own (failureCount, error) — proving
params, error shape, and count: 4xx/NonRetryableError → 1 attempt; 5xx/network → 4 (1 + 3
retries). Full suite green except the pre-existing Course.test.jsx waitFor-leak flake
(unrelated; un-awaited waitFor on this branch's base) — it passes in isolation (16/16).
| import { getCourseMetadata, getLearningSequencesOutline } from './api'; | ||
| import { coursewareQueryKeys } from './queryKeys'; | ||
|
|
||
| export const useCoursewareMetadata = (courseId: string | undefined) => useQuery({ |
There was a problem hiding this comment.
Suggestion: set refetchOnWindowFocus: false on both hooks, as useLiveTabData does.
fetchCourse only ran on courseId change, so courseStatus never moved on its own; with the default true, a focus refetch re-derives it from fresh results and a transient failure sends the bridge to fetchCourseDenied/fetchCourseFailure (statusBridge.ts:30-45), redirecting the learner off the unit they were reading.
There was a problem hiding this comment.
Decided to set refetchOnWindowFocus to false globally in createQueryClient instead of per-query. Redux didn't do any window focus refetching so this keeps things in line with previous behavior. I figure if there are places where we want window focus refetching in the future we can look into regression-free ways to do so in follow-up issues/PRs.
🤖 The summary and decision log below were written by Claude Code, which implemented this change.
Summary: RQ defaults refetchOnWindowFocus: true and nothing sets staleTime, so every query refetched on every window focus; on the courseware route that re-ran useCourseStatusBridge, and a failing focus-refetch (e.g. a 4xx session-expiry) redirected the learner off the unit they were reading. Setting it false as a global createQueryClient default (mirrored in the test client) rather than per-hook is the more faithful fix — the Redux app never focus-refetched anywhere (thunks fired on route/mount only), so global restores that uniformly, whereas per-hook would leave focus-refetch on for the tabs/search the app never refetched. Freshness is unaffected: mutable data is invalidation-driven (unit events, date shift, tour mutations) + refetchOnMount. Dropped the now-redundant per-hook flags on useLiveTabData/useTourData, whose focus test now verifies the global default end-to-end.
Full decision log
Comment: set refetchOnWindowFocus: false so a focus refetch can't redirect the learner
Decision
Set refetchOnWindowFocus: false globally on the app query client (src/queryClient.ts,
defaultOptions.queries, beside the retry policy) — and mirror it in the test client
(createTestQueryClient). Drop the now-redundant per-hook flags on useLiveTabData and
useTourData. Off-by-default is the floor: if a specific query later benefits from focus-refetch,
re-enable it per-hook then.
The bug
RQ defaults to refetchOnWindowFocus: true, and nothing sets staleTime, so every query is
immediately stale → it refetches on every window focus. On the courseware route,
useCourseStatusBridge derives courseStatus from useCoursewareMetadata, useCoursewareOutline,
and useCourseHomeMeta; a focus refetch of any of them re-runs the bridge, and a failure
dispatches fetchCourseDenied/fetchCourseFailure (statusBridge.ts:30-45) → the container
redirects the learner off the unit they were reading. (The retry policy from the previous comment
softens transient 5xx/network — those retry and usually recover — but a 4xx like a session-expiry
401/403 still fast-fails → redirect.)
Why global, not per-hook: faithfulness
The strongest argument is parity with the pre-conversion Redux app, which never refetched on
window focus — every thunk (fetchCourse, fetchTab/fetchProgressTab/fetchOutlineTab/
fetchDatesTab, search, tour) fired on route/mount only. RQ's conversion introduced app-wide
focus-refetch as a new default. A global false restores the old app-wide behavior uniformly; a
per-hook fix (just the three bridge hooks) would leave focus-refetch on for
dates/progress/outline-tab/search/recommendations — a behavior the Redux app never had. So global
is the more faithful option; per-hook would leave an inconsistent state the app never shipped.
Future freshness is additive: opt a query back into focus-refetch per-hook if it genuinely benefits.
Safety: restoring old behavior breaks no freshness
Investigation confirms nothing relies on focus-refetch for freshness:
- No query sets
staleTime— focus-refetch fires on every focus, not as targeted freshness. - Mutable data is invalidation-driven:
useIFrameBehaviorinvalidates
metadata/outline/course-home-meta on unit postEvents;ShiftDatesAlertinvalidates dates +
outline on a date shift; tour mutations invalidate tour data. PlusrefetchOnMounton navigation. - Two queries have no invalidation —
useProgressTabData(grades) and the course-home
useOutlineTabData(completion is written to the Redux model viaupdateCourseOutlineCompletion,
not the tab query). They refresh on navigation/mount, and had no focus-refresh under Redux
either — so globalfalseis parity, not a regression.
Reference
learner-dashboard (frontend-base) sets refetchOnWindowFocus per-hook and keeps it on
for normal users — its dashboard data changes mid-session. We diverge deliberately: our in-course
data is stable and invalidation-driven, so uniform off matches our app (and our Redux history),
not the dashboard's. This is our own call, not a copied pattern.
Mechanism + tests
createQueryClient(src/queryClient.ts): addrefetchOnWindowFocus: falseto
defaultOptions.queries(alongsideretry).createTestQueryClient(src/setupTest.js): add it too, so tests mirror production — and so
LiveTab.test.jsx's existing "does not refetch on focus" test (focusManager) now verifies the
global default end-to-end through a real component (it currently passes viauseLiveTabData's
per-hook flag, which we're removing).- Remove the redundant per-hook
refetchOnWindowFocus: falsefromuseLiveTabDataand
useTourData. - Wiring test in
queryClient.test.ts:createQueryClient's default query options set
refetchOnWindowFocus: false.
Plan
- Add
refetchOnWindowFocus: falsetocreateQueryClient'sdefaultOptions.queries. - Add it to
createTestQueryClientinsetupTest.js. - Remove the per-hook flags on
useLiveTabDataanduseTourData. - Wiring test; confirm
LiveTab's focus test still passes (now via the global default). - Full suite + types + lint.
Verification
npm run types ✓ · npm run lint ✓ · full suite green (111 suites, 950 passed, 3 pre-existing
skips, 0 failures). The queryClient.test.ts wiring test confirms createQueryClient's default
refetchOnWindowFocus: false, and LiveTab.test.jsx's "does not refetch on focus" now passes via
the global test-client default (per-hook flag removed) — an end-to-end confirmation of the
no-focus-refetch behavior.
| dispatch(fetchCourseRequest({ courseId })); | ||
| return; | ||
| } | ||
| if (metadataQuery.isSuccess && courseHomeMetaQuery.data?.courseAccess?.hasAccess) { |
There was a problem hiding this comment.
Suggestion: give this bridge the same failure branch as useCourseStatusBridge.
There's no error case here, so a failed fetch lands in the else as courseStatus: 'denied' with errorMessage/errorCode still nulled by fetchCourseRequest, and the exit page shows TabPage's generic failure text where fetchCourse surfaced the 403 detail.
| errorMessage: is403 ? (error?.response?.data?.detail ?? null) : null, | ||
| errorCode: is403 ? (error?.response?.data?.error_code ?? null) : null, | ||
| })); | ||
| }, [courseId, metadataQuery, outlineQuery, courseHomeMetaQuery, dispatch]); |
There was a problem hiding this comment.
Suggestion: depend on the derived values (metadataQuery.status, courseHomeMetaQuery.data?.courseAccess?.hasAccess) rather than the query results, here and at line 72.
useQuery returns observer.trackResult(result), a fresh object every render, so these deps never compare equal and the effect re-dispatches on every render of CoursewareContainer - harmless only while the immer reducers no-op on unchanged values.
| useCourseExitStatusBridge(courseId, metadataQuery, courseHomeMetaQuery); | ||
|
|
||
| return ( | ||
| <TabPage |
There was a problem hiding this comment.
Suggestion: swap in TabWithTimer.
The old TabContainer wrapper rendered TabWithTimer, which adds <OuterExamTimer courseId={...} /> (TabWithTimer.tsx:6-12), so the exit page silently loses it and becomes the only tab not on that wrapper.
| } | ||
| dispatch(fetchCourseFailure({ courseId, errorMessage, errorCode })); | ||
| }); | ||
| try { |
There was a problem hiding this comment.
Worth a small test for the one fetch left here.
The deleted describe('Test fetchCourse') block was the only place asserting coursewareOutlineSidebarSettings and the toggles' logError, so nothing now covers fetchCourse's surviving responsibility - and the PR description still lists redux.test.js as covering it.
332f52e to
52b714e
Compare
52b714e to
e6c5f4e
Compare
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>
97c0d3e to
e11cc01
Compare
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>
e11cc01 to
1d711f2
Compare
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 theCoursewareContainerde-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.fetchCoursefetched four things (course metadata, the learning-sequences outline, course-home metadata, sidebar toggles) and derivedcourseStatus. This moves the three data fetches to query hooks (mirrored into the model store via the bridge so the existinguseModelreaders keep working), moves the status derivation into transitional bridge hooks, and thinsfetchCourseto just the un-converted sidebar-toggles fetch.What changed
courseware/data/apiHooks.ts/queryKeys.ts(new) —useCoursewareMetadata(getCourseMetadata) anduseCoursewareOutline(getLearningSequencesOutline), tagged withmeta.modelsso the bridge fans each result into the right model(s).courseIdisstring | undefined(fromuseParams) with anenabledguard; key factories stay strictstring.course-home/data/modelStoreBridge.ts(Peel: extend the model-store bridge to collection dispatches #2009) — the bridge'smetagains amodels: [{ 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) anduseCourseExitStatusBridge(CourseExit) run the queries and mirror their combined state intostate.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— callsuseCourseStatusBridge(routeCourseId); the memoizedcheckFetchCourseguard is byte-identical to base (it now dispatches the thinnedfetchCourse).CourseExit.jsx— self-wrapping on the query hooks viauseCourseExitStatusBridge, renderingTabPageitself.courseware/data/thunks.js—fetchCoursethinned 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 appQueryCache'sonErrorlogs query failures; a query overrides the level per HTTP status viameta.logStatusAs: { <status>: <level> }(the outline's expected 403 →logInfo).metais typed globally via aRegister.queryMetaaugmentation, soonErrorand 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 dispatchingfetchCourse.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/courseIdare still written (transitionally, by the status bridges) so the redirects, gating, andTabPagebehave as before. The sidebar toggles still load via the thinnedfetchCourse. Query error logging is preserved, including the outline's403 → logInfo(a logged-out learner's expected redirect is logged at info level, not surfaced as an error).Testing
npm run types,npm run lint, and the fullnpm testsuite pass (109 suites / 912 passing / 3 pre-existing skips at PR-open; the fix below addscourseware/data/apiHooks.test.tsx, +1 suite / +1 test).queryClient.test.tscoversonError(defaultlogError,logStatusAsoverride) and the model-store bridge;modelStoreBridge.test.tscovers the list-form fan-out; the container / CourseExit / useIFrameBehavior / ProductTours tests render through the bridged query client;setupTest'sseedCoursewareModelsreplaces theexecuteThunk(fetchCourse)seed.Manual browser verification is done (tutor local, DemoX), confirming no user-facing change: cold load / hard reload, the full set of URL → redirect rules, prev/next sequence navigation (within and across sequences), unit completion, the course-exit / celebration page, and preview mode all behave as before. The sequence-navigation data path is additionally covered by a new
courseware/data/apiHooks.test.tsx, which asserts thecoursewareMetamirror preservessectionIdsregardless of query-resolution order (see thecoursewareMetamirror note in the decision log). Items needing a specific user/backend state (access-denied, the outline's expected 403 telemetry, a forced 5xx, sidebar toggles) are left to the automated suite. Full checklist below.Manual testing checklist & findings
Run against a live backend (tutor local, DemoX). Since this conversion claims no
user-facing change, the pass confirms equivalence end-to-end with real data, a real unit
iframe, and real redirects — what the jest suite can't fully exercise.
Verified by hand:
coursewareMeta/sections/sequencesand the metadata intocoursewareMeta/courseHomeMeta; page renders fully (title/header, unit content,outline sidebar, iframe) with no flash of missing structure.
checkResumeRedirect.checkSectionToSequenceRedirect), thenthat sequence's
activeUnitIndexunit (saved-position resume, unchanged by refactor: convert the courseware metadata fetch to React Query #2023);a never-visited chapter deterministically lands on the sequence's unit 1.
(
checkSectionUnitToUnitRedirect→checkUnitToSequenceUnitRedirect).checkUnitToSequenceUnitRedirect)./firstand/last→ first / last unit of the sequence.order match the outline.
invalidation. The completion checkmark updates on navigation, not instantly:
confirmed not a regression — the checkmark reads
units[].completefrom thesequence metadata (
fetchSequence), which neither the olddispatch(fetchCourse)northe new invalidation refetches; refactor: convert the courseware metadata fetch to React Query #2023 invalidates exactly the three queries
fetchCourseused to (courseware metadata, outline, course-home metadata).CourseExit(self-wrapping viauseCourseExitStatusBridge)renders the correct state.
touches no preview-sensitive path (
isPreviewonly affectsfetchSequenceand theCoursewareContainerredirect prefix).Not exercised by hand (need a specific user/backend state; covered by the suite):
courseHomeMeta.courseAccess).logInfotelemetry (logged-out learner).logError.fetchCourse).Covered by the automated suite instead:
modelStoreBridge.test.ts.statusBridge.test.ts.onErrordefault +logStatusAsoverride —queryClient.test.ts.CoursewareContainer.test.jsx(70 tests).CourseExit/useIFrameBehavior/ProductToursrender paths — their suites via thebridged client.
fetchCourse(sidebar toggles) —redux.test.js.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
metaform until #2010 wiresa 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 querycan mirror collection results (and several model targets) into the
modelsstore,not just a single
addModel.Why: the courseware producers write collections, and one fetch → many models
fetchCoursewrites four model types with mixed strategies; the outline endpoint alonewrites three from one response:
getCourseMetadatacoursewareMetaaddModel(data has its own id)getCourseHomeCourseMetadatacourseHomeMetaaddModelkeyed by courseIdgetLearningSequencesOutline→.coursescoursewareMetaupdateModelsMap(merge sectionIds)getLearningSequencesOutline→.sectionssectionsaddModelsMapgetLearningSequencesOutline→.sequencessequencesupdateModelsMap(merge)The bridge runs in the QueryCache
onSuccess, which receives the query's rawresult, so a query whose result is
{ courses, sections, sequences }must fan that oneresult out to three mirrors.
Contract: keep the single form, add a
modelslist{ modelType, courseId }(unchanged) — mirror the whole result as one model keyedby courseId. The course-home tabs use this; byte-compatible.
{ models: [{ modelType, strategy, source? }] }(new) — one or more mirrors.strategyis a model-store action (addModel/updateModel/addModelsMap/updateModelsMap/updateModels);sourceselects a key of the result (omitted =the whole result). Lets one query populate several targets with the right add-vs-merge
semantics;
sourceis what avoids splitting the outline into 3 fetches.Alternatives considered
collectionflag. Rejected: the bridge sees the raw result andmetais one-target-per-query, so the outline's 3-in-1 shape couldn't be expressedwithout 3 separate fetches (3× network) or a bespoke
onSuccess.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 ofmodels are assembled from two endpoints (
sequences= outline shallow +getSequenceMetadatafull;coursewareMeta=getCourseMetadata+ outlinesectionIds), so those readers combine the relevant hooks — a #2011/#2013/#1977concern, not this PR.
Tests
modelStoreBridge.test.tsdrives real queries throughcreateModelStoreQueryCache(store)and asserts the resulting
modelsstate: single form, list-form fan-out viasource,updateModelsMapmerging (not clobbering),updateModelsover an array, and the no-opwhen 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 themodel store via the Part-1 bridge so the ~14
useModelreaders keep working.Scope: convert the metadata/outline/courseHomeMeta fetches; thin
fetchCoursein placefetchCoursedid four fetches (metadata, outline, courseHomeMeta, sidebar toggles) + setcourseStatus. Its consumers:CoursewareContainer(player), the CourseExit route(
<TabContainer fetch={fetchCourse}>),useIFrameBehavior(refetch on an iframe event), andsetupTest'sinitializeTestStore. Decision: move the three data fetches to RQ hooks andthe status derivation into the container, then thin
fetchCoursein place so it does onlythe un-converted remainder (the sidebar toggles) — rather than deleting it and adding a new
fetchCoursewareOutlineSidebarTogglesthunk.deletes
fetchCoursethen. Renaming/replacing it now is churn for a transitional step —CoursewareContainer's guard (checkFetchCourse→dispatch(fetchCourse(id))) staysbyte-identical to the base, and
fetchCoursevisibly shrinks (4 fetches → 1) acrosslayers until it's gone. Transitional cost:
fetchCourseis briefly a misnomer (it onlyfetches toggles now).
useIFrameBehaviordouble-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).{ modelType, courseId }form (asuseCourseHomeMetauses)? The single form (bridge line 44) doesaddModel({ model: { id: courseId, ...data } })— its purpose is to injectcourseIdas the id, which
courseHomeMetaneeds because its payload has no id of its own.coursewareMeta's payload has its own id, andfetchCoursestored it withaddModel({ 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, anddata.id === courseIdon thisroute), 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 coursewarehooks 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 ] }.useCourseHomeMetaforcourseHomeMeta— don't re-fetch it.useCourseHomeMetafetches the'outline'rootSlug variant;fetchCourseused'courseware'.rootSlugonly renames the courseware tab'sslugin the normalized
tabs;courseAccess(what the gating reads) is identical. Soreuse is faithful for gating; the only difference is that tab's slug.
courseIdtyping: guarded hooks, strict keys (a migration-wide convention)useParams()types every route param asstring | undefined— React Router can't provewhich route a component renders under — even though
:courseIdis always present on thecourseware/course-exit routes. This bit only surfaces now because #2010 adds the first
typed (
.tsx) caller of these hooks (CoursewareContainer); the existing course-homecallers are all
.jsx, so the argument was never type-checked. Every future.tsxconversion hits the same thing, so we picked one convention:
string | undefined+enabled: !!courseId. The hook honestly toleratesthe
useParamstype by not firing when the id is absent (the standard React Queryidiom for "param may not be ready"). Call sites pass
useParamsstraight through — nocasts or guards proliferating across the migration. No-op for the 6 existing
.jsxcallers, since courseId is never actually undefined there.
string(neverundefined). A query key is a real identity; akey on
undefinedis meaningless. SoqueryKeys.tsstays strict.!at the key call inside the hook —coursewareQueryKeys.metadata(courseId!).This is deliberate, not sloppy:
queryKeyis evaluated eagerly (React Query computesit every render regardless of
enabled), so the factory is still called when the queryis disabled. The
!says "keys are built from real ids"; the adjacentenabled: !!courseIdis what actually makes the never-happens undefined case safe (no fetch). Runtime-wise the
!is purely type-level.Alternative considered — narrow
courseIdtostringonce at the.tsxboundary(then keys and hooks are
string, noenabled, no!). Rejected: it relocates theuseParamsundefined into a guard/assertion at every typed boundary — thecast-in-the-wrong-place friction from #2019 — instead of handling it once, idiomatically,
in the hook. (This also reverts a
string→string | undefinedwidening ofuseCourseHomeMeta/courseHomeQueryKeys.metadatathat #2010 briefly introduced before wesettled on this convention.)
Error logging: global
QueryCache.onError+ the outline's 403 nuancefetchCourselogged each endpoint independently:logErroron failed metadata/courseHomeMeta/toggles, and for the outline a
403 ? logInfo : logErrorsplit (a 403 there is the expectedaccess-denied case — the learner is redirected — so it's logged via
logInfo, notlogError,which would surface it as a noticed error).
React Query v5 removed
onErrorfromuseQuery(it's only onuseMutationand theQueryCache), so per-hook query logging isn't possible. The home for query error logging is theglobal
QueryCache.onError— permanent app infra, introduced with the QueryCache in #1987and 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 queryerrors are only genuine failures). So:
useCoursewareOutlinetagsmeta: { logStatusAs: { 403: 'info' } }.onErrorreadsquery.meta.logStatusAs— a status →LogLevelmap, defaulting toerror(it says how to log each status, not a "quiet" flag). So an outline 403 →logInfo, restoringfetchCourse's behavior; anything else →logError.LogLevel(
'error' | 'info') derives from a{ error: logError, info: logInfo }map — the only twologgers platform exposes.
fetchCourse'scatch → logError.Typed
meta, no casts.metais typed globally via aRegister.queryMetaaugmentation(
ModelStoreMeta & { logStatusAs?: Record<number, LogLevel> }), so bothonErrorand themodel-store bridge read
query.metawithout a cast, andmetaliterals are checked at thewrite site.
getResponseStatus(data/http-error.ts) reads the error's status. At #1977 theaugmentation drops its
ModelStoreMetahalf along with the bridge.Migration-wide context (#2022): other converted queries dropped their thunks'
logErrortoo,but their getters swallow 401/403/404, so the global
onError(plainlogError) already coverstheir genuine failures without per-query
meta. The outline is the exception that needs thelogStatusAstag.Access-gating + status: a transitional
useCourseStatusBridgeThe old
fetchCoursederivedcourseStatus(request → success/denied/failure) fromcourseAccess.hasAccess+ outline success and dispatchedfetchCourse{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 andmirrors their combined state into
state.courseware.courseStatusvia the same status actions,so the still-Redux readers (the container's redirect helpers/selectors,
TabPage's stringstatus,
useContextId) keep working.CoursewareContainerjust callsuseCourseStatusBridge(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
courseStatusis aderivation across all three queries — which a per-query
QueryCache.onSuccesscan'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.
fetchCourseis thinned, not deleted (see the scope section), socheckFetchCoursestays too — it just dispatches the thinnedfetchCourse(toggles only). RQauto-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}>becomesCourseExitself-wrappingon the query hooks (rendering
TabPageitself), matching the course-home tab pattern.Why the transitional slice write: the course-exit children (
CourseCelebration,CourseNonPassing,CourseInProgress, and the recommendation/upgrade helpers) readcourseIdfromstate.courseware— whichfetchCourseused to set on this route. Without thethunk, that slice field would be unset on the CourseExit route, so
useModel('courseHomeMeta', undefined)would return{}andtabs.find(...)would throw. Rather than convert all ~7 children offthe slice (that's #1976's job),
CourseExitwritescourseId/courseStatusto the slicetransitionally via
useCourseExitStatusBridge(courseware/data/statusBridge.ts) — theCourseExit sibling of
useCourseStatusBridge(2 queries, no outline). CourseExit owns thetwo queries (it also feeds them to its own
TabPagegating), so it passes them into thebridge rather than the bridge returning them. The children keep reading the slice until they
convert.
useIFrameBehaviorrefetch → query invalidationThe iframe
POST_EVENThandler'spostEvent.mutateonSuccessdiddispatch(fetchCourse(courseId)); it nowqueryClient.invalidateQueriesthe three query keys(
coursewareQueryKeys.metadata,coursewareQueryKeys.outline,courseHomeQueryKeys.metadata)via
useQueryClient, so the refetch goes through RQ.fetchCourserefetched a fourth thing — the sidebar toggles — but we deliberately don'tinvalidate those here, and nothing else is needed for them:
enableCompletionTrackingis astatic 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
courseStatusre-derives viauseCourseStatusBridgeonce those queries resettle.Sidebar toggles peeled aside (to #2013)
getCoursewareOutlineSidebarToggles→setCoursewareOutlineSidebarTogglesfeeds onlythe outline sidebar. It's the one fetch left in the thinned
fetchCourse(which thecontainer still dispatches via the unchanged
checkFetchCourseguard), so the setting keepsloading until the sidebar layer (#2013) converts it and deletes
fetchCourse. Not foldedinto 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, plusisEnabledCompletionTrackingandisActiveEntranceExam) anduseCourseOutlineSidebar()(just open/collapse UI state). Notablyit moves
isEnabledCompletionTrackingfromuseCourseOutlineSidebar()touseCourseOutlineData()— 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 toReact Query, group the toggle conversion (#2013) with the outline-data conversion
(sections/sequences/units/status) so both feed
useCourseOutlineData. Don't structure thetoggle as its own sidebar-flavored peel. Aligned that way, the eventual rebase over #1920 is
just "point
useCourseOutlineDataat the query hooks." (#1920 is UI-only — nodata//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 viaexecuteThunk(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.Model-store mirror strategy
The
coursewareMetamirror merges, not replacesDecision.
useCoursewareMetadatamirrors its result into thecoursewareMetamodelwith
strategy: 'updateModel'(merge), notaddModel(replace).Why.
coursewareMeta[courseId]is written by two independent queries: the metadataquery (
getCourseMetadata, whose payload has nosectionIds) and the outline query(
getLearningSequencesOutline, the only source ofsectionIds, mirrored viaupdateModelsMaponcourses).addModelis a full replace (state[type][id] = model),so when the metadata query resolved after the outline, it wiped the
sectionIdstheoutline had merged in.
sequenceIdsSelectorthen returned[], souseSequenceNavigationMetadatacomputedsequenceIndex = -1→previousSequenceId = null(couldn't go back a sequence) and
isLastUnittrue → next went to/course-end.Intermittent, because it was a network-order race, re-rolled by the unit-completion query
invalidation. The old
fetchCoursewas immune: afterPromise.allSettledit dispatchedaddModel(metadata)thenupdateModelsMap(courses)synchronously, so thesectionIdsmerge always ran last (there was even a comment saying so).
updateModelrestores thatguarantee regardless of resolution order; the payload carries
id, and the bridge alreadysupports the strategy.
Tested.
courseware/data/apiHooks.test.tsxrenders both hooks through the bridgedquery client with the metadata response deferred so its mirror lands last, then asserts
coursewareMeta.sectionIdsandsequenceIdsSelectorsurvive — RED withaddModel, GREENwith
updateModel.Audit: no other model-store mirror has the same exposure
Decision. Only the
coursewareMetametadata mirror needed the fix; the other mirrorsare correct as-is.
The rule. A replace-style mirror (
addModel/addModelsMap) is only unsafe whenanother writer contributes a field that the replacing query's own endpoint does not
return — a cross-source field.
coursewareMeta.sectionIdswas the unique case (metadataquery replaces the model;
sectionIdsonly ever comes from the outline endpoint).Findings.
sections—addModelsMap(replace-per-section), but the outline is the only runtimewriter, so nothing else's fields can be clobbered. Safe.
sequences— outlineupdateModelsMap+fetchSequenceupdateModel, both merge (theold code deliberately merged here: "sequence metadata may have come back first"). Safe.
coursewareMeta— after the fix, all three runtime writers (metadata mirror, outlinemirror, a
updateModelinthunks.js) merge. Safe.courseHomeMeta(pre-existing, not refactor: convert the courseware metadata fetch to React Query #2023) — same shape (useCourseHomeMetaaddModelreplace vs celebration/streak
updateModel), but benign:celebrationsis part of thecourse-home metadata response (
normalizeCourseHomeCourseMetadataspreads the wholepayload) and is server-persisted (
postCelebrationComplete), so a replace-refetchrestores it — the field is same-source, unlike
sectionIds.refetchOnWindowFocusisalso
false. No action.