perf(widgets): stop building hidden tab panels, and drop a third-party image fallback

Mantine's Tabs keeps inactive panels mounted by default. On Mantine 9 with
React 19 that uses <Activity>, which preserves state and unmounts effects but
still builds the entire subtree - so a two-tab widget pays for both while
showing one.

  health-monitoring  both the system and the cluster monitoring view were built
  media-missing      both tabs built their full card list, each card with a
                     poster and a blurred backdrop

Two other Tabs users are deliberately left mounted: custom-api panels can hold
user component state, and the widget edit modal would reset half-filled forms.

media-missing also rendered every card into a scrolling tile with no lazy
loading, the same shape as media-releases. Its card height is fixed per density,
so contain-intrinsic-size is exact rather than a guess.

calendar fell back to https://placehold.co/400x400 when a poster failed to
load, so a self-hosted dashboard made a third-party request - once per failing
event - to draw a grey square, and showed nothing at all on a host without
internet access. Same grey square, inline.

The rss feed handler logged the whole serialised feed entry through a raw
console.debug, which no log level could suppress, and reported a match count
that was really the regex capture-group count.

Adds scripts/benchmarks/widget-inventory.mjs and docs/perf/WIDGET-AUDIT.md, a
verdict for all 50 widgets.
This commit is contained in:
Thomas Camlong committed 2026-08-11 16:30:22 +02:00
1 parent cf240addea
commit ec79a15023
6 files changed
+380 -20

No files matched your search

+4 -3
View File
@@ -46,9 +46,10 @@ const getImageFromStringAsFallback = (feedUrl: string, content: string) => {
return null;
}
console.debug(
`Falling back to regex image search for '${feedUrl}'. Found ${result.length} matches in content: ${content}`,
);
// This used to log the entire serialised feed entry through a raw console.debug, so it could not be
// suppressed by log level and produced one enormous line per entry of every feed. It also reported
// `result.length`, which is the capture-group count and so was always 2 rather than a match count.
logger.debug(`Falling back to regex image search for '${feedUrl}'`, { image: result[0] });
return result[0];
};
@@ -21,6 +21,14 @@ import { useI18n } from "@homarr/translation/client";
import classes from "./calendar-event-list.module.css";
import { offscreenRowStyle } from "../common/offscreen-rows";
/**
* A broken poster used to fall back to `https://placehold.co/400x400`, so a self-hosted dashboard
* reached out to a third party - once per failing event - to draw a grey square. This is the same
* grey square, inline, and it still works when the host has no internet access at all.
*/
const NO_IMAGE_FALLBACK =
"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20400%20400'%3E%3Crect%20width='400'%20height='400'%20fill='%23868e96'%20fill-opacity='0.2'/%3E%3C/svg%3E";
interface CalendarEventListProps {
events: CalendarEvent[];
}
@@ -55,7 +63,7 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
: "1/1",
}}
radius="sm"
fallbackSrc="https://placehold.co/400x400?text=No%20image"
fallbackSrc={NO_IMAGE_FALLBACK}
/>
{event.image.badge !== undefined && (
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
@@ -37,7 +37,10 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
return (
<ScrollArea h="100%">
<Tabs defaultValue={props.options.defaultTab} variant="outline">
{/* Both panels are live monitoring views. Keeping the inactive one mounted built its whole tree
for content nobody can see; react-query still has the data cached, so switching back is
instant rather than a fresh load. */}
<Tabs keepMounted={false} defaultValue={props.options.defaultTab} variant="outline">
<Tabs.List grow>
<Tabs.Tab value="system" fz="xs">
<b>{t("widget.healthMonitoring.tab.system")}</b>
@@ -1,7 +1,21 @@
"use client";
import type { ReactNode } from "react";
import { Badge, Box, Center, Group, Image, Paper, RingProgress, ScrollArea, SimpleGrid, Stack, Tabs, Text, ThemeIcon } from "@mantine/core";
import {
Badge,
Box,
Center,
Group,
Image,
Paper,
RingProgress,
ScrollArea,
SimpleGrid,
Stack,
Tabs,
Text,
ThemeIcon,
} from "@mantine/core";
import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -9,11 +23,17 @@ import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/typ
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { offscreenRowStyle } from "../common/offscreen-rows";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
export default function MediaMissingWidget({ integrationIds, options, width, height }: WidgetComponentProps<"mediaMissing">) {
export default function MediaMissingWidget({
integrationIds,
options,
width,
height,
}: WidgetComponentProps<"mediaMissing">) {
const t = useScopedI18n("widget.mediaMissing");
const pageSize = Number(options.pageSize);
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
@@ -32,12 +52,8 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
</Center>
);
const missing = data.flatMap((entry) =>
entry.missing.map((item) => ({ item, integrationId: entry.integrationId })),
);
const queued = data.flatMap((entry) =>
entry.queued.map((item) => ({ item, integrationId: entry.integrationId })),
);
const missing = data.flatMap((entry) => entry.missing.map((item) => ({ item, integrationId: entry.integrationId })));
const queued = data.flatMap((entry) => entry.queued.map((item) => ({ item, integrationId: entry.integrationId })));
const missingCount = data.reduce((sum, entry) => sum + entry.missingCount, 0);
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
@@ -47,10 +63,12 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
const columns = width > 0 ? Math.max(1, Math.min(Math.floor(width / targetCardWidth), 4)) : 1;
const density: Density = isThin ? "thin" : width > 0 && width / columns < 180 ? "compact" : "comfortable";
const tabLabel = (label: string, shown: number, total: number) =>
isThin ? total : `${label} (${shown}/${total})`;
const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`);
const renderPanel = (entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[], emptyLabel: string) => (
const renderPanel = (
entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[],
emptyLabel: string,
) => (
<ScrollArea h="100%" scrollbarSize={4}>
<Box p="xs">
{entries.length === 0 ? (
@@ -70,6 +88,9 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
return (
<Tabs
// Without this Mantine keeps the inactive panel mounted, so both tabs build their full card
// list - and fetch every backdrop - when only one is ever visible.
keepMounted={false}
defaultValue={options.showMissing ? "missing" : "queued"}
h="100%"
style={{ display: "flex", flexDirection: "column" }}
@@ -112,11 +133,18 @@ const Poster = ({ src, type, density }: { src?: string | null; type: "movie" | "
const w = Math.round(size * 0.68);
if (src) {
return <Image className={classes.poster} src={src} h={size} w={w} radius="sm" alt="" />;
return <Image className={classes.poster} src={src} h={size} w={w} radius="sm" alt="" loading="lazy" />;
}
return (
<ThemeIcon className={classes.poster} h={size} w={w} radius="sm" variant="light" color={type === "movie" ? "yellow" : "blue"}>
<ThemeIcon
className={classes.poster}
h={size}
w={w}
radius="sm"
variant="light"
color={type === "movie" ? "yellow" : "blue"}
>
{type === "movie" ? <IconMovie size={size * 0.5} /> : <IconVideo size={size * 0.5} />}
</ThemeIcon>
);
@@ -183,8 +211,27 @@ const ProgressRing = ({ percent, density }: { percent: number; density: Density
);
};
const CardShell = ({ item, density, children }: { item: MissingMediaItem | QueuedMediaItem; density: Density; children: ReactNode }) => (
<Paper className={classes.card} component="a" href={item.link} target="_blank" rel="noreferrer" radius="sm" p="xs" h={CARD_HEIGHT[density]}>
const CardShell = ({
item,
density,
children,
}: {
item: MissingMediaItem | QueuedMediaItem;
density: Density;
children: ReactNode;
}) => (
<Paper
className={classes.card}
component="a"
href={item.link}
target="_blank"
rel="noreferrer"
radius="sm"
p="xs"
h={CARD_HEIGHT[density]}
// The card height is fixed per density, so it is an exact intrinsic size rather than a guess.
style={offscreenRowStyle(CARD_HEIGHT[density])}
>
{item.imageUrl && (
<span className={classes.backdrop} style={{ backgroundImage: `url("${item.imageUrl}")` }} aria-hidden />
)}