diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index e5b91992..9098cdd8 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useLiveQuery } from "../lib/useLiveQuery"; -import { channelDetailPollMs } from "../lib/channelDetailPoll"; +import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll"; import { qk } from "../lib/queryKeys"; import { useNavigationActions } from "./NavigationProvider"; import { PriorityStepper } from "./ui/PriorityStepper"; @@ -93,6 +93,10 @@ export default function ChannelPage({ }); const ch = detail.data; useEffect(() => setBannerAttempt(0), [ch?.banner_url]); // fresh channel/banner → try again + // The poll above is also the ONLY signal that the running backfill has written videos, so it + // drives the video grid's refresh too (the "Get full history" mutation below can't: it fires on + // the PATCH response, before the background job has stored anything). + useBackfillFeedRefresh(ch, qc); // Background auto-ingest ("explore") of an un-subscribed channel's uploads. const [nextToken, setNextToken] = useState(undefined); diff --git a/frontend/src/lib/channelDetailPoll.backfill.dom.test.tsx b/frontend/src/lib/channelDetailPoll.backfill.dom.test.tsx new file mode 100644 index 00000000..f92ff55c --- /dev/null +++ b/frontend/src/lib/channelDetailPoll.backfill.dom.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +// +// "Get full history" on a channel page must not leave the video grid frozen on the cards the +// channel already had. The backfill runs in a BackgroundTask, so the PATCH that starts it returns +// before a single row is written — invalidating the feed on that RESPONSE (what the mutation does) +// refetches the OLD catalogue. Reported from daily use: the stored count climbed into the +// thousands while the list stayed on the ~19 cards from subscribe time, until a browser F5. +// +// The channel detail's 4s live poll is the only thing that sees the job progress, so it is what +// has to drive the grid. These assert the real cache effect (the feed query ends up invalidated), +// not that some helper was called. +import { describe, it, expect } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { QueryClient } from "@tanstack/react-query"; +import { useBackfillFeedRefresh } from "./channelDetailPoll"; +import { qk } from "./queryKeys"; +import type { ChannelDetail, FeedFilters } from "./api"; + +const CH = "UCsXVk37bltHxD1rDPwtNM8Q"; +// The channel page's Feed is channel-scoped; only the shape matters for the cache key. +const filters = { q: "", channel: CH } as unknown as FeedFilters; + +/** A detail reading mid-backfill: full history requested, back-catalogue not in yet. */ +const detail = (over: Partial = {}): ChannelDetail => + ({ + id: CH, + deep_requested: true, + backfill_done: false, + stored_videos: 19, + ...over, + }) as ChannelDetail; + +/** The two queries behind the grid and the count pill beside it. */ +const seed = (qc: QueryClient) => { + qc.setQueryData(qk.feed(filters), { + pages: [{ items: [], next_cursor: null }], + pageParams: [null], + }); + qc.setQueryData(qk.feedSummary(filters), { count: 19 }); +}; + +const stale = (qc: QueryClient) => ({ + grid: qc.getQueryState(qk.feed(filters))?.isInvalidated === true, + count: qc.getQueryState(qk.feedSummary(filters))?.isInvalidated === true, +}); + +/** Mount on `first`, then hand the hook the next poll reading. */ +const poll = (qc: QueryClient, first: ChannelDetail | undefined, next: ChannelDetail) => { + const { rerender } = renderHook((d: ChannelDetail | undefined) => useBackfillFeedRefresh(d, qc), { + initialProps: first, + }); + rerender(next); +}; + +describe("useBackfillFeedRefresh", () => { + it("refreshes the grid and its count when the running backfill stores more videos", () => { + const qc = new QueryClient(); + seed(qc); + poll(qc, detail(), detail({ stored_videos: 79 })); + expect(stale(qc)).toEqual({ grid: true, count: true }); + }); + + it("refreshes when the backfill finishes, even if its last page added nothing new", () => { + // The tail can be all duplicates: the count never moves, but the grid's `hasNextPage` is still + // the one it cached from the pre-backfill catalogue. + const qc = new QueryClient(); + seed(qc); + poll(qc, detail(), detail({ backfill_done: true })); + expect(stale(qc)).toEqual({ grid: true, count: true }); + }); + + it("leaves the grid alone on a poll tick that changed nothing", () => { + const qc = new QueryClient(); + seed(qc); + poll(qc, detail(), detail()); + expect(stale(qc)).toEqual({ grid: false, count: false }); + }); + + it("stays out of the way when no backfill is running", () => { + // A detail refetch outside the poll window comes from a mutation, and every one of those + // invalidates the feed itself — firing here too would just double it. + const qc = new QueryClient(); + seed(qc); + poll( + qc, + detail({ deep_requested: false }), + detail({ deep_requested: false, stored_videos: 79 }), + ); + expect(stale(qc)).toEqual({ grid: false, count: false }); + }); + + it("does not mistake a switch to another channel for backfill progress", () => { + const qc = new QueryClient(); + seed(qc); + poll(qc, detail(), detail({ id: "UCba9j5PIA1zvHf7L5WVJKAg", stored_videos: 412 })); + expect(stale(qc)).toEqual({ grid: false, count: false }); + }); + + it("survives the first render, when there is no previous reading yet", () => { + const qc = new QueryClient(); + seed(qc); + poll(qc, undefined, detail()); + expect(stale(qc)).toEqual({ grid: false, count: false }); + }); +}); diff --git a/frontend/src/lib/channelDetailPoll.ts b/frontend/src/lib/channelDetailPoll.ts index 0f796e51..f3057de1 100644 --- a/frontend/src/lib/channelDetailPoll.ts +++ b/frontend/src/lib/channelDetailPoll.ts @@ -1,3 +1,6 @@ +import { useEffect, useRef } from "react"; +import type { QueryClient } from "@tanstack/react-query"; +import { qk } from "./queryKeys"; import { type ChannelDetail } from "./api"; /** How often the channel page re-polls its detail (via useLiveQuery): every 4s while THIS user's @@ -6,3 +9,46 @@ import { type ChannelDetail } from "./api"; * the user never requested it. Extracted so the poll-or-stop decision is unit-testable. */ export const channelDetailPollMs = (d: ChannelDetail | undefined): number | false => d?.deep_requested && !d.backfill_done ? 4000 : false; + +/** Did the poll above just watch the running backfill change this channel's catalogue? + * + * Only the poll can tell: the backfill runs in a BackgroundTask, so the PATCH that STARTS it + * returns before it has written anything, and it emits no event of its own. The window is the + * same one the poll runs in (`deep_requested && !backfill_done` on the PREVIOUS reading), so a + * detail refetch triggered by some other mutation — which invalidates the feed itself — can't + * come through here twice. Completion counts even with an unchanged count: the last page can be + * all-duplicates and still leave the grid's `hasNextPage` reading from a one-page catalogue. */ +const backfillChangedCatalog = ( + prev: ChannelDetail | undefined, + next: ChannelDetail | undefined, +): boolean => + !!prev && + !!next && + prev.id === next.id && + prev.deep_requested && + !prev.backfill_done && + (next.stored_videos !== prev.stored_videos || next.backfill_done); + +/** Keep the channel page's video grid in step with the backfill its detail poll is watching. + * + * Without this the grid is frozen until an F5: the feed's infinite query has no interval, and the + * app-wide defaults are `staleTime: 30s` + `refetchOnWindowFocus: false`, so nothing refetches it + * on its own. "Get full history" invalidates the feed on the PATCH RESPONSE — before the + * background job has written a row — which is why the list stayed on the ~19 cards the channel had + * at subscribe time while the count above it climbed into the thousands. + * + * Cost is bounded: the predicate fires only on a tick where the count actually moved, the poll + * itself pauses while the tab is unfocused, and a channel grid is normally a single 60-item page + * whose newest-first head the (older) backfilled videos don't disturb — so the refetch usually + * comes back structurally identical and re-renders nothing. */ +export function useBackfillFeedRefresh(detail: ChannelDetail | undefined, qc: QueryClient) { + const seen = useRef(undefined); + useEffect(() => { + const prev = seen.current; + seen.current = detail; + if (!backfillChangedCatalog(prev, detail)) return; + qc.invalidateQueries({ queryKey: qk.feed() }); + // …and the count pill beside it, which reads the same catalogue through its own query. + qc.invalidateQueries({ queryKey: qk.feedSummary() }); + }, [detail, qc]); +}