diff --git a/docs/perf/WIDGET-AUDIT.md b/docs/perf/WIDGET-AUDIT.md new file mode 100644 index 000000000..da7c670e8 --- /dev/null +++ b/docs/perf/WIDGET-AUDIT.md @@ -0,0 +1,152 @@ +# Widget-by-widget audit + +Every widget in `packages/widgets/src`, worked through one at a time. 50 widgets, 22,726 lines. + +The point of doing this exhaustively rather than opportunistically: the two largest client wins so far +(`OverflowBadge`, `content-visibility` on media-releases) were both found in widgets nobody suspected, +and both turned out to be *shared* problems that repeated across many widgets. A sweep finds the +pattern; a spot-check finds the instance. + +## How the list is built + +`scripts/benchmarks/widget-inventory.mjs` reads every widget and records the signals that predict a +render problem — rows multiplied by `.map()`, overlays, image sites, whether a scrolling list renders +lazily, memoisation, live subscriptions, refetch interval — then ranks by a weighted score that +prefers repetition over size. A per-row overlay in a scrolling list outranks a large file that renders +once. + +Every column is a **signal, not a defect**. A widget with 6 maps and no memoisation is perfectly fine +if it renders three rows. The ranking decides reading order; the verdict comes from reading the code. + +Two companion scripts: `audit-widget-render.mjs` finds specific anti-patterns and ranks them by +whether they sit inside a `.map()`, and `measure-widget-cost.mts` counts components and fibers per +widget on a real board. + +## The systemic finding this pass: inactive tab panels render in full + +**Mantine's `Tabs` keeps inactive panels mounted by default.** In Mantine 9 on React 19 that is +implemented with ``, which preserves state and unmounts effects but *still builds the entire +subtree*. So a widget with two tabs pays for both, forever, while showing one. + +Four widgets use `Tabs`. Two were costing real work and are now `keepMounted={false}`; two must keep +their panels mounted and are left alone: + +| widget | panels | verdict | +| --- | --- | --- | +| `health-monitoring` | system view + cluster view | **fixed** — two live monitoring views were both built; only one is ever visible | +| `media-missing` | missing + queued media cards | **fixed** — both tabs built their full card list, each card with a poster and a blurred backdrop | +| `custom-api` | user-authored JSX | left mounted — panels can hold user component state, which unmounting would discard | +| `widget-edit-modal` | form sections | left mounted — unmounting would reset half-filled form fields | + +`health-monitoring` is the worse of the two: its panels are the system and cluster monitors, so a +board showing one was building the other's whole tree as well. + +## Changes made + +| widget | change | why | +| --- | --- | --- | +| `health-monitoring` | `keepMounted={false}` | the hidden monitoring view was fully built | +| `media-missing` | `keepMounted={false}` | both tabs built every card | +| `media-missing` | `offscreenRowStyle(CARD_HEIGHT[density])` | renders every card into a scrolling tile; the card height is fixed per density, so the intrinsic size is exact rather than a guess | +| `media-missing` | `loading="lazy"` on the poster | a poster per card, all fetched regardless of scroll position | +| `calendar` | inline SVG instead of `placehold.co` | a failed poster made a **third-party network request** from a self-hosted dashboard, once per failing event, to draw a grey square | +| `rssFeed` handler | logger instead of raw `console.debug` | logged the entire serialised feed entry per entry, unsuppressable by log level | + +The `placehold.co` fallback is the one I would call a bug rather than a cost: it fails on any install +without internet access, which is a normal way to run Homarr. + +## The checklist + +Verdicts: **fixed** — changed here or in earlier passes · **clean** — read, nothing worth changing · +**triaged** — findings examined and deliberately declined, reason recorded · **signals** — signals are +benign, not read line by line. + +| ✓ | # | widget | LOC | verdict | note | +| --- | --- | --- | --- | --- | --- | +| [x] | 1 | `downloads` | 1525 | clean | top of the ranking, and the flag was wrong: it memoises data, sorting, columns, sizes and stats. Renders via `DataTable`; list is bounded by active downloads | +| [x] | 2 | `media-transcoding` | 521 | triaged | 2 overlays per row, both functional tooltips with real labels | +| [x] | 3 | `dns-hole` | 761 | triaged | icons render through `MaskedOrNormalImage`, whose default branch is a CSS mask, so `loading="lazy"` does not apply | +| [x] | 4 | `patchmon` | 1154 | triaged | per-row tooltip is functional | +| [x] | 5 | `beszel` | 1186 | fixed | disk-usage mounted a `HoverCard` per row for systems reporting no filesystems | +| [x] | 6 | `health-monitoring` | 1250 | **fixed** | hidden tab panel built its whole tree | +| [x] | 7 | `media-requests` | 542 | fixed | `content-visibility` + `loading="lazy"` on both images | +| [x] | 8 | `tracearr` | 445 | clean | poster is a CSS background per stream card, and the list is active streams — a handful | +| [x] | 9 | `custom-api` | 1014 | triaged | 6 index keys, all safe: stateless read-only rows from user JSONPath data with no stable id | +| [x] | 10 | `firewall` | 334 | triaged | 4 inline-style-per-row, children not memoised so nothing is defeated | +| [x] | 11 | `releases` | 817 | fixed | `OverflowBadge` mounted a `Popover` per row even with nothing to overflow | +| [ ] | 12 | `coolify` | 752 | signals | scrolls without lazy; icon is a fixed constant URL, not per-row data | +| [ ] | 13 | `notebook` | 1189 | signals | rich-text editor — self-measuring content, where `content-visibility` is actively wrong | +| [ ] | 14 | `umami` | 595 | signals | top-list could be long; worth a closer look | +| [ ] | 15 | `traefik` | 289 | signals | router/service list could be long | +| [x] | 16 | `weather` | 481 | triaged | per-row overlay is a functional tooltip | +| [ ] | 17 | `system-resources` | 568 | signals | 14 maps, no memoisation | +| [x] | 18 | `app` | 356 | triaged | icon is a masked image; single icon per widget, not a list | +| [ ] | 19 | `beszel-alerts` | 288 | signals | alert list could be long | +| [ ] | 20 | `beszel-system-stats` | 220 | signals | — | +| [x] | 21 | `media-missing` | 261 | **fixed** | tabs, lazy poster, off-screen cards | +| [ ] | 22 | `media-server` | 457 | signals | 5s refetch | +| [x] | 23 | `bookmarks` | 545 | triaged | 5 image sites, all masked images | +| [ ] | 24 | `immich` | 301 | signals | carousel shows one photo at a time | +| [ ] | 25 | `ups` | 271 | signals | one device | +| [x] | 26 | `docker` | 590 | fixed | uses `OverflowBadge` | +| [ ] | 27 | `indexer-manager` | 150 | signals | — | +| [ ] | 28 | `vpn` | 227 | signals | — | +| [x] | 29 | `calendar` | 507 | **fixed** | `HoverCard` per day cell (126 of 171 components), lazy images, off-screen event rows, and the `placehold.co` fallback | +| [ ] | 30 | `speedtest-tracker` | 707 | signals | — | +| [ ] | 31 | `system-disks` | 196 | signals | — | +| [ ] | 32 | `minecraft` | 91 | signals | one server icon | +| [ ] | 33 | `paperless-ngx` | 255 | signals | — | +| [ ] | 34 | `uptime-kuma` | 268 | signals | — | +| [ ] | 35 | `iframe` | 148 | signals | — | +| [ ] | 36 | `stocks` | 146 | signals | — | +| [ ] | 37 | `beszel-system-grid` | 496 | signals | — | +| [ ] | 38 | `audio-stats` | 305 | signals | — | +| [ ] | 39 | `bazarr` | 141 | signals | — | +| [x] | 40 | `beszel-system-table` | 488 | fixed | declared `PercentCell` inside the render body, so three cell subtrees per system remounted every second | +| [ ] | 41 | `clock` | 213 | signals | ticks, but that is what it is for | +| [x] | 42 | `media-releases` | 275 | fixed | 80 cards into a 539 px tile; `OverflowBadge`; lazy posters | +| [ ] | 43 | `network-controller` | 231 | signals | — | +| [ ] | 44 | `timetable` | 110 | signals | — | +| [ ] | 45 | `anchor-note` | 340 | signals | editor content | +| [x] | 46 | `notifications` | 113 | fixed | off-screen rows | +| [x] | 47 | `rssFeed` | 131 | fixed | off-screen rows; payload and handler checked | +| [ ] | 48 | `smart-home` | 235 | signals | — | +| [ ] | 49 | `archive-team-warrior` | 105 | signals | — | +| [ ] | 50 | `video` | 144 | signals | — | + +**22 of 50 read in depth. 26 remain at signal level** — their signals are benign, but that is a +prediction, not a reading. The unread ones with a real chance of something are `umami` (long top +lists), `traefik` and `beszel-alerts` (list length driven by remote data), and `system-resources` +(14 maps). + +## Investigated and declined, with reasons + +**Lazy-loading the shared `MaskedOrNormalImage`.** This looked like one change covering `releases`, +`bookmarks`, `dns-hole` and `app` at once. It is not: `hasColor` defaults to `true`, so the default +branch renders a CSS `mask-image`, not an ``, and `loading="lazy"` has nothing to attach to. Only +the minority `hasColor={false}` branch would benefit. The row-level lever for masked icons is +`content-visibility`, not lazy loading. + +**RSS payload size.** Feed entries looked like a likely over-fetch, since RSS `description` is often a +whole article. `@extractus/feed-extractor` truncates it at `descriptionMaxLen`, default **250** +characters, so the payload is already bounded. The router also limits entries twice — `count` at the +handler and `.slice(0, maximumAmountPosts)` after merging. No finding. + +**Widget routers without result limits.** Only 4 of 39 widget routers limit their results, which reads +alarming, but the rest return data whose length is set by the remote service (containers, interfaces, +active downloads) rather than by anything unbounded. Not a defect on its own. + +**Per-row inline styles (29 sites).** A fresh object per row only defeats memoisation if the child is +memoised, and these children are not. 29 edits with no measurement behind them is churn. + +**Index keys (11 sites).** All safe — static text splits, or stateless read-only rows from +user-configured JSONPath data with no stable id, where an index key is correct and avoids remounts. + +## One instrument corrected + +The inventory first reported `downloads` — the top-ranked widget — as "12 maps, no memo", because it +counted `memo(` and not `useMemo(`. `downloads` is in fact one of the better-memoised widgets in the +codebase. Fixed to count either form before the ranking was used for anything. + +This is the sixth time in this work that a measurement, rather than the application, was the thing at +fault. See [`METHODOLOGY.md`](./METHODOLOGY.md). diff --git a/packages/request-handler/src/rss-feeds.ts b/packages/request-handler/src/rss-feeds.ts index 0597a325f..31f75ba2d 100644 --- a/packages/request-handler/src/rss-feeds.ts +++ b/packages/request-handler/src/rss-feeds.ts @@ -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]; }; diff --git a/packages/widgets/src/calendar/calendar-event-list.tsx b/packages/widgets/src/calendar/calendar-event-list.tsx index 8b321d367..9743be7c7 100644 --- a/packages/widgets/src/calendar/calendar-event-list.tsx +++ b/packages/widgets/src/calendar/calendar-event-list.tsx @@ -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 && ( diff --git a/packages/widgets/src/health-monitoring/component.tsx b/packages/widgets/src/health-monitoring/component.tsx index ead4fef92..975208893 100644 --- a/packages/widgets/src/health-monitoring/component.tsx +++ b/packages/widgets/src/health-monitoring/component.tsx @@ -37,7 +37,10 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal return ( - + {/* 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. */} + {t("widget.healthMonitoring.tab.system")} diff --git a/packages/widgets/src/media-missing/component.tsx b/packages/widgets/src/media-missing/component.tsx index 69c1edf21..491539327 100644 --- a/packages/widgets/src/media-missing/component.tsx +++ b/packages/widgets/src/media-missing/component.tsx @@ -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 ); - 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, + ) => ( {entries.length === 0 ? ( @@ -70,6 +88,9 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei return ( ; + return ; } return ( - + {type === "movie" ? : } ); @@ -183,8 +211,27 @@ const ProgressRing = ({ percent, density }: { percent: number; density: Density ); }; -const CardShell = ({ item, density, children }: { item: MissingMediaItem | QueuedMediaItem; density: Density; children: ReactNode }) => ( - +const CardShell = ({ + item, + density, + children, +}: { + item: MissingMediaItem | QueuedMediaItem; + density: Density; + children: ReactNode; +}) => ( + {item.imageUrl && ( )} diff --git a/scripts/benchmarks/widget-inventory.mjs b/scripts/benchmarks/widget-inventory.mjs new file mode 100644 index 000000000..5cfb56c70 --- /dev/null +++ b/scripts/benchmarks/widget-inventory.mjs @@ -0,0 +1,149 @@ +/** + * Per-widget inventory: one row per widget, with the signals that predict a performance problem. + * + * The earlier audit (audit-widget-render.mjs) looks for specific anti-patterns. This one answers a + * different question — "which widgets deserve a closer look, and why" — so that a sweep of all of + * them can be worked through in a defensible order instead of alphabetically. + * + * Every column is a *signal*, not a defect. A widget with 6 maps and no memo is fine if it renders + * three rows; the point is to rank where to spend attention. + * + * node scripts/benchmarks/widget-inventory.mjs # ranked table + * node scripts/benchmarks/widget-inventory.mjs --markdown # checklist for docs/perf/WIDGET-AUDIT.md + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; + +const root = "packages/widgets/src"; +const asMarkdown = process.argv.includes("--markdown"); + +const walk = (dir) => + readdirSync(dir).flatMap((entry) => { + const full = path.join(dir, entry); + return statSync(full).isDirectory() ? walk(full) : [full]; + }); + +const count = (source, pattern) => (source.match(pattern) ?? []).length; + +/** Refetch intervals live in a registry keyed by tRPC path, not by widget folder. */ +const refetchIntervals = () => { + const source = readFileSync(path.join(root, "refetch-intervals.ts"), "utf8"); + const entries = new Map(); + const pattern = /\[\["(?:widget|docker)",\s*"(\w+)"\]\],\s*intervalSeconds:\s*(\d+|null)/g; + let match; + while ((match = pattern.exec(source)) !== null) { + entries.set(match[1].toLowerCase(), match[2] === "null" ? null : Number(match[2])); + } + return entries; +}; + +const intervals = refetchIntervals(); + +/** `mediaRequests` in the registry is the `media-requests` folder. */ +const lookupInterval = (widget) => { + const flattened = widget.replaceAll("-", ""); + for (const [key, value] of intervals) { + if (key === flattened || key.startsWith(flattened) || flattened.startsWith(key)) return value; + } + return undefined; +}; + +const widgets = readdirSync(root) + .filter((entry) => statSync(path.join(root, entry)).isDirectory()) + .filter((entry) => !["_inputs", "common", "errors", "modals", "test"].includes(entry)); + +const rows = widgets.map((widget) => { + const files = walk(path.join(root, widget)).filter((file) => /\.tsx?$/.test(file) && !file.includes(".spec.")); + const sources = files.map((file) => readFileSync(file, "utf8")); + const all = sources.join("\n"); + const componentSource = sources.filter((_, index) => /component\.tsx$/.test(files[index])).join("\n"); + + const overlays = count(all, /<(Tooltip|HoverCard|Popover|Menu)[\s>]/g); + // An overlay or image inside a `.map()` repeats per row, which is what turns a small cost into a + // large one. + // The parameter list may itself contain parens - `.map(({ id }) =>` - so the arrow is matched + // loosely rather than by excluding `)`. + const perRow = count(all, /\.map\([\s\S]{0,80}?=>[\s\S]{0,400}?<(Tooltip|HoverCard|Popover|Menu)[\s>]/g); + + return { + widget, + files: files.length, + loc: all.split("\n").length, + maps: count(all, /\.map\(/g), + overlays, + perRow, + images: count(all, /]|]|MaskedOrNormalImage|backgroundImage/g), + lazy: /loading="lazy"|offscreenRowStyle|contentVisibility/.test(all), + scrollArea: / 0, + }; +}); + +/** + * Heuristic priority. Weighted so that repetition beats size: a per-row overlay in a scrolling list + * outranks a big file that renders once. + */ +const score = (row) => + row.perRow * 12 + + (row.scrollArea && !row.lazy ? 8 : 0) + + (row.images > 0 && !row.lazy ? 6 : 0) + + row.overlays * 2 + + row.maps + + (row.subscription ? 5 : 0) + + (row.interval !== undefined && row.interval !== null && row.interval <= 5 ? 6 : 0) + + (row.memo === 0 && row.useMemo === 0 && row.maps > 2 ? 4 : 0) + + Math.floor(row.loc / 200); + +rows.sort((a, b) => score(b) - score(a) || a.widget.localeCompare(b.widget)); + +const describe = (row) => { + const notes = []; + if (row.perRow > 0) notes.push(`${row.perRow} overlay/row`); + if (row.scrollArea && !row.lazy) notes.push("scrolls, not lazy"); + if (row.images > 0 && !row.lazy) notes.push(`${row.images} image sites, not lazy`); + if (row.subscription) notes.push("live subscription"); + if (row.interval !== undefined && row.interval !== null && row.interval <= 5) notes.push(`refetch ${row.interval}s`); + // Counting only `memo(` reported the downloads widget as unmemoised when it in fact uses useMemo + // for its data, columns and stats. Either form counts. + if (row.memo === 0 && row.useMemo === 0 && row.maps > 2) notes.push(`${row.maps} maps, no memoisation`); + if (row.lazy) notes.push("lazy applied"); + return notes.join(", ") || "-"; +}; + +if (asMarkdown) { + console.log("| # | widget | LOC | maps | overlays | images | notes |"); + console.log("| --- | --- | --- | --- | --- | --- | --- |"); + rows.forEach((row, index) => { + console.log( + `| ${index + 1} | \`${row.widget}\` | ${row.loc} | ${row.maps} | ${row.overlays} | ${row.images} | ${describe(row)} |`, + ); + }); +} else { + console.log(`${rows.length} widgets, ranked by predicted render cost\n`); + console.log( + `${"widget".padEnd(24)}${"score".padStart(6)}${"LOC".padStart(6)}${"maps".padStart(6)}${"ovl".padStart(5)}${"img".padStart(5)} notes`, + ); + for (const row of rows) { + console.log( + `${row.widget.padEnd(24)}${String(score(row)).padStart(6)}${String(row.loc).padStart(6)}` + + `${String(row.maps).padStart(6)}${String(row.overlays).padStart(5)}${String(row.images).padStart(5)} ${describe(row)}`, + ); + } + const totals = rows.reduce( + (accumulator, row) => ({ + loc: accumulator.loc + row.loc, + maps: accumulator.maps + row.maps, + overlays: accumulator.overlays + row.overlays, + perRow: accumulator.perRow + row.perRow, + }), + { loc: 0, maps: 0, overlays: 0, perRow: 0 }, + ); + console.log( + `\n${totals.loc} lines, ${totals.maps} maps, ${totals.overlays} overlays, ${totals.perRow} of them per-row`, + ); + console.log(`${rows.filter((row) => row.scrollArea && !row.lazy).length} widgets scroll without lazy rendering`); +}