Files
homarr/docs/perf/WIDGET-AUDIT.md
T
Thomas Camlong 09df2910c5 docs(perf): check the widgets against Vercel's React/Next performance rules
Sourced with the skills CLI - npx skills find "react performance", then
npx skills use affaan-m/ecc@react-performance, an adaptation of vercel-labs
react-best-practices: 70+ rules across 8 categories. Every mechanically
checkable rule was run against the widgets.

Seven already hold, and that is worth recording as much as a finding: no widget
registers a scroll listener, no numeric && can render a literal 0, the nested
array scans that exist are over single-digit collections, React.cache() is
applied, TanStack Query does the deduplication, content-visibility is on the six
lists that need it, and the two components-declared-in-render were already found
and fixed.

One rule this codebase deliberately contradicts: the skill prefers React 19
<Activity> over unmounting for tabs, and this pass did the opposite with
keepMounted={false}. The rule optimises for switching; a dashboard optimises for
mounting. Every board load mounts every widget while a user switches a widget's
inner tabs rarely, so building a hidden monitoring view on every page load buys
a faster switch nobody performs. Where state loss would show - custom-api's user
JSX, the edit modal's forms - the panels stay mounted.

Two candidates declined rather than guessed at, both recorded with reasoning:
the 20+ `data = []` defaults only destabilise while data is undefined, since
react-query returns a stable reference once loaded; and the 7 unlisted Mantine
entry points need two production builds and a first-load-JS diff before anyone
claims they help. @homarr/ui is not eligible at all despite 111 importers,
because Next's barrel optimiser only rewrites pure re-export files.

Also records the process error behind the earlier "no skills CLI" claim: the
entries in ~/.claude/skills are symlinks, which find -type f skips, so the
directory looked empty right after cat had succeeded on a file inside it.
2026-08-11 16:40:27 +02:00

230 lines
17 KiB
Markdown

# 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 `<Activity>`, 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, and the evidence behind each — the distinction matters, because "I read it" and "its list
cannot get long" are different strengths of claim:
- **fixed** — changed here or in an earlier pass
- **clean (read)** — the component was read; nothing worth changing
- **clean (bounded)** — list length verified bounded by a widget option, by viewport width, or by the
remote service (active streams, configured instances, physical disks), *and* the row is cheap:
no image, no overlay. Not a line-by-line read
- **triaged** — a finding was examined and deliberately declined, reason recorded
| ✓ | # | widget | LOC | verdict | note |
| --- | --- | --- | --- | --- | --- |
| [x] | 1 | `downloads` | 1525 | clean (read) | 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 (read) | 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 |
| [x] | 12 | `coolify` | 752 | clean (bounded) | one map over configured instances; the icon is a fixed constant URL, not per-row data |
| [x] | 13 | `notebook` | 1189 | clean (read) | rich-text editor. Deliberately **not** given `content-visibility`: it measures itself, and skipping its layout would be wrong |
| [x] | 14 | `umami` | 595 | clean (read) | the top-list is bounded by the widget's own `limit` option and each row is three `<Text>` — containment would cost more than it saves |
| [x] | 15 | `traefik` | 289 | clean (read) | already bounded: `.slice(0, getEntryPointLimit(width))` |
| [x] | 16 | `weather` | 481 | triaged | per-row overlay is a functional tooltip |
| [x] | 17 | `system-resources` | 568 | clean (read) | the 14 maps build chart data series, not DOM rows, and none of the six chart components is memoised — so memoising the arrays would defeat nothing. Its data changes every tick anyway |
| [x] | 18 | `app` | 356 | triaged | icon is a masked image; single icon per widget, not a list |
| [x] | 19 | `beszel-alerts` | 288 | clean (read) | fully memoised — input, name map, alerts and history — and history is sliced to `options.maxHistoryItems` |
| [x] | 20 | `beszel-system-stats` | 220 | clean (bounded) | one row per configured system |
| [x] | 21 | `media-missing` | 261 | **fixed** | tabs, lazy poster, off-screen cards |
| [x] | 22 | `media-server` | 457 | clean (read) | the flat session list is explicitly memoised on `currentStreams`, and is bounded by active streams |
| [x] | 23 | `bookmarks` | 545 | triaged | 5 image sites, all masked images |
| [x] | 24 | `immich` | 301 | clean (bounded) | carousel shows one photo at a time |
| [x] | 25 | `ups` | 271 | clean (bounded) | one row per UPS device |
| [x] | 26 | `docker` | 590 | fixed | uses `OverflowBadge` |
| [x] | 27 | `indexer-manager` | 150 | clean (bounded) | one row per configured indexer |
| [x] | 28 | `vpn` | 227 | clean (bounded) | one row per tunnel |
| [x] | 29 | `calendar` | 507 | **fixed** | `HoverCard` per day cell (126 of 171 components), lazy images, off-screen event rows, and the `placehold.co` fallback |
| [x] | 30 | `speedtest-tracker` | 707 | clean (bounded) | no list map in the component |
| [x] | 31 | `system-disks` | 196 | clean (bounded) | one row per physical disk |
| [x] | 32 | `minecraft` | 91 | clean (bounded) | one server icon |
| [x] | 33 | `paperless-ngx` | 255 | clean (bounded) | statistic rows, fixed set |
| [x] | 34 | `uptime-kuma` | 268 | clean (bounded) | one row per monitor |
| [x] | 35 | `iframe` | 148 | clean (bounded) | maps are over option lists, not remote data |
| [x] | 36 | `stocks` | 146 | clean (bounded) | one row per configured symbol |
| [x] | 37 | `beszel-system-grid` | 496 | clean (bounded) | already slices its input |
| [x] | 38 | `audio-stats` | 305 | clean (bounded) | no list map in the component |
| [x] | 39 | `bazarr` | 141 | clean (bounded) | one row per configured instance |
| [x] | 40 | `beszel-system-table` | 488 | fixed | declared `PercentCell` inside the render body, so three cell subtrees per system remounted every second |
| [x] | 41 | `clock` | 213 | clean (read) | re-renders once a second by design; no list |
| [x] | 42 | `media-releases` | 275 | fixed | 80 cards into a 539 px tile; `OverflowBadge`; lazy posters |
| [x] | 43 | `network-controller` | 231 | clean (bounded) | one row per device |
| [x] | 44 | `timetable` | 110 | clean (bounded) | one map over the configured schedule |
| [x] | 45 | `anchor-note` | 340 | clean (read) | editor content, no list; self-measuring like `notebook` |
| [x] | 46 | `notifications` | 113 | fixed | off-screen rows |
| [x] | 47 | `rssFeed` | 131 | fixed | off-screen rows; payload and handler checked |
| [x] | 48 | `smart-home` | 235 | clean (bounded) | one entity per widget |
| [x] | 49 | `archive-team-warrior` | 105 | clean (bounded) | no list map |
| [x] | 50 | `video` | 144 | clean (bounded) | a single video element |
**All 50 have a verdict.** 11 fixed, 8 triaged, 10 read and found clean, 21 verified bounded.
The four the ranking suggested were most likely to hide something all came back clean, and three of
them were *already* bounded before I looked: `traefik` slices to what its width can show, `umami` to
its own `limit` option, `beszel-alerts` to `options.maxHistoryItems`. `system-resources` was the
interesting one — its 14 unmemoised maps look bad until you notice none of the six chart components it
feeds is memoised, so there is nothing for a stable array identity to preserve.
The 21 "bounded" verdicts are the weakest claim here. They rest on the list length being fixed by
configuration or by physical reality — disks, tunnels, configured instances — plus a cheap row with no
image and no overlay. That is sound reasoning, not a reading, and a widget whose data source changes
shape could invalidate it.
## Checked against Vercel's React/Next performance rules
Sourced with the `skills` CLI — `npx skills find "react performance"`, then
`npx skills use affaan-m/ecc@react-performance`, which is an adaptation of
[Vercel Labs `react-best-practices`](https://github.com/vercel-labs/agent-skills): 70+ rules across
8 priority categories. Every rule that is mechanically checkable was run against the widgets.
| rule | category | result |
| --- | --- | --- |
| `content-visibility: auto` for long lists | rendering | **applied** — 6 widgets |
| Don't define components inside components | re-render | **2 found, both fixed** (`PercentCell`, drag handle) |
| Ternary over `&&` for conditional render | rendering | **passes** — only 2 `&&` renders exist and both operands are already boolean or string, so nothing can render a literal `0` |
| Passive listeners for scroll | client fetching | **N/A** — no widget registers a scroll listener |
| `Set`/`Map` for membership over `Array.includes` | JS micro-perf | **passes** — the nested scans that exist are over configured integrations and column accessors, single digits either way. `use-persisted-table-layout` already uses a `Set` for the hot half |
| `React.cache()` for per-request dedup | server | **applied** — 4 functions |
| TanStack Query for dedup, not hand-rolled `useEffect` + `fetch` | client fetching | **passes** — used throughout |
| Direct imports, not barrels | bundle | **partial** — see below |
| Hoist default non-primitive props | re-render | **declined** — see below |
| `<Activity>` for show/hide instead of mount/unmount | rendering | **deliberately contradicted** — see below |
### The one rule this codebase deliberately contradicts
The skill says to prefer React 19's `<Activity>` over unmounting for tabs, because it avoids
remount cost. `keepMounted={false}`, which this pass added to two widgets, is exactly the opposite.
That is a considered trade, not an oversight. The rule optimises for **switching**; a dashboard
optimises for **mounting**. Every board load mounts every widget, while a user switches a widget's
inner tabs rarely or never — so paying to build a hidden monitoring view on every single page load, and
holding its DOM for the whole session, buys a faster tab switch nobody performs. Where state loss
would be user-visible — `custom-api`'s user JSX, the edit modal's forms — the panels stay mounted.
The cost is real and worth stating: switching tabs in `health-monitoring` or `media-missing` now
remounts the panel. react-query still has the data cached, so it repaints rather than reloads.
### Bundle: partially satisfied
`optimizePackageImports` already covers the three that matter by volume — `@mantine/core` (727
imports), `@mantine/hooks` (69), `@tabler/icons-react`. Seven smaller Mantine entry points are not
listed: `dates` (13 files), `dropzone` (12), `charts` (10), `spotlight` (7), `form` (4),
`notifications` (3), `tiptap` (1).
`@homarr/ui` is the largest barrel in the repo at 111 importers, and it is **not eligible**: Next's
barrel optimiser only rewrites files that consist purely of re-exports, and `packages/ui/src/index.ts`
also defines a runtime `uiConfiguration` value. Same for the other workspace barrels.
Adding the seven Mantine entry points is a one-line change, but it is left undone rather than guessed
at: it needs two production builds and a first-load-JS comparison, and everything else in this work
that shipped without a measurement was later found to be wrong. Recorded in
[NEXT-STEPS.md](./NEXT-STEPS.md) instead.
### `data = []` defaults: declined, with the reasoning
The rule against fresh non-primitive defaults (`items ?? []` creating a new array identity per render)
matches **20+ sites**, all of the form `const { data = [] } = useQuery(...)`.
It is declined because the window is narrow: react-query returns a stable reference once data has
loaded, so the fresh `[]` only appears while `data` is `undefined` — initial load, error, or no
integration configured. Outside that window the default never evaluates and nothing is destabilised.
20 edits to improve the render path of the loading state, with no measurement, is the same churn as the
29 inline-style sites already declined.
## 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 `<img>`, 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.
That is the sixth time in this work that a measurement, rather than the application, was the thing at
fault — and this pass produced a seventh of a different kind: I reported that the `skills` CLI did not
exist on this machine after checking only `which` and the plugin directory. It does, and it is where
the Vercel rule set above came from. Both are recorded in [`METHODOLOGY.md`](./METHODOLOGY.md).