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:
1 parent
3efb719eee
commit
240397e7ac
13 files changed
+64
-150
No files matched your search
@@ -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) => (
|
||||
</GridEditorRegistryProvider>
|
||||
);
|
||||
|
||||
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 = ({
|
||||
</BoardSelectionGridProvider>
|
||||
);
|
||||
|
||||
if (representativeWidth === null) return content;
|
||||
|
||||
return (
|
||||
<WidgetTimeProvider initialTimestamp={initialTimestamp} initialTimeZone={initialTimeZone}>
|
||||
{representativeWidth === null ? (
|
||||
content
|
||||
) : (
|
||||
<Stack align="center" gap="xs" p="md" mih="100%">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{tPreview("editorWidthLabel", { layoutName: currentLayout.name, width: representativeWidth })}
|
||||
</Text>
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
w={`min(${representativeWidth}px, calc(100vw - 2rem))`}
|
||||
mih="calc(100dvh - 8rem)"
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{content}
|
||||
</Paper>
|
||||
</Stack>
|
||||
)}
|
||||
</WidgetTimeProvider>
|
||||
<Stack align="center" gap="xs" p="md" mih="100%">
|
||||
<Text size="xs" c="dimmed" fw={500}>
|
||||
{tPreview("editorWidthLabel", { layoutName: currentLayout.name, width: representativeWidth })}
|
||||
</Text>
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="sm"
|
||||
radius="md"
|
||||
w={`min(${representativeWidth}px, calc(100vw - 2rem))`}
|
||||
mih="calc(100dvh - 8rem)"
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{content}
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -96,10 +96,7 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<IntegrationProvider integrations={integrations}>
|
||||
<ClientBoard
|
||||
initialTimestamp={Date.now()}
|
||||
initialTimeZone={Intl.DateTimeFormat().resolvedOptions().timeZone}
|
||||
/>
|
||||
<ClientBoard />
|
||||
</IntegrationProvider>
|
||||
</HydrationBoundary>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
export { WidgetTimeProvider } from "./common/widget-time-context";
|
||||
Reference in new issue
Block a user