From 240397e7ac7948c43fe0e469d5a108b2f7fdaf47 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 23 Aug 2026 02:19:18 +0200 Subject: [PATCH] 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";