diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-cache-error-cleanup.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-cache-error-cleanup.ts new file mode 100644 index 000000000..d3a69cf00 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-cache-error-cleanup.ts @@ -0,0 +1,29 @@ +import type { QueryClient } from "@tanstack/react-query"; + +import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache"; + +export const createQueryCacheErrorCleanup = (queryClient: QueryClient): (() => void) => { + const wasSuccessful = new Set(); + + return queryClient.getQueryCache().subscribe((event) => { + const query = event.query; + const key = query.queryHash; + + if (event.type === "removed") { + wasSuccessful.delete(key); + return; + } + + if (event.type !== "updated") return; + + if (query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey)) { + wasSuccessful.add(key); + return; + } + + if (query.state.status === "error" && wasSuccessful.has(key)) { + queryClient.resetQueries({ queryKey: query.queryKey, exact: true }); + wasSuccessful.delete(key); + } + }); +}; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 3e167dd8c..5a4c0987b 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -1,7 +1,7 @@ "use client"; import type { PropsWithChildren } from "react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { QueryKey } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; @@ -36,6 +36,7 @@ import { env } from "@homarr/common/env"; import { showWarningNotification } from "@homarr/notifications"; import { widgetImports } from "@homarr/widgets"; +import { createQueryCacheErrorCleanup } from "./query-cache-error-cleanup"; import { createWidgetQueryPersister } from "./query-cache-persister"; const getWebSocketProtocol = () => { @@ -102,6 +103,10 @@ export function TRPCReactProvider(props: PropsWithChildren) { return client; }); + useEffect(() => { + return createQueryCacheErrorCleanup(queryClient); + }, [queryClient]); + const [trpcClient] = useState(() => { return clientApi.createClient({ links: [ @@ -162,6 +167,9 @@ export function TRPCReactProvider(props: PropsWithChildren) { query.state.status === "success" && isPersistableWidgetQueryKey(query.queryKey), }, }} + onSuccess={() => { + queryClient.invalidateQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) }); + }} > {props.children} diff --git a/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts b/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts index b666df4e9..bfb207dbf 100644 --- a/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts +++ b/apps/nextjs/src/components/board/items/widget-query-recovery.spec.ts @@ -1,6 +1,9 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, test } from "vitest"; +import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache"; + +import { createQueryCacheErrorCleanup } from "~/app/[locale]/_client-providers/query-cache-error-cleanup"; import { removePersistedWidgetQueries } from "./widget-query-recovery"; describe("removePersistedWidgetQueries", () => { @@ -52,3 +55,103 @@ describe("removePersistedWidgetQueries", () => { expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated"); }); }); + +describe("query cache error cleanup", () => { + test("resets data when a persistable query transitions from success to error", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const unsubscribe = createQueryCacheErrorCleanup(queryClient); + + const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }]; + const userKey = [["user", "me"], { input: {} }]; + + queryClient.setQueryData(widgetKey, { old: true }); + queryClient.setQueryData(userKey, { user: true }); + + await queryClient + .fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("API down")) }) + .catch(() => {}); + + expect(queryClient.getQueryData(widgetKey)).toBeUndefined(); + expect(queryClient.getQueryData(userKey)).toEqual({ user: true }); + + unsubscribe(); + }); + + test("preserves stale data for non-persistable query errors", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const unsubscribe = createQueryCacheErrorCleanup(queryClient); + + const boardKey = [["board", "getBoardByName"], { input: {} }]; + + queryClient.setQueryData(boardKey, { boards: [] }); + + await queryClient + .fetchQuery({ queryKey: boardKey, queryFn: () => Promise.reject(new Error("API down")) }) + .catch(() => {}); + + expect(queryClient.getQueryData(boardKey)).toEqual({ boards: [] }); + + unsubscribe(); + }); + + test("handles success→error→success→error cycling without getting stuck", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const unsubscribe = createQueryCacheErrorCleanup(queryClient); + + const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }]; + + queryClient.setQueryData(widgetKey, "first-success"); + await queryClient.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.resolve("second-success") }); + expect(queryClient.getQueryData(widgetKey)).toBe("second-success"); + + await queryClient + .fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("boom")) }) + .catch(() => {}); + expect(queryClient.getQueryData(widgetKey)).toBeUndefined(); + + await queryClient.fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.resolve("third-success") }); + expect(queryClient.getQueryData(widgetKey)).toBe("third-success"); + + await queryClient + .fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("boom-2")) }) + .catch(() => {}); + expect(queryClient.getQueryData(widgetKey)).toBeUndefined(); + + unsubscribe(); + }); + + test("unsubscribe stops processing further events", async () => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const unsubscribe = createQueryCacheErrorCleanup(queryClient); + + const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }]; + queryClient.setQueryData(widgetKey, "stale"); + + unsubscribe(); + + await queryClient + .fetchQuery({ queryKey: widgetKey, queryFn: () => Promise.reject(new Error("API down")) }) + .catch(() => {}); + + expect(queryClient.getQueryData(widgetKey)).toBe("stale"); + }); +}); + +describe("rehydration marks persistable data as stale", () => { + test("rehydrated queries are invalidated so they refetch on mount", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0 } }, + }); + + const widgetKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }]; + const userKey = [["user", "me"], { input: {} }]; + + queryClient.setQueryData(widgetKey, { rehydrated: true }); + queryClient.setQueryData(userKey, { rehydrated: true }); + + queryClient.invalidateQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) }); + + expect(queryClient.getQueryState(widgetKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(userKey)?.isInvalidated).toBe(false); + }); +});