From a845d57d571b733acfe4309dacd79ed4280a481e Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 27 Aug 2026 11:21:57 +0200 Subject: [PATCH] perf: simplify dashboard query persistence --- apps/nextjs/package.json | 1 + .../query-persistence.spec.ts | 339 ++---------------- .../_client-providers/query-persistence.ts | 328 ++--------------- .../app/[locale]/_client-providers/trpc.tsx | 40 +-- packages/api/src/query-cache.ts | 10 - pnpm-lock.yaml | 14 + pnpm-workspace.yaml | 1 + 7 files changed, 78 insertions(+), 655 deletions(-) diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 16392ff8d..f5c4794b7 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -72,6 +72,7 @@ "@rexa-developer/tiks": "catalog:", "@scalar/api-reference-react": "catalog:", "@tabler/icons-react": "catalog:", + "@tanstack/query-sync-storage-persister": "catalog:", "@tanstack/react-devtools": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "catalog:", 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 fd31a6914..d0af3f69d 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 @@ -1,29 +1,22 @@ import { dehydrate, QueryClient } from "@tanstack/react-query"; import type { PersistedClient } from "@tanstack/react-query-persist-client"; -import { persistQueryClientRestore } from "@tanstack/react-query-persist-client"; -import { parse, stringify } from "superjson"; import { afterEach, describe, expect, test, vi } from "vitest"; import { - createSessionQueryPersister, + createSessionQueryPersistence, getQueryPersistenceStorageKey, queryPersistenceBuster, - queryPersistenceMaxDataBytes, - queryPersistenceThrottleMs, shouldPersistDashboardQuery, } from "./query-persistence"; -const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => { +const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => { const queryClient = new QueryClient(); - queryClient.setQueryData(queryKey, data, { updatedAt }); + queryClient.setQueryData(queryKey, data); const query = queryClient.getQueryCache().find({ queryKey }); if (!query) throw new Error("Expected query to be present"); return { query, queryClient }; }; -const stockPriceQueryKey = (symbol = "HOMARR") => - [["widget", "stockPrice", "getPriceHistory"], { input: { symbol }, type: "query" }] as const; - const createMemoryStorage = (): Storage => { const values = new Map(); return { @@ -41,335 +34,65 @@ const createMemoryStorage = (): Storage => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); - window.sessionStorage.clear(); }); -describe("widget query persistence", () => { - test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => { - const widget = createSuccessfulQuery(stockPriceQueryKey(), { price: 21 }); - const location = createSuccessfulQuery([["widget", "weather", "atLocation"], { type: "query" }], { temp: 21 }); +describe("dashboard query persistence", () => { + test("keeps successful dashboard data and skips volatile or unrelated queries", () => { + const widget = createSuccessfulQuery([["widget", "weather", "atLocation"], { type: "query" }], { temp: 21 }); const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]); - const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" }); const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" }); - const oversized = createSuccessfulQuery(stockPriceQueryKey("OVERSIZED"), { - value: "x".repeat(queryPersistenceMaxDataBytes + 1), - }); + const beszel = createSuccessfulQuery([["widget", "beszel", "getSystemStats"], { type: "query" }], []); + const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" }); const pendingClient = new QueryClient(); const pending = pendingClient.getQueryCache().build(pendingClient, { queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }], queryFn: () => Promise.resolve([]), }); - const forbidden = createSuccessfulQuery(stockPriceQueryKey("FORBIDDEN"), [{ title: "revoked" }]); - forbidden.query.setState({ - ...forbidden.query.state, - error: Object.assign(new Error("access denied"), { data: { code: "FORBIDDEN" } }), - status: "error", - }); + const failed = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], []); + failed.query.setState({ ...failed.query.state, error: new Error("refresh failed"), status: "error" }); expect(shouldPersistDashboardQuery(widget.query)).toBe(true); - expect(shouldPersistDashboardQuery(location.query)).toBe(false); - expect(shouldPersistDashboardQuery(supporting.query)).toBe(false); - expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false); + expect(shouldPersistDashboardQuery(supporting.query)).toBe(true); expect(shouldPersistDashboardQuery(ping.query)).toBe(false); - expect(shouldPersistDashboardQuery(oversized.query)).toBe(false); + expect(shouldPersistDashboardQuery(beszel.query)).toBe(false); + expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false); expect(shouldPersistDashboardQuery(pending)).toBe(false); - expect(shouldPersistDashboardQuery(forbidden.query)).toBe(false); + expect(shouldPersistDashboardQuery(failed.query)).toBe(false); }); - test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => { - expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a")); - expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b")); - expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a")); - - const userAKey = getQueryPersistenceStorageKey("user-a"); - window.sessionStorage.setItem(userAKey, "a"); - createSessionQueryPersister("user-a").removeClient(); - expect(window.sessionStorage.getItem(userAKey)).toBeNull(); - }); - - test("evicts every other scope so signing out cannot leave data behind", () => { - const userAKey = getQueryPersistenceStorageKey("user-a"); - const anonymousKey = getQueryPersistenceStorageKey(null); - window.sessionStorage.setItem(userAKey, "a"); - window.sessionStorage.setItem(anonymousKey, "anonymous"); - window.sessionStorage.setItem("unrelated", "kept"); - - createSessionQueryPersister("user-b"); - - expect(window.sessionStorage.getItem(userAKey)).toBeNull(); - expect(window.sessionStorage.getItem(anonymousKey)).toBeNull(); - expect(window.sessionStorage.getItem("unrelated")).toBe("kept"); - }); - - test("never restores another session scope", async () => { - const queryKey = stockPriceQueryKey(); - const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 }); - const userAPersister = createSessionQueryPersister("user-a"); - userAPersister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(queryClient), - }); - userAPersister.flush(); - expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1); - - expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined(); - }); - - test("stops persisting once the provider tears the cache down", async () => { - const storage = createMemoryStorage(); - const persister = createSessionQueryPersister("user-a", storage); - const { queryClient } = createSuccessfulQuery(stockPriceQueryKey(), { price: 21 }); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(queryClient), - }); - persister.flush(); - persister.stop(); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { mutations: [], queries: [] }, - }); - persister.flush(); - - expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1); - }); - - test("removes the cache instead of storing an empty one", () => { - const scope = "user-a"; - const key = getQueryPersistenceStorageKey(scope); - const persister = createSessionQueryPersister(scope); - window.sessionStorage.setItem(key, "stale"); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { mutations: [], queries: [] }, - }); - persister.flush(); - - expect(window.sessionStorage.getItem(key)).toBeNull(); - }); - - test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => { + test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => { vi.useFakeTimers(); - const scope = "user-a"; - const key = getQueryPersistenceStorageKey(scope); - const persister = createSessionQueryPersister(scope); - const { query, queryClient } = createSuccessfulQuery(stockPriceQueryKey(), { + const storage = createMemoryStorage(); + const userA = createSessionQueryPersistence("user-a", storage); + const userB = createSessionQueryPersistence("user-b", storage); + const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], { startsAt: new Date("2026-08-22T12:00:00.000Z"), }); - query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" }); - expect(shouldPersistDashboardQuery(query)).toBe(true); const persistedClient: PersistedClient = { timestamp: Date.now(), buster: queryPersistenceBuster, - clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }), + clientState: dehydrate(queryClient, userA.dehydrateOptions), }; - persister.persistClient(persistedClient); - persister.flush(); - const restored = await persister.restoreClient(); - const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined; - expect(restoredData?.startsAt).toEqual(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(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true); - expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error"); + await userA.persister.persistClient(persistedClient); + await vi.runAllTimersAsync(); - query.setState({ - ...query.state, - error: Object.assign(new Error("access denied"), { data: { code: "FORBIDDEN" } }), - status: "error", + const restored = await userA.persister.restoreClient(); + expect(restored?.clientState.queries[0]?.state.data).toEqual({ + startsAt: new Date("2026-08-22T12:00:00.000Z"), }); - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(queryClient), - }); - persister.flush(); - expect(window.sessionStorage.getItem(key)).toBeNull(); + expect(await userB.persister.restoreClient()).toBeUndefined(); - window.sessionStorage.setItem(key, "not-valid-superjson"); - expect(persister.restoreClient()).toBeUndefined(); - expect(window.sessionStorage.getItem(key)).toBeNull(); + await userA.persister.removeClient(); + expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull(); }); - test("sanitizes structurally valid but unsafe stored state before hydration", async () => { - const scope = "user-a"; - const key = getQueryPersistenceStorageKey(scope); - const { queryClient } = createSuccessfulQuery(stockPriceQueryKey(), [{ title: "safe" }]); - const validQuery = dehydrate(queryClient).queries[0]; - if (!validQuery) throw new Error("Expected a dehydrated query"); - - window.sessionStorage.setItem( - key, - stringify({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { - mutations: [{ state: { status: "pending" } }], - queries: [ - { - ...validQuery, - state: { - ...validQuery.state, - error: new Error("private refresh error"), - fetchStatus: "fetching", - status: "error", - }, - }, - { - ...validQuery, - queryHash: "unrelated", - queryKey: [["board", "getBoardByName"], { type: "query" }], - }, - { queryHash: "malformed", queryKey: [["widget", "weather"]], state: null }, - { - ...validQuery, - queryHash: "missing-timestamp", - state: { ...validQuery.state, dataUpdatedAt: undefined }, - }, - ], - }, - }), - ); - - const restored = await createSessionQueryPersister(scope).restoreClient(); - expect(restored?.clientState.mutations).toEqual([]); - expect(restored?.clientState.queries).toHaveLength(1); - expect(restored?.clientState.queries[0]?.state.status).toBe("success"); - expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle"); - expect(restored?.clientState.queries[0]?.state.error).toBeNull(); - }); - - test.each([ - ["expired", 1, queryPersistenceBuster], - ["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"], - ["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster], - ])("discards %s cache entries", async (_case, timestamp, storedBuster) => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z")); - const scope = "user-a"; - const persister = createSessionQueryPersister(scope); - const { queryClient } = createSuccessfulQuery(stockPriceQueryKey(), { price: 21 }); - persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) }); - persister.flush(); - - const restoredClient = new QueryClient(); - await persistQueryClientRestore({ - queryClient: restoredClient, - persister, - maxAge: 5 * 60 * 1_000, - buster: queryPersistenceBuster, - }); - - expect(restoredClient.getQueryData(stockPriceQueryKey())).toBeUndefined(); - expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull(); - }); - - test("quota retry writes the cache after evicting the oldest query", async () => { - const old = createSuccessfulQuery(stockPriceQueryKey("OLD"), "old", 1); - const recent = createSuccessfulQuery(stockPriceQueryKey("RECENT"), "recent", 2); - const storage = createMemoryStorage(); - const setItem = storage.setItem.bind(storage); - const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => { - const client = parse(value); - if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError"); - setItem(key, value); - }); - const persistedClient: PersistedClient = { - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { - mutations: [], - queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries], - }, - }; - const persister = createSessionQueryPersister("user-a", storage); - - persister.persistClient(persistedClient); - persister.flush(); - - expect(setItemSpy).toHaveBeenCalledTimes(2); - const restored = await persister.restoreClient(); - expect(restored?.clientState.queries).toHaveLength(1); - expect(restored?.clientState.queries[0]?.queryKey).toEqual(stockPriceQueryKey("RECENT")); - - persister.persistClient(persistedClient); - persister.flush(); - expect(setItemSpy).toHaveBeenCalledTimes(3); - }); - - test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => { - vi.useFakeTimers(); - const storage = createMemoryStorage(); - const setItem = vi.spyOn(storage, "setItem"); - const queryKey = stockPriceQueryKey(); - const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1); - const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2); - const persister = createSessionQueryPersister("user-a", storage); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(first.queryClient), - }); - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(latest.queryClient), - }); - - expect(setItem).not.toHaveBeenCalled(); - await vi.advanceTimersByTimeAsync(queryPersistenceThrottleMs - 1); - expect(setItem).not.toHaveBeenCalled(); - persister.flush(); - expect(setItem).toHaveBeenCalledOnce(); - await vi.advanceTimersByTimeAsync(queryPersistenceThrottleMs); - expect(setItem).toHaveBeenCalledOnce(); - - const restored = await persister.restoreClient(); - expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 }); - }); - - test("treats blocked storage operations as an empty cache", async () => { - const blockedStorage = { - getItem: () => { - throw new DOMException("blocked", "SecurityError"); - }, - removeItem: () => { - throw new DOMException("blocked", "SecurityError"); - }, - setItem: () => { - throw new DOMException("blocked", "SecurityError"); - }, - } as unknown as Storage; - const persister = createSessionQueryPersister("blocked", blockedStorage); - const { queryClient } = createSuccessfulQuery(stockPriceQueryKey(), { price: 21 }); - - expect(persister.restoreClient()).toBeUndefined(); - expect(() => { - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: dehydrate(queryClient), - }); - persister.flush(); - }).not.toThrow(); - expect(() => persister.removeClient()).not.toThrow(); - }); - - test("treats an unavailable session storage getter as an empty cache", async () => { + test("treats unavailable session storage as an empty cache", async () => { vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => { throw new DOMException("blocked", "SecurityError"); }); - expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow(); - expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined(); + const persistence = createSessionQueryPersistence("blocked"); + expect(await persistence.persister.restoreClient()).toBeUndefined(); }); }); 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 37d6671cc..cfacfe279 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -1,19 +1,12 @@ import type { QueryKey } from "@tanstack/react-query"; -import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client"; +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 { parse, stringify } from "superjson"; -import { - isPersistableDashboardQueryKey, - isTrpcForbiddenError, - queryCacheDefaultGcTimeMs, -} from "@homarr/api/query-cache"; +import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache"; -export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs; -export const queryPersistenceMaxDataBytes = 256 * 1024; -export const queryPersistenceMaxCacheBytes = 2 * 1024 * 1024; -export const queryPersistenceThrottleMs = 1_000; -export const queryPersistenceIdleTimeoutMs = 1_000; -export const queryPersistenceBuster = "v4-public-data-only"; +export const queryPersistenceBuster = "v5-dashboard-data"; const queryPersistenceStoragePrefix = "homarr:widget-query-cache"; @@ -22,125 +15,13 @@ export const getQueryPersistenceStorageKey = (scope: string | null) => interface PersistableQuery { queryKey: QueryKey; - state: { - data: unknown; - dataUpdatedAt: number; - error?: unknown; - }; + state: { data: unknown; status: string }; } -export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void }; - -const textEncoder = new TextEncoder(); -const budgetVerdictCache = new WeakMap(); - -const isRecord = (value: unknown): value is Record => - typeof value === "object" && value !== null && !Array.isArray(value); - -const isQuotaExceededError = (error: unknown): boolean => { - if (!isRecord(error)) return false; - return ( - error.name === "QuotaExceededError" || - error.name === "NS_ERROR_DOM_QUOTA_REACHED" || - error.code === 22 || - error.code === 1014 - ); -}; - -const measureFitsBudget = (data: unknown) => { - try { - return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes; - } catch { - return false; - } -}; - -const measureSerializedBytes = (data: unknown) => textEncoder.encode(stringify(data)).byteLength; - -const fitsStorageBudget = (data: unknown) => { - if (typeof data !== "object" || data === null) return measureFitsBudget(data); - - const cached = budgetVerdictCache.get(data); - if (cached !== undefined) return cached; - - const verdict = measureFitsBudget(data); - budgetVerdictCache.set(data, verdict); - return verdict; -}; - -const holdsDashboardData = (query: PersistableQuery) => - query.state.data !== undefined && - Number.isFinite(query.state.dataUpdatedAt) && - query.state.dataUpdatedAt > 0 && - isPersistableDashboardQueryKey(query.queryKey); - export const shouldPersistDashboardQuery = (query: PersistableQuery) => - holdsDashboardData(query) && !isTrpcForbiddenError(query.state.error) && fitsStorageBudget(query.state.data); + query.state.status === "success" && query.state.data !== undefined && isWidgetDataQueryKey(query.queryKey); -type DehydratedQuery = PersistedClient["clientState"]["queries"][number]; - -const keepNewestQueries = (client: PersistedClient, maximumQueries: number): PersistedClient | undefined => { - if (maximumQueries <= 0) return undefined; - if (client.clientState.queries.length <= maximumQueries) return client; - - return { - ...client, - clientState: { - ...client.clientState, - queries: client.clientState.queries - .toSorted((left, right) => right.state.dataUpdatedAt - left.state.dataUpdatedAt) - .slice(0, maximumQueries), - }, - }; -}; - -const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({ - ...query, - state: { - ...query.state, - error: null, - errorUpdateCount: 0, - errorUpdatedAt: 0, - fetchFailureCount: 0, - fetchFailureReason: null, - fetchStatus: "idle", - isInvalidated: true, - status: "success", - }, -}); - -const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({ - ...client, - clientState: { - mutations: [], - queries: client.clientState.queries - .filter((query) => holdsDashboardData(query) && !isTrpcForbiddenError(query.state.error)) - .map(sanitizePersistedQuery), - }, -}); - -const isRestorableQuery = (value: unknown): value is DehydratedQuery => { - if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false; - if (!isRecord(value.state) || typeof value.state.status !== "string") return false; - - return holdsDashboardData(value as unknown as PersistableQuery); -}; - -const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => { - if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined; - if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined; - - return { - timestamp: value.timestamp as number, - buster: value.buster, - clientState: { - mutations: [], - queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery), - }, - }; -}; - -const getSessionStorage = (): Storage | undefined => { +const getSessionStorage = () => { if (typeof window === "undefined") return undefined; try { @@ -150,185 +31,20 @@ const getSessionStorage = (): Storage | undefined => { } }; -const removeStorageItem = (storage: Storage | undefined, key: string) => { - try { - storage?.removeItem(key); - } catch { - return undefined; - } -}; +export type SessionQueryPersistence = PersistQueryClientProviderProps["persistOptions"]; -const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => { - if (!storage) return; - - try { - const foreignKeys: string[] = []; - for (let index = 0; index < storage.length; index += 1) { - const storedKey = storage.key(index); - if (storedKey === null || storedKey === activeKey) continue; - if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey); - } - - for (const foreignKey of foreignKeys) storage.removeItem(foreignKey); - } catch { - return undefined; - } -}; - -export const createSessionQueryPersister = ( +export const createSessionQueryPersistence = ( scope: string | null, storage: Storage | undefined = getSessionStorage(), -): SessionQueryPersister => { - const key = getQueryPersistenceStorageKey(scope); - removeForeignScopes(storage, key); - let pendingClient: PersistedClient | undefined; - let persistDelayTimer: ReturnType | undefined; - let persistIdleCallback: number | undefined; - let persistedQueryLimit = Number.POSITIVE_INFINITY; - let persistenceDisabled = false; - let stopped = false; - - const trimToQueryLimit = (client: PersistedClient): PersistedClient | undefined => { - const limitedClient = keepNewestQueries(client, persistedQueryLimit); - if (!limitedClient || measureSerializedBytes(limitedClient) <= queryPersistenceMaxCacheBytes) return limitedClient; - - const emptyClient: PersistedClient = { - ...limitedClient, - clientState: { ...limitedClient.clientState, queries: [] }, - }; - let remainingBytes = queryPersistenceMaxCacheBytes - measureSerializedBytes(emptyClient); - const selectedQueries: DehydratedQuery[] = []; - const newestQueries = limitedClient.clientState.queries.toSorted( - (left, right) => right.state.dataUpdatedAt - left.state.dataUpdatedAt, - ); - - for (const query of newestQueries) { - const queryBytes = measureSerializedBytes(query) + 1; - if (queryBytes > remainingBytes) continue; - selectedQueries.push(query); - remainingBytes -= queryBytes; - } - - const buildCandidate = (queryCount: number): PersistedClient => ({ - ...limitedClient, - clientState: { ...limitedClient.clientState, queries: selectedQueries.slice(0, queryCount) }, - }); - let minimumQueryCount = 0; - let maximumQueryCount = selectedQueries.length; - - while (minimumQueryCount < maximumQueryCount) { - const queryCount = Math.ceil((minimumQueryCount + maximumQueryCount) / 2); - const candidate = buildCandidate(queryCount); - if (measureSerializedBytes(candidate) <= queryPersistenceMaxCacheBytes) { - minimumQueryCount = queryCount; - } else { - maximumQueryCount = queryCount - 1; - } - } - - const candidate = buildCandidate(minimumQueryCount); - if (measureSerializedBytes(candidate) > queryPersistenceMaxCacheBytes) return undefined; - return candidate; - }; - - const persistNow = (client: PersistedClient) => { - if (!storage || persistenceDisabled) return; - let candidate: PersistedClient | undefined; - try { - const sanitizedClient = sanitizePersistedClient(client); - candidate = trimToQueryLimit(sanitizedClient); - } catch { - persistenceDisabled = true; - removeStorageItem(storage, key); - return; - } - if (!candidate || candidate.clientState.queries.length === 0) { - removeStorageItem(storage, key); - return; - } - - let quotaLimited = false; - while (candidate) { - try { - storage.setItem(key, stringify(candidate)); - if (quotaLimited) { - persistedQueryLimit = Math.min(persistedQueryLimit, candidate.clientState.queries.length); - } - return; - } catch (error) { - removeStorageItem(storage, key); - if (!isQuotaExceededError(error)) { - persistenceDisabled = true; - return; - } - quotaLimited = true; - candidate = keepNewestQueries(candidate, Math.floor(candidate.clientState.queries.length / 2)); - if (!candidate) return; - } - } - }; - - const clearScheduledPersist = () => { - if (persistDelayTimer !== undefined) { - clearTimeout(persistDelayTimer); - persistDelayTimer = undefined; - } - if (persistIdleCallback !== undefined && typeof window !== "undefined") { - window.cancelIdleCallback?.(persistIdleCallback); - persistIdleCallback = undefined; - } - }; - - const flush = () => { - clearScheduledPersist(); - const client = pendingClient; - pendingClient = undefined; - if (client) persistNow(client); - }; - - const scheduleIdlePersist = () => { - persistDelayTimer = undefined; - if (typeof window !== "undefined" && window.requestIdleCallback) { - persistIdleCallback = window.requestIdleCallback(flush, { timeout: queryPersistenceIdleTimeoutMs }); - return; - } - flush(); - }; - - return { - persistClient(client) { - if (!storage || stopped || persistenceDisabled) return; - pendingClient = client; - if (persistDelayTimer !== undefined || persistIdleCallback !== undefined) return; - persistDelayTimer = setTimeout(scheduleIdlePersist, queryPersistenceThrottleMs); - }, - restoreClient() { - try { - const storedValue = storage?.getItem(key); - if (!storedValue) return undefined; - - const client = sanitizeRestoredClient(parse(storedValue)); - if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) { - removeStorageItem(storage, key); - return undefined; - } - - return client; - } catch { - removeStorageItem(storage, key); - return undefined; - } - }, - removeClient() { - clearScheduledPersist(); - pendingClient = undefined; - removeStorageItem(storage, key); - }, - flush, - stop() { - stopped = true; - clearScheduledPersist(); - pendingClient = undefined; - }, - }; -}; +): SessionQueryPersistence => ({ + persister: createSyncStoragePersister({ + storage, + key: getQueryPersistenceStorageKey(scope), + serialize: stringify, + deserialize: parse, + retry: removeOldestQuery, + }), + maxAge: queryCacheDefaultGcTimeMs, + buster: queryPersistenceBuster, + dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery }, +}); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index fbd3e3744..50dd85d7b 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -38,13 +38,8 @@ import { showWarningNotification } from "@homarr/notifications"; import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; -import { - createSessionQueryPersister, - queryPersistenceBuster, - queryPersistenceMaxAgeMs, - shouldPersistDashboardQuery, -} from "./query-persistence"; -import type { SessionQueryPersister } from "./query-persistence"; +import { createSessionQueryPersistence } from "./query-persistence"; +import type { SessionQueryPersistence } from "./query-persistence"; import { createQueryRetry } from "./query-retry"; const DevelopmentTools = @@ -77,11 +72,11 @@ export function TRPCReactProvider({ children }: PropsWithChildren) { const { data: session } = useSession(); const sessionQueryScope = getSessionQueryScope(session); const [initialSessionQueryScope] = useState(() => sessionQueryScope); - const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope)); + const [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope)); const handleScopeChange = useCallback(() => { - void queryPersister.removeClient(); + void queryPersistence.persister.removeClient(); reloadPage(); - }, [queryPersister]); + }, [queryPersistence]); return ( - {children} + {children} ); } @@ -98,8 +93,8 @@ const reloadPage = () => window.location.reload(); const ScopedTRPCReactProvider = ({ children, - queryPersister, -}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => { + queryPersistence, +}: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => { const wsClient = useMemo( () => createWSClient({ @@ -160,15 +155,6 @@ const ScopedTRPCReactProvider = ({ return client; }); - useEffect(() => { - const flush = () => queryPersister.flush(); - window.addEventListener("pagehide", flush); - return () => { - window.removeEventListener("pagehide", flush); - flush(); - queryPersister.stop(); - }; - }, [queryPersister]); useEffect(() => () => queryClient.clear(), [queryClient]); const trpcClient = useMemo(() => { @@ -219,15 +205,7 @@ const ScopedTRPCReactProvider = ({ return ( - + {children} {process.env.NODE_ENV === "development" && } diff --git a/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts index a817df632..7f77b7fbe 100644 --- a/packages/api/src/query-cache.ts +++ b/packages/api/src/query-cache.ts @@ -19,7 +19,6 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5; export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5; const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]); -const persistableDashboardDataPaths = new Set(["widget.stockPrice.getPriceHistory"]); // Beszel system stats are large time-series payloads and were deliberately kept out of // the persisted cache in #6289; live stats come from an SSE subscription instead. const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]); @@ -78,12 +77,3 @@ export const isWidgetDataQueryKey = (queryKey: QueryKey) => { return isWidgetDataTrpcPath(path.join(".")); }; - -// Persist only data that is both public and independent of Homarr access grants. -// Integration, app, Docker, board-backed, and location queries must refetch after reload. -export const isPersistableDashboardQueryKey = (queryKey: QueryKey) => { - const path = getTrpcPathFromQueryKey(queryKey); - if (!path) return false; - - return persistableDashboardDataPaths.has(path.join(".")); -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0d9d55c9..31ecb25f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ catalogs: '@tabler/icons-react': specifier: ^3.44.0 version: 3.44.0 + '@tanstack/query-sync-storage-persister': + specifier: 5.101.2 + version: 5.101.2 '@tanstack/react-devtools': specifier: ^0.10.8 version: 0.10.9 @@ -1133,6 +1136,9 @@ importers: '@tabler/icons-react': specifier: 'catalog:' version: 3.44.0(react@19.2.7) + '@tanstack/query-sync-storage-persister': + specifier: 'catalog:' + version: 5.101.2 '@tanstack/react-devtools': specifier: 'catalog:' version: 0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14) @@ -8846,6 +8852,9 @@ packages: '@tanstack/query-persist-client-core@5.101.2': resolution: {integrity: sha512-rgMsbvBVpSPDUsprC9FYxojdG0Q1LH6yq1i3DXz2aps4VEqv2l4Msj3YDqIifU3xkl/DrYe9YWntZAJLrM6xTg==} + '@tanstack/query-sync-storage-persister@5.101.2': + resolution: {integrity: sha512-hRyLEJvkUWtfCUA3Lf5vD1CrXbHHK4pZNM/iNd7JM5GgC4CWzGIHkVkeg8zC2LV11KceKObKgjNuMSg3b6NOjA==} + '@tanstack/react-devtools@0.10.9': resolution: {integrity: sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw==} engines: {node: '>=18'} @@ -23948,6 +23957,11 @@ snapshots: dependencies: '@tanstack/query-core': 5.101.2 + '@tanstack/query-sync-storage-persister@5.101.2': + dependencies: + '@tanstack/query-core': 5.101.2 + '@tanstack/query-persist-client-core': 5.101.2 + '@tanstack/react-devtools@0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)': dependencies: '@tanstack/devtools': 0.13.0(csstype@3.2.3)(solid-js@1.9.14) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 952d1f3a8..443103fcc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -59,6 +59,7 @@ catalog: "@semantic-release/release-notes-generator": ^14.1.1 "@t3-oss/env-nextjs": ^0.13.11 "@tabler/icons-react": ^3.44.0 + "@tanstack/query-sync-storage-persister": 5.101.2 "@tanstack/react-devtools": ^0.10.8 "@tanstack/react-query": ^5.101.2 "@tanstack/react-query-devtools": ^5.101.2