openedx / openedx/frontend-app-learning
Tear down the courseware Redux slice + replace useContextId
@brian-smith-tcril is already working on this.
Since Aug 5, 2026.
- Dominant language
- JavaScript
- Stars
- 70
- Forks
- 335
- Avg merge
- 1d 17h
- Merged PRs (30d)
- 35
Description
Part of #1946 — Redux → React Query migration (Stage 1). Final teardown of the courseware conversion (Target 6 in the decomposition plan).
This issue was originally "Convert courseware/data to React Query + de-class CoursewareContainer" — the too-big item. It has been decomposed into direct sub-issues of #1946 and re-scoped to just the teardown at the end. The rest of the courseware conversion is carried by these siblings, in dependency order:
- #2008 — peel: de-class
CoursewareContainer(structural)- #2019 — peel: convert
CoursewareContainerto TypeScript (fast-follow to #2008)- #2009 — peel: extend the model-store bridge to collections
- #2010 — courseware metadata → RQ
- #2011 — sequence data → RQ
- #2012 — peel:
checkBlockCompletion→ mutation- #2013 — outline sidebar → RQ + context
- #2014 — bookmarking → RQ + de-class
UnitButton- #2015 —
saveIntegritySignature+saveSequencePosition→ mutations- #2016 —
getCourseDiscussionTopics→ RQ
Goal (this issue): after the siblings above land, remove the courseware Redux slice.
Tasks
- Migrate the residual
state.courseware.*reads (courseId/courseStatus/sequenceId/sequenceStatus) to query state /useParams.courseware.courseIdis kept written transitionally by the earlier layers (it's a read-hub, not a shared writer) and is removed here. - Replace
useContextId(src/data/hooks.ts, readsstate.courseware.courseId ?? state.courseHome.courseId) with a route/params- or context-based courseId. - Remove the
coursewarereducer fromstore.tsonce no reader remains.
Coordinate with #1977 (model-store dissolution) — this is where the two meet.
Verify: no state.courseware.* references remain; useContextId callers work off the replacement; suite green.
Plan
[!NOTE]
The findings and plan below were generated by Claude (Claude Code) and reviewed before posting. Updated as layers land: this revision reflects the B review reshapes — the redirect machinery extracted tocourseware/redirects.ts(typed options objects, seven rules including the outline-failure redirect) and the B-prepgetErrorDetailshape.
Investigation findings that adjust the task list above:
- The slice's only writers are the three
statusBridge.tshooks, and its id fields are route mirrors. The bridges dispatch the route params verbatim, souseParamsis the faithful replacement forcourseId/sequenceId(minus a one-effect-tick lag).errorCodeis written but read by nothing, anduseContextId'sstate.courseHome.courseIdfallback is dead —fetchTabFailure, its only writer, lost its last dispatcher in the tab conversions. - The reader inventory is larger than a naive grep suggests. A sweep matching
state.courseware.(with the trailing dot) misses eight destructuring readers of the formconst { courseId } = useSelector(state => state.courseware)— five course-exit components plusUnitButton(id-only, folded into A1),SequenceNavigation(ids + status, A2), andTabPage(already B's). The table above reflects the corrected inventory. - Six stacked layers instead of one teardown PR. Readers convert one at a time while the bridge still writes the slice — converted readers derive from the same queries the bridge subscribes to, so the two coexist and every layer is individually green and behavior-faithful. Only the final layer deletes anything: A1 route-param id swaps (#2069) → A2 sequence readers onto the sequence query (#2070) → A3
useIsCourseLoaded+useSequenceIds+ sequence-navigation readers, deletesselectors.js(#2071) → A4 breadcrumbs (#2072) → B-prep 403-detail restore (additive, #2073) → B container + slice/bridge/store teardown (closes this issue). - The Redux status-string vocabulary (
LOADING/LOADED/FAILED) is dropped at conversion, not ported. Readers consume the query hooks directly and gate onisPending/isSuccess/isError— exactly how every converted course-home reader already works.useSequenceMetadataabsorbs the route-derived preview flag its callers all passed (so there is no wrapper hook), the 422-means-not-a-sequence translation is a plainsequenceMightBeUnit(query)predicate, and the course composite collapses to auseIsCourseLoadedboolean — every remaining consumer gates on exactly "loaded"; the denied/failed distinctions live only in TabPage, which takes the raw queries. B reshapes the redirect rules' status params to booleans (typed options objects, extracted whole tocourseware/redirects.ts— ADR 0008's "liberal courseware path handling" — behind oneuseCoursewareRedirects()hook, with the outline-failure bounce as the seventh rule), after which no courseware code touches the status constants (the leftover deadcourseHomeusage dies with that slice's teardown, #1978). - TabPage's transitional string branch retires at the end, with an additive prep layer first.
CourseStatusbecomes queries-only and keeps its generic two-slot shape — the outline is courseware routing policy and lives with the redirect rules, not in TabPage. The 403-detail extraction lives insrc/data/http-error.ts(getErrorDetail, a learner-facing-messages filter besidegetResponseStatus); TabPage'sderiveViewreads it off the failed query's error, and converted course-home tabs share that path and so regain the specific detail message that went dead when their thunks converted. - The container's ids-match race guard is deleted with the race — and it is currently a latent bug: after an in-app navigate to
/course/:courseId(the invalid-sequence fallback), the slice keeps the stalesequenceId, the guard bails forever, and the resume redirect never runs. Route-derived ids fix that. - Model reads stay on
useModel/state.modelslookups — query-result read conversions are #1977's home. The container's replacement lookups keep exact null semantics (?? null, notuseModel's{}fallback) so the redirect helpers'section &&guards behave identically. - Behavior deltas are the standard conversion posture: later-mounting query subscribers refetch stale queries (default
staleTime: 0) where the single-subscriber bridge didn't, and TabPage surfaces an error as soon as courseHomeMeta fails instead of after all three queries settle (transient-only, same terminal state). Dead drops: sliceerrorCode, the outline-sidebar hook's unreadsequenceStatusreturn. - The reducer removal ships as plain
refactor:— precedent: the recommendations and product-tours reducer removals (#1967, #1968); the store shape is not a documented plugin surface.
Full plan
Plan: #1976 — Tear down the courseware Redux slice + replace useContextId
Split into six stacked layers (A1–A4, B-prep, B), each individually green
and reviewable as a small diff. The bridge keeps writing the slice through all
of A1–A4 and B-prep; converted readers just read the same query state one
effect-tick earlier. Only B deletes anything load-bearing. All layers are
"Part of #1976"; the last closes it.
Context
Part of epic #1946 (Redux → React Query, Stage 1) — Target 6, the final
teardown of the courseware decomposition. All ten prerequisite siblings
(#2008–#2016, #2019) are merged or in the open stack (#2060–#2068); these
layers go on top.
How it works today
The courseware slice (courseware/data/slice.js) holds seven fields:
courseId, courseStatus, sequenceId, sequenceStatus,
sequenceMightBeUnit, errorMessage, errorCode. Since #2010/#2011 its
only writers are the three transitional hooks in
courseware/data/statusBridge.ts, which mirror React Query state into the
slice:
useCourseStatusBridge(routeCourseId)(called byCoursewareContainer)
derivescourseStatusfrom three queries —useCoursewareMetadata,
useCoursewareOutline,useCourseHomeMeta(courseId, 'courseware'): any
pending →loading; metadata + courseHomeMeta success →
(hasAccess&& outline success ?loaded:denied); otherwisefailed,
extractingerrorMessage/errorCodefrom a 403 courseHomeMeta body
(data.detail/data.error_code, raw snake_case).useSequenceStatusBridge(routeSequenceId, isPreview)(same caller) mirrors
useSequenceMetadata: pending →loading, success →loaded, else
failedwithsequenceMightBeUnit = (status === 422).useCourseExitStatusBridge(called byCourseExit) — the no-outline
variant. CourseExit already hands TabPage its queries
({ metadataQuery: courseHomeMetaQuery, tabDataQuery: metadataQuery });
the bridge survives there forCourseRecommendations' slicecourseId
read and TabPage'serrorMessageread.
The bridges dispatch the route params verbatim, so slice courseId /
sequenceId are just route mirrors (one effect-tick delayed) — route params
are their faithful replacement. errorCode has no reader anywhere (only
writers). useContextId's state.courseHome.courseId fallback is dead:
fetchTabFailure, the only writer, has had no dispatcher since the tab
conversions.
Residual readers, mapped to their layer:
| Reader | Fields | Layer |
|---|---|---|
course/sequence/Unit/hooks/useIFrameBehavior.ts |
sequenceId (via getSequenceId) |
A1 |
course/sidebar/sidebars/course-outline/hooks.js |
sequenceId, sequenceStatus (via selectors) | A1 |
course/course-exit/CourseRecommendations.jsx |
courseId | A1 |
course/course-exit/CourseCelebration.jsx, CourseInProgress.jsx, CourseNonPassing.jsx, CatalogSuggestion.jsx, UpgradeFootnote.jsx |
courseId (destructured) | A1 |
course/sequence/sequence-navigation/UnitButton.tsx |
courseId, sequenceId (destructured) | A1 |
src/data/hooks.ts (useContextId) |
courseId | A1 |
course/sequence/Sequence.jsx |
sequenceStatus, sequenceMightBeUnit | A2 |
course/sequence/sequence-navigation/SequenceNavigation.jsx |
courseId, sequenceStatus (destructured) | A2 |
alerts/sequence-alerts/hooks.js (both hooks) |
sequenceStatus | A2 |
course/sequence/sequence-navigation/hooks.js |
courseId, courseStatus, sequenceStatus, sequenceIdsSelector |
A3 |
course/sequence/sequence-navigation/UnitNavigationEffortEstimate.jsx |
sequenceIdsSelector |
A3 |
course/breadcrumbs/CourseBreadcrumbs.jsx |
courseStatus, sequenceStatus | A4 |
tab-page/TabPage.tsx |
errorMessage + transitional string CourseStatus branch |
B-prep / B |
CoursewareContainer.tsx (5 local createSelectors + 5 component reads) |
all five id/status fields | B |
Layer A1 — route-param id swaps (no new hooks)
Branch bsmith/courseware-route-id-reads — refactor: read courseware route ids from useParams, not the Redux slice.
The readers that only consume the slice's route-mirror id fields:
useIFrameBehavior.ts:const { sequenceId: activeSequenceId } = useParams();
replacesuseSelector(getSequenceId)(component renders under the unit
route).useSelector+ selector imports go.course-outline/hooks.js:activeSequenceIdfromuseParams(already
imported there). Also drop the returnedsequenceStatus— no component
consumes it (verified: no reader inCourseOutline.tsx, tray, trigger, or
the Sidebar* components) — so this file sheds both selector imports and its
useSelectorin one layer.CourseRecommendations.jsx:const { courseId } = useParams();(renders
under/course/:courseId/course-end).- Same swap for the five destructuring courseId readers the original
inventory'sstate.courseware.-with-dot grep missed (found in A1's test
run):CourseCelebration.jsx,CourseInProgress.jsx,CourseNonPassing.jsx,
CatalogSuggestion.jsx,UpgradeFootnote.jsx(all course-exit). UnitButton.tsx(also a missed destructurer):courseId+sequenceId
fromuseParams— it renders on unit routes and builds unit hrefs from
them; theRootStateimport leaves the file.src/data/hooks.ts:export const useContextId = () => useParams().courseId;
— every course-home/courseware route has:courseId. The dead
state.courseHome.courseIdfallback and theRootStateimport go. Sole
caller (DashboardFootnoteLinkPluginSlot) feedsuseModel/logClick,
both fine with the route value.
CourseExit.jsx is not touched: its bridge call still feeds TabPage's
errorMessage read until B.
Tests: CourseExit.test.jsx drops its fetchCourseSuccess({ courseId })
dispatch (it existed to seed the slice courseId for CourseRecommendations)
and renders under a :courseId route so useParams serves it. Outline
sidebar + iframe suites: id sourcing only, assertions unchanged.
Layer A2 — sequence readers onto the sequence query
Branch bsmith/use-sequence-status — refactor: derive sequence status from the sequence query.
No new hook: useSequenceMetadata absorbs the route-derived preview flag
(every caller passed pathname.startsWith('/preview'), threaded from the
container — preview-ness is a property of the route), its isPreview param
drops, and readers call the query hook directly. The Redux status-string
vocabulary (LOADING/LOADED/FAILED) is dropped, not ported — converted
readers speak query flags, exactly like the course-home conversions (nothing
in src/course-home touches those constants any more; TabPage derives view
booleans; the outline-sidebar hook exposes isOutlinePending). The one
derived value becomes a plain predicate:
// A 422 from the sequence query means the requested id names a unit, not a sequence
// (the container redirects on it).
export const sequenceMightBeUnit = (sequenceQuery: { error: unknown }): boolean => (
getResponseStatus(sequenceQuery.error) === 422
);
- Readers translate their gates:
status === 'loading'→.isPending,
=== 'loaded'→.isSuccess,=== 'failed'→.isError. Same decision
points, no new vocabulary. enabled: !!sequenceId+isPendingreproduces the slice's initial
loading state for a missing id exactly (the bridge bailed without
dispatching).- The bridge slims along the way:
useSequenceStatusBridgeloses its
isPreviewpass-through (the container call updates) since the query hook
now derives it.
Converted readers:
Sequence.jsx:loading = sequenceQuery.isPending || sequenceMightBeUnit(sequenceQuery)
(the failed-and-might-be-unit arm folds in: the predicate is only true on a
422 error); loaded gates on.isSuccess; the failed fall-through is
unchanged.alerts/sequence-alerts/hooks.js(both hooks): gate on
useSequenceMetadata(sequenceId).isSuccess.SequenceNavigation.jsx(a destructurer the original inventory missed;
deferred whole from A1 so the file is touched once):courseIdfrom
useParams, render/lock gates on the query's.isSuccess; itsLOADED
constant import dies.
Tests: new useSequenceStatus describe in apiHooks.test.tsx porting the
sequence half of the bridge matrix (missing id → loading; pending → loading;
success → loaded; 422 → failed + might-be-unit; non-422 → failed, not).
Sequence.test.jsx / alerts tests / Course.test.jsx: components now fetch
sequence metadata through the axios mocks initializeTestStore already
registers, via render's non-bridged query client — loaded paths may need
findBy*/waitFor settles; loading paths hold the response open (existing
reply(() => new Promise(() => {})) pattern); failed paths mock error replies
(422 for the might-be-unit case).
Layer A3 — useIsCourseLoaded + useSequenceIds + the navigation readers
Branch bsmith/course-loaded-sequence-ids — refactor: derive the courseware loaded gate and sequence ids from queries.
Two new hooks in apiHooks.ts. No status strings here either: every
remaining consumer of the composite course status gates on exactly "loaded"
(the nav hooks, breadcrumbs, useSequenceIds, and B's redirect logic — the
denied/failed distinctions live only in TabPage, which takes the raw queries),
so the composite collapses to one boolean:
export const useIsCourseLoaded = (courseId: string | undefined): boolean => {
const metadataQuery = useCoursewareMetadata(courseId);
const outlineQuery = useCoursewareOutline(courseId);
const courseHomeMetaQuery = useCourseHomeMeta(courseId, 'courseware');
return metadataQuery.isSuccess && courseHomeMetaQuery.isSuccess
&& !!courseHomeMetaQuery.data?.courseAccess?.hasAccess && outlineQuery.isSuccess;
};
export const useSequenceIds = (courseId: string | undefined): string[] => {
const isCourseLoaded = useIsCourseLoaded(courseId);
const { sectionIds = [] } = useModel('coursewareMeta', courseId);
const sections = useModels('sections', isCourseLoaded ? sectionIds : []);
return useMemo(() => sections.flatMap(section => section.sequenceIds), [sections]);
};
- The bridge's
denied/failed/loadingoutcomes all behave as "not
loaded" for these consumers, exactly as their string comparisons did. useSequenceIdsreplacessequenceIdsSelector; model reads stay on
useModel/useModels(query-result read conversions are #1977's home).
useModels'shallowEqualkeeps the array reference stable anduseMemo
keeps the flatMap result stable — the memoizationcreateSelectorprovided.
Converted readers:
sequence-navigation/hooks.js(useSequenceNavigationMetadata):
courseIdfromuseParams,useSequenceIds(courseId),
useIsCourseLoaded(courseId),
useSequenceMetadata(currentSequenceId).isSuccess.UnitNavigationEffortEstimate.jsx:useSequenceIds(useParams().courseId).
Delete courseware/data/selectors.js: getSequenceId/getSequenceStatus
lost their consumers in A1; sequenceIdsSelector's two consumers convert here
(the container has its own inline copy, untouched until B).
courseware/data/index.js drops the sequenceIdsSelector export.
Tests: new useCourseStatus describe porting the course half of the
bridge matrix (missing id → loading; any pending → loading; access + outline
→ loaded; no access → denied; outline failure → denied; query failure →
failed). Sequence-navigation suites (UnitNavigation, effort estimate) get
the same fetch-through-mocks settling as A2.
Layer A4 — CourseBreadcrumbs
Branch bsmith/breadcrumbs-status-hooks — refactor: convert CourseBreadcrumbs to the status hooks. (Foldable into A3 if a two-line PR
feels silly.)
useIsCourseLoaded(courseId)+useSequenceMetadata(sequenceId).isSuccess
(both ids are already props); the'loaded' && 'loaded'gate becomes
isCourseLoaded && sequenceQuery.isSuccess.useSelectorimport goes.
Tests: CourseBreadcrumbs.test.jsx settling as above.
Layer B-prep — restore the 403 detail message (additive)
Commit: fix: restore the 403 detail message on query-converted course-home tabs.
While the string branch still exists, TabPage's query branch learns to source
the error message from the query error — which also fixes a silent regression:
the old shared fetchTab thunk extracted detail/error_code from a 403
body into fetchTabFailure (visible at d6d9a619~1), the tab conversions
#1987→#2006 removed its dispatchers tab by tab, and since then
state.courseHome.errorMessage is permanently null — converted course-home
tabs show the generic failure text where they used to show the 403's
detail. (Courseware never regressed: its status bridge performs the same
extraction into state.courseware.errorMessage.)
-
The body-shape knowledge lives in
src/data/http-error.ts(which
already models the shape viaRequestError):
getErrorDetail(error): string | undefined— a status switch handling 403
(backend-authored learner-facing prose) and defaulting to undefined,
named to match its siblinggetResponseStatus. TabPage imports it and
stays a renderer;deriveViewownserrorDetail. -
The message source is a branch on the
courseStatusshape, not a
fallback chain — string callers and query callers are two transports for
the same message from different eras, and OR-ing them only works by the
other era's values being null:CourseExit — the one query caller whose detail previously traveled
bridge→slice — gets the identical detail from the extraction (its
metadataQueryis the courseHomeMeta query; its existing 403-detail
integration test passes unchanged). In B the string branch dies and both
slice reads leave TabPage with it. -
TabPage does NOT grow an
outlineQuerymember. A first draft widened
CourseStatuswith it (outline pending → loading, outline error → denied);
rejected in review — TabPage's two-slot contract (access authority + tab
content) is generic, and the outline is courseware-specific routing policy
that belongs in the component that owns routing policy: the container (see
Layer B).errorCodeis not carried over anywhere — the thunk and bridge
wrote it, nothing ever read it.
Tests: three new query-branch cases — 403-with-detail renders the detail,
non-403 and bodyless 403 render the generic message. String-branch cases
untouched (they die in B).
Layer B — the teardown proper
Branch bsmith/courseware-slice-teardown — refactor: tear down the courseware Redux slice — closes #1976.
CoursewareContainer.tsx
-
The five slice reads become:
routeCourseId/routeSequenceId(already
destructured fromuseParams),useIsCourseLoaded(routeCourseId),
useSequenceMetadata(routeSequenceId)+ thesequenceMightBeUnit
predicate; the two bridge calls and their import go. -
The redirect machinery extracts whole to
src/courseware/redirects.ts
(review-driven): it is ADR 0008's "liberal courseware path handling", so the
module carries that name and story. The rules reshape from positional
status-string params to typed options objects with booleans
(isCourseLoaded,isSequenceFailed, …), drop their class-eracheck
prefix (resumeRedirect,sectionUnitToUnitRedirect, …), and are memoized
withdefaultMemoize(fn, shallowEqual)so the fire-once guard semantics
survive object args. A self-containeduseCoursewareRedirects()hook owns
the no-dep-array effect and the redirect-only derivations; the container
calls one hook. The outline-failure bounce is the seventh rule
(outlineFailureRedirect: outline failed + access granted →
/course/:courseId/home, effect-time like its siblings). The courseware
tree stops speaking the Redux status vocabulary entirely: after this layer
theLOADING/LOADED/FAILED/DENIEDconstants (andStatusValue) have
no courseware users; what remains is the deadcourseHomeslice usage,
which dies with that slice's teardown (#1978). -
The five local
createSelectors become model lookups parameterized by the
route ids, preserving exact null semantics (?? null, since these return
stored references or null — stable under defaultuseSelector
equality — whileuseModelwould return{}and break the helpers'
section &&guards):currentCourseSelector→state.models.coursewareMeta?.[routeCourseId] ?? nullcurrentSequenceSelector→state.models.sequences?.[routeSequenceId] ?? nullsectionViaSequenceIdSelector→state.models.sections?.[routeSequenceId] ?? nullsequenceIdsSelector(inline copy) →useSequenceIds(routeCourseId)nextSequenceSelector/firstSequenceIdSelector→ derived inline from
useSequenceIds/sectionIds+ a model lookup, same guards
(!sequenceId || ids.length === 0 → null, status gate onloaded).- The untyped
state.modelsreads go through one named cast:
data/modelReader.ts(readModels+CoursewareModels), shared by the
container and the redirects hook; the file dissolves with #1977.
-
The ids-match race guard goes (the
courseId !== (routeCourseId || null)
early-return and its comment block): statuses now derive from queries keyed
by the route ids, so the "redux ids lag the route" race it defended against
no longer exists. Side effect worth documenting: after an in-app navigate to
/course/:courseId(e.g. the invalid-sequence fallback), the slice kept the
stalesequenceId, so the guard bailed forever and the resume redirect
never ran; with route-derived ids the redirect checks run correctly. -
The redirect rules keep their decision structure — only the status params
change shape (strings → booleans, per the bullet above) and the values
change source.latest.currentfor thecheckSaveSequencePositionguard
is fed the new values; the memoize-by-unitId pattern stays. -
TabPage gets queries instead of the string — the standard two-slot shape,
exactly CourseExit's ({ metadataQuery: courseHomeMetaQuery, tabDataQuery: metadataQuery }). The outline is courseware routing policy, not
TabPage's: on outline failure with access otherwise granted, the
outlineFailureRedirectrule navigates to the same home destination the
bridge'sdeniedproduced (thegetAccessDeniedRedirectUrldefault-branch
outcome for a non-outline tab; ordering: only whenhasAccessis true, so
an access denial's specific redirect still wins via TabPage; effect-time
rather than the bridge's pre-paint redirect — one frame of chrome can paint
on the outline-error edge). Outline-pending needs no handling:
post-A3, everything underCoursetolerates a not-yet-loaded outline
(useSequenceIds→[], nav defaults,Sequence's spinner rides the
independent sequence query) — the only delta is a transient tail where page
chrome renders while the outline finishes, replacing a full-page spinner
(documented as a behavior delta). -
Coursekeeps receivingcourseId/sequenceIdprops, now the route
values.react-redux'suseSelectorstays only for the model lookups
(#1977);createSelector/reselectimports go if nothing else needs them.
tab-page/TabPage.tsx
CourseStatusdrops theStatusValueunion member andderiveViewloses
the string branch — courseware was the last string caller, as the inline
comment promised. Thestate.courseware.errorMessageread goes (the
state.courseHome.errorMessageread stays; its slice's teardown is later
work, and nothing writes it today).
Other
CourseExit.jsx: delete theuseCourseExitStatusBridgecall + import (its
remaining purposes — CourseRecommendations' id in A1, errorMessage in
B-prep — are gone). The bareuseCoursewareOutline(courseId)call stays —
it seeds section/sequence models for the celebration content, unrelated to
the bridge.
Deletions + wiring
- Delete
courseware/data/statusBridge.ts+statusBridge.test.ts
(matrix already ported in A2/A3) andcourseware/data/slice.js. - Delete
tab-page/TabContainer.jsx(+ its test, itsindex.jsexport,
and the stalejest.mockinsrc/index.test.jsx) — the Redux-era generic
tab wrapper (dispatches afetchthunk prop, readsstate[slice]string
status). The tab conversions removed its last renderer; found in the
pre-B audit as the only other string-status passer, so it dies with
TabPage's string branch. Not a plugin surface (internal module, no docs
references, no renderers). - Delete the
StatusValuetype fromconstants.ts— TabPage's string
branch is its last consumer. TheLOADING/LOADED/FAILED/DENIED
constants themselves stay:course-exit/track.jsuses two as analytics
labels andpreferences-unsubscribeas local component state (neither is
TabPage view status), plus the deadcourseHomeslice usage (#1978). courseware/data/index.js: drop thereducerexport (api re-exports
remain).store.ts: dropcoursewareReducerimport +coursewareentry.
RootStateshrinks; nostate.coursewareTS references remain. Reducer
removals shipped as plainrefactor:before (#1967, #1968) — same here;
the store shape is not a documented plugin surface.setupTest.js:initializeTestStoredrops thecoursewarereducer;
seedCoursewareModels/seedSequenceModelsdrop the
fetchCourseSuccess/fetchSequenceSuccessdispatches + slice imports
(model seeding stays).apiHooks.ts:CheckBlockCompletionVars— both call sites now pass route
params; retype| null→| undefinedand update the "still-untyped Redux
slice" comment.
Tests:
TabPage.test.jsx: string-status cases (loading/failure/courseware-state
message) deleted with the branch; query-object cases from B-prep carry the
coverage.CoursewareContainer.test.jsxis already a full integration suite (real
store, bridged test query client, axios mocks, real routes) — the data flow
it exercises survives intact; expect it green modulo incidental references,
and the redirect-helper unit tests are untouched.- Slice-id conveniences: ~15 suites read
state.coursewarepurely to learn
ids — re-sourced through agetTestStoreIds(store)helper insetupTest.js
({ courseId, sequenceId }as the first keys of the single-course/
single-sequence factories' model maps). - The moved rule unit tests live in
redirects.test.ts; the container's
integration suite passes unchanged. Sequence.test's "displays error message on sequence load failure" case:
itsfetchSequenceFailuredispatch is dead (Sequence reads the query since
A2) and the test currently passes by accident — the unmatched sequence GET
hitslogUnhandledRequests'200 {}fallback, which throws in
normalizeSequenceMetadata→ query error. Drop the dispatch + slice import
and mock an explicit error reply so the failure is stated, not incidental.
Behavior changes (by layer)
- A1–A4: each converted reader loses the one-effect-tick Redux lag (reads
the same query state, synchronously keyed to the route). New query
subscribers mean mount refetches with defaultstaleTime: 0— opening
the outline sidebar or mounting Sequence can trigger background refetches
the single-subscriber bridge didn't; results land in the model store through
the same bridge. Standard posture of every converted query. - A1: outline sidebar hook's returned
sequenceStatusdropped (no
consumer). - B-prep: course-home tabs regain 403 detail messages (dead since their
thunks stopped dispatchingfetchTabFailure).errorCodedropped (no
reader). - B: no more id/status race — the container's ids-match guard goes; the
stale-bail after in-app navigation to the course root (resume redirect never
firing) is fixed. TabPage surfaces an error as soon as courseHomeMeta fails
instead of waiting for all three queries to settle (transient-only; same
terminal state). - Otherwise faithful: same status derivations, same denied-on-outline-failure
fold, same redirect helpers with the same inputs at decision points.
Decision doc
Spans the layers; each PR body picks its bullets:
- The Redux status-string vocabulary is dropped at conversion, not ported —
readers consume the query hooks directly (useSequenceMetadataflags plus
thesequenceMightBeUnitpredicate,useIsCourseLoadedfor the course
composite), matching how every converted course-home reader already works;
nouseSequenceStatus-style wrapper hook exists —useSequenceMetadata
absorbed the route-derived preview flag its callers all passed. B reshapes
the exported redirect helpers' status params to booleans, after which no
courseware code touchesLOADING/LOADED/FAILED/DENIED. Each hook
lands with its first consumers (no dead-code layer); the bridge coexists
with converted readers until B. - Ids come from route params — the bridges only ever mirrored them; race
guard deleted with the race (latent course-root stale-bail fixed). - TabPage's transitional string branch removed as planned;
CourseStatusis
queries-only and keeps its generic two-slot shape — the outline is
courseware routing policy and lives with the redirect rules in
courseware/redirects.ts(B), not TabPage;
the 403-detail extraction lives indata/http-error.tsand TabPage's
query branch reads it off the metadata query error (course-home tabs
regain detail messages, B-prep). - Model reads stay on
useModel/useSelector(state.models…)— #1977's home. useContextId→useParams().courseId; dead courseHome fallback dropped.coursewarereducer removed from the store as plainrefactor:(precedent:
#1967, #1968; store shape is not a documented plugin surface).- Dead drops:
errorCode, outline-sidebar hook's unreadsequenceStatus.
Stack
Six layers on top of bsmith/react-query-discussion-topics (#2068), in
order:
- A1 — draft PR #2069 (
bsmith/courseware-route-id-reads) - A2 — draft PR #2070 (
bsmith/use-sequence-status; branch name predates
the review rework that eliminated the hook it names) - A3 — draft PR #2071 (
bsmith/course-loaded-sequence-ids) - A4 — draft PR #2072 (
bsmith/breadcrumbs-status-hooks) - B-prep — draft PR #2073 (
bsmith/tabpage-outline-query; branch named
before the review reshape dropped the outlineQuery idea) - B —
bsmith/courseware-slice-teardown(closes #1976)
A1 is independent; A2 and A3 are independent of each other; A4 needs both;
B-prep is independent of A; B needs everything. Stacked in the order above for
simplicity. Local work stays unpushed until told to submit.
Verification
Per layer: nvm use && npm run types, npm run lint:fix, and the targeted
suites (output to a file) —
- A1:
src/courseware/course/sequence/Unit,course-outline,course-exit,src/plugin-slots/CourseExitPluginSlots - A2:
src/courseware/data,src/courseware/course/sequence,src/alerts,Course.test - A3:
src/courseware/data,sequence-navigation - A4:
breadcrumbs - B-prep:
src/tab-page - B: full suite
Manual smoke at B (tutor local, DemoX): unit page loads; breadcrumbs + unit
navigation render; outline sidebar opens and marks the active sequence;
/course/:courseId resume-redirects; /course/:courseId/:sectionId redirects
to its first sequence; an invalid sequence id redirects to the course root and
resumes; unit-id-as-sequence URL redirects into the parent sequence (422
path); course-end page renders with recommendations/footnote link; masquerade
a learner without access → denied redirect. Spot-check after A2 (sequence
loads, invalid-sequence spinner) and B-prep (denied learner on a course-home
tab) as cheap sanity passes.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.