Skip to content

refactor: convert the courseware outline sidebar to React Query - #2064

Merged
brian-smith-tcril merged 1 commit into
masterfrom
bsmith/react-query-outline-sidebar
Sep 18, 2026
Merged

brian-smith-tcril merged 1 commit into
masterfrom
bsmith/react-query-outline-sidebar

Conversation

@brian-smith-tcril

@brian-smith-tcril brian-smith-tcril commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Convert the courseware outline sidebar off Redux to React Query: the navigation tree (/api/course_home/v1/navigation/), the completion-tracking waffle toggles, and the completion rollups that checkBlockCompletion writes into the tree. This is Target 2 of the courseware decomposition (plan) in the Redux → React Query migration (#1946, Stage 1), stacked on #2063 — the checkBlockCompletion mutation peel that exists precisely so this layer and the unit view don't fight over completion state. Closes #2013.

After this layer the courseware slice is down to exactly the re-scoped #1976 teardown set (courseId/courseStatus/sequenceId/sequenceStatus/sequenceMightBeUnit/errorMessage/errorCode).

What changed

  • Two new queries in courseware/data/apiHooks.ts: useCourseOutlineStructure (replacing the getCourseOutlineStructure thunk and the courseOutline/courseOutlineStatus slice fields) and useCoursewareOutlineSidebarToggles (replacing what was left of fetchCourse — by Convert courseware metadata to React Query #2010 it only fetched the sidebar toggles). Neither carries meta.models: this state was never in the model store.
  • Completion rollups move to the query cache. useCheckBlockCompletion's onSuccess swaps the transitional updateCourseOutlineCompletion dispatch for getQueryDataapplyUnitCompletionsetQueryData, and the courseOutlineShouldUpdate refetch flag becomes invalidateQueries — the repo's first use of either. applyUnitCompletion lives in the new courseware/data/courseOutline.ts: a statement-by-statement immutable port of the deleted reducer, plus types transcribed from normalizeOutlineBlocks's output shape.
  • useCourseOutlineSidebar reworked onto the two queries; its fetch effect is deleted (mounting the query replaces "fetch when not LOADED"; invalidation replaces the flag-watching). It now returns isOutlinePending instead of a LOADING/LOADED/FAILED string — fully-converted reads consume query booleans, per the pattern-setter (CourseRecommendations).
  • Two deliberate behavior changes (decision log §3): completing a unit while the outline was never loaded is now a clean cache-miss no-op instead of an accidental reducer-TypeError → logError; and the locked-sequence refetch keeps the rolled-up tree visible and interactive (invalidateQueries) instead of blanking the sidebar to its spinner.
  • Deletions: the fetchCourse and getCourseOutlineStructure thunks, five outline reducers + four slice fields, four selectors, and the container's checkFetchCourse guard.
  • Tests: the sidebar suites gained real route scaffolding (the queries key off useParams().courseId); CourseOutlineTray.test fetches through the mocks and anchors on a waitForOutlineLoaded() helper (the spinner only tracks one of the two queries), with the loading state held by a new preventOutlineSidebarLoad option (never-resolving mock); the leaf component suites render from a seeded cache via a new seedQueryData test utility; CoursewareContainer.test gained mocks for the two URLs it had been silently 404ing all along; one ProductTours assertion became a waitFor (render-timing shift, verified benign).

Testing

npm run types (0 errors), npm run lint (clean), full jest suite green at head (112 suites, 1110 passed / 3 pre-existing skips). The rollup helper has 100% line coverage — the not-found guard gained a dedicated case after codecov flagged it. Manual pass on tutor local (DemoX) in the details block below; the locked-sequence refetch and tracking-off paths rest on their unit tests (no prereq-gated course handy / waffle flip not exercised).

Decisions

Full decision log

Decisions: #2013 — Convert the courseware outline sidebar to React Query

1. Issue-body corrections

  • "Move coursewareOutlineSidebarSettings (UI/config) → React context / local
    state" — no.
    It's the camelCased result of the waffle-toggles fetch (server
    state), so it became the useCoursewareOutlineSidebarToggles query. Nothing in
    this layer needed a new context: isOpen was already local state and
    currentSidebar/toggleSidebar already live in SidebarContext. The issue was
    retitled to drop "+ context".
  • "courseOutlineShouldUpdatesetQueryData" — refined. The completion
    rollups became setQueryData; the flag became
    queryClient.invalidateQueries on the outline query. The flag existed only to
    trigger a refetch, and invalidation is that trigger.

2. First setQueryData / invalidateQueries use in the repo

useCheckBlockCompletion's onSuccess swaps the transitional
updateCourseOutlineCompletion dispatch (kept by #2012) for cache updates. The epic
plan's wording for this layer ("completion rollups / courseOutlineShouldUpdate
setQueryData") sanctioned the pattern.

Get-then-set instead of an updater function. setQueryData(key, old => …) can't
also report whether the locked-sequence refetch condition fired. So the hook does
getQueryDataapplyUnitCompletion(outline, unitId) (pure helper, returns
{ outline, refetchNeeded }) → setQueryData → conditional invalidateQueries.

Everything in src/courseware/data/courseOutline.ts is transcription, not
invention
— review the file by tracing each line to one of two sources. The four
interfaces are copied field-for-field from normalizeOutlineBlocks's three switch
branches in courseware/data/utils.js (chapter → sections, sequential/lock →
sequences, vertical → units), cross-checked against SidebarSequence's PropTypes
(previously the closest thing to a shape declaration); the optionality judgments
(specialExamInfo?, icon?: string | null, required completionStat numbers) are
the one place the types assert more than the JS proves — see §6.

applyUnitCompletion is a statement-by-statement port of the deleted
updateCourseOutlineCompletion reducer
(slice.js:78–124 in the parent commit),
with immer draft mutations rewritten as spreads: the containing-sequence scan,
completedUnits/isAllUnitsAreComplete, both completionStat computations, and
hasLockedSequence are the reducer's expressions verbatim (reading the post-write
units map exactly as the reducer read its mutated draft);
if (cond) { complete = true } became the equivalent set-or-leave
complete: cond || old.complete; the courseOutlineShouldUpdate = true write
became the refetchNeeded return value under the same condition; the reducer's
inline locked-sequence comment became the function's doc comment. The only new
logic is the not-found early return (below); the sequenceId && … chaining in the
section lookup exists only to narrow the find results for TypeScript.

Fidelity details:

  • It finds the containing sequence by scanning unitIds, as the reducer did (the
    reducer ignored the payload sequenceId; the outline tree is the authority).
  • When the tree doesn't contain the unit/sequence/section, it returns the outline
    unchanged. That mirrors the old semantics exactly: the immer reducer threw
    mid-recipe and immer discards the draft on throw, so the outline was left
    entirely unmodified (and the units-model write, dispatched separately, had
    already landed).

Subtleties reviewed and accepted (apiHooks.ts):

  • The courseId! assertions in the two queryFns exist because getCourseOutline
    and getCoursewareOutlineSidebarToggles are the only api functions with JSDoc
    @param {string} annotations, so tsc checks their call sites (the rest have
    implicitly-any params). Runtime-safe via enabled: !!courseId; same idiom as
    the queryKey usages. Alternatives considered in review and rejected: the !
    can't be removed at the queryKey level — a disabled query still registers under
    its key, so the key is built on courseId === undefined renders, and widening
    the builder to accept undefined both admits phantom
    [..., 'courseOutline', undefined] keys into prefix-matched key space and
    un-types the call sites where strictness does real work (the mutation's
    onSuccess passes courseId with no assertion because its guard genuinely
    narrows it). Since the key-level ! must stay, fixing only the queryFn level
    (TanStack v5 skipToken, or a dead runtime guard) isn't worth the idiom fork —
    every converted hook uses enabled + key-level !. skipToken is parked as a
    possible repo-wide migration once Tear down the courseware Redux slice + replace useContextId #1976 settles how route identity is threaded.
  • The get→apply→set sequence in onSuccess has no await between the steps, so
    it's synchronous and effectively atomic — no interleaving guard needed. The one
    real race (an outline refetch already in flight when a completion lands
    overwrites the rolled-up cache on resolve) is identical to the Redux behavior
    (fetchCourseOutlineSuccess replaced wholesale), and the server response
    includes the completion anyway.
  • invalidateQueries is deliberately fire-and-forget — the mutation lifecycle
    shouldn't block on the refetch.
  • The early return's !unitId || !courseId guards are typing-driven (nullable
    until Tear down the courseware Redux slice + replace useContextId #1976). A hypothetically-undefined unitId used to reach the reducer and
    throw-into-log; now it's a silent skip — a theoretical third micro-instance of
    the throw→no-op family in §3 (both call sites pass real ids in practice).
  • The cache write keys off variables.courseId, not ambient state — a completion
    resolving after a course switch writes to the course it was fired for.

3. Deliberate behavior changes (both approved 2026-09-15)

  1. Completion against a never-loaded outline: logError → silent no-op. The old
    "log" was a TypeError from the throwing reducer falling into the thunk's
    catch-all — an accident of the reducer's shape, not a designed signal. The new
    shape is a cache miss (getQueryData → undefined → return); writing a deliberate
    logError there would upgrade noise into a contract. The state is legitimate
    (outline fetch failed or still in flight; the in-flight refetch returns the
    completion anyway). Pinned by the "still marks the unit complete, quietly" test.
  2. Locked-sequence refetch: spinner blank → stale-while-revalidate. The old flag
    path dispatched fetchCourseOutlineRequest, resetting the outline to
    {}/LOADING — the sidebar blanked to its spinner and every SidebarSequence's
    collapse state reset. invalidateQueries keeps the rolled-up tree visible and
    interactive until fresh data lands. resetQueries would have reproduced the
    blanking exactly; rejected as an artifact of the request-action pattern, not a
    chosen behavior. Side-effect audit: no test referenced
    courseOutlineShouldUpdate or courseOutlineStatus; the status has exactly one
    reader (CourseOutline.tsx's spinner branch); nothing keys remounts or effects
    off it. The genuinely new runtime state — the tree staying clickable during the
    refetch — goes through the same handleUnitClick path over stale-but-valid maps.

4. Null-outline hardening

getCourseOutline (the api fn) returns null when the response has no blocks.
The old flow stored that null and useCourseOutlineSidebar's destructuring would
have crashed on it; the hook now destructures outlineQuery.data ?? {}, covering
null and undefined alike. The query is typed CourseOutlineData | null and the
null case is pinned by a test.

5. Query-key naming

coursewareQueryKeys.courseOutline(courseId) sits next to the pre-existing
outline(courseId) (the learning-sequences outline from #2010). Distinct on
purpose: courseOutline matches the feature dir (course-outline) and the thunk it
replaces (getCourseOutlineStructure); renaming the learning-sequences key was out
of scope. sidebarToggles(courseId) covers the waffle-toggles fetch.

6. Typing completionStat as required numbers

The plan sketched optional completed?/total? (the normalizer copies
completion_stat?.completion, which can be undefined). The reducer's arithmetic
(acc + completionStat.completed) always assumed presence — absent stats meant a
throw-and-discard, not a handled case. Typing them optional would force either
non-null casts or semantic changes (?? 0) in the ported arithmetic. The types
describe the contract the rollup relies on; the not-found guard covers the tree
shapes that used to throw.

7. checkFetchCourse removed: the toggles fetch moved from the container to the sidebar hook

fetchCourse was originally the courseware hub thunk (metadata + outline +
courseHomeMeta + sidebar toggles). The metadata layer (#2010) peeled everything
else into the query hooks and status bridge and left only the sidebar-toggles
fetch, with an inline comment marking it as staying "until #2013 converts it and
deletes fetchCourse". CoursewareContainer's checkFetchCourse guard was
nothing but the memoized dispatcher of that thunk — post-#2010, "load course data
whenever the course ID changes" meant only "fetch the sidebar toggles once per
course ID". With the fetch converted to useCoursewareOutlineSidebarToggles, the
guard has no job left: the guard entry, its effect call, the import, the thunk,
its index.js re-export, and the setCoursewareOutlineSidebarToggles reducer all
go together.

The query mounts in useCourseOutlineSidebar, not the container, because the flag
has exactly one consumer — the sidebar (it feeds isEnabledCompletionTracking and
nothing else) — so the query lives next to its reader and the container edit is
pure deletion; React Query dedupes the trigger/tray/components all mounting it.
The fetch now starts at first sidebar-hook mount instead of container mount — same
page render, marginally later in the waterfall; it gates icon decoration, not
layout.

8. What stays Redux on purpose

After this layer the courseware slice is exactly the re-scoped #1976 teardown set:
courseId/courseStatus/sequenceId/sequenceStatus/sequenceMightBeUnit/
errorMessage/errorCode.

9. Test strategy: fetch-through for the Tray, seeded cache for leaf components

The queries key off useParams().courseId, so every sidebar test needed real route
scaffolding (MemoryRouter + Routes path="/course/:courseId"); under Redux the
data was global and the missing param didn't matter.

  • CourseOutlineTray.test fetches through the initializeTestStore axios mocks
    and awaits loaded content, matching the converted-tab precedent (OutlineTab etc.).
    This pins the pending→loaded transition through a real fetch. Each loaded test
    synchronizes on a file-local waitForOutlineLoaded() (a findByText of the
    completion sr-only text) and keeps its original synchronous assertions.
    Spinner-disappearance can't be the anchor anymore: the spinner is gated on the
    outline query alone, while the completion sr-only content is gated on the toggles
    query, and the two resolve independently — "spinner gone" no longer implies
    "loaded". The sr-only completion text is the one signal gated on both queries
    (row from the outline, text from the toggles), so its appearance makes every
    subsequent synchronous assertion safe. Under Redux this distinction didn't exist:
    one pre-seeded store, one status field.
    The "loading" case uses a new preventOutlineSidebarLoad option (replacing
    excludeFetchOutlineSidebar): the mock returns a never-resolving promise — the
    only way to hold a query in its pending state. The old name couldn't survive the
    conversion semantically: it meant "skip the setup-time seeding" (the render-time
    fetch still ran, and the loading test's sync assertions simply outran the
    response), whereas the new option pins the render-time fetch itself so the
    pending state is a stable fixture, not a won race.

    Option naming. Alternatives considered in review: state-holding verbs
    (pinOutlineSidebarPending, keepOutlineSidebarPending, force… — rejected
    since the query starts pending naturally; nothing is forced into it) and an
    inverted default-true flag (allowOutlineSidebarLoad: false). The inversion is
    workable — it needs a per-key destructure default in initializeTestStore
    (const { allowOutlineSidebarLoad = true } = options), not a whole-object
    parameter default, which doesn't merge and silently drops the flag when any
    other option is passed — but it would be the file's only default-true option
    next to the default-false, truthy-checked excludeFetchCourse /
    excludeFetchSequence family. preventOutlineSidebarLoad keeps the sibling
    convention and names the observable contract (the outline never loads) rather
    than the React Query state.

  • SidebarSection / SidebarSequence / SidebarUnit tests seed the query
    cache instead (new seedQueryData helper in setupTest.js). Fetch-through can't
    work there: these tests bypass CourseOutline's loading gate, and during the
    pending tick SidebarUnit destructures units[unitId] (undefined → crash) and
    handleUnitClick's log-event scans sequences (click-vs-resolution race).
    Seeding matches the contract the real app provides (children render only after
    the gate opens). seedQueryData sets staleTime: Infinity on the seeded key so
    mounting observers don't refetch over the seed — setQueryData alone leaves the
    entry immediately stale under the default staleTime: 0, and the mount-time
    refetch that follows means a phantom request against the mock, a post-assertion
    state update (act-warning fodder), and the fixture silently replaced if the
    mock's payload differs from the seed. setQueryDefaults is per-key, so every
    other query in the same client behaves normally. The helper lives in
    setupTest.js rather than inline because the staleTime half is exactly the
    non-obvious line a later "simplification" would delete, and it's deliberately
    generic (any client/key/data) — the repo's first cache-seeding test utility,
    which the Dissolve the model-store normalized cache #1977-era test migrations will likely reuse. Current call sites: six —
    the three leaf files × two keys (courseOutline, sidebarToggles).

    Subtleties reviewed and accepted in these two files:

    • SidebarUnit's wrapper renders the element under exactly two routes
      (/course/:courseId, /preview/course/:courseId); a pathname matching
      neither makes Routes render nothing, so a future mismatch fails as
      "unable to find element" rather than pointing at the route table. The
      scaffolding is load-bearing: it feeds useParams().courseId to the queries.
    • The SidebarUnit click tests fire a real-looking get_completion POST
      through the mutation (the route param supplies a real courseId where
      useParams() used to return {}). It's unmocked; logUnhandledRequests
      answers 200 {}complete: false → a harmless model write. Pre-branch
      the same POST fired via the thunk with undefined in the URL; neither
      version asserts it.
    • Prop fixtures mix with cache fixtures deliberately: SidebarUnit gets
      isCompletionTrackingEnabled/unit as props (the outline seed exists for
      handleUnitClick's log-event — tab_count comes from the seeded sequence's
      unitIds), and SidebarSequence's complete-sequence test passes
      complete: true as a prop while child units stay complete: false from the
      seed — the same prop-vs-source split the tests had against Redux.
    • The wrapper shapes differ (SidebarSequence: plain function, client per
      call; SidebarSection: RootWrapper component with useMemo) — a
      pre-existing asymmetry preserved rather than harmonized.
    • Pre-existing coverage gap, unchanged: no test renders with completion
      tracking off (the seeds hardcode enableCompletionTracking: true, as
      initializeTestStore's Redux seeding did before).
  • Fixture derivation in all of them: state.courseware.courseOutline.…
    await getCourseOutline(courseId) against the same mock. Because the file-local
    setup helpers no longer only build a store (they derive fixtures via the api fn
    too), they were renamed initTestStoreinitTestData in the four touched
    files. (initializeTestStore itself keeps its name — a misnomer at this point in
    the migration, since it also registers all the axios mocks and seeds via api
    fns; renaming the shared bootstrap belongs with its rebuild at Tear down the courseware Redux slice + replace useContextId #1976/Dissolve the model-store normalized cache #1977.)

  • courseId comes from the store (store.getState().courseware.courseId) in
    SidebarSection/SidebarUnit for the route param and query-cache keys. Not a
    new pattern: eight test files already grab it that way (Tray, Trigger,
    SidebarSequence, DiscussionsTrigger/Sidebar, TabContainer, LoadedTabPage,
    CourseAccessErrorPage); these two just join the cohort. All ten sites need a new
    source when Tear down the courseware Redux slice + replace useContextId #1976 deletes courseware.courseId from the slice — a wholesale
    migration then, rather than a divergent source for two files now.

  • CourseOutlineTrigger.test needed no changes: with no route param the queries
    are disabled (enabled: !!courseId), and the trigger renders from context alone.

Invalidation is pinned with a spy (jest.spyOn(queryClient, 'invalidateQueries')) rather than by observing a refetch: renderHook mounts only
the mutation hook, so there is no active outline observer to refetch, and the spy
pins the contract (called with the outline key on the locked-sequence rollup; not
called on a plain rollup) without depending on RQ's observer mechanics.

Subtleties reviewed and accepted (apiHooks.test.tsx):

  • The toggles failure test passes a store to createTestQueryClient solely to
    activate createAppQueryCache — that's where the onError logging lives; drop
    the argument and the logError assertion fails with no obvious cause.
  • Two seeding idioms coexist by design: this file seeds pure data
    (normalizeOutlineBlocks(courseId, courseBlocks.blocks), no HTTP), while the
    Tray/leaf tests derive fixtures via getCourseOutline against the axios mock.
    Same normalizer either way.
  • The locked-sequence fixture is hand-built (the block factory can't produce a
    type: 'lock' sequence), and its unit-1 is deliberately absent from
    models.units — the already-complete guard reads the model store, finds
    nothing, and proceeds. That absence is doing quiet duty.
  • The spy assertion pins the exact call shape
    (toHaveBeenCalledWith({ queryKey: outlineQueryKey })) — adding options to the
    hook's invalidateQueries call later will fail the test on purpose.

10. Collateral test fixes

  • CoursewareContainer.test.jsx gained mocks for the navigation and toggles
    URLs. Pre-conversion both requests already fired (the toggles via the container's
    fetchCourse, the navigation via the trigger's effect) and 404'd silently
    against the unmocked adapter — the suite had been running with a broken,
    empty sidebar as its steady state, hidden by the thunks' catch-alls. Rather than
    carry "unmocked URL 404s and gets logged" forward as the fixture's baseline (the
    queries surface it through the global QueryCache.onError), the mocks make the
    sidebar actually load. This is a deliberate fixture behavior change: the sidebar
    in these tests now renders loaded instead of failed-empty. No container assertion
    reads sidebar state, so no other edits in the file were needed.
  • ProductTours.test.jsx: the courseware-checkpoint assertion became a
    waitFor. The sidebar hook's render-time queries shift scheduling by a tick, and
    the Paragon checkpoint (mounted when the tour effect fires after tourData
    resolves) now appears just after the synchronous DOM count ran. Verified the
    checkpoint still mounts — pure timing, not a regression.

11. Manual testing (tutor local, DemoX)

Run against the draft PR (#2064) while CI ran. Full checklist in the working
manual-testing doc; outcomes:

  • Verified by hand: the sidebar loads from the navigation query (tree renders,
    expand/collapse, active highlighting); completing a unit rolls the open sidebar's
    counts/icons up live with no extra navigation refetch (the setQueryData
    path, not a refetch); mobile collapse still lands the completion after the
    sidebar unmounts (reopening shows the rollups — the Peel: convert checkBlockCompletion to a React Query mutation #2012 unmount-survival
    behavior through this layer's new cache-write path).
  • Verified informally (behavior change 1): completing a unit before the outline
    loaded produced no logError where the Peel: convert checkBlockCompletion to a React Query mutation #2012 pass documented one as expected —
    observed as intended, though not under a deterministically-blocked navigation
    request. The exact semantics are pinned by the "still marks the unit complete,
    quietly, when the outline was never cached" jest case.
  • Not run by hand: completion-tracking-off icon hiding (needs the completion
    waffle switch flipped; the toggles query's false path is covered by
    apiHooks.test.tsx and the flag gating is prop-driven in the components); and
    the locked-sequence refetch / behavior change 2 (needs a prereq-gated course —
    DemoX has none; carried unchecked from the Peel: convert checkBlockCompletion to a React Query mutation #2012 pass for the same reason and
    covered by the new locked-sequence invalidation jest case).

12. courseOutlineStatus dropped for a query boolean

The first cut kept a derived LOADING/FAILED/LOADED string so
CourseOutline.tsx stayed byte-identical. Review flagged it against the epic's
end-state convention, confirmed by precedent: the phase-0 pattern-setter
(CourseRecommendations.jsx) ships reading isPending/isError/isSuccess
directly (its plan had also said "derive the old status string" — the shipped code
moved past that), the converted tabs use no status constants, and the only
non-test component in converted territory still importing them is
SequenceNavigation.jsx, whose sequenceStatus is genuinely still Redux (#1976).
Status strings are for surfaces straddling the Redux boundary; fully-converted
reads consume query booleans — and the sidebar is fully converted at this layer.

So the hook now returns isOutlinePending: outlineQuery.isPending (TanStack v5
vocabulary, matching the pattern-setter) and CourseOutline.tsx checks that; the
@src/constants imports left both files. The derived FAILED arm had no reader
anyway — on error the ?? {} renders an empty tree, exactly as the old
fetchCourseOutlineFailure (courseOutline = {}) did.

13. staleTime: Infinity on both sidebar queries (review, arbrandes)

useCourseOutlineSidebar is called by the tray, the trigger, CourseOutline, and by
every SidebarSection / SidebarSequence / UnitLinkWrapper row, so each row mounts
useCourseOutlineStructure and useCoursewareOutlineSidebarToggles as another
observer. The app query client leaves staleTime at the default 0, so data is stale the
moment it lands; TanStack dedupes observers that mount mid-flight but refetches when a
new observer mounts after the data has settled. Rows mount only once the outline exists,
so the sequence was: fetch, rows mount, refetch; expand a section, more rows, refetch;
navigate, the tray re-renders, refetch — two GETs each time. The Redux effect fetched
once per course per session (courseOutlineStatus !== LOADED || courseOutlineShouldUpdate).
staleTime: Infinity on both hooks restores that: invalidateQueries (the
refetchNeeded path in §2) marks stale and refetches regardless of staleTime;
setQueryData doesn't involve staleness; a new courseId is a new key. One difference
from Redux: TanStack's default gcTime drops the cache five minutes after the last
observer unmounts, so a sidebar closed longer than that refetches on reopen, where Redux
kept the outline forever — acceptable. Prior art: authoring's useWaffleFlags
(src/data/apiHooks.ts, staleTime: Infinity with a one-line comment; its
refetchOnWindowFocus: false is already our client-wide default). The same mount-count
question applies in principle to useCoursewareMetadata / useCoursewareOutline /
useCourseHomeMeta via useIsCourseLoaded, but with far fewer observers; left as a
possible follow-up rather than widened here.

Manual testing

Manual testing — courseware outline sidebar → React Query (#2013)

In-browser verification for the top-of-stack PR, run against a live backend (tutor
local). This conversion claims almost no user-facing change: the sidebar tree
(/api/course_home/v1/navigation/) and the completion-tracking toggles
(/courses/{id}/courseware-navigation-sidebar/toggles/) now come from queries, and
useCheckBlockCompletion writes the rollups into the query cache
(setQueryData) instead of dispatching updateCourseOutlineCompletion.

The two deliberate behavior changes are the visible bits to watch:

  1. No logError on completion with the outline never loaded — the old reducer
    TypeError → catch-all log is now a clean cache-miss no-op. The log the Peel: convert checkBlockCompletion to a React Query mutation #2012 pass
    documented as "expected" should now not appear.
  2. The locked-sequence refetch no longer blanks the sidebarinvalidateQueries
    keeps the rolled-up tree visible (and interactive, with collapse states intact)
    while refetching, where the old flag reset it to the spinner.

(Checklist run against tutor local while CI ran on the draft PR; results below.)

Getting real IDs (DemoX on tutor local)

Course id: course-v1:OpenedX+DemoX+DemoCourse; base
http://apps.local.openedx.io:2000/learning. Grab a sequence + unit id from the
address bar on any unit page (…type@sequential+block@… / …type@vertical+block@…).
Completion rollups need the outline sidebar with completion tracking enabled
(enable_completion_tracking toggle) — check the sidebar shows completion icons first.
The navigation fetch shows in DevTools → Network filtered on navigation; the toggles
fetch on courseware-navigation-sidebar.

Verify by hand

  • Sidebar loads from the query — open a unit page: one navigation GET, the
    tree renders (sections ↔ sequence/unit levels, back button), expand/collapse
    works, active sequence/unit highlighted.
  • Completion still rolls up live — complete a unit (navigate past it or click
    another in the sidebar): the get_completion POST fires and the open sidebar's
    sequence/section counts/icons tick up with no extra navigation refetch
    (plain completions update the cache in place).
  • Completion tracking off hides the icons — flip
    enable_completion_tracking off: no completion icons/sr-only text; back on:
    they return.
  • Mobile collapse still lands completion — narrow viewport, click a unit (the
    sidebar collapses immediately): reopen the sidebar and the rollups reflect the
    completed unit — no lost update. (Carried forward from the Peel: convert checkBlockCompletion to a React Query mutation #2012 pass, where it
    was left unchecked; the write path it exercises changed again in this layer.)
  • [(i think so? not sure if i properly tested but pretty sure this is working as intended now)] No log on completion with the sidebar outline never loaded (behavior change
    1) — complete a unit before the sidebar tree has loaded (e.g. throttle the
    navigation request or complete quickly after a hard reload): unit still marked
    complete in the sequence nav, no logError page-action (the old pass
    expected one here — its absence is the new correct behavior).
  • Locked-sequence refetch keeps the tree visible (behavior change 2) — needs
    a prereq-gated course (not DemoX; carried forward unchecked from the Peel: convert checkBlockCompletion to a React Query mutation #2012
    pass — rely on the new unit test if none is handy). Complete the last unit of
    the gating sequence: a navigation refetch fires, the sidebar does not
    blank to the spinner, expanded/collapsed sections survive, clicks during the
    refetch work, and the unlocked sequence appears when it lands.

Left to the automated suite (not re-done by hand)

  • Both new queries (fetch/normalize, the null-blocks case, camelCased toggles,
    error → logError + falsy flag) — apiHooks.test.tsx.
  • The reworked useCheckBlockCompletion cache writes: rollup helper, quiet
    cache-miss no-op, and the locked-sequence invalidation (pinned with an
    invalidateQueries spy; also asserts no invalidation on a plain rollup) —
    apiHooks.test.tsx.
  • The pending→loaded transition through a real fetch (preventOutlineSidebarLoad
    hanging mock for the loading state) — CourseOutlineTray.test.jsx; the leaf
    component suites render from a seeded query cache.

Results

Env: tutor local, course-v1:OpenedX+DemoX+DemoCourse, against draft PR #2064.

Checked items passed as described; nothing surprising observed. The no-log item
(behavior change 1) was observed as intended but not under a deterministically
blocked navigation request — the jest case pins the exact semantics. Tracking-off
and the locked-sequence refetch were not run by hand (waffle flip / prereq-gated
course needed); both rest on their unit tests. Summary in decisions-2013.md §11.

🤖 Generated with Claude Code

@brian-smith-tcril
brian-smith-tcril added this pull request to stack #2062 September 15, 2026 15:24
@codecov

codecov Bot commented Sep 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.68%. Comparing base (ffaa25c) to head (c8f18dc).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2064      +/-   ##
==========================================
- Coverage   93.69%   93.68%   -0.02%     
==========================================
  Files         368      369       +1     
  Lines        6031     6005      -26     
  Branches     1420     1386      -34     
==========================================
- Hits         5651     5626      -25     
+ Misses        364      363       -1     
  Partials       16       16              

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

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

@arbrandes arbrandes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-approved, with one suggested change.

Comment on lines +57 to +71
export const useCourseOutlineStructure = (courseId: string | undefined) => useQuery<CourseOutlineData | null>({
queryKey: coursewareQueryKeys.courseOutline(courseId!),
queryFn: () => getCourseOutline(courseId!),
enabled: !!courseId,
});

export const useCoursewareOutlineSidebarToggles = (courseId: string | undefined) => useQuery({
queryKey: coursewareQueryKeys.sidebarToggles(courseId!),
queryFn: async () => {
const {
enable_completion_tracking: enableCompletionTracking,
} = await getCoursewareOutlineSidebarToggles(courseId!);
return { enableCompletionTracking };
},
enabled: !!courseId,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a staleTime: Infinity. useCourseOutlineSidebar is called once per outline row, so at the default staleTime: 0 every batch of rows that mounts re-GETs both endpoints, where Redux fetched once per course per session.

There's prior art in authoring's useWaffleFlags: it does the same thing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-outline-sidebar branch from a6254ba to e00e865 Compare September 18, 2026 18:05
Base automatically changed from bsmith/react-query-check-block-completion to master September 18, 2026 18:18
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-outline-sidebar branch from e00e865 to 840068f Compare September 18, 2026 18:18
The navigation-sidebar outline (getCourseOutlineStructure) and the
completion-tracking waffle toggles (the last job of fetchCourse) become
queries; useCheckBlockCompletion's completion rollups move from the
updateCourseOutlineCompletion reducer to a pure helper applied with
setQueryData, and the courseOutlineShouldUpdate refetch flag becomes
invalidateQueries. The courseware slice is now down to the fields the
container teardown (#1976) owns.

Closes #2013

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-outline-sidebar branch from 840068f to c8f18dc Compare September 18, 2026 18:35
@brian-smith-tcril
brian-smith-tcril merged commit df9f938 into master Sep 18, 2026
7 checks passed
@brian-smith-tcril
brian-smith-tcril deleted the bsmith/react-query-outline-sidebar branch September 18, 2026 18:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Convert the courseware outline sidebar to React Query

2 participants