From e1ab780a0e49bd9054e198a1b2f9d2a0cdd6ebe1 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 27 Aug 2026 12:02:59 +0200 Subject: [PATCH] fix: normalize restored dashboard state --- .../query-persistence.spec.ts | 18 +++++-- .../_client-providers/query-persistence.ts | 50 +++++++++++++++++-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts index 79003ea6d..d7324e679 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts @@ -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(); }); }); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts index 77dc9902b..ce60cfb92 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -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, }),