From c848dbfc1fe41268a9771cde5f98f7a2a0aa41a2 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 25 Jun 2026 17:38:13 +0200 Subject: [PATCH] fix: scope polling to widgets, fix retry, guard dates, throttle umami - Remove global refetchInterval; scope to widget queries via setQueryDefaults - Fix error boundary retry: correct queryKey to [["widget"]], move invalidation to onReset - Add toValidDate() helper in @homarr/common for cache hydration safety - Guard date formatting in media-releases and media-requests against non-Date values - Set umami refetchInterval to 60s via shared umamiQueryOptions constant - Simplify dns-hole mutations to invalidate() instead of manual optimistic setData --- .../app/[locale]/_client-providers/trpc.tsx | 54 +++++++++---------- .../components/board/items/item-content.tsx | 8 ++- packages/api/src/query-cache.ts | 2 - packages/common/src/date.ts | 5 ++ .../src/dns-hole/controls/component.tsx | 47 +--------------- .../widgets/src/media-releases/component.tsx | 24 +++++---- .../src/media-requests/list/component.tsx | 3 +- packages/widgets/src/umami/umami-content.tsx | 19 +++---- .../src/umami/umami-events-content.tsx | 12 ++--- packages/widgets/src/umami/umami-top-list.tsx | 22 ++++---- packages/widgets/src/umami/umami-utils.ts | 2 + 11 files changed, 84 insertions(+), 114 deletions(-) diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index ae8f57903..7a02e9284 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -61,35 +61,35 @@ const wsClient = createWSClient({ export function TRPCReactProvider(props: PropsWithChildren) { const [persister] = useState(() => createWidgetQueryPersister()); - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - staleTime: queryCacheDefaultStaleTimeMs, - refetchInterval: queryCacheDefaultRefetchIntervalMs, - gcTime: queryCacheDefaultGcTimeMs, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - retry: 3, - }, - mutations: { - onError(error) { - if ( - error instanceof TRPCClientError && - error.data?.code === "FORBIDDEN" && - error.message === "Mutations are disabled in demo mode" - ) { - showWarningNotification({ - title: "Demo mode", - message: "This action is disabled in demo mode.", - }); - } - }, + const [queryClient] = useState(() => { + const client = new QueryClient({ + defaultOptions: { + queries: { + staleTime: queryCacheDefaultStaleTimeMs, + gcTime: queryCacheDefaultGcTimeMs, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: 3, + }, + mutations: { + onError(error) { + if ( + error instanceof TRPCClientError && + error.data?.code === "FORBIDDEN" && + error.message === "Mutations are disabled in demo mode" + ) { + showWarningNotification({ + title: "Demo mode", + message: "This action is disabled in demo mode.", + }); + } }, }, - }), - ); + }, + }); + client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs }); + return client; + }); const [trpcClient] = useState(() => { return clientApi.createClient({ diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 9f56a7d48..5472c95fa 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,6 +1,6 @@ import { Badge, Card } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; -import { QueryErrorResetBoundary } from "@tanstack/react-query"; +import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; import { ErrorBoundary } from "react-error-boundary"; @@ -98,12 +98,16 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { updateItemOptions({ itemId: item.id, newOptions }); const widgetSupportsIntegrations = "supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1; + const queryClient = useQueryClient(); return ( {({ reset }) => ( { + void queryClient.invalidateQueries({ queryKey: [["widget"]] }); + reset(); + }} fallbackRender={({ resetErrorBoundary, error }) => ( <> diff --git a/packages/api/src/query-cache.ts b/packages/api/src/query-cache.ts index 79f08f0be..965c21aae 100644 --- a/packages/api/src/query-cache.ts +++ b/packages/api/src/query-cache.ts @@ -1,7 +1,5 @@ import type { QueryKey } from "@tanstack/react-query"; -// ponytail: staleTime 0 = always refetch on mount (fresh data every dashboard load). -// refetchInterval 30s = poll while page is open. gcTime 24h = Redis TTL for persisted cache. export const queryCacheDefaultStaleTimeMs = 0; export const queryCacheDefaultRefetchIntervalMs = 30_000; export const queryCacheDefaultGcTimeMs = 1000 * 60 * 60 * 24; diff --git a/packages/common/src/date.ts b/packages/common/src/date.ts index de9ecadaf..487684dba 100644 --- a/packages/common/src/date.ts +++ b/packages/common/src/date.ts @@ -17,6 +17,11 @@ export function formatDuration(milliseconds: number) { return `${minutes}:${String(seconds).padStart(2, "0")}`; } +export const toValidDate = (value: unknown): Date | null => { + const date = value instanceof Date ? value : new Date(value as string | number); + return Number.isFinite(date.getTime()) ? date : null; +}; + export const isDateWithin = (date: Date, relativeDate: string): boolean => { if (relativeDate.length < 2) { throw new Error("Relative date must be at least 2 characters long"); diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index 97e3ac7ad..f46c3d746 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -56,54 +56,11 @@ export default function DnsHoleControlsWidget({ }); const utils = clientApi.useUtils(); - // Mutations for dnsHole state, set to undefined on click, and change again on settle const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({ - onSettled: (_, error, { integrationId }) => { - utils.widget.dnsHole.summary.setData( - { - integrationIds, - }, - (prevData) => { - if (!prevData) return []; - - return prevData.map((item) => - item.integration.id === integrationId - ? { - ...item, - summary: { - ...item.summary, - status: error ? "disabled" : "enabled", - }, - } - : item, - ); - }, - ); - }, + onSettled: () => void utils.widget.dnsHole.summary.invalidate(), }); const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({ - onSettled: (_, error, { integrationId }) => { - utils.widget.dnsHole.summary.setData( - { - integrationIds, - }, - (prevData) => { - if (!prevData) return []; - - return prevData.map((item) => - item.integration.id === integrationId - ? { - ...item, - summary: { - ...item.summary, - status: error ? "enabled" : "disabled", - }, - } - : item, - ); - }, - ); - }, + onSettled: () => void utils.widget.dnsHole.summary.invalidate(), }); const toggleDns = (integrationId: string) => { const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId); diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index 26fe367a9..9148948e4 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -6,7 +6,7 @@ import { IconBook, IconCalendar, IconClock, IconStarFilled } from "@tabler/icons import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; -import { getMantineColor } from "@homarr/common"; +import { getMantineColor, toValidDate } from "@homarr/common"; import { getIconUrl } from "@homarr/definitions"; import type { MediaRelease } from "@homarr/integrations/types"; import { mediaTypeConfigurations } from "@homarr/integrations/types"; @@ -39,6 +39,19 @@ interface ItemProps { options: WidgetComponentProps<"mediaReleases">["options"]; } +const formatReleaseDate = (value: unknown, locale: string) => { + const date = toValidDate(value); + if (!date) return "—"; + return Intl.DateTimeFormat(locale, { + month: "2-digit", + year: "numeric", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(date); +}; + const Item = ({ item, options }: ItemProps) => { const locale = useCurrentLocale(); const t = useI18n(); @@ -93,14 +106,7 @@ const Item = ({ item, options }: ItemProps) => { {length !== undefined && ( <> diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index 0a4fb4598..bbbd0047e 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -19,6 +19,7 @@ import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react"; import type { RouterInputs, RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; +import { toValidDate } from "@homarr/common"; import type { MediaRequestStatus } from "@homarr/integrations/types"; import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { openMediaRequestSearch } from "@homarr/spotlight"; @@ -134,7 +135,7 @@ const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) = - {request.airDate?.getFullYear() ?? t("toBeDetermined")} + {toValidDate(request.airDate)?.getFullYear() ?? t("toBeDetermined")} {!isTiny && ( 0 }, + { enabled: viewMode === "events" && eventNames.length > 0, ...umamiQueryOptions }, ); const multiEventTotal = multiEventSeries diff --git a/packages/widgets/src/umami/umami-events-content.tsx b/packages/widgets/src/umami/umami-events-content.tsx index 6dfac617c..ef91df422 100644 --- a/packages/widgets/src/umami/umami-events-content.tsx +++ b/packages/widgets/src/umami/umami-events-content.tsx @@ -7,7 +7,7 @@ import { clientApi } from "@homarr/api/client"; import type { UmamiEventSeries } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; -import { EVENT_COLORS, formatXLabel } from "./umami-utils"; +import { EVENT_COLORS, formatXLabel, umamiQueryOptions } from "./umami-utils"; interface UmamiEventsContentProps { integrationIds: string[]; @@ -28,12 +28,10 @@ export function UmamiEventsContent({ const { colorScheme } = useMantineColorScheme(); const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057"; - const { data: series = [] } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery({ - integrationId: integrationIds[0] ?? "", - websiteId, - timeFrame, - eventNames: [...eventNames].toSorted(), - }); + const { data: series = [] } = clientApi.widget.umami.getMultiEventTimeSeries.useQuery( + { integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() }, + umamiQueryOptions, + ); if (eventNames.length === 0) { return ( diff --git a/packages/widgets/src/umami/umami-top-list.tsx b/packages/widgets/src/umami/umami-top-list.tsx index 699df9b74..3ce4b8730 100644 --- a/packages/widgets/src/umami/umami-top-list.tsx +++ b/packages/widgets/src/umami/umami-top-list.tsx @@ -6,6 +6,8 @@ import { clientApi } from "@homarr/api/client"; import type { UmamiMetricItem } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; +import { umamiQueryOptions } from "./umami-utils"; + interface UmamiTopListProps { integrationIds: string[]; websiteId: string; @@ -15,23 +17,19 @@ interface UmamiTopListProps { export function UmamiTopPagesContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) { const t = useScopedI18n("widget.umami"); - const { data = [] } = clientApi.widget.umami.getTopPages.useQuery({ - integrationId: integrationIds[0] ?? "", - websiteId, - timeFrame, - limit, - }); + const { data = [] } = clientApi.widget.umami.getTopPages.useQuery( + { integrationId: integrationIds[0] ?? "", websiteId, timeFrame, limit }, + umamiQueryOptions, + ); return ; } export function UmamiTopReferrersContent({ integrationIds, websiteId, timeFrame, limit }: UmamiTopListProps) { const t = useScopedI18n("widget.umami"); - const { data = [] } = clientApi.widget.umami.getTopReferrers.useQuery({ - integrationId: integrationIds[0] ?? "", - websiteId, - timeFrame, - limit, - }); + const { data = [] } = clientApi.widget.umami.getTopReferrers.useQuery( + { integrationId: integrationIds[0] ?? "", websiteId, timeFrame, limit }, + umamiQueryOptions, + ); return (