refactor: de-class CoursewareContainer - #2020
Open
brian-smith-tcril wants to merge 1 commit into
Open
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## bsmith/react-query-course-home-live-tab #2020 +/- ##
===========================================================================
+ Coverage 93.28% 93.51% +0.22%
===========================================================================
Files 363 362 -1
Lines 5901 5889 -12
Branches 1404 1401 -3
===========================================================================
+ Hits 5505 5507 +2
+ Misses 381 367 -14
Partials 15 15 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
brian-smith-tcril
force-pushed
the
bsmith/react-query-declass-courseware-container
branch
from
August 20, 2026 08:05
8c88207 to
394456e
Compare
Convert CoursewareContainer from a class + connect component to a functional
component using hooks. Structural only — no data-layer or behavior change: it
dispatches the same thunks and reads the same store. This unblocks the later
courseware React Query peels, which need a function component to call query
hooks a class can't.
- class -> function; connect -> useSelector/useDispatch
- inline the route hooks (useParams/useNavigate/useLocation) and delete the
withParamsAndNavigation HOC (utils.jsx), its only consumer
- keep the reselect memoize fetch/save guards (held in refs, reading a
per-render latest ref) inside one no-dependency effect, matching the old
componentDidMount + componentDidUpdate exactly
- drop the dead previousSequence selector/prop (unused; keep the empty
previousSequenceHandler that Course still requires)
- guard useIFrameBehavior's activeSequence.unitIds read: removing connect lets
the global getSequenceId briefly lead the rendered sequenceId prop, so the
sequence model can transiently be the outline stub (no unitIds)
- drop dead fetchCourseRecommendations{Request,Success,Failure} exports
(orphaned from the recommendations RQ conversion in #1967)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
bsmith/react-query-declass-courseware-container
branch
from
August 20, 2026 09:03
394456e to
4ca8e3a
Compare
brian-smith-tcril
marked this pull request as ready for review
August 20, 2026 09:56
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
De-class
CoursewareContainer: convert it from aclass+connectcomponent to a functional component using hooks. Purely structural — no data-layer or behavior change (same thunks, same store reads). First peel of the courseware-player decomposition (#1976 → #2008–#2016), part of the Redux → React Query migration (#1946, Stage 1). Stacked on the live-tab conversion (#2006) as the bottom of the courseware chain. Closes #2008.Why first:
CoursewareContaineris the single orchestrator that callsfetchCourse/fetchSequence; a class can't call theuseQueryhooks the later peels introduce, so it has to become a function component before any of them. TypeScript is deliberately not part of this PR — it's the fast-follow #2019 (stacked directly above), so this one reads as a pure structural change.What changed
src/courseware/CoursewareContainer.jsx—class→ function component.connect→useSelector/useDispatch; route inputs via inlineduseParams/useNavigate/useLocation.memoizefetch/save guards are preserved (held in auseRef, reading a per-renderlatestref — the analog ofthis.propsalways being current), inside a single no-dependencyuseEffectthat reproducescomponentDidMount+componentDidUpdateexactly. The de-dupe mechanism is unchanged (memoize, not dependency arrays).previousSequenceSelector+previousSequenceprop (unused;nextSequencestays). The emptypreviousSequenceHandleris kept —Course→Sequencestill requires it.PropTypes/defaultPropsremoved — the component now takes no props.src/courseware/utils.jsx— deleted.withParamsAndNavigationexisted only to feed route params into the class;CoursewareContainerwas its only consumer.src/courseware/course/sequence/Unit/hooks/useIFrameBehavior.ts— guardactiveSequence.unitIds?.length > 0(see Behavior).src/courseware/data/slice.js— drop the deadfetchCourseRecommendations{Request,Success,Failure}exports (orphaned from the recommendations RQ conversion in refactor: convert course recommendations to React Query #1967; no readers).Behavior
No user-facing change — the de-class preserves the exact fetch orchestration, URL-normalization redirects, sequence-position save, and store reads.
One downstream guard is required. Removing
connectmeansuseIFrameBehavior's read of the global store id (getSequenceId→state.courseware.sequenceId) can briefly lead the renderedsequenceIdprop during a navigation, so the sequence model it looks up can transiently be the course-outline stub (nounitIds). The unconditionalactiveSequence.unitIds.lengththen threwCannot read properties of undefined (reading 'length'). Guarding with?.length > 0makes that transient render a no-op (activeUnitId = null) that resolves once the sequence loads.This is isolated, not the tip of an iceberg:
useIFrameBehavioris the only component in the courseware render path that reads the global id anduseModel('sequences', …)on it. Every other unguarded.unitIdsreader takessequenceIdas a prop/arg — the value that passedSequence'ssequenceStatus === 'loaded'gate — so it's always consistent with the render that mounted it.connectkept the global id and the rendered prop in lockstep, which masked this latent fragility; the full analysis is in the decision log.Testing
npm run types,npm run lint, and the full suite pass.CoursewareContainer.test.jsx's existing cases are unchanged (they render<CoursewareContainer />with no props, driven by URL + store + mocks); two tests were added to cover the relocated lines codecov's patch flagged: (1)handleUnitNavigationClick→checkBlockCompletion, via amock-prefixed opt-in flag that renders the real unit nav only in that test so aNextclick exercises the handler (asserted through theget_completionPOST); (2)firstSequenceIdSelector's empty-sectionIdsbranch, via a sectionless course that asserts the resume redirect doesn't fire (no first sequence to pick). The other cases are untouched. The one remaining flagged line is thehandleNextSequenceClickcelebration branch, which codecov's diff shows unmoved (byte-identical, not counted as patch), pre-existing and equally uncovered onmaster. In-browser parity sweep verified core load, all URL-normalization redirects, resume, next/prev unit + sequence, and unit completion; plugin/preview/access cases were not exercised in-browser (details in the manual-testing log). Full logs below.Decisions
Full decision log
Decisions — de-class CoursewareContainer (#2008)
Working notes for this PR (part of the wider Redux → React Query migration,
#1946). Not checked in — referenced when opening the PR. First peel of the
courseware-player decomposition (#1976 → #2008–#2016); stacked on the live-tab
conversion (#2006) as the bottom of the courseware chain. Closes #2008.
This layer is purely structural:
CoursewareContainergoes from aclass+connectto a function component using hooks. No data-layer orbehavior change — it dispatches the same thunks and reads the same store. It
exists so the later peels can call
useQueryhooks a class can't.Stays
.jsx; TypeScript is the fast-follow #2019Decision. The de-class is already a churn-y structural diff; layering a
.tsxrename + type annotations on top would make it harder to review. TypeScriptis split into #2019 (stacked directly above this), so the RQ peels happen in
TS-land while this PR reads as a single idea.
Effects: keep the reselect
memoizeguards (faithful, not dep-arrays)Decision. The class de-duped its work with reselect
memoize(last-argsguards
checkFetchCourse/checkFetchSequence/checkSaveSequencePosition),not React dependency arrays. We preserve that exact mechanism:
useRef(stable identity, created once) — recreatingthem per render would reset their last-args memoization.
latestref, the analog ofthis.propsalways being current (socheckSaveSequencePositionreads the livesequence/ids, never a stale closure).componentDidMount+componentDidUpdatecollapse into a singleuseEffectwith no dependency array (runs after every render), body identicalto the old
componentDidUpdate.Why not idiomatic dep-array effects. Swapping the de-dupe engine to React deps
now would mean proving every dependency array exactly right across a redirect block
that reads many inputs — a large correctness argument for no functional gain. We
prove equivalence bit-by-bit as each fetch becomes a
useQuerylater, when theguard for that fetch falls away on its own.
Mount-runs-everything is safe. The class ran only the two fetches on mount and
everything on update; the no-dep effect runs the whole body on mount too. Harmless:
saveSequencePositionand all redirects gate onsequenceStatus/courseStatus === 'loaded'(false at mount), and the ids-mismatch bail returns early at mount anyway.Inline the router hooks; delete
utils.jsxDecision.
withParamsAndNavigation(src/courseware/utils.jsx) existed only tofeed route params/
navigateinto the class. The function component callsuseParams/useNavigate/useLocationdirectly, soutils.jsxis deleted —CoursewareContainerwas its only consumer (verified repo-wide).Dead code removed:
previousSequenceselector + propDecision. Remove
previousSequenceSelectorand thepreviousSequenceprop itfed. Keep the empty
previousSequenceHandler(() => {}) — that's a differentthing, a required prop of
Course→Sequence.We specifically questioned this during review — is
previousSequenceactuallydead, or a plugin/top-nav extension point we'd be breaking? Verified it is dead:
previousSequence\bhas zeroreaders outside this file. Its only consumer was
mapStateToProps(
previousSequence: previousSequenceSelector(state)), feeding a prop thatrender()/handlers never read. Chain:selector → prop → nowhere.nextSequence, which is live —handleNextSequenceClickreads it forthe next-section celebration. So next stays, previous goes.
(
SequenceNavigationSlot,NextUnitTopNavTriggerSlot,SequenceBottomNavigationSlot) compute their own prev/next insrc/courseware/course/sequence/sequence-navigation/hooks.js(
previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null→
previousLink). Previous-sequence navigation runs throughpreviousSequenceHandler(kept) + that hook's own computation, never through thecontainer's
previousSequencedata.Dead exports removed:
fetchCourseRecommendations*Decision. Drop the
fetchCourseRecommendations{Request,Success,Failure}re-exports from
src/courseware/data/slice.js— no readers anywhere outsideslice.js(verified).Strictly unrelated to the de-class — these are orphaned leftovers from the
course-recommendations React Query conversion (#1967, already merged), which
dropped the reducers but left the dead destructured exports (so they've resolved
to
undefinedsince). Because the PR that should have removed them has alreadylanded on
master, there's no cleaner owner elsewhere in the stack, so thistrivial deletion rides along here rather than manufacturing a separate layer.
Guard
useIFrameBehavior's sequence read (crash surfaced by the de-class)Decision. In
useIFrameBehavior.ts, change the unconditionalactiveSequence.unitIds.length > 0toactiveSequence.unitIds?.length > 0— addthe
?.null-guard while preserving the exact> 0predicate. This is the onedownstream change this PR makes outside
CoursewareContainer.Why — the crash. Manual testing (jumping between units via the course-outline
sidebar) hit
Uncaught TypeError: Cannot read properties of undefined (reading 'length')atuseIFrameBehavior.ts:42, flashing the ErrorBoundary thenrecovering. Instrumentation caught the exact state:
useIFrameBehaviorwasrendering while
sequenceStatus === 'loaded'/mid-transition against a sequencewhose model was still the outline stub
fetchCourseseeds (keys=[id, title, sectionId], nounitIds). A 22k-line master log of the same navigation nevercrashes; my branch hit it on essentially every jump.
Why it's isolated, not the tip of an iceberg.
useIFrameBehavioris theonly component in the courseware render path that reads the global store id
(
getSequenceId→state.courseware.sequenceId) and thenuseModel('sequences', …)on it. Every other unguarded.unitIdsreader (Sequence,SequenceNavigation,UnitNavigationEffortEstimate,useSequenceNavigationMetadata) takessequenceIdas a prop/arg — the samevalue that passed
Sequence'ssequenceStatus === 'loaded'gate — so their modelis always the loaded one, consistent with the render that mounted them. Only
useIFrameBehaviorbypasses the prop, so during a transition its global id canlead the rendered prop and point at the not-yet-loaded stub.
connect(the pre-de-class wiring) delivered store updates toCoursewareContainerand its subtree in lockstep, so the global id and the rendered prop never
disagreed and this latent fragility stayed hidden. Removing
connectin favor ofindependent
useSelectorsubscriptions lets them momentarily diverge, exposing it.So this is a "should have been guarded all along" fix — reading the global
sequenceIdand assuming a fully-loaded model was never safe — not a symptom ofbroad breakage. The grep confirming this (single global-id render-path reader) is
the basis for that confidence.
Tests: existing suite unchanged + one added coverage test
Decision. The existing
CoursewareContainer.test.jsxcases are left as-is —they render
<CoursewareContainer />with no props inside<AppProvider store>+<Routes>, driven by URL + store + axios mocks, and separately unit-test theexported redirect helpers. Behavior is identical, so they pass without edits.
Two tests added to cover the relocated lines codecov's patch flagged (both
rewritten/moved by the de-class, so codecov sees them as new; both were equally
uncovered on
master):handleUnitNavigationClick→checkBlockCompletion(wasthis.props.checkBlockCompletion(...)→dispatch(...)). The existing suitemocks
Unitas a bare div with no navigation, and 7 cases assertassertNoSequenceNavigation, so the mock can't render nav globally. Amock-prefixed opt-in flag (mockRenderUnitNav, reset inafterEach) makes theUnitmock render the realrenderUnitNavigation()only in the new test; aNextclick driveshandleNext → unitNavigationHandler → handleUnitNavigationClick,asserted via the
get_completionPOST. Existing cases untouched (flag defaults off).firstSequenceIdSelector's empty-sectionIdsbranch — a course with no sectionsmakes
firstSequenceIdresolve tonull, so the resume redirect can't pick afirst sequence. The test loads a sectionless course at
/course/:courseIdandasserts no redirect happens (URL stays at the course root, no unit rendered) —
exercising the branch via its observable behavior, not just for the line hit.
The one remaining codecov-flagged line is the
handleNextSequenceClickcelebrationbranch, which codecov's diff shows unmoved (byte-identical, no +/− markers) so it
isn't counted as patch — pre-existing, equally uncovered on
master, left as-is.Manual testing sweep
Manual testing — de-class CoursewareContainer (#2008)
Scoped to what this PR changes:
CoursewareContainergoes from a class +connectto a function component on hooks. It is a purely structural change —same thunks, same store reads, same behavior. So this list is a parity sweep:
everything the container orchestrated should work exactly as it did on
master.The risky part is that
componentDidMount+componentDidUpdate(data fetches,the URL-normalization redirects, and sequence-position saving) now run in a single
render
useEffect, and thememoizede-dupe guards moved into refs. Focus testingon those flows.
Mark results as you go —
[x]pass,[!]problem (add a note),[-]not tested (with reason).Automated tests already cover (68 passing in
CoursewareContainer.test.jsx,unchanged): initial spinner, successful course/sequence render, the
course_accesserror path, and all six redirect helpers as units. So this listtargets the real-browser navigation/timing behaviors those can't fully exercise.
1. Core load
/course/:courseId/:sequenceId/:unitIdURL: spinner →course header + sequence + unit render, no flash/crash/double-fetch.
2. URL normalization redirects (the effect's main job)
Each of these should land on the normalized
/course/:courseId/:sequenceId/:unitIdURL, same as
master:/course/:courseId→ resume redirect to the last activesequence/unit (or the first sequence if none active).
/course/:courseId/:sequenceId(no unit) → fills in the active-or-first unit./course/:courseId/:sequenceId/firstand.../last→ resolve to the first /last unit of the sequence.
/course/:courseId/:sectionId(a section id where a sequence is expected) →redirects to the section's first sequence.
/course/:courseId/:unitId(bare unit) → fills in the parent sequence id.sequenceMightBeUnitpath) → redirects to the correct parent sequence, then the unit.
3. Sequence-position save + resume
saveSequencePositionnow fires from the effect via the ref'd guard.(
/course/:courseId) — resume lands on the last unit you were on.stale saves; the memoize guard should still de-dupe on the unit id).
(Not specifically exercised; resume itself verified above.)
4. In-sequence and cross-sequence navigation
Exercises the nav slots (top + bottom) and the
next/previoushandlers passed toCourse:sequences and lands correctly.
(Not tested — it's a plugin slot requiring
env.config.jsxsetup. The navcomputes prev/next itself in
sequence-navigation/hooks.js, independent ofthe container.)
5. Next-section celebration
handleNextSequenceClickstill fireshandleNextSectionCelebration.unit of the first section into the next section shows the celebration modal
(same as
master). (Not tested — needs celebration setup. Note the handleris unchanged from the class version.)
6. Unit completion
handleUnitNavigationClick→checkBlockCompletion.same as before.
7. Preview mode
isPreviewis now derived fromuseLocation().pathnameinstead of the HOC./preview/course/...URL — the same redirects fire but keep the/previewprefix on every normalized URL. (Not tested.)8. Access / error states
courseStatus: 'denied') shows the denied/redirectbehavior, unchanged. (Not tested.)
course_accesserror renders the error state, not a crash. (Not testedin-browser; covered by the automated
course_accesstest inCoursewareContainer.test.jsx.)🤖 Generated with Claude Code