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.
This commit is contained in:
Thomas Camlong authored and Thomas Camlong committed 2026-08-23 09:00:34 +02:00
1 parent 3efb719eee
commit 240397e7ac
13 files changed
+64 -150

No files matched your search

+2 -4
View File
@@ -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);
+26 -3
View File
@@ -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<Date | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const intervalRef = useRef<ReturnType<typeof setInterval>>(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;
};
@@ -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,
@@ -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<Date | null>(() => (initialTimestamp === null ? null : new Date(initialTimestamp)));
const [now, setNow] = useState<Date | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const cancelTick = useCallback(() => {
@@ -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 = () => (
<time>
{useWidgetNow("minute")?.toISOString() ?? "missing"}|{useWidgetLocalTimeZone()}
</time>
);
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 = (
<WidgetTimeProvider initialTimestamp={serverTimestamp} initialTimeZone={serverTimeZone}>
<TimeProbe />
</WidgetTimeProvider>
);
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());
});
});
@@ -1,52 +0,0 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext, useMemo, useSyncExternalStore } from "react";
const WidgetTimestampContext = createContext<number | null>(null);
const WidgetTimeZoneContext = createContext("UTC");
interface WidgetTimeProviderProps {
initialTimestamp: number;
initialTimeZone: string;
}
export const WidgetTimeProvider = ({
children,
initialTimestamp,
initialTimeZone,
}: PropsWithChildren<WidgetTimeProviderProps>) => {
const timeZone = useMemo(() => getSupportedTimeZone(initialTimeZone), [initialTimeZone]);
return (
<WidgetTimestampContext.Provider value={initialTimestamp}>
<WidgetTimeZoneContext.Provider value={timeZone}>{children}</WidgetTimeZoneContext.Provider>
</WidgetTimestampContext.Provider>
);
};
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";
}
};
-1
View File
@@ -1 +0,0 @@
export { WidgetTimeProvider } from "./common/widget-time-context";