Persist widget data across reloads

This commit is contained in:
Thomas Camlong committed 2026-08-23 09:00:33 +02:00
1 parent 0d9eeb5752
commit a2a408410a
30 files changed
+713 -127

No files matched your search

@@ -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 <WidgetQueryLoadingState />;
return (
<Stack h="100%" mih={0} gap={height < 120 ? 6 : "sm"} p={height < 120 ? 6 : "sm"}>
{options.title.length > 0 ? (
+4 -1
View File
@@ -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 <WidgetQueryLoadingState />;
return (
<CalendarBase
isEditMode={isEditMode}
+3 -26
View File
@@ -1,27 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import dayjs from "dayjs";
import { useWidgetNow } from "../common/use-widget-now";
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;
};
export const useCurrentTime = ({ showSeconds }: { showSeconds: boolean }) =>
useWidgetNow(showSeconds ? "second" : "minute");
@@ -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<Date | null>(null);
const initialTimestamp = useWidgetInitialTimestamp();
const [now, setNow] = useState<Date | null>(() => (initialTimestamp === null ? null : new Date(initialTimestamp)));
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const cancelTick = useCallback(() => {
@@ -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 = () => <time>{useWidgetNow("minute")?.toISOString() ?? "missing"}</time>;
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 = (
<WidgetTimeProvider initialTimestamp={timestamp}>
<TimeProbe />
</WidgetTimeProvider>
);
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());
});
});
@@ -0,0 +1,12 @@
"use client";
import type { PropsWithChildren } from "react";
import { createContext, useContext } from "react";
const WidgetTimeContext = createContext<number | null>(null);
export const WidgetTimeProvider = ({ children, initialTimestamp }: PropsWithChildren<{ initialTimestamp: number }>) => (
<WidgetTimeContext.Provider value={initialTimestamp}>{children}</WidgetTimeContext.Provider>
);
export const useWidgetInitialTimestamp = () => useContext(WidgetTimeContext);
@@ -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<string[]>([]);
@@ -141,15 +140,7 @@ export default function DnsHoleControlsWidget({
setBulkPending(false);
};
if (isSummaryPending) {
return (
<Stack h="100%" justify="center" align="center">
<Text c="dimmed" size="sm">
{tCommon("action.loading")}
</Text>
</Stack>
);
}
if (isInitialWidgetQueryPending(summaryQuery)) return <WidgetQueryLoadingState />;
return (
<Stack
@@ -31,7 +31,8 @@ import type { TablerIcon } from "@homarr/ui";
import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } 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";
export default function DnsHoleSummaryWidget({
options,
@@ -43,10 +44,8 @@ export default function DnsHoleSummaryWidget({
integrationIds,
});
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
const { isPending } = summaryQuery;
const t = useI18n();
const tCommon = useI18n("common");
const tDns = useI18n("widget.dnsHoleSummary");
const tWidgetCommon = useI18n("widget.common");
@@ -55,15 +54,7 @@ export default function DnsHoleSummaryWidget({
const layoutProps = boxPropsByLayout(options.layout);
const showSourceStatuses = successfulSummaries.length > 1 && width >= 240 && height >= 220;
if (isPending) {
return (
<Stack h="100%" justify="center" align="center">
<Text c="dimmed" size="sm">
{tCommon("action.loading")}
</Text>
</Stack>
);
}
if (isInitialWidgetQueryPending(summaryQuery)) return <WidgetQueryLoadingState />;
return (
<Stack h="100%" gap={0} pos="relative">
+4 -1
View File
@@ -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 <WidgetQueryLoadingState />;
let rowContextMenuHandler: typeof handleContextMenu | undefined = handleContextMenu;
if (isEditMode) rowContextMenuHandler = undefined;
@@ -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 <WidgetQueryLoadingState />;
if (!mediaRequestData) return <WidgetEmptyState />;
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
@@ -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 <WidgetQueryLoadingState />;
if (!requestStats) return <WidgetEmptyState />;
if (
requestStats.users.length === 0 &&
+3
View File
@@ -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";