Fix scoped catalog refresh invalidation (#51)

* Fix scoped catalog refresh invalidation

* Remove duplicate catalog refetch

* fix(web): keep library lists fresh on scoped catalog events

---------

Co-authored-by: RXWatcher <14085001+RXWatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
This commit is contained in:
RXWatcher
2026-06-07 18:15:34 -04:00
committed by GitHub
co-authored by Quick
parent 480ca44306
commit 6b1554a86c
7 changed files with 246 additions and 35 deletions
@@ -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 = [];
+37 -33
View File
@@ -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":
@@ -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",
});
}
@@ -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();
+13 -2
View File
@@ -12,9 +12,14 @@ import {
sectionKeys,
watchlistKeys,
} from "./keys";
import {
activeCatalogQueryMatchesLibrary,
activeSectionQueryMatchesLibrary,
} from "@/lib/queryInvalidation";
interface InvalidateMediaSurfaceOptions {
itemId?: string;
libraryId?: number;
watchedKeys?: Array<readonly unknown[]>;
}
@@ -96,8 +101,14 @@ export async function invalidateMediaSurfaceQueries(
) {
const invalidations: Array<Promise<void>> = [
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 }),
+27
View File
@@ -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);
});
});
+13
View File
@@ -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;
}