refactor(widgets): stabilize widget components after useQuery migration

Add WidgetEmptyState component for widgets that need a loading fallback.
Disable refetch-on-mount/focus/reconnect and retry for integration widgets
so cached data is preserved. Ensure hooks are never called conditionally.
This commit is contained in:
Thomas Camlong committed 2026-05-29 17:26:05 +02:00
1 parent f7f6d1e382
commit f49ae79c99
17 files changed
+59 -20

No files matched your search

@@ -14,6 +14,7 @@ import { useTimeAgo } from "@homarr/common";
import type { AnchorNotePermission } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import "react-quill-new/dist/quill.snow.css";
@@ -264,7 +265,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
);
}, [canEdit, draftContent, draftTitle, hasChanges, integrationId, note, noteId, refetch, t, updateNoteAsync]);
if (!note) return null;
if (!note) return <WidgetEmptyState />;
return (
<Stack h="100%" gap="xs" p="sm">
+4 -3
View File
@@ -3,7 +3,7 @@
import type { PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconLoader } from "@tabler/icons-react";
import { IconMinus } from "@tabler/icons-react";
import combineClasses from "clsx";
import { clientApi } from "@homarr/api/client";
@@ -13,6 +13,7 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
@@ -55,7 +56,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
[app, options.openInNewTab],
);
if (!app) return null;
if (!app) return <WidgetEmptyState />;
const isTiny = height < 100 || width < 100;
const isColumnLayout = options.layout.startsWith("column");
@@ -131,7 +132,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
</Flex>
</Tooltip.Floating>
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
<Suspense fallback={<PingDot icon={IconLoader} color="blue" tooltip={`${t("common.action.loading")}…`} />}>
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
<PingIndicator appId={app.id} />
</Suspense>
) : null}
@@ -1,6 +1,7 @@
import { IconCheck, IconX } from "@tabler/icons-react";
import { IconCheck, IconMinus, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { PingDot } from "./ping-dot";
@@ -9,6 +10,7 @@ interface PingIndicatorProps {
}
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
const t = useI18n();
const { data: pingResult } = clientApi.widget.app.ping.useQuery(
{ id: appId },
{ refetchOnMount: false, refetchOnWindowFocus: false },
@@ -24,7 +26,7 @@ export const PingIndicator = ({ appId }: PingIndicatorProps) => {
},
);
if (!pingResult) return null;
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
const isError = "error" in pingResult || pingResult.statusCode >= 500;
@@ -0,0 +1,15 @@
import { Center, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
export const WidgetEmptyState = () => {
const t = useI18n();
return (
<Center h="100%" w="100%" p="sm">
<Text c="dimmed" size="sm" ta="center">
{t("widget.common.error.noData")}
</Text>
</Center>
);
};
@@ -5,6 +5,7 @@ import { clientApi } from "@homarr/api/client";
import type { Resource } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { formatUptime } from "../system-health";
import { ResourceAccordionItem } from "./resource-accordion-item";
@@ -56,7 +57,7 @@ export const ClusterHealthMonitoring = ({
},
);
if (!healthData) return null;
if (!healthData) return <WidgetEmptyState />;
const activeNodes = healthData.nodes.reduce(running, 0);
const activeVMs = healthData.vms.reduce(running, 0);
@@ -37,6 +37,7 @@ import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { WidgetEmptyState } from "../common/empty-state";
import { CpuRing } from "./rings/cpu-ring";
import { CpuTempRing } from "./rings/cpu-temp-ring";
import { GpuRing } from "./rings/gpu-ring";
@@ -86,6 +87,8 @@ export const SystemHealthMonitoring = ({
const isTiny = width < 256;
if (healthData.length === 0) return <WidgetEmptyState />;
return (
<Stack h="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo, updatedAt }) => {
@@ -8,6 +8,7 @@ import { IconAlertCircle, IconCalendar } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import classes from "./component.module.css";
@@ -29,7 +30,7 @@ export default function ImmichAlbumCarouselWidget({
return <NoAlbumSelected />;
}
if (!album) return null;
if (!album) return <WidgetEmptyState />;
if (album.assets.length === 0) {
return <NoPhotosInAlbum />;
@@ -8,6 +8,7 @@ import { clientApi } from "@homarr/api/client";
import { humanFileSize } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import classes from "./component.module.css";
@@ -20,7 +21,7 @@ export default function ImmichServerStatsWidget({
integrationId: integrationIds[0] ?? "",
});
if (!stats) return null;
if (!stats) return <WidgetEmptyState />;
return (
<Stack gap="md" h="100%" p="md">
@@ -10,6 +10,7 @@ import type { MediaRequestStatus } from "@homarr/integrations/types";
import { mediaAvailabilityConfiguration, mediaRequestStatusConfiguration } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
@@ -19,7 +20,7 @@ export default function MediaServerWidget({
options,
width,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const { data: mediaRequests = [], isFetched } = clientApi.widget.mediaRequests.getLatestRequests.useQuery(
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery(
{
integrationIds,
},
@@ -58,7 +59,7 @@ export default function MediaServerWidget({
},
);
if (!isFetched) return null;
if (!mediaRequests) return <WidgetEmptyState />;
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
return (
@@ -19,6 +19,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
import type { RequestStats } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { NoIntegrationDataError } from "../../errors/no-data-integration";
import classes from "./component.module.css";
@@ -45,7 +46,7 @@ export default function MediaServerWidget({
const board = useRequiredBoard();
if (!requestStats) return null;
if (!requestStats) return <WidgetEmptyState />;
if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError();
const data = [
@@ -9,6 +9,7 @@ import { useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { views } from ".";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
@@ -53,7 +54,7 @@ export default function MediaTranscodingWidget({
const [view, setView] = useState<View>(options.defaultView);
const t = useI18n("widget.mediaTranscoding");
if (!transcodingData) return null;
if (!transcodingData) return <WidgetEmptyState />;
const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
const isTiny = width < 256;
@@ -7,6 +7,7 @@ import { clientApi } from "@homarr/api/client";
import { formatNumber } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
export default function MinecraftServerStatusWidget({ options }: WidgetComponentProps<"minecraftServerStatus">) {
@@ -22,7 +23,7 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent
});
const tStatus = useScopedI18n("widget.minecraftServerStatus.status");
if (!result) return null;
if (!result) return <WidgetEmptyState />;
const { data } = result;
const title = options.title.trim().length > 0 ? options.title : options.domain;
@@ -8,6 +8,7 @@ import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { WidgetEmptyState } from "../common/empty-state";
import { AveragesSection } from "./averages";
import { mergeStats } from "./helpers";
import { LatestResultSection } from "./latest-result";
@@ -86,6 +87,7 @@ export default function SpeedtestTrackerWidget({
{t("noSectionsEnabled")}
</Text>
)}
{!noSectionsEnabled && !hasStatSection && !hasChart && <WidgetEmptyState />}
</Stack>
);
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
function round(value: number) {
@@ -26,7 +27,7 @@ export default function StockPriceWidget({ options, width, height }: WidgetCompo
const theme = useMantineTheme();
const { data: result } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options);
if (!result) return null;
if (!result) return <WidgetEmptyState />;
const { data } = result;
const stockValuesChange = round(calculateChange(data.priceHistory.at(-1) ?? 0, data.previousClose));
@@ -7,6 +7,7 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { humanFileSize } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
@@ -49,7 +50,7 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
const lastItem = data.at(-1);
if (!lastItem) return null;
if (!lastItem) return <WidgetEmptyState />;
const { fileSystem, smart } = lastItem.healthInfo;
if (fileSystem.length === 0) {
@@ -2,7 +2,7 @@
import type { ReactNode } from "react";
import type { AreaChartSeries } from "@mantine/charts";
import { AreaChart, LineChart } from "@mantine/charts";
import { Card, Center, Group, Loader, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
import { Card, Center, Group, Stack, Text, useComputedColorScheme, useMantineTheme } from "@mantine/core";
import { useElementSize, useHover, useMergedRef } from "@mantine/hooks";
import type { TooltipProps, YAxisProps } from "recharts";
@@ -85,8 +85,13 @@ export const CommonChart = ({
)}
{data.length <= 1 ? (
<Center pos="absolute" w="100%" h="100%">
<Stack px={"xs"} align={"center"}>
<Loader type="bars" size={height > 100 ? "md" : "xs"} color={"rgba(94, 94, 94, 1)"} />
<Stack px={"xs"} align={"center"} gap={4}>
{showIcon && <Icon color={"var(--mantine-color-dimmed)"} size={height > 100 ? 20 : 14} stroke={1.5} />}
{showText && (
<Text c={"dimmed"} size={height > 100 ? "md" : "xs"} fw={"bold"} ta="center">
{title}
</Text>
)}
</Stack>
</Center>
) : (
+2 -1
View File
@@ -10,6 +10,7 @@ import { clientApi } from "@homarr/api/client";
import { metricToImperial } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { WeatherDescription, WeatherIcon } from "./icon";
@@ -29,7 +30,7 @@ export default function WeatherWidget({ isEditMode, options }: WidgetComponentPr
onData: (data) => utils.widget.weather.atLocation.setData(input, data),
});
if (!weather) return null;
if (!weather) return <WidgetEmptyState />;
return (
<Stack