Also from PR #6582. An iframe is an entire browsing context, so one scrolled off
a tall board is the single most expensive thing a widget can load eagerly - and
this one had no loading attribute at all.
Records what else was mined from that PR and declined, notably its dynamic()
for BoardItemMenu: that component already early-returns null outside edit mode,
so there is no per-item overlay cost, and dynamic() fetches its chunk when the
component renders rather than when it returns something - so the chunk would
still load on every board render. It only pays off with the isEditMode guard
hoisted to the call site, which is a board-rendering change worth measuring
first rather than adopting on faith.
From reading PR #6582, which adds keepMounted={false} to the two coolify
accordions. The widget sweep had checked Tabs and never checked Accordion, so it
missed this on all five sites - an honest gap that PR closed for two of them.
Accordion behaves exactly like Tabs, verified in the installed version rather
than assumed: with three marker children per panel, the default renders 3 of 3
children of the *collapsed* panel and keepMounted={false} renders 0.
It matters more here than in Tabs, because collapsed is an accordion section's
normal state rather than the exception:
coolify instance-card three remote-data lists: servers, applications,
services - all built while collapsed
coolify single-instance-layout same three lists
health-monitoring cluster per-node resource tables (beyond #6582)
firewall interface rows (beyond #6582)
Left mounted: the multiReleasesRepositories input, which holds selection state
and is edit-modal only rather than on the board render path.
In every fixed case the open/closed state lives in the parent, so unmounting a
collapsed panel discards nothing.
The spec is renamed tabs-keep-mounted -> keep-mounted since it now pins both
components, and three verdicts in the audit table are corrected: coolify was
recorded "clean (bounded)" and firewall triaged on other grounds, while both were
carrying this cost.
health-monitoring and media-missing pass keepMounted={false} because Mantine
otherwise builds the inactive panel's whole subtree. That came from reading
Mantine's docs, which is a weak basis for a prop that trades a remount cost for a
mount saving: if Mantine ever stopped rendering inactive panels, the prop would be
pure loss and nobody would notice.
Verified in the installed version instead. With three marker children per panel,
the default renders 3 of 3 children of the *inactive* panel into the DOM, and
keepMounted={false} renders 0. If that changes, this fails and the prop should
come back out.
Also records two things checked while re-reading the audit:
_inputs/ is excluded from the widget sweep on purpose, not by oversight - its
2,748 lines are option editors reached only from the edit modal and the
custom-widget form, and the one widget importing from it uses `import type`,
which is erased at build.
The "statically analyzable dynamic imports" rule has exactly one exception,
translation/src/mapping.ts building import(`./lang/${language}.json`) over 36
languages. It stands: the rule targets accidental over-inclusion, and every
language is genuinely reachable since the user can switch at runtime. Server-side
message loading is already memoised per locale, so it is not per-request either.
Seven Mantine entry points were missing from optimizePackageImports while
core/hooks/icons-react were listed. Added all seven, rebuilt, and the client
bundle came out byte-for-byte identical: 482 files, 18050.7 KiB, same largest
chunk, same chunk hashes.
Identical output down to the hashes normally means a cached build, and
turbopackFileSystemCacheForBuild is enabled here, so it got checked rather than
written up as a null result. The Next.js docs are explicit: "This is not needed
when using Turbopack. Turbopack automatically analyzes and optimizes imports
without requiring this configuration." The build banner confirms Turbopack.
So the change is reverted and the existing three-entry list documented as dead
config, kept only as a webpack fallback. This also retires the sub-question of
whether @homarr/ui - the largest barrel at 111 importers - could be optimised
this way: it cannot, and it does not need to be.
Two notes for anyone revisiting: Next 16 with Turbopack prints no size columns in
its route table and emits no app-build-manifest.json, so per-route first-load JS
is unavailable from build output; these numbers are total bytes under
.next/static/chunks.
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.
Completes the sweep: 11 fixed, 8 triaged, 10 read and found clean, 21 verified
bounded. Each verdict records the evidence behind it, because "I read this" and
"its list cannot get long" are different strengths of claim and should not look
alike in a table.
The four widgets the ranking flagged as most likely to hide something all came
back clean, and three were already bounded before I looked - traefik slices to
what its width shows, umami to its own limit option, beszel-alerts to
options.maxHistoryItems. system-resources looked worst of all with 14 unmemoised
maps, until it turned out none of the six chart components it feeds is memoised
either, so a stable array identity would preserve nothing.
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.