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:
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { channelDetailPollMs } from "../lib/channelDetailPoll";
|
import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useNavigationActions } from "./NavigationProvider";
|
import { useNavigationActions } from "./NavigationProvider";
|
||||||
import { PriorityStepper } from "./ui/PriorityStepper";
|
import { PriorityStepper } from "./ui/PriorityStepper";
|
||||||
@@ -93,6 +93,10 @@ export default function ChannelPage({
|
|||||||
});
|
});
|
||||||
const ch = detail.data;
|
const ch = detail.data;
|
||||||
useEffect(() => setBannerAttempt(0), [ch?.banner_url]); // fresh channel/banner → try again
|
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.
|
// Background auto-ingest ("explore") of an un-subscribed channel's uploads.
|
||||||
const [nextToken, setNextToken] = useState<string | null | undefined>(undefined);
|
const [nextToken, setNextToken] = useState<string | null | undefined>(undefined);
|
||||||
|
|||||||
@@ -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> = {}): 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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";
|
import { type ChannelDetail } from "./api";
|
||||||
|
|
||||||
/** How often the channel page re-polls its detail (via useLiveQuery): every 4s while THIS user's
|
/** 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. */
|
* the user never requested it. Extracted so the poll-or-stop decision is unit-testable. */
|
||||||
export const channelDetailPollMs = (d: ChannelDetail | undefined): number | false =>
|
export const channelDetailPollMs = (d: ChannelDetail | undefined): number | false =>
|
||||||
d?.deep_requested && !d.backfill_done ? 4000 : 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