| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
Sorry, something went wrong.
Codecov Report✅ All modified and coverable lines are covered by tests. @@ Coverage Diff @@
## master #2023 +/- ##
==========================================
+ Coverage 93.53% 93.55% +0.02%
==========================================
Files 363 367 +4
Lines 5905 5972 +67
Branches 1367 1416 +49
==========================================
+ Hits 5523 5587 +64
- Misses 367 368 +1
- Partials 15 17 +2 ☔ View full report in Codecov by Harness.
|
Sorry, something went wrong.
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.
Sorry, something went wrong.
|
All comments addressed. Most of them were very straightforward "just address the comment" fixes, but one was a little more involved. @arbrandes I'd specifically like to make sure you have a chance to look at #2023 (comment) before I land this. |
Sorry, something went wrong.
|
Go on right ahead, looks good! |
Sorry, something went wrong.
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>
`tabs[].slug` was never a property of the tab. `course_metadata` returns one entry,
`tabId: 'courseware'`, for a destination this MFE splits into two pages, and
`normalizeCourseHomeCourseMetadata` stamped it with whichever page was asking so
that the nav's `slug === activeTabSlug` check would highlight it on either. That
meant `rootSlug` in the query key, and one endpoint cached twice per course.
Nothing about a tab is an input to the request — it is
`GET /api/course_home/course_metadata/{courseId}?browser_timezone=…` and the
response is identical for every page — so the whole tabs mapping goes with the
parameter rather than being reduced to `slug: tab.tabId`. `camelCaseObject` has
already produced `tabId`, and the endpoint sends only `tab_id`, `title` and
`url`, so what remained would have re-spelled one field and filtered nothing.
The normalizer now computes only `isMasquerading`, and `course-home/data/` has
no opinion about tabs at all.
`course-tabs/utils.ts` becomes the only module naming a tab id, exporting an
accessor per destination that returns what callers use — `getCourseOutlineUrl`,
`getDatesTabUrl`, `getProgressTabUrl`, `hasDiscussionTab` — over a private
`getTab`, alongside `isActiveTab`, which knows the Course tab covers both the
outline and the content routes, and `getActiveTab`. All eight lookups move onto
them, so no component outside `course-tabs/` names a tab field or holds a tab.
`CourseTabLink` keeps its `slug` prop — the slot README documents operators
passing a custom tab name there, and `activeTabSlug` is a page name rather than a
tab id, so that identity's domain is wider than `course_metadata`'s and does not
move with this change.
`useCourseHomeMeta(courseId)` and `courseHomeQueryKeys.metadata(courseId)` lose
the parameter, along with ten call sites — including `useIFrameBehavior`, which
builds the key directly to invalidate it. Both contexts now share one cache
entry. That does not reduce requests in the browser: `useCourseHomeMeta` sets no
`staleTime`, so a shared key still refetches for each observer that remounts on it,
and counting `course_metadata` hits across an outline-to-courseware crossing gives
exactly 3 either way, five runs each. Collapsing the keys turns a cache miss into a stale
hit; reducing the count is a `staleTime` question this layer leaves tractable by
having one key to tune instead of two.
The tab factory emitted `slug` alongside a derived `tab_id`, plus `priority` and
`type` that this endpoint never sends, and the mapping is what stripped them.
`useModel` returns `any`, so no reader of this payload is type-checked and that
fixture is the only thing between a missed consumer and a silent production
break; it now matches the pact. Of the three tests #2023 added to pin the old
behaviour, the two asserting a data-layer opinion about tab ids are deleted and
the third inverts into both contexts sharing one cache entry. `discussionsPrefetch`
gains the test it never had — nothing exercised its tab gate.
Part of #1946 (Stage 1). Closes #2084.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`tabs[].slug` was never a property of the tab. `course_metadata` returns one entry,
`tabId: 'courseware'`, for a destination this MFE splits into two pages, and
`normalizeCourseHomeCourseMetadata` stamped it with whichever page was asking so
that the nav's `slug === activeTabSlug` check would highlight it on either. That
meant `rootSlug` in the query key, and one endpoint cached twice per course.
Nothing about a tab is an input to the request — it is
`GET /api/course_home/course_metadata/{courseId}?browser_timezone=…` and the
response is identical for every page — so the whole tabs mapping goes with the
parameter rather than being reduced to `slug: tab.tabId`. `camelCaseObject` has
already produced `tabId`, and the endpoint sends only `tab_id`, `title` and
`url`, so what remained would have re-spelled one field and filtered nothing.
The normalizer now computes only `isMasquerading`, and `course-home/data/` has
no opinion about tabs at all.
`course-tabs/utils.ts` becomes the only module naming a tab id, exporting an
accessor per destination that returns what callers use — `getCourseOutlineUrl`,
`getDatesTabUrl`, `getProgressTabUrl`, `hasDiscussionTab` — over a private
`getTab`, alongside `isActiveTab`, which knows the Course tab covers both the
outline and the content routes, and `getActiveTab`. All eight lookups move onto
them, so no component outside `course-tabs/` names a tab field or holds a tab.
`CourseTabLink` keeps its `slug` prop — the slot README documents operators
passing a custom tab name there, and `activeTabSlug` is a page name rather than a
tab id, so that identity's domain is wider than `course_metadata`'s and does not
move with this change.
`useCourseHomeMeta(courseId)` and `courseHomeQueryKeys.metadata(courseId)` lose
the parameter, along with ten call sites — including `useIFrameBehavior`, which
builds the key directly to invalidate it. Both contexts now share one cache
entry. That does not reduce requests in the browser: `useCourseHomeMeta` sets no
`staleTime`, so a shared key still refetches for each observer that remounts on it,
and counting `course_metadata` hits across an outline-to-courseware crossing gives
exactly 3 either way, five runs each. Collapsing the keys turns a cache miss into a stale
hit; reducing the count is a `staleTime` question this layer leaves tractable by
having one key to tune instead of two.
The tab factory emitted `slug` alongside a derived `tab_id`, plus `priority` and
`type` that this endpoint never sends, and the mapping is what stripped them.
`useModel` returns `any`, so no reader of this payload is type-checked and that
fixture is the only thing between a missed consumer and a silent production
break; it now matches the pact. Of the three tests #2023 added to pin the old
behaviour, the two asserting a data-layer opinion about tab ids are deleted and
the third inverts into both contexts sharing one cache entry. `discussionsPrefetch`
gains the test it never had — nothing exercised its tab gate.
Part of #1946 (Stage 1). Closes #2084.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| Back | FazBrowse Home | New Git URL |
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
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 is logged at info level, not surfaced as an error).
Testing
npm run types, npm run lint, and the full npm test suite pass (109 suites / 912 passing / 3 pre-existing skips at PR-open; the fix below adds courseware/data/apiHooks.test.tsx, +1 suite / +1 test). 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 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 the coursewareMeta mirror preserves sectionIds regardless of query-resolution order (see the coursewareMeta mirror 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 & findingsRun 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/sequences and the metadata into
coursewareMeta/courseHomeMeta; page renders fully (title/header, unit content,
outline sidebar, iframe) with no flash of missing structure.
checkResumeRedirect.
that sequence's activeUnitIndex unit (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).
order match the outline.
invalidation. The completion checkmark updates on navigation, not instantly:
confirmed not a regression — the checkmark reads units[].complete from the
sequence metadata (fetchSequence), which neither the old dispatch(fetchCourse) nor
the new invalidation refetches; refactor: convert the courseware metadata fetch to React Query #2023 invalidates exactly the three queries
fetchCourse used to (courseware metadata, outline, course-home metadata).
renders the correct state.
touches no preview-sensitive path (isPreview only affects fetchSequence and the
CoursewareContainer redirect prefix).
Not exercised by hand (need a specific user/backend state; covered by the suite):
Covered by the automated suite instead:
bridged client.
Decisions
Full decision logDecisions — 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:
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
by courseId. The course-home tabs use this; byte-compatible.
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
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.
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.
deletes fetchCourse then. Renaming/replacing it now is churn for a transitional step —
CoursewareContainer's guard (checkFetchCourse → dispatch(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).
useIFrameBehavior double-populating models on the player.
The query hooks (new courseware/data/apiHooks.ts + queryKeys.ts)
meta: { models: [{ modelType: 'coursewareMeta', strategy: 'addModel' }] } (result has its own id).
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).
meta: { models: [ courses→coursewareMeta/updateModelsMap, sections→addModelsMap, sequences→updateModelsMap ] }.
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:
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 on undefined is meaningless. So queryKeys.ts stays strict.
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 string→string | 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 logged via logInfo, not logError,
which would surface it as a noticed error).
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:
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.
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.
Why the transitional slice write: 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. Without the
thunk, that slice field would be unset on the CourseExit route, so useModel('courseHomeMeta', undefined) would return {} and tabs.find(...) would throw. 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)
getCoursewareOutlineSidebarToggles → setCoursewareOutlineSidebarToggles 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.
Model-store mirror strategy
The coursewareMeta mirror merges, not replaces
Decision. useCoursewareMetadata mirrors its result into the coursewareMeta model
with strategy: 'updateModel' (merge), not addModel (replace).
Why. coursewareMeta[courseId] is written by two independent queries: the metadata
query (getCourseMetadata, whose payload has no sectionIds) and the outline query
(getLearningSequencesOutline, the only source of sectionIds, mirrored via
updateModelsMap on courses). addModel is a full replace (state[type][id] = model),
so when the metadata query resolved after the outline, it wiped the sectionIds the
outline had merged in. sequenceIdsSelector then returned [], so
useSequenceNavigationMetadata computed sequenceIndex = -1 → previousSequenceId = null
(couldn't go back a sequence) and isLastUnit true → next went to /course-end.
Intermittent, because it was a network-order race, re-rolled by the unit-completion query
invalidation. The old fetchCourse was immune: after Promise.allSettled it dispatched
addModel(metadata) then updateModelsMap(courses) synchronously, so the sectionIds
merge always ran last (there was even a comment saying so). updateModel restores that
guarantee regardless of resolution order; the payload carries id, and the bridge already
supports the strategy.
Tested. courseware/data/apiHooks.test.tsx renders both hooks through the bridged
query client with the metadata response deferred so its mirror lands last, then asserts
coursewareMeta.sectionIds and sequenceIdsSelector survive — RED with addModel, GREEN
with updateModel.
Audit: no other model-store mirror has the same exposure
Decision. Only the coursewareMeta metadata mirror needed the fix; the other mirrors
are correct as-is.
The rule. A replace-style mirror (addModel / addModelsMap) is only unsafe when
another writer contributes a field that the replacing query's own endpoint does not
return — a cross-source field. coursewareMeta.sectionIds was the unique case (metadata
query replaces the model; sectionIds only ever comes from the outline endpoint).
Findings.
writer, so nothing else's fields can be clobbered. Safe.
old code deliberately merged here: "sequence metadata may have come back first"). Safe.
mirror, a updateModel in thunks.js) merge. Safe.
replace vs celebration/streak updateModel), but benign: celebrations is part of the
course-home metadata response (normalizeCourseHomeCourseMetadata spreads the whole
payload) and is server-persisted (postCelebrationComplete), so a replace-refetch
restores it — the field is same-source, unlike sectionIds. refetchOnWindowFocus is
also false. No action.