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:
Thomas Camlong committed 2026-08-11 16:30:22 +02:00
1 parent cf240addea
commit ec79a15023
6 files changed
+380 -20

No files matched your search

+152
View File
@@ -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).
+4 -3
View File
@@ -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];
};
@@ -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 && (
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
@@ -37,7 +37,10 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
return (
<ScrollArea h="100%">
<Tabs defaultValue={props.options.defaultTab} variant="outline">
{/* 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. */}
<Tabs keepMounted={false} defaultValue={props.options.defaultTab} variant="outline">
<Tabs.List grow>
<Tabs.Tab value="system" fz="xs">
<b>{t("widget.healthMonitoring.tab.system")}</b>
@@ -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
</Center>
);
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,
) => (
<ScrollArea h="100%" scrollbarSize={4}>
<Box p="xs">
{entries.length === 0 ? (
@@ -70,6 +88,9 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
return (
<Tabs
// Without this Mantine keeps the inactive panel mounted, so both tabs build their full card
// list - and fetch every backdrop - when only one is ever visible.
keepMounted={false}
defaultValue={options.showMissing ? "missing" : "queued"}
h="100%"
style={{ display: "flex", flexDirection: "column" }}
@@ -112,11 +133,18 @@ const Poster = ({ src, type, density }: { src?: string | null; type: "movie" | "
const w = Math.round(size * 0.68);
if (src) {
return <Image className={classes.poster} src={src} h={size} w={w} radius="sm" alt="" />;
return <Image className={classes.poster} src={src} h={size} w={w} radius="sm" alt="" loading="lazy" />;
}
return (
<ThemeIcon className={classes.poster} h={size} w={w} radius="sm" variant="light" color={type === "movie" ? "yellow" : "blue"}>
<ThemeIcon
className={classes.poster}
h={size}
w={w}
radius="sm"
variant="light"
color={type === "movie" ? "yellow" : "blue"}
>
{type === "movie" ? <IconMovie size={size * 0.5} /> : <IconVideo size={size * 0.5} />}
</ThemeIcon>
);
@@ -183,8 +211,27 @@ const ProgressRing = ({ percent, density }: { percent: number; density: Density
);
};
const CardShell = ({ item, density, children }: { item: MissingMediaItem | QueuedMediaItem; density: Density; children: ReactNode }) => (
<Paper className={classes.card} component="a" href={item.link} target="_blank" rel="noreferrer" radius="sm" p="xs" h={CARD_HEIGHT[density]}>
const CardShell = ({
item,
density,
children,
}: {
item: MissingMediaItem | QueuedMediaItem;
density: Density;
children: ReactNode;
}) => (
<Paper
className={classes.card}
component="a"
href={item.link}
target="_blank"
rel="noreferrer"
radius="sm"
p="xs"
h={CARD_HEIGHT[density]}
// The card height is fixed per density, so it is an exact intrinsic size rather than a guess.
style={offscreenRowStyle(CARD_HEIGHT[density])}
>
{item.imageUrl && (
<span className={classes.backdrop} style={{ backgroundImage: `url("${item.imageUrl}")` }} aria-hidden />
)}
+149
View File
@@ -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, /<img[\s>]|<Image[\s>]|MaskedOrNormalImage|backgroundImage/g),
lazy: /loading="lazy"|offscreenRowStyle|contentVisibility/.test(all),
scrollArea: /<ScrollArea/.test(all),
memo: count(all, /\bmemo\(/g),
useMemo: count(all, /useMemo\(/g),
subscription: /useSubscription|onData|\.subscribe\(/.test(all),
interval: lookupInterval(widget),
hasComponent: componentSource.length > 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`);
}