diff --git a/frontend/src/components/ChannelDiscovery.tsx b/frontend/src/components/ChannelDiscovery.tsx index f5a49278..ef651069 100644 --- a/frontend/src/components/ChannelDiscovery.tsx +++ b/frontend/src/components/ChannelDiscovery.tsx @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; +import { invalidateSubscription } from "../lib/subscriptionCache"; import { UserPlus } from "lucide-react"; import { api, type DiscoveredChannel } from "../lib/api"; import { accountKey, LS } from "../lib/storage"; @@ -58,9 +59,7 @@ export default function ChannelDiscovery({ onSuccess: (_data, c) => { // The channel moves from "discovery" to "subscribed", and its videos will start // arriving — refresh both lists plus the per-user status. - qc.invalidateQueries({ queryKey: qk.discoveredChannels() }); - qc.invalidateQueries({ queryKey: qk.channels() }); - qc.invalidateQueries({ queryKey: qk.myStatus() }); + invalidateSubscription(qc, c.id); const name = c.title ?? c.id; // Name the channel and ship a typed payload so the inbox can offer "open in the // Channel manager" / "open on YouTube" links — kept after reload, when live diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 86a400a3..4e436f9f 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useLiveQuery } from "../lib/useLiveQuery"; import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll"; import { qk } from "../lib/queryKeys"; +import { invalidateSubscription } from "../lib/subscriptionCache"; import { useNavigationActions } from "./navigationContext"; import { PriorityStepper } from "./ui/PriorityStepper"; import { useChannelPriority } from "../lib/useChannelPriority"; @@ -181,12 +182,7 @@ export default function ChannelPage({ const subscribe = useMutation({ mutationFn: () => api.subscribeChannel(channelId), onSuccess: () => { - qc.invalidateQueries({ queryKey: qk.channel(channelId) }); - qc.invalidateQueries({ queryKey: qk.feed() }); - qc.invalidateQueries({ queryKey: qk.channels() }); - // Match ChannelDiscovery: the channel leaves discovery and the manager's stats move. - qc.invalidateQueries({ queryKey: qk.myStatus() }); - qc.invalidateQueries({ queryKey: qk.discoveredChannels() }); + invalidateSubscription(qc, channelId); notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) }); }, // A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle @@ -195,11 +191,7 @@ export default function ChannelPage({ }); const unsubscribe = useMutation({ mutationFn: () => api.unsubscribeChannel(channelId), - onSuccess: () => { - qc.invalidateQueries({ queryKey: qk.channel(channelId) }); - qc.invalidateQueries({ queryKey: qk.feed() }); - qc.invalidateQueries({ queryKey: qk.channels() }); - }, + onSuccess: () => invalidateSubscription(qc, channelId), onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")), }); // "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's diff --git a/frontend/src/components/useChannelActions.tsx b/frontend/src/components/useChannelActions.tsx index 695c5349..0c123d61 100644 --- a/frontend/src/components/useChannelActions.tsx +++ b/frontend/src/components/useChannelActions.tsx @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { ArrowUpDown, EyeOff, ExternalLink, History, Tags, Tv, UserMinus } from "lucide-react"; import { qk } from "../lib/queryKeys"; +import { invalidateSubscription } from "../lib/subscriptionCache"; import { api, type ManagedChannel, type Tag } from "../lib/api"; import { priorityChoices } from "../lib/channelOrder"; import { channelYouTubeUrl } from "../lib/format"; @@ -79,8 +80,7 @@ export function useChannelActions({ const unsubscribe = useMutation({ mutationFn: (c: ManagedChannel) => api.unsubscribeChannel(c.id), onSuccess: (_d, c) => { - qc.invalidateQueries({ queryKey: qk.channels() }); - qc.invalidateQueries({ queryKey: qk.myStatus() }); + invalidateSubscription(qc, c.id); notify({ level: "success", message: t("channels.notify.unsubscribed", { name: nameOf(c) }) }); }, onError: (e) => diff --git a/frontend/src/lib/subscriptionCache.test.ts b/frontend/src/lib/subscriptionCache.test.ts new file mode 100644 index 00000000..90fd873f --- /dev/null +++ b/frontend/src/lib/subscriptionCache.test.ts @@ -0,0 +1,38 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; + +import { qk } from "./queryKeys"; +import { invalidateSubscription } from "./subscriptionCache"; + +// E28 review L8: a subscription change touches the same reads wherever it is made, and each site kept +// its own list — the channel menu's unsubscribe left the feed showing the channel's videos, the +// channel page's left the status counts stale. One set, one function, every site on it. +describe("a subscription change", () => { + it("refreshes the channel, the lists, the counts and the feed", () => { + const qc = new QueryClient(); + const spy = vi.spyOn(qc, "invalidateQueries"); + invalidateSubscription(qc, "UC1"); + expect(spy.mock.calls.map(([f]) => f?.queryKey)).toEqual([ + qk.channel("UC1"), + qk.channels(), + qk.myStatus(), + qk.feed(), + qk.feedSummary(), + qk.discoveredChannels(), + ]); + }); + + it("is refreshed through that one function wherever it is made", () => { + const root = new URL("../components/", import.meta.url); + const sites = readdirSync(root) + .filter((f) => f.endsWith(".tsx") && !f.includes(".test.")) + .map((f) => [f, readFileSync(new URL(f, root), "utf8")] as const) + .filter(([, src]) => /api\.(un)?subscribeChannel\(/.test(src)); + expect(sites.length).toBeGreaterThanOrEqual(3); + for (const [f, src] of sites) { + const calls = src.match(/api\.(un)?subscribeChannel\(/g)!.length; + expect(src.match(/invalidateSubscription\(/g)?.length ?? 0, f).toBe(calls); + } + }); +}); diff --git a/frontend/src/lib/subscriptionCache.ts b/frontend/src/lib/subscriptionCache.ts new file mode 100644 index 00000000..aa1cdb85 --- /dev/null +++ b/frontend/src/lib/subscriptionCache.ts @@ -0,0 +1,19 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { qk } from "./queryKeys"; + +/** Everything a subscribe or an unsubscribe changes: the channel itself, the manager's list and its + * counts, the feed and its facets (the channel's videos come or go), and discovery (a subscribed + * channel leaves it). ONE set for every site that makes the change (E28 review L8): each used to keep + * its own list, and each list missed something. */ +export function invalidateSubscription(qc: QueryClient, channelId: string): void { + for (const queryKey of [ + qk.channel(channelId), + qk.channels(), + qk.myStatus(), + qk.feed(), + qk.feedSummary(), + qk.discoveredChannels(), + ]) + void qc.invalidateQueries({ queryKey }); +}