fix(channels): the video grid follows a running backfill instead of freezing until F5
"Get full history" on a channel page left the list on the cards the channel already had. The
count above it climbed into the thousands, the grid did not move, and only a browser F5 brought
the rest in.
The mechanism: the backfill runs in a BackgroundTask, so the PATCH that starts it returns before
a single row is written — and the mutation invalidates `qk.feed()` on that RESPONSE, which just
refetches the OLD catalogue. Nothing afterwards refetches it: the feed's infinite query has no
interval, and the app-wide defaults are `staleTime: 30s` + `refetchOnWindowFocus: false`. The job
emits no event either (the realtime layer only carries messages today), so the channel detail's
4s live poll is the only thing in the app that sees the job progress — and it refreshed only
`qk.channel`.
So the poll now drives the grid too: on a tick where the stored count moved (or the backfill
finished), it invalidates `qk.feed()` and the `qk.feedSummary()` behind the count pill. The window
is the poll's own (`deep_requested && !backfill_done` on the previous reading), so a detail
refetch caused by some other mutation — each of which invalidates the feed itself — cannot come
through here twice.
Measured on localdev against the real account, same instrument, `document.visibilityState`
forced visible (the poll deliberately pauses on a hidden tab, and a headless pane reads hidden —
without that the first measurement was about the harness, not the fix):
before — CASCADA 19 -> 176 stored: the header updated live to "162 normal", the grid and its
count pill sat on "5 items" for 50s.
after — fern 54 -> 139 stored: "54 items" -> "139 items" by ~12s, no reload.
6 vitest, mutation-checked against six mutations (no invalidation at all; grid only, count pill
forgotten; predicate ignored; no channel-identity guard; completion with an unchanged count
dropped; poll-window guard dropped) — each one red on exactly the test that names it.
This commit is contained in:
@@ -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<ChannelDetail | undefined>(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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user