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:
@@ -0,0 +1,44 @@
|
||||
import type { FeedFilters } from "./api";
|
||||
|
||||
// Single source of truth for React Query keys. Every key root lives here exactly once, so a typo
|
||||
// at one of the ~200 call sites can no longer mint an orphan key that silently never invalidates
|
||||
// (the R7 driver: `["feed"]` was hand-written in 15+ files). CONTRACT: each function returns the
|
||||
// SAME array shape the call sites used before, so TanStack's partial (prefix) matching is
|
||||
// unchanged — `qk.feed()` (`["feed"]`) still invalidates `qk.feed(filters)` (`["feed", filters]`).
|
||||
// Deliberately NOT bundling co-invalidations into helpers here: the clusters differ per call site
|
||||
// (some invalidate `feed` alone, others `feed` + `feed-count`), so collapsing them would change
|
||||
// which queries refetch. Grouped invalidation stays opt-in per domain (see `invalidateDownloads`).
|
||||
// Extended per domain as R7 S2 migrates each area (S2a: feed/channels/playlists + shared roots).
|
||||
export const qk = {
|
||||
// --- shared roots (invalidated from more than one domain) ---
|
||||
me: () => ["me"] as const,
|
||||
myStatus: () => ["my-status"] as const,
|
||||
tags: () => ["tags"] as const,
|
||||
|
||||
// --- feed / video ---
|
||||
feed: (filters?: FeedFilters) => (filters ? (["feed", filters] as const) : (["feed"] as const)),
|
||||
feedCount: (filters?: FeedFilters) =>
|
||||
filters ? (["feed-count", filters] as const) : (["feed-count"] as const),
|
||||
facets: (filters?: FeedFilters) =>
|
||||
filters ? (["facets", filters] as const) : (["facets"] as const),
|
||||
// `q`/`id` accept null (not just undefined) because the call sites key on nullable state
|
||||
// (`selectedId`, the search string): a NULL is a real key segment and must be preserved
|
||||
// (`["playlist", null]`), while a genuinely absent arg is the bare prefix key. The guard is
|
||||
// `!== undefined`, so only the no-arg call collapses to the prefix.
|
||||
ytSearch: (q?: string | null, count?: number) =>
|
||||
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
|
||||
videoDetail: (id: string) => ["video-detail", id] as const,
|
||||
|
||||
// --- channels ---
|
||||
channels: () => ["channels"] as const,
|
||||
channel: (id: string) => ["channel", id] as const,
|
||||
discoveredChannels: () => ["discovered-channels"] as const,
|
||||
blockedChannels: () => ["blocked-channels"] as const,
|
||||
|
||||
// --- playlists ---
|
||||
playlists: () => ["playlists"] as const,
|
||||
playlist: (id?: number | null) =>
|
||||
id !== undefined ? (["playlist", id] as const) : (["playlist"] as const),
|
||||
playlistMembership: (videoId: string) => ["playlist-membership", videoId] as const,
|
||||
savedViews: () => ["saved-views"] as const,
|
||||
};
|
||||
Reference in New Issue
Block a user