refactor: stop the sequence query refetching from components under the gate - #2124
Merged
Merged
Conversation
brian-smith-tcril
added this pull request to stack #2121
September 25, 2026 10:00
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2124 +/- ##
=======================================
Coverage 94.85% 94.85%
=======================================
Files 370 370
Lines 5985 5989 +4
Branches 1466 1468 +2
=======================================
+ Hits 5677 5681 +4
Misses 295 295
Partials 13 13 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This was referenced Sep 25, 2026
brian-smith-tcril
force-pushed
the
bsmith/sequence-gate-readers
branch
from
September 28, 2026 05:46
933efbc to
a352029
Compare
brian-smith-tcril
force-pushed
the
bsmith/sequence-gate-readers
branch
3 times, most recently
from
September 28, 2026 06:22
2dcf5d0 to
2a32deb
Compare
Base automatically changed from
bsmith/discussion-topics-query-reads
to
master
September 28, 2026 07:02
…e gate
`useSequenceMetadata` sets no `staleTime`, so its data is stale on arrival and
any fetching observer that mounts after it settles refetches it. Five observers
sit under `CoursewareContainer` and mount later: `Sequence`, `SequenceNavigation`,
`CourseBreadcrumbs`, the two sequence alerts and `useSequenceNavigationMetadata`.
In the container suite's request-count case that is two
`/api/courseware/sequence/{id}` requests per load. Same defect #2098 fixed for
the three course-level queries, same fix: the owner fetches, observers under it
only read.
- `useSequenceMetadata` takes `{ enabled }` (the module's `QueryOptions`),
default `true`; the five under-gate observers pass `{ enabled: false }`.
`CoursewareContainer` and `useCoursewareRedirects` keep fetching.
- `MountCourseQueryHooks` takes an optional `sequenceId` and mounts the sequence
query with it, so the suites that render one of those components alone mount
the owner beside it instead of relying on the component's own observer.
- New cases: the disabled hook fetches nothing on its own and reads the owner's
result without fetching again; the container requests the sequence metadata
once per load (was two). With the five observers flipped back, exactly that
case fails.
- `Course.test.jsx`: the discussions-trigger click now awaits the trigger,
which renders when the topics query resolves, independently of the outline
sidebar the case was waiting on; the earlier sequence arrival exposed the
race.
Part of #1946. Closes #2123.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
bsmith/sequence-gate-readers
branch
from
September 28, 2026 07:03
2a32deb to
672f55f
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
useSequenceMetadatasets nostaleTime, so its data is stale on arrival, and React Query's defaultrefetchOnMountrefetches whenever a fetching observer mounts onto stale data.CoursewareContaineranduseCoursewareRedirectsobserve the sequence query from the first render; five more fetching observers sit under the gate and mount later —Sequence,SequenceNavigation,CourseBreadcrumbs, the twosequence-alertshooks anduseSequenceNavigationMetadata— and each refetches on mount. Measured: threeapi/courseware/sequence/{id}requests per unit load on tutor dev (two in the jest harness), one after this change. It is the defect #2098 fixed for the three course-level queries, with the same fix: the owner fetches, observers under it only read. No learner-visible change and no data change; the request count is the only intended difference. Peeled out of #2088's plan review because both of that issue's layers convert every one of these five readers, so the fix would otherwise have landed in passing. Part of the Redux → React Query migration (#1946, Stage 1); fifth layer of stack #2121, on top of #2122. Closes #2123.What changed
useSequenceMetadata(sequenceId, { enabled })incourseware/data/apiHooks.ts, the module's ownQueryOptionsshape (useCoursewareMetadata/useCoursewareOutline), defaulttrue,enabled: enabled && !!sequenceId. Key,queryFn,retryandmetaare unchanged.CoursewareContainer.tsx:34andredirects.ts:251keep the default and remain the owner.{ enabled: false }—Sequence.jsx,SequenceNavigation.jsx,CourseBreadcrumbs.jsx,alerts/sequence-alerts/hooks.js(both hooks),sequence-navigation/hooks.js. Each keeps itssequenceQueryvariable for theisPending/isSuccess/isErrorreads it already makes; only the call gains the option. NouseModelline changes; those are Read units and sequences from the courseware queries, not useModel #2088's.src/tests/MountCourseQueryHooks.tsxtakes an optionalsequenceIdand callsuseSequenceMetadata(sequenceId)unconditionally (the hook is disabled forundefined), so the suites that render one of those components alone mount the owner beside it — the shape Stop the courseware gate queries refetching from components under the gate #2098 introduced for the course queries (decision 3).Sequence,SequenceNavigation,UnitNavigation,CourseBreadcrumbs,Courseandcourse/test-utils.jsxpass the sequence id to the mount helper.Sequence.test's load-failure case mounts the owner under its failing adapter, and the navigation suite's is empty while loading keeps the owner so it passes for the right reason (decision 4).CoursewareContainer.testandProductTours.testrender the container itself and are unchanged.apiHooks.test.tsx: fetches nothing on its own when disabled and reads the owner's result without fetching again, theuseIsCourseLoadedpair on the sequence hook.CoursewareContainer.test.jsx: requests the sequence metadata once per load beside Stop the courseware gate queries refetching from components under the gate #2098's course-metadata case — two on the layer below, one here. Negative check, run: with the five observers flipped back, exactly that case fails; the hook pair correctly stays green, since it pins the option rather than the call sites.Course.test.jsx. opens discussions when the user clicks its trigger, closing the outline clicked the discussions trigger with a synchronousgetByRoleafter waiting on the outline sidebar; the trigger renders when the topics query resolves, independently of the outline. With the sequence fetch now above the gate the outline settles a round earlier and the lookup missed. The click awaitsfindByRole(decision 8).Testing
npm run typesandnpm run lintclean; full suite 116 suites, 1175 passed, 0 skipped. Manual checks on tutor dev: 11 of 15 run, all passing — see the checklist below, including the request counts before and after.Decisions
Full decision log
Decisions — stop the sequence query refetching from components under the gate (#2123)
Peeled out of the #2088 plan review (2026-09-25) as its own issue and layer,
the way #2098 was for the course queries; the fifth layer of the running
stack #2121, on top of #2122 (#2087), branch
bsmith/sequence-gate-readers.Entries 1–7 were proposed in the plan review (2026-09-25, posted to #2123);
8–9 landed with the code.
The change, file by file
src/courseware/data/apiHooks.ts.useSequenceMetadata(sequenceId, { enabled = true }: QueryOptions = {}),enabled: enabled && !!sequenceId,the shape
useCoursewareMetadata/useCoursewareOutlinealready have(
:29-37,:39-45). Nothing else in the hook changes: same key, samequeryFn, sameretry: false, samemeta.The five under-gate observers pass
{ enabled: false }. Each keeps itssequenceQueryvariable for theisSuccess/isPending/isErrorreadsit makes today; only the call gains the option.
courseware/course/sequence/Sequence.jsxisPending,isSuccess,sequenceMightBeUnit(sequenceQuery)courseware/course/sequence/sequence-navigation/SequenceNavigation.jsxisSuccesscourseware/course/breadcrumbs/CourseBreadcrumbs.jsxisSuccessalerts/sequence-alerts/hooks.jsisSuccesscourseware/course/sequence/sequence-navigation/hooks.jsisSuccessCoursewareContainer.tsx:34andredirects.ts:251are untouched and keepfetching. No
useModelline changes anywhere; those are #2088's.src/tests/MountCourseQueryHooks.tsxtakes an optionalsequenceIdandcalls
useSequenceMetadata(sequenceId)when given one (useQueryisdisabled for
undefined, so an unconditional call with the optional prop isfine). Its comment says it mounts what
CoursewareContainermounts.Suites. Every suite that renders one of the five components alone gets
its sequence data from the component's own observer today; after this
change that observer is disabled, so each mounts the owner with the
sequence id:
Sequence.test.jsxSidebarWrapperand two inline renders mountMountCourseQueryHooks courseId(:57,:152,:451); displays error message on sequence load failure (:173) rendersSequencealone under a failing adaptersequenceIdto the three mounts; the failure case mounts the owner besideSequenceunder the same failing mock, at a route with:sequenceIdSequenceNavigation.test.jsxrenderNavmountsMountCourseQueryHooks courseId(:45)sequenceId(the resolved onerenderNavalready computes)UnitNavigation.test.jsxrenderNavmountsMountCourseQueryHooks courseId(:48)sequenceId; renders correctly without units passes'', which stays disabled as todayCourseBreadcrumbs.test.jsxrenderWithProvidermountsMountCourseQueryHooks courseId(:51)sequenceId={props.sequenceId}Course.test.jsx,course/test-utils.jsxMountCourseQueryHooks courseId(:66,:37);SequenceunderCoursefetched for itselfsequenceIdCoursewareContainer.test.jsx,ProductTours.test.jsxuseIFrameBehavior.test.jssequence-navigation/hooksoutright (:55)renders correctly without data (
Sequence.test.jsx:77,sequenceIdundefined) is unchanged: the query was already disabled for a missing id.New tests.
apiHooks.test.tsx,useSequenceMetadatadescribe: fetches nothing onits own and reads the owner's result without fetching again, the
useIsCourseLoadedpair (:219-238) on the sequence hook with{ enabled: false };makeWrapper(client, pathname)already gives therouter the hook needs.
CoursewareContainer.test.jsx,request countdescribe: requests thesequence metadata once per load, beside Stop the courseware gate queries refetching from components under the gate #2098's course-metadata case
(
:538), filteringaxiosMock.history.geton/api/courseware/sequence/{defaultSequenceBlock.id}. Measured onmasterwith a throwaway copy of that case: 2 requests; expectedafter: 1.
Negative check, run: with the five
{ enabled: false }flipped back,exactly one case fails — requests the sequence metadata once per load —
across the hooks, container, sequence, navigation, breadcrumbs and Course
suites (155 of 156 pass). The two hook cases stay green, correctly: they pin
the option on
useSequenceMetadata, not the call sites, and would fail onlyif the option itself were removed.
Entries
{ enabled }onuseSequenceMetadatauses the module's existingQueryOptions(Stop the courseware gate queries refetching from components under the gate #2098, entry 1: declared locally incourseware/data/apiHooks.ts, not shared). Defaulttrue, soCoursewareContainerandredirectsneed no change.The disabled readers carry no comment (Stop the courseware gate queries refetching from components under the gate #2098, entry 4):
{ enabled: false }is React Query's own vocabulary, and the owner is recorded hereand in
MountCourseQueryHooks' comment, where the suites mirror it.MountCourseQueryHookstakes an optionalsequenceId(settled as P2in the Read units and sequences from the courseware queries, not useModel #2088 plan review): one helper mirroring
CoursewareContainer,which owns all four queries. Stop the courseware gate queries refetching from components under the gate #2098's entry 3 named it course "as opposed
to the sequence query
CoursewareContaineralso mounts" because its threesuites did not need it, not as a rule. Rejected: a sibling
MountSequenceQueryHook, two helpers for one production owner.Suites produce loading, failure and gated states through the owner,
not by rendering the component without one. A disabled observer with
nothing fetching stays
pendingforever, so is empty while loading(
SequenceNavigation.test.jsx:56) would pass for the wrong reasonwithout the owner, and displays error message on sequence load failure
(
Sequence.test.jsx:173) would never reach the error. Each renders theowner beside the component; the failing case keeps its own adapter and
the owner fetches through it. This is the Stop the courseware gate queries refetching from components under the gate #2098 entry-3 shape ("the
owner fetches, the components under it read") applied to the fourth
query.
Commit:
refactor:(Stop the courseware gate queries refetching from components under the gate #2098, entry 2: restructures who fetches, nobug,
perf:unused in this repo). Title: stop the sequence queryrefetching from components under the gate. No
!: no plugin-facingchange —
useSequenceMetadata's new parameter is optional and defaultsto today's behaviour.
staleTimestays at the default. The owner's refetch on a newsequence is where
position,completeandbookmarkedare read, andRead units and sequences from the courseware queries, not useModel #2088 relies on the entry being the live one. Out of scope, as Stop the courseware gate queries refetching from components under the gate #2098
recorded for the course queries.
The manual test names the second requester. The jest count says
two; the browser trace (initiator column) says which under-gate observer
sends the second request and whether there is a third in a real render,
and the manual-testing doc records that, not just the count.
One pre-existing race in
Course.test.jsxsurfaced and is fixed here.opens discussions when the user clicks its trigger, closing the outline
waited for the outline sidebar and then clicked the discussions trigger
with a synchronous
getByRole. The trigger renders only once the topicsquery resolves (Read discussion topics from the query, not useModel #2087's manual test recorded "the trigger appears once the
topics request resolves"), independently of the outline. With the owner
mounted above
Course, the sequence data now lands earlier relative tothe topics response, the outline appears first, and the lookup missed.
The click now awaits
findByRole, no comment. The neighbouring cases wait on the discussions sidebar itselfand were unaffected. Same family as Un-awaited
waitForandactcalls inCourse.test.jsxandSequence.test.jsxlet tests pass without asserting #2118, kept here because this layeris what exposed it.
MountCourseQueryHookscallsuseSequenceMetadata(sequenceId)unconditionally. The hook is already disabled for an
undefinedid(
enabled: enabled && !!sequenceId), so the optional prop needs nobranch; a conditional hook call would also break the rules of hooks.
Manual testing
Checklist
Manual testing — stop the sequence query refetching from components under the gate (#2123)
In-browser verification against a live backend (tutor dev).
What changed:
useSequenceMetadatatakes{ enabled }, and the fiveobservers under
CoursewareContainer—Sequence,SequenceNavigation,CourseBreadcrumbs, the two sequence alerts anduseSequenceNavigationMetadata— pass{ enabled: false }, so onlyCoursewareContaineranduseCoursewareRedirectsfetch the sequencemetadata. No reader changes, no data changes; the only intended difference is
the request count.
The bugs this layer could introduce. (1) A component that never sees
the data — a disabled observer with nothing fetching its query stays
pendingforever. Every one of the five renders underCoursewareContainer,which fetches, so the sequence, its navigation, breadcrumbs and alerts should
render exactly as before; a spinner that never clears, or a missing
breadcrumb / alert, means that assumption is wrong somewhere. (2) A stale
sequence — the owner's refetch on navigating to a new sequence is what
brings in
position,completeandbookmarked; if the owner somehowstopped refetching, the unit navigation would show the previous sequence's
state. (3) The count did not drop — an observer was missed, or the second
request comes from somewhere not in the five.
Setup
A course with at least two sections, each with two or more sequences, some
with several units. One sequence with a banner text and, if available, a
prerequisite-gated sequence and a timed exam. Browser devtools open on the
Network tab filtered to
courseware/sequence, with the Initiator columnvisible, and on the Console.
Checks
Request count (bug 3) — the #2098 protocol
master(or the layer below): hard-reload a unit, wait for the Network tab to go idle:api/courseware/sequence/{id}count = 3. Initiator of each request beyond the first: ___.courseware/sequencerequest./course/{id}/{sequenceId}with no unit: one request; the redirect lands on the active unit.The components see the data (bug 1)
ENABLE_JUMPNAV) lists the sequences.useSequenceMetadata,sequenceMightBeUnitorMountCourseQueryHooks.Owner still refetches (bug 2)
Results
Run 2026-09-25 on tutor dev. 11 of 15 checks run, all passing; 4 not run.
Run
bsmith/discussion-topics-query-reads),a hard reload of a unit requested
api/courseware/sequence/{id}3times (the jest harness shows 2; a real render mounts more observers after
the data settles). On this layer the same reload requested it once.
Another unit in the same sequence: no new request. A unit in another
sequence, by next/previous and by the sidebar: one request, for the new
sequence.
/course/{id}/{sequenceId}with no unit: one request, and theredirect landed on the active unit. The initiators of the two extra
baseline requests were not recorded.
the unit tabs and previous/next.
useSequenceMetadata,sequenceMightBeUnitor
MountCourseQueryHooks.sequence and returning, the completion tick was present; after
bookmarking a unit, leaving the sequence and returning, the bookmark icon
was present on its tab.
Not run
CourseBreadcrumbs.test.jsx(4 cases) renders the component under the owner mount this layer added,
the same shape as production.
Covered by renders banner text alert in
Course.test.jsx, which rendersCourseunder the owner mount, so the alert hook's disabled observer isexercised.
renders correctly for gated content in
Sequence.test.jsxand renderslocked button for gated content in
SequenceNavigation.test.jsx, bothunder the owner mount.
SequenceExamWrapperreceivesthe
sequencemodel fromuseModel, which this layer does not touch, andthe exam library is mocked in
Course.test.jsx; not covered by thislayer's tests either, by design of the layer (no reader change).
🤖 Generated with Claude Code