refactor(fe): query-key factory + migrate feed/channels/playlists (R7 S2a)

Introduce lib/queryKeys.ts as the single source of truth for React Query
keys — the R7 driver was ~200 hand-written key literals across 51 files
(`["feed"]` alone in 15+), where one typo silently orphans a key so an
invalidate never matches.

S2a migrates the three highest-traffic domains (feed/video, channels,
playlists) plus the shared me/my-status/tags roots: ~90 literal sites in
18 files now call qk.*(). Pure substitution — each factory fn returns the
byte-identical array the call sites used before, so TanStack's prefix
matching is unchanged (qk.feed() still invalidates qk.feed(filters)).
Co-invalidation clusters are deliberately NOT bundled into helpers (they
differ per site), so no query's refetch scope changes.

Nullable keys (playlist(selectedId), ytSearch(q)) accept null so a null
segment is preserved (["playlist", null]) while a no-arg call is the bare
prefix key — guard is `!== undefined`.

Gate green: typecheck, eslint (0 err), prettier, 68 vitest. Smoke: app
boots, feed/facets/tags/my-status all 200, no console errors. Factory
extended to plex/messaging/admin/downloads/notifications in S2b.
This commit is contained in:
2026-07-27 21:48:35 +02:00
parent d0aa1cf9a7
commit ad86d6d126
19 changed files with 169 additions and 107 deletions
+12 -11
View File
@@ -6,6 +6,7 @@ import {
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
ArrowDown,
ArrowUp,
@@ -155,7 +156,7 @@ export default function Feed({
const queryFilters: FeedFilters = { ...filters, q: debouncedQ };
const query = useInfiniteQuery({
queryKey: ["feed", queryFilters],
queryKey: qk.feed(queryFilters),
queryFn: ({ pageParam }) => api.feed(queryFilters, pageParam as string | null, PAGE),
initialPageParam: null as string | null,
getNextPageParam: (last) => last.next_cursor ?? undefined,
@@ -168,7 +169,7 @@ export default function Feed({
// retry:false so a 429 (quota/limit) surfaces at once for inline display. staleTime keeps a
// revisited search from re-spending.
const ytQuery = useInfiniteQuery({
queryKey: ["yt-search", ytSearch, ytCount],
queryKey: qk.ytSearch(ytSearch, ytCount),
queryFn: ({ pageParam }) =>
api.searchYoutube(ytSearch as string, pageParam as string | null, ytCount),
initialPageParam: null as string | null,
@@ -225,7 +226,7 @@ export default function Feed({
}, [query.dataUpdatedAt]);
const countQuery = useQuery({
queryKey: ["feed-count", queryFilters],
queryKey: qk.feedCount(queryFilters),
queryFn: () => api.feedCount(queryFilters),
staleTime: 30_000,
enabled: !ytActive,
@@ -250,7 +251,7 @@ export default function Feed({
api
.setState(id, status)
.then(() => {
qc.invalidateQueries({ queryKey: ["feed"] });
qc.invalidateQueries({ queryKey: qk.feed() });
if (status === "hidden") {
const v = loadedRef.current.find((x) => x.id === id);
notify({
@@ -307,7 +308,7 @@ export default function Feed({
setOverrides((o) => ({ ...o, [id]: "new" }));
api
.clearState(id)
.then(() => qc.invalidateQueries({ queryKey: ["feed"] }))
.then(() => qc.invalidateQueries({ queryKey: qk.feed() }))
.catch(() =>
setOverrides((o) => {
const next = { ...o };
@@ -324,8 +325,8 @@ export default function Feed({
setSavedOverrides((o) => ({ ...o, [id]: saved }));
(saved ? api.watchLaterAdd(id) : api.watchLaterRemove(id))
.then(() => {
qc.invalidateQueries({ queryKey: ["playlists"] });
qc.invalidateQueries({ queryKey: ["playlist"] });
qc.invalidateQueries({ queryKey: qk.playlists() });
qc.invalidateQueries({ queryKey: qk.playlist() });
})
.catch(() => setSavedOverrides((o) => ({ ...o, [id]: !saved })));
},
@@ -355,9 +356,9 @@ export default function Feed({
// A live search ingests new catalog videos, so the disabled feed queries still hold their stale
// pre-search cache. Drop them so the feed re-fetches fresh on return.
const dropFeedCaches = () => {
qc.removeQueries({ queryKey: ["feed"] });
qc.removeQueries({ queryKey: ["feed-count"] });
qc.removeQueries({ queryKey: ["facets"] });
qc.removeQueries({ queryKey: qk.feed() });
qc.removeQueries({ queryKey: qk.feedCount() });
qc.removeQueries({ queryKey: qk.facets() });
};
// --- Live YouTube search mode -------------------------------------------------------------
@@ -435,7 +436,7 @@ export default function Feed({
setFilters({ ...filters, librarySource: "organic", sort: "newest" });
onExitYtSearch();
dropFeedCaches();
qc.removeQueries({ queryKey: ["yt-search"] });
qc.removeQueries({ queryKey: qk.ytSearch() });
}}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
>