From 3810af66da875d90e64d02ceae3c1f61606041e6 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 27 Sep 2026 15:07:39 +0200 Subject: [PATCH] fix(channels): every subscribe and unsubscribe refreshes the same reads Each of the four sites kept its own invalidation list and each missed something (the channel menu's unsubscribe left the feed showing the channel's videos; the channel page's left the counts stale). lib/subscriptionCache invalidateSubscription is the one set; a contract test holds every call site to it. --- frontend/src/components/ChannelDiscovery.tsx | 5 +-- frontend/src/components/ChannelPage.tsx | 14 ++----- frontend/src/components/useChannelActions.tsx | 4 +- frontend/src/lib/subscriptionCache.test.ts | 38 +++++++++++++++++++ frontend/src/lib/subscriptionCache.ts | 19 ++++++++++ 5 files changed, 64 insertions(+), 16 deletions(-) create mode 100644 frontend/src/lib/subscriptionCache.test.ts create mode 100644 frontend/src/lib/subscriptionCache.ts 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 }); +}