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:
6 files changed
+380
-20
No files matched your search
@@ -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 `<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: **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 `<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.
|
||||
|
||||
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).
|
||||
Reference in new issue
Block a user