diff --git a/.oxlintrc.json b/.oxlintrc.json index 8f0096195..37ed7a019 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -30,6 +30,26 @@ "options": { "reportUnusedDisableDirectives": "off" }, + "overrides": [ + { + "files": ["apps/nextjs/src/**/*.ts", "apps/nextjs/src/**/*.tsx"], + "excludeFiles": ["**/*.spec.ts", "**/*.spec.tsx"], + "rules": { + "eslint/no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@homarr/widgets", + "allowTypeImports": true, + "message": "Import a narrow @homarr/widgets export to preserve widget lazy loading." + } + ] + } + ] + } + } + ], "ignorePatterns": [ "**/*.config.js", "**/*.config.mjs", diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 9b8c3305d..16392ff8d 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -76,6 +76,7 @@ "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "catalog:", "@tanstack/react-query-next-experimental": "catalog:", + "@tanstack/react-query-persist-client": "catalog:", "@trpc/client": "catalog:", "@trpc/next": "catalog:", "@trpc/react-query": "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 new file mode 100644 index 000000000..5c18c31f6 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts @@ -0,0 +1,350 @@ +import { dehydrate, QueryClient } from "@tanstack/react-query"; +import type { PersistedClient } from "@tanstack/react-query-persist-client"; +import { persistQueryClientRestore } from "@tanstack/react-query-persist-client"; +import { parse, stringify } from "superjson"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { + createSessionQueryPersister, + getQueryPersistenceStorageKey, + queryPersistenceBuster, + queryPersistenceMaxDataBytes, + shouldPersistDashboardQuery, +} from "./query-persistence"; + +const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, updatedAt = Date.now()) => { + const queryClient = new QueryClient(); + queryClient.setQueryData(queryKey, data, { updatedAt }); + const query = queryClient.getQueryCache().find({ queryKey }); + if (!query) throw new Error("Expected query to be present"); + 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(); + window.sessionStorage.clear(); +}); + +describe("widget query persistence", () => { + test("persists successful dashboard data but not unrelated, ping, pending, or oversized queries", () => { + const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 }); + const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]); + const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" }); + const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" }); + const oversized = createSuccessfulQuery([["widget", "example"], { type: "query" }], { + value: "x".repeat(queryPersistenceMaxDataBytes + 1), + }); + const pendingClient = new QueryClient(); + const pending = pendingClient.getQueryCache().build(pendingClient, { + queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }], + queryFn: () => Promise.resolve([]), + }); + + expect(shouldPersistDashboardQuery(widget.query)).toBe(true); + expect(shouldPersistDashboardQuery(supporting.query)).toBe(true); + expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false); + expect(shouldPersistDashboardQuery(ping.query)).toBe(false); + expect(shouldPersistDashboardQuery(oversized.query)).toBe(false); + expect(shouldPersistDashboardQuery(pending)).toBe(false); + }); + + test("uses stable, isolated storage keys for anonymous and authenticated scopes", () => { + expect(getQueryPersistenceStorageKey("user-a")).toBe(getQueryPersistenceStorageKey("user-a")); + expect(getQueryPersistenceStorageKey("user-a")).not.toBe(getQueryPersistenceStorageKey("user-b")); + expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a")); + + const userAKey = getQueryPersistenceStorageKey("user-a"); + window.sessionStorage.setItem(userAKey, "a"); + createSessionQueryPersister("user-a").removeClient(); + expect(window.sessionStorage.getItem(userAKey)).toBeNull(); + }); + + test("evicts every other scope so signing out cannot leave data behind", () => { + const userAKey = getQueryPersistenceStorageKey("user-a"); + const anonymousKey = getQueryPersistenceStorageKey(null); + window.sessionStorage.setItem(userAKey, "a"); + window.sessionStorage.setItem(anonymousKey, "anonymous"); + window.sessionStorage.setItem("unrelated", "kept"); + + createSessionQueryPersister("user-b"); + + expect(window.sessionStorage.getItem(userAKey)).toBeNull(); + expect(window.sessionStorage.getItem(anonymousKey)).toBeNull(); + expect(window.sessionStorage.getItem("unrelated")).toBe("kept"); + }); + + test("never restores another session scope", async () => { + const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const; + const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 }); + const userAPersister = createSessionQueryPersister("user-a"); + userAPersister.persistClient({ + timestamp: Date.now(), + buster: queryPersistenceBuster, + clientState: dehydrate(queryClient), + }); + userAPersister.flush(); + expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1); + + expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined(); + }); + + test("stops persisting once the provider tears the cache down", async () => { + const storage = createMemoryStorage(); + const persister = createSessionQueryPersister("user-a", storage); + const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { + temp: 21, + }); + + persister.persistClient({ + timestamp: Date.now(), + buster: queryPersistenceBuster, + clientState: dehydrate(queryClient), + }); + persister.flush(); + persister.stop(); + + persister.persistClient({ + timestamp: Date.now(), + buster: queryPersistenceBuster, + clientState: { mutations: [], queries: [] }, + }); + persister.flush(); + + expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1); + }); + + test("removes the cache instead of storing an empty one", () => { + const scope = "user-a"; + const key = getQueryPersistenceStorageKey(scope); + const persister = createSessionQueryPersister(scope); + window.sessionStorage.setItem(key, "stale"); + + persister.persistClient({ + timestamp: Date.now(), + buster: queryPersistenceBuster, + clientState: { mutations: [], queries: [] }, + }); + persister.flush(); + + expect(window.sessionStorage.getItem(key)).toBeNull(); + }); + + test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => { + vi.useFakeTimers(); + const scope = "user-a"; + const key = getQueryPersistenceStorageKey(scope); + const persister = createSessionQueryPersister(scope); + const { query, queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], { + startsAt: new Date("2026-08-22T12:00:00.000Z"), + }); + query.setState({ ...query.state, error: new Error("private refresh error"), status: "error" }); + expect(shouldPersistDashboardQuery(query)).toBe(true); + const persistedClient: PersistedClient = { + timestamp: Date.now(), + buster: queryPersistenceBuster, + clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }), + }; + + persister.persistClient(persistedClient); + persister.flush(); + const restored = await persister.restoreClient(); + const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined; + expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z")); + expect(restored?.clientState.queries[0]?.state.status).toBe("success"); + expect(restored?.clientState.queries[0]?.state.error).toBeNull(); + expect(restored?.clientState.queries[0]?.state.isInvalidated).toBe(true); + expect(window.sessionStorage.getItem(key)).not.toContain("private refresh error"); + + window.sessionStorage.setItem(key, "not-valid-superjson"); + expect(persister.restoreClient()).toBeUndefined(); + 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: queryPersistenceBuster, + clientState: { + mutations: [{ state: { status: "pending" } }], + queries: [ + { + ...validQuery, + state: { + ...validQuery.state, + error: new Error("private refresh error"), + fetchStatus: "fetching", + status: "error", + }, + }, + { + ...validQuery, + queryHash: "unrelated", + queryKey: [["board", "getBoardByName"], { type: "query" }], + }, + { queryHash: "malformed", queryKey: [["widget", "weather"]], state: null }, + { + ...validQuery, + queryHash: "missing-timestamp", + state: { ...validQuery.state, dataUpdatedAt: undefined }, + }, + ], + }, + }), + ); + + const restored = await createSessionQueryPersister(scope).restoreClient(); + expect(restored?.clientState.mutations).toEqual([]); + expect(restored?.clientState.queries).toHaveLength(1); + expect(restored?.clientState.queries[0]?.state.status).toBe("success"); + expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle"); + expect(restored?.clientState.queries[0]?.state.error).toBeNull(); + }); + + test.each([ + ["expired", 1, queryPersistenceBuster], + ["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), "old-version"], + ["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, queryPersistenceBuster], + ])("discards %s cache entries", async (_case, timestamp, storedBuster) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z")); + const scope = "user-a"; + const persister = createSessionQueryPersister(scope); + const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { + temp: 21, + }); + persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) }); + persister.flush(); + + const restoredClient = new QueryClient(); + await persistQueryClientRestore({ + queryClient: restoredClient, + persister, + maxAge: 5 * 60 * 1_000, + buster: queryPersistenceBuster, + }); + + expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined(); + expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull(); + }); + + 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: queryPersistenceBuster, + clientState: { + mutations: [], + queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries], + }, + }; + const persister = createSessionQueryPersister("user-a", storage); + + persister.persistClient(persistedClient); + persister.flush(); + + expect(setItemSpy).toHaveBeenCalledTimes(2); + const restored = await persister.restoreClient(); + expect(restored?.clientState.queries).toHaveLength(1); + expect(restored?.clientState.queries[0]?.queryKey).toEqual([["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: queryPersistenceBuster, + clientState: dehydrate(first.queryClient), + }); + persister.persistClient({ + timestamp: Date.now(), + buster: queryPersistenceBuster, + 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("treats blocked storage operations as an empty cache", async () => { + const blockedStorage = { + getItem: () => { + throw new DOMException("blocked", "SecurityError"); + }, + removeItem: () => { + throw new DOMException("blocked", "SecurityError"); + }, + setItem: () => { + throw new DOMException("blocked", "SecurityError"); + }, + } as unknown as Storage; + const persister = createSessionQueryPersister("blocked", blockedStorage); + const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 }); + + expect(persister.restoreClient()).toBeUndefined(); + expect(() => { + persister.persistClient({ + timestamp: Date.now(), + buster: queryPersistenceBuster, + clientState: dehydrate(queryClient), + }); + persister.flush(); + }).not.toThrow(); + expect(() => persister.removeClient()).not.toThrow(); + }); + + test("treats an unavailable session storage getter as an empty cache", async () => { + vi.spyOn(window, "sessionStorage", "get").mockImplementation(() => { + throw new DOMException("blocked", "SecurityError"); + }); + + expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow(); + expect(createSessionQueryPersister("blocked-getter").restoreClient()).toBeUndefined(); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts new file mode 100644 index 000000000..e5145ff5c --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -0,0 +1,226 @@ +import type { QueryKey } from "@tanstack/react-query"; +import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client"; +import { removeOldestQuery } from "@tanstack/react-query-persist-client"; +import { parse, stringify } from "superjson"; + +import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache"; + +export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs; +export const queryPersistenceMaxDataBytes = 256 * 1024; +export const queryPersistenceBuster = "v2-session-superjson"; + +const queryPersistenceStoragePrefix = "homarr:widget-query-cache"; + +export const getQueryPersistenceStorageKey = (scope: string | null) => + `${queryPersistenceStoragePrefix}:${encodeURIComponent(scope ?? "anonymous")}`; + +interface PersistableQuery { + queryKey: QueryKey; + state: { + data: unknown; + dataUpdatedAt: number; + }; +} + +export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void }; + +const textEncoder = new TextEncoder(); +const budgetVerdictCache = new WeakMap(); + +const measureFitsBudget = (data: unknown) => { + try { + return textEncoder.encode(stringify(data)).byteLength <= queryPersistenceMaxDataBytes; + } catch { + return false; + } +}; + +const fitsStorageBudget = (data: unknown) => { + if (typeof data !== "object" || data === null) return measureFitsBudget(data); + + const cached = budgetVerdictCache.get(data); + if (cached !== undefined) return cached; + + const verdict = measureFitsBudget(data); + budgetVerdictCache.set(data, verdict); + return verdict; +}; + +const holdsDashboardData = (query: PersistableQuery) => + query.state.data !== undefined && + Number.isFinite(query.state.dataUpdatedAt) && + query.state.dataUpdatedAt > 0 && + isWidgetDataQueryKey(query.queryKey); + +export const shouldPersistDashboardQuery = (query: PersistableQuery) => + holdsDashboardData(query) && fitsStorageBudget(query.state.data); + +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", + isInvalidated: true, + status: "success", + }, +}); + +const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({ + ...client, + clientState: { + mutations: [], + queries: client.clientState.queries.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 holdsDashboardData(value as unknown as PersistableQuery); +}; + +const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => { + if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined; + if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined; + + return { + timestamp: value.timestamp as number, + buster: value.buster, + clientState: { + mutations: [], + queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery), + }, + }; +}; + +const getSessionStorage = (): Storage | undefined => { + if (typeof window === "undefined") return undefined; + + try { + return window.sessionStorage; + } catch { + return undefined; + } +}; + +const removeStorageItem = (storage: Storage | undefined, key: string) => { + try { + storage?.removeItem(key); + } catch { + return undefined; + } +}; + +const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => { + if (!storage) return; + + try { + const foreignKeys: string[] = []; + for (let index = 0; index < storage.length; index += 1) { + const storedKey = storage.key(index); + if (storedKey === null || storedKey === activeKey) continue; + if (storedKey.startsWith(`${queryPersistenceStoragePrefix}:`)) foreignKeys.push(storedKey); + } + + for (const foreignKey of foreignKeys) storage.removeItem(foreignKey); + } catch { + return undefined; + } +}; + +export const createSessionQueryPersister = ( + scope: string | null, + storage: Storage | undefined = getSessionStorage(), +): SessionQueryPersister => { + const key = getQueryPersistenceStorageKey(scope); + removeForeignScopes(storage, key); + let pendingClient: PersistedClient | undefined; + let persistTimer: ReturnType | undefined; + let stopped = false; + + const persistNow = (client: PersistedClient) => { + if (!storage) return; + let candidate: PersistedClient | undefined = sanitizePersistedClient(client); + let errorCount = 0; + + if (candidate.clientState.queries.length === 0) { + removeStorageItem(storage, key); + return; + } + + 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 { + persistClient(client) { + if (!storage || stopped) return; + pendingClient = client; + if (persistTimer !== undefined) return; + persistTimer = setTimeout(flush, 0); + }, + restoreClient() { + try { + const storedValue = storage?.getItem(key); + if (!storedValue) return undefined; + + const client = sanitizeRestoredClient(parse(storedValue)); + if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) { + removeStorageItem(storage, key); + return undefined; + } + + return client; + } catch { + removeStorageItem(storage, key); + return undefined; + } + }, + removeClient() { + clearPersistTimer(); + pendingClient = undefined; + removeStorageItem(storage, key); + }, + flush, + stop() { + stopped = true; + clearPersistTimer(); + pendingClient = undefined; + }, + }; +}; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 535d88e1c..dc5242de4 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -1,10 +1,11 @@ "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, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient } from "@tanstack/react-query"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; +import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client"; import { createWSClient, httpBatchStreamLink, @@ -35,6 +36,13 @@ import { showWarningNotification } from "@homarr/notifications"; import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; +import { + createSessionQueryPersister, + queryPersistenceBuster, + queryPersistenceMaxAgeMs, + shouldPersistDashboardQuery, +} from "./query-persistence"; +import type { SessionQueryPersister } from "./query-persistence"; import { createQueryRetry } from "./query-retry"; const DevelopmentTools = @@ -67,21 +75,29 @@ 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 ( - {children} + {children} ); } const reloadPage = () => window.location.reload(); -const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => { +const ScopedTRPCReactProvider = ({ + children, + queryPersister, +}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => { const wsClient = useMemo( () => createWSClient({ @@ -136,6 +152,15 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => { return client; }); + useEffect(() => { + const flush = () => queryPersister.flush(); + window.addEventListener("pagehide", flush); + return () => { + window.removeEventListener("pagehide", flush); + flush(); + queryPersister.stop(); + }; + }, [queryPersister]); useEffect(() => () => queryClient.clear(), [queryClient]); const trpcClient = useMemo(() => { @@ -186,10 +211,18 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => { return ( - + {children} {process.env.NODE_ENV === "development" && } - + ); }; diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index b4a486fda..a7f7f6440 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -32,7 +32,7 @@ import { showErrorNotification, showWarningNotification } from "@homarr/notifica import { closeSpotlight } from "@homarr/spotlight/store"; import { useI18n } from "@homarr/translation/client"; import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight"; -import { AssistantWidgetRendererProvider } from "@homarr/widgets"; +import { AssistantWidgetRendererProvider } from "@homarr/widgets/assistant/context"; import { createWorkshopClient } from "~/components/workshop/workshop-client"; import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context"; diff --git a/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts b/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts index 704829201..2d199d91d 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-widget-tool.spec.ts @@ -20,6 +20,7 @@ describe("getAssistantWidgetConfiguration", () => { test("preserves generated notebook content and fills its hidden defaults", () => { const result = getAssistantWidgetConfiguration( + widgetImports.notebook.definition, { boardId: "board-1", boardName: "Home", @@ -41,6 +42,7 @@ describe("getAssistantWidgetConfiguration", () => { test("keeps only accessible, compatible integrations and respects the widget maximum", () => { const result = getAssistantWidgetConfiguration( + widgetImports.audioStats.definition, { boardId: "board-1", boardName: "Home", @@ -88,11 +90,13 @@ describe("getAssistantWidgetConfiguration", () => { test("treats integration-backed widgets as required unless explicitly optional", () => { const mediaServer = getAssistantWidgetConfiguration( + widgetImports.mediaServer.definition, { boardId: "board-1", boardName: "Home", kind: "mediaServer", summary: "Show Plex streams" }, settings, [], ); const calendar = getAssistantWidgetConfiguration( + widgetImports.calendar.definition, { boardId: "board-1", boardName: "Home", kind: "calendar", summary: "Show releases" }, settings, [], diff --git a/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx b/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx index 3c1495360..faa44941b 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx +++ b/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx @@ -1,9 +1,11 @@ "use client"; -import { useMemo } from "react"; +import { Suspense, use, useMemo } from "react"; import type { ToolCallMessagePartProps } from "@assistant-ui/react"; import { Alert, Box, Button, Group, Skeleton, Stack, Text, ThemeIcon } from "@mantine/core"; import { IconAlertTriangle, IconCheck, IconSettings } from "@tabler/icons-react"; +import { ErrorBoundary } from "react-error-boundary"; +import type { FallbackProps } from "react-error-boundary"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; @@ -13,7 +15,8 @@ import { useModalAction } from "@homarr/modals"; import type { SettingsContextProps } from "@homarr/settings/creator"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; -import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; +import type { WidgetDefinition } from "@homarr/widgets/definition"; +import { loadWidgetDefinition, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { IntegrationSelectOption } from "@homarr/widgets/widget-integration-select"; import { WidgetEditModal } from "@homarr/widgets/modals"; @@ -29,11 +32,11 @@ interface AssistantIntegration extends IntegrationSelectOption { } export const getAssistantWidgetConfiguration = ( + definition: WidgetDefinition, args: ConfigureWidgetArgs, settings: Pick, integrations: AssistantIntegration[], ) => { - const definition = widgetImports[args.kind].definition; const integrationSupport = "supportedIntegrations" in definition; const supportedIntegrations = integrationSupport ? (definition.supportedIntegrations as readonly IntegrationKind[]) @@ -54,35 +57,91 @@ export const getAssistantWidgetConfiguration = ( integrationSupport && (!("integrationsRequired" in definition) || definition.integrationsRequired !== false), value: { advancedOptions: { title: null, customCssClasses: [], borderColor: "" }, - options: reduceWidgetOptionsWithDefaultValues(args.kind, settings, args.options), + options: reduceWidgetOptionsWithDefinition(definition, settings, args.options), integrationIds, }, }; }; -export const AssistantConfigureWidgetTool = ({ +type AssistantConfigureWidgetToolProps = ToolCallMessagePartProps; + +export const AssistantConfigureWidgetTool = (props: AssistantConfigureWidgetToolProps) => { + const hasCompleteArguments = hasCompleteAssistantToolArguments(props.status); + const completeArgs = + props.result === undefined && !hasFailedAssistantToolArguments(props.status) && hasCompleteArguments + ? props.args + : undefined; + + if (!completeArgs) return ; + + return ( + + }> + + + + ); +}; + +const AssistantWidgetDefinitionErrorFallback = ({ resetErrorBoundary }: FallbackProps) => { + const t = useI18n("assistant.configureWidget"); + const actionT = useI18n("common.action"); + + return ( + }> + + {t("definitionLoadErrorDescription")} + + + + ); +}; + +const LoadedAssistantConfigureWidgetTool = ( + props: AssistantConfigureWidgetToolProps & { args: ConfigureWidgetArgs }, +) => { + const definition = use(loadWidgetDefinition(props.args.kind)); + return ; +}; + +const AssistantConfigureWidgetSkeleton = () => { + const t = useI18n("assistant.configureWidget"); + return ( + + + + + + + + ); +}; + +const AssistantConfigureWidgetToolContent = ({ args, result, addResult, status, toolCallId, -}: ToolCallMessagePartProps) => { + definition, +}: AssistantConfigureWidgetToolProps & { definition: WidgetDefinition | undefined }) => { const t = useI18n("assistant.configureWidget"); const actionT = useI18n("common.action"); const fullT = useI18n(); const { data: session } = useSession(); const settings = useSettings(); const hasCompleteArguments = hasCompleteAssistantToolArguments(status); - const definition = args?.kind ? widgetImports[args.kind].definition : undefined; const needsIntegrations = definition !== undefined && "supportedIntegrations" in definition; const integrations = clientApi.integration.all.useQuery(undefined, { enabled: result === undefined && hasCompleteArguments && needsIntegrations, retry: false, }); const configuration = useMemo(() => { - if (!args || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null; - return getAssistantWidgetConfiguration(args, settings, integrations.data ?? []); - }, [args, hasCompleteArguments, integrations.data, needsIntegrations, settings]); + if (!args || !definition || !hasCompleteArguments || (needsIntegrations && !integrations.data)) return null; + return getAssistantWidgetConfiguration(definition, args, settings, integrations.data ?? []); + }, [args, definition, hasCompleteArguments, integrations.data, needsIntegrations, settings]); const missingRequiredIntegration = configuration?.integrationsRequired === true && configuration.value.integrationIds.length === 0; const { openModal: openWidgetModal } = useModalAction(WidgetEditModal); @@ -134,15 +193,7 @@ export const AssistantConfigureWidgetTool = ({ } if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) { - return ( - - - - - - - - ); + return ; } if (integrations.isError) { @@ -158,7 +209,7 @@ export const AssistantConfigureWidgetTool = ({ ); } - if (!configuration) return null; + if (!configuration || !definition) return null; const openConfigurationModal = ( value = configuration.value, @@ -167,7 +218,7 @@ export const AssistantConfigureWidgetTool = ({ openWidgetModal( { kind: args.kind, - definition: widgetImports[args.kind].definition, + definition, value, integrationData, integrationSupport: configuration.integrationSupport, @@ -240,7 +291,7 @@ export const AssistantConfigureWidgetTool = ({ ); } - const WidgetIcon = widgetImports[args.kind].definition.icon; + const WidgetIcon = definition.icon; return ( diff --git a/apps/nextjs/src/components/board/items/item-content.module.css b/apps/nextjs/src/components/board/items/item-content.module.css index c00ac34d4..1e1226291 100644 --- a/apps/nextjs/src/components/board/items/item-content.module.css +++ b/apps/nextjs/src/components/board/items/item-content.module.css @@ -1,3 +1,17 @@ +.refreshIndicator { + position: absolute; + top: 0.25rem; + right: 0.25rem; + z-index: 3; + display: grid; + place-items: center; + width: 1.25rem; + height: 1.25rem; + border-radius: 999px; + background: color-mix(in srgb, var(--mantine-color-body) 78%, transparent); + pointer-events: none; +} + .settingsButton { position: absolute; top: -34px; diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index f695dc002..caac4cd60 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -4,7 +4,7 @@ import { createPortal } from "react-dom"; import dynamic from "next/dynamic"; import { Box, Button, Center, Loader, Portal } from "@mantine/core"; import { useElementSize, useIsomorphicEffect } from "@mantine/hooks"; -import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; +import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; import type { FallbackProps } from "react-error-boundary"; @@ -19,6 +19,7 @@ import { WidgetError } from "@homarr/widgets/errors"; import { createWidgetRuntimeState, getWidgetQueryKeys, + getWidgetRuntimeQueries, supportsAdvancedFocus as definitionSupportsAdvancedFocus, } from "@homarr/widgets/definition"; import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; @@ -39,6 +40,7 @@ import { useItemActions } from "./item-actions"; import itemContentClasses from "./item-content.module.css"; import { WidgetContextMenu } from "./widget-context-menu"; import { removeWidgetDataQueries } from "./widget-query-recovery"; +import { matchesWidgetItemQuery } from "./widget-query-scope"; const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), { ssr: false, @@ -156,6 +158,24 @@ const LoadedBoardItemContent = ({ () => ({ ...item, options: reduceWidgetOptionsWithDefinition(definition, settings, item.options) }), [definition, item, settings], ); + const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]); + const isWidgetRefreshing = + useIsFetching({ + predicate: (query) => + query.state.data !== undefined && + matchesWidgetItemQuery( + query.queryKey, + widgetQueryKeys, + { + itemId: item.id, + boardId: board.id, + integrationIds: item.integrationIds, + options: menuItem.options, + runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef), + }, + definition.queryMatcher, + ), + }) > 0; const [manualSurface, setManualSurface] = useState(null); const [surfacePortalTarget, setSurfacePortalTarget] = useState(null); const { active, viewportSize, open, close, dismiss, hover, leave } = useAdvancedFocus(); @@ -322,6 +342,11 @@ const LoadedBoardItemContent = ({ /> + {isWidgetRefreshing && ( + + + + )} ); diff --git a/e2e/lazy-widgets.spec.ts b/e2e/lazy-widgets.spec.ts index 570e260aa..d777ed0dc 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,10 +102,18 @@ describe("lazy widget application graph", () => { const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; const browser = await chromium.launch(); - const context = await browser.newContext(); + const context = await browser.newContext({ serviceWorkers: "block" }); const page = await context.newPage(); const pageErrors: Error[] = []; + const hydrationErrors: string[] = []; page.on("pageerror", (error) => pageErrors.push(error)); + const hydrationErrorPattern = /hydrat|did not match|react\.dev\/errors\/4(18|2[1235])|react error #4(18|2[1235])/i; + page.on("console", (message) => { + if (message.type() === "error" && hydrationErrorPattern.test(message.text())) { + hydrationErrors.push(message.text()); + } + }); + let releaseRefresh: (() => void) | undefined; try { await page.goto(`${baseUrl}/auth/login`); @@ -88,9 +129,18 @@ describe("lazy widget application graph", () => { 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("--:--", { + timeout: 30_000, + }); + 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, @@ -101,23 +151,59 @@ 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("--:--", { + timeout: 30_000, + }); + await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true); + await expect(cachedDownload).toBeVisible({ timeout: 5_000 }); + await expect(downloadsWidget.locator("[data-widget-refreshing]")).toBeVisible(); + + const refreshedDownloadsResponse = page.waitForResponse( + (response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(), + ); + releaseRefresh(); + releaseRefresh = undefined; + await refreshedDownloadsResponse; + await expect(downloadsWidget.locator("[data-widget-refreshing]")).toHaveCount(0); + await page.unroute(downloadsRequestPattern, delayDownloadsRefresh); expect(pageErrors).toEqual([]); + expect(hydrationErrors).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/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts index 50f96e1c2..a1cb201ea 100644 --- a/packages/api/src/query-cache.ts +++ b/packages/api/src/query-cache.ts @@ -8,6 +8,8 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5; export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5; const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]); +// Beszel system stats are large time-series payloads and were deliberately kept out of +// the persisted cache in #6289; live stats come from an SSE subscription instead. const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]); export const isWidgetDataTrpcPath = (path: string) => { diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 8849361b0..2652ca33d 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -7771,6 +7771,8 @@ "ready": "Widget configuration ready", "cancelled": "Widget was not added", "automaticContinue": "Accepting the proposed widget configuration automatically", + "definitionLoadErrorTitle": "Widget settings could not be loaded", + "definitionLoadErrorDescription": "Homarr could not load this widget's settings. Try again.", "loadErrorTitle": "Integrations could not be loaded", "loadErrorDescription": "Homarr could not load the integrations available for this widget.", "noIntegrationTitle": "A compatible integration is required", diff --git a/packages/widgets/src/bookmarks/bookmarks-widget.tsx b/packages/widgets/src/bookmarks/bookmarks-widget.tsx index 77b3971ba..7719d49a0 100644 --- a/packages/widgets/src/bookmarks/bookmarks-widget.tsx +++ b/packages/widgets/src/bookmarks/bookmarks-widget.tsx @@ -23,7 +23,8 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight"; import { useI18n } from "@homarr/translation/client"; import { getSafeAppHref, SAFE_NEW_TAB_REL } from "../common/application-url"; -import { getUsableWidgetQueryData } from "../common/query-state"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryLoadingState } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; import { createDirectBookmark, getBookmarkFaviconUrl, getDirectBookmarkUrl } from "./bookmark-item"; import type { BookmarkItem } from "./bookmark-item"; @@ -43,14 +44,12 @@ export default function BookmarksWidget({ const board = useRequiredBoard(); const advanced = displayMode === "advanced"; const appIds = useMemo(() => options.items.filter((value) => !getDirectBookmarkUrl(value)), [options.items]); - const apps = - getUsableWidgetQueryData( - clientApi.app.byIds.useQuery(appIds, { - select(selectedApps) { - return selectedApps; - }, - }), - ) ?? []; + const appsQuery = clientApi.app.byIds.useQuery(appIds, { + select(selectedApps) { + return selectedApps; + }, + }); + const apps = getUsableWidgetQueryData(appsQuery) ?? []; const appsById = new Map(apps.map((app) => [app.id, app])); const configuredItems = options.items.flatMap((value) => { const directUrl = getDirectBookmarkUrl(value); @@ -127,6 +126,8 @@ export default function BookmarksWidget({ /> )); + if (appIds.length > 0 && isInitialWidgetQueryPending(appsQuery)) return ; + return ( {options.title.length > 0 ? ( diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index d2f6ddc25..affe151f8 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -15,7 +15,8 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import actionTargetClasses from "../common/action-target.module.css"; import type { WidgetComponentProps } from "../definition"; -import { getUsableWidgetQueryData } from "../common/query-state"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryLoadingState } from "../common/query-state-indicator"; import { useWidgetRuntimeQueries } from "../runtime-hooks"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { CalendarDay } from "./calender-day"; @@ -98,6 +99,8 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [], ) ?? []; + if (isInitialWidgetQueryPending(calendarQuery)) return ; + return ( ([]); @@ -141,15 +140,7 @@ export default function DnsHoleControlsWidget({ setBulkPending(false); }; - if (isSummaryPending) { - return ( - - - {tCommon("action.loading")} - - - ); - } + if (isInitialWidgetQueryPending(summaryQuery)) return ; return ( 1 && width >= 240 && height >= 220; - if (isPending) { - return ( - - - {tCommon("action.loading")} - - - ); - } + if (isInitialWidgetQueryPending(summaryQuery)) return ; return ( diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index 8d246d26a..e1195f7f3 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -54,7 +54,8 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; -import { getUsableWidgetQueryData } from "../common/query-state"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryLoadingState } from "../common/query-state-indicator"; import { HomarrDataTable } from "../common/homarr-data-table"; import { formatLocalizedDateTime } from "../common/locale"; import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout"; @@ -741,6 +742,8 @@ export default function DownloadClientsWidget({ ); } + if (isInitialWidgetQueryPending(downloadsQuery)) return ; + let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu; if (isEditMode) rowContextMenuHandler = undefined; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 86ea2599d..a9ee650fe 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -3,9 +3,7 @@ import type { Loader } from "next/dynamic"; import dynamic from "next/dynamic"; import { Center, Loader as UiLoader } from "@mantine/core"; -import { objectEntries } from "@homarr/common"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; -import type { SettingsContextProps } from "@homarr/settings/creator"; import * as anchorNote from "./anchor-note"; import * as audioStats from "./audio-stats"; @@ -41,7 +39,6 @@ import * as notebook from "./notebook"; import * as paperlessNgx from "./paperless-ngx"; import * as patchmon from "./patchmon"; import * as notifications from "./notifications"; -import type { WidgetOptionDefinition } from "./options"; import * as releases from "./releases"; import * as rssFeed from "./rssFeed"; import * as smartHomeEntityState from "./smart-home/entity-state"; @@ -191,19 +188,3 @@ export type inferSupportedIntegrationsStrict = (Widget } ? WidgetImports[TKind]["definition"]["supportedIntegrations"] : never[])[number]; - -export const reduceWidgetOptionsWithDefaultValues = ( - kind: WidgetKind, - settings: Pick, - currentValue: Record = {}, -) => { - const definition = widgetImports[kind].definition; - const options = definition.createOptions(settings) as Record; - return objectEntries(options).reduce( - (prev, [key, value]) => ({ - ...prev, - [key]: currentValue[key] ?? value.defaultValue, - }), - {} as Record, - ); -}; diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index f53bbf2c7..94dc9492e 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -15,7 +15,8 @@ import { useI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../../common/empty-state"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url"; -import { getUsableWidgetQueryData } from "../../common/query-state"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state"; +import { WidgetQueryLoadingState } from "../../common/query-state-indicator"; import actionTargetClasses from "../../common/action-target.module.css"; import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import type { WidgetComponentProps } from "../../definition"; @@ -35,17 +36,17 @@ export default function MediaServerWidget({ .filter(({ id }) => integrationIds.includes(id)) .map(({ id }) => id), ); - const mediaRequestData = getUsableWidgetQueryData( - clientApi.widget.mediaRequests.getLatestRequests.useQuery({ - integrationIds, - statuses: - options.statusFilter.length > 0 - ? options.statusFilter - : ["pending", "approved", "declined", "failed", "completed"], - recentDays: options.recentDays, - }), - ); + const mediaRequestQuery = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ + integrationIds, + statuses: + options.statusFilter.length > 0 + ? options.statusFilter + : ["pending", "approved", "declined", "failed", "completed"], + recentDays: options.recentDays, + }); + const mediaRequestData = getUsableWidgetQueryData(mediaRequestQuery); + if (isInitialWidgetQueryPending(mediaRequestQuery)) return ; if (!mediaRequestData) return ; const { requests: mediaRequests, failedIntegrations } = mediaRequestData; if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError(); diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index 4fdf87f16..07a67bd34 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -23,7 +23,8 @@ import { useI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../../common/empty-state"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url"; -import { getUsableWidgetQueryData } from "../../common/query-state"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state"; +import { WidgetQueryLoadingState } from "../../common/query-state-indicator"; import actionTargetClasses from "../../common/action-target.module.css"; import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import type { WidgetComponentProps } from "../../definition"; @@ -42,10 +43,12 @@ export default function MediaServerWidget({ }: WidgetComponentProps<"mediaRequests-requestStats">) { const t = useI18n("widget.mediaRequests-requestStats"); const tCommon = useI18n("common"); - const requestStats = getUsableWidgetQueryData(clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds })); + const requestStatsQuery = clientApi.widget.mediaRequests.getStats.useQuery({ integrationIds }); + const requestStats = getUsableWidgetQueryData(requestStatsQuery); const board = useRequiredBoard(); + if (isInitialWidgetQueryPending(requestStatsQuery)) return ; if (!requestStats) return ; if ( requestStats.users.length === 0 && diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d959b800..43b4000ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,6 +201,9 @@ catalogs: '@tanstack/react-query-next-experimental': specifier: ^5.101.2 version: 5.101.2 + '@tanstack/react-query-persist-client': + specifier: ^5.101.2 + version: 5.101.2 '@testcontainers/mysql': specifier: ^12.0.4 version: 12.0.4 @@ -1142,6 +1145,9 @@ importers: '@tanstack/react-query-next-experimental': specifier: 'catalog:' version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(next@16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0))(react@19.2.7) + '@tanstack/react-query-persist-client': + specifier: 'catalog:' + version: 5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7) '@trpc/client': specifier: 'catalog:' version: 11.18.0(@trpc/server@11.18.0(typescript@7.0.2))(typescript@7.0.2) @@ -8831,6 +8837,9 @@ packages: '@tanstack/query-devtools@5.101.2': resolution: {integrity: sha512-o+wHcqgN7Pp0s8v1i0UGq/ZrrEKrxdIiMQmKRdYb2w7NPtylYSJ4+wg/tIn71m9DLstwUwdEGAvROdly6HXP6w==} + '@tanstack/query-persist-client-core@5.101.2': + resolution: {integrity: sha512-rgMsbvBVpSPDUsprC9FYxojdG0Q1LH6yq1i3DXz2aps4VEqv2l4Msj3YDqIifU3xkl/DrYe9YWntZAJLrM6xTg==} + '@tanstack/react-devtools@0.10.9': resolution: {integrity: sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw==} engines: {node: '>=18'} @@ -8853,6 +8862,12 @@ packages: next: ^13 || ^14 || ^15 || ^16 react: ^18 || ^19 + '@tanstack/react-query-persist-client@5.101.2': + resolution: {integrity: sha512-19UpaRtf0lvB2QAJ2MoixxtGf3osMOd508g99EsttUj4+3eLs+ulnNgYjB7AkQMHrRlcbVXqpVNqWkB4a7g8Zw==} + peerDependencies: + '@tanstack/react-query': ^5.101.2 + react: ^18 || ^19 + '@tanstack/react-query@5.101.2': resolution: {integrity: sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==} peerDependencies: @@ -23923,6 +23938,10 @@ snapshots: '@tanstack/query-devtools@5.101.2': {} + '@tanstack/query-persist-client-core@5.101.2': + dependencies: + '@tanstack/query-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) @@ -23948,6 +23967,12 @@ snapshots: next: 16.3.1(@babel/core@7.29.0(supports-color@10.2.2))(@opentelemetry/api@1.9.0)(@playwright/test@1.61.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(sass@1.101.0) react: 19.2.7 + '@tanstack/react-query-persist-client@5.101.2(@tanstack/react-query@5.101.2(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/query-persist-client-core': 5.101.2 + '@tanstack/react-query': 5.101.2(react@19.2.7) + react: 19.2.7 + '@tanstack/react-query@5.101.2(react@19.2.7)': dependencies: '@tanstack/query-core': 5.101.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9e5955a4b..952d1f3a8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -63,6 +63,7 @@ catalog: "@tanstack/react-query": ^5.101.2 "@tanstack/react-query-devtools": ^5.101.2 "@tanstack/react-query-next-experimental": ^5.101.2 + "@tanstack/react-query-persist-client": ^5.101.2 "@testcontainers/mysql": ^12.0.4 "@testcontainers/postgresql": ^12.0.4 "@testcontainers/redis": ^12.0.4