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";