openedx / openedx/frontend-app-learning

Tear down the courseware Redux slice + replace useContextId

Open
#1,976 0 comments 0 reactions 1 assignee View on GitHub

@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:

  1. #2008 — peel: de-class CoursewareContainer (structural)
  2. #2019 — peel: convert CoursewareContainer to TypeScript (fast-follow to #2008)
  3. #2009 — peel: extend the model-store bridge to collections
  4. #2010 — courseware metadata → RQ
  5. #2011 — sequence data → RQ
  6. #2012 — peel: checkBlockCompletion → mutation
  7. #2013 — outline sidebar → RQ + context
  8. #2014 — bookmarking → RQ + de-class UnitButton
  9. #2015 — saveIntegritySignature + saveSequencePosition → mutations
  10. #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.courseId is 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, reads state.courseware.courseId ?? state.courseHome.courseId) with a route/params- or context-based courseId.
  • Remove the courseware reducer from store.ts once 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 to courseware/redirects.ts (typed options objects, seven rules including the outline-failure redirect) and the B-prep getErrorDetail shape.

Investigation findings that adjust the task list above:

  • The slice's only writers are the three statusBridge.ts hooks, and its id fields are route mirrors. The bridges dispatch the route params verbatim, so useParams is the faithful replacement for courseId/sequenceId (minus a one-effect-tick lag). errorCode is written but read by nothing, and useContextId's state.courseHome.courseId fallback 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 form const { courseId } = useSelector(state => state.courseware) — five course-exit components plus UnitButton (id-only, folded into A1), SequenceNavigation (ids + status, A2), and TabPage (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, deletes selectors.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 on isPending/isSuccess/isError — exactly how every converted course-home reader already works. useSequenceMetadata absorbs the route-derived preview flag its callers all passed (so there is no wrapper hook), the 422-means-not-a-sequence translation is a plain sequenceMightBeUnit(query) predicate, and the course composite collapses to a useIsCourseLoaded boolean — 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 to courseware/redirects.ts — ADR 0008's "liberal courseware path handling" — behind one useCoursewareRedirects() hook, with the outline-failure bounce as the seventh rule), after which no courseware code touches the status constants (the leftover dead courseHome usage dies with that slice's teardown, #1978).
  • TabPage's transitional string branch retires at the end, with an additive prep layer first. CourseStatus becomes 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 in src/data/http-error.ts (getErrorDetail, a learner-facing-messages filter beside getResponseStatus); TabPage's deriveView reads 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 stale sequenceId, the guard bails forever, and the resume redirect never runs. Route-derived ids fix that.
  • Model reads stay on useModel / state.models lookups — query-result read conversions are #1977's home. The container's replacement lookups keep exact null semantics (?? null, not useModel'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: slice errorCode, the outline-sidebar hook's unread sequenceStatus return.
  • 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 by CoursewareContainer)
    derives courseStatus from three queries — useCoursewareMetadata,
    useCoursewareOutline, useCourseHomeMeta(courseId, 'courseware'): any
    pending → loading; metadata + courseHomeMeta success →
    (hasAccess && outline success ? loaded : denied); otherwise failed,
    extracting errorMessage/errorCode from a 403 courseHomeMeta body
    (data.detail / data.error_code, raw snake_case).
  • useSequenceStatusBridge(routeSequenceId, isPreview) (same caller) mirrors
    useSequenceMetadata: pending → loading, success → loaded, else
    failed with sequenceMightBeUnit = (status === 422).
  • useCourseExitStatusBridge (called by CourseExit) — the no-outline
    variant. CourseExit already hands TabPage its queries
    ({ metadataQuery: courseHomeMetaQuery, tabDataQuery: metadataQuery });
    the bridge survives there for CourseRecommendations' slice courseId
    read and TabPage's errorMessage read.

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-readsrefactor: 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();
    replaces useSelector(getSequenceId) (component renders under the unit
    route). useSelector + selector imports go.
  • course-outline/hooks.js: activeSequenceId from useParams (already
    imported there). Also drop the returned sequenceStatus — no component
    consumes it (verified: no reader in CourseOutline.tsx, tray, trigger, or
    the Sidebar* components) — so this file sheds both selector imports and its
    useSelector in one layer.
  • CourseRecommendations.jsx: const { courseId } = useParams(); (renders
    under /course/:courseId/course-end).
  • Same swap for the five destructuring courseId readers the original
    inventory's state.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
    from useParams — it renders on unit routes and builds unit hrefs from
    them; the RootState import leaves the file.
  • src/data/hooks.ts: export const useContextId = () => useParams().courseId;
    — every course-home/courseware route has :courseId. The dead
    state.courseHome.courseId fallback and the RootState import go. Sole
    caller (DashboardFootnoteLinkPluginSlot) feeds useModel/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-statusrefactor: 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 + isPending reproduces the slice's initial
    loading state for a missing id exactly (the bridge bailed without
    dispatching).
  • The bridge slims along the way: useSequenceStatusBridge loses its
    isPreview pass-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): courseId from
    useParams, render/lock gates on the query's .isSuccess; its LOADED
    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-idsrefactor: 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/loading outcomes all behave as "not
    loaded" for these consumers, exactly as their string comparisons did.
  • useSequenceIds replaces sequenceIdsSelector; model reads stay on
    useModel/useModels (query-result read conversions are #1977's home).
    useModels' shallowEqual keeps the array reference stable and useMemo
    keeps the flatMap result stable — the memoization createSelector provided.

Converted readers:

  • sequence-navigation/hooks.js (useSequenceNavigationMetadata):
    courseId from useParams, 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-hooksrefactor: 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. useSelector import 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 via RequestError):
    getErrorDetail(error): string | undefined — a status switch handling 403
    (backend-authored learner-facing prose) and defaulting to undefined,
    named to match its sibling getResponseStatus. TabPage imports it and
    stays a renderer; deriveView owns errorDetail.

  • The message source is a branch on the courseStatus shape, 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
    metadataQuery is 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 outlineQuery member. A first draft widened
    CourseStatus with 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). errorCode is 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-teardownrefactor: tear down the courseware Redux slicecloses #1976.

CoursewareContainer.tsx
  • The five slice reads become: routeCourseId / routeSequenceId (already
    destructured from useParams), useIsCourseLoaded(routeCourseId),
    useSequenceMetadata(routeSequenceId) + the sequenceMightBeUnit
    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-era check
    prefix (resumeRedirect, sectionUnitToUnitRedirect, …), and are memoized
    with defaultMemoize(fn, shallowEqual) so the fire-once guard semantics
    survive object args. A self-contained useCoursewareRedirects() 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
    the LOADING/LOADED/FAILED/DENIED constants (and StatusValue) have
    no courseware users; what remains is the dead courseHome slice 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 default useSelector
    equality — while useModel would return {} and break the helpers'
    section && guards):

    • currentCourseSelectorstate.models.coursewareMeta?.[routeCourseId] ?? null
    • currentSequenceSelectorstate.models.sequences?.[routeSequenceId] ?? null
    • sectionViaSequenceIdSelectorstate.models.sections?.[routeSequenceId] ?? null
    • sequenceIdsSelector (inline copy) → useSequenceIds(routeCourseId)
    • nextSequenceSelector / firstSequenceIdSelector → derived inline from
      useSequenceIds / sectionIds + a model lookup, same guards
      (!sequenceId || ids.length === 0 → null, status gate on loaded).
    • The untyped state.models reads 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
    stale sequenceId, 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.current for the checkSaveSequencePosition guard
    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
    outlineFailureRedirect rule navigates to the same home destination the
    bridge's denied produced (the getAccessDeniedRedirectUrl default-branch
    outcome for a non-outline tab; ordering: only when hasAccess is 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 under Course tolerates 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).

  • Course keeps receiving courseId/sequenceId props, now the route
    values. react-redux's useSelector stays only for the model lookups
    (#1977); createSelector/reselect imports go if nothing else needs them.

tab-page/TabPage.tsx
  • CourseStatus drops the StatusValue union member and deriveView loses
    the string branch — courseware was the last string caller, as the inline
    comment promised. The state.courseware.errorMessage read goes (the
    state.courseHome.errorMessage read stays; its slice's teardown is later
    work, and nothing writes it today).
Other
  • CourseExit.jsx: delete the useCourseExitStatusBridge call + import (its
    remaining purposes — CourseRecommendations' id in A1, errorMessage in
    B-prep — are gone). The bare useCoursewareOutline(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) and courseware/data/slice.js.
  • Delete tab-page/TabContainer.jsx (+ its test, its index.js export,
    and the stale jest.mock in src/index.test.jsx) — the Redux-era generic
    tab wrapper (dispatches a fetch thunk prop, reads state[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 StatusValue type from constants.ts — TabPage's string
    branch is its last consumer. The LOADING/LOADED/FAILED/DENIED
    constants themselves stay: course-exit/track.js uses two as analytics
    labels and preferences-unsubscribe as local component state (neither is
    TabPage view status), plus the dead courseHome slice usage (#1978).
  • courseware/data/index.js: drop the reducer export (api re-exports
    remain).
  • store.ts: drop coursewareReducer import + courseware entry.
    RootState shrinks; no state.courseware TS references remain. Reducer
    removals shipped as plain refactor: before (#1967, #1968) — same here;
    the store shape is not a documented plugin surface.
  • setupTest.js: initializeTestStore drops the courseware reducer;
    seedCoursewareModels / seedSequenceModels drop the
    fetchCourseSuccess / fetchSequenceSuccess dispatches + slice imports
    (model seeding stays).
  • apiHooks.ts: CheckBlockCompletionVars — both call sites now pass route
    params; retype | null| undefined and 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.jsx is 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.courseware purely to learn
    ids — re-sourced through a getTestStoreIds(store) helper in setupTest.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:
    its fetchSequenceFailure dispatch is dead (Sequence reads the query since
    A2) and the test currently passes by accident — the unmatched sequence GET
    hits logUnhandledRequests' 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 default staleTime: 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 sequenceStatus dropped (no
    consumer).
  • B-prep: course-home tabs regain 403 detail messages (dead since their
    thunks stopped dispatching fetchTabFailure). errorCode dropped (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 (useSequenceMetadata flags plus
    the sequenceMightBeUnit predicate, useIsCourseLoaded for the course
    composite), matching how every converted course-home reader already works;
    no useSequenceStatus-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 touches LOADING/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; CourseStatus is
    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 in data/http-error.ts and 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.
  • useContextIduseParams().courseId; dead courseHome fallback dropped.
  • courseware reducer removed from the store as plain refactor: (precedent:
    #1967, #1968; store shape is not a documented plugin surface).
  • Dead drops: errorCode, outline-sidebar hook's unread sequenceStatus.

Stack

Six layers on top of bsmith/react-query-discussion-topics (#2068), in
order:

  1. A1 — draft PR #2069 (bsmith/courseware-route-id-reads)
  2. A2 — draft PR #2070 (bsmith/use-sequence-status; branch name predates
    the review rework that eliminated the hook it names)
  3. A3 — draft PR #2071 (bsmith/course-loaded-sequence-ids)
  4. A4 — draft PR #2072 (bsmith/breadcrumbs-status-hooks)
  5. B-prep — draft PR #2073 (bsmith/tabpage-outline-query; branch named
    before the review reshape dropped the outlineQuery idea)
  6. 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.