diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index f5c4794b7..16392ff8d 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -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:", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts index 7740f3eb4..d51d074a9 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts @@ -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(); + 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(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(); }); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts index bb42ff6a3..6293a8eac 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -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 => + 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(); - const persister = createSyncStoragePersister({ - storage, - key, - retry: removeOldestQuery, - serialize: (client) => stringify(sanitizePersistedClient(client)), - deserialize: (value) => parse(value), - }); + let pendingClient: PersistedClient | undefined; + let persistTimer: ReturnType | 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(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); }; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index a11ecd52d..3e7656800 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -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 ( { - removeSessionQueryCache(initialSessionQueryScope); - reloadPage(); - }} + onScopeChange={handleScopeChange} > - {children} + + {children} + ); } @@ -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({ diff --git a/e2e/lazy-widgets.spec.ts b/e2e/lazy-widgets.spec.ts index ca2768375..14efef71c 100644 --- a/e2e/lazy-widgets.spec.ts +++ b/e2e/lazy-widgets.spec.ts @@ -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((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); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9509a868..a56278e42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2ef3b29bd..4f6c9b329 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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