refactor: simplify the course-home tour button - #1993
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-dates-tab #1993 +/- ##
=========================================================================
Coverage 92.87% 92.87%
=========================================================================
Files 363 363
Lines 5938 5938
Branches 1418 1381 -37
=========================================================================
Hits 5515 5515
Misses 403 403
Partials 20 20 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Remove the vestigial `metadataModel` prop from the shared TabPage. Its only live effect was gating the screen-reader-only launch-tour button, which is only ever real on the outline tab — gate that on `activeTabSlug === 'outline'` instead and rename the helper to `renderSrOnlyTourButton`. Drop `metadataModel` from LoadedTabPage and its dead pass-through to StreakCelebrationModal, and from the DatesTab / CoursewareContainer / TabContainer call sites. Move LaunchCourseHomeTourButton's `courseId` from the Redux slice to `useParams`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
bsmith/react-query-tour-button-cleanup
branch
from
August 11, 2026 20:23
6980715 to
2d7d34e
Compare
brian-smith-tcril
marked this pull request as ready for review
August 11, 2026 20:23
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
Simplify the course-home tour button on the shared
TabPageby removing the vestigialmetadataModelprop. Part of the Redux → React Query migration (#1946); a small, behavior-preserving refactor that lands below the outline-tab conversion (#1991) so outline builds on the cleaned-upTabPageand inherits thecourseIdfix. Closes #1992.Investigation showed the screen-reader-only launch-tour button
TabPagerenders is only ever real on the outline tab: the tour data is fetched only on outline/courseware (long-standing — predates the React Query migration), and course tabs are full page loads, so nothing warms across tabs.metadataModelwas a boolean-in-disguise ("is course-home") whose only live effect was gating that button, plus dead threading intoLoadedTabPage→StreakCelebrationModal.What changed
TabPage: gate the srOnly launch button onactiveTabSlug === 'outline'— the tab identityTabPagealready has (it uses it for the access-denied redirect) — instead ofmetadataModel === 'courseHomeMeta'. RenamerenderTourButton→renderSrOnlyTourButton, and remove themetadataModelprop. The above-the-header placement (the a11y intent) is unchanged.metadataModelremoved end to end: fromLoadedTabPageand its dead pass-through toStreakCelebrationModal(declared required, never read), and from theDatesTab/CoursewareContainer/TabContainercall sites.LaunchCourseHomeTourButton:courseIdmoves from thecourseHomeRedux slice touseParams, so the visible outline button keeps a realcourseIdonce a converted tab stops populating the slice.Behavior
No user-facing change. The srOnly button already only materialized on outline (full page loads keep it inert everywhere else), so gating the mount to outline matches reality, and it still renders above the header. The gate behaves identically whether outline is
TabContainer-rendered or self-wrapped — both passactiveTabSlug="outline".Testing
npm run types,npm run lint, and the fullnpm testsuite (106 suites, 902 passing, 3 pre-existing skips) pass.TabPage.test.jsxgains two tests for the new gate (renders the srOnly button on outline, not on other tabs); the call-site test fixtures drop the removedmetadataModelprop.Decisions
Full decision log
Findings — what actually renders the course-home tour button
Decision doc for the tour-button cleanup PR — #1993 (sub-issue #1992):
removes the vestigial
metadataModelprop and moves the button'scourseIdoffthe Redux slice. Not checked in. The behavior described below is
masterasof the investigation; the "Agreed cleanup approach" section is what shipped.
The two render sites of
LaunchCourseHomeTourButtoncourse-home/outline-tab/widgets/CourseTools.jsxrenders<LaunchCourseHomeTourButton />.CourseToolsis rendered only by theoutline tab, so the visible button is outline-only.
tab-page/TabPage.tsxrenderTourButton()renders<LaunchCourseHomeTourButton srOnly />, gated bymetadataModel === 'courseHomeMeta'(i.e. all course-home tabs, notcourseware) and
shouldRenderContent. It's placed above<HeaderSlot>in the DOM — deliberate reading-order placement inherited from fix: remove launch tour from header #775 (see
History).
What decides whether the button emits any DOM
LaunchCourseHomeTourButtonwraps its entire output in{toursEnabled && (…)}— when
toursEnabledis falsy it mounts (its hooks run) but renders an emptyfragment (no DOM).
toursEnabledchain:toursEnabled = tourData?.toursEnabled, wheretourDatacomes fromuseTourData(username, false)— a disabled query. The button neverfetches; it only reads the shared cache entry (
tourQueryKeys.user(username)).ProductToursviauseTourData(username, shouldFetchTourData()).shouldFetchTourData()(inProductTours.jsx) is true only when:authenticated AND the active tab is outline or courseware AND
(on outline)
proctoringPanelStatus === 'loaded'. Its own comment: "Toursonly exist on the Outline and Courseware tabs, so avoid calling the tour
endpoint on any other tab."
getTourData(/api/user_tours/v1/{username}) →{ toursEnabled: true, … }on 200;
{ toursEnabled: false }on 401/403/404 (403 = tour waffle flag off).The navigation model is the key fact
Course tabs are plain anchors:
course-tabs/CourseTabLink.tsxrenders<a href={url}>whereurlis the backend-suppliedcourseHomeMeta.tabs[].url(a full URL — tabs can span MFEs). So switching tabs is a full page load,
not client-side routing. React Router does not intercept a plain
<a href>.Consequences:
state.courseHome.courseIdstarts at its initial
null) and a fresh React Query cache.fetched or set survives the navigation.
courseIdis onlyever whatever the current page populated.
The button's data dependencies
courseId←useSelector(state.courseHome)(Redux, transitional). On apage that doesn't run a
fetchTab*thunk, this isnull.org←useModel('courseHomeMeta', courseId).toursEnabled← the shared (cold-per-page) RQ cache described above.Net effective behavior (the shape)
courseIdset on this page?metadataModelgate)fetchOutlineTab);nullafter conversionfetchTab) but no fetch/visible btncoursewareMeta)Conclusion: the launch-tour button is only ever "real" on the outline tab.
On every other course-home tab the srOnly instance mounts but can never emit DOM
(full page load ⇒ no warmed cache ⇒
toursEnabledundefined). ThemetadataModel === 'courseHomeMeta'gate thus mounts a dead button in fourplaces, and on outline the srOnly button is redundant with the visible
CourseToolsone.History (why it looks like this)
feat: new user course home tour, 2020) — introduced the tour, thevisible button in
CourseTools, and the srOnly button. Per theauthor's inline review comment on
Header.jsx(on the diff, not the PRconversation thread — easy to miss): "This functions like a 'Skip to main
content' link. Just prompts users to launch the tour if they'd like because
the 'launch tour' button is pretty hidden in the DOM under 'Course Tools'.
Might need to revisit this w/ Jeff Witt to take a second pass at the a11y here,
but this is what we agreed on for now." So the srOnly button was a
skip-link-style a11y aid, added because the visible launch button is
buried under
CourseTools(an outline concern), and explicitly flagged asprovisional pending an a11y review that (per the code) never happened. The
tour-data fetch was already gated to outline/courseware here:
userIsAuthenticated && (isCoursewareTab || (isOutlineTab && proctoringPanelStatus === 'loaded')).fix: remove launch tour from header, Dec 2021) — the localHeaderwas being replaced by the shared@edx/frontend-component-header(no tour logic), so the srOnly button was lifted out of the header into
TabPage, placed above it to preserve the top-of-DOM reading order. Thiscommit added the
metadataModel === 'courseHomeMeta'gate and hardcoded thebutton's
useModel(...)to'courseHomeMeta'(before this,metadataModelactually selected the model — hence the prop's later vestigial drift).
fetch gate: the pre-existing
dispatch(fetchTourData)condition was movedverbatim into
shouldFetchTourData()and passed as the RQ query'senabledflag. It did not change the gating or the behavior.
Corrected conclusion (an earlier draft of this doc/analysis got this wrong):
the srOnly button being effectively outline-only is long-standing, not
something the React Query migration caused — the outline/courseware fetch gate
predates #1968 (identical in the Redux version). And per the #750 rationale the
srOnly button was really an outline a11y aid all along (it substitutes for
the visible button that lives under outline's
CourseTools). So the accurateframing is over-broad mounting, not inversion:
metadataModelmounts it onall five course-home tabs, but it only ever materializes on outline (where it's
redundant with the visible one) and is harmlessly inert elsewhere.
Empirical confirmation (courseware): with the tour armed and the sequence-nav
slot filled, the courseware page shows the courseware tour overlay
(
#pgn__checkpoint) but no srOnly launch button — the onlysr-only sr-only-focusableelement is the shared header's#main-contentskip-nav link. Consistent with the
metadataModelgate excluding courseware.The courseware tour is a separate mechanism (and also dead-by-default)
Distinct from the launch button. The courseware tour is a guided
ProductTouroverlay, not button-launched:ProductTours:if (coursewareTabActive && showCoursewareTour) setIsCoursewareTourEnabled(true)→ renderscoursewareTour(...).showCoursewareTour←TourContextfrom the tour data'sshowCoursewareTour(camelCased from the API's
show_courseware_tour).show_courseware_tour: false(useEndCoursewareTour).coursewareTourhas a single checkpoint targeting#courseware-sequence-navigation(product-tours/CoursewareTour.jsx).Why it doesn't show on a stock install:
#courseware-sequence-navigationlives only in
SequenceNavigation.jsx, which is rendered nowhere directly —it's the would-be content of
SequenceNavigationSlot(
org.openedx.frontend.learning.sequence_navigation.v1), aPluginSlotthat isempty by default. No slot fill ⇒ no nav element ⇒ the ProductTour has no
anchor ⇒ nothing renders, regardless of
show_courseware_tour. (Confirmed: thecourseware tour appears once the slot is filled — see recipe below.)
Re-arm + view recipe (local dev)
UserTourmodel (lms/djangoapps/user_tours/models.py):show_courseware_tour(BooleanField, default
True) andcourse_home_tour_status(choices:show-new-user-tour/show-existing-user-tour/no-tour).Re-arm in the LMS Django shell:
Fill the sequence-nav slot so the courseware tour has its anchor (untracked
env.config.jsx, requires a dev-server restart):Implications for the cleanup
metadataModelinTabPageis a boolean-in-disguise ("is course-home") whoseonly live effect is gating a button that's only real on outline — plus it's
threaded dead into
LoadedTabPage→StreakCelebrationModal(declaredrequired, never used).
affordance needs to exist at all, and if so where — rather than leaving it
mounted-but-dead on four tabs.
HeaderSlotfor the a11y readingorder (the fix: remove launch tour from header #775 intent) — which argues against pushing it down into tab
content.
LaunchCourseHomeTourButton'scourseIdmust move fromuseSelector(state.courseHome)touseParams— but note it only matterson outline (the one tab where the button is real and, post-conversion, would
otherwise read a
nullslicecourseId).Agreed cleanup approach
Its own stack layer, below the outline conversion (so outline builds on the
cleaned
TabPageand inherits thecourseIdfix). Keep the srOnly affordance —no a11y removal (that would need a deliberate a11y-reviewed change, per the
never-done #750 "revisit w/ Jeff Witt" note); this cleanup only makes the code
say what's already true, with no user-facing behavior change.
TabPage.tsx— gate the srOnly button onactiveTabSlug === 'outline'(not
metadataModel, not a new boolean).activeTabSlugis already a prop;this directly encodes the headline finding ("only ever on outline") and is
behavior-preserving (the button already only materialized on outline —
full page loads keep it inert elsewhere). Comment it, citing the a11y
rationale inline via the feat: engage product tour #750 review-comment link. Rename
renderTourButton→renderSrOnlyTourButtonto make the srOnly variant explicit. Keep it aboveHeaderSlot.metadataModelend to end — the prop onTabPageandLoadedTabPage, the dead threading intoStreakCelebrationModal(destructuremetadataModel=...at every call site(
DatesTab,CoursewareContainer,TabContainer's`${slice}Meta`).LaunchCourseHomeTourButton.jsx—courseId:useSelector(state.courseHome)→useParams(the de-Redux fix; matters forthe visible outline
CourseToolsbutton).TabPagetests for the new gate (srOnly button renderson outline, not on other tabs); drop the removed
metadataModelprop from thecall-site fixtures (
LoadedTabPage,StreakCelebrationModal,ProductTours,ProgressTab,OutlineTab).Works whether outline is still
TabContainer-rendered or converted: both passactiveTabSlug="outline"for the outline route, so the gate behaves identically.