fix: normalize restored dashboard state

This commit is contained in:
ajnart committed 2026-08-27 12:02:59 +02:00
1 parent 5eed557dda
commit e1ab780a0e
2 files changed
+62 -6

No files matched your search

@@ -72,6 +72,11 @@ describe("dashboard query persistence", () => {
const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
startsAt: new Date("2026-08-22T12:00:00.000Z"),
});
const cachedQuery = queryClient.getQueryCache().find({
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
});
if (!cachedQuery) throw new Error("Expected cached query to be present");
cachedQuery.setState({ ...cachedQuery.state, error: new Error("refresh failed"), status: "error" });
queryClient.getMutationCache().build(
queryClient,
{ mutationKey: ["integration", "create"], mutationFn: async () => undefined },
@@ -102,6 +107,8 @@ describe("dashboard query persistence", () => {
expect(restored?.clientState.queries[0]?.state.data).toEqual({
startsAt: new Date("2026-08-22T12:00:00.000Z"),
});
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
expect(await userB.persister.restoreClient()).toBeUndefined();
await userA.persister.removeClient();
@@ -109,11 +116,16 @@ describe("dashboard query persistence", () => {
});
test("treats unavailable session storage as an empty cache", async () => {
vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => {
const blockedStorage = createMemoryStorage();
blockedStorage.getItem = () => {
throw new DOMException("blocked", "SecurityError");
});
};
blockedStorage.removeItem = () => {
throw new DOMException("blocked", "SecurityError");
};
const persistence = createSessionQueryPersistence("blocked");
const persistence = createSessionQueryPersistence("blocked", blockedStorage);
expect(await persistence.persister.restoreClient()).toBeUndefined();
expect(() => persistence.persister.removeClient()).not.toThrow();
});
});
@@ -1,7 +1,7 @@
import type { QueryKey } from "@tanstack/react-query";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
import type { PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client";
import type { PersistedClient, PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson";
import { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
@@ -21,6 +21,26 @@ interface PersistableQuery {
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey);
const serializePersistedClient = (client: PersistedClient) =>
stringify({
...client,
clientState: {
...client.clientState,
queries: client.clientState.queries.map((query) => ({
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
status: "success",
},
})),
},
});
const getSessionStorage = () => {
if (typeof window === "undefined") return undefined;
@@ -31,6 +51,30 @@ const getSessionStorage = () => {
}
};
const protectStorage = (storage: Storage | undefined) => {
if (!storage) return undefined;
return {
getItem(key: string) {
try {
return storage.getItem(key);
} catch {
return null;
}
},
setItem(key: string, value: string) {
storage.setItem(key, value);
},
removeItem(key: string) {
try {
storage.removeItem(key);
} catch {
return undefined;
}
},
};
};
export type SessionQueryPersistence = PersistQueryClientProviderProps["persistOptions"];
export const createSessionQueryPersistence = (
@@ -38,9 +82,9 @@ export const createSessionQueryPersistence = (
storage: Storage | undefined = getSessionStorage(),
): SessionQueryPersistence => ({
persister: createSyncStoragePersister({
storage,
storage: protectStorage(storage),
key: getQueryPersistenceStorageKey(scope),
serialize: stringify,
serialize: serializePersistedClient,
deserialize: parse,
retry: removeOldestQuery,
}),