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:
committed by
GitHub
co-authored by
Quick
parent
480ca44306
commit
6b1554a86c
@@ -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 = [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user