perf: simplify dashboard query persistence

This commit is contained in:
ajnart committed 2026-08-27 11:21:57 +02:00
1 parent a935fd78fb
commit a845d57d57
7 files changed
+78 -655

No files matched your search

+1
View File
@@ -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:",
@@ -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<string, string>();
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<PersistedClient>(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();
});
});
@@ -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<object, boolean>();
const isRecord = (value: unknown): value is Record<string, unknown> =>
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<typeof setTimeout> | 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<unknown>(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 },
});
@@ -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 (
<SessionQueryScopeGuard
@@ -89,7 +84,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
currentScope={sessionQueryScope}
onScopeChange={handleScopeChange}
>
<ScopedTRPCReactProvider queryPersister={queryPersister}>{children}</ScopedTRPCReactProvider>
<ScopedTRPCReactProvider queryPersistence={queryPersistence}>{children}</ScopedTRPCReactProvider>
</SessionQueryScopeGuard>
);
}
@@ -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 (
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister: queryPersister,
maxAge: queryPersistenceMaxAgeMs,
buster: queryPersistenceBuster,
dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery },
}}
>
<PersistQueryClientProvider client={queryClient} persistOptions={queryPersistence}>
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
</PersistQueryClientProvider>
-10
View File
@@ -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("."));
};
+14
View File
@@ -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)
+1
View File
@@ -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