The off-screen work was committed unmeasured; this closes that debt. Measured on two
containers restored from identical data:
image requests 138, 135 -> 22, 22 (-84%)
image bytes 12.27 MiB -> 0.03 MiB
layout objects 11611, 7836 -> 3694, 3705 (-53%)
A 99.8% drop in image bytes is not believable, so it was checked instead of reported. Two
checks were bad instruments before one worked:
- Counting decoded images via getBoundingClientRect() reported 80/80 loaded in BOTH arms.
Reading geometry forces layout, which forces content-visibility subtrees to render, which
triggers the very image loads being tested for. The instrument caused the result.
- Screenshots came back byte-identical in both arms, because this environment shows an
onboarding overlay and media-requests renders blank with unreachable integrations. Good
evidence of no visual difference; useless as evidence that images render.
- Counting img.complete && naturalWidth > 0 reads no geometry and gave the answer: 80/80
decoded in both arms, 138 responses before against 25 after.
So nothing is visually broken. The 80 imgs are integration icons sharing URLs, deduplicated to
~25 unique fetches in both arms; the ~113 avoided requests are other widgets' off-screen
artwork, chiefly media-requests' full-size per-card backdrops, which is also where the 12 MiB
came from.
Stated limit: media-requests renders blank in this environment, so its visible-card behaviour
after the change is unverified. Media-releases is verified at 80/80 decoded.
137 lines
8.0 KiB
Markdown
137 lines
8.0 KiB
Markdown
# Dashboard performance in Chrome
|
||
|
||
Measured on the busiest board of a restored real backup: 28 items, 16 widget kinds.
|
||
|
||
## A systemic Mantine cost, found in five places
|
||
|
||
**Mantine overlay components mount their full machinery regardless of `disabled`.** A `Tooltip`,
|
||
`HoverCard` or `Popover` that can never open still creates a `Popover`, a `PopoverTarget` and a
|
||
`PopoverDropdown`. `disabled` suppresses *display*, not *cost* — so avoiding it means deciding before
|
||
rendering, not passing a prop.
|
||
|
||
That single fact accounts for five separate findings, three of them measured:
|
||
|
||
| widget | before | after |
|
||
| --- | --- | --- |
|
||
| **mediaReleases** | 340 components / 8,387 fibers | **170 / 8,057** (−50% components) |
|
||
| **calendar** | 171 components / 1,195 fibers | **75 / 907** (−56% components, −24% fibers) |
|
||
| **beszelSystemTable** | 36 components / 1,286 fibers | **18 / 1,230** (−50% components) |
|
||
| downloads, mediaRequests, app | unchanged | unchanged — their overlays are functional |
|
||
|
||
- **calendar** mounted a `HoverCard` per day cell. A month grid is ~42 cells and `disabled` is
|
||
`isEditMode || eventsForDate.length === 0`, so most days — and in edit mode *every* day — mounted
|
||
three components for a card that can never open. 126 of its 171 components, in a widget with only
|
||
251 DOM nodes.
|
||
- **OverflowBadge** (shared UI in `packages/ui`) mounted a `Popover` and `Popover.Dropdown` even when
|
||
nothing overflowed, which is the common case. Used by media-releases, releases, the docker table and
|
||
the widget inputs, so it repeated per row wherever a list renders it. This is where the entire
|
||
media-releases win came from.
|
||
- **beszel disk-usage** mounted a `HoverCard` per table row for systems reporting no filesystems —
|
||
it already fell back to a plain bar inside its own target, so the overlay existed to render nothing.
|
||
|
||
### Two remount bugs
|
||
|
||
A component declared inside a render body is a **new type every render**, so React unmounts and
|
||
remounts its subtree instead of updating it.
|
||
|
||
- `beszelSystemTable` declared `PercentCell` inline and used it for three columns, while the widget
|
||
re-renders ~1×/second from its live subscription — three cell subtrees per system rebuilt every
|
||
second. Hoisted and memoised.
|
||
- A drag handle was passed to a child as `handle={Handle}` while its parent re-renders every frame of
|
||
a drag, so the handle was rebuilt continuously mid-drag. Stabilised with `useMemo`.
|
||
|
||
### The audit that found them
|
||
|
||
`scripts/benchmarks/audit-widget-render.mjs` scans every widget and ranks findings by whether they sit
|
||
inside a `.map()`, since those multiply by row count. All 54 findings were triaged:
|
||
|
||
| count | pattern | outcome |
|
||
| --- | --- | --- |
|
||
| 12 | overlay-per-row | **5 fixed**; the other 7 are functional tooltips with real labels — nothing is disabled to skip, and swapping them for native `title` would change UX, not just cost |
|
||
| 11 | index-key | **all safe.** Either static text splits (`description.split("\n")`) or stateless read-only rows from user-configured JSONPath data with no stable id, where an index key is correct and avoids remounts |
|
||
| 2 | nested-component | **both fixed** |
|
||
| 29 | inline-style-per-row | **left alone.** A fresh object per row only defeats memoisation if the child is memoised, and these are not. 29 edits without a measurement is churn |
|
||
|
||
### Off-screen rendering
|
||
|
||
`mediaReleases` rendered **80 cards into a 539 px tile whose content is 7,206 px** — 7 visible, 73
|
||
off-screen, each still fetching and decoding a poster and a full-tile backdrop. Decoded bitmaps are
|
||
far larger in memory than their files, which is what made this expensive.
|
||
|
||
Fixed with `content-visibility: auto` + `contain-intrinsic-size`, plus `loading="lazy"`. Three repeats
|
||
per side:
|
||
|
||
| metric | before | after |
|
||
| --- | --- | --- |
|
||
| image requests | 135, 135, 135 | **55, 55, 56** (−59%) |
|
||
| image bytes | 12.27 MiB | **8.41 MiB** (−31%) |
|
||
| layout objects | 8346, 7815, 7815 | **5117, 4696, 4636** (−41%) |
|
||
|
||
Extracted to `offscreenRowStyle()` and applied to the other data-driven lists (rssFeed, notifications,
|
||
calendar events, media-requests). Applied to 5 of 26 `ScrollArea` widgets deliberately:
|
||
`content-visibility` costs containment work and can be wrong on self-measuring content like charts.
|
||
|
||
### Two metrics that cannot be used, and one claim withdrawn
|
||
|
||
**Chrome's board-level `JSEventListeners` is unusable here.** Across six runs of identical code it read
|
||
7905, 8000, 8166, 8179, 10360 and 17114. Its `Nodes` counter has the same problem — 23,072 on one run
|
||
against ~8,850 on the others — because it counts detached nodes awaiting collection, while
|
||
"elements under body" stayed at 6,691–6,726 throughout. Only per-widget component and fiber counts are
|
||
stable enough to compare, so those are what is reported above.
|
||
|
||
**The media-releases tooltip fix measured zero**, and is reported as such. Every release on the test
|
||
board has a description and the option is on, so all 80 tooltips are legitimately enabled and none are
|
||
skipped. The mechanism is correct; this board is not the case it helps. The −50% for that widget came
|
||
entirely from OverflowBadge.
|
||
|
||
### Idle churn is legitimate, and an earlier claim here was wrong
|
||
|
||
This description previously said ~80% of idle React commits entered at Next's `HeadManagerContext` and
|
||
that the App Router re-rendered once a second. **Both were false.** A 3-item board commits **0.0**
|
||
times per minute against 76.0 for the 28-item board — if the router re-rendered on a timer, every board
|
||
would churn. The original attribution was a tooling artefact: `fiber.actualDuration` is only populated
|
||
by a React *profiling* build, so on production every fiber fails the "did it re-render" test and the
|
||
tables come back empty, which reads as "nothing" rather than "this build cannot tell you".
|
||
|
||
Attributed properly, three Beszel widgets are 82% of idle churn — but the real rate is **1.22 commits
|
||
per second** for the whole board at **13.3 DOM mutations per commit**. That is three live-monitoring
|
||
widgets refreshing about once a second, which is what they are for. A coalescing fix measured *exactly
|
||
zero* (55 commits both sides) and was reverted.
|
||
|
||
|
||
## Verification of the off-screen changes, and one instrument that lied
|
||
|
||
The off-screen work was committed before it was measured. Verified afterwards on two containers
|
||
restored from identical data, `maxopt` against `client3`:
|
||
|
||
| metric | before | after |
|
||
| --- | --- | --- |
|
||
| image requests | 138, 135 | **22, 22** (−84%) |
|
||
| image bytes (declared) | 12.27 MiB | **0.03 MiB** |
|
||
| layout objects | 11611, 7836 | **3694, 3705** (−53%) |
|
||
|
||
**A −99.8% drop in image bytes is not believable, so it was checked rather than reported.** Three
|
||
checks, and the first two were bad instruments:
|
||
|
||
1. Counting decoded images with `getBoundingClientRect()` reported **80/80 loaded in both arms** — but
|
||
reading geometry forces layout, which forces `content-visibility` subtrees to render, which triggers
|
||
exactly the image loads the check was looking for. The instrument caused the result.
|
||
2. Screenshotting the widgets produced **byte-identical images in both arms** (3,372 bytes for
|
||
media-releases, 1,580 for media-requests), because this environment shows an onboarding overlay and
|
||
media-requests renders blank with unreachable integrations. Useful only as evidence of *no visual
|
||
difference*, not as evidence images render.
|
||
3. Counting `img.complete && img.naturalWidth > 0`, which reads no geometry, gave the answer:
|
||
|
||
| arm | images decoded | image responses |
|
||
| --- | --- | --- |
|
||
| before | **80 / 80** | 138 |
|
||
| after | **80 / 80** | **25** |
|
||
|
||
**Everything still displays.** The 80 `<img>` in media-releases are integration icons that share URLs,
|
||
so the browser deduplicates them to ~25 unique fetches in both arms; the ~113 requests avoided are
|
||
other widgets' off-screen artwork, chiefly media-requests' full-size per-card backdrops. That also
|
||
explains the byte figure: those backdrops are where the 12.27 MiB came from.
|
||
|
||
One limit worth stating: media-requests renders blank here because its integrations are unreachable, so
|
||
its *visible-card* behaviour after the change is not verified. Media-releases is, at 80/80 decoded.
|