Skip to content

Convert the live tab to React Query #2002

Description

@brian-smith-tcril

Part of #1975 (convert course-home tab data to React Query), Phase 3 of the epic #1946. The top course-home tab layer — stacks on the discussion-tab conversion (#2003), which sits above progress (#2001) — reusing the self-wrapping pattern the dates (#1987), outline (#1997), and progress (#2001) tabs set.

Summary

Convert the live tab off Redux to React Query, following the self-wrapping pattern: LiveTab renders <TabWithTimer> itself and owns its data via useCourseHomeMeta + a new useLiveTabData hook. The iframe HTML moves into a LiveTabContent child so its styling useEffect runs when the content mounts (post-load), not when the outer tab mounts (mid-load). The index.jsx route drops its <TabContainer tab="lti_live" fetch={fetchLiveTab} slice="courseHome"> wrapper for a bare <LiveTab />, and fetchLiveTab is deleted.

No transitional bridge needed

Unlike progress (which keeps a scoped meta bridge for the masquerade banner), useLiveTabData needs no meta tag:

  • The only reader of state.models.live is LiveTab itself (state.models.live[courseId]?.iframe), and it moves to the query.
  • The shared masquerade banner (useAccessExpirationMasqueradeBanner(courseId, tab)useModel(tab, courseId)) is passed tab="lti_live" on this route, but fetchLiveTab writes its data under model live, not lti_live — so that useModel('lti_live', …) already resolves empty and the banner never renders on the live tab today. The conversion preserves that exactly (the TabWithTimer activeTabSlug stays "lti_live"), so nothing needs the bridge.

So this tab retires its Redux with no #1999-style holdout — cleaner than progress.

fetchTab retires here

This is the last course-home tab on the shared fetchTab helper. Discussion (#2003, below) already removed the other consumer, so deleting fetchLiveTab here orphans fetchTab entirely — both go together, and the redux.test.js Test fetchTab block is removed wholesale. Ordering discussion below live is what makes this a clean deletion instead of retargeting that test block from fetchLiveTab to fetchDiscussionTab and back.

Tasks

  • queryKeys.ts — add liveTab(courseId).
  • apiHooks.ts — add useLiveTabData(courseId) (getLiveTabIframe, no meta).
  • LiveTab.jsx — self-wrap TabWithTimer (activeTabSlug="lti_live", courseStatus={{ metadataQuery, tabDataQuery }}); split the iframe body + its getElementById('lti-tab-embed') useEffect into a LiveTabContent child; courseId from useParams.
  • index.jsx — live route → <LiveTab />; drop the fetchLiveTab import/wiring.
  • Delete fetchLiveTab and the now-orphaned shared fetchTab helper (discussion, its last other consumer, converted in Convert the discussion tab to React Query #2003).
  • Tests: LiveTab.test.jsx renders <LiveTab /> directly through the bridged query client (mirroring ProgressTab.test.jsx); add a useLiveTabData block to apiHooks.test.tsx (success/iframe + 404→{}); delete the Test fetchTab block from redux.test.js wholesale (its subject fetchTab is deleted here).

Notes / decisions

  • The LiveTab/LiveTabContent split is required, not cosmetic. Today LiveTab is TabContainer's child, so it mounts only once the tab data is loaded — its mount useEffect finds the injected #lti-tab-embed iframe and applies the sizing classes. In the self-wrapping shape the data-owning component mounts before load, so the effect must live in the gated LiveTabContent child to keep the same timing.
  • fetchTab is deleted here, not retargeted. Ordering discussion below live (Convert the discussion tab to React Query #2003) means fetchLiveTab is fetchTab's last consumer; removing it orphans the helper, so both go together and the Test fetchTab block is removed wholesale — no back-and-forth churn in redux.test.js.

Note

The plan below was generated by Claude (Claude Code) and reviewed before posting.

Claude Plan — live tab → React Query

Approach

A self-wrapping conversion mirroring dates/outline/progress: the page owns its data via query hooks and renders <TabWithTimer> directly; TabContainer stops wrapping this route. No model-store bridge — the sole state.models.live reader is the tab itself, and the masquerade banner never resolves to this tab's model (route slug lti_live ≠ data model live), so the conversion is behavior-preserving with the query as the only cache.

Data layer

// queryKeys.ts
liveTab: (courseId: string) => [...courseHomeQueryKeys.all, 'liveTab', courseId] as const,

// apiHooks.ts
export const useLiveTabData = (courseId: string) => useQuery({
  queryKey: courseHomeQueryKeys.liveTab(courseId),
  queryFn: () => getLiveTabIframe(courseId),
});

LiveTab.jsx

const LiveTab = () => {
  const { courseId } = useParams();
  const metadataQuery = useCourseHomeMeta(courseId);
  const tabDataQuery = useLiveTabData(courseId);
  return (
    <TabWithTimer
      activeTabSlug="lti_live"
      courseId={courseId}
      courseStatus={{ metadataQuery, tabDataQuery }}
    >
      <LiveTabContent html={tabDataQuery.data?.iframe} />
    </TabWithTimer>
  );
};

// LiveTabContent: the dangerouslySetInnerHTML div (prop named `html` to avoid shadowing
// the effect's `iframe` DOM node) + the existing getElementById('lti-tab-embed') sizing
// useEffect (renders only once loaded).

Metadata

Metadata

Labels

No labels
No labels

Projects

Status
In Review

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions