refactor: move the progress tab's legacy-page redirect out of the query and into ProgressTab - #2105
Merged
brian-smith-tcril merged 1 commit intoSep 23, 2026
Conversation
brian-smith-tcril
added this pull request to stack #2080
September 23, 2026 11:01
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #2105 +/- ##
=======================================
Coverage 93.94% 93.95%
=======================================
Files 367 367
Lines 5933 5937 +4
Branches 1433 1434 +1
=======================================
+ Hits 5574 5578 +4
+ Misses 346 345 -1
- Partials 13 14 +1 ☔ 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:14
14 tasks
brian-smith-tcril
force-pushed
the
bsmith/progress-tab-owns-legacy-redirect
branch
2 times, most recently
from
September 23, 2026 11:38
eb0ae22 to
c991c33
Compare
…ry and into ProgressTab
`getProgressTabData`, the `queryFn` of `useProgressTabData`, answered a 404
from `/api/course_home/progress/` by calling `location.replace` to the LMS's
legacy progress page and resolving to `{}`. The destination is right: the
endpoint 404s when the MFE progress tab is not active for the course (the
`course_home.course_home_mfe_progress_tab` course flag, an Old Mongo course,
or `DisableProgressPageStackedConfig`), and the legacy view is the page that
works there. But the redirect sat inside the query, so anything that fetched
the query navigated the whole page, not just the progress tab. A plugin
widget calling `useExamsData()` from a slot on another tab fetched through it
and, on such a course, bounced the learner off the outline to the legacy
progress page.
The side effect belongs to the fetch's owner. `getProgressTabData` now lets
the 404 propagate like every other unhandled status; the 401 and 403
branches, which resolve to `{}` because access is decided from the metadata
request, are unchanged. `ProgressTab`, the component that fetches this query,
reads the status off `tabDataQuery.error`, calls `location.replace` from an
effect, and renders `PageLoading` while the browser navigates, announcing
`TabPage`'s existing loading message. Rendering `TabWithTimer` instead would
have shown `TabPage`'s error view ("There was an error loading this course.")
until the legacy page, which computes grades, finished loading. Any other
fetcher of the progress query now reads an error and stays on its page. The
redirect still fires on every 404, as before: the endpoint's two 404 causes
(tab disabled; bad or unauthorized `student_id`) are indistinguishable from
the response, and this change is about where the redirect lives, not when.
Letting the 404 throw has one new consequence: the global `QueryCache`
`onError` would log it as an error. Before this change the 404 was not logged
at all, since the query succeeded, so the hook declares
`logStatusAs: { 404: 'silent' }` to keep it that way, through the mechanism
the courseware outline query uses for its expected 403. Retries already skip
4xx.
The hook's 404 test now asserts the query errors without navigating, and
`ProgressTab.test.jsx` gains the redirect case; its `fetchAndRender` took a
`waitForLoaded` option because the loading indicator it normally waits out is
the expected view here.
Part of #1946 (Stage 1). Closes #2104.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
brian-smith-tcril
force-pushed
the
bsmith/progress-tab-owns-legacy-redirect
branch
from
September 23, 2026 11:44
c991c33 to
e206249
Compare
brian-smith-tcril
deleted the
bsmith/progress-tab-owns-legacy-redirect
branch
September 23, 2026 14:04
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
getProgressTabData, thequeryFnbehinduseProgressTabData, answered a 404 from/api/course_home/progress/by callinglocation.replaceto the LMS's legacy progress page and resolving to{}. The destination is right: the endpoint 404s when the MFE progress tab is not active for the course (thecourse_home.course_home_mfe_progress_tabcourse flag off, an Old Mongo course, orDisableProgressPageStackedConfig), and the legacy view is the page that works there. The trigger is wrong: because the redirect sat inside the query, anything that fetched the query navigated the whole page, not just the progress tab. A plugin widget callinguseExamsData()from a slot on another tab fetches through it, and on such a course bounced the learner off the outline to the legacy progress page.The side effect now belongs to the fetch's owner.
getProgressTabDatalets the 404 propagate like every other unhandled status, andProgressTab, the component that fetches this query, redirects from an effect when its query fails with a 404, showing a spinner while the browser navigates. Any other fetcher of the progress query reads an error and stays on its page. Nothing changes on any other response.Part of the Redux → React Query migration (#1946, Stage 1). Closes #2104. Stacked above #2102 (#2098); #2103 stacks on this one, so that its plugin migration note — mount
useProgressTabData(courseId)yourself — is side-effect free.What changed
getProgressTabDatadrops its 404 branch. The 401 and 403 branches, which resolve to{}because access is decided from the metadata request, are unchanged.useProgressTabDatadeclareslogStatusAs: { 404: 'silent' }. Letting the 404 throw has one new consequence — the globalQueryCacheonErrorwould log it as an error — and before this change the 404 was not logged at all (the query succeeded with{}), so the hook keeps it silent, through the samelogStatusAsmechanism the courseware outline query uses for its expected 403. Retries already skip 4xx.ProgressTabowns the redirect. It reads the status offtabDataQuery.errorwithgetResponseStatus, callslocation.replacefrom an effect with the same URL as before, and returnsPageLoading— announcingTabPage's existing loading message — instead ofTabWithTimer. RenderingTabWithTimerwould have shownTabPage's error view ("There was an error loading this course.") until the legacy page, which computes grades, finished loading. The redirect still fires on every 404, as before: the endpoint's two 404 causes (tab disabled; bad or unauthorizedstudent_id) are indistinguishable from the response, and this change is about where the redirect lives, not when.window.locationswap to provereplacewas not called).ProgressTab.test.jsxgains the redirect case: 404 reply,replacecalled with the legacy URL, loading indicator present, failure text absent. ItsfetchAndRendertook a{ waitForLoaded }option because the loading indicator it normally waits out is the expected view here. Both new cases fail against the pre-change source.Testing
npm run types,npm run lintand the full suite (113 suites, 1141 passed, 3 skipped) are green.Manually verified on tutor dev as staff, producing real 404s with a
targetUserIdthat matches no user (/progress/999999) — the endpoint answers that exactly as it answers an opted-out course, and the frontend takes the same path from there. The bad-id progress URL shows the bare spinner and lands on the legacy page with no error view in between; browser back returns cleanly; an outline-tab probe widget that mountsuseProgressTabDatawith the bad id stays on the outline and reads the 404; one request per load, no retry, nothing logged; the plain progress URL, viewing a real learner's progress, and masquerade all render as before. The console was not compared against a before capture. A course with the MFE progress tab actually disabled was not set up — same status, same body, same frontend path.Decisions
Working notes for this layer, kept out of the tree:
Full decision log
Decisions — move the 404 redirect to the legacy progress page out of
getProgressTabDataand intoProgressTab(#2104)Layer on top of #2098 (PR #2102); #2103 stacks on it. The diagnosis, the
ownership move and the two open questions were settled in the plan on issue
#2104; entries here record choices made while implementing.
What the page shows while the browser navigates:
PageLoading,announcing
TabPage's own loading message. With the 404 now an error,rendering
TabWithTimeras usual would putTabPagein its error view:deriveViewreadstabDataQuery.isError, andgetErrorDetailhasbackend prose only for a 403, so a 404 falls back to "There was an error
loading this course." That text would stay up until the LMS serves the
legacy progress page, which computes grades and is often seconds, for a
learner who is about to land on a page that works. The repo's redirect
precedents (
TabPage's<Navigate>for denied access,courseware/RedirectPage.tsx) render nothing; the spinner is the smallhonest deviation.
PageLoadinghas no text of its own —srMessageis arequired prop rendered
sr-only— soProgressTabformatstab-page/messages'loading("Loading course page…") itself, the wayTabPage.tsx:113-115does. That keeps the screen-reader announcementidentical to the loading state that preceded it; a progress-specific
string can be added later if wanted. The header, visible during that
preceding state, drops out when the 404 lands; keeping it would mean
holding
TabPagein its loading view with an errored query, and the jumpis not worth that.
Redirect on every 404, as today. The endpoint 404s for two unrelated
reasons — the MFE progress tab disabled for the course, or a bad or
unauthorized
student_id— with an identical body, and the current coderedirects on both without looking. Guarding on
targetUserIdwould be abehaviour change unrelated to where the side effect lives; if that
experience should change it gets its own issue.
Effect, not render-time.
courseware/RedirectPage.tsxcallslocation.assignduring render. Here auseEffectkeyed on the derivedboolean and
courseIdkeeps the render pure; underStrictMode'sdevelopment double-invoke the worst case is
replacecalled twice withthe same URL, a no-op.
logStatusAs: { 404: 'silent' }on the hook. The only new consequence ofletting the 404 throw is logging:
shouldRetryQueryalready skips 4xx, andthe global
QueryCache.onError(Restore dropped query error logging via a global QueryCache.onError #2022) logs aterrorunless the querysays otherwise. Before this change the 404 was not logged at any level —
getProgressTabDatacaught it and the query succeeded with{}— so thefaithful port keeps it silent.
'info'was the first draft, following thecourseware outline query's treatment of its expected 403 and on the
grounds that the redirect is now a decision the app makes; review
(2026-09-23) preferred preserving the old behaviour exactly, and
'silent'is a
LogLevelthe mechanism already supports. The mechanism is covered insrc/queryClient.test.ts(including a'silent'case); no hook-level logassertion, matching the outline.
Tests. The hook case redirects to the legacy progress page and
resolves to an empty object on a 404 became surfaces a 404 as an error
without navigating; it keeps the
window.locationswap so it can assertreplacewas not called.ProgressTab.test.jsxgained the redirect casein a describe of its own.
fetchAndRendertook a{ waitForLoaded }option (the shape
OutlineTab.test.jsx's helper has) because its defaultwait — for the loading indicator to disappear — never ends when the
indicator is the expected view. Both new cases fail against the
pre-change source (
replacecalled from thequeryFn; query succeedswith
{}) and pass with it.The sequence on a 404, render by render. The early return and the
effect divide the work, and it is worth being exact about which does what:
tabDataQuery.erroris null, sogetResponseStatusreturns undefined andredirectToLegacyProgressisfalse.
ProgressTabrendersTabWithTimer;TabPagederives itsloading view — header, spinner, footer. The effect registers; its body
is skipped.
ProgressTabre-renders.getResponseStatusnow returns 404, the flagis true, and in that same render
ProgressTabtakes the earlyreturn and renders
PageLoadingdirectly.TabWithTimerunmounts, sothe header and footer go and a bare spinner remains.
TabPageneversees the errored query — it stops being rendered in the render where
the error first appears — which is what keeps its error view off the
screen without any change to
TabPage.so the body executes and calls
location.replace.useEffectrunsafter paint, so the bare spinner is painted before navigation starts.
stays visible until the LMS responds with the legacy progress page.
So the early return decides what is on screen from step 3 until the new
page arrives, and the effect decides when navigation starts. On the happy
path the flag is false at every render: the early return is never taken
and the effect body never runs, so
ProgressTabreturns the same tree itdid before this change.
Commit type
refactor:, not breaking. Nothing a learner on theprogress tab sees is broken today, and the change is where a side effect
lives. A plugin that fetched this query off the tab loses an unintended
page navigation, which is the point, and no documented contract changes.
Manual testing
Checklist
Manual testing — move the 404 redirect to the legacy progress page out of
getProgressTabDataand intoProgressTab(#2104)In-browser verification against a live backend (tutor dev).
What changed:
getProgressTabDatano longer navigates the page on a 404; thequery errors instead (and stays unlogged, as before).
ProgressTab, thecomponent that owns that fetch, redirects to
/courses/:courseId/progressonthe LMS when its query fails with a 404, rendering a bare spinner from the
render in which the 404 lands until the browser gets there. Any other fetcher
of the progress query reads an error and stays put.
The bug this layer could introduce is a course that used to fall back to the
legacy progress page and now shows an error view, or a spinner forever. The
first group below is for that. The second group is the reason for the layer: a
fetcher that is not the progress tab must no longer move the page.
Setup
Producing a real 404 without changing any config. The endpoint 404s for a
bad
student_idas well as for an opted-out course, and the frontend cannottell the two apart — same status, same empty body, same code path from the
response onward. So run the "opted-out" checks below as staff against an
ordinary course, using a
targetUserIdthat matches no user:The alternative is the real thing: Django admin → Waffle → Waffle flag course
overrides →
course_home.course_home_mfe_progress_tabfor one course,override off (or
Disable progress page stacked configs). Not needed forthe frontend path; use it only if you want the learner-facing framing.
Do not block the endpoint in the Network tab for this. A blocked request
fails at the network level with no
response, sogetResponseStatusreadsundefined, the flag stays false, the query retries three times (only 4xx skips
retries) and
TabPageshows its ordinary error view. That is correct andunchanged for a non-404 failure, but it never reaches the redirect.
One check needs a widget that fetches the progress query from the outline.
Add it to
env.config.jsx(untracked — check a stale local copy is notcarrying other overrides). It asks for the bad id so it gets the 404 too:
That slot is rendered by
OutlineTab.jsxin the side column (CourseOutlineTabNotificationsSlot);course_home_section_outline.v1is the only other slot on the outline tab.This is the #2103 migration shape (a widget mounting
useProgressTabDataitself), so it doubles as a preview of what that layer will tell plugins to do.
Run everything as staff on one ordinary course; the 404 checks use the
bad-id URL above, the happy-path checks use the plain progress URL.
Verify by hand
The redirect still works for the progress tab (404 via bad id)
/course/:courseId/progress/999999. Header and spinner whilethe queries are in flight, then a bare spinner (no header or footer)
once the 404 lands, then the LMS legacy progress page at
/courses/:courseId/progress. "There was an error loading this course."never appears. (Staff are not bounced back to the MFE by the legacy
view, so it renders the legacy page for the staff user's own progress —
same as before this change.)
a redirect loop.
location.replaceadds no history entry, so the MFE/progress/999999URL should not be in the back stack.Nothing but the progress tab redirects (404 via bad id)
stays. The probe reads
progress query: error 404. Nothing navigates.this query.
Request and logging (404 via bad id)
/api/course_home/progress/…/999999/request per load, answered404, on both the progress tab and the outline-with-probe. No retry — 4xx
is not retried.
the mock logger prints there) records no error and no info line for it;
before this change the 404 was never logged either.
The happy path is unchanged (same course, valid URLs)
/course/:courseId/progress. Grades, completion donut,certificate status, related links render as before; header present
throughout; no bare-spinner frame.
/course/:courseId/progress/:targetUserIdfor a real enrolledlearner: "Course progress for ", as before.
renders as before.
General
warning from
ProgressTababout hooks or effects, and no "Notimplemented: navigation" — that one is jsdom-only. (not sure, doesn't seem like it)
Not covered:
DisableProgressPageStackedConfig, or an Old Mongo course): the endpointreturns the same 404 with the same empty body, and the frontend takes the
same path from there, so the bad-id runs above cover it. The difference is
only the learner-facing framing — a learner, not staff, and the legacy view
then rendering their real progress rather than redirecting.
/progress/<someone else>: the endpoint404s, the redirect fires as before, and the legacy view bounces a non-staff
learner back to their own MFE progress page. Unchanged by this layer and
needs a second learner account; the staff bad-id check exercises the same
frontend path.
Results
Run 2026-09-23 on tutor dev, as staff on one ordinary course, 404s produced
via the bad-id URL. Nine of ten checks pass as written: the bad-id progress
URL shows the bare spinner and lands on the legacy page with no error view;
browser back returns cleanly; the outline with the probe stays put and the
probe reads
error 404; the dates tab is unaffected; one request per load,no retry, nothing logged; the plain progress URL, a real learner's id and
masquerade all render as before. The console check is left unchecked: nothing
stood out, but the console was not compared against a before capture.
🤖 Generated with Claude Code