fix: harden widget query persistence

This commit is contained in:
Thomas Camlong committed 2026-08-23 09:00:33 +02:00
1 parent a2a408410a
commit 357355283c
7 files changed
+401 -96

No files matched your search

@@ -2,7 +2,8 @@
import { dehydrate, QueryClient } from "@tanstack/react-query";
import type { PersistedClient } from "@tanstack/react-query-persist-client";
import { persistQueryClientRestore, removeOldestQuery } 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 {
@@ -23,6 +24,20 @@ const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, upda
return { query, queryClient };
};
const createMemoryStorage = (): Storage => {
const values = new Map<string, string>();
return {
get length() {
return values.size;
},
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => Array.from(values.keys())[index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
};
};
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
@@ -67,6 +82,42 @@ describe("widget query persistence", () => {
expect(window.sessionStorage.getItem(userBKey)).toBe("b");
});
test("never restores another session scope", async () => {
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 });
const userAPersister = createSessionQueryPersister("user-a");
userAPersister.persistClient({
timestamp: Date.now(),
buster: getQueryPersistenceBuster("user-a"),
clientState: dehydrate(queryClient),
});
userAPersister.flush();
await expect(createSessionQueryPersister("user-b").restoreClient()).resolves.toBeUndefined();
expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1);
});
test("persister itself rejects unrelated query payloads", async () => {
const scope = "user-a";
const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 });
const unrelated = createSuccessfulQuery([["user", "session"], { type: "query" }], { secret: true });
const persister = createSessionQueryPersister(scope);
persister.persistClient({
timestamp: Date.now(),
buster: getQueryPersistenceBuster(scope),
clientState: {
mutations: [],
queries: [...dehydrate(widget.queryClient).queries, ...dehydrate(unrelated.queryClient).queries],
},
});
persister.flush();
const storedValue = window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope));
expect(storedValue).not.toContain("secret");
expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1);
});
test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => {
vi.useFakeTimers();
const scope = "user-a";
@@ -83,8 +134,8 @@ describe("widget query persistence", () => {
clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }),
};
void persister.persistClient(persistedClient);
await vi.advanceTimersByTimeAsync(1_000);
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"));
@@ -100,9 +151,56 @@ describe("widget query persistence", () => {
expect(window.sessionStorage.getItem(key)).toBeNull();
});
test("sanitizes structurally valid but unsafe stored state before hydration", async () => {
const scope = "user-a";
const key = getQueryPersistenceStorageKey(scope);
const { queryClient } = createSuccessfulQuery(
[["widget", "calendar", "findAllEvents"], { type: "query" }],
[{ 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: getQueryPersistenceBuster(scope),
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 },
],
},
}),
);
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, getQueryPersistenceBuster("user-a")],
["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), getQueryPersistenceBuster("user-b")],
["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, getQueryPersistenceBuster("user-a")],
])("discards %s cache entries", async (_case, timestamp, storedBuster) => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z"));
@@ -111,8 +209,8 @@ describe("widget query persistence", () => {
const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], {
temp: 21,
});
void persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
await vi.advanceTimersByTimeAsync(1_000);
persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) });
persister.flush();
const restoredClient = new QueryClient();
await persistQueryClientRestore({
@@ -126,9 +224,16 @@ describe("widget query persistence", () => {
expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull();
});
test("quota retry removes the oldest query first", () => {
test("quota retry writes the cache after evicting the oldest query", async () => {
const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1);
const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "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: getQueryPersistenceBuster("user-a"),
@@ -137,10 +242,45 @@ describe("widget query persistence", () => {
queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries],
},
};
const persister = createSessionQueryPersister("user-a", storage);
const retried = removeOldestQuery({ persistedClient, error: new Error("quota"), errorCount: 1 });
expect(retried?.clientState.queries).toHaveLength(1);
expect(retried?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]);
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([["widget", "recent"], { type: "query" }]);
});
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 = [["widget", "weather", "getWeather"], { type: "query" }] as const;
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: getQueryPersistenceBuster("user-a"),
clientState: dehydrate(first.queryClient),
});
persister.persistClient({
timestamp: Date.now(),
buster: getQueryPersistenceBuster("user-a"),
clientState: dehydrate(latest.queryClient),
});
expect(setItem).not.toHaveBeenCalled();
persister.flush();
expect(setItem).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(0);
expect(setItem).toHaveBeenCalledOnce();
const restored = await persister.restoreClient();
expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 });
});
test("refreshes only restored cache data without awaiting the refresh", async () => {
@@ -149,12 +289,12 @@ describe("widget query persistence", () => {
const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const;
const cached = createSuccessfulQuery(queryKey, { temp: 20 }, 10);
const persister = createSessionQueryPersister(scope);
void persister.persistClient({
persister.persistClient({
timestamp: Date.now(),
buster: getQueryPersistenceBuster(scope),
clientState: dehydrate(cached.queryClient),
});
await vi.advanceTimersByTimeAsync(1_000);
persister.flush();
await persister.restoreClient();
const fresh = createSuccessfulQuery(queryKey, { temp: 21 }, 20);
@@ -165,6 +305,7 @@ describe("widget query persistence", () => {
expect(invalidateQueries).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(0);
expect(invalidateQueries).toHaveBeenCalledOnce();
expect(invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ type: "all", refetchType: "active" }));
const predicate = invalidateQueries.mock.calls[0]?.[0]?.predicate;
expect(predicate?.(cached.query)).toBe(true);
@@ -197,8 +338,17 @@ describe("widget query persistence", () => {
},
} as unknown as Storage;
const persister = createSessionQueryPersister("blocked", blockedStorage);
const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 });
await expect(persister.restoreClient()).resolves.toBeUndefined();
expect(() => {
persister.persistClient({
timestamp: Date.now(),
buster: getQueryPersistenceBuster("blocked"),
clientState: dehydrate(queryClient),
});
persister.flush();
}).not.toThrow();
expect(() => persister.removeClient()).not.toThrow();
expect(() => removeSessionQueryCache("blocked", blockedStorage)).not.toThrow();
});
@@ -1,7 +1,6 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client";
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { parse, stringify } from "superjson";
import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
@@ -39,6 +38,7 @@ interface PersistableQuery {
type RestoredDashboardQueryPredicate = (query: PersistableQuery) => boolean;
export interface SessionQueryPersister extends Persister {
flush: () => void;
takeRestoredDashboardQueryPredicate: () => RestoredDashboardQueryPredicate | undefined;
}
@@ -54,25 +54,59 @@ export const shouldPersistDashboardQuery = (query: PersistableQuery) => {
}
};
type DehydratedQuery = PersistedClient["clientState"]["queries"][number];
const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
fetchStatus: "idle",
status: "success",
},
});
const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({
...client,
clientState: {
mutations: [],
queries: client.clientState.queries.map((query) => ({
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
status: "success",
},
})),
// Keep this guard inside the persister as well as in the provider's
// dehydration options so a future caller cannot accidentally store an
// unrelated query payload.
queries: client.clientState.queries
.filter((query) => shouldPersistDashboardQuery(query as PersistableQuery))
.map(sanitizePersistedQuery),
},
});
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
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 shouldPersistDashboardQuery(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 => {
if (typeof window === "undefined") return undefined;
@@ -98,30 +132,70 @@ export const createSessionQueryPersister = (
const key = getQueryPersistenceStorageKey(scope);
const buster = getQueryPersistenceBuster(scope);
let restoredQueries = new Map<string, number>();
const persister = createSyncStoragePersister({
storage,
key,
retry: removeOldestQuery,
serialize: (client) => stringify(sanitizePersistedClient(client)),
deserialize: (value) => parse<PersistedClient>(value),
});
let pendingClient: PersistedClient | undefined;
let persistTimer: ReturnType<typeof setTimeout> | undefined;
const persistNow = (client: PersistedClient) => {
if (!storage) return;
let candidate: PersistedClient | undefined = sanitizePersistedClient(client);
let errorCount = 0;
while (candidate) {
try {
storage.setItem(key, stringify(candidate));
return;
} catch (error) {
errorCount += 1;
candidate = removeOldestQuery({
persistedClient: candidate,
error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"),
errorCount,
});
}
}
};
const clearPersistTimer = () => {
if (persistTimer === undefined) return;
clearTimeout(persistTimer);
persistTimer = undefined;
};
const flush = () => {
clearPersistTimer();
const client = pendingClient;
pendingClient = undefined;
if (client) persistNow(client);
};
return {
...persister,
persistClient(client) {
if (!storage) return;
pendingClient = client;
if (persistTimer !== undefined) return;
persistTimer = setTimeout(flush, 0);
},
async restoreClient() {
restoredQueries = new Map();
try {
const client = await persister.restoreClient();
if (
client &&
client.timestamp > 0 &&
Date.now() - client.timestamp <= queryPersistenceMaxAgeMs &&
client.buster === buster
) {
restoredQueries = new Map(
client.clientState.queries.map((query) => [query.queryHash, query.state.dataUpdatedAt]),
);
const storedValue = storage?.getItem(key);
if (!storedValue) return undefined;
const client = sanitizeRestoredClient(parse<unknown>(storedValue));
const age = client ? Date.now() - client.timestamp : Number.POSITIVE_INFINITY;
if (!client || age < 0 || age > queryPersistenceMaxAgeMs || client.buster !== buster) {
removeStorageItem(storage, key);
return undefined;
}
if (client.clientState.queries.length === 0) {
removeStorageItem(storage, key);
return undefined;
}
restoredQueries = new Map(
client.clientState.queries.map((query) => [query.queryHash, query.state.dataUpdatedAt]),
);
return client;
} catch {
removeStorageItem(storage, key);
@@ -129,9 +203,12 @@ export const createSessionQueryPersister = (
}
},
removeClient() {
clearPersistTimer();
pendingClient = undefined;
restoredQueries = new Map();
removeStorageItem(storage, key);
},
flush,
takeRestoredDashboardQueryPredicate() {
if (restoredQueries.size === 0) return undefined;
const restoredSnapshot = restoredQueries;
@@ -149,7 +226,10 @@ export const scheduleRestoredDashboardQueryRefresh = (queryClient: QueryClient,
if (!predicate) return;
setTimeout(() => {
void queryClient.invalidateQueries({ type: "active", predicate });
// Mark restored data stale even if its widget observer has not mounted yet.
// TanStack still refetches only active queries; a widget that mounts after
// this callback observes stale cached data and refreshes immediately.
void queryClient.invalidateQueries({ type: "all", refetchType: "active", predicate });
}, 0);
};
@@ -1,7 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { QueryClient } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
@@ -40,10 +40,10 @@ import {
createSessionQueryPersister,
getQueryPersistenceBuster,
queryPersistenceMaxAgeMs,
removeSessionQueryCache,
scheduleRestoredDashboardQueryRefresh,
shouldPersistDashboardQuery,
} from "./query-persistence";
import type { SessionQueryPersister } from "./query-persistence";
import { createQueryRetry } from "./query-retry";
const DevelopmentTools =
@@ -76,17 +76,21 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
const { data: session } = useSession();
const sessionQueryScope = getSessionQueryScope(session);
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope));
const handleScopeChange = useCallback(() => {
void queryPersister.removeClient();
reloadPage();
}, [queryPersister]);
return (
<SessionQueryScopeGuard
initialScope={initialSessionQueryScope}
currentScope={sessionQueryScope}
onScopeChange={() => {
removeSessionQueryCache(initialSessionQueryScope);
reloadPage();
}}
onScopeChange={handleScopeChange}
>
<ScopedTRPCReactProvider sessionQueryScope={initialSessionQueryScope}>{children}</ScopedTRPCReactProvider>
<ScopedTRPCReactProvider sessionQueryScope={initialSessionQueryScope} queryPersister={queryPersister}>
{children}
</ScopedTRPCReactProvider>
</SessionQueryScopeGuard>
);
}
@@ -96,7 +100,8 @@ const reloadPage = () => window.location.reload();
const ScopedTRPCReactProvider = ({
children,
sessionQueryScope,
}: PropsWithChildren<{ sessionQueryScope: string | null }>) => {
queryPersister,
}: PropsWithChildren<{ sessionQueryScope: string | null; queryPersister: SessionQueryPersister }>) => {
const wsClient = useMemo(
() =>
createWSClient({
@@ -152,7 +157,14 @@ const ScopedTRPCReactProvider = ({
});
useEffect(() => () => queryClient.clear(), [queryClient]);
const [queryPersister] = useState(() => createSessionQueryPersister(sessionQueryScope));
useEffect(() => {
const flush = () => queryPersister.flush();
window.addEventListener("pagehide", flush);
return () => {
window.removeEventListener("pagehide", flush);
flush();
};
}, [queryPersister]);
const trpcClient = useMemo(() => {
return clientApi.createClient({