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

-1
View File
@@ -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
View File
@@ -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);
});
-14
View File
@@ -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)
-1
View File
@@ -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