diff --git a/web/src/components/RealtimeEventsProvider.test.tsx b/web/src/components/RealtimeEventsProvider.test.tsx index a645956b..6ab7ad1b 100644 --- a/web/src/components/RealtimeEventsProvider.test.tsx +++ b/web/src/components/RealtimeEventsProvider.test.tsx @@ -1,6 +1,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, cleanup, render } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { adminKeys, catalogKeys, libraryKeys, sectionKeys } from "@/hooks/queries/keys"; +import { invalidateCatalogState } from "./realtimeCatalogInvalidation"; import { buildEventsUrl, RealtimeEventsProvider } from "./RealtimeEventsProvider"; const mockState = vi.hoisted(() => ({ @@ -84,6 +86,84 @@ describe("buildEventsUrl", () => { }); }); +describe("invalidateCatalogState", () => { + it("invalidates library lists for a scoped library change", async () => { + const queryClient = new QueryClient(); + const otherCatalogKey = catalogKeys.list({ + source: "section", + scope: "library", + section_id: "all", + library_id: 1, + limit: 60, + offset: 0, + }); + const changedCatalogKey = catalogKeys.list({ + source: "section", + scope: "library", + section_id: "all", + library_id: 3, + limit: 60, + offset: 0, + }); + const otherSectionKey = sectionKeys.libraryLayout(1); + const changedSectionKey = sectionKeys.libraryLayout(3); + const userLibrariesKey = libraryKeys.user("profile-1"); + + queryClient.setQueryData(adminKeys.libraries(), []); + queryClient.setQueryData(adminKeys.libraryMatchQueueStatuses(), []); + queryClient.setQueryData(userLibrariesKey, []); + queryClient.setQueryData(otherCatalogKey, { items: [] }); + queryClient.setQueryData(changedCatalogKey, { items: [] }); + queryClient.setQueryData(otherSectionKey, { sections: [] }); + queryClient.setQueryData(changedSectionKey, { sections: [] }); + + invalidateCatalogState(queryClient, { libraryId: 3, allowDashboardRefetch: false }); + await Promise.resolve(); + + expect(queryClient.getQueryState(adminKeys.libraries())?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(adminKeys.libraryMatchQueueStatuses())?.isInvalidated).toBe( + true, + ); + expect(queryClient.getQueryState(userLibrariesKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherCatalogKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(changedCatalogKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherSectionKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(changedSectionKey)?.isInvalidated).toBe(true); + }); + + it("can skip library lists for item-scoped catalog changes", async () => { + const queryClient = new QueryClient(); + const changedCatalogKey = catalogKeys.list({ + source: "section", + scope: "library", + section_id: "all", + library_id: 3, + limit: 60, + offset: 0, + }); + + queryClient.setQueryData(adminKeys.libraries(), []); + queryClient.setQueryData(adminKeys.libraryMatchQueueStatuses(), []); + queryClient.setQueryData(libraryKeys.all, []); + queryClient.setQueryData(changedCatalogKey, { items: [] }); + + invalidateCatalogState(queryClient, { + itemId: "item-1", + libraryId: 3, + allowDashboardRefetch: false, + includeLibraryLists: false, + }); + await Promise.resolve(); + + expect(queryClient.getQueryState(adminKeys.libraries())?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(adminKeys.libraryMatchQueueStatuses())?.isInvalidated).toBe( + false, + ); + expect(queryClient.getQueryState(libraryKeys.all)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(changedCatalogKey)?.isInvalidated).toBe(true); + }); +}); + describe("RealtimeEventsProvider", () => { beforeEach(() => { FakeWebSocket.instances = []; diff --git a/web/src/components/RealtimeEventsProvider.tsx b/web/src/components/RealtimeEventsProvider.tsx index 36ee0595..803249c4 100644 --- a/web/src/components/RealtimeEventsProvider.tsx +++ b/web/src/components/RealtimeEventsProvider.tsx @@ -18,9 +18,10 @@ import { type RealtimeConnectionState, type RealtimeEventsContextValue, } from "@/components/realtimeEventsContext"; +import { invalidateCatalogState } from "@/components/realtimeCatalogInvalidation"; import { useAuth } from "@/hooks/useAuth"; import { usePageActivity } from "@/hooks/usePageActivity"; -import { adminKeys, catalogKeys, historyImportKeys, libraryKeys } from "@/hooks/queries/keys"; +import { adminKeys, historyImportKeys, libraryKeys } from "@/hooks/queries/keys"; import { invalidateMediaSurfaceQueries, updateCatalogItemDetail, @@ -46,7 +47,12 @@ interface UserStatePayload { in_watchlist?: boolean; } -const CATALOG_ITEM_CHANGED_EVENTS = new Set(["metadata.updated", "catalog.item.changed"]); +const CATALOG_ITEM_CHANGED_EVENTS = new Set([ + "catalog.item.changed", + "library.item_added", + "metadata.updated", +]); +const SCOPED_CATALOG_LIBRARY_EVENTS = new Set(["catalog.library.changed", "library.changed"]); const DASHBOARD_QUERY_KEYS = [ adminKeys.stats(), adminKeys.sessions(), @@ -178,25 +184,12 @@ function invalidateDashboardQueries(queryClient: QueryClient, allowRefetch: bool } } -function invalidateCatalogState( - queryClient: QueryClient, - options: { itemId?: string; allowDashboardRefetch: boolean }, -) { - const { itemId, allowDashboardRefetch } = options; - void invalidateMediaSurfaceQueries(queryClient, itemId ? { itemId } : {}).then(() => { - bumpHomeRefreshSignal(queryClient); - }); - void queryClient.refetchQueries({ queryKey: catalogKeys.all, type: "active" }); - void queryClient.invalidateQueries({ - queryKey: adminKeys.libraries(), - refetchType: allowDashboardRefetch ? "active" : "none", - }); - void queryClient.invalidateQueries({ queryKey: adminKeys.libraryMatchQueueStatuses() }); - void queryClient.invalidateQueries({ - queryKey: adminKeys.stats(), - refetchType: allowDashboardRefetch ? "active" : "none", - }); - void queryClient.invalidateQueries({ queryKey: libraryKeys.all }); +function catalogEventLibraryID(data: unknown) { + if (!data || typeof data !== "object" || !("library_id" in data)) { + return undefined; + } + const value = (data as { library_id?: unknown }).library_id; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function handleJobSideEffects( @@ -473,18 +466,29 @@ export function RealtimeEventsProvider({ children }: { children: ReactNode }) { function handleEvent(message: EventsEventMessage) { switch (message.channel) { case "catalog": - if (CATALOG_ITEM_CHANGED_EVENTS.has(message.event)) { - invalidateCatalogState(queryClient, { - itemId: - typeof message.data === "object" && message.data && "content_id" in message.data - ? (message.data as { content_id?: string }).content_id - : undefined, - allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current, - }); - } else { - invalidateCatalogState(queryClient, { - allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current, - }); + { + const eventLibraryID = catalogEventLibraryID(message.data); + if (CATALOG_ITEM_CHANGED_EVENTS.has(message.event)) { + invalidateCatalogState(queryClient, { + itemId: + typeof message.data === "object" && message.data && "content_id" in message.data + ? (message.data as { content_id?: string }).content_id + : undefined, + libraryId: eventLibraryID, + allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current, + includeLibraryLists: false, + }); + } else if (SCOPED_CATALOG_LIBRARY_EVENTS.has(message.event) && eventLibraryID) { + invalidateCatalogState(queryClient, { + libraryId: eventLibraryID, + allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current, + }); + } else { + invalidateCatalogState(queryClient, { + libraryId: eventLibraryID, + allowDashboardRefetch: allowDashboardRealtimeUpdatesRef.current, + }); + } } break; case "jobs": diff --git a/web/src/components/realtimeCatalogInvalidation.ts b/web/src/components/realtimeCatalogInvalidation.ts new file mode 100644 index 00000000..1ece19d9 --- /dev/null +++ b/web/src/components/realtimeCatalogInvalidation.ts @@ -0,0 +1,31 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { adminKeys, libraryKeys } from "@/hooks/queries/keys"; +import { invalidateMediaSurfaceQueries } from "@/hooks/queries/mediaSurfaceRefresh"; +import { bumpHomeRefreshSignal } from "@/pages/homeSurfaceRefresh"; + +export function invalidateCatalogState( + queryClient: QueryClient, + options: { + itemId?: string; + libraryId?: number; + allowDashboardRefetch: boolean; + includeLibraryLists?: boolean; + }, +) { + const { itemId, libraryId, allowDashboardRefetch, includeLibraryLists = true } = options; + void invalidateMediaSurfaceQueries(queryClient, { itemId, libraryId }).then(() => { + bumpHomeRefreshSignal(queryClient); + }); + if (includeLibraryLists) { + void queryClient.invalidateQueries({ + queryKey: adminKeys.libraries(), + refetchType: allowDashboardRefetch ? "active" : "none", + }); + void queryClient.invalidateQueries({ queryKey: adminKeys.libraryMatchQueueStatuses() }); + void queryClient.invalidateQueries({ queryKey: libraryKeys.all }); + } + void queryClient.invalidateQueries({ + queryKey: adminKeys.stats(), + refetchType: allowDashboardRefetch ? "active" : "none", + }); +} diff --git a/web/src/hooks/queries/mediaSurfaceRefresh.test.ts b/web/src/hooks/queries/mediaSurfaceRefresh.test.ts index 6887d5cd..f8821ffe 100644 --- a/web/src/hooks/queries/mediaSurfaceRefresh.test.ts +++ b/web/src/hooks/queries/mediaSurfaceRefresh.test.ts @@ -91,6 +91,51 @@ describe("invalidateMediaSurfaceQueries", () => { expect(queryClient.getQueryState(catalogKeys.itemDetail("item-1"))?.isInvalidated).toBe(true); }); + it("does not invalidate catalog list queries for a different library scope", async () => { + const queryClient = new QueryClient(); + const moviesKey = catalogKeys.list({ + source: "section", + scope: "library", + section_id: "all", + library_id: 1, + limit: 60, + offset: 0, + }); + const internationalKey = catalogKeys.list({ + source: "section", + scope: "library", + section_id: "all", + library_id: 3, + limit: 60, + offset: 0, + }); + + queryClient.setQueryData(moviesKey, { items: [] }); + queryClient.setQueryData(internationalKey, { items: [] }); + + await invalidateMediaSurfaceQueries(queryClient, { libraryId: 3 }); + + expect(queryClient.getQueryState(moviesKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(internationalKey)?.isInvalidated).toBe(true); + }); + + it("does not invalidate library section queries for a different library scope", async () => { + const queryClient = new QueryClient(); + const moviesLayoutKey = sectionKeys.libraryLayout(1); + const moviesSectionKey = sectionKeys.libraryItems(1, "recently-added"); + const internationalLayoutKey = sectionKeys.libraryLayout(3); + + queryClient.setQueryData(moviesLayoutKey, { sections: [] }); + queryClient.setQueryData(moviesSectionKey, { section: { id: "recently-added", items: [] } }); + queryClient.setQueryData(internationalLayoutKey, { sections: [] }); + + await invalidateMediaSurfaceQueries(queryClient, { libraryId: 3 }); + + expect(queryClient.getQueryState(moviesLayoutKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(moviesSectionKey)?.isInvalidated).toBe(false); + expect(queryClient.getQueryState(internationalLayoutKey)?.isInvalidated).toBe(true); + }); + it("sets all cached detail keys for the mutated item", () => { const queryClient = new QueryClient(); diff --git a/web/src/hooks/queries/mediaSurfaceRefresh.ts b/web/src/hooks/queries/mediaSurfaceRefresh.ts index 160da0c3..3cafbe93 100644 --- a/web/src/hooks/queries/mediaSurfaceRefresh.ts +++ b/web/src/hooks/queries/mediaSurfaceRefresh.ts @@ -12,9 +12,14 @@ import { sectionKeys, watchlistKeys, } from "./keys"; +import { + activeCatalogQueryMatchesLibrary, + activeSectionQueryMatchesLibrary, +} from "@/lib/queryInvalidation"; interface InvalidateMediaSurfaceOptions { itemId?: string; + libraryId?: number; watchedKeys?: Array; } @@ -96,8 +101,14 @@ export async function invalidateMediaSurfaceQueries( ) { const invalidations: Array> = [ queryClient.invalidateQueries({ queryKey: itemKeys.all }), - queryClient.invalidateQueries({ queryKey: catalogKeys.all }), - queryClient.invalidateQueries({ queryKey: sectionKeys.all }), + queryClient.invalidateQueries({ + queryKey: catalogKeys.all, + predicate: (query) => activeCatalogQueryMatchesLibrary(query.queryKey, options.libraryId), + }), + queryClient.invalidateQueries({ + queryKey: sectionKeys.all, + predicate: (query) => activeSectionQueryMatchesLibrary(query.queryKey, options.libraryId), + }), queryClient.invalidateQueries({ queryKey: progressKeys.all }), queryClient.invalidateQueries({ queryKey: historyKeys.all }), queryClient.invalidateQueries({ queryKey: favoriteKeys.all }), diff --git a/web/src/lib/queryInvalidation.test.ts b/web/src/lib/queryInvalidation.test.ts new file mode 100644 index 00000000..7f272d20 --- /dev/null +++ b/web/src/lib/queryInvalidation.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + activeCatalogQueryMatchesLibrary, + activeSectionQueryMatchesLibrary, +} from "./queryInvalidation"; + +describe("activeCatalogQueryMatchesLibrary", () => { + it("does not match a catalog list query for a different library event", () => { + expect(activeCatalogQueryMatchesLibrary(["catalog", "list", { library_id: 1 }], 3)).toBe(false); + }); + + it("matches unscoped and same-library catalog queries", () => { + expect(activeCatalogQueryMatchesLibrary(["catalog", "list", { library_id: 3 }], 3)).toBe(true); + expect(activeCatalogQueryMatchesLibrary(["catalog", "list", {}], 3)).toBe(true); + }); +}); + +describe("activeSectionQueryMatchesLibrary", () => { + it("does not match a library section query for a different library event", () => { + expect(activeSectionQueryMatchesLibrary(["sections", "library", 1, "items"], 3)).toBe(false); + }); + + it("matches non-library and same-library section queries", () => { + expect(activeSectionQueryMatchesLibrary(["sections", "home", "items"], 3)).toBe(true); + expect(activeSectionQueryMatchesLibrary(["sections", "library", 3, "layout"], 3)).toBe(true); + }); +}); diff --git a/web/src/lib/queryInvalidation.ts b/web/src/lib/queryInvalidation.ts new file mode 100644 index 00000000..4952fe3f --- /dev/null +++ b/web/src/lib/queryInvalidation.ts @@ -0,0 +1,13 @@ +export function activeCatalogQueryMatchesLibrary(queryKey: unknown, libraryId?: number) { + if (!libraryId || !Array.isArray(queryKey)) return true; + if (queryKey[0] !== "catalog" || queryKey[1] !== "list") return true; + const params = queryKey[2] as { library_id?: number } | undefined; + return params?.library_id == null || params.library_id === libraryId; +} + +export function activeSectionQueryMatchesLibrary(queryKey: unknown, libraryId?: number) { + if (!libraryId || !Array.isArray(queryKey)) return true; + if (queryKey[0] !== "sections" || queryKey[1] !== "library") return true; + const queryLibraryId = queryKey[2]; + return queryLibraryId == null || queryLibraryId === libraryId; +}