refactor: convert the courseware outline sidebar to React Query - #2064
Merged
Merged
Conversation
brian-smith-tcril
added this pull request to stack #2062
September 15, 2026 15:24
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
brian-smith-tcril
force-pushed
the
bsmith/react-query-outline-sidebar
branch
from
September 15, 2026 15:39
6140904 to
c738da2
Compare
brian-smith-tcril
marked this pull request as ready for review
September 15, 2026 15:45
5 tasks
arbrandes
force-pushed
the
bsmith/react-query-outline-sidebar
branch
from
September 18, 2026 14:58
c738da2 to
47bc982
Compare
arbrandes
force-pushed
the
bsmith/react-query-outline-sidebar
branch
from
September 18, 2026 16:13
47bc982 to
a6254ba
Compare
arbrandes
approved these changes
Sep 18, 2026
arbrandes
left a comment
Contributor
There was a problem hiding this comment.
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, |
Contributor
There was a problem hiding this comment.
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.
Contributor
Author
There was a problem hiding this comment.
brian-smith-tcril
force-pushed
the
bsmith/react-query-outline-sidebar
branch
from
September 18, 2026 18:05
a6254ba to
e00e865
Compare
Base automatically changed from
bsmith/react-query-check-block-completion
to
master
September 18, 2026 18:18
brian-smith-tcril
force-pushed
the
bsmith/react-query-outline-sidebar
branch
from
September 18, 2026 18:18
e00e865 to
840068f
Compare
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
force-pushed
the
bsmith/react-query-outline-sidebar
branch
from
September 18, 2026 18:35
840068f to
c8f18dc
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 thatcheckBlockCompletionwrites into the tree. This is Target 2 of the courseware decomposition (plan) in the Redux → React Query migration (#1946, Stage 1), stacked on #2063 — thecheckBlockCompletionmutation peel that exists precisely so this layer and the unit view don't fight over completion state. Closes #2013.After this layer the
coursewareslice is down to exactly the re-scoped #1976 teardown set (courseId/courseStatus/sequenceId/sequenceStatus/sequenceMightBeUnit/errorMessage/errorCode).What changed
courseware/data/apiHooks.ts:useCourseOutlineStructure(replacing thegetCourseOutlineStructurethunk and thecourseOutline/courseOutlineStatusslice fields) anduseCoursewareOutlineSidebarToggles(replacing what was left offetchCourse— by Convert courseware metadata to React Query #2010 it only fetched the sidebar toggles). Neither carriesmeta.models: this state was never in the model store.useCheckBlockCompletion'sonSuccessswaps the transitionalupdateCourseOutlineCompletiondispatch forgetQueryData→applyUnitCompletion→setQueryData, and thecourseOutlineShouldUpdaterefetch flag becomesinvalidateQueries— the repo's first use of either.applyUnitCompletionlives in the newcourseware/data/courseOutline.ts: a statement-by-statement immutable port of the deleted reducer, plus types transcribed fromnormalizeOutlineBlocks's output shape.useCourseOutlineSidebarreworked onto the two queries; its fetch effect is deleted (mounting the query replaces "fetch when not LOADED"; invalidation replaces the flag-watching). It now returnsisOutlinePendinginstead of aLOADING/LOADED/FAILEDstring — fully-converted reads consume query booleans, per the pattern-setter (CourseRecommendations).logError; and the locked-sequence refetch keeps the rolled-up tree visible and interactive (invalidateQueries) instead of blanking the sidebar to its spinner.fetchCourseandgetCourseOutlineStructurethunks, five outline reducers + four slice fields, four selectors, and the container'scheckFetchCourseguard.useParams().courseId);CourseOutlineTray.testfetches through the mocks and anchors on awaitForOutlineLoaded()helper (the spinner only tracks one of the two queries), with the loading state held by a newpreventOutlineSidebarLoadoption (never-resolving mock); the leaf component suites render from a seeded cache via a newseedQueryDatatest utility;CoursewareContainer.testgained mocks for the two URLs it had been silently 404ing all along; oneProductToursassertion became awaitFor(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
coursewareOutlineSidebarSettings(UI/config) → React context / localstate" — no. It's the camelCased result of the waffle-toggles fetch (server
state), so it became the
useCoursewareOutlineSidebarTogglesquery. Nothing inthis layer needed a new context:
isOpenwas already local state andcurrentSidebar/toggleSidebaralready live inSidebarContext. The issue wasretitled to drop "+ context".
courseOutlineShouldUpdate→setQueryData" — refined. The completionrollups became
setQueryData; the flag becamequeryClient.invalidateQuerieson the outline query. The flag existed only totrigger a refetch, and invalidation is that trigger.
2. First
setQueryData/invalidateQueriesuse in the repouseCheckBlockCompletion'sonSuccessswaps the transitionalupdateCourseOutlineCompletiondispatch (kept by #2012) for cache updates. The epicplan's wording for this layer ("completion rollups /
courseOutlineShouldUpdate→setQueryData") sanctioned the pattern.Get-then-set instead of an updater function.
setQueryData(key, old => …)can'talso report whether the locked-sequence refetch condition fired. So the hook does
getQueryData→applyUnitCompletion(outline, unitId)(pure helper, returns{ outline, refetchNeeded }) →setQueryData→ conditionalinvalidateQueries.Everything in
src/courseware/data/courseOutline.tsis transcription, notinvention — review the file by tracing each line to one of two sources. The four
interfaces are copied field-for-field from
normalizeOutlineBlocks's three switchbranches 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, requiredcompletionStatnumbers) arethe one place the types assert more than the JS proves — see §6.
applyUnitCompletionis a statement-by-statement port of the deletedupdateCourseOutlineCompletionreducer (slice.js:78–124in the parent commit),with immer draft mutations rewritten as spreads: the containing-sequence scan,
completedUnits/isAllUnitsAreComplete, bothcompletionStatcomputations, andhasLockedSequenceare the reducer's expressions verbatim (reading the post-writeunitsmap exactly as the reducer read its mutated draft);if (cond) { complete = true }became the equivalent set-or-leavecomplete: cond || old.complete; thecourseOutlineShouldUpdate = truewritebecame the
refetchNeededreturn value under the same condition; the reducer'sinline locked-sequence comment became the function's doc comment. The only new
logic is the not-found early return (below); the
sequenceId && …chaining in thesection lookup exists only to narrow the
findresults for TypeScript.Fidelity details:
unitIds, as the reducer did (thereducer ignored the payload
sequenceId; the outline tree is the authority).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):courseId!assertions in the two queryFns exist becausegetCourseOutlineand
getCoursewareOutlineSidebarTogglesare the only api functions with JSDoc@param {string}annotations, sotscchecks their call sites (the rest haveimplicitly-
anyparams). Runtime-safe viaenabled: !!courseId; same idiom asthe 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 === undefinedrenders, and wideningthe builder to accept
undefinedboth admits phantom[..., 'courseOutline', undefined]keys into prefix-matched key space andun-types the call sites where strictness does real work (the mutation's
onSuccesspassescourseIdwith no assertion because its guard genuinelynarrows 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!.skipTokenis parked as apossible repo-wide migration once Tear down the courseware Redux slice + replace useContextId #1976 settles how route identity is threaded.
onSuccesshas noawaitbetween the steps, soit'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
(
fetchCourseOutlineSuccessreplaced wholesale), and the server responseincludes the completion anyway.
invalidateQueriesis deliberately fire-and-forget — the mutation lifecycleshouldn't block on the refetch.
!unitId || !courseIdguards are typing-driven (nullableuntil Tear down the courseware Redux slice + replace useContextId #1976). A hypothetically-undefined
unitIdused to reach the reducer andthrow-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).
variables.courseId, not ambient state — a completionresolving after a course switch writes to the course it was fired for.
3. Deliberate behavior changes (both approved 2026-09-15)
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 deliberatelogErrorthere 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.
path dispatched
fetchCourseOutlineRequest, resetting the outline to{}/LOADING— the sidebar blanked to its spinner and everySidebarSequence'scollapse state reset.
invalidateQuerieskeeps the rolled-up tree visible andinteractive until fresh data lands.
resetQuerieswould have reproduced theblanking exactly; rejected as an artifact of the request-action pattern, not a
chosen behavior. Side-effect audit: no test referenced
courseOutlineShouldUpdateorcourseOutlineStatus; the status has exactly onereader (
CourseOutline.tsx's spinner branch); nothing keys remounts or effectsoff it. The genuinely new runtime state — the tree staying clickable during the
refetch — goes through the same
handleUnitClickpath over stale-but-valid maps.4. Null-outline hardening
getCourseOutline(the api fn) returnsnullwhen the response has noblocks.The old flow stored that null and
useCourseOutlineSidebar's destructuring wouldhave crashed on it; the hook now destructures
outlineQuery.data ?? {}, coveringnull and undefined alike. The query is typed
CourseOutlineData | nulland thenull case is pinned by a test.
5. Query-key naming
coursewareQueryKeys.courseOutline(courseId)sits next to the pre-existingoutline(courseId)(the learning-sequences outline from #2010). Distinct onpurpose:
courseOutlinematches the feature dir (course-outline) and the thunk itreplaces (
getCourseOutlineStructure); renaming the learning-sequences key was outof scope.
sidebarToggles(courseId)covers the waffle-toggles fetch.6. Typing
completionStatas required numbersThe plan sketched optional
completed?/total?(the normalizer copiescompletion_stat?.completion, which can be undefined). The reducer's arithmetic(
acc + completionStat.completed) always assumed presence — absent stats meant athrow-and-discard, not a handled case. Typing them optional would force either
non-null casts or semantic changes (
?? 0) in the ported arithmetic. The typesdescribe the contract the rollup relies on; the not-found guard covers the tree
shapes that used to throw.
7.
checkFetchCourseremoved: the toggles fetch moved from the container to the sidebar hookfetchCoursewas 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'scheckFetchCourseguard wasnothing 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, theguard has no job left: the guard entry, its effect call, the import, the thunk,
its
index.jsre-export, and thesetCoursewareOutlineSidebarTogglesreducer allgo together.
The query mounts in
useCourseOutlineSidebar, not the container, because the flaghas exactly one consumer — the sidebar (it feeds
isEnabledCompletionTrackingandnothing 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
getSequenceId/getSequenceStatusreads in the hook — courseware-slice fieldsstill written by the status bridge; they convert with the container teardown
(Tear down the courseware Redux slice + replace useContextId #1976). (
sequenceStatusis returned by the hook but currently has noconsumer; left untouched rather than removed here.)
useModel('coursewareMeta', courseId)forentranceExamData— model-storedissolution is Dissolve the model-store normalized cache #1977.
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 routescaffolding (
MemoryRouter+Routes path="/course/:courseId"); under Redux thedata was global and the missing param didn't matter.
CourseOutlineTray.testfetches through theinitializeTestStoreaxios mocksand 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()(afindByTextof thecompletion 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
preventOutlineSidebarLoadoption (replacingexcludeFetchOutlineSidebar): the mock returns a never-resolving promise — theonly 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…— rejectedsince the query starts pending naturally; nothing is forced into it) and an
inverted default-true flag (
allowOutlineSidebarLoad: false). The inversion isworkable — it needs a per-key destructure default in
initializeTestStore(
const { allowOutlineSidebarLoad = true } = options), not a whole-objectparameter 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/excludeFetchSequencefamily.preventOutlineSidebarLoadkeeps the siblingconvention and names the observable contract (the outline never loads) rather
than the React Query state.
SidebarSection/SidebarSequence/SidebarUnittests seed the querycache instead (new
seedQueryDatahelper insetupTest.js). Fetch-through can'twork there: these tests bypass
CourseOutline's loading gate, and during thepending tick
SidebarUnitdestructuresunits[unitId](undefined → crash) andhandleUnitClick's log-event scanssequences(click-vs-resolution race).Seeding matches the contract the real app provides (children render only after
the gate opens).
seedQueryDatasetsstaleTime: Infinityon the seeded key somounting observers don't refetch over the seed —
setQueryDataalone leaves theentry immediately stale under the default
staleTime: 0, and the mount-timerefetch 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.
setQueryDefaultsis per-key, so everyother query in the same client behaves normally. The helper lives in
setupTest.jsrather than inline because thestaleTimehalf is exactly thenon-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 matchingneither makes
Routesrender 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().courseIdto the queries.SidebarUnitclick tests fire a real-lookingget_completionPOSTthrough the mutation (the route param supplies a real courseId where
useParams()used to return{}). It's unmocked;logUnhandledRequestsanswers
200 {}→complete: false→ a harmless model write. Pre-branchthe same POST fired via the thunk with
undefinedin the URL; neitherversion asserts it.
SidebarUnitgetsisCompletionTrackingEnabled/unitas props (the outline seed exists forhandleUnitClick's log-event —tab_countcomes from the seeded sequence'sunitIds), andSidebarSequence's complete-sequence test passescomplete: trueas a prop while child units staycomplete: falsefrom theseed — the same prop-vs-source split the tests had against Redux.
SidebarSequence: plain function, client percall;
SidebarSection:RootWrappercomponent withuseMemo) — apre-existing asymmetry preserved rather than harmonized.
tracking off (the seeds hardcode
enableCompletionTracking: true, asinitializeTestStore's Redux seeding did before).Fixture derivation in all of them:
state.courseware.courseOutline.…→await getCourseOutline(courseId)against the same mock. Because the file-localsetup helpers no longer only build a store (they derive fixtures via the api fn
too), they were renamed
initTestStore→initTestDatain the four touchedfiles. (
initializeTestStoreitself keeps its name — a misnomer at this point inthe 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.)
courseIdcomes from the store (store.getState().courseware.courseId) inSidebarSection/SidebarUnitfor the route param and query-cache keys. Not anew 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.courseIdfrom the slice — a wholesalemigration then, rather than a divergent source for two files now.
CourseOutlineTrigger.testneeded no changes: with no route param the queriesare 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:renderHookmounts onlythe 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):createTestQueryClientsolely toactivate
createAppQueryCache— that's where theonErrorlogging lives; dropthe argument and the
logErrorassertion fails with no obvious cause.(
normalizeOutlineBlocks(courseId, courseBlocks.blocks), no HTTP), while theTray/leaf tests derive fixtures via
getCourseOutlineagainst the axios mock.Same normalizer either way.
type: 'lock'sequence), and itsunit-1is deliberately absent frommodels.units— the already-complete guard reads the model store, findsnothing, and proceeds. That absence is doing quiet duty.
(
toHaveBeenCalledWith({ queryKey: outlineQueryKey })) — adding options to thehook's
invalidateQueriescall later will fail the test on purpose.10. Collateral test fixes
CoursewareContainer.test.jsxgained mocks for the navigation and togglesURLs. Pre-conversion both requests already fired (the toggles via the container's
fetchCourse, the navigation via the trigger's effect) and 404'd silentlyagainst 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 thesidebar 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 awaitFor. The sidebar hook's render-time queries shift scheduling by a tick, andthe Paragon checkpoint (mounted when the tour effect fires after
tourDataresolves) 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:
expand/collapse, active highlighting); completing a unit rolls the open sidebar's
counts/icons up live with no extra
navigationrefetch (thesetQueryDatapath, 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).
loaded produced no
logErrorwhere 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.
waffle switch flipped; the toggles query's false path is covered by
apiHooks.test.tsxand the flag gating is prop-driven in the components); andthe 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.
courseOutlineStatusdropped for a query booleanThe first cut kept a derived
LOADING/FAILED/LOADEDstring soCourseOutline.tsxstayed byte-identical. Review flagged it against the epic'send-state convention, confirmed by precedent: the phase-0 pattern-setter
(
CourseRecommendations.jsx) ships readingisPending/isError/isSuccessdirectly (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, whosesequenceStatusis 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 v5vocabulary, matching the pattern-setter) and
CourseOutline.tsxchecks that; the@src/constantsimports left both files. The derivedFAILEDarm had no readeranyway — on error the
?? {}renders an empty tree, exactly as the oldfetchCourseOutlineFailure(courseOutline = {}) did.13.
staleTime: Infinityon both sidebar queries (review, arbrandes)useCourseOutlineSidebaris called by the tray, the trigger,CourseOutline, and byevery
SidebarSection/SidebarSequence/UnitLinkWrapperrow, so each row mountsuseCourseOutlineStructureanduseCoursewareOutlineSidebarTogglesas anotherobserver. The app query client leaves
staleTimeat the default 0, so data is stale themoment 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: Infinityon both hooks restores that:invalidateQueries(therefetchNeededpath in §2) marks stale and refetches regardless ofstaleTime;setQueryDatadoesn't involve staleness; a newcourseIdis a new key. One differencefrom Redux: TanStack's default
gcTimedrops the cache five minutes after the lastobserver 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: Infinitywith a one-line comment; itsrefetchOnWindowFocus: falseis already our client-wide default). The same mount-countquestion applies in principle to
useCoursewareMetadata/useCoursewareOutline/useCourseHomeMetaviauseIsCourseLoaded, but with far fewer observers; left as apossible 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, anduseCheckBlockCompletionwrites the rollups into the query cache(
setQueryData) instead of dispatchingupdateCourseOutlineCompletion.The two deliberate behavior changes are the visible bits to watch:
logErroron completion with the outline never loaded — the old reducerTypeError → 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.
invalidateQuerieskeeps 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; basehttp://apps.local.openedx.io:2000/learning. Grab a sequence + unit id from theaddress bar on any unit page (
…type@sequential+block@…/…type@vertical+block@…).Completion rollups need the outline sidebar with completion tracking enabled
(
enable_completion_trackingtoggle) — check the sidebar shows completion icons first.The navigation fetch shows in DevTools → Network filtered on
navigation; the togglesfetch on
courseware-navigation-sidebar.Verify by hand
navigationGET, thetree renders (sections ↔ sequence/unit levels, back button), expand/collapse
works, active sequence/unit highlighted.
another in the sidebar): the
get_completionPOST fires and the open sidebar'ssequence/section counts/icons tick up with no extra
navigationrefetch(plain completions update the cache in place).
enable_completion_trackingoff: no completion icons/sr-only text; back on:they return.
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.)
1) — complete a unit before the sidebar tree has loaded (e.g. throttle the
navigationrequest or complete quickly after a hard reload): unit still markedcomplete in the sequence nav, no
logErrorpage-action (the old passexpected one here — its absence is the new correct behavior).
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
navigationrefetch fires, the sidebar does notblank 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)
error → logError + falsy flag) —
apiHooks.test.tsx.useCheckBlockCompletioncache writes: rollup helper, quietcache-miss no-op, and the locked-sequence invalidation (pinned with an
invalidateQueriesspy; also asserts no invalidation on a plain rollup) —apiHooks.test.tsx.preventOutlineSidebarLoadhanging mock for the loading state) —
CourseOutlineTray.test.jsx; the leafcomponent 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
navigationrequest — the jest case pins the exact semantics. Tracking-offand 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