Files
siftlode/frontend/src/lib/channelDetailPoll.backfill.dom.test.tsx
T
peter 782362b808 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.
2026-09-10 20:56:16 +02:00

106 lines
4.2 KiB
TypeScript

// @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 });
});
});