From a2a408410a3e9cf22e0c0f250cb21ec7eb355e20 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 22 Aug 2026 16:35:47 +0200 Subject: [PATCH 1/9] 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 From 357355283c6d1013425199b8cb34b0e91ee75bc9 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 22 Aug 2026 17:10:08 +0200 Subject: [PATCH 2/9] fix: harden widget query persistence --- apps/nextjs/package.json | 1 - .../query-persistence.spec.ts | 172 ++++++++++++++++-- .../_client-providers/query-persistence.ts | 144 +++++++++++---- .../app/[locale]/_client-providers/trpc.tsx | 30 ++- e2e/lazy-widgets.spec.ts | 135 +++++++++++--- pnpm-lock.yaml | 14 -- pnpm-workspace.yaml | 1 - 7 files changed, 401 insertions(+), 96 deletions(-) diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index f5c4794b7..16392ff8d 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -72,7 +72,6 @@ "@rexa-developer/tiks": "catalog:", "@scalar/api-reference-react": "catalog:", "@tabler/icons-react": "catalog:", - "@tanstack/query-sync-storage-persister": "catalog:", "@tanstack/react-devtools": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-query-devtools": "catalog:", diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts index 7740f3eb4..d51d074a9 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts @@ -2,7 +2,8 @@ import { dehydrate, QueryClient } from "@tanstack/react-query"; import type { PersistedClient } from "@tanstack/react-query-persist-client"; -import { persistQueryClientRestore, removeOldestQuery } from "@tanstack/react-query-persist-client"; +import { persistQueryClientRestore } from "@tanstack/react-query-persist-client"; +import { parse, stringify } from "superjson"; import { afterEach, describe, expect, test, vi } from "vitest"; import { @@ -23,6 +24,20 @@ const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown, upda return { query, queryClient }; }; +const createMemoryStorage = (): Storage => { + const values = new Map(); + return { + get length() { + return values.size; + }, + clear: () => values.clear(), + getItem: (key) => values.get(key) ?? null, + key: (index) => Array.from(values.keys())[index] ?? null, + removeItem: (key) => values.delete(key), + setItem: (key, value) => values.set(key, value), + }; +}; + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); @@ -67,6 +82,42 @@ describe("widget query persistence", () => { expect(window.sessionStorage.getItem(userBKey)).toBe("b"); }); + test("never restores another session scope", async () => { + const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const; + const { queryClient } = createSuccessfulQuery(queryKey, { temp: 21 }); + const userAPersister = createSessionQueryPersister("user-a"); + userAPersister.persistClient({ + timestamp: Date.now(), + buster: getQueryPersistenceBuster("user-a"), + clientState: dehydrate(queryClient), + }); + userAPersister.flush(); + + await expect(createSessionQueryPersister("user-b").restoreClient()).resolves.toBeUndefined(); + expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1); + }); + + test("persister itself rejects unrelated query payloads", async () => { + const scope = "user-a"; + const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 }); + const unrelated = createSuccessfulQuery([["user", "session"], { type: "query" }], { secret: true }); + const persister = createSessionQueryPersister(scope); + + persister.persistClient({ + timestamp: Date.now(), + buster: getQueryPersistenceBuster(scope), + clientState: { + mutations: [], + queries: [...dehydrate(widget.queryClient).queries, ...dehydrate(unrelated.queryClient).queries], + }, + }); + persister.flush(); + + const storedValue = window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope)); + expect(storedValue).not.toContain("secret"); + expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1); + }); + test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => { vi.useFakeTimers(); const scope = "user-a"; @@ -83,8 +134,8 @@ describe("widget query persistence", () => { clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }), }; - void persister.persistClient(persistedClient); - await vi.advanceTimersByTimeAsync(1_000); + persister.persistClient(persistedClient); + persister.flush(); const restored = await persister.restoreClient(); const restoredData = restored?.clientState.queries[0]?.state.data as { startsAt?: Date } | undefined; expect(restoredData?.startsAt).toEqual(new Date("2026-08-22T12:00:00.000Z")); @@ -100,9 +151,56 @@ describe("widget query persistence", () => { expect(window.sessionStorage.getItem(key)).toBeNull(); }); + test("sanitizes structurally valid but unsafe stored state before hydration", async () => { + const scope = "user-a"; + const key = getQueryPersistenceStorageKey(scope); + const { queryClient } = createSuccessfulQuery( + [["widget", "calendar", "findAllEvents"], { type: "query" }], + [{ title: "safe" }], + ); + const validQuery = dehydrate(queryClient).queries[0]; + if (!validQuery) throw new Error("Expected a dehydrated query"); + + window.sessionStorage.setItem( + key, + stringify({ + timestamp: Date.now(), + buster: getQueryPersistenceBuster(scope), + clientState: { + mutations: [{ state: { status: "pending" } }], + queries: [ + { + ...validQuery, + state: { + ...validQuery.state, + error: new Error("private refresh error"), + fetchStatus: "fetching", + status: "error", + }, + }, + { + ...validQuery, + queryHash: "unrelated", + queryKey: [["board", "getBoardByName"], { type: "query" }], + }, + { queryHash: "malformed", queryKey: [["widget", "weather"]], state: null }, + ], + }, + }), + ); + + const restored = await createSessionQueryPersister(scope).restoreClient(); + expect(restored?.clientState.mutations).toEqual([]); + expect(restored?.clientState.queries).toHaveLength(1); + expect(restored?.clientState.queries[0]?.state.status).toBe("success"); + expect(restored?.clientState.queries[0]?.state.fetchStatus).toBe("idle"); + expect(restored?.clientState.queries[0]?.state.error).toBeNull(); + }); + test.each([ ["expired", 1, getQueryPersistenceBuster("user-a")], ["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), getQueryPersistenceBuster("user-b")], + ["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, getQueryPersistenceBuster("user-a")], ])("discards %s cache entries", async (_case, timestamp, storedBuster) => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-08-22T12:00:00.000Z")); @@ -111,8 +209,8 @@ describe("widget query persistence", () => { const { queryClient } = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21, }); - void persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) }); - await vi.advanceTimersByTimeAsync(1_000); + persister.persistClient({ timestamp, buster: storedBuster, clientState: dehydrate(queryClient) }); + persister.flush(); const restoredClient = new QueryClient(); await persistQueryClientRestore({ @@ -126,9 +224,16 @@ describe("widget query persistence", () => { expect(window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope))).toBeNull(); }); - test("quota retry removes the oldest query first", () => { + test("quota retry writes the cache after evicting the oldest query", async () => { const old = createSuccessfulQuery([["widget", "old"], { type: "query" }], "old", 1); const recent = createSuccessfulQuery([["widget", "recent"], { type: "query" }], "recent", 2); + const storage = createMemoryStorage(); + const setItem = storage.setItem.bind(storage); + const setItemSpy = vi.spyOn(storage, "setItem").mockImplementation((key, value) => { + const client = parse(value); + if (client.clientState.queries.length > 1) throw new DOMException("quota", "QuotaExceededError"); + setItem(key, value); + }); const persistedClient: PersistedClient = { timestamp: Date.now(), buster: getQueryPersistenceBuster("user-a"), @@ -137,10 +242,45 @@ describe("widget query persistence", () => { queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries], }, }; + const persister = createSessionQueryPersister("user-a", storage); - const retried = removeOldestQuery({ persistedClient, error: new Error("quota"), errorCount: 1 }); - expect(retried?.clientState.queries).toHaveLength(1); - expect(retried?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]); + persister.persistClient(persistedClient); + persister.flush(); + + expect(setItemSpy).toHaveBeenCalledTimes(2); + const restored = await persister.restoreClient(); + expect(restored?.clientState.queries).toHaveLength(1); + expect(restored?.clientState.queries[0]?.queryKey).toEqual([["widget", "recent"], { type: "query" }]); + }); + + test("coalesces pending writes and can synchronously flush the latest cache before reload", async () => { + vi.useFakeTimers(); + const storage = createMemoryStorage(); + const setItem = vi.spyOn(storage, "setItem"); + const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const; + const first = createSuccessfulQuery(queryKey, { temp: 20 }, 1); + const latest = createSuccessfulQuery(queryKey, { temp: 21 }, 2); + const persister = createSessionQueryPersister("user-a", storage); + + persister.persistClient({ + timestamp: Date.now(), + buster: getQueryPersistenceBuster("user-a"), + clientState: dehydrate(first.queryClient), + }); + persister.persistClient({ + timestamp: Date.now(), + buster: getQueryPersistenceBuster("user-a"), + clientState: dehydrate(latest.queryClient), + }); + + expect(setItem).not.toHaveBeenCalled(); + persister.flush(); + expect(setItem).toHaveBeenCalledOnce(); + await vi.advanceTimersByTimeAsync(0); + expect(setItem).toHaveBeenCalledOnce(); + + const restored = await persister.restoreClient(); + expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 }); }); test("refreshes only restored cache data without awaiting the refresh", async () => { @@ -149,12 +289,12 @@ describe("widget query persistence", () => { const queryKey = [["widget", "weather", "getWeather"], { type: "query" }] as const; const cached = createSuccessfulQuery(queryKey, { temp: 20 }, 10); const persister = createSessionQueryPersister(scope); - void persister.persistClient({ + persister.persistClient({ timestamp: Date.now(), buster: getQueryPersistenceBuster(scope), clientState: dehydrate(cached.queryClient), }); - await vi.advanceTimersByTimeAsync(1_000); + persister.flush(); await persister.restoreClient(); const fresh = createSuccessfulQuery(queryKey, { temp: 21 }, 20); @@ -165,6 +305,7 @@ describe("widget query persistence", () => { expect(invalidateQueries).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(0); expect(invalidateQueries).toHaveBeenCalledOnce(); + expect(invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ type: "all", refetchType: "active" })); const predicate = invalidateQueries.mock.calls[0]?.[0]?.predicate; expect(predicate?.(cached.query)).toBe(true); @@ -197,8 +338,17 @@ describe("widget query persistence", () => { }, } as unknown as Storage; const persister = createSessionQueryPersister("blocked", blockedStorage); + const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 }); await expect(persister.restoreClient()).resolves.toBeUndefined(); + expect(() => { + persister.persistClient({ + timestamp: Date.now(), + buster: getQueryPersistenceBuster("blocked"), + clientState: dehydrate(queryClient), + }); + persister.flush(); + }).not.toThrow(); expect(() => persister.removeClient()).not.toThrow(); expect(() => removeSessionQueryCache("blocked", blockedStorage)).not.toThrow(); }); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts index bb42ff6a3..6293a8eac 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -1,7 +1,6 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { PersistedClient, Persister } from "@tanstack/react-query-persist-client"; import { removeOldestQuery } from "@tanstack/react-query-persist-client"; -import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister"; import { parse, stringify } from "superjson"; import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache"; @@ -39,6 +38,7 @@ interface PersistableQuery { type RestoredDashboardQueryPredicate = (query: PersistableQuery) => boolean; export interface SessionQueryPersister extends Persister { + flush: () => void; takeRestoredDashboardQueryPredicate: () => RestoredDashboardQueryPredicate | undefined; } @@ -54,25 +54,59 @@ export const shouldPersistDashboardQuery = (query: PersistableQuery) => { } }; +type DehydratedQuery = PersistedClient["clientState"]["queries"][number]; + +const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({ + ...query, + state: { + ...query.state, + error: null, + errorUpdateCount: 0, + errorUpdatedAt: 0, + fetchFailureCount: 0, + fetchFailureReason: null, + fetchStatus: "idle", + status: "success", + }, +}); + const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({ ...client, clientState: { mutations: [], - queries: client.clientState.queries.map((query) => ({ - ...query, - state: { - ...query.state, - error: null, - errorUpdateCount: 0, - errorUpdatedAt: 0, - fetchFailureCount: 0, - fetchFailureReason: null, - status: "success", - }, - })), + // Keep this guard inside the persister as well as in the provider's + // dehydration options so a future caller cannot accidentally store an + // unrelated query payload. + queries: client.clientState.queries + .filter((query) => shouldPersistDashboardQuery(query as PersistableQuery)) + .map(sanitizePersistedQuery), }, }); +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isRestorableQuery = (value: unknown): value is DehydratedQuery => { + if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false; + if (!isRecord(value.state) || typeof value.state.status !== "string") return false; + + return shouldPersistDashboardQuery(value as unknown as PersistableQuery); +}; + +const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => { + if (!isRecord(value) || !Number.isFinite(value.timestamp) || typeof value.buster !== "string") return undefined; + if (!isRecord(value.clientState) || !Array.isArray(value.clientState.queries)) return undefined; + + return { + timestamp: value.timestamp as number, + buster: value.buster, + clientState: { + mutations: [], + queries: value.clientState.queries.filter(isRestorableQuery).map(sanitizePersistedQuery), + }, + }; +}; + const getSessionStorage = (): Storage | undefined => { if (typeof window === "undefined") return undefined; @@ -98,30 +132,70 @@ export const createSessionQueryPersister = ( const key = getQueryPersistenceStorageKey(scope); const buster = getQueryPersistenceBuster(scope); let restoredQueries = new Map(); - const persister = createSyncStoragePersister({ - storage, - key, - retry: removeOldestQuery, - serialize: (client) => stringify(sanitizePersistedClient(client)), - deserialize: (value) => parse(value), - }); + let pendingClient: PersistedClient | undefined; + let persistTimer: ReturnType | undefined; + + const persistNow = (client: PersistedClient) => { + if (!storage) return; + let candidate: PersistedClient | undefined = sanitizePersistedClient(client); + let errorCount = 0; + + while (candidate) { + try { + storage.setItem(key, stringify(candidate)); + return; + } catch (error) { + errorCount += 1; + candidate = removeOldestQuery({ + persistedClient: candidate, + error: error instanceof Error ? error : new Error("Failed to persist the widget query cache"), + errorCount, + }); + } + } + }; + + const clearPersistTimer = () => { + if (persistTimer === undefined) return; + clearTimeout(persistTimer); + persistTimer = undefined; + }; + + const flush = () => { + clearPersistTimer(); + const client = pendingClient; + pendingClient = undefined; + if (client) persistNow(client); + }; return { - ...persister, + persistClient(client) { + if (!storage) return; + pendingClient = client; + if (persistTimer !== undefined) return; + persistTimer = setTimeout(flush, 0); + }, async restoreClient() { restoredQueries = new Map(); try { - const client = await persister.restoreClient(); - if ( - client && - client.timestamp > 0 && - Date.now() - client.timestamp <= queryPersistenceMaxAgeMs && - client.buster === buster - ) { - restoredQueries = new Map( - client.clientState.queries.map((query) => [query.queryHash, query.state.dataUpdatedAt]), - ); + const storedValue = storage?.getItem(key); + if (!storedValue) return undefined; + + const client = sanitizeRestoredClient(parse(storedValue)); + const age = client ? Date.now() - client.timestamp : Number.POSITIVE_INFINITY; + if (!client || age < 0 || age > queryPersistenceMaxAgeMs || client.buster !== buster) { + removeStorageItem(storage, key); + return undefined; } + + if (client.clientState.queries.length === 0) { + removeStorageItem(storage, key); + return undefined; + } + + restoredQueries = new Map( + client.clientState.queries.map((query) => [query.queryHash, query.state.dataUpdatedAt]), + ); return client; } catch { removeStorageItem(storage, key); @@ -129,9 +203,12 @@ export const createSessionQueryPersister = ( } }, removeClient() { + clearPersistTimer(); + pendingClient = undefined; restoredQueries = new Map(); removeStorageItem(storage, key); }, + flush, takeRestoredDashboardQueryPredicate() { if (restoredQueries.size === 0) return undefined; const restoredSnapshot = restoredQueries; @@ -149,7 +226,10 @@ export const scheduleRestoredDashboardQueryRefresh = (queryClient: QueryClient, if (!predicate) return; setTimeout(() => { - void queryClient.invalidateQueries({ type: "active", predicate }); + // Mark restored data stale even if its widget observer has not mounted yet. + // TanStack still refetches only active queries; a widget that mounts after + // this callback observes stale cached data and refreshes immediately. + void queryClient.invalidateQueries({ type: "all", refetchType: "active", predicate }); }, 0); }; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index a11ecd52d..3e7656800 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -1,7 +1,7 @@ "use client"; import type { PropsWithChildren } from "react"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import dynamic from "next/dynamic"; import { QueryClient } from "@tanstack/react-query"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; @@ -40,10 +40,10 @@ import { createSessionQueryPersister, getQueryPersistenceBuster, queryPersistenceMaxAgeMs, - removeSessionQueryCache, scheduleRestoredDashboardQueryRefresh, shouldPersistDashboardQuery, } from "./query-persistence"; +import type { SessionQueryPersister } from "./query-persistence"; import { createQueryRetry } from "./query-retry"; const DevelopmentTools = @@ -76,17 +76,21 @@ export function TRPCReactProvider({ children }: PropsWithChildren) { const { data: session } = useSession(); const sessionQueryScope = getSessionQueryScope(session); const [initialSessionQueryScope] = useState(() => sessionQueryScope); + const [queryPersister] = useState(() => createSessionQueryPersister(initialSessionQueryScope)); + const handleScopeChange = useCallback(() => { + void queryPersister.removeClient(); + reloadPage(); + }, [queryPersister]); return ( { - removeSessionQueryCache(initialSessionQueryScope); - reloadPage(); - }} + onScopeChange={handleScopeChange} > - {children} + + {children} + ); } @@ -96,7 +100,8 @@ const reloadPage = () => window.location.reload(); const ScopedTRPCReactProvider = ({ children, sessionQueryScope, -}: PropsWithChildren<{ sessionQueryScope: string | null }>) => { + queryPersister, +}: PropsWithChildren<{ sessionQueryScope: string | null; queryPersister: SessionQueryPersister }>) => { const wsClient = useMemo( () => createWSClient({ @@ -152,7 +157,14 @@ const ScopedTRPCReactProvider = ({ }); useEffect(() => () => queryClient.clear(), [queryClient]); - const [queryPersister] = useState(() => createSessionQueryPersister(sessionQueryScope)); + useEffect(() => { + const flush = () => queryPersister.flush(); + window.addEventListener("pagehide", flush); + return () => { + window.removeEventListener("pagehide", flush); + flush(); + }; + }, [queryPersister]); const trpcClient = useMemo(() => { return clientApi.createClient({ diff --git a/e2e/lazy-widgets.spec.ts b/e2e/lazy-widgets.spec.ts index ca2768375..14efef71c 100644 --- a/e2e/lazy-widgets.spec.ts +++ b/e2e/lazy-widgets.spec.ts @@ -1,5 +1,6 @@ import { createId } from "@paralleldrive/cuid2"; import { chromium, expect } from "@playwright/test"; +import type { Route } from "@playwright/test"; import { eq } from "drizzle-orm"; import { stringify } from "superjson"; import { describe, test } from "vitest"; @@ -21,7 +22,9 @@ describe("lazy widget application graph", () => { const boardId = createId(); const layoutId = createId(); const sectionId = createId(); - const itemId = createId(); + const clockItemId = createId(); + const downloadsItemId = createId(); + const mockIntegrationId = createId(); await db.insert(sqliteSchema.boards).values({ id: boardId, @@ -41,26 +44,56 @@ describe("lazy widget application graph", () => { xOffset: 0, yOffset: 0, }); - await db.insert(sqliteSchema.items).values({ - id: itemId, - boardId, - kind: "clock", - options: stringify({ showSeconds: true }), + await db.insert(sqliteSchema.integrations).values({ + id: mockIntegrationId, + name: "Mock downloads", + url: "http://mock.local", + kind: "mock", }); - await db.insert(sqliteSchema.itemLayouts).values({ - itemId, - sectionId, - layoutId, - xOffset: 0, - yOffset: 0, - width: 4, - height: 2, + await db.insert(sqliteSchema.items).values([ + { + id: clockItemId, + boardId, + kind: "clock", + options: stringify({ showSeconds: true }), + }, + { + id: downloadsItemId, + boardId, + kind: "downloads", + options: stringify({}), + }, + ]); + await db.insert(sqliteSchema.itemLayouts).values([ + { + itemId: clockItemId, + sectionId, + layoutId, + xOffset: 0, + yOffset: 0, + width: 4, + height: 2, + }, + { + itemId: downloadsItemId, + sectionId, + layoutId, + xOffset: 0, + yOffset: 2, + width: 8, + height: 5, + }, + ]); + await db.insert(sqliteSchema.integrationItems).values({ + itemId: downloadsItemId, + integrationId: mockIntegrationId, }); await db.update(sqliteSchema.users).set({ homeBoardId: boardId }).where(eq(sqliteSchema.users.id, userId)); const homarrContainer = await createHomarrContainer({ environment: { AUTH_PROVIDERS: "credentials", + UNSAFE_ENABLE_MOCK_INTEGRATION: "true", }, mounts: { "/appdata": localMountPath, @@ -69,29 +102,43 @@ describe("lazy widget application graph", () => { const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; const browser = await chromium.launch(); - const context = await browser.newContext(); + // Playwright cannot route requests handled by a service worker. Keep it + // blocked in this context so the delayed refresh below is observable, then + // verify registration separately in an unblocked context. + const context = await browser.newContext({ serviceWorkers: "block" }); const page = await context.newPage(); const pageErrors: Error[] = []; page.on("pageerror", (error) => pageErrors.push(error)); + let releaseRefresh: (() => void) | undefined; try { await page.goto(`${baseUrl}/auth/login`); await page.getByLabel("Username").fill(adminCredentials.username); await page.locator("#password").fill(adminCredentials.password); + const initialDownloadsResponse = page.waitForResponse( + (response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(), + ); const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname === "/", { waitUntil: "commit", timeout: 60_000, }); await page.locator("css=button[type='submit']").click(); await signedIn; + await initialDownloadsResponse; const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first(); await expect(visibleBoard).toBeVisible({ timeout: 30_000 }); const clockWidget = page.locator( - `[data-id="${itemId}"] .clock-widget-container, [data-grid-item-id="${itemId}"] .clock-widget-container`, + `[data-id="${clockItemId}"] .clock-widget-container, [data-grid-item-id="${clockItemId}"] .clock-widget-container`, ); await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 }); await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--"); + const downloadsWidget = page + .locator(`[data-id="${downloadsItemId}"], [data-grid-item-id="${downloadsItemId}"]`) + .filter({ visible: true }) + .first(); + const cachedDownload = downloadsWidget.getByText("Big.Buck.Bunny.2008.1080p.BluRay.x264"); + await expect(cachedDownload).toBeVisible({ timeout: 30_000 }); await expect(page.locator("[data-homarr-dev-benchmark-spotlight-preloaded]")).toHaveCount(1, { timeout: 15_000, @@ -102,24 +149,56 @@ describe("lazy widget application graph", () => { await page.keyboard.press("Escape"); await expect(page.locator("[data-homarr-dev-benchmark-spotlight]")).not.toBeVisible(); - await expect - .poll( - async () => - await page.evaluate(async () => { - const registrations = await navigator.serviceWorker.getRegistrations(); - return registrations[0]?.scope ?? null; - }), - { timeout: 15_000 }, - ) - .toBe(`${baseUrl}/`); + let refreshRequestStarted = false; + const refreshGate = new Promise((resolve) => { + releaseRefresh = resolve; + }); + const downloadsRequestPattern = "**/*widget.downloads.getJobsAndStatuses*"; + const delayDownloadsRefresh = async (route: Route) => { + refreshRequestStarted = true; + await refreshGate; + await route.continue(); + }; + await page.route(downloadsRequestPattern, delayDownloadsRefresh); - await page.reload(); + await page.reload({ waitUntil: "domcontentloaded" }); await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 }); await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--"); + await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true); + await expect(cachedDownload).toBeVisible({ timeout: 5_000 }); + await expect(downloadsWidget.locator('output[aria-live="polite"]')).toBeVisible(); + + const refreshedDownloadsResponse = page.waitForResponse( + (response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(), + ); + releaseRefresh(); + releaseRefresh = undefined; + await refreshedDownloadsResponse; + await expect(downloadsWidget.locator('output[aria-live="polite"]')).toHaveCount(0); + await page.unroute(downloadsRequestPattern, delayDownloadsRefresh); expect(pageErrors).toEqual([]); + + const serviceWorkerContext = await browser.newContext(); + const serviceWorkerPage = await serviceWorkerContext.newPage(); + try { + await serviceWorkerPage.goto(`${baseUrl}/auth/login`); + await expect + .poll( + async () => + await serviceWorkerPage.evaluate(async () => { + const registrations = await navigator.serviceWorker.getRegistrations(); + return registrations[0]?.scope ?? null; + }), + { timeout: 15_000 }, + ) + .toBe(`${baseUrl}/`); + } finally { + await serviceWorkerContext.close(); + } } finally { + releaseRefresh?.(); await browser.close(); await homarrContainer.stop(); } - }, 120_000); + }, 180_000); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c9509a868..a56278e42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,9 +189,6 @@ catalogs: '@tabler/icons-react': specifier: ^3.44.0 version: 3.44.0 - '@tanstack/query-sync-storage-persister': - specifier: 5.101.2 - version: 5.101.2 '@tanstack/react-devtools': specifier: ^0.10.8 version: 0.10.9 @@ -1136,9 +1133,6 @@ importers: '@tabler/icons-react': specifier: 'catalog:' version: 3.44.0(react@19.2.7) - '@tanstack/query-sync-storage-persister': - specifier: 'catalog:' - version: 5.101.2 '@tanstack/react-devtools': specifier: 'catalog:' version: 0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14) @@ -8846,9 +8840,6 @@ packages: '@tanstack/query-persist-client-core@5.101.2': resolution: {integrity: sha512-rgMsbvBVpSPDUsprC9FYxojdG0Q1LH6yq1i3DXz2aps4VEqv2l4Msj3YDqIifU3xkl/DrYe9YWntZAJLrM6xTg==} - '@tanstack/query-sync-storage-persister@5.101.2': - resolution: {integrity: sha512-hRyLEJvkUWtfCUA3Lf5vD1CrXbHHK4pZNM/iNd7JM5GgC4CWzGIHkVkeg8zC2LV11KceKObKgjNuMSg3b6NOjA==} - '@tanstack/react-devtools@0.10.9': resolution: {integrity: sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw==} engines: {node: '>=18'} @@ -23951,11 +23942,6 @@ snapshots: dependencies: '@tanstack/query-core': 5.101.2 - '@tanstack/query-sync-storage-persister@5.101.2': - dependencies: - '@tanstack/query-core': 5.101.2 - '@tanstack/query-persist-client-core': 5.101.2 - '@tanstack/react-devtools@0.10.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(csstype@3.2.3)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(solid-js@1.9.14)': dependencies: '@tanstack/devtools': 0.13.0(csstype@3.2.3)(solid-js@1.9.14) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2ef3b29bd..4f6c9b329 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -64,7 +64,6 @@ catalog: "@tanstack/react-query-devtools": ^5.101.2 "@tanstack/react-query-next-experimental": ^5.101.2 "@tanstack/react-query-persist-client": 5.101.2 - "@tanstack/query-sync-storage-persister": 5.101.2 "@testcontainers/mysql": ^12.0.4 "@testcontainers/postgresql": ^12.0.4 "@testcontainers/redis": ^12.0.4 From 65a96b6b1406782cea9ad5e29b039354a97c5939 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 22 Aug 2026 17:29:07 +0200 Subject: [PATCH 3/9] fix: address widget reload review findings --- .../app/[locale]/boards/(content)/_client.tsx | 16 ++- .../[locale]/boards/(content)/_creator.tsx | 5 +- .../assistant/assistant-widget-tool.tsx | 26 ++++- .../assistant/widget-import-boundary.spec.ts | 100 ++++++++++++++++-- e2e/lazy-widgets.spec.ts | 7 ++ packages/translation/src/lang/en.json | 2 + packages/widgets/src/clock/component.tsx | 6 +- .../src/common/widget-time-context.spec.tsx | 25 +++-- .../src/common/widget-time-context.tsx | 46 ++++++-- packages/widgets/src/time.ts | 2 +- 10 files changed, 206 insertions(+), 29 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 9e86f427b..ce9737984 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -40,7 +40,13 @@ const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => ( ); -export const ClientBoard = ({ initialTimestamp }: { initialTimestamp: number }) => { +export const ClientBoard = ({ + initialTimestamp, + initialTimeZone, +}: { + initialTimestamp: number; + initialTimeZone: string; +}) => { const board = useRequiredBoard(); const t = useI18n("board.landmark"); const tPreview = useI18n("board.setting.section.layout.preview"); @@ -157,11 +163,15 @@ export const ClientBoard = ({ initialTimestamp }: { initialTimestamp: number }) ); if (representativeWidth === null) { - return {content}; + return ( + + {content} + + ); } return ( - + {tPreview("editorWidthLabel", { layoutName: currentLayout.name, width: representativeWidth })} diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 689afb5bb..ccd61caca 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -96,7 +96,10 @@ export const createBoardContentPage = >( return ( - + ); diff --git a/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx b/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx index 444e31d0e..faa44941b 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx +++ b/apps/nextjs/src/components/assistant/assistant-widget-tool.tsx @@ -4,6 +4,8 @@ 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"; @@ -73,9 +75,27 @@ export const AssistantConfigureWidgetTool = (props: AssistantConfigureWidgetTool if (!completeArgs) return ; return ( - }> - - + + }> + + + + ); +}; + +const AssistantWidgetDefinitionErrorFallback = ({ resetErrorBoundary }: FallbackProps) => { + const t = useI18n("assistant.configureWidget"); + const actionT = useI18n("common.action"); + + return ( + }> + + {t("definitionLoadErrorDescription")} + + + ); }; diff --git a/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts b/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts index 78cc82c04..7b38dd798 100644 --- a/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts +++ b/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts @@ -1,6 +1,7 @@ import { readdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { parse } from "@babel/parser"; import { describe, expect, test } from "vitest"; const sourceRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); @@ -13,18 +14,105 @@ const collectRuntimeSourceFiles = (directory: string): string[] => return [path]; }); +const rootWidgetsModule = "@homarr/widgets"; + +interface AstNode { + type: string; + [key: string]: unknown; +} + +const isAstNode = (value: unknown): value is AstNode => + typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"; + +const getAstNode = (value: unknown) => (isAstNode(value) ? value : undefined); +const getAstNodes = (value: unknown) => (Array.isArray(value) ? value.filter(isAstNode) : []); + +const isRootWidgetsModule = (node: unknown) => { + const astNode = getAstNode(node); + return astNode?.type === "StringLiteral" && astNode.value === rootWidgetsModule; +}; + +const hasRuntimeRootWidgetImport = (source: string, fileName: string): boolean => { + const sourceFile = parse(source, { + sourceFilename: fileName, + sourceType: "unambiguous", + plugins: ["typescript", "jsx", "decorators-legacy", "importAttributes"], + }); + let found = false; + + const visit = (node: AstNode) => { + if (found) return; + + if (node.type === "ImportDeclaration" && isRootWidgetsModule(node.source)) { + const specifiers = getAstNodes(node.specifiers); + if ( + node.importKind !== "type" && + (specifiers.length === 0 || + specifiers.some((specifier) => specifier.type !== "ImportSpecifier" || specifier.importKind !== "type")) + ) { + found = true; + } + } else if ( + (node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration") && + isRootWidgetsModule(node.source) + ) { + const specifiers = getAstNodes(node.specifiers); + if ( + node.exportKind !== "type" && + (node.type === "ExportAllDeclaration" || + specifiers.length === 0 || + specifiers.some((specifier) => specifier.exportKind !== "type")) + ) { + found = true; + } + } else if (node.type === "TSImportEqualsDeclaration" && node.importKind !== "type") { + const moduleReference = getAstNode(node.moduleReference); + if (moduleReference?.type === "TSExternalModuleReference" && isRootWidgetsModule(moduleReference.expression)) { + found = true; + } + } else if (node.type === "CallExpression") { + const callee = getAstNode(node.callee); + const argument = getAstNodes(node.arguments)[0]; + if ( + isRootWidgetsModule(argument) && + (callee?.type === "Import" || (callee?.type === "Identifier" && callee.name === "require")) + ) { + found = true; + } + } + + for (const value of Object.values(node)) { + if (isAstNode(value)) visit(value); + else if (Array.isArray(value)) value.filter(isAstNode).forEach(visit); + } + }; + + visit(sourceFile as unknown as AstNode); + return found; +}; + 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) - ); + return hasRuntimeRootWidgetImport(source, path); }); expect(offenders).toEqual([]); }); + + test.each([ + ['import type { WidgetDefinition } from "@homarr/widgets";', false], + ['import { type WidgetDefinition } from "@homarr/widgets";', false], + ['export type { WidgetDefinition } from "@homarr/widgets";', false], + ["const example = 'import { widgets } from \"@homarr/widgets\";';", false], + ['// import { widgets } from "@homarr/widgets";', false], + ['import { widgets } from "@homarr/widgets";', true], + ['import { type WidgetDefinition, widgets } from "@homarr/widgets";', true], + ['export * from "@homarr/widgets";', true], + ['import /* chunk */ ( /* source */ "@homarr/widgets" );', true], + ['require("@homarr/widgets");', true], + ])("detects runtime root imports in %s", (source, expected) => { + expect(hasRuntimeRootWidgetImport(source, "fixture.ts")).toBe(expected); + }); }); diff --git a/e2e/lazy-widgets.spec.ts b/e2e/lazy-widgets.spec.ts index 14efef71c..26cedeefb 100644 --- a/e2e/lazy-widgets.spec.ts +++ b/e2e/lazy-widgets.spec.ts @@ -108,7 +108,13 @@ describe("lazy widget application graph", () => { 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)); + page.on("console", (message) => { + if (message.type() === "error" && /hydration|did not match/i.test(message.text())) { + hydrationErrors.push(message.text()); + } + }); let releaseRefresh: (() => void) | undefined; try { @@ -177,6 +183,7 @@ describe("lazy widget application graph", () => { await expect(downloadsWidget.locator('output[aria-live="polite"]')).toHaveCount(0); await page.unroute(downloadsRequestPattern, delayDownloadsRefresh); expect(pageErrors).toEqual([]); + expect(hydrationErrors).toEqual([]); const serviceWorkerContext = await browser.newContext(); const serviceWorkerPage = await serviceWorkerContext.newPage(); 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/clock/component.tsx b/packages/widgets/src/clock/component.tsx index 45180cff9..385f18262 100644 --- a/packages/widgets/src/clock/component.tsx +++ b/packages/widgets/src/clock/component.tsx @@ -4,6 +4,7 @@ import { Box, Center, Stack, Text, Title } from "@mantine/core"; import dayjs from "dayjs"; import advancedFormat from "dayjs/plugin/advancedFormat"; +import { useWidgetLocalTimeZone } from "../common/widget-time-context"; import type { WidgetComponentProps } from "../definition"; import { AdvancedClockView } from "./advanced-view"; import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format"; @@ -17,7 +18,10 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid const isAdvanced = displayMode === "advanced"; const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat); const time = useCurrentTime({ showSeconds }); - const requestedTimeZone = options.useCustomTimezone ? options.timezone : getResolvedLocalTimeZone(); + const initialLocalTimeZone = useWidgetLocalTimeZone(); + const requestedTimeZone = options.useCustomTimezone + ? options.timezone + : (initialLocalTimeZone ?? getResolvedLocalTimeZone()); const primaryTimeZoneInvalid = !isTimeZoneSupported(requestedTimeZone); const primaryTimeZone = primaryTimeZoneInvalid ? "UTC" : requestedTimeZone; const zonedTime = time === null ? null : dayjs(time).tz(primaryTimeZone); diff --git a/packages/widgets/src/common/widget-time-context.spec.tsx b/packages/widgets/src/common/widget-time-context.spec.tsx index 39fee29ee..4912dc706 100644 --- a/packages/widgets/src/common/widget-time-context.spec.tsx +++ b/packages/widgets/src/common/widget-time-context.spec.tsx @@ -6,9 +6,13 @@ 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"; +import { useWidgetLocalTimeZone, WidgetTimeProvider } from "./widget-time-context"; -const TimeProbe = () => ; +const TimeProbe = () => ( + +); afterEach(() => { vi.useRealTimers(); @@ -16,12 +20,13 @@ afterEach(() => { }); describe("WidgetTimeProvider", () => { - test("uses the same non-placeholder timestamp for server rendering and hydration", async () => { + test("hydrates with server time and timezone before switching to browser values", async () => { vi.useFakeTimers(); - const timestamp = new Date("2026-08-22T12:34:00.000Z").getTime(); - vi.setSystemTime(timestamp); + const serverTimestamp = new Date("2026-08-22T12:34:00.000Z").getTime(); + const clientTimestamp = serverTimestamp + 60_000; + vi.setSystemTime(serverTimestamp); const content = ( - + ); @@ -29,11 +34,15 @@ describe("WidgetTimeProvider", () => { const host = document.createElement("div"); host.innerHTML = html; const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ + timeZone: "Europe/Paris", + } as Intl.ResolvedDateTimeFormatOptions); + vi.setSystemTime(clientTimestamp); - expect(host.textContent).toBe("2026-08-22T12:34:00.000Z"); + expect(host.textContent).toBe("2026-08-22T12:34:00.000Z|UTC"); const root = hydrateRoot(host, content); await act(async () => undefined); - expect(host.textContent).toBe("2026-08-22T12:34:00.000Z"); + expect(host.textContent).toBe("2026-08-22T12:35:00.000Z|Europe/Paris"); 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 index 2e09ae467..f46f58d7d 100644 --- a/packages/widgets/src/common/widget-time-context.tsx +++ b/packages/widgets/src/common/widget-time-context.tsx @@ -1,12 +1,46 @@ "use client"; import type { PropsWithChildren } from "react"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useEffect, useMemo, useState } from "react"; -const WidgetTimeContext = createContext(null); +interface WidgetTimeContextValue { + initialTimestamp: number | null; + localTimeZone: string | null; +} -export const WidgetTimeProvider = ({ children, initialTimestamp }: PropsWithChildren<{ initialTimestamp: number }>) => ( - {children} -); +const WidgetTimeContext = createContext({ initialTimestamp: null, localTimeZone: null }); -export const useWidgetInitialTimestamp = () => useContext(WidgetTimeContext); +interface WidgetTimeProviderProps { + initialTimestamp: number; + initialTimeZone: string; +} + +export const WidgetTimeProvider = ({ + children, + initialTimestamp, + initialTimeZone, +}: PropsWithChildren) => { + const [localTimeZone, setLocalTimeZone] = useState(() => getSupportedTimeZone(initialTimeZone)); + + useEffect(() => { + setLocalTimeZone(getResolvedLocalTimeZone()); + }, []); + + const value = useMemo(() => ({ initialTimestamp, localTimeZone }), [initialTimestamp, localTimeZone]); + return {children}; +}; + +export const useWidgetInitialTimestamp = () => useContext(WidgetTimeContext).initialTimestamp; +export const useWidgetLocalTimeZone = () => useContext(WidgetTimeContext).localTimeZone; + +const getResolvedLocalTimeZone = () => getSupportedTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone); + +const getSupportedTimeZone = (timeZone: string | undefined) => { + if (!timeZone) return "UTC"; + try { + new Intl.DateTimeFormat("en", { timeZone }).format(0); + return timeZone; + } catch { + return "UTC"; + } +}; diff --git a/packages/widgets/src/time.ts b/packages/widgets/src/time.ts index a294c53bb..07babe34b 100644 --- a/packages/widgets/src/time.ts +++ b/packages/widgets/src/time.ts @@ -1,3 +1,3 @@ -export { WidgetTimeProvider, useWidgetInitialTimestamp } from "./common/widget-time-context"; +export { WidgetTimeProvider, useWidgetInitialTimestamp, useWidgetLocalTimeZone } from "./common/widget-time-context"; export { getNextWidgetTickDelay, useWidgetNow } from "./common/use-widget-now"; export type { WidgetClockPrecision } from "./common/use-widget-now"; From 89e4cb843dd1bca92cb2993d301504ded64469b2 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 22 Aug 2026 17:59:06 +0200 Subject: [PATCH 4/9] refactor: simplify widget reload persistence --- .../query-persistence.spec.ts | 87 ++++-------------- .../_client-providers/query-persistence.ts | 73 ++------------- .../app/[locale]/_client-providers/trpc.tsx | 15 +-- .../assistant/widget-import-boundary.spec.ts | 91 +++++-------------- e2e/lazy-widgets.spec.ts | 3 - packages/widgets/src/clock/component.tsx | 8 +- .../src/common/widget-time-context.spec.tsx | 11 +-- .../src/common/widget-time-context.tsx | 32 +++---- 8 files changed, 79 insertions(+), 241 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts index d51d074a9..bbce19bc2 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts @@ -8,11 +8,9 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { createSessionQueryPersister, - getQueryPersistenceBuster, getQueryPersistenceStorageKey, + queryPersistenceBuster, queryPersistenceMaxDataBytes, - removeSessionQueryCache, - scheduleRestoredDashboardQueryRefresh, shouldPersistDashboardQuery, } from "./query-persistence"; @@ -71,13 +69,12 @@ describe("widget query persistence", () => { 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"); + createSessionQueryPersister("user-a").removeClient(); expect(window.sessionStorage.getItem(userAKey)).toBeNull(); expect(window.sessionStorage.getItem(userBKey)).toBe("b"); }); @@ -88,12 +85,12 @@ describe("widget query persistence", () => { const userAPersister = createSessionQueryPersister("user-a"); userAPersister.persistClient({ timestamp: Date.now(), - buster: getQueryPersistenceBuster("user-a"), + buster: queryPersistenceBuster, clientState: dehydrate(queryClient), }); userAPersister.flush(); - await expect(createSessionQueryPersister("user-b").restoreClient()).resolves.toBeUndefined(); + expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined(); expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1); }); @@ -105,7 +102,7 @@ describe("widget query persistence", () => { persister.persistClient({ timestamp: Date.now(), - buster: getQueryPersistenceBuster(scope), + buster: queryPersistenceBuster, clientState: { mutations: [], queries: [...dehydrate(widget.queryClient).queries, ...dehydrate(unrelated.queryClient).queries], @@ -130,7 +127,7 @@ describe("widget query persistence", () => { expect(shouldPersistDashboardQuery(query)).toBe(true); const persistedClient: PersistedClient = { timestamp: Date.now(), - buster: getQueryPersistenceBuster(scope), + buster: queryPersistenceBuster, clientState: dehydrate(queryClient, { shouldDehydrateQuery: shouldPersistDashboardQuery }), }; @@ -141,13 +138,11 @@ describe("widget query persistence", () => { 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"); - 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(persister.restoreClient()).toBeUndefined(); expect(window.sessionStorage.getItem(key)).toBeNull(); }); @@ -165,7 +160,7 @@ describe("widget query persistence", () => { key, stringify({ timestamp: Date.now(), - buster: getQueryPersistenceBuster(scope), + buster: queryPersistenceBuster, clientState: { mutations: [{ state: { status: "pending" } }], queries: [ @@ -198,9 +193,9 @@ describe("widget query persistence", () => { }); test.each([ - ["expired", 1, getQueryPersistenceBuster("user-a")], - ["different buster", new Date("2026-08-22T12:00:00.000Z").getTime(), getQueryPersistenceBuster("user-b")], - ["future timestamp", new Date("2026-08-22T12:00:00.000Z").getTime() + 1, getQueryPersistenceBuster("user-a")], + ["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")); @@ -217,7 +212,7 @@ describe("widget query persistence", () => { queryClient: restoredClient, persister, maxAge: 5 * 60 * 1_000, - buster: getQueryPersistenceBuster(scope), + buster: queryPersistenceBuster, }); expect(restoredClient.getQueryData([["widget", "weather", "getWeather"], { type: "query" }])).toBeUndefined(); @@ -236,7 +231,7 @@ describe("widget query persistence", () => { }); const persistedClient: PersistedClient = { timestamp: Date.now(), - buster: getQueryPersistenceBuster("user-a"), + buster: queryPersistenceBuster, clientState: { mutations: [], queries: [...dehydrate(old.queryClient).queries, ...dehydrate(recent.queryClient).queries], @@ -264,12 +259,12 @@ describe("widget query persistence", () => { persister.persistClient({ timestamp: Date.now(), - buster: getQueryPersistenceBuster("user-a"), + buster: queryPersistenceBuster, clientState: dehydrate(first.queryClient), }); persister.persistClient({ timestamp: Date.now(), - buster: getQueryPersistenceBuster("user-a"), + buster: queryPersistenceBuster, clientState: dehydrate(latest.queryClient), }); @@ -283,48 +278,6 @@ describe("widget query persistence", () => { expect(restored?.clientState.queries[0]?.state.data).toEqual({ temp: 21 }); }); - 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); - persister.persistClient({ - timestamp: Date.now(), - buster: getQueryPersistenceBuster(scope), - clientState: dehydrate(cached.queryClient), - }); - persister.flush(); - 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(); - expect(invalidateQueries).toHaveBeenCalledWith(expect.objectContaining({ type: "all", refetchType: "active" })); - - 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: () => { @@ -340,17 +293,16 @@ describe("widget query persistence", () => { const persister = createSessionQueryPersister("blocked", blockedStorage); const { queryClient } = createSuccessfulQuery([["widget", "weather"], { type: "query" }], { temp: 21 }); - await expect(persister.restoreClient()).resolves.toBeUndefined(); + expect(persister.restoreClient()).toBeUndefined(); expect(() => { persister.persistClient({ timestamp: Date.now(), - buster: getQueryPersistenceBuster("blocked"), + buster: queryPersistenceBuster, clientState: dehydrate(queryClient), }); persister.flush(); }).not.toThrow(); expect(() => persister.removeClient()).not.toThrow(); - expect(() => removeSessionQueryCache("blocked", blockedStorage)).not.toThrow(); }); test("treats an unavailable session storage getter as an empty cache", async () => { @@ -359,7 +311,6 @@ describe("widget query persistence", () => { }); expect(() => createSessionQueryPersister("blocked-getter")).not.toThrow(); - expect(() => removeSessionQueryCache("blocked-getter")).not.toThrow(); - await expect(createSessionQueryPersister("blocked-getter").restoreClient()).resolves.toBeUndefined(); + 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 index 6293a8eac..76e798cc9 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -1,4 +1,4 @@ -import type { QueryClient, QueryKey } from "@tanstack/react-query"; +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"; @@ -7,40 +7,22 @@ import { isWidgetDataQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/que export const queryPersistenceMaxAgeMs = queryCacheDefaultGcTimeMs; export const queryPersistenceMaxDataBytes = 256 * 1024; -const queryPersistenceBuster = "v1-session-superjson"; +export const queryPersistenceBuster = "v2-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); -}; +const queryPersistenceStoragePrefix = "homarr:widget-query-cache"; export const getQueryPersistenceStorageKey = (scope: string | null) => - `${queryPersistenceStoragePrefix}:${hashScope(scope ?? "anonymous")}`; - -export const getQueryPersistenceBuster = (scope: string | null) => `${queryPersistenceBuster}:${scope ?? "anonymous"}`; + `${queryPersistenceStoragePrefix}:${encodeURIComponent(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 { - flush: () => void; - takeRestoredDashboardQueryPredicate: () => RestoredDashboardQueryPredicate | undefined; -} +export type SessionQueryPersister = Persister & { flush: () => void }; export const shouldPersistDashboardQuery = (query: PersistableQuery) => { if (query.state.data === undefined || query.state.dataUpdatedAt <= 0 || !isWidgetDataQueryKey(query.queryKey)) { @@ -66,6 +48,7 @@ const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({ fetchFailureCount: 0, fetchFailureReason: null, fetchStatus: "idle", + isInvalidated: true, status: "success", }, }); @@ -74,9 +57,6 @@ const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({ ...client, clientState: { mutations: [], - // Keep this guard inside the persister as well as in the provider's - // dehydration options so a future caller cannot accidentally store an - // unrelated query payload. queries: client.clientState.queries .filter((query) => shouldPersistDashboardQuery(query as PersistableQuery)) .map(sanitizePersistedQuery), @@ -120,9 +100,7 @@ const getSessionStorage = (): Storage | 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. - } + } catch {} }; export const createSessionQueryPersister = ( @@ -130,8 +108,6 @@ export const createSessionQueryPersister = ( storage: Storage | undefined = getSessionStorage(), ): SessionQueryPersister => { const key = getQueryPersistenceStorageKey(scope); - const buster = getQueryPersistenceBuster(scope); - let restoredQueries = new Map(); let pendingClient: PersistedClient | undefined; let persistTimer: ReturnType | undefined; @@ -175,15 +151,13 @@ export const createSessionQueryPersister = ( if (persistTimer !== undefined) return; persistTimer = setTimeout(flush, 0); }, - async restoreClient() { - restoredQueries = new Map(); + restoreClient() { try { const storedValue = storage?.getItem(key); if (!storedValue) return undefined; const client = sanitizeRestoredClient(parse(storedValue)); - const age = client ? Date.now() - client.timestamp : Number.POSITIVE_INFINITY; - if (!client || age < 0 || age > queryPersistenceMaxAgeMs || client.buster !== buster) { + if (!client || client.timestamp > Date.now()) { removeStorageItem(storage, key); return undefined; } @@ -193,9 +167,6 @@ export const createSessionQueryPersister = ( return undefined; } - restoredQueries = new Map( - client.clientState.queries.map((query) => [query.queryHash, query.state.dataUpdatedAt]), - ); return client; } catch { removeStorageItem(storage, key); @@ -205,34 +176,8 @@ export const createSessionQueryPersister = ( removeClient() { clearPersistTimer(); pendingClient = undefined; - restoredQueries = new Map(); removeStorageItem(storage, key); }, flush, - 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(() => { - // Mark restored data stale even if its widget observer has not mounted yet. - // TanStack still refetches only active queries; a widget that mounts after - // this callback observes stale cached data and refreshes immediately. - void queryClient.invalidateQueries({ type: "all", refetchType: "active", predicate }); - }, 0); -}; - -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 3e7656800..51aefdd43 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -38,9 +38,8 @@ import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals"; import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope"; import { createSessionQueryPersister, - getQueryPersistenceBuster, + queryPersistenceBuster, queryPersistenceMaxAgeMs, - scheduleRestoredDashboardQueryRefresh, shouldPersistDashboardQuery, } from "./query-persistence"; import type { SessionQueryPersister } from "./query-persistence"; @@ -88,9 +87,7 @@ export function TRPCReactProvider({ children }: PropsWithChildren) { currentScope={sessionQueryScope} onScopeChange={handleScopeChange} > - - {children} - + {children} ); } @@ -99,9 +96,8 @@ const reloadPage = () => window.location.reload(); const ScopedTRPCReactProvider = ({ children, - sessionQueryScope, queryPersister, -}: PropsWithChildren<{ sessionQueryScope: string | null; queryPersister: SessionQueryPersister }>) => { +}: PropsWithChildren<{ queryPersister: SessionQueryPersister }>) => { const wsClient = useMemo( () => createWSClient({ @@ -219,12 +215,9 @@ const ScopedTRPCReactProvider = ({ persistOptions={{ persister: queryPersister, maxAge: queryPersistenceMaxAgeMs, - buster: getQueryPersistenceBuster(sessionQueryScope), + buster: queryPersistenceBuster, dehydrateOptions: { shouldDehydrateQuery: shouldPersistDashboardQuery }, }} - onSuccess={() => { - scheduleRestoredDashboardQueryRefresh(queryClient, queryPersister); - }} > {children} {process.env.NODE_ENV === "development" && } diff --git a/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts b/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts index 7b38dd798..e4aa2a4ad 100644 --- a/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts +++ b/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts @@ -16,79 +16,39 @@ const collectRuntimeSourceFiles = (directory: string): string[] => const rootWidgetsModule = "@homarr/widgets"; -interface AstNode { - type: string; - [key: string]: unknown; -} - -const isAstNode = (value: unknown): value is AstNode => - typeof value === "object" && value !== null && "type" in value && typeof value.type === "string"; - -const getAstNode = (value: unknown) => (isAstNode(value) ? value : undefined); -const getAstNodes = (value: unknown) => (Array.isArray(value) ? value.filter(isAstNode) : []); - -const isRootWidgetsModule = (node: unknown) => { - const astNode = getAstNode(node); - return astNode?.type === "StringLiteral" && astNode.value === rootWidgetsModule; -}; - const hasRuntimeRootWidgetImport = (source: string, fileName: string): boolean => { - const sourceFile = parse(source, { + const { program } = parse(source, { sourceFilename: fileName, sourceType: "unambiguous", plugins: ["typescript", "jsx", "decorators-legacy", "importAttributes"], }); - let found = false; + const hasStaticImport = program.body.some( + (node) => + node.type === "ImportDeclaration" && + node.source.value === rootWidgetsModule && + node.importKind !== "type" && + (node.specifiers.length === 0 || + node.specifiers.some((specifier) => specifier.type !== "ImportSpecifier" || specifier.importKind !== "type")), + ); + return hasStaticImport || hasDynamicRootWidgetImport(program); +}; - const visit = (node: AstNode) => { - if (found) return; +const hasDynamicRootWidgetImport = (value: unknown): boolean => { + if (Array.isArray(value)) return value.some(hasDynamicRootWidgetImport); + if (typeof value !== "object" || value === null) return false; - if (node.type === "ImportDeclaration" && isRootWidgetsModule(node.source)) { - const specifiers = getAstNodes(node.specifiers); - if ( - node.importKind !== "type" && - (specifiers.length === 0 || - specifiers.some((specifier) => specifier.type !== "ImportSpecifier" || specifier.importKind !== "type")) - ) { - found = true; - } - } else if ( - (node.type === "ExportNamedDeclaration" || node.type === "ExportAllDeclaration") && - isRootWidgetsModule(node.source) - ) { - const specifiers = getAstNodes(node.specifiers); - if ( - node.exportKind !== "type" && - (node.type === "ExportAllDeclaration" || - specifiers.length === 0 || - specifiers.some((specifier) => specifier.exportKind !== "type")) - ) { - found = true; - } - } else if (node.type === "TSImportEqualsDeclaration" && node.importKind !== "type") { - const moduleReference = getAstNode(node.moduleReference); - if (moduleReference?.type === "TSExternalModuleReference" && isRootWidgetsModule(moduleReference.expression)) { - found = true; - } - } else if (node.type === "CallExpression") { - const callee = getAstNode(node.callee); - const argument = getAstNodes(node.arguments)[0]; - if ( - isRootWidgetsModule(argument) && - (callee?.type === "Import" || (callee?.type === "Identifier" && callee.name === "require")) - ) { - found = true; - } + const node = value as Record; + if (node.type === "CallExpression") { + const callee = node.callee as Record | undefined; + const argument = Array.isArray(node.arguments) + ? (node.arguments[0] as Record | undefined) + : undefined; + if (callee?.type === "Import" && argument?.type === "StringLiteral" && argument.value === rootWidgetsModule) { + return true; } + } - for (const value of Object.values(node)) { - if (isAstNode(value)) visit(value); - else if (Array.isArray(value)) value.filter(isAstNode).forEach(visit); - } - }; - - visit(sourceFile as unknown as AstNode); - return found; + return Object.values(node).some(hasDynamicRootWidgetImport); }; describe("widget import boundary", () => { @@ -104,14 +64,11 @@ describe("widget import boundary", () => { test.each([ ['import type { WidgetDefinition } from "@homarr/widgets";', false], ['import { type WidgetDefinition } from "@homarr/widgets";', false], - ['export type { WidgetDefinition } from "@homarr/widgets";', false], ["const example = 'import { widgets } from \"@homarr/widgets\";';", false], ['// import { widgets } from "@homarr/widgets";', false], ['import { widgets } from "@homarr/widgets";', true], ['import { type WidgetDefinition, widgets } from "@homarr/widgets";', true], - ['export * from "@homarr/widgets";', true], ['import /* chunk */ ( /* source */ "@homarr/widgets" );', true], - ['require("@homarr/widgets");', true], ])("detects runtime root imports in %s", (source, expected) => { expect(hasRuntimeRootWidgetImport(source, "fixture.ts")).toBe(expected); }); diff --git a/e2e/lazy-widgets.spec.ts b/e2e/lazy-widgets.spec.ts index 26cedeefb..f3e6b1d03 100644 --- a/e2e/lazy-widgets.spec.ts +++ b/e2e/lazy-widgets.spec.ts @@ -102,9 +102,6 @@ describe("lazy widget application graph", () => { const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; const browser = await chromium.launch(); - // Playwright cannot route requests handled by a service worker. Keep it - // blocked in this context so the delayed refresh below is observable, then - // verify registration separately in an unblocked context. const context = await browser.newContext({ serviceWorkers: "block" }); const page = await context.newPage(); const pageErrors: Error[] = []; diff --git a/packages/widgets/src/clock/component.tsx b/packages/widgets/src/clock/component.tsx index 385f18262..ab0d5926b 100644 --- a/packages/widgets/src/clock/component.tsx +++ b/packages/widgets/src/clock/component.tsx @@ -10,7 +10,7 @@ import { AdvancedClockView } from "./advanced-view"; import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format"; import { useCurrentTime } from "./use-current-time"; import { ClockWeatherSummary } from "./weather-summary"; -import { getResolvedLocalTimeZone, isTimeZoneSupported } from "./world-clock"; +import { isTimeZoneSupported } from "./world-clock"; dayjs.extend(advancedFormat); @@ -18,10 +18,8 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid const isAdvanced = displayMode === "advanced"; const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat); const time = useCurrentTime({ showSeconds }); - const initialLocalTimeZone = useWidgetLocalTimeZone(); - const requestedTimeZone = options.useCustomTimezone - ? options.timezone - : (initialLocalTimeZone ?? getResolvedLocalTimeZone()); + const localTimeZone = useWidgetLocalTimeZone(); + const requestedTimeZone = options.useCustomTimezone ? options.timezone : localTimeZone; const primaryTimeZoneInvalid = !isTimeZoneSupported(requestedTimeZone); const primaryTimeZone = primaryTimeZoneInvalid ? "UTC" : requestedTimeZone; const zonedTime = time === null ? null : dayjs(time).tz(primaryTimeZone); diff --git a/packages/widgets/src/common/widget-time-context.spec.tsx b/packages/widgets/src/common/widget-time-context.spec.tsx index 4912dc706..286c8f95f 100644 --- a/packages/widgets/src/common/widget-time-context.spec.tsx +++ b/packages/widgets/src/common/widget-time-context.spec.tsx @@ -24,9 +24,11 @@ describe("WidgetTimeProvider", () => { vi.useFakeTimers(); const serverTimestamp = new Date("2026-08-22T12:34:00.000Z").getTime(); const clientTimestamp = serverTimestamp + 60_000; + const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const serverTimeZone = browserTimeZone === "UTC" ? "Europe/Paris" : "UTC"; vi.setSystemTime(serverTimestamp); const content = ( - + ); @@ -34,15 +36,12 @@ describe("WidgetTimeProvider", () => { const host = document.createElement("div"); host.innerHTML = html; const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ - timeZone: "Europe/Paris", - } as Intl.ResolvedDateTimeFormatOptions); vi.setSystemTime(clientTimestamp); - expect(host.textContent).toBe("2026-08-22T12:34:00.000Z|UTC"); + expect(host.textContent).toBe(`2026-08-22T12:34:00.000Z|${serverTimeZone}`); const root = hydrateRoot(host, content); await act(async () => undefined); - expect(host.textContent).toBe("2026-08-22T12:35:00.000Z|Europe/Paris"); + expect(host.textContent).toBe(`2026-08-22T12:35:00.000Z|${browserTimeZone}`); 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 index f46f58d7d..d344d5380 100644 --- a/packages/widgets/src/common/widget-time-context.tsx +++ b/packages/widgets/src/common/widget-time-context.tsx @@ -1,14 +1,10 @@ "use client"; import type { PropsWithChildren } from "react"; -import { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { createContext, useContext, useSyncExternalStore } from "react"; -interface WidgetTimeContextValue { - initialTimestamp: number | null; - localTimeZone: string | null; -} - -const WidgetTimeContext = createContext({ initialTimestamp: null, localTimeZone: null }); +const WidgetTimestampContext = createContext(null); +const WidgetTimeZoneContext = createContext("UTC"); interface WidgetTimeProviderProps { initialTimestamp: number; @@ -19,19 +15,21 @@ export const WidgetTimeProvider = ({ children, initialTimestamp, initialTimeZone, -}: PropsWithChildren) => { - const [localTimeZone, setLocalTimeZone] = useState(() => getSupportedTimeZone(initialTimeZone)); +}: PropsWithChildren) => ( + + + {children} + + +); - useEffect(() => { - setLocalTimeZone(getResolvedLocalTimeZone()); - }, []); - - const value = useMemo(() => ({ initialTimestamp, localTimeZone }), [initialTimestamp, localTimeZone]); - return {children}; +export const useWidgetInitialTimestamp = () => useContext(WidgetTimestampContext); +export const useWidgetLocalTimeZone = () => { + const initialTimeZone = useContext(WidgetTimeZoneContext); + return useSyncExternalStore(subscribeToTimeZone, getResolvedLocalTimeZone, () => initialTimeZone); }; -export const useWidgetInitialTimestamp = () => useContext(WidgetTimeContext).initialTimestamp; -export const useWidgetLocalTimeZone = () => useContext(WidgetTimeContext).localTimeZone; +const subscribeToTimeZone = () => () => undefined; const getResolvedLocalTimeZone = () => getSupportedTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone); From a22fb6064756e0fdfa7fd72c0aa57c97ae2a8ed2 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 22 Aug 2026 18:27:19 +0200 Subject: [PATCH 5/9] fix: harden widget query persistence and refresh indicator Review follow-ups on the session query cache: - Cache the per-payload size verdict and reuse a single TextEncoder. The persist subscription re-dehydrates the whole cache on every cache event, so every widget payload was being superjson-serialized several times per refresh cycle just to measure it. - Skip the size measurement on the restore path. It runs before any query is allowed to fetch and the payload was already budgeted when it was written. - Reject stored queries whose dataUpdatedAt is not a positive finite number; `undefined <= 0` is false, so those were hydrating with a NaN timestamp. - Add `stop()` and tear the persister down before `queryClient.clear()`, so the clear cannot overwrite the saved snapshot with an empty cache. - Remove every other scope's cache when a persister is created: signing out navigates away before the scope guard can react, which left the previous account's dashboard data in the tab. - Remove the entry instead of storing an empty cache. Refresh indicator: - Make it decorative instead of an aria-live region; every widget refreshes on a timer, so it made assistive technology repeat "loading" forever. Also broadens the e2e hydration-error matcher to production React's minified error codes, hoists the single WidgetTimeProvider in ClientBoard, memoizes the resolved local time zone, and drops the exports this branch made dead. --- .../query-persistence.spec.ts | 68 +++++++++++++- .../_client-providers/query-persistence.ts | 90 +++++++++++++++---- .../app/[locale]/_client-providers/trpc.tsx | 6 +- .../app/[locale]/boards/(content)/_client.tsx | 42 ++++----- .../[locale]/boards/(content)/_creator.tsx | 7 +- .../components/board/items/item-content.tsx | 10 ++- e2e/lazy-widgets.spec.ts | 13 ++- packages/widgets/src/clock/world-clock.ts | 5 -- .../src/common/widget-time-context.spec.tsx | 2 +- .../src/common/widget-time-context.tsx | 33 +++++-- packages/widgets/src/index.tsx | 19 ---- packages/widgets/src/time.ts | 4 +- 12 files changed, 208 insertions(+), 91 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts index bbce19bc2..954797a22 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts @@ -71,12 +71,23 @@ describe("widget query persistence", () => { expect(getQueryPersistenceStorageKey(null)).not.toBe(getQueryPersistenceStorageKey("user-a")); const userAKey = getQueryPersistenceStorageKey("user-a"); - const userBKey = getQueryPersistenceStorageKey("user-b"); window.sessionStorage.setItem(userAKey, "a"); - window.sessionStorage.setItem(userBKey, "b"); createSessionQueryPersister("user-a").removeClient(); expect(window.sessionStorage.getItem(userAKey)).toBeNull(); - expect(window.sessionStorage.getItem(userBKey)).toBe("b"); + }); + + 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 () => { @@ -89,9 +100,53 @@ describe("widget query persistence", () => { clientState: dehydrate(queryClient), }); userAPersister.flush(); + expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1); expect(createSessionQueryPersister("user-b").restoreClient()).toBeUndefined(); - expect((await userAPersister.restoreClient())?.clientState.queries).toHaveLength(1); + }); + + 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(); + + // A torn down provider clears its query client, which would otherwise be + // persisted as an empty cache on top of the snapshot we just saved. + 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 { queryClient } = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" }); + const persister = createSessionQueryPersister(scope); + window.sessionStorage.setItem(key, "stale"); + + persister.persistClient({ + timestamp: Date.now(), + buster: queryPersistenceBuster, + clientState: dehydrate(queryClient), + }); + persister.flush(); + + expect(window.sessionStorage.getItem(key)).toBeNull(); }); test("persister itself rejects unrelated query payloads", async () => { @@ -179,6 +234,11 @@ describe("widget query persistence", () => { queryKey: [["board", "getBoardByName"], { type: "query" }], }, { queryHash: "malformed", queryKey: [["widget", "weather"]], state: null }, + { + ...validQuery, + queryHash: "missing-timestamp", + state: { ...validQuery.state, dataUpdatedAt: undefined }, + }, ], }, }), diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts index 76e798cc9..f899a881d 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -22,20 +22,45 @@ interface PersistableQuery { }; } -export type SessionQueryPersister = Persister & { flush: () => void }; +export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void }; -export const shouldPersistDashboardQuery = (query: PersistableQuery) => { - if (query.state.data === undefined || query.state.dataUpdatedAt <= 0 || !isWidgetDataQueryKey(query.queryKey)) { - return false; - } +const textEncoder = new TextEncoder(); +// React Query re-dehydrates the whole cache on every cache event, so this +// predicate runs for every query several times per refresh cycle. Measuring a +// payload means serializing it, which dwarfs everything else in the pass, so +// remember the verdict per data reference instead of re-encoding every widget +// payload every time. +const budgetVerdictCache = new WeakMap(); +const measureFitsBudget = (data: unknown) => { try { - return new TextEncoder().encode(stringify(query.state.data)).byteLength <= queryPersistenceMaxDataBytes; + 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); + +/** Write path: dashboard data that is small enough to keep in session storage. */ +export const shouldPersistDashboardQuery = (query: PersistableQuery) => + holdsDashboardData(query) && fitsStorageBudget(query.state.data); + type DehydratedQuery = PersistedClient["clientState"]["queries"][number]; const sanitizePersistedQuery = (query: DehydratedQuery): DehydratedQuery => ({ @@ -66,11 +91,14 @@ const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({ const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +// Read path: the payload was already budgeted when it was written, and this runs +// before any query is allowed to fetch, so it deliberately skips the expensive +// size measurement and only rejects anything that is not restorable widget data. const isRestorableQuery = (value: unknown): value is DehydratedQuery => { if (!isRecord(value) || typeof value.queryHash !== "string" || !Array.isArray(value.queryKey)) return false; if (!isRecord(value.state) || typeof value.state.status !== "string") return false; - return shouldPersistDashboardQuery(value as unknown as PersistableQuery); + return holdsDashboardData(value as unknown as PersistableQuery); }; const sanitizeRestoredClient = (value: unknown): PersistedClient | undefined => { @@ -100,7 +128,29 @@ const getSessionStorage = (): Storage | undefined => { const removeStorageItem = (storage: Storage | undefined, key: string) => { try { storage?.removeItem(key); - } catch {} + } catch { + return undefined; + } +}; + +// Signing out navigates away before the session scope guard can observe the +// change, so a previous account's cached dashboard data would otherwise linger +// in the tab. Only the active scope may keep a cache. +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 = ( @@ -108,14 +158,21 @@ export const createSessionQueryPersister = ( 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)); @@ -146,7 +203,7 @@ export const createSessionQueryPersister = ( return { persistClient(client) { - if (!storage) return; + if (!storage || stopped) return; pendingClient = client; if (persistTimer !== undefined) return; persistTimer = setTimeout(flush, 0); @@ -157,12 +214,7 @@ export const createSessionQueryPersister = ( if (!storedValue) return undefined; const client = sanitizeRestoredClient(parse(storedValue)); - if (!client || client.timestamp > Date.now()) { - removeStorageItem(storage, key); - return undefined; - } - - if (client.clientState.queries.length === 0) { + if (!client || client.timestamp > Date.now() || client.clientState.queries.length === 0) { removeStorageItem(storage, key); return undefined; } @@ -179,5 +231,13 @@ export const createSessionQueryPersister = ( removeStorageItem(storage, key); }, flush, + // Called when the provider tears down, after the last flush: the teardown + // also clears the query cache, and that clear must not be able to overwrite + // the snapshot we just saved with an empty one. + 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 51aefdd43..52bc9ac29 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -152,15 +152,19 @@ const ScopedTRPCReactProvider = ({ return client; }); - useEffect(() => () => queryClient.clear(), [queryClient]); + // Declared before the cache teardown below so its cleanup runs first: the + // persister has to save the real cache and shut down before `clear()` empties + // it, otherwise the resulting "empty cache" event would be persisted instead. 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(() => { return clientApi.createClient({ diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index ce9737984..c0015fcdd 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -162,31 +162,27 @@ export const ClientBoard = ({ ); - if (representativeWidth === null) { - return ( - - {content} - - ); - } - return ( - - - {tPreview("editorWidthLabel", { layoutName: currentLayout.name, width: representativeWidth })} - - - {content} - - + {representativeWidth === null ? ( + 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 ccd61caca..ce91b82a2 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -96,9 +96,14 @@ export const createBoardContentPage = >( return ( + {/* + Rendered on the server, so this is the server's clock and time + zone. Widgets paint with it immediately and switch to the browser + values right after hydration. + */} diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 5c03f2648..0bf05fb7f 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -2,7 +2,7 @@ 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, VisuallyHidden } from "@mantine/core"; +import { Box, Button, Center, Loader, Portal } from "@mantine/core"; import { useElementSize, useIsomorphicEffect } from "@mantine/hooks"; import { QueryErrorResetBoundary, useIsFetching, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; @@ -343,10 +343,14 @@ const LoadedBoardItemContent = ({ /> + {/* + Purely decorative: widgets keep showing their previous data while they + refresh, and every widget refreshes on a timer, so announcing this would + make assistive technology repeat "loading" for the whole board forever. + */} {isWidgetRefreshing && ( - + - {t("common.action.loading")} )} diff --git a/e2e/lazy-widgets.spec.ts b/e2e/lazy-widgets.spec.ts index f3e6b1d03..511dcb966 100644 --- a/e2e/lazy-widgets.spec.ts +++ b/e2e/lazy-widgets.spec.ts @@ -107,8 +107,11 @@ describe("lazy widget application graph", () => { const pageErrors: Error[] = []; const hydrationErrors: string[] = []; page.on("pageerror", (error) => pageErrors.push(error)); + // Production React reports hydration mismatches as minified error codes with + // a react.dev link, so matching the development wording alone never fires. + 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" && /hydration|did not match/i.test(message.text())) { + if (message.type() === "error" && hydrationErrorPattern.test(message.text())) { hydrationErrors.push(message.text()); } }); @@ -118,16 +121,12 @@ describe("lazy widget application graph", () => { await page.goto(`${baseUrl}/auth/login`); await page.getByLabel("Username").fill(adminCredentials.username); await page.locator("#password").fill(adminCredentials.password); - const initialDownloadsResponse = page.waitForResponse( - (response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(), - ); const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname === "/", { waitUntil: "commit", timeout: 60_000, }); await page.locator("css=button[type='submit']").click(); await signedIn; - await initialDownloadsResponse; const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first(); await expect(visibleBoard).toBeVisible({ timeout: 30_000 }); @@ -169,7 +168,7 @@ describe("lazy widget application graph", () => { await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--"); await expect.poll(() => refreshRequestStarted, { timeout: 10_000 }).toBe(true); await expect(cachedDownload).toBeVisible({ timeout: 5_000 }); - await expect(downloadsWidget.locator('output[aria-live="polite"]')).toBeVisible(); + await expect(downloadsWidget.locator("[data-widget-refreshing]")).toBeVisible(); const refreshedDownloadsResponse = page.waitForResponse( (response) => response.url().includes("widget.downloads.getJobsAndStatuses") && response.ok(), @@ -177,7 +176,7 @@ describe("lazy widget application graph", () => { releaseRefresh(); releaseRefresh = undefined; await refreshedDownloadsResponse; - await expect(downloadsWidget.locator('output[aria-live="polite"]')).toHaveCount(0); + await expect(downloadsWidget.locator("[data-widget-refreshing]")).toHaveCount(0); await page.unroute(downloadsRequestPattern, delayDownloadsRefresh); expect(pageErrors).toEqual([]); expect(hydrationErrors).toEqual([]); diff --git a/packages/widgets/src/clock/world-clock.ts b/packages/widgets/src/clock/world-clock.ts index e951fa916..8ae090e87 100644 --- a/packages/widgets/src/clock/world-clock.ts +++ b/packages/widgets/src/clock/world-clock.ts @@ -61,11 +61,6 @@ export interface WorldClockTime { dayDifference: number | null; } -export const getResolvedLocalTimeZone = () => { - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - return timeZone && isTimeZoneSupported(timeZone) ? timeZone : "UTC"; -}; - export const createWorldClockTime = ({ city, now, diff --git a/packages/widgets/src/common/widget-time-context.spec.tsx b/packages/widgets/src/common/widget-time-context.spec.tsx index 286c8f95f..2e091e7b0 100644 --- a/packages/widgets/src/common/widget-time-context.spec.tsx +++ b/packages/widgets/src/common/widget-time-context.spec.tsx @@ -10,7 +10,7 @@ import { useWidgetLocalTimeZone, WidgetTimeProvider } from "./widget-time-contex const TimeProbe = () => ( ); diff --git a/packages/widgets/src/common/widget-time-context.tsx b/packages/widgets/src/common/widget-time-context.tsx index d344d5380..347975369 100644 --- a/packages/widgets/src/common/widget-time-context.tsx +++ b/packages/widgets/src/common/widget-time-context.tsx @@ -1,7 +1,7 @@ "use client"; import type { PropsWithChildren } from "react"; -import { createContext, useContext, useSyncExternalStore } from "react"; +import { createContext, useContext, useMemo, useSyncExternalStore } from "react"; const WidgetTimestampContext = createContext(null); const WidgetTimeZoneContext = createContext("UTC"); @@ -15,15 +15,22 @@ export const WidgetTimeProvider = ({ children, initialTimestamp, initialTimeZone, -}: PropsWithChildren) => ( - - - {children} - - -); +}: PropsWithChildren) => { + const timeZone = useMemo(() => getSupportedTimeZone(initialTimeZone), [initialTimeZone]); + + return ( + + {children} + + ); +}; export const useWidgetInitialTimestamp = () => useContext(WidgetTimestampContext); + +/** + * Resolves to the browser time zone, but renders (and hydrates) with the time + * zone the server used so widgets can paint a time before hydration finishes. + */ export const useWidgetLocalTimeZone = () => { const initialTimeZone = useContext(WidgetTimeZoneContext); return useSyncExternalStore(subscribeToTimeZone, getResolvedLocalTimeZone, () => initialTimeZone); @@ -31,7 +38,15 @@ export const useWidgetLocalTimeZone = () => { const subscribeToTimeZone = () => () => undefined; -const getResolvedLocalTimeZone = () => getSupportedTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone); +// React calls the snapshot getter on every render of every consumer, and +// resolving a time zone builds `Intl.DateTimeFormat` instances, so keep the +// answer around: it cannot change without a new document. +let resolvedLocalTimeZone: string | undefined; + +const getResolvedLocalTimeZone = () => { + resolvedLocalTimeZone ??= getSupportedTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone); + return resolvedLocalTimeZone; +}; const getSupportedTimeZone = (timeZone: string | undefined) => { if (!timeZone) return "UTC"; 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/time.ts b/packages/widgets/src/time.ts index 07babe34b..180cb0d18 100644 --- a/packages/widgets/src/time.ts +++ b/packages/widgets/src/time.ts @@ -1,3 +1 @@ -export { WidgetTimeProvider, useWidgetInitialTimestamp, useWidgetLocalTimeZone } from "./common/widget-time-context"; -export { getNextWidgetTickDelay, useWidgetNow } from "./common/use-widget-now"; -export type { WidgetClockPrecision } from "./common/use-widget-now"; +export { WidgetTimeProvider } from "./common/widget-time-context"; From 0073dfe044fb01b9afc9351bee31741f91eac347 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 22 Aug 2026 18:36:40 +0200 Subject: [PATCH 6/9] refactor: trim widget reload plumbing --- .../query-persistence.spec.ts | 26 +------------------ .../_client-providers/query-persistence.ts | 19 +------------- .../app/[locale]/_client-providers/trpc.tsx | 3 --- .../[locale]/boards/(content)/_creator.tsx | 5 ---- .../components/board/items/item-content.tsx | 6 ----- e2e/lazy-widgets.spec.ts | 2 -- .../src/common/widget-time-context.tsx | 7 ----- 7 files changed, 2 insertions(+), 66 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts index 954797a22..7b57a1c68 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts @@ -120,8 +120,6 @@ describe("widget query persistence", () => { persister.flush(); persister.stop(); - // A torn down provider clears its query client, which would otherwise be - // persisted as an empty cache on top of the snapshot we just saved. persister.persistClient({ timestamp: Date.now(), buster: queryPersistenceBuster, @@ -135,41 +133,19 @@ describe("widget query persistence", () => { test("removes the cache instead of storing an empty one", () => { const scope = "user-a"; const key = getQueryPersistenceStorageKey(scope); - const { queryClient } = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" }); const persister = createSessionQueryPersister(scope); window.sessionStorage.setItem(key, "stale"); persister.persistClient({ timestamp: Date.now(), buster: queryPersistenceBuster, - clientState: dehydrate(queryClient), + clientState: { mutations: [], queries: [] }, }); persister.flush(); expect(window.sessionStorage.getItem(key)).toBeNull(); }); - test("persister itself rejects unrelated query payloads", async () => { - const scope = "user-a"; - const widget = createSuccessfulQuery([["widget", "weather", "getWeather"], { type: "query" }], { temp: 21 }); - const unrelated = createSuccessfulQuery([["user", "session"], { type: "query" }], { secret: true }); - const persister = createSessionQueryPersister(scope); - - persister.persistClient({ - timestamp: Date.now(), - buster: queryPersistenceBuster, - clientState: { - mutations: [], - queries: [...dehydrate(widget.queryClient).queries, ...dehydrate(unrelated.queryClient).queries], - }, - }); - persister.flush(); - - const storedValue = window.sessionStorage.getItem(getQueryPersistenceStorageKey(scope)); - expect(storedValue).not.toContain("secret"); - expect((await persister.restoreClient())?.clientState.queries).toHaveLength(1); - }); - test("round-trips SuperJSON data, strips refresh errors, and removes corrupted storage", async () => { vi.useFakeTimers(); const scope = "user-a"; diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts index f899a881d..e5145ff5c 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.ts @@ -25,11 +25,6 @@ interface PersistableQuery { export type SessionQueryPersister = Persister & { flush: () => void; stop: () => void }; const textEncoder = new TextEncoder(); -// React Query re-dehydrates the whole cache on every cache event, so this -// predicate runs for every query several times per refresh cycle. Measuring a -// payload means serializing it, which dwarfs everything else in the pass, so -// remember the verdict per data reference instead of re-encoding every widget -// payload every time. const budgetVerdictCache = new WeakMap(); const measureFitsBudget = (data: unknown) => { @@ -57,7 +52,6 @@ const holdsDashboardData = (query: PersistableQuery) => query.state.dataUpdatedAt > 0 && isWidgetDataQueryKey(query.queryKey); -/** Write path: dashboard data that is small enough to keep in session storage. */ export const shouldPersistDashboardQuery = (query: PersistableQuery) => holdsDashboardData(query) && fitsStorageBudget(query.state.data); @@ -82,18 +76,13 @@ const sanitizePersistedClient = (client: PersistedClient): PersistedClient => ({ ...client, clientState: { mutations: [], - queries: client.clientState.queries - .filter((query) => shouldPersistDashboardQuery(query as PersistableQuery)) - .map(sanitizePersistedQuery), + queries: client.clientState.queries.map(sanitizePersistedQuery), }, }); const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -// Read path: the payload was already budgeted when it was written, and this runs -// before any query is allowed to fetch, so it deliberately skips the expensive -// size measurement and only rejects anything that is not restorable widget data. 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; @@ -133,9 +122,6 @@ const removeStorageItem = (storage: Storage | undefined, key: string) => { } }; -// Signing out navigates away before the session scope guard can observe the -// change, so a previous account's cached dashboard data would otherwise linger -// in the tab. Only the active scope may keep a cache. const removeForeignScopes = (storage: Storage | undefined, activeKey: string) => { if (!storage) return; @@ -231,9 +217,6 @@ export const createSessionQueryPersister = ( removeStorageItem(storage, key); }, flush, - // Called when the provider tears down, after the last flush: the teardown - // also clears the query cache, and that clear must not be able to overwrite - // the snapshot we just saved with an empty one. stop() { stopped = true; clearPersistTimer(); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 52bc9ac29..dc5242de4 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -152,9 +152,6 @@ const ScopedTRPCReactProvider = ({ return client; }); - // Declared before the cache teardown below so its cleanup runs first: the - // persister has to save the real cache and shut down before `clear()` empties - // it, otherwise the resulting "empty cache" event would be persisted instead. useEffect(() => { const flush = () => queryPersister.flush(); window.addEventListener("pagehide", flush); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index ce91b82a2..991b4ce2e 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -96,11 +96,6 @@ export const createBoardContentPage = >( return ( - {/* - Rendered on the server, so this is the server's clock and time - zone. Widgets paint with it immediately and switch to the browser - values right after hydration. - */} getWidgetQueryKeys(definition, item.kind), [definition, item.kind]); const isWidgetRefreshing = useIsFetching({ - type: "active", predicate: (query) => query.state.data !== undefined && matchesWidgetItemQuery( @@ -343,11 +342,6 @@ const LoadedBoardItemContent = ({ /> - {/* - Purely decorative: widgets keep showing their previous data while they - refresh, and every widget refreshes on a timer, so announcing this would - make assistive technology repeat "loading" for the whole board forever. - */} {isWidgetRefreshing && ( diff --git a/e2e/lazy-widgets.spec.ts b/e2e/lazy-widgets.spec.ts index 511dcb966..9bdaac3a9 100644 --- a/e2e/lazy-widgets.spec.ts +++ b/e2e/lazy-widgets.spec.ts @@ -107,8 +107,6 @@ describe("lazy widget application graph", () => { const pageErrors: Error[] = []; const hydrationErrors: string[] = []; page.on("pageerror", (error) => pageErrors.push(error)); - // Production React reports hydration mismatches as minified error codes with - // a react.dev link, so matching the development wording alone never fires. 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())) { diff --git a/packages/widgets/src/common/widget-time-context.tsx b/packages/widgets/src/common/widget-time-context.tsx index 347975369..021663784 100644 --- a/packages/widgets/src/common/widget-time-context.tsx +++ b/packages/widgets/src/common/widget-time-context.tsx @@ -27,10 +27,6 @@ export const WidgetTimeProvider = ({ export const useWidgetInitialTimestamp = () => useContext(WidgetTimestampContext); -/** - * Resolves to the browser time zone, but renders (and hydrates) with the time - * zone the server used so widgets can paint a time before hydration finishes. - */ export const useWidgetLocalTimeZone = () => { const initialTimeZone = useContext(WidgetTimeZoneContext); return useSyncExternalStore(subscribeToTimeZone, getResolvedLocalTimeZone, () => initialTimeZone); @@ -38,9 +34,6 @@ export const useWidgetLocalTimeZone = () => { const subscribeToTimeZone = () => () => undefined; -// React calls the snapshot getter on every render of every consumer, and -// resolving a time zone builds `Intl.DateTimeFormat` instances, so keep the -// answer around: it cannot change without a new document. let resolvedLocalTimeZone: string | undefined; const getResolvedLocalTimeZone = () => { From 382cffc015c822031aeca94f732f5f22997c1a81 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 22 Aug 2026 18:57:50 +0200 Subject: [PATCH 7/9] test: use configured DOM environment --- .../app/[locale]/_client-providers/query-persistence.spec.ts | 2 -- packages/widgets/src/common/widget-time-context.spec.tsx | 2 -- 2 files changed, 4 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts index 7b57a1c68..5c18c31f6 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts +++ b/apps/nextjs/src/app/[locale]/_client-providers/query-persistence.spec.ts @@ -1,5 +1,3 @@ -// @vitest-environment jsdom - import { dehydrate, QueryClient } from "@tanstack/react-query"; import type { PersistedClient } from "@tanstack/react-query-persist-client"; import { persistQueryClientRestore } from "@tanstack/react-query-persist-client"; diff --git a/packages/widgets/src/common/widget-time-context.spec.tsx b/packages/widgets/src/common/widget-time-context.spec.tsx index 2e091e7b0..213b45639 100644 --- a/packages/widgets/src/common/widget-time-context.spec.tsx +++ b/packages/widgets/src/common/widget-time-context.spec.tsx @@ -1,5 +1,3 @@ -// @vitest-environment jsdom - import { act } from "react"; import { hydrateRoot } from "react-dom/client"; import { renderToString } from "react-dom/server"; From 3efb719eeeb7537771612a02445206974eed8a19 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 22 Aug 2026 19:47:12 +0200 Subject: [PATCH 8/9] refactor: simplify widget import boundary --- .oxlintrc.json | 20 +++++ .../assistant/widget-import-boundary.spec.ts | 75 ------------------- pnpm-lock.yaml | 2 +- pnpm-workspace.yaml | 2 +- 4 files changed, 22 insertions(+), 77 deletions(-) delete mode 100644 apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts 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/src/components/assistant/widget-import-boundary.spec.ts b/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts deleted file mode 100644 index e4aa2a4ad..000000000 --- a/apps/nextjs/src/components/assistant/widget-import-boundary.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { parse } from "@babel/parser"; -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]; - }); - -const rootWidgetsModule = "@homarr/widgets"; - -const hasRuntimeRootWidgetImport = (source: string, fileName: string): boolean => { - const { program } = parse(source, { - sourceFilename: fileName, - sourceType: "unambiguous", - plugins: ["typescript", "jsx", "decorators-legacy", "importAttributes"], - }); - const hasStaticImport = program.body.some( - (node) => - node.type === "ImportDeclaration" && - node.source.value === rootWidgetsModule && - node.importKind !== "type" && - (node.specifiers.length === 0 || - node.specifiers.some((specifier) => specifier.type !== "ImportSpecifier" || specifier.importKind !== "type")), - ); - return hasStaticImport || hasDynamicRootWidgetImport(program); -}; - -const hasDynamicRootWidgetImport = (value: unknown): boolean => { - if (Array.isArray(value)) return value.some(hasDynamicRootWidgetImport); - if (typeof value !== "object" || value === null) return false; - - const node = value as Record; - if (node.type === "CallExpression") { - const callee = node.callee as Record | undefined; - const argument = Array.isArray(node.arguments) - ? (node.arguments[0] as Record | undefined) - : undefined; - if (callee?.type === "Import" && argument?.type === "StringLiteral" && argument.value === rootWidgetsModule) { - return true; - } - } - - return Object.values(node).some(hasDynamicRootWidgetImport); -}; - -describe("widget import boundary", () => { - test("application runtime code uses narrow widget exports", () => { - const offenders = collectRuntimeSourceFiles(sourceRoot).filter((path) => { - const source = readFileSync(path, "utf8"); - return hasRuntimeRootWidgetImport(source, path); - }); - - expect(offenders).toEqual([]); - }); - - test.each([ - ['import type { WidgetDefinition } from "@homarr/widgets";', false], - ['import { type WidgetDefinition } from "@homarr/widgets";', false], - ["const example = 'import { widgets } from \"@homarr/widgets\";';", false], - ['// import { widgets } from "@homarr/widgets";', false], - ['import { widgets } from "@homarr/widgets";', true], - ['import { type WidgetDefinition, widgets } from "@homarr/widgets";', true], - ['import /* chunk */ ( /* source */ "@homarr/widgets" );', true], - ])("detects runtime root imports in %s", (source, expected) => { - expect(hasRuntimeRootWidgetImport(source, "fixture.ts")).toBe(expected); - }); -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a56278e42..43b4000ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,7 +202,7 @@ catalogs: specifier: ^5.101.2 version: 5.101.2 '@tanstack/react-query-persist-client': - specifier: 5.101.2 + specifier: ^5.101.2 version: 5.101.2 '@testcontainers/mysql': specifier: ^12.0.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4f6c9b329..952d1f3a8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -63,7 +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 + "@tanstack/react-query-persist-client": ^5.101.2 "@testcontainers/mysql": ^12.0.4 "@testcontainers/postgresql": ^12.0.4 "@testcontainers/redis": ^12.0.4 From 240397e7ac7948c43fe0e469d5a108b2f7fdaf47 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 23 Aug 2026 02:19:18 +0200 Subject: [PATCH 9/9] refactor: drop server-seeded widget clock The WidgetTimeProvider plumbed a server timestamp and the container's timezone down to useWidgetNow so clock widgets would paint a real time in the SSR HTML instead of "--:--". That traded a cosmetic first-paint win for a wrong clock: the seeded timezone is the server's, so anyone whose timezone differs from the container's saw an incorrect time for the whole hydration window. Fixing that properly needs a client-set timezone cookie read during RSC render, which is far more plumbing than the problem warrants. Revert the clock to release/v2 behaviour ("--:--" until hydration) and drop the provider, the @homarr/widgets/time export and their tests. The e2e clock assertions now wait on hydration explicitly instead of the seeded paint. Also restore the widget.beszel.getSystemStats persistence exclusion. #6289 added it deliberately to keep those large time-series payloads out of the persisted cache, and this branch had removed it with no stated rationale. Query-cache persistence and the background-refresh indicator are unchanged. --- .../app/[locale]/boards/(content)/_client.tsx | 47 +++++++---------- .../[locale]/boards/(content)/_creator.tsx | 5 +- e2e/lazy-widgets.spec.ts | 8 ++- packages/api/src/query-cache.ts | 4 +- packages/api/src/test/query-cache.spec.ts | 4 +- packages/widgets/package.json | 1 - packages/widgets/src/clock/component.tsx | 6 +-- .../widgets/src/clock/use-current-time.ts | 29 +++++++++-- packages/widgets/src/clock/world-clock.ts | 5 ++ packages/widgets/src/common/use-widget-now.ts | 5 +- .../src/common/widget-time-context.spec.tsx | 47 ----------------- .../src/common/widget-time-context.tsx | 52 ------------------- packages/widgets/src/time.ts | 1 - 13 files changed, 64 insertions(+), 150 deletions(-) delete mode 100644 packages/widgets/src/common/widget-time-context.spec.tsx delete mode 100644 packages/widgets/src/common/widget-time-context.tsx delete mode 100644 packages/widgets/src/time.ts diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index c0015fcdd..395178901 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -9,7 +9,6 @@ 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"; @@ -40,13 +39,7 @@ const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => ( ); -export const ClientBoard = ({ - initialTimestamp, - initialTimeZone, -}: { - initialTimestamp: number; - initialTimeZone: string; -}) => { +export const ClientBoard = () => { const board = useRequiredBoard(); const t = useI18n("board.landmark"); const tPreview = useI18n("board.setting.section.layout.preview"); @@ -162,27 +155,23 @@ export const ClientBoard = ({ ); + if (representativeWidth === null) return content; + return ( - - {representativeWidth === null ? ( - content - ) : ( - - - {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 991b4ce2e..9cf907b5e 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -96,10 +96,7 @@ export const createBoardContentPage = >( return ( - + ); diff --git a/e2e/lazy-widgets.spec.ts b/e2e/lazy-widgets.spec.ts index 9bdaac3a9..d777ed0dc 100644 --- a/e2e/lazy-widgets.spec.ts +++ b/e2e/lazy-widgets.spec.ts @@ -132,7 +132,9 @@ describe("lazy widget application graph", () => { `[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("--:--"); + 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 }) @@ -163,7 +165,9 @@ describe("lazy widget application graph", () => { await page.reload({ waitUntil: "domcontentloaded" }); await expect(clockWidget.filter({ visible: true }).first()).toBeVisible({ timeout: 30_000 }); - await expect(clockWidget.locator("time").filter({ visible: true }).first()).not.toHaveText("--:--"); + await expect(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(); diff --git a/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts index e07b2a246..a1cb201ea 100644 --- a/packages/api/src/query-cache.ts +++ b/packages/api/src/query-cache.ts @@ -8,7 +8,9 @@ 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"]); +// 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) => { 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 02103d13d..e5928bf71 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" }], true], + [[["widget", "beszel", "getSystemStats"], { type: "query" }], false], [[["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", true], + ["widget.beszel.getSystemStats", false], ["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 5036148b5..2daffc1b5 100644 --- a/packages/widgets/package.json +++ b/packages/widgets/package.json @@ -31,7 +31,6 @@ "./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/clock/component.tsx b/packages/widgets/src/clock/component.tsx index ab0d5926b..45180cff9 100644 --- a/packages/widgets/src/clock/component.tsx +++ b/packages/widgets/src/clock/component.tsx @@ -4,13 +4,12 @@ import { Box, Center, Stack, Text, Title } from "@mantine/core"; import dayjs from "dayjs"; import advancedFormat from "dayjs/plugin/advancedFormat"; -import { useWidgetLocalTimeZone } from "../common/widget-time-context"; import type { WidgetComponentProps } from "../definition"; import { AdvancedClockView } from "./advanced-view"; import { clockTimeFormatShowsSeconds, resolveClockTimeFormat } from "./format"; import { useCurrentTime } from "./use-current-time"; import { ClockWeatherSummary } from "./weather-summary"; -import { isTimeZoneSupported } from "./world-clock"; +import { getResolvedLocalTimeZone, isTimeZoneSupported } from "./world-clock"; dayjs.extend(advancedFormat); @@ -18,8 +17,7 @@ export default function ClockWidget({ options, width, height, displayMode }: Wid const isAdvanced = displayMode === "advanced"; const showSeconds = clockTimeFormatShowsSeconds(options.customTimeFormat); const time = useCurrentTime({ showSeconds }); - const localTimeZone = useWidgetLocalTimeZone(); - const requestedTimeZone = options.useCustomTimezone ? options.timezone : localTimeZone; + const requestedTimeZone = options.useCustomTimezone ? options.timezone : getResolvedLocalTimeZone(); const primaryTimeZoneInvalid = !isTimeZoneSupported(requestedTimeZone); const primaryTimeZone = primaryTimeZoneInvalid ? "UTC" : requestedTimeZone; const zonedTime = time === null ? null : dayjs(time).tz(primaryTimeZone); diff --git a/packages/widgets/src/clock/use-current-time.ts b/packages/widgets/src/clock/use-current-time.ts index 9992e5f40..6d7b30a6f 100644 --- a/packages/widgets/src/clock/use-current-time.ts +++ b/packages/widgets/src/clock/use-current-time.ts @@ -1,4 +1,27 @@ -import { useWidgetNow } from "../common/use-widget-now"; +import { useEffect, useMemo, useRef, useState } from "react"; +import dayjs from "dayjs"; -export const useCurrentTime = ({ showSeconds }: { showSeconds: boolean }) => - useWidgetNow(showSeconds ? "second" : "minute"); +export const useCurrentTime = ({ showSeconds }: { showSeconds: boolean }) => { + 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; +}; diff --git a/packages/widgets/src/clock/world-clock.ts b/packages/widgets/src/clock/world-clock.ts index 8ae090e87..e951fa916 100644 --- a/packages/widgets/src/clock/world-clock.ts +++ b/packages/widgets/src/clock/world-clock.ts @@ -61,6 +61,11 @@ export interface WorldClockTime { dayDifference: number | null; } +export const getResolvedLocalTimeZone = () => { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return timeZone && isTimeZoneSupported(timeZone) ? timeZone : "UTC"; +}; + export const createWorldClockTime = ({ city, now, diff --git a/packages/widgets/src/common/use-widget-now.ts b/packages/widgets/src/common/use-widget-now.ts index 03dfc90ee..40f7c88cd 100644 --- a/packages/widgets/src/common/use-widget-now.ts +++ b/packages/widgets/src/common/use-widget-now.ts @@ -2,8 +2,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useWidgetInitialTimestamp } from "./widget-time-context"; - export type WidgetClockPrecision = "second" | "minute" | "day"; const minimumDelayMs = 10; @@ -22,8 +20,7 @@ export const getNextWidgetTickDelay = (now: Date, precision: WidgetClockPrecisio }; export const useWidgetNow = (precision: WidgetClockPrecision = "minute"): Date | null => { - const initialTimestamp = useWidgetInitialTimestamp(); - const [now, setNow] = useState(() => (initialTimestamp === null ? null : new Date(initialTimestamp))); + const [now, setNow] = useState(null); 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 deleted file mode 100644 index 213b45639..000000000 --- a/packages/widgets/src/common/widget-time-context.spec.tsx +++ /dev/null @@ -1,47 +0,0 @@ -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 { useWidgetLocalTimeZone, WidgetTimeProvider } from "./widget-time-context"; - -const TimeProbe = () => ( - -); - -afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); -}); - -describe("WidgetTimeProvider", () => { - test("hydrates with server time and timezone before switching to browser values", async () => { - vi.useFakeTimers(); - const serverTimestamp = new Date("2026-08-22T12:34:00.000Z").getTime(); - const clientTimestamp = serverTimestamp + 60_000; - const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - const serverTimeZone = browserTimeZone === "UTC" ? "Europe/Paris" : "UTC"; - vi.setSystemTime(serverTimestamp); - const content = ( - - - - ); - const html = renderToString(content); - const host = document.createElement("div"); - host.innerHTML = html; - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); - vi.setSystemTime(clientTimestamp); - - expect(host.textContent).toBe(`2026-08-22T12:34:00.000Z|${serverTimeZone}`); - const root = hydrateRoot(host, content); - await act(async () => undefined); - expect(host.textContent).toBe(`2026-08-22T12:35:00.000Z|${browserTimeZone}`); - 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 deleted file mode 100644 index 021663784..000000000 --- a/packages/widgets/src/common/widget-time-context.tsx +++ /dev/null @@ -1,52 +0,0 @@ -"use client"; - -import type { PropsWithChildren } from "react"; -import { createContext, useContext, useMemo, useSyncExternalStore } from "react"; - -const WidgetTimestampContext = createContext(null); -const WidgetTimeZoneContext = createContext("UTC"); - -interface WidgetTimeProviderProps { - initialTimestamp: number; - initialTimeZone: string; -} - -export const WidgetTimeProvider = ({ - children, - initialTimestamp, - initialTimeZone, -}: PropsWithChildren) => { - const timeZone = useMemo(() => getSupportedTimeZone(initialTimeZone), [initialTimeZone]); - - return ( - - {children} - - ); -}; - -export const useWidgetInitialTimestamp = () => useContext(WidgetTimestampContext); - -export const useWidgetLocalTimeZone = () => { - const initialTimeZone = useContext(WidgetTimeZoneContext); - return useSyncExternalStore(subscribeToTimeZone, getResolvedLocalTimeZone, () => initialTimeZone); -}; - -const subscribeToTimeZone = () => () => undefined; - -let resolvedLocalTimeZone: string | undefined; - -const getResolvedLocalTimeZone = () => { - resolvedLocalTimeZone ??= getSupportedTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone); - return resolvedLocalTimeZone; -}; - -const getSupportedTimeZone = (timeZone: string | undefined) => { - if (!timeZone) return "UTC"; - try { - new Intl.DateTimeFormat("en", { timeZone }).format(0); - return timeZone; - } catch { - return "UTC"; - } -}; diff --git a/packages/widgets/src/time.ts b/packages/widgets/src/time.ts deleted file mode 100644 index 180cb0d18..000000000 --- a/packages/widgets/src/time.ts +++ /dev/null @@ -1 +0,0 @@ -export { WidgetTimeProvider } from "./common/widget-time-context";