refactor: convert product-tours from Redux to React Query - #1968
Open
brian-smith-tcril wants to merge 1 commit into
Open
refactor: convert product-tours from Redux to React Query#1968brian-smith-tcril wants to merge 1 commit into
brian-smith-tcril wants to merge 1 commit into
Conversation
brian-smith-tcril
marked this pull request as ready for review
July 31, 2026 19:37
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## bsmith/react-query-course-recommendations #1968 +/- ##
=============================================================================
+ Coverage 91.55% 92.34% +0.79%
=============================================================================
Files 354 355 +1
Lines 5824 5852 +28
Branches 1391 1404 +13
=============================================================================
+ Hits 5332 5404 +72
+ Misses 473 429 -44
Partials 19 19 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
3 tasks
brian-smith-tcril
force-pushed
the
bsmith/react-query-product-tours
branch
from
July 31, 2026 19:41
649de23 to
60fc453
Compare
Replace the `tours` Redux slice with React Query for server state and a
React context for the client UI flags (OEP-0067):
- data/apiHooks.ts: useTourData (useQuery) + useEndCourseHomeTour /
useEndCoursewareTour (useMutation); data/queryKeys.ts keyed by username
- TourContext.tsx: mirrors the former slice's show-flags via useReducer
- ProductTours.jsx / LaunchCourseHomeTourButton.jsx read from the query +
context instead of Redux; TabPage supplies TourProvider
- remove the tours reducer from store.ts and setupTest.js; delete
data/{slice,thunks,index}.js
Preserve the original show/hide behavior: invalidateQueries on the end-tour
mutations keeps the cache in sync with the PATCH, and refetchOnWindowFocus:
false prevents a focus refetch from reopening a tour mid-session.
Update tests that render the tour tree to supply TourProvider and, for the
suites that hand-build their tree with RTL render, a QueryClientProvider.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
bsmith/react-query-product-tours
branch
from
August 1, 2026 08:10
60fc453 to
1fd02ca
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
Converts product tours (the
toursRedux slice) from Redux to React Query for server state and a React context for the client UI flags, per OEP-0067 ADR-0010. Part of the Redux → React Query migration (#1946), stacked on the course-recommendations pattern-setter (the branch below).Behavior is unchanged — the new-user modal, the new-user / existing-user / courseware tours, and the launch button all trigger and dismiss exactly as before. Verified with the full test suite and a live manual smoke pass (details in the decision log).
What changed
product-tours/data/queryKeys.ts(keyed by username) +data/apiHooks.ts—useTourData(useQuery) anduseEndCourseHomeTour/useEndCoursewareTour(useMutation).product-tours/TourContext.tsxmirrors the former slice's show-flags viauseReducer;TourProvideris supplied byTabPage(the lowest node covering all tour consumers — the sr-only launch button lives directly inTabPage, outsideLoadedTabPage).ProductTours.jsxandLaunchCourseHomeTourButton.jsxread from the query + context instead of Redux.data/{slice,thunks,index}.js; drop thetoursreducer fromstore.tsand the test store.invalidateQuerieson the end-tour mutations keeps the query cache in sync with the PATCH (so a dismissed tour doesn't flash back on remount), andrefetchOnWindowFocus: falseprevents a focus refetch from reopening a tour mid-session. The only intentional deviation is dropping the in-memory replay-flag persistence (a Redux-only quirk that never survived a page reload) — see the decision log.Testing
Automated:
npm run types,npm run lint,npm run build, and the fullnpm testsuite all pass (102 suites / 888 tests). Unit tests coverTourContext, the query/mutation hooks, and the tour interaction flows.Manual smoke (dev). Tours are driven by the server-side
UserTourrow (/api/user_tours/v1/<username>). Set it via Django admin at/admin/user_tours/usertour/—course_home_tour_status∈ {show-new-user-tour,show-existing-user-tour,no-tour} and theshow_courseware_tourcheckbox. Finishing/dismissing a tour PATCHes it back, so re-arm between runs. Keep DevTools → Network filtered touser_tours.show-new-user-tour→ Home: modal appears → Begin tour starts the tour (no PATCH) → Skip for now dismisses it and shows the abandon prompt (PATCHno-tour); completing the tour PATCHesno-tour.show-existing-user-tour→ Home: tour renders directly (no modal).show_courseware_tour: true→ open a unit: checkpoint anchors to the sequence-nav. Note: the anchor#courseware-sequence-navigationis rendered bySequenceNavigation, which sits behindSequenceNavigationSlot(empty by default), so it only appears when that slot is populated — pre-existing, unrelated to this PR (verified by injecting the default component into the slot).no-tourrow, Home shows the Launch tour button → click starts the tour.invalidateQuerieskeeps the cache in sync with the PATCH).refetchOnWindowFocus: false).GET /v1/<username>fires only on Home + Courseware (not Dates/Progress/Discussion; thediscussion_toursendpoint is a separate, unrelated feature).All of the above verified live. Full analysis and results are in the decision log below.
Decisions
Full decision log
Decisions — Redux → React Query: product tours
Working notes for this PR (part of the wider Redux → React Query migration,
#1946). Not checked in — referenced when opening the PR. Stacked on the
course-recommendations conversion (#1966).
Target selection
product-tours(thetoursslice). Picked as the nextself-contained Redux slice to remove after course recommendations.
bookmark(addBookmark/removeBookmark): it is a genuineRedux mutation, but its
bookmarked/bookmarkedUpdateStatestate lives inthe shared courseware
unitsmodel and is read byUnitTitleSlotand thesequence-nav
UnitButton, not justBookmarkButton. Converting it now wouldbe a partial job — the
useMutationwould still have to dispatchupdateModelinto the still-Reduxunitsstore to keep those consumers insync — coupling it to the courseware model that belongs to a later phase.
Defer until courseware/
models.enrollment-alert,celebration): they use noRedux, so converting them wouldn't remove any Redux (same reason we passed on
preferences-unsubscribefor the read pattern-setter).product-toursis self-contained: its owntoursslice +data/api.js(
getTourData/patchTourData), read only byProductTours.jsxandLaunchCourseHomeTourButton.jsx. It only readscourseHomeMeta(anot-yet-converted slice — acceptable mid-migration, same as recommendations
reading
courseware.courseId) and never writes the sharedmodelsstore.Removing it deletes a whole reducer, and it exercises the next patterns we
need in one focused PR:
useQuery(tour data),useMutation(patch/dismiss),and client-state → React context/local state (the UI on/off flags, per
OEP-0067's "client state stays in React").
Stacked PR
react-query-course-recommendations(refactor: convert course recommendations to React Query #1966) so it inherits theReact Query scaffolding (the app-level
QueryClientProvider, the test-renderwrapper, and the
appIdconstant). PR base is that branch; retarget tomasterafter refactor: convert course recommendations to React Query #1966 merges, then rebase.QueryClient; testclient
retry: false; colocated per-featuredata/{queryKeys,apiHooks}rootedat
appId; branch on RQ flags; tracking in a colocatedtrack.js.Conversion plan
The
toursslice state is a mix of server data and client UI state, so itsplits three ways (this is the OEP-0067 "server → React Query, client → React
state/context" split in miniature):
toursslice piecegetTourData(fetchTourDatathunk)useTourData—useQuerypatchTourData(endCourseHomeTour/endCoursewareTour)useEndCourseHomeTour/useEndCoursewareTour—useMutationshowCoursewareTour,showExistingUserCourseHomeTour,showNewUserCourseHomeModal,showNewUserCourseHomeTourTourContext(React context), seeded from the querytoursEnabledLaunchCourseHomeTourButton)useTourDataWhy a context (not just local state)
showNewUserCourseHomeTouris a cross-component signal:LaunchCourseHomeTourButton(rendered inoutline-tab/widgets/CourseToolsandtab-page/TabPage) flips it, andProductTours(rendered intab-page/LoadedTabPage) reacts. Different subtrees → shared client state needs acontext (the same "react context conversion" learner-dashboard did).
useQueryis deduped by key, so both components can call
useTourDataand share the cachedserver result; only the live show/dismiss flags live in the context.
Files
.ts/.tsx) — matches the references (authn/LDwrite new contexts as
.tsx, api/hooks as.ts) and the direction of travel.Existing
.jsx/.jsfiles we only edit (ProductTours.jsx,LaunchCourseHomeTourButton.jsx,TabPage.jsx) stay as-is; converting them toTS is out of scope here.
data/queryKeys.ts(rooted atappId),data/apiHooks.ts(
useTourData+ the two end-tour mutations), andTourContext.tsxat thefeature root —
src/product-tours/TourContext.tsx(exportsTourProvider+useTourState; holds the show flags +disableCourseHomeTour,disableCoursewareTour,closeNewUserCourseHomeModal,launchCourseHomeTour;seeds itself from the
useTourDataresult).(
src/<feature>/…Context.tsx) rather than learner-dashboard's centralsrc/data/context/— consistent with the colocated data-layer choice fromrefactor: convert course recommendations to React Query #1966. Placed at the feature root (not a
components/subdir like authn) becauseproduct-tourskeeps all its components flat at the feature root; and notunder
data/(it's client state, not a fetch concern).data/api.js(getTourData/patchTourDataunchanged — thequery/mutation fns; note
getTourDataalready swallows 401/403/404 into{ toursEnabled: false }, so the query resolves rather than errors).data/slice.js,data/thunks.js; remove thetoursreducer fromsrc/store.tsandsetupTest.js'sinitializeTestStore.ProductTours.jsx,LaunchCourseHomeTourButton.jsx.Provider placement (key design point)
TourProvidermust wrap bothProductToursand everyLaunchCourseHomeTourButton.Their common ancestor is the tab page —
TabPage.jsxrenders both the sr-onlybutton and
LoadedTabPage(which rendersProductToursand the tab content,including the outline
CourseToolsbutton).Decision: wrap
TabPage's entire return (<>…</>→<TourProvider>…</TourProvider>),including
HeaderSlot/FooterSlotand the loading/failed branches, rather thanwrapping only the button +
LoadedTabPage. The sr-onlyLaunchCourseHomeTourButtonsits in the
['loaded','denied']block beforeHeaderSlot, so the provider hasto open before the header regardless; wrapping the whole return is cleaner than
restructuring the fragment to wrap two non-adjacent children. Header/footer don't
consume the context — harmless.
Client state: faithful slice → context mirror
Chose a faithful 1:1 mirror of the
toursslice asTourContext(over theleaner "minimal context + local state" option) — lowest-risk, mechanical, and
matches learner-dashboard's react-context conversion.
TourContextusesuseReducerwhose cases mirror the slice reducers exactly (setTourData,disableCourseHomeTour,disableCoursewareTour,closeNewUserCourseHomeModal,launchCourseHomeTour), seeded from theuseTourDataresult.ProductTourskeeps its existing local
is*Enabledlayer unchanged (it just reads the contextflags instead of
useSelector(state.tours)).toursEnabledis read from the query, not the context. Unlike theshow*flags,
toursEnabledis pure server data — never mutated by anydisable*/launchaction — and it's read by only
LaunchCourseHomeTourButton(notProductTours).So it stays in React Query rather than being mirrored into
TourContext. Thebutton reads it via
useTourData(username, /* enabled */ false)— anobserve-only subscriber that reads whatever
ProductToursput in the query cache(same key) without triggering its own fetch. This preserves the original's fetch
optimization exactly: only
ProductToursfetches (on its guarded tabs), so onnon-outline
courseHomeMetatabs (dates/progress, where the sr-only button alsorenders) the cache is empty → button hidden, same as today. Rejected having the
button
enableits own fetch (would GET tour data on those tabs — a regression,worsened by our bare client's lack of
staleTime).The original end-tour thunks did two things — persist (
patchTourData) andflip a client flag (
dispatch(disable*)). That splits cleanly:patchTourDatapersistence is unchanged, now the
mutationFnof the end-tour mutations; theclient flag flip becomes a
TourContextaction. Both are called in the tour'sonEnd(mutate(username)+context.disable*()), rather than coupling the hideinto the mutation's
onSuccess(which would force the data hook to consume thecontext).
Modal action renamed
disable…→closeNewUserCourseHomeModal. The slicecalled it
disableNewUserCourseHomeModal, but the action only setsshowNewUserCourseHomeModal = false, and that flag is the modal'sisOpen. Soit's a transient close, not a permanent disable — the "won't reopen"
persistence is a separate
patch(endCourseHomeTour) called alongside it inonDismiss(and notably not inonStartTour, which just closes the modal toreveal the tour).
closenames the actual behavior;disableover-impliedpermanence the action doesn't provide. The tour actions
(
disableCourseHomeTour/disableCoursewareTour) keepdisablefor now. SeeTour reappearance: matching Redux persistence below for how
refetchOnWindowFocusand mutation cache-invalidation preserve the original show/hide behavior.
Thunk/action → new mapping
fetchTourData(username)→useTourData(username, shouldFetchTourData()); theguard matches the original effect guard — see ProductTours readability below.
setTourData(data)→ the context seeds its flags from the query result.endCourseHomeTour(username)→useEndCourseHomeTour().mutate()(PATCHcourse_home_tour_status: 'no-tour') +context.disableCourseHomeTour().endCoursewareTour(username)→useEndCoursewareTour().mutate()(PATCHshow_courseware_tour: false) +context.disableCoursewareTour().closeNewUserCourseHomeModal()→context.closeNewUserCourseHomeModal().launchCourseHomeTour()→context.launchCourseHomeTour().ProductTourskeeps its existing localis*Enabledstate andsendTrackEventwiring; it just sources the seed flags from context (seeded by the query) and
calls mutations + context actions instead of dispatching thunks.
LaunchCourseHomeTourButton:toursEnabledfromuseTourData;onClick→context.launchCourseHomeTour().courseIdstill from thecourseHomesliceand
orgviauseModel('courseHomeMeta')— not-yet-converted reads, acceptable.ProductTours readability (from review)
shouldFetchTourData()function with early returns, not achain of named booleans. It has three independent reasons to bail — (1) not
authenticated (the endpoint is per-user), (2) not on a tab that has a tour (only
outline/courseware; avoid needless calls), (3) on the outline tab but the
proctoring panel hasn't loaded, so the tour's target widget (weekly-goal) isn't
in the DOM yet. We tried encoding each reason in a variable name and it stayed
unclear ("resolved"/"ready"/"tour tab" didn't convey why), so each
return falseis a guard clause with a comment stating its reason. Called inline:useTourData(username, shouldFetchTourData()).isCoursewareTab/isOutlineTab→coursewareTabActive/outlineTabActive(theis…Tabprefix read awkwardly);updated their other uses (the
show* → is*Enabledsync effects and the modalisOpen).onEnd/onDismissmust persist (mutation) and hide (context) — two coupledlines. Extracted
endCoursewareTour()/endCourseHomeTour()(each =mutation.mutate(username)+disable*(), closing over the in-scopeusernameso call sites pass no args) so every call site is one line. Kept two named
functions over a general
endTour(type): the two branches share no logic(different mutation and different disable), so a discriminator would just be a
stringly-typed switch needing a defensive
else— worse than two clear names.LaunchCourseHomeTourButton (from review)
toursEnablednow needsusername; the old selector didn't. Redux'stoursslice was a single global box —
ProductTours's thunk wrotestate.toursandthis button read the same
state.tours, no key involved. React Query's cache iskeyed (
tourQueryKeys.user(username)), so to read the entryProductTourscached the button must reconstruct the same key, which requires
username. Notnew coupling — both components already get
usernamefrom the samegetAuthenticatedUser(); the per-key cache just makes the address explicit for avalue that is genuinely per-user. This is the general shape of Redux-global →
RQ-per-key: readers name the key in exchange for dedupe / staleness / per-user
isolation.
getAuthenticatedUser()to one top-level call. The originalcalled it inside
handleClick(onlyadministrator, for the track event). Thebutton now also needs
usernameat render time for the observe-only query, sowe destructure
{ administrator, username } = getAuthenticatedUser() || {}onceat the top — auth is stable for the render, one call reads cleaner, and it
matches how
ProductTours.jsxsources auth.useSelectorstays forstate.courseHome.courseId(feedsuseModel('courseHomeMeta')fororg) — a not-yet-converted slice, acceptablemid-migration.
Tour reappearance: matching Redux persistence
The
toursslice was a single global, persisted store, and that persistence —not any single line — is what governed when tours/modals reappeared. Redux had one
persistent store; React Query splits that persistence across two layers, and the
seam between them is where behavior can drift. We audited every reappearance path
and reduced it to three discriminators; each fix maps to exactly one.
Layer split. Server-derived flags (
showNewUserCourseHomeModal,showExistingUserCourseHomeTour,showCoursewareTour— all computed bysetTourDatafrom server data) are persisted by the query cache, which (likethe old store) lives above the tab tree and survives SPA navigation. The one
purely-client flag
showNewUserCourseHomeTour(set only bylaunchCourseHomeTour,cleared only by
disableCourseHomeTour) has no server field, so itspersistence depended on the Redux store's in-memory lifetime.
Note: the tour's step position was never persisted anywhere (the slice held
only booleans; position lives inside Paragon's
ProductTourand dies on unmount).So a tour never resumes mid-way in either Redux or RQ — on return it either
re-shows from the start or re-prompts. There is no "resume where you left off."
Discriminator 1 — did the action PATCH the server? If yes (finish/dismiss →
no-tour), the cache must match the patched server; a stale cache that still says"show" is a bug (tour flashes back on the next remount). If no (start-from-modal
or abandon-mid-tour), the server still says "show," the cache correctly still says
"show," and re-showing on return is the intended re-prompt.
→ Fix:
invalidateQueriesin the end-tour mutations'onSuccess(the standardRQ pattern; also the most faithful, since the original re-fetched fresh rather than
hand-patching local state). Keeps the cache in sync with the PATCH the way Redux's
dispatch(disable*)kept the store in sync.onStartTourdeliberately fires nomutation, so the intended re-prompt is untouched.
Discriminator 2 — remount (navigation) or no remount (focus)? On navigation the
component unmounts, local tour state is gone, and a re-derived modal is a clean
prompt. On focus nothing unmounts — the local tour is still running — so a
focus-triggered
setTourDatapops the modal on top of the live tour. Theoriginal never refetched on focus (its fetch effect keyed on
[proctoringPanelStatus]).→ Fix:
refetchOnWindowFocus: falseonuseTourData.Discriminator 3 — is the "show" server-backed or client-only? Server-backed
persistence lives in the query cache, survives a page reload (re-fetched), and is
kept. The client-only
showNewUserCourseHomeTourlived only in Redux memory: itsurvived SPA tab-switches but died on any page reload — an in-memory artifact,
not durable state, and untested/undocumented. Its only observable effect was the
replay button re-launching the tour on every outline visit within one page load,
inconsistent with the server-driven tours (which re-prompt via the modal).
→ Decision: don't replicate it. Per-
TabPageTourProviderresets clientflags on navigation, so the replay button is a clean one-shot per visit. We keep
every server-backed "show"; we drop only the in-memory replay-flag's survival
across a tab switch (which never survived a reload anyway).
The
ProductTours.test.jsxsuite only asserts single-mount behavior (server data →correct tour on load; launch button → tour) and the 401/403/404 empty cases —
nothing about cross-navigation persistence — so both fixes and the Discriminator-3
drop leave every defined behavior intact.
Tests
Tour data still flows through the existing
axios-mock-adaptersetup (the same waythe rest of these suites mock course-metadata/outline/proctoring, all still Redux
until Phase 3) —
useTourData(RQ) hits the same mockedtourDataUrl, so nojest.mockof the hook/api is needed. The only structural change is supplying thetour context.
TourProvider, mirroringTabPage. The provider livesin
TabPagein production (it must — the sr-onlyLaunchCourseHomeTourButtonisa direct child of
TabPage, outsideLoadedTabPage, soLoadedTabPagewouldbe too low). These suites render
LoadedTabPage/OutlineTabdirectly, bypassingTabPage, so each render is wrapped in<TourProvider>to reproduce thatboundary. Chosen over mocking the tour components (
ProductTours,LaunchCourseHomeTourButton): the wrap is one line, less fragile (a new tourconsumer just works), and keeps the real components in the tree.
ProductTours.test.jsx: unchanged assertions; theCourse Home Toursblockwraps its
LoadedTabPagerender inTourProvider; theCourseware Tourblockneeds nothing (it renders
CoursewareContainer→ the realTabPage→ provider).OutlineTab.test.jsxrendersOutlineTab(→
CourseTools→ the launch button) and, in two masquerade-banner tests,LoadedTabPage;ProgressTab.test.jsxrendersLoadedTabPagein four tests.Those render sites are wrapped in
TourProvider(progress tab never fetches —shouldFetchTourDatais false off outline/courseware). Bare<ProgressTab/>/<OutlineTab/>-only renders with no tour consumer are left alone.Verify
npm run types✅ clean ·npm run lint✅ clean ·npm run build✅ clean(only the pre-existing webpack asset-size-limit warnings) · full suite ✅
102 suites / 888 passed / 3 skipped / 0 failed. (
Course.test's "displayslearner tools … /previous/" is a pre-existing sequence-nav timing flake under
full-suite load — passes in isolation and on re-run; unrelated to tours.)
TourContext.test.tsxanddata/apiHooks.test.tsx(unittests for the reducer/provider and the query/mutations) and extended
ProductTours.test.jsxwith the tour complete/dismiss/abandon flows + thestreak/non-tour-tab guards, to bring patch coverage up to codecov's
autotarget. New-user completion advances the checkpoints via a recursive helper
(not a
while+awaitloop) to satisfyno-await-in-loopwithout a disable;interactions use
userEvent.git grep 'state.tours'→ empty;toursreducer gone fromstore.tsandsetupTest.js;data/{slice,thunks,index}.jsdeleted.ProductToursnow needing a provider +a query client):
OutlineTab/ProgressTabwrap their tab-content renders inTourProvider;DatesTab/DiscussionTab/CoursewareContainer(which buildtheir own tree with RTL
render) add aQueryClientProvidermirroringindex.jsx.config. It anchors to
#courseware-sequence-navigation, which is rendered bySequenceNavigation— now behindSequenceNavigationSlot, an empty-by-defaultPluginSlot(Sequence.jsx:198). So the anchor isn't in the DOM by default andthe checkpoint has nothing to point at, on
masteras well. The conversiontargets the same anchor as the original; verified live by injecting the default
SequenceNavigationinto the slot viaenv.config.jsx(keepDefault+PLUGIN_OPERATIONS.Insert), after which the tour renders and anchors correctly.skip; ✅ existing-user tour; ✅ launch/replay button; ✅ courseware tour (via slot
injection — see above); ✅ end-tour PATCH + invalidate GET on completion/dismiss;
✅ Fix A — dismiss → navigate away/back → modal does not flash back;
✅ Fix B — window focus mid-tour → no GET, modal does not reopen; ✅ fetch
gate —
/v1/<username>GET fires only on Home + Courseware (not Dates/Progress/Discussion; the
discussion_toursendpoint is the unrelated UserDiscussionsToursfeature).
🤖 Generated with Claude Code