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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug(
|
// This used to log the entire serialised feed entry through a raw console.debug, so it could not be
|
||||||
`Falling back to regex image search for '${feedUrl}'. Found ${result.length} matches in content: ${content}`,
|
// 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];
|
return result[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import classes from "./calendar-event-list.module.css";
|
import classes from "./calendar-event-list.module.css";
|
||||||
import { offscreenRowStyle } from "../common/offscreen-rows";
|
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 {
|
interface CalendarEventListProps {
|
||||||
events: CalendarEvent[];
|
events: CalendarEvent[];
|
||||||
}
|
}
|
||||||
@@ -55,7 +63,7 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
|
|||||||
: "1/1",
|
: "1/1",
|
||||||
}}
|
}}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
fallbackSrc="https://placehold.co/400x400?text=No%20image"
|
fallbackSrc={NO_IMAGE_FALLBACK}
|
||||||
/>
|
/>
|
||||||
{event.image.badge !== undefined && (
|
{event.image.badge !== undefined && (
|
||||||
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
|
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea h="100%">
|
<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.List grow>
|
||||||
<Tabs.Tab value="system" fz="xs">
|
<Tabs.Tab value="system" fz="xs">
|
||||||
<b>{t("widget.healthMonitoring.tab.system")}</b>
|
<b>{t("widget.healthMonitoring.tab.system")}</b>
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
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 { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
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 { useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
|
import { offscreenRowStyle } from "../common/offscreen-rows";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||||
import classes from "./component.module.css";
|
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 t = useScopedI18n("widget.mediaMissing");
|
||||||
const pageSize = Number(options.pageSize);
|
const pageSize = Number(options.pageSize);
|
||||||
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
|
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
|
||||||
@@ -32,12 +52,8 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
|
|||||||
</Center>
|
</Center>
|
||||||
);
|
);
|
||||||
|
|
||||||
const missing = data.flatMap((entry) =>
|
const missing = data.flatMap((entry) => entry.missing.map((item) => ({ item, integrationId: entry.integrationId })));
|
||||||
entry.missing.map((item) => ({ item, integrationId: entry.integrationId })),
|
const queued = data.flatMap((entry) => entry.queued.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 missingCount = data.reduce((sum, entry) => sum + entry.missingCount, 0);
|
||||||
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 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 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 density: Density = isThin ? "thin" : width > 0 && width / columns < 180 ? "compact" : "comfortable";
|
||||||
|
|
||||||
const tabLabel = (label: string, shown: number, total: number) =>
|
const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`);
|
||||||
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}>
|
<ScrollArea h="100%" scrollbarSize={4}>
|
||||||
<Box p="xs">
|
<Box p="xs">
|
||||||
{entries.length === 0 ? (
|
{entries.length === 0 ? (
|
||||||
@@ -70,6 +88,9 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<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"}
|
defaultValue={options.showMissing ? "missing" : "queued"}
|
||||||
h="100%"
|
h="100%"
|
||||||
style={{ display: "flex", flexDirection: "column" }}
|
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);
|
const w = Math.round(size * 0.68);
|
||||||
|
|
||||||
if (src) {
|
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 (
|
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} />}
|
{type === "movie" ? <IconMovie size={size * 0.5} /> : <IconVideo size={size * 0.5} />}
|
||||||
</ThemeIcon>
|
</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 }) => (
|
const CardShell = ({
|
||||||
<Paper className={classes.card} component="a" href={item.link} target="_blank" rel="noreferrer" radius="sm" p="xs" h={CARD_HEIGHT[density]}>
|
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 && (
|
{item.imageUrl && (
|
||||||
<span className={classes.backdrop} style={{ backgroundImage: `url("${item.imageUrl}")` }} aria-hidden />
|
<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