Skip to content

refactor: convert the live tab from Redux to React Query - #2006

Merged
brian-smith-tcril merged 1 commit into
masterfrom
bsmith/react-query-course-home-live-tab
Aug 24, 2026
Merged

refactor: convert the live tab from Redux to React Query#2006
brian-smith-tcril merged 1 commit into
masterfrom
bsmith/react-query-course-home-live-tab

Conversation

@brian-smith-tcril

@brian-smith-tcril brian-smith-tcril commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

Convert the course-home live tab from Redux to React Query. Part of the Redux → React Query migration (#1946), stacked on the discussion-tab conversion (#2005) as the top course-home tab layer. Closes #2002.

The live tab has real tab data (the LTI iframe HTML) but needs no model-store bridge — its only reader was the tab itself. It's the last course-home tab on the shared fetchTab helper, so this layer also retires fetchTab.

What changed

  • course-home/data/queryKeys.ts / apiHooks.ts — add useLiveTabData(courseId) (getLiveTabIframe, no meta tag) and the liveTab(courseId) key.
  • course-home/live-tab/LiveTab.jsx — self-wrapping: a thin data-owning LiveTab (runs useCourseHomeMeta + useLiveTabData, renders <TabWithTimer>) + a LiveTabContent child holding the dangerouslySetInnerHTML div and the getElementById('lti-tab-embed') sizing useEffect, so it mounts post-load. courseId from useParams.
  • index.jsx — live route → bare <LiveTab />; the fetchLiveTab import/wiring dropped.
  • course-home/data/thunks.js — delete fetchLiveTab and the now-orphaned shared fetchTab helper (discussion, its other consumer, was converted in refactor: convert the discussion tab from Redux to React Query #2005), plus their now-unused imports. eventTypes, deprecatedSaveCourseGoal, and fetchExamAttemptsData remain.
  • course-home/data/slice.js — remove the three now-dead reducers orphaned by the fetchTab deletion (fetchTabRequest / fetchTabDenied / fetchTabSuccess); fetchTabFailure stays (still dispatched by TabPage.test.jsx for the errorMessage display).

Behavior

No user-facing change. No live model bridge: the masquerade banner reads useModel('lti_live'), but fetchLiveTab wrote model live (slug ≠ model), so that read was already empty and the banner never rendered here — preserved. TabWithTimer is retained, so the outer exam timer still mounts as it did via TabContainer. The three fetchTab request/denied/success reducers in slice.js, orphaned by the deletion, are removed; fetchTabFailure stays (still exercised by TabPage.test.jsx) and retires with the courseHome reducer in #1975.

Testing

npm run types, npm run lint, and the affected suites pass. New LiveTab.test.jsx renders <LiveTab /> through the bridged query client; apiHooks.test.tsx gains a useLiveTabData block (success / 404→{} / error); redux.test.js drops the Test fetchTab block wholesale (its subject fetchTab is deleted here). Manual browser verification (HAR-confirmed clean load) is documented in the decision log below.

Decisions

Full decision log

Decisions — Redux → React Query: the live tab (#1946)

Working notes for this PR (part of the wider Redux → React Query migration,
#1946). Not checked in — referenced when opening the PR. Part of #1975
(course-home tab data), stacked on the discussion-tab conversion (#2005) as the
top course-home tab layer. Closes #2002.

The live tab has real tab data (the LTI iframe HTML) but still needs no
model-store bridge
— its only reader was the tab itself. It's the last
course-home tab on the shared fetchTab helper, so this layer also retires
fetchTab.

Scope: self-wrapping conversion

Decision. LiveTab becomes self-wrapping — it owns its data via
useCourseHomeMeta + a new useLiveTabData hook and renders <TabWithTimer>
itself. The index.jsx route drops <TabContainer tab="lti_live" fetch={fetchLiveTab} slice="courseHome"> for a bare <LiveTab />.

No transitional bridge (cleaner than progress)

Decision. useLiveTabData carries no meta tag:

  • The only reader of state.models.live is LiveTab itself
    (state.models.live[courseId]?.iframe), which moves to the query.
  • The shared masquerade banner reads useModel(tab, courseId) with
    tab="lti_live", but fetchLiveTab wrote its data under model live, not
    lti_live — so useModel('lti_live', …) already resolved empty and the banner
    never rendered on this tab. TabWithTimer activeTabSlug stays "lti_live", so
    that's preserved exactly. No #1999-style holdout.

LiveTab splits into a thin wrapper + LiveTabContent

Decision. LiveTab runs the two queries and renders <TabWithTimer courseStatus={{ metadataQuery, tabDataQuery }}><LiveTabContent /></TabWithTimer>;
the iframe <div> and its getElementById('lti-tab-embed') sizing useEffect
move to LiveTabContent. TabPage renders children only once loaded, so putting
the effect in the gated child preserves today's timing (where LiveTab was
TabContainer's already-loaded child); in the self-wrapping shape the data-owning
component mounts before load.

The iframe HTML is a prop named html, not iframe

Decision. LiveTabContent takes html (the iframe markup) and renders it via
dangerouslySetInnerHTML. It's named html rather than iframe so it doesn't
shadow the effect's const iframe = document.getElementById('lti-tab-embed')
DOM-node variable — which keeps that effect byte-identical to the original.

getLiveTabIframe reused unchanged

Decision. useLiveTabData's queryFn is the existing getLiveTabIframe
(GET /api/course_live/iframe/<courseId>/; 404 → {}, else throw), reused
as-is. Its name doesn't match the get<Tab>TabData siblings; a rename was
considered and rejected as out-of-scope churn for a conversion.

fetchTab retires here (the last consumer)

Decision. Deleting fetchLiveTab leaves the shared fetchTab with no callers
(discussion, its other consumer, was converted in #2005). So fetchTab and its
now-unused imports (getCourseHomeCourseMetadata, addModel,
fetchTab{Request,Denied,Failure,Success}) are removed from thunks.js. What's
left there is eventTypes, deprecatedSaveCourseGoal, and fetchExamAttemptsData.

Slice cleanup: deleting fetchTab orphaned three of its reducers —
fetchTabRequest, fetchTabDenied, and fetchTabSuccess had no remaining
dispatcher, so they're removed from slice.js (along with the now-unused
LOADING / DENIED imports). fetchTabFailure stays: TabPage.test.jsx
still dispatches it to exercise TabPage's errorMessage display. The residual
courseStatus / errorMessage fields retire with the courseHome-reducer
teardown (#1975), not this layer.

redux.test.js: Test fetchTab block removed wholesale

Decision. With fetchTab gone, the Test fetchTab block (which drove it via
fetchLiveTab) is removed entirely, along with its now-orphaned courseMetadataUrl
/ courseHomeAccessDeniedMetadata declarations and the appendBrowserTimezoneToUrl
import. This is the clean, single deletion the below-live ordering was chosen to
enable — no retarget churn.

Tests

  • LiveTab.test.jsx (new) renders <LiveTab /> through the bridged query client
    and asserts the iframe HTML from useLiveTabData renders — exercising LiveTab,
    LiveTabContent, and the sizing effect.
  • apiHooks.test.tsx adds a useLiveTabData block: success (iframe payload),
    404 → {}, and non-404 → error. The 404/500 mocks attach customAttributes
    (via Object.assign on a real Error, so it's typed and lint-clean) because
    getLiveTabIframe reads error.customAttributes.httpErrorStatus — matching the
    approach in api.test.js.
  • redux.test.jsTest fetchTab block removed (see above).

Manual testing (in-browser)

Verified (no LTI live provider configured — the realistic demo case): a direct,
cold load of /course/<id>/live renders cleanly. The HAR shows
GET /api/course_home/course_metadata/… → 200 (useCourseHomeMeta) and
GET /api/course_live/iframe/<id>/ → 200 (useLiveTabData), the latter with an
empty payload, so #live_tab renders empty — no crash, full page render. The
OuterExamTimer path fired (…/proctored_exam/attempt/… → 200), confirming
TabWithTimer is preserved. Zero 4xx/5xx, and nothing hit a fetchTab /
courseStatus / courseware/sequence path — i.e. deleting the shared fetchTab
helper broke nothing on this render.

Not exercised: a fully-configured LTI live provider (visible iframe content +
the #lti-tab-embed sizing effect) — none available locally. The automated
LiveTab.test.jsx covers the iframe-render + effect path with a mocked payload.

🤖 Generated with Claude Code

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.29%. Comparing base (09e57e0) to head (a61c3f6).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2006      +/-   ##
==========================================
+ Coverage   93.11%   93.29%   +0.18%     
==========================================
  Files         364      364              
  Lines        5939     5906      -33     
  Branches     1414     1367      -47     
==========================================
- Hits         5530     5510      -20     
+ Misses        391      381      -10     
+ Partials       18       15       -3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-live-tab branch from 9475085 to 62d92eb Compare August 19, 2026 20:43
@brian-smith-tcril
brian-smith-tcril marked this pull request as ready for review August 19, 2026 20:48
@brian-smith-tcril
brian-smith-tcril marked this pull request as draft August 20, 2026 23:57
@brian-smith-tcril

Copy link
Copy Markdown
Contributor Author

Moved back to draft — this conversion drops the query-side error logging its thunk used to emit (React Query v5 removed useQuery.onError), which is tracked in #2022. Keeping it out of the merge queue until that logging layer is sorted.

🤖 Comment via Claude Code.

@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-live-tab branch from 62d92eb to 5005683 Compare August 21, 2026 00:58
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-live-tab branch from 5005683 to 4568fed Compare August 21, 2026 01:59
@brian-smith-tcril

Copy link
Copy Markdown
Contributor Author

Moved back to draft — this conversion drops the query-side error logging its thunk used to emit (React Query v5 removed useQuery.onError), which is tracked in #2022. Keeping it out of the merge queue until that logging layer is sorted.

🤖 Comment via Claude Code.

addressed in https://github.com/openedx/frontend-app-learning/compare/5005683e0cee3acad4dbd29809bd6b8d70418d67..4568fedb91eb6f7e926ca5c7a40057968d1226fe (part of a stack rebase, see #1987)

@brian-smith-tcril
brian-smith-tcril marked this pull request as ready for review August 21, 2026 02:11

@arbrandes arbrandes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we might have an actual blocker, on this one. Let me know.

Comment on lines +79 to +82
export const useLiveTabData = (courseId: string) => useQuery({
queryKey: courseHomeQueryKeys.liveTab(courseId),
queryFn: () => getLiveTabIframe(courseId),
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we might need to add refetchOnWindowFocus: false to useLiveTabData.

The payload isn't stable across requests, it looks like. (The LMS builds the iframe srcdoc from lti_embed, so every response carries a fresh oauth_nonce/oauth_timestamp/oauth_signature). React compares dangerouslySetInnerHTML.__html by value, so a differing string means the div's contents are replaced and the #lti-tab-embed iframe is destroyed and relaunched. With current React Query's defaults, that fires whenever the learner switches windows and comes back (which could drop, say, an in-progress meeting embedded in the tab). The old thunk fetched once per navigation, so this is new.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines 9 to 11
useEffect(() => {
const iframe = document.getElementById('lti-tab-embed');
if (iframe) {

@arbrandes arbrandes Aug 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change the dependency array to [html]. Same root cause.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-live-tab branch from 4568fed to e32fe4d Compare August 21, 2026 14:48
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-live-tab branch from e32fe4d to 9310fcb Compare August 21, 2026 18:01
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-live-tab branch 2 times, most recently from 5531908 to 9435011 Compare August 21, 2026 18:32
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-live-tab branch from 9435011 to a85af66 Compare August 21, 2026 18:34
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-live-tab branch from a85af66 to 182a385 Compare August 21, 2026 18:40
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-live-tab branch from 182a385 to 93ba4ef Compare August 21, 2026 18:55
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-live-tab branch from 93ba4ef to 5ad8e8d Compare August 21, 2026 19:05
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-live-tab branch from 5ad8e8d to 64b6873 Compare August 21, 2026 19:15
Base automatically changed from bsmith/react-query-course-home-discussion-tab to master August 21, 2026 19:23
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-live-tab branch from 64b6873 to 4205572 Compare August 21, 2026 19:23
Self-wrap the live tab (LiveTab owns useCourseHomeMeta + a new useLiveTabData,
renders TabWithTimer, splits the iframe into a LiveTabContent child). Delete
fetchLiveTab and the now-orphaned shared fetchTab helper (discussion, its other
consumer, converted in the layer below), and remove the Test fetchTab block from
redux.test.js. Part of #1975; closes #2002.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@brian-smith-tcril
brian-smith-tcril force-pushed the bsmith/react-query-course-home-live-tab branch from 4205572 to a61c3f6 Compare August 21, 2026 21:19
@brian-smith-tcril

brian-smith-tcril commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Decision log for the review fixes, written by Claude 🤖:

Decisions — #2006 live-tab review: LTI iframe stability (TDD)

Working notes for addressing arbrandes's CHANGES_REQUESTED review on #2006 (convert
the live tab from Redux to React Query). Not checked in.

The blocker (arbrandes)

Two related review comments, one root cause — the LTI payload is not stable across
requests
. The LMS builds the iframe srcdoc from lti_embed, so every response
carries a fresh oauth_nonce/oauth_timestamp/oauth_signature. LiveTabContent
renders it via dangerouslySetInnerHTML, and React compares __html by value — a
differing string replaces the div's contents, so the #lti-tab-embed iframe is
destroyed and relaunched. Any spurious refetch therefore drops an in-progress
meeting embedded in the tab.

The old Redux thunk fetched once per navigation, so this never happened. React
Query's defaults reintroduce it in two ways:

  1. refetchOnWindowFocus (default true) — switching away from the browser and
    back refetches the (stale, staleTime: 0) live query, producing a new payload →
    iframe relaunched. src/course-home/data/apiHooks.ts:82 — fix: add
    refetchOnWindowFocus: false to useLiveTabData.
  2. The class-styling effect's [] dependencyLiveTabContent adds
    vh-100 w-100 border-0 to the iframe once on mount. When the html legitimately
    changes and a new iframe node is created (navigation, invalidation, reconnect),
    the effect doesn't re-run, so the fresh iframe loses its sizing classes.
    src/course-home/live-tab/LiveTab.jsx — fix: change the dep array to [html].

The two fixes are complementary: (1) stops the unwanted refetch; (2) keeps styling
correct for the wanted html changes.

Approach: TDD

Per the review, write tests that reproduce each failure first (RED), then apply the
suggested changes and confirm they pass (GREEN) — rather than making the changes blind.

Test 1 — no refetch on window focus

does not refetch the live embed when the window regains focus. Renders LiveTab,
waits for the embed, records the live-endpoint call count, drives a focus transition
via focusManager.setFocused(false) → setFocused(true) inside act, and asserts the
call count is unchanged. createTestQueryClient only sets retry: false, so RQ's
default refetchOnWindowFocus: true + staleTime: 0 apply and the query is stale at
focus time. afterEach resets focusManager.setFocused(undefined) so the global
focus state doesn't leak between tests.

Test 2 — sizing classes survive an html change

re-applies the sizing classes when the iframe html changes. Serves iframe A first
(replyOnce) then iframe B, waits for A + asserts it has the classes, forces a refetch
with queryClient.invalidateQueries(), waits for B, and asserts B also has the
classes. Distinguishes A/B by src so the assertion targets the recreated node.

Log

  • RED (confirmed) — both new tests fail against the unfixed code, each for the
    intended reason:

    • Test 1 (does not refetch the live embed when the window regains focus):
      expect(liveCallCount()).toBe(1)Expected: 1, Received: 2. The focus
      transition triggered a second GET to the live endpoint — the refetch that would
      relaunch the iframe.
    • Test 2 (re-applies the sizing classes when the iframe html changes): after the
      payload changes to iframe B, toHaveClass('vh-100 w-100 border-0') fails with an
      empty class list. The src=live-b waitFor passed first, proving the iframe node
      was recreated; the []-dep effect didn't re-run, so the new node lost its classes.
  • GREEN (confirmed) — applied both fixes and reran; all 3 tests pass (the two new
    ones + the original render test):

    • src/course-home/data/apiHooks.ts — added refetchOnWindowFocus: false to
      useLiveTabData. Test 1 now records a single live-endpoint call after the focus
      transition.
    • src/course-home/live-tab/LiveTab.jsx — changed the styling effect's dep array
      from [] to [html]. Test 2's recreated iframe (B) now carries the sizing classes.
    • (The 404 AxiosErrors in the run output are pre-existing console noise from other
      unmocked endpoints rendered by TabWithTimer/InstructorToolbar via logError,
      not test failures — present before these changes too.)
  • Fix-isolation matrix — reverted each fix independently to confirm each test maps
    1:1 to its own fix, with no cross-coupling:

    apiHooks refetchOnWindowFocus:false LiveTab dep Test 1 (focus) Test 2 (classes)
    [] fail fail
    [html] fail pass
    [] pass fail
    [html] pass pass

    Test 1 tracks only the refetchOnWindowFocus fix (it drives focus, unaffected by the
    dep array); Test 2 tracks only the [html] dep (it drives an invalidateQueries
    refetch, unaffected by the focus setting). Both fixes are therefore necessary and
    each is independently verified.

@arbrandes arbrandes left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing! 👍🏼

@brian-smith-tcril
brian-smith-tcril merged commit 50df091 into master Aug 24, 2026
7 checks passed
@brian-smith-tcril
brian-smith-tcril deleted the bsmith/react-query-course-home-live-tab branch August 24, 2026 21:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Convert the live tab to React Query

2 participants