refactor!: stop the progress tab data refetching from components under the tab - #2106
Merged
Merged
Conversation
brian-smith-tcril
added this pull request to stack #2080
September 23, 2026 11:26
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2106 +/- ##
=======================================
Coverage 93.95% 93.95%
=======================================
Files 367 367
Lines 5937 5939 +2
Branches 1391 1435 +44
=======================================
+ Hits 5578 5580 +2
Misses 346 346
Partials 13 13 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
brian-smith-tcril
marked this pull request as ready for review
September 23, 2026 11:32
brian-smith-tcril
force-pushed
the
bsmith/progress-tab-readers-no-refetch
branch
3 times, most recently
from
September 23, 2026 11:44
e9f0220 to
c843c7b
Compare
This was referenced Sep 23, 2026
Base automatically changed from
bsmith/progress-tab-owns-legacy-redirect
to
master
September 23, 2026 14:04
…r the tab One load of the progress tab requested `/api/course_home/progress/` twice. `ProgressTab` fetches it and gates on it, and every component under the gate reads it through `useProgressData()`, which was a fetching observer of the same query: 22 call sites, 21 files under `course-home/progress-tab/` plus the grade-breakdown slot. They mount in one commit after the data has landed, find it stale at the default `staleTime` of 0, and refetch. React Query dedupes them against each other, which is why the count was two and not twenty-two, but not against the owner's already-settled fetch. This is the defect #2083 fixed on the outline and dates tabs and #2098 fixed on the courseware gate, and it gets the same fix: the owner fetches, the readers under it subscribe without fetching. `useProgressTabData` takes the course-home `{ enabled }` option, and `useProgressData()` passes `{ enabled: false }`; it takes no option of its own, since every caller renders under `ProgressTab`, which fetches the same key from the same route params. A disabled observer returns the query's current state and re-renders when the owner's fetch lands, so every reader sees the same value as before. `ProgressTab` keeps fetching. A `staleTime` would have hidden the reader fetches without deciding who owns them, and would have changed the owner's freshness too: the progress payload changes server-side with no app action, and `useRequestCert`, which renders under this tab, relies on refetch-on-mount to resync. `useExamsData()`, the plugin-facing hook in the same module, reads through `useProgressData()` and inherits the change. Its documented use, a widget in a progress-tab slot, renders under `ProgressTab` and returns exactly what it did; the README now says so, and says how a widget outside the tab fetches the data itself. #2104, the layer below, moved the progress query's 404 redirect out of the `queryFn` so that fetch has no side effect. Tests: a disabled case for `useProgressTabData`; a `useProgressData` describe with the two cases `useIsCourseLoaded` has (fetches nothing on its own; reads the owner's result without fetching again), sharing the route wrapper with the `useExamsData` cases; and a request count in `ProgressTab.test.jsx` that waits for the client to go idle and then counts one progress request (two before this change). BREAKING CHANGE: `useExamsData` and `useProgressData` (./src/course-home/progress-tab/hooks) no longer fetch the progress data themselves; they read what `ProgressTab` has fetched. In a progress-tab slot, the documented use, they return exactly what they did. Rendered anywhere else they now return null / undefined unless something rendered on the page fetches the progress data — for example, a widget calling `useProgressTabData(courseId)` from ./src/course-home/data/apiHooks; see src/course-home/progress-tab/README.md. Part of #1946 (Stage 1). Closes #2103. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
bsmith/progress-tab-readers-no-refetch
branch
from
September 23, 2026 14:05
c843c7b to
2851dbe
Compare
brian-smith-tcril
deleted the
bsmith/progress-tab-readers-no-refetch
branch
September 23, 2026 14:11
This was referenced Sep 23, 2026
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
One load of the progress tab requested
/api/course_home/progress/twice.ProgressTabfetches it and gates on it; everything under the gate reads it throughuseProgressData(), which was also a fetching observer of the same query — 22 call sites, 21 files undercourse-home/progress-tab/plus the grade-breakdown slot. They mount in one commit after the data has landed, find it stale at the defaultstaleTimeof 0, and refetch. React Query dedupes them against each other (two requests, not twenty-two) but not against the owner's already-settled fetch.This is the defect #2083 fixed on the outline and dates tabs and #2098 fixed on the courseware gate, and it gets the same fix: the owner fetches, the readers under it subscribe without fetching.
useProgressData()never fetches;ProgressTabkeeps fetching. One request per load.Part of the Redux → React Query migration (#1946, Stage 1). Closes #2103. Stacked above #2105 (#2104), which moved the progress query's 404 redirect out of the
queryFnso that the migration step below — a plugin mounting the fetch itself — has no side effect.What changed
useProgressTabDatatakes{ enabled }, the course-homeQueryOptions, in the two-positional-plus-options shapeuseProctoringInfoDatahas. Plainenabled, no!!courseIdguard — the hook never had one and the route always supplies acourseId.useProgressData()passes{ enabled: false }and takes no option of its own — the shape Stop the courseware gate queries refetching from components under the gate #2098 settled foruseIsCourseLoaded. Every caller renders underProgressTab, which fetches the same key from the same route params, so no caller wants the reader to fetch; a default-on option would let a new reader silently reintroduce the duplicate. A disabled observer returns the query's current state and re-renders when the owner's fetch lands, so every reader sees the same value as before.staleTime. It would hide the reader fetches without deciding who owns them, and would change the owner's freshness too: the progress payload changes server-side with no app action, anduseRequestCert, which renders under this tab, relies on refetch-on-mount to resync.progress-tab/README.mdnow saysuseExamsData()reads the progress tab's own data, is for widgets in progress-tab slots, and returnsnullelsewhere unless something rendered on the page fetches that data.useProgressTabData(endpoint mocked 200, zero requests,fetchStatus === 'idle'); auseProgressDatadescribe inhooks.test.jsxwith the two casesuseIsCourseLoadedhas — fetches nothing on its own and reads the owner's result without fetching again — sharing the route wrapper with theuseExamsDatacases, whose first case now also asserts zero progress requests; and a request count inProgressTab.test.jsxthat waits for the client to go idle and then counts one progress request. All five fail against the layer below (the count reads 2 there).Plugins — breaking
useExamsData()(documented inprogress-tab/README.md) anduseProgressData(), both exported from./src/course-home/progress-tab/hooks, were fetching observers and now only read whatProgressTabhas fetched. In a progress-tab slot — the documented use — they return exactly what they did. Rendered anywhere else they returnnull/undefinedunless something rendered on the page fetches the progress data.No default serves both placements:
useExamsData()is built onuseProgressData(), so a fetching default would put the duplicate request back into the documented use. Off the tab, some rendered widget has to own the fetch — the query cache is shared, so one fetching observer on the page populates it for every reader on that page. The simplest shape is the reader mounting it alongside itself:Both sides build the same key (
targetUserIdis undefined off the progress route), so the one-argument call lands on the entry the reader hooks use. With #2105 below this layer, that fetch has no side effect: on a course whose MFE progress tab is disabled the widget's query errors and the page stays put.useExamsData's own exam-attempts query is unchanged.Testing
npm run types,npm run lintand the full suite (113 suites, 1145 passed, 3 skipped) are green.Manually verified on tutor dev as staff, with three
env.config.jsxwidgets installed: the README example in a progress-tab slot, an off-tab reader callinguseExamsData()alone, and an off-tab owner mountinguseProgressTabDatabeside it. Onecourse_home/progressrequest per load on both the plain progress URL and/progress/:targetUserId(two before this layer); header, completion chart, grade summary, detailed grades, related links and masquerade render as before; the README-example widget renders exam names with one attempt request per subsection; the off-tab reader readsnullwith no progress request; the off-tab owner reads the data with exactly one progress request. With both off-tab widgets installed the reader shows the data too — the cache is shared, so one fetching observer on the page feeds every reader — which is what the README sentence now says. Not run: certificate status and credit information (the test course produced neither), and the console was not compared against a before capture.Decisions
Working notes for this layer, kept out of the tree:
Full decision log
Decisions — stop the progress tab data refetching from components under the tab (#2103)
Layer on top of #2104 (PR #2105). The diagnosis, the ownership fix, the
non-fetching reader shape, the plugin contract and the commit type were
settled in the plan on issue #2103; entries here record choices made while
implementing, and confirm the plan's expectations against the code.
useProgressData()never fetches and takes no option. The shapedecisions-2098.mdentry 4 settled foruseIsCourseLoaded: every one ofthe 22 call sites renders under
ProgressTab, which fetches the same keyfrom the same
useParams()values, so no caller wants the reader to fetch.A default-
trueoption would let a new reader silently reintroduce theduplicate request; always-non-fetching fails loudly instead (a reader with
no owner reads
undefinedforever).useExamsData()reads through it andinherits the shape — see entry 3 for why no default serves both of its
placements.
Plain
enabled, no!!courseIdguard. Every sibling course-home hookwith the option guards on
courseId;useProgressTabDatanever has, andan undefined
courseIdcannot happen on its route. Adding the guard wouldbe a behaviour addition, not a faithful change, so the inconsistency in the
diff is deliberate.
The plugin contract narrows, and that is
refactor!:.useExamsData()(documented in
progress-tab/README.md) anduseProgressData()werefetching observers and now only read what
ProgressTabhas fetched. In aprogress-tab slot — the documented use — they return exactly what they did.
Anywhere else they now return
null/undefinedinstead of triggeringthe fetch. Keeping a fetching default so the off-tab use kept working was
rejected:
useExamsData()is built onuseProgressData(), so that shapeputs the duplicate request back into the documented use (a widget under the
tab mounts after the gate, finds the data stale, refetches). No default
serves both placements. The off-tab use gets a migration path instead —
mount
useProgressTabData(courseId)alongside the reader, the sameowner-fetches / reader-reads mechanism
src/tests/MountCourseQueryHooks.tsxuses — stated in the README sentence, the
BREAKING CHANGEfooter and thePR's Plugins section. Plugins can observe the change through the import
path the README gives, and Convert the progress-tab exam attempts fetch (
courseHome.examsData) to a React Query hook #2075's footer told them to migrate ontouseExamsData, so it gets the footer the epic's three earlierrefactor!:commits got. With Move the 404 redirect to the legacy progress page out of
getProgressTabDataand intoProgressTab#2104 below this layer, the fetch the migration notetells plugins to mount has no side effect (the 404 redirect now belongs to
ProgressTab), which is why Move the 404 redirect to the legacy progress page out ofgetProgressTabDataand intoProgressTab#2104 was sequenced first.The README sentence names the fix, not just the constraint. The README
was example-only and is the hook's only public contract. After this change
a widget calling
useExamsData()off the tab readsnullwith no errorand no log, so the placement constraint is stated where plugin authors
read, together with the one-line way out.
decisions-2098.mdentry 4'sno-comment reasoning is about in-repo readers whose owner is a few frames
up — a different audience.
Wording, after manual testing. The first draft said the hook returns
nulloff the tab "unless the widget also callsuseProgressTabData".Testing with both an off-tab reader widget and an off-tab owner widget
installed showed the reader reporting the count too: the query cache is
shared, so one fetching observer anywhere on the page populates the entry
for every reader on that page, and the owner need not be the same widget.
Removing the owner widget took the reader back to
null. The sentencenow says "unless something rendered on the page fetches that data", with
the self-owning widget as the example, and the
BREAKING CHANGEfooter andPR body say the same.
The component-level request count waits for the client to go idle.
fetchAndRenderreturns whenTabPage's loading indicator disappears,which is when the readers mount; any refetch they trigger reaches the mock
adapter a few microtasks later, so an immediate assertion could read one
even on the old code. The test waits for
queryClient.isFetching()to be0, then counts. Confirmed against the Move the 404 redirect to the legacy progress page out of
getProgressTabDataand intoProgressTab#2104 source: the count reads 2there and 1 here. Neither Read the dates and outline tab data from their queries, not useModel #2083 nor Stop the courseware gate queries refetching from components under the gate #2098 pinned a count at component
level; this suite already renders the owner against a mocked endpoint, so
it is the one place the number the manual protocol counts can live in code.
hooks.test.jsxhoists the route wrapper, mock adapter and query clientto module scope. The new
useProgressDatadescribe needs the sameMemoryRouter+QueryClientProviderwrapper and per-test client theuseExamsDatadescribe had built inside itself. Duplicating them was thealternative; hoisting them (plus
courseIdand aprogressRequestshelper) lets both describes share one setup, with each describe's
beforeEachregistering only its own mocks. TheuseExamsDatacases areotherwise unchanged apart from the first one also asserting zero progress
requests — on the Move the 404 redirect to the legacy progress page out of
getProgressTabDataand intoProgressTab#2104 baseline it fired an unmocked GET that the adapteranswered 404, which the requests nothing claim did not count.
The disabled-hook test mocks the endpoint with a 200. Stays idle with
no request when disabled registers a successful handler for the progress
URL before rendering the disabled hook, so a zero request count proves the
option held the fetch back rather than a missing handler failing it.
Negative check. With
apiHooks.tsandhooks.jsxstashed back to theMove the 404 redirect to the legacy progress page out of
getProgressTabDataand intoProgressTab#2104 state, all five new or tightened assertions fail: the disabled hookreads
fetching,useProgressDatafetches on its own and again beside anowner, the first
useExamsDatacase fires a progress GET, and theProgressTabcount reads 2.Manual testing
Checklist
Manual testing — stop the progress tab data refetching from components under the tab (#2103)
In-browser verification against a live backend (tutor dev).
What changed:
useProgressData()— anduseExamsData(), which reads throughit — no longer fetch
/api/course_home/progress/.ProgressTabis the onlything fetching it; everything under the tab subscribes to that cache entry
without fetching. One request per progress-tab load instead of two.
The bug this layer could introduce is a reader that never sees loaded
data. A disabled observer with nothing fetching its query stays
pending,so a component reads
undefinedand renders its empty state without erroring.In the app nothing should be in that position — every reader renders under
ProgressTab— so the tab checks below are mostly "does everything stillrender". The plugin checks are the contract change this layer makes.
Setup
An ordinary course with graded subsections (so the grade tables have rows)
and, ideally, at least one timed or proctored exam (so
useExamsDatahassomething to show). Test as staff so
/progress/:targetUserIdworks.Two checks need plugin widgets in
env.config.jsx(untracked — check astale local copy is not carrying other overrides). Both go in one config:
Slot ids are from
src/plugin-slots/*/index.*; the outline notifications slotis rendered by
OutlineTab.jsxin the side column.Request-count protocol (same as #2083 / #2098): hard reload the page, wait
for the Network tab to go idle, filter on
course_home/progress, count.Verify by hand
Request count (the point of the layer)
/course/:courseId/progress, hard reload/course/:courseId/progress/:targetUserId(staff, real learner), hard reload"Before" for the plain URL was measured when #2103 was filed; the
targetUserIdrow is inferred from the same mechanism./api/course_home/progress/…request on a hard reload of theprogress tab, with the exam-attempt widget installed (it is a reader
too, so it must not add one).
/progress/:targetUserId.The tab still renders (every reader now reads without fetching)
" on the
targetUserIdURL), Studio link for staff.tooltips, the assignment-type table and its footer / droppable footnote
where the grading policy has drops.
links; the "grades feature locked" overlay if the course gates it.
confirm its absence is unchanged.
learner's data as before.
Plugins
exam-attempt widget renders
exams: <names>(or-per non-examsubsection), and the Network tab shows one
/exam/attempt/request persubsection — the attempts fetch is unchanged and still fires only
because a plugin asked.
outline reader: null, and nocourse_home/progressrequest in theNetwork tab. This is the breaking change.
outline owner: <count>once its own progress fetch resolves, and exactly one
course_home/progressrequest (the owner's; the reader beside it addsnone). With both widgets installed the reader shows the count too —
the cache is shared, so the owner's fetch feeds every reader on the
page; the reader-reads-null check above was run with the owner widget
removed.
General
Not covered:
progress_tab_course_grade:every progress-tab slot renders under
ProgressTabContent, so the owner isabove all of them; the one slot checked stands for the rest.
useIFrameBehavior-style invalidation: nothing under the progress tabinvalidates the progress key, so there is no refetch-through-owner path to
exercise here (unlike Stop the courseware gate queries refetching from components under the gate #2098).
Results
Run 2026-09-23 on tutor dev, as staff, with the three widgets installed.
Eleven of fourteen checks pass as written: one progress request per load on
both the plain and
targetUserIdURLs (two before); header, completion,grade summary, detailed grades, related links and masquerade render as
before; the README-example widget renders exam names with one attempt request
per subsection; the off-tab reader reads
nullwith no progress request (runwith the owner widget removed — with both installed the reader shows the count
too, since the owner's fetch feeds every reader on the page); the off-tab
owner reads the count with exactly one progress request. Not run: certificate
status and credit information (the course produced neither), and the console
was looked at but not compared against a before capture.
🤖 Generated with Claude Code