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).
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
Reference in new issue
Block a user