fix: harden widget query persistence
This commit is contained in:
7 files changed
+401
-96
No files matched your search
@@ -72,7 +72,6 @@
|
||||
"@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:",
|
||||
|
||||
@@ -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({
|
||||
|
||||
+107
-28
@@ -1,5 +1,6 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { chromium, expect } from "@playwright/test";
|
||||
import type { Route } from "@playwright/test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { stringify } from "superjson";
|
||||
import { describe, test } from "vitest";
|
||||
@@ -21,7 +22,9 @@ describe("lazy widget application graph", () => {
|
||||
const boardId = createId();
|
||||
const layoutId = createId();
|
||||
const sectionId = createId();
|
||||
const itemId = createId();
|
||||
const clockItemId = createId();
|
||||
const downloadsItemId = createId();
|
||||
const mockIntegrationId = createId();
|
||||
|
||||
await db.insert(sqliteSchema.boards).values({
|
||||
id: boardId,
|
||||
@@ -41,26 +44,56 @@ describe("lazy widget application graph", () => {
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
await db.insert(sqliteSchema.items).values({
|
||||
id: itemId,
|
||||
boardId,
|
||||
kind: "clock",
|
||||
options: stringify({ showSeconds: true }),
|
||||
await db.insert(sqliteSchema.integrations).values({
|
||||
id: mockIntegrationId,
|
||||
name: "Mock downloads",
|
||||
url: "http://mock.local",
|
||||
kind: "mock",
|
||||
});
|
||||
await db.insert(sqliteSchema.itemLayouts).values({
|
||||
itemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 2,
|
||||
await db.insert(sqliteSchema.items).values([
|
||||
{
|
||||
id: clockItemId,
|
||||
boardId,
|
||||
kind: "clock",
|
||||
options: stringify({ showSeconds: true }),
|
||||
},
|
||||
{
|
||||
id: downloadsItemId,
|
||||
boardId,
|
||||
kind: "downloads",
|
||||
options: stringify({}),
|
||||
},
|
||||
]);
|
||||
await db.insert(sqliteSchema.itemLayouts).values([
|
||||
{
|
||||
itemId: clockItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 2,
|
||||
},
|
||||
{
|
||||
itemId: downloadsItemId,
|
||||
sectionId,
|
||||
layoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 2,
|
||||
width: 8,
|
||||
height: 5,
|
||||
},
|
||||
]);
|
||||
await db.insert(sqliteSchema.integrationItems).values({
|
||||
itemId: downloadsItemId,
|
||||
integrationId: mockIntegrationId,
|
||||
});
|
||||
await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId));
|
||||
|
||||
const homarrContainer = await createHomarrContainer({
|
||||
environment: {
|
||||
AUTH_PROVIDERS: "credentials",
|
||||
UNSAFE_ENABLE_MOCK_INTEGRATION: "true",
|
||||
},
|
||||
mounts: {
|
||||
"/appdata": localMountPath,
|
||||
@@ -69,29 +102,43 @@ describe("lazy widget application graph", () => {
|
||||
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
// Playwright cannot route requests handled by a service worker. Keep it
|
||||
// blocked in this context so the delayed refresh below is observable, then
|
||||
// verify registration separately in an unblocked context.
|
||||
const context = await browser.newContext({ serviceWorkers: "block" });
|
||||
const page = await context.newPage();
|
||||
const pageErrors: Error[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error));
|
||||
let releaseRefresh: (() => void) | undefined;
|
||||
|
||||
try {
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.locator("#password").fill(adminCredentials.password);
|
||||
const initialDownloadsResponse = page.waitForResponse(
|
||||
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
|
||||
);
|
||||
const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname === "/", {
|
||||
waitUntil: "commit",
|
||||
timeout: 60_000,
|
||||
});
|
||||
await page.locator("css=button[type='submit']").click();
|
||||
await signedIn;
|
||||
await initialDownloadsResponse;
|
||||
|
||||
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
|
||||
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
|
||||
const clockWidget = page.locator(
|
||||
`[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`,
|
||||
`[data-id="${clockItemId}"] .clock-widget-container, [data-grid-item-id="${clockItemId}"] .clock-widget-container`,
|
||||
);
|
||||
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--");
|
||||
const downloadsWidget = page
|
||||
.locator(`[data-id="${downloadsItemId}"], [data-grid-item-id="${downloadsItemId}"]`)
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
const cachedDownload = downloadsWidget.getByText("Big.Buck.Bunny.2008.1080p.BluRay.x264");
|
||||
await expect(cachedDownload).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, {
|
||||
timeout: 15_000,
|
||||
@@ -102,24 +149,56 @@ describe("lazy widget application graph", () => {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
await page.evaluate(async () => {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
return registrations[0]?.scope ?? null;
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(`${baseUrl}/`);
|
||||
let refreshRequestStarted = false;
|
||||
const refreshGate = new Promise<void>((resolve) => {
|
||||
releaseRefresh = resolve;
|
||||
});
|
||||
const downloadsRequestPattern = "**/*widget.downloads.getJobsAndStatuses*";
|
||||
const delayDownloadsRefresh = async (route: Route) => {
|
||||
refreshRequestStarted = true;
|
||||
await refreshGate;
|
||||
await route.continue();
|
||||
};
|
||||
await page.route(downloadsRequestPattern, delayDownloadsRefresh);
|
||||
|
||||
await page.reload();
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--");
|
||||
await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true);
|
||||
await expect(cachedDownload).toBeVisible({ timeout: 5_000 });
|
||||
await expect(downloadsWidget.locator('output[aria-live="polite"]')).toBeVisible();
|
||||
|
||||
const refreshedDownloadsResponse = page.waitForResponse(
|
||||
(response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(),
|
||||
);
|
||||
releaseRefresh();
|
||||
releaseRefresh = undefined;
|
||||
await refreshedDownloadsResponse;
|
||||
await expect(downloadsWidget.locator('output[aria-live="polite"]')).toHaveCount(0);
|
||||
await page.unroute(downloadsRequestPattern, delayDownloadsRefresh);
|
||||
expect(pageErrors).toEqual([]);
|
||||
|
||||
const serviceWorkerContext = await browser.newContext();
|
||||
const serviceWorkerPage = await serviceWorkerContext.newPage();
|
||||
try {
|
||||
await serviceWorkerPage.goto(`${baseUrl}/auth/login`);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
await serviceWorkerPage.evaluate(async () => {
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
return registrations[0]?.scope ?? null;
|
||||
}),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(`${baseUrl}/`);
|
||||
} finally {
|
||||
await serviceWorkerContext.close();
|
||||
}
|
||||
} finally {
|
||||
releaseRefresh?.();
|
||||
await browser.close();
|
||||
await homarrContainer.stop();
|
||||
}
|
||||
}, 120_000);
|
||||
}, 180_000);
|
||||
});
|
||||
Generated
-14
@@ -189,9 +189,6 @@ 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
|
||||
@@ -1136,9 +1133,6 @@ 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,9 +8840,6 @@ 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'}
|
||||
@@ -23951,11 +23942,6 @@ 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)
|
||||
|
||||
@@ -64,7 +64,6 @@ catalog:
|
||||
"@tanstack/react-query-devtools": ^5.101.2
|
||||
"@tanstack/react-query-next-experimental": ^5.101.2
|
||||
"@tanstack/react-query-persist-client": 5.101.2
|
||||
"@tanstack/query-sync-storage-persister": 5.101.2
|
||||
"@testcontainers/mysql": ^12.0.4
|
||||
"@testcontainers/postgresql": ^12.0.4
|
||||
"@testcontainers/redis": ^12.0.4
|
||||
|
||||
Reference in new issue
Block a user