From a2a408410a3e9cf22e0c0f250cb21ec7eb355e20 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 22 Aug 2026 16:35:47 +0200 Subject: [PATCH] Persist widget data across reloads --- apps/nextjs/package.json | 2 + .../query-persistence.spec.ts | 215 ++++++++++++++++++ .../_client-providers/query-persistence.ts | 158 +++++++++++++ .../app/[locale]/_client-providers/trpc.tsx | 39 +++- .../app/[locale]/boards/(content)/_client.tsx | 39 ++-- .../[locale]/boards/(content)/_creator.tsx | 2 +- .../assistant/assistant-provider.tsx | 2 +- .../assistant/assistant-widget-tool.spec.ts | 4 + .../assistant/assistant-widget-tool.tsx | 75 ++++-- .../assistant/widget-import-boundary.spec.ts | 30 +++ .../board/items/item-content.module.css | 14 ++ .../components/board/items/item-content.tsx | 31 ++- e2e/lazy-widgets.spec.ts | 2 + packages/api/src/query-cache.ts | 2 +- packages/api/src/test/query-cache.spec.ts | 4 +- packages/widgets/package.json | 1 + .../src/bookmarks/bookmarks-widget.tsx | 19 +- packages/widgets/src/calendar/component.tsx | 5 +- .../widgets/src/clock/use-current-time.ts | 29 +-- packages/widgets/src/common/use-widget-now.ts | 5 +- .../src/common/widget-time-context.spec.tsx | 41 ++++ .../src/common/widget-time-context.tsx | 12 + .../src/dns-hole/controls/component.tsx | 15 +- .../src/dns-hole/summary/component.tsx | 15 +- packages/widgets/src/downloads/component.tsx | 5 +- .../src/media-requests/list/component.tsx | 23 +- .../src/media-requests/stats/component.tsx | 7 +- packages/widgets/src/time.ts | 3 + pnpm-lock.yaml | 39 ++++ pnpm-workspace.yaml | 2 + 30 files changed, 713 insertions(+), 127 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts create mode 100644 apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts create mode 100644 apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts create mode 100644 packages/widgets/src/common/widget-time-context.spec.tsx create mode 100644 packages/widgets/src/common/widget-time-context.tsx create mode 100644 packages/widgets/src/time.ts diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 9b8c3305d..f5c4794b7 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -72,10 +72,12 @@ "@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:", "@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..7740f3eb4 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts @@ -0,0 +1,215 @@ +// @vitest-environment jsdom + +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 { afterEach, describe, expect, test, vi } from "vitest"; + +import { + createSessionQueryPersister, + getQueryPersistenceBuster, + getQueryPersistenceStorageKey, + queryPersistenceMaxDataBytes, + removeSessionQueryCache, + scheduleRestoredDashboardQueryRefresh, + 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 }; +}; + +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")); + expect(getQueryPersistenceStorageKey("user-a")).not.toContain("user-a"); + + const userAKey = getQueryPersistenceStorageKey("user-a"); + const userBKey = getQueryPersistenceStorageKey("user-b"); + window.sessionStorage.setItem(userAKey, "a"); + window.sessionStorage.setItem(userBKey, "b"); + removeSessionQueryCache("user-a"); + expect(window.sessionStorage.getItem(userAKey)).toBeNull(); + expect(window.sessionStorage.getItem(userBKey)).toBe("b"); + }); + + 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: getQueryPersistenceBuster(scope), + clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }), + }; + + void persister.persistClient(persistedClient); + await vi.advanceTimersByTimeAsync(1_000); + 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(window.sessionStorage.getItem(key)).not.toContain("private refresh error"); + const restoredPredicate = persister.takeRestoredDashboardQueryPredicate(); + expect(restoredPredicate?.(query)).toBe(true); + expect(persister.takeRestoredDashboardQueryPredicate()).toBeUndefined(); + + window.sessionStorage.setItem(key, "not-valid-superjson"); + await expect(persister.restoreClient()).resolves.toBeUndefined(); + expect(window.sessionStorage.getItem(key)).toBeNull(); + }); + + test.each([ + ["expired", 1, getQueryPersistenceBuster("user-a")], + ["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), getQueryPersistenceBuster("user-b")], + ])("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, + }); + void persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) }); + await vi.advanceTimersByTimeAsync(1_000); + + const restoredClient = new QueryClient(); + await persistQueryClientRestore({ + queryClient: restoredClient, + persister, + maxAge: 5 * 60 * 1_000, + buster: getQueryPersistenceBuster(scope), + }); + + expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined(); + expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull(); + }); + + test("quota retry removes the oldest query first", () => { + const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1); + const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2); + const persistedClient: PersistedClient = { + timestamp: Date.now(), + buster: getQueryPersistenceBuster("user-a"), + clientState: { + mutations: [], + queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries], + }, + }; + + 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" }]); + }); + + test("refreshes only restored cache data without awaiting the refresh", async () => { + vi.useFakeTimers(); + const scope = "user-a"; + const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const; + const cached = createSuccessfulQuery(queryKey, { temp: 20 }, 10); + const persister = createSessionQueryPersister(scope); + void persister.persistClient({ + timestamp: Date.now(), + buster: getQueryPersistenceBuster(scope), + clientState: dehydrate(cached.queryClient), + }); + await vi.advanceTimersByTimeAsync(1_000); + await persister.restoreClient(); + + const fresh = createSuccessfulQuery(queryKey, { temp: 21 }, 20); + const neverSettles = new Promise(() => undefined); + const invalidateQueries = vi.spyOn(fresh.queryClient, "invalidateQueries").mockReturnValue(neverSettles); + + expect(scheduleRestoredDashboardQueryRefresh(fresh.queryClient, persister)).toBeUndefined(); + expect(invalidateQueries).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(0); + expect(invalidateQueries).toHaveBeenCalledOnce(); + + const predicate = invalidateQueries.mock.calls[0]?.[0]?.predicate; + expect(predicate?.(cached.query)).toBe(true); + expect(predicate?.(fresh.query)).toBe(false); + }); + + test("does not invalidate fresh queries when no cache was restored", async () => { + vi.useFakeTimers(); + const persister = createSessionQueryPersister("empty"); + await persister.restoreClient(); + const fresh = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 }); + const invalidateQueries = vi.spyOn(fresh.queryClient, "invalidateQueries"); + + scheduleRestoredDashboardQueryRefresh(fresh.queryClient, persister); + await vi.advanceTimersByTimeAsync(0); + + expect(invalidateQueries).not.toHaveBeenCalled(); + }); + + 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); + + await expect(persister.restoreClient()).resolves.toBeUndefined(); + expect(() => persister.removeClient()).not.toThrow(); + expect(() => removeSessionQueryCache("blocked", blockedStorage)).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(() => removeSessionQueryCache("blocked-getter")).not.toThrow(); + await expect(createSessionQueryPersister("blocked-getter").restoreClient()).resolves.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..bb42ff6a3 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -0,0 +1,158 @@ +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"; + +export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs; +export const queryPersistenceMaxDataBytes = 256 * 1024; +const queryPersistenceBuster = "v1-session-superjson"; + +const queryPersistenceStoragePrefix = "homarr:widget-query-cache:v1"; + +const hashScope = (scope: string) => { + let hash = 2_166_136_261; + for (let index = 0; index < scope.length; index += 1) { + hash ^= scope.charCodeAt(index); + hash = Math.imul(hash, 16_777_619); + } + return (hash >>> 0).toString(36); +}; + +export const getQueryPersistenceStorageKey = (scope: string | null) => + `${queryPersistenceStoragePrefix}:${hashScope(scope ?? "anonymous")}`; + +export const getQueryPersistenceBuster = (scope: string | null) => `${queryPersistenceBuster}:${scope ?? "anonymous"}`; + +interface PersistableQuery { + queryHash?: string; + queryKey: QueryKey; + state: { + data: unknown; + dataUpdatedAt: number; + status: string; + }; +} + +type RestoredDashboardQueryPredicate = (query: PersistableQuery) => boolean; + +export interface SessionQueryPersister extends Persister { + takeRestoredDashboardQueryPredicate: () => RestoredDashboardQueryPredicate | undefined; +} + +export const shouldPersistDashboardQuery = (query: PersistableQuery) => { + if (query.state.data === undefined || query.state.dataUpdatedAt <= 0 || !isWidgetDataQueryKey(query.queryKey)) { + return false; + } + + try { + return new TextEncoder().encode(stringify(query.state.data)).byteLength <= queryPersistenceMaxDataBytes; + } catch { + return false; + } +}; + +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", + }, + })), + }, +}); + +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 { + // Persistence is optional. Treat blocked or unavailable storage as an empty cache. + } +}; + +export const createSessionQueryPersister = ( + scope: string | null, + storage: Storage | undefined = getSessionStorage(), +): SessionQueryPersister => { + 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), + }); + + return { + ...persister, + 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]), + ); + } + return client; + } catch { + removeStorageItem(storage, key); + return undefined; + } + }, + removeClient() { + restoredQueries = new Map(); + removeStorageItem(storage, key); + }, + takeRestoredDashboardQueryPredicate() { + if (restoredQueries.size === 0) return undefined; + const restoredSnapshot = restoredQueries; + restoredQueries = new Map(); + return (query) => + query.queryHash !== undefined && + restoredSnapshot.get(query.queryHash) === query.state.dataUpdatedAt && + shouldPersistDashboardQuery(query); + }, + }; +}; + +export const scheduleRestoredDashboardQueryRefresh = (queryClient: QueryClient, persister: SessionQueryPersister) => { + const predicate = persister.takeRestoredDashboardQueryPredicate(); + if (!predicate) return; + + setTimeout(() => { + void queryClient.invalidateQueries({ type: "active", predicate }); + }, 0); +}; + +export const removeSessionQueryCache = (scope: string | null, storage: Storage | undefined = getSessionStorage()) => { + removeStorageItem(storage, getQueryPersistenceStorageKey(scope)); +}; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 535d88e1c..a11ecd52d 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -3,8 +3,9 @@ import type { PropsWithChildren } from "react"; import { 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,14 @@ import { showWarningNotification } from "@homarr/notifications"; import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; +import { + createSessionQueryPersister, + getQueryPersistenceBuster, + queryPersistenceMaxAgeMs, + removeSessionQueryCache, + scheduleRestoredDashboardQueryRefresh, + shouldPersistDashboardQuery, +} from "./query-persistence"; import { createQueryRetry } from "./query-retry"; const DevelopmentTools = @@ -72,16 +81,22 @@ export function TRPCReactProvider({ children }: PropsWithChildren) { { + removeSessionQueryCache(initialSessionQueryScope); + reloadPage(); + }} > - {children} + {children} ); } const reloadPage = () => window.location.reload(); -const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => { +const ScopedTRPCReactProvider = ({ + children, + sessionQueryScope, +}: PropsWithChildren<{ sessionQueryScope: string | null }>) => { const wsClient = useMemo( () => createWSClient({ @@ -137,6 +152,7 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => { }); useEffect(() => () => queryClient.clear(), [queryClient]); + const [queryPersister] = useState(() => createSessionQueryPersister(sessionQueryScope)); const trpcClient = useMemo(() => { return clientApi.createClient({ @@ -186,10 +202,21 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => { return ( - + { + scheduleRestoredDashboardQueryRefresh(queryClient, queryPersister); + }} + > {children} {process.env.NODE_ENV === "development" && } - + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 395178901..9e86f427b 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -9,6 +9,7 @@ import { useEditMode } from "@homarr/boards/edit-mode"; import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview"; import { useI18n } from "@homarr/translation/client"; import { FloatingTip } from "@homarr/ui"; +import { WidgetTimeProvider } from "@homarr/widgets/time"; import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; import { BoardEmptyState } from "~/components/board/board-empty-state"; @@ -39,7 +40,7 @@ const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => ( ); -export const ClientBoard = () => { +export const ClientBoard = ({ initialTimestamp }: { initialTimestamp: number }) => { const board = useRequiredBoard(); const t = useI18n("board.landmark"); const tPreview = useI18n("board.setting.section.layout.preview"); @@ -155,23 +156,27 @@ export const ClientBoard = () => { ); - if (representativeWidth === null) return content; + if (representativeWidth === null) { + return {content}; + } return ( - - - {tPreview("editorWidthLabel", { layoutName: currentLayout.name, width: representativeWidth })} - - - {content} - - + + + + {tPreview("editorWidthLabel", { layoutName: currentLayout.name, width: representativeWidth })} + + + {content} + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 9cf907b5e..689afb5bb 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -96,7 +96,7 @@ export const createBoardContentPage = >( return ( - + ); 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..444e31d0e 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx +++ b/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx @@ -1,6 +1,6 @@ "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"; @@ -13,7 +13,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 +30,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 +55,73 @@ 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 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 +173,7 @@ export const AssistantConfigureWidgetTool = ({ } if (!hasCompleteArguments || !args || (needsIntegrations && integrations.isPending)) { - return ( - - - - - - - - ); + return ; } if (integrations.isError) { @@ -158,7 +189,7 @@ export const AssistantConfigureWidgetTool = ({ ); } - if (!configuration) return null; + if (!configuration || !definition) return null; const openConfigurationModal = ( value = configuration.value, @@ -167,7 +198,7 @@ export const AssistantConfigureWidgetTool = ({ openWidgetModal( { kind: args.kind, - definition: widgetImports[args.kind].definition, + definition, value, integrationData, integrationSupport: configuration.integrationSupport, @@ -240,7 +271,7 @@ export const AssistantConfigureWidgetTool = ({ ); } - const WidgetIcon = widgetImports[args.kind].definition.icon; + const WidgetIcon = definition.icon; return ( diff --git a/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts b/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts new file mode 100644 index 000000000..78cc82c04 --- /dev/null +++ b/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts @@ -0,0 +1,30 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; + +const sourceRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); + +const collectRuntimeSourceFiles = (directory: string): string[] => + readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name); + if (entry.isDirectory()) return collectRuntimeSourceFiles(path); + if (!/\.(ts|tsx)$/.test(entry.name) || /\.spec\.(ts|tsx)$/.test(entry.name)) return []; + return [path]; + }); + +describe("widget import boundary", () => { + test("application runtime code uses narrow widget exports", () => { + const offenders = collectRuntimeSourceFiles(sourceRoot).filter((path) => { + const source = readFileSync(path, "utf8"); + const importStatements = source.match(/import[\s\S]*?;/g) ?? []; + return ( + importStatements.some( + (statement) => !/^import\s+type\b/.test(statement) && /\sfrom\s+["']@homarr\/widgets["']/.test(statement), + ) || /import\(["']@homarr\/widgets["']\)/.test(source) + ); + }); + + expect(offenders).toEqual([]); + }); +}); 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..5c03f2648 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -2,9 +2,9 @@ import type { ComponentType, CSSProperties, MutableRefObject, PropsWithChildren import { memo, Suspense, use, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import dynamic from "next/dynamic"; -import { Box, Button, Center, Loader, Portal } from "@mantine/core"; +import { Box, Button, Center, Loader, Portal, VisuallyHidden } 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,25 @@ 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({ + type: "active", + 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 +343,12 @@ const LoadedBoardItemContent = ({ /> + {isWidgetRefreshing && ( + + + {t("common.action.loading")} + + )} ); diff --git a/e2e/lazy-widgets.spec.ts b/e2e/lazy-widgets.spec.ts index 570e260aa..ca2768375 100644 --- a/e2e/lazy-widgets.spec.ts +++ b/e2e/lazy-widgets.spec.ts @@ -91,6 +91,7 @@ describe("lazy widget application graph", () => { `[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .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("--:--"); await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, { timeout: 15_000, @@ -114,6 +115,7 @@ describe("lazy widget application graph", () => { await page.reload(); await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 }); + await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--"); expect(pageErrors).toEqual([]); } finally { await browser.close(); diff --git a/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts index 50f96e1c2..e07b2a246 100644 --- a/packages/api/src/query-cache.ts +++ b/packages/api/src/query-cache.ts @@ -8,7 +8,7 @@ export const queryCacheDefaultGcTimeMs = 1000 * 60 * 5; export const queryCacheMetadataStaleTimeMs = 1000 * 60 * 5; const widgetDataQueryPaths = new Set(["app.byId", "app.byIds", "docker.getContainers", "integration.byIds"]); -const excludedWidgetPaths = new Set(["widget.app.ping", "widget.beszel.getSystemStats"]); +const excludedWidgetPaths = new Set(["widget.app.ping"]); export const isWidgetDataTrpcPath = (path: string) => { if (path.startsWith("widget.")) return !excludedWidgetPaths.has(path); diff --git a/packages/api/src/test/query-cache.spec.ts b/packages/api/src/test/query-cache.spec.ts index e5928bf71..02103d13d 100644 --- a/packages/api/src/test/query-cache.spec.ts +++ b/packages/api/src/test/query-cache.spec.ts @@ -22,7 +22,7 @@ describe("isWidgetDataQueryKey", () => { [[["docker", "getContainers"], { type: "query" }], true], [[["integration", "byIds"], { type: "query" }], true], [[["widget", "app", "ping"], { type: "query", input: { id: "abc" } }], false], - [[["widget", "beszel", "getSystemStats"], { type: "query" }], false], + [[["widget", "beszel", "getSystemStats"], { type: "query" }], true], [[["widget", "beszel", "getSystems"], { type: "query" }], true], [[["app", "selectable"], { type: "query" }], false], [[["board", "getBoardByName"], { type: "query" }], false], @@ -39,7 +39,7 @@ describe("isWidgetDataTrpcPath", () => { ["integration.byIds", true], ["docker.getContainers", true], ["widget.app.ping", false], - ["widget.beszel.getSystemStats", false], + ["widget.beszel.getSystemStats", true], ["board.getBoardByName", false], ])("classifies dashboard data path %s", (path, expected) => { expect(isWidgetDataTrpcPath(path)).toBe(expected); diff --git a/packages/widgets/package.json b/packages/widgets/package.json index 2daffc1b5..5036148b5 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -31,6 +31,7 @@ "./modals": "./src/modals/index.ts", "./prefetch": "./src/prefetch.ts", "./refetch-intervals": "./src/refetch-intervals.ts", + "./time": "./src/time.ts", "./widget-card-shell": "./src/widget-card-shell.tsx" }, "scripts": { 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 ( { - const [time, setTime] = useState(null); - const timeoutRef = useRef>(null); - const intervalRef = useRef>(null); - const intervalMultiplier = useMemo(() => (showSeconds ? 1 : 60), [showSeconds]); - - useEffect(() => { - setTime(new Date()); - timeoutRef.current = setTimeout( - () => { - setTime(new Date()); - intervalRef.current = setInterval(() => setTime(new Date()), intervalMultiplier * 1000); - }, - intervalMultiplier * 1000 - (1000 * (showSeconds ? 0 : dayjs().second()) + dayjs().millisecond()), - ); - - return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current); - if (intervalRef.current) clearInterval(intervalRef.current); - }; - }, [intervalMultiplier, showSeconds]); - - return time; -}; +export const useCurrentTime = ({ showSeconds }: { showSeconds: boolean }) => + useWidgetNow(showSeconds ? "second" : "minute"); diff --git a/packages/widgets/src/common/use-widget-now.ts b/packages/widgets/src/common/use-widget-now.ts index 40f7c88cd..03dfc90ee 100644 --- a/packages/widgets/src/common/use-widget-now.ts +++ b/packages/widgets/src/common/use-widget-now.ts @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { useWidgetInitialTimestamp } from "./widget-time-context"; + export type WidgetClockPrecision = "second" | "minute" | "day"; const minimumDelayMs = 10; @@ -20,7 +22,8 @@ export const getNextWidgetTickDelay = (now: Date, precision: WidgetClockPrecisio }; export const useWidgetNow = (precision: WidgetClockPrecision = "minute"): Date | null => { - const [now, setNow] = useState(null); + const initialTimestamp = useWidgetInitialTimestamp(); + const [now, setNow] = useState(() => (initialTimestamp === null ? null : new Date(initialTimestamp))); const timeoutRef = useRef>(undefined); const cancelTick = useCallback(() => { diff --git a/packages/widgets/src/common/widget-time-context.spec.tsx b/packages/widgets/src/common/widget-time-context.spec.tsx new file mode 100644 index 000000000..39fee29ee --- /dev/null +++ b/packages/widgets/src/common/widget-time-context.spec.tsx @@ -0,0 +1,41 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { hydrateRoot } from "react-dom/client"; +import { renderToString } from "react-dom/server"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { useWidgetNow } from "./use-widget-now"; +import { WidgetTimeProvider } from "./widget-time-context"; + +const TimeProbe = () => ; + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("WidgetTimeProvider", () => { + test("uses the same non-placeholder timestamp for server rendering and hydration", async () => { + vi.useFakeTimers(); + const timestamp = new Date("2026-08-22T12:34:00.000Z").getTime(); + vi.setSystemTime(timestamp); + const content = ( + + + + ); + const html = renderToString(content); + const host = document.createElement("div"); + host.innerHTML = html; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + expect(host.textContent).toBe("2026-08-22T12:34:00.000Z"); + const root = hydrateRoot(host, content); + await act(async () => undefined); + expect(host.textContent).toBe("2026-08-22T12:34:00.000Z"); + expect(consoleError).not.toHaveBeenCalled(); + + await act(async () => root.unmount()); + }); +}); diff --git a/packages/widgets/src/common/widget-time-context.tsx b/packages/widgets/src/common/widget-time-context.tsx new file mode 100644 index 000000000..2e09ae467 --- /dev/null +++ b/packages/widgets/src/common/widget-time-context.tsx @@ -0,0 +1,12 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { createContext, useContext } from "react"; + +const WidgetTimeContext = createContext(null); + +export const WidgetTimeProvider = ({ children, initialTimestamp }: PropsWithChildren<{ initialTimestamp: number }>) => ( + {children} +); + +export const useWidgetInitialTimestamp = () => useContext(WidgetTimeContext); diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index 5a17b33bc..f4d991b48 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -34,7 +34,8 @@ import { MaskedOrNormalImage } from "@homarr/ui"; import type { widgetKind } from "."; 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 actionTargetClasses from "../../common/action-target.module.css"; import classes from "./component.module.css"; import TimerModal from "./TimerModal"; @@ -67,7 +68,6 @@ export default function DnsHoleControlsWidget({ const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds }); const summaryResults = getUsableWidgetQueryData(summaryQuery) ?? []; const summaries = summaryResults.filter(isAvailableDnsSummaryResult); - const { isPending: isSummaryPending } = summaryQuery; const utils = clientApi.useUtils(); const { @@ -122,7 +122,6 @@ export default function DnsHoleControlsWidget({ ); const t = useI18n("widget.dnsHoleControls"); - const tCommon = useI18n("common"); // Timer modal setup const [selectedIntegrationIds, setSelectedIntegrationIds] = useState([]); @@ -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/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/packages/widgets/src/time.ts b/packages/widgets/src/time.ts new file mode 100644 index 000000000..a294c53bb --- /dev/null +++ b/packages/widgets/src/time.ts @@ -0,0 +1,3 @@ +export { WidgetTimeProvider, useWidgetInitialTimestamp } from "./common/widget-time-context"; +export { getNextWidgetTickDelay, useWidgetNow } from "./common/use-widget-now"; +export type { WidgetClockPrecision } from "./common/use-widget-now"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d959b800..c9509a868 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ 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 @@ -201,6 +204,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 @@ -1130,6 +1136,9 @@ 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) @@ -1142,6 +1151,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 +8843,12 @@ 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/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'} @@ -8853,6 +8871,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 +23947,15 @@ snapshots: '@tanstack/query-devtools@5.101.2': {} + '@tanstack/query-persist-client-core@5.101.2': + 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) @@ -23948,6 +23981,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..2ef3b29bd 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -63,6 +63,8 @@ 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 + "@tanstack/query-sync-storage-persister": 5.101.2 "@testcontainers/mysql": ^12.0.4 "@testcontainers/postgresql": ^12.0.4 "@testcontainers/redis": ^12.0.4