Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cef3f42a0b | ||
|
|
319a083c6d |
No files matched your search
@@ -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<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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) });
|
||||
}}
|
||||
>
|
||||
<ReactQueryStreamedHydration transformer={superjson}>{props.children}</ReactQueryStreamedHydration>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { BoardItemMenu } from "./item-menu";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
import { removePersistedWidgetQueries } from "./widget-query-recovery";
|
||||
|
||||
interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
@@ -112,7 +113,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
reset();
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
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", () => {
|
||||
test("removes stale widget queries and allows fresh data to be fetched", async () => {
|
||||
const queryClient = new QueryClient();
|
||||
const crashedWidgetQueryKey = [["widget", "mediaServer", "getCurrentStreams"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
|
||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
|
||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
await expect(
|
||||
queryClient.fetchQuery({ queryKey: crashedWidgetQueryKey, queryFn: () => Promise.resolve("fresh") }),
|
||||
).resolves.toBe("fresh");
|
||||
});
|
||||
|
||||
test("removes persisted queries outside the widget router", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const dockerQueryKey = [["docker", "getContainers"], { input: {} }];
|
||||
const appQueryKey = [["app", "byId"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
|
||||
queryClient.setQueryData(dockerQueryKey, "stale");
|
||||
queryClient.setQueryData(appQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
|
||||
expect(queryClient.getQueryData(dockerQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(appQueryKey)).toBeUndefined();
|
||||
expect(queryClient.getQueryData(unrelatedQueryKey)).toBe("unrelated");
|
||||
});
|
||||
|
||||
test("removes persisted widget queries when the widget kind and query path differ", () => {
|
||||
const queryClient = new QueryClient();
|
||||
const crashedWidgetQueryKey = [["widget", "anchorNotes", "getNote"], { input: {} }];
|
||||
const unrelatedQueryKey = [["user", "me"], { input: {} }];
|
||||
|
||||
queryClient.setQueryData(crashedWidgetQueryKey, "stale");
|
||||
queryClient.setQueryData(unrelatedQueryKey, "unrelated");
|
||||
|
||||
removePersistedWidgetQueries(queryClient);
|
||||
|
||||
expect(queryClient.getQueryData(crashedWidgetQueryKey)).toBeUndefined();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { isPersistableWidgetQueryKey } from "@homarr/api/query-cache";
|
||||
|
||||
export const removePersistedWidgetQueries = (queryClient: QueryClient) => {
|
||||
queryClient.removeQueries({ predicate: (query) => isPersistableWidgetQueryKey(query.queryKey) });
|
||||
};
|
||||
Reference in new issue
Block a user