Files
5chan/docs/agent-runs/codebase-audit-2026-04-23/06-performance.md
T
Tommaso CasaburiandGitHub 5dc5408a15 fix(codebase audit): preserve cleanup without regressions
Fix codebase audit regressions while preserving UI/UX behavior and adding review-driven hardening.
2026-04-24 15:48:07 +07:00

124 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Performance Audit
## Summary
5chan is a hash-routed SPA whose hot paths are feed views (`Board`, `Catalog`), thread views (`Post` + `PostDesktop`/`PostMobile`), and popular threads on the home page. The repo already uses `react-virtuoso` for the three large list surfaces, has a Pretext-driven `itemSize` estimator, has calibrated heights for preview cards, has `manualChunks` for vendors, has `memoizee`-cached `getLinkMediaInfo`, and has `React.memo` on the three leaf list items (`Post`, `CatalogPost`, `CatalogRow`, `CommentMedia`, `PopularThreadCard`). `babel-plugin-react-compiler` is wired into the Vite pipeline, so many inline-allocation issues will be auto-compiled out — but several bugs below sit in places the compiler cannot legally fix (bail-outs across module boundaries, regex compilation inside called utilities, DOM-attached listeners, store subscriptions).
The largest remaining wins are:
1. a single `useWindowWidth` that re-renders 20+ components on every `resize` event without throttling,
2. `matchesPattern` (and catalog filter color mapping) which compiles RegExps per comment per enabled filter on every render,
3. feed `<img>` elements that never set `loading="lazy"`, `decoding="async"`, or explicit `width`/`height`, and
4. `Intl.DateTimeFormat` constructed per post per render inside `getFormattedDate`.
None of the findings below revisit material already closed out by the prior audit runs (summarised next).
## Prior Work
Three prior runs already triaged the hottest rerender/jank issues and their fixes are present on master:
- **`docs/agent-runs/popular-threads-rerenders/`** — detached the Popular Threads box from feed-state and community subscriptions once the popular-post cache is revealed, and disabled `useCurrentTime` timer updates for frozen consumers. The current `use-popular-posts.ts` confirms this: it calls `useCurrentTime(cacheEntry.revealed ? false : 5)` and freezes `cacheEntry.posts` once revealed.
- **`docs/agent-runs/mobile-virtuoso-scroll-jank/`** — raised the mobile multiboard `defaultItemHeight` and reverse-scroll viewport buffer, made the board scroll-state listener passive and pagehide-triggered (no longer fires on every scroll tick), and removed duplicate GIF first-frame work in `CommentMedia`. All three changes are visible in `src/views/board/board.tsx` (the `pagehide` save + `minOverscanItemCount`) and `src/components/comment-media/comment-media.tsx` (single `useFetchGifFirstFrame` call).
- **`docs/agent-runs/pretext-feed-sizing/`** — introduced Pretext height estimates for catalog/feed/reply surfaces, split preview-reply from thread-reply calibrations, disabled `content-visibility:auto` on virtualized reply roots under the `item-size` path, compressed the `itemSize` lookup, and — critically — fixed the `itemSize={undefined}` footgun by only passing the `itemSize` prop when the explicit `item-size` mode is active (`src/views/board/board.tsx` L375, `src/views/catalog/catalog.tsx` L735). Don't re-report this pitfall; it's now inline-commented in both files.
Findings below avoid these closed surfaces except where a *new* sibling issue sits next to them.
## Findings
### Critical
- **`src/hooks/use-window-width.ts:1-18`** — `useWindowWidth()` puts its own `resize` listener on `window` and calls `setWindowWidth(window.innerWidth)` on every single resize event with no `requestAnimationFrame`/debounce/throttle. `useIsMobile` is a thin wrapper around it, and 22 components/hooks call `useIsMobile()` (plus Catalog calls `useWindowWidth` directly for `columnCount`). During a window resize every one of those hook instances commits a state update per frame, which cascades into Catalog/Board/PostDesktop/PostMobile renders and blows through the 16ms budget. Each consumer also installs its *own* listener, so each resize fires N listeners where N = mounted consumers. *Fix:* move to a single module-level resize listener feeding a Zustand store (or a single `useSyncExternalStore` hook with a `ResizeObserver` attached to `document.documentElement`), throttle via `requestAnimationFrame`, and snap to a coarse breakpoint token (`'mobile' | 'desktop'`) so `useIsMobile` only re-renders when the breakpoint actually crosses 640px instead of on every pixel change.
- **`src/lib/utils/pattern-utils.ts:30-92`** — `matchesPattern` compiles a *new* `RegExp(\`\\b${…}\\b\`, 'i')` on every call — up to ~5 regexes per call in the OR/AND/wildcard branches. It's called per-comment per-enabled-filter from Catalog's `createCombinedFilter`, `processedFeed` (L649 — top filter), and `matchedFilterColors` (L674-695 — colored filters over the entire `catalogRenderFeed`). With even a couple of enabled filters on a 500-post catalog that is thousands of regex compilations per Catalog render. *Fix:* cache compiled regexes in a module-level `Map<string, RegExp>` keyed by `${pattern}\0${flags}`, and/or precompile per `FilterItem` when filters change and attach the compiled regexes to the filter item. `matchesPattern` should accept a prepared filter object, not the raw pattern string.
- **`src/views/catalog/catalog.tsx:674-695`** — `matchedFilterColors` iterates the full `catalogRenderFeed` and calls `commentMatchesPattern` for each colored filter on every render in which `catalogRenderFeed` or `filterItems` changes. The Map is also returned fresh each render and threaded into `CatalogRow` via `renderCatalogRow`, which flows into every `CatalogPost`. Combined with the regex-compile issue above, this is the single hottest catalog path. *Fix:* precompile filter regexes (see item 2), and replace the linear scan with a prepass keyed on `cid` that is only re-run when `filterItems` changes (use the feed length and the last cid as a cheap invalidation token, or move the matched-color assignment into the already-memoized `rows` pass so the Map is built once per feed mutation rather than once per render).
### High
- **`src/lib/utils/time-utils.ts:3-35`** — `getFormattedDate` constructs a brand-new `new Intl.DateTimeFormat(locale, { … })` on every call. There are 29 `getFormattedDate`/`getFormattedTimeAgo` call sites across posts, replies, tooltips, archive, and mod queue; a thread with 200 visible replies re-instantiates `Intl.DateTimeFormat` 200 times per render just for the timestamp strings. `Intl.DateTimeFormat` construction is measurably expensive (sub-ms but never free; it allocates an ICU formatter). *Fix:* module-scope a `Map<string, Intl.DateTimeFormat>` keyed by locale and memoize; invalidate on `i18next.on('languageChanged')`. The `.format(new Date(ts*1000))` call is fine to keep.
- **`src/views/catalog/catalog.tsx:532-584` and `585-634`** — The component builds *two* near-identical footer memo objects, `footerComponents` and `catalogFooter`, from the exact same 14-element dep array. Both trees call `<CatalogFooter>`, `<PageFooterDesktop>`, and `<PageFooterMobile>` with the same props. Even with the deps aligned, the deps include `location.search` and `cappedFeed.length`, so the memo churns on every feed append and every query-string change, re-minting the `components={{Footer: () => …}}` identity and forcing Virtuoso to swap its footer component on scroll-triggered loads. *Fix:* keep a single memoized footer (drop `catalogFooter`, render `footerComponents.Footer()` in the non-virtualized branch), and split the footer into stable leaf components so that `CatalogFooter`, `CatalogFooterFirstRow`, and the mobile footer receive only the exact subset of props they depend on. The 14-dep list should shrink to three or four once the child components are split.
- **`src/views/catalog/catalog.tsx:275,309,328` and `src/views/home/popular-threads-box/popular-threads-box.tsx:69`** — Whole-store destructuring `const { filterItems, searchText } = useCatalogFiltersStore();`, `const { imageSize, showOPComment } = useCatalogStyleStore();`, `const { sortType } = useSortingStore();`, `const { showWorksafeContentOnly, showNsfwContentOnly } = usePopularThreadsOptionsStore();`. Zustand returns the entire state when called with no selector, so Catalog re-renders on *any* field change in any of those three stores — including `filteredCount`, `filteredCids`, `matchedFilters`, `currentCommunityAddress`, `filterText`, etc., which `incrementFilterCount` mutates as the feed loads. Catalog re-rendering in turn re-runs the per-comment `processedFeed`/`matchedFilterColors` loops. *Fix:* replace each destructure with atomic selectors, e.g. `useCatalogFiltersStore(s => s.filterItems)` and `useCatalogFiltersStore(s => s.searchText)`. Same pattern elsewhere (`mod-queue.tsx:845`, `boards-bar.tsx:100-103`, `board-buttons.tsx:248,268,285,358,374,425,464,630`).
- **`src/components/comment-media/comment-media.tsx:144`** — `const linkWithoutThumbnail = url && new URL(url);` runs inside the `Thumbnail` render body. `new URL(…)` allocates and parses; it executes per reply media per render (and throws on malformed URLs, which is caught by the surrounding flow only because `linkWithoutThumbnail` is then checked for truthiness — malformed urls will throw). *Fix:* wrap in the `safeParseUrl` helper that `markdown.tsx` already defines and call it outside the render path (memoize on `url`). `fallbackLinkLabel` should be computed from the memoized URL.
- **`src/components/post-desktop/post-desktop.tsx:632` and `:662`** — Inside `PostMedia`: `const embedUrl = url && new URL(url);` (unconditional per render) and `const filename = new URL(url).pathname.split('/').pop();` inside an IIFE that runs every render. Every feed card with a link reconstructs `URL` twice per commit. *Fix:* move both behind a module-scope `safeParseUrl` memoized by `url`, or compute once in a `useMemo`.
- **`src/components/comment-media/comment-media.tsx` & `src/components/catalog-row/catalog-row.tsx` & `src/components/post-desktop/post-desktop.tsx` & `src/components/post-mobile/post-mobile.tsx`** — **No `<img>` in the entire feed path sets `loading="lazy"`, `decoding="async"`, or `fetchpriority`.** I grepped the whole `src/` tree — zero matches. In Catalog this means every thumbnail in the initial column grid starts decoding on the main thread at mount (Chrome's `loading="lazy"` default for non-viewport images would cut this dramatically). Board cards behind Virtuoso still mount for overscan + viewport buffer (`increaseViewportBy={bottom: 2400, top: 1200}` on desktop), so 30-40 above-the-fold images decode synchronously on first feed load. This is a direct LCP/INP hit. *Fix:* on every `<img>` in `comment-media.tsx`, `catalog-row.tsx` (`CatalogPostMedia`), `post-desktop.tsx`, `post-mobile.tsx`, add `loading="lazy"` and `decoding="async"` (except the first card's thumbnail, which should be eager to preserve LCP). Also forward `width`/`height` attrs to the thumbnail `<img>` in `Thumbnail` (the wrapper already computes `displayWidth`/`displayHeight` but only writes them as CSS vars) so the browser can reserve layout and skip a CLS bounce.
- **`src/components/comment-media/comment-media.tsx:297-408`** — `Image` renders the expanded `<img>` without `loading="lazy"` or `decoding="async"` and without `width`/`height` attrs when expanded. The entire thumbnail scaffold writes `--width`/`--height` CSS vars but never a numeric `width`/`height` on the element; browsers can't reserve space, so every image load is a guaranteed CLS increment. `CatalogPostMedia` at `catalog-row.tsx:84-99` already computes `numericWidth`/`numericHeight` for this reason — the rest of the codebase should follow. *Fix:* compute numeric pixel dimensions once and pass them as real `width`/`height` attributes (plus CSS for the stylistic scaling).
- **`src/views/board/board.tsx:480-492`** — In mobile `PageFooterMobile`, `Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => …)` allocates a new array on every render and emits a `<Link>` per page. For popular boards `maxGuiPages` can be 15, so it's a small allocation, but the footer is part of `footerComponents` whose dep list is 14 items; many of those deps change during scroll-triggered loads. *Fix:* either hoist this page list into a memo keyed by `totalPages`, or render a compact page selector rather than enumerating all pages.
### Medium
- **`src/components/feed-cache-container/feed-cache-container.tsx:71`** — `const { cachedFeeds, accessFeed } = useFeedCacheStore();` subscribes to the *entire* feed cache store. When the store mutates `lastAccessed` inside `accessFeed` (which it does every time a new feed route is visited, see store L23-31), the container re-renders and re-emits all cached feed wrappers. Each wrapper holds a `<Catalog>` or `<Board>`, which then re-evaluates its entire prop list through the visibility CSS trick. *Fix:* `useFeedCacheStore(s => s.cachedFeeds, shallow)` and grab `accessFeed` via `useFeedCacheStore.getState()` inside the effect. Better: split the store so `cachedFeeds` lives in its own atom and `accessFeed` is a plain exported function.
- **`src/components/markdown/markdown.tsx:184`** — `tokenize()` calls `new RegExp(COMBINED_REGEX.source, 'g')` per invocation. It's called once per non-empty line per post render inside the memoized `rendered`, so it's shielded for stable posts, but for any post whose `content` changes the rebuild cost is O(lines × RegExp-compile). *Fix:* tokenize can reuse a module-scoped `RegExp` and `regex.lastIndex = 0` between lines; the current allocation per line is unnecessary because `g` regex state is local to `exec` loops that finish before the next line starts.
- **`src/lib/utils/pattern-utils.ts:213-268`** — `commentMatchesPattern` rebuilds `titleLower + ' ' + contentLower` (two `toLowerCase` calls per comment per call) every time. With three to five enabled filters over 500 comments per render, that's 2000+ `toLowerCase` allocations on the main thread. *Fix:* cache the lowercase concatenation on the comment object (WeakMap keyed by comment reference) or compute once in the memoized feed loop and pass the precomputed string to a lower-level matcher.
- **`src/components/post-desktop/post-desktop.tsx:248`, `src/components/post-mobile/post-mobile.tsx:103`** — `const currentTime = useCurrentTime();` runs in every visible PostDesktop/PostMobile, each starting its own `setInterval(…, 60_000)`. Virtuoso keeps 10-30 mounted rows, so that's 10-30 intervals, each fanning out a state update that re-renders the whole post every minute (and `currentTime` is only used if `isInModQueueView` — otherwise the value is read but unused). For feed posts outside the mod queue the entire interval + state is dead weight. *Fix:* pass `useCurrentTime(false)` (or omit the call) when `!isInModQueueView`, or expose a singleton current-time store (one interval, N subscribers with granular selectors).
- **`src/components/boards-bar/boards-bar.tsx:370-385`** — The mobile navbar hide-on-scroll uses `debounce(..., 50ms)` from lodash but then calls `setVisible(...)` on every debounced tick, re-rendering `BoardsBarMobile`. Debounce of 50ms is effectively no debounce at 60 fps (~3 frames). *Fix:* switch to `requestAnimationFrame` coalescing (track `scrollY` in a ref, schedule a single `rAF` if not pending, compare direction against last recorded `scrollY`) and only `setVisible` when the direction *flips*, not on every sample.
- **`src/views/catalog/catalog.tsx:331`** — `const themeKey = typeof document !== 'undefined' ? document.body.className : '';` is read on every Catalog render and then used as a useMemo dep for `catalogMetrics`. It's not wrong (it tracks theme swaps) but it bypasses React's data flow and will miss theme changes unless Catalog re-renders for some other reason first. *Fix:* subscribe to theme via the existing `use-theme-store` / `useTheme` hook so the dependency is explicit and Catalog re-measures when the theme actually changes.
- **`src/hooks/use-directories.ts:331-398`** — `useDirectories()` sets component state in addition to a module-level `cacheCommunities` variable, and *both* `useDirectories` and `useDirectoriesState` mount their own `fetchDirectoriesFromGitHubDeduped()` effect. Every component that calls `useDirectories` (app.tsx, boards-bar, catalog, board, every post, reply-quote-preview, rules) installs its own hook state; a mount storm on first-paint means N×state-hydration for the shared dataset. `inFlightGitHubFetch` dedupes the network call but not the hook state. *Fix:* hoist the directory cache into a tiny Zustand store with a single initialization effect guarded by a module-level boolean; consumers read a stable reference.
- **`src/views/catalog/catalog.tsx:459` / `src/views/board/board.tsx:320`** — `const feedCids = useMemo(() => new Set(feed.map(f => f.cid)), [feed])` allocates a Set plus a string array of O(N) on every feed mutation. Fine for N=100, expensive in multiboard mode where `feed` can be 500+ entries and mutations are frequent. *Fix:* maintain the Set incrementally inside the store that produces `feed`, or fall back to a linear scan when the number of `recentAccountComments` is 0 (the common case) — right now you build the full Set even when there's nothing to test against.
- **`src/components/catalog-row/catalog-row.tsx:158-188`** — `CatalogPost` configures a Floating UI floating with a `middleware` array containing a `size({ apply })` that reads `window.innerWidth` and `getBoundingClientRect()` on every resize. With N=500 catalog cards all registering `useFloating`, *every* catalog card installs a Floating UI autoUpdate listener. *Fix:* only call `useFloating` when `showPortal` is true (or mount a single shared portal at the Catalog level and pass per-card reference through context), so the setup is deferred until hover.
- **`src/hooks/use-fetch-gif-first-frame.ts:85-138`** — Each `useFetchGifFirstFrame` call holds its own state (`IDLE`/`LOADING`/`READY`/`FAILED`) and, on first mount with a cache miss, runs `parseGif` which draws into a canvas. For multiple visible GIFs, each runs its own canvas decode in parallel. The 2026-03-31 audit removed the *duplicate* call inside `CommentMedia`, but the cross-component duplication remains: `comment-media.tsx:428` and `post-desktop.tsx:623` both call the hook with the same URL for the same post. *Fix:* consolidate to a single call at the `CommentMedia` layer and pass the `gifFrameState` down via props (already the pattern inside `comment-media.tsx` between `CommentMedia` and `Thumbnail`, it just isn't respected across the post shell).
- **`src/components/catalog-row/catalog-row.tsx:37-116`** — `CatalogPostMedia` is *not* wrapped in `memo`, yet it's the per-thumbnail leaf in the catalog grid. Its only internal state is `isLoaded`/`hasError`, and its props are all primitives (except `commentMediaInfo`, which is memoized upstream via `useCommentMediaInfo`). Without `memo`, when the parent `CatalogPost` re-renders (e.g. when `matchedFilterColor` flips), every `CatalogPostMedia` re-renders and re-creates inline `loadingStyle`/`CSSProperties` objects. *Fix:* `export default memo(CatalogPostMedia)` with a shallow prop equality check.
- **`src/views/catalog/catalog.tsx:697-717`** — The `rows` memo caches individual rows by cid-concat key but compares arrays element-wise inside the same pass every time. For a 200-thread catalog that's 200 key builds + 200 Map lookups + 200 row comparisons per feed change. This is fine in isolation, but it also triggers on `columnCount` or `isFeedLoaded` changes, which means a single window resize through `useWindowWidth` repaints the entire cache. *Fix:* debounce `columnCount` to the nearest column-width breakpoint (e.g. bucket `windowWidth / columnWidth` with a 50-pixel hysteresis) so a continuous drag doesn't repaint the grid 30 times per second.
### Low
- **`src/hooks/use-current-time.ts:20-23`** — Every `setInterval` tick calls `setCurrentTime(Date.now() / 1000)` even when the value would round to the same second for many consumers. Low-impact because `React.memo` filters downstream, but still forces a commit in every subscriber. *Fix:* only `setCurrentTime` if `Math.floor(newTime) !== Math.floor(prev)`.
- **`src/views/home/home.tsx:87-132`** — `Stats` receives `directoryAddresses` as a prop and iterates through the full list in a memo, but also emits `<CommunityStatsCollector key={address} communityAddress={address} />` for every directory board. Each collector mounts its own `useCommunity({ community: communityIdentifier })` hook, so the home page fetches community metadata for every tracked board concurrently. *Fix:* gate the collectors behind visibility (`IntersectionObserver`) or throttle to the first N on mount and load the rest incrementally.
- **`src/components/post-desktop/post-desktop.tsx:452-467`, `:1151`** — `<img src='assets/icons/sticky.gif'/>`, `<img src='assets/icons/closed.gif'/>`, `<img src='assets/icons/archived.gif'/>`, `<img src='assets/xmashat.gif'/>` — no `width`/`height` or `loading="lazy"`. Same for mobile (`post-mobile.tsx:834`). Small cost per image but these tags render inside post-info near the LCP area. *Fix:* add intrinsic sizes; ideally inline as CSS `background-image` with fixed dimensions so they don't participate in the image-decoding queue at all.
- **`src/components/boards-bar/boards-bar.tsx:109-119`** — `useAccountsStore(state => [...(activeAccount?.subscriptions || [])], equalityFn)` rebuilds the subscriptions array on every store update and relies on a custom equalityFn. It works, but the `shallow` helper from `zustand/shallow` is cheaper and better documented. Low priority — the custom equality is correct.
- **`src/lib/utils/pattern-utils.ts:23-35`** — The regex-pattern detection uses `/\/[gimsuy]*$/.test(pattern)` on every call for every filter string, even ones the user never wrote as regex. Cheap but avoidable. *Fix:* detect regex once per filter item at filter-item-change time.
- **`src/lib/snow.ts:85-102`** — `shouldShowSnow()` constructs `new Date()` inside a helper called from several renders (posts, board header, archive, catalog row). For 500 catalog cards that's 500 `new Date()` allocations. *Fix:* cache the December-24/25 answer once per page load since the date can't change during a session.
- **`src/views/board/board.tsx:540-541`** — `const boardViewportBuffer = isMultiboardView ? … : …;` and `const boardMinOverscanItemCount = …;` are inline objects allocated every render and handed to Virtuoso's `increaseViewportBy` / `minOverscanItemCount` props. Virtuoso re-reads those props on each update; with React compiler off this would cause extra Virtuoso churn. With compiler on, it should be auto-memoized — but note the `isMultiboardView && isMobile` branch writes `{ bottom: 4, top: 8 }` which is always allocated fresh. *Fix:* `useMemo` these two objects explicitly (belt and suspenders — some compiler heuristics bail on conditional returns).
- **`src/stores/use-feed-cache-store.ts:20`** — `maxCacheSize: 2` means every navigation between three different feed routes mounts a fresh `<Board>`/`<Catalog>` (with all of its hooks) and evicts the least-recent. The current CSS trick in `feed-cache-container.module.css` hides the non-active feed but keeps it mounted; bumping to 3-4 would keep common back-nav flows warm without a mount cost. *Fix:* raise to 3 or 4 after verifying memory cost.
- **`src/components/post-desktop/post-desktop.tsx:1231-1262` and `:1235`** — The thread-replies `<Virtuoso>` is passed a fresh inline `itemContent={(index, reply) => …}` *and* a fresh `increaseViewportBy={{ bottom: 1200, top: 1200 }}` object on every PostDesktop render. Virtuoso dedupes internally on prop identity, so fresh object identities force its internal memo cache to invalidate and re-render the mounted reply rows. Additionally, `ref={(element) => reportReplyHeightAuditSample(element, …, reply.cid)}` is an inline ref callback which React calls twice (null then element) on every render for every mounted row — that's a lot of DOM-read audit work per scroll tick. *Fix:* `useCallback` the `itemContent` factory, hoist `increaseViewportBy` into a module constant (or `useMemo`), and guard the audit ref with `import.meta.env.DEV` so production scroll is audit-free. Same pattern likely in `post-mobile.tsx`.
- **`src/components/post-desktop/post-desktop.tsx:1283-1302`** — Board-view preview replies render non-virtualized: `.map((reply, index) => <div …><Reply … /></div>)`. That's fine for 3-5 preview replies per card, but the `<Reply>` inner tree still does a full render including its own `useReplies` call (inside the downstream components). Every `PostDesktop` re-render also re-renders all previews unless `Reply` is itself `memo`ed. *Fix:* verify `Reply` is wrapped in `memo` with a cid-based equality check; if not, wrap it. The Reply props include `quotedByMap` and `directRepliesByParentCid` which come from `useMemo` in the parent — make sure those memos are stable across unrelated parent rerenders.
- **`src/hooks/use-count-links-in-replies.ts:5-18`** — `useCountLinksInReplies` runs a linear scan over every reply on every call, and its loop runs *outside* the `useMemo` (only `flattenedReplies` is memoized). The `for` loop and `linkCount` allocation happens every render. It's called from `CatalogPost` (catalog-row.tsx:126), `PostDesktop` (via `PostInfo`), and `PostMobile`. For a thread OP with 500 replies the count re-runs on every memo miss. *Fix:* wrap the count itself in `useMemo([flattenedReplies, firstXReplies])` so only reply-list changes re-count.
- **`src/components/catalog-row/catalog-row.tsx:360-390`** — `CatalogRow` has a custom `memo` equality that includes a for-loop over `row.length` comparing element identity and matched color. That's correct, but it's O(N) per candidate rerender, and N=columns (up to 6 on wide monitors). With 200 rows that's 1200 comparisons per Catalog render. Not a hotspot on its own, but combined with the `matchedFilterColors` churn it amplifies. *Fix:* `matchedFilterColors` should be the same Map identity across renders when no filter actually changed (fix the upstream memo), at which point the equality check short-circuits.
- **`src/components/catalog-row/catalog-row.tsx:46,74-79,107-110`** — `CatalogPostMedia` allocates `loadingStyle = { opacity: isLoaded ? 1 : 0 }`, `CSSProperties = {...}`, and the merged `style={{ ...CSSProperties, ...(matchedFilterColor ? { border: ... } : {}) }}` on every render. React will see a new `style` object identity and diff styles per commit. *Fix:* memoize; ideally migrate to a CSS custom-property approach so JS doesn't allocate style objects per card at all.
- **`src/components/board-header/board-header.tsx:72`** — `const subscriptionsCount = useAccountsStore((state) => { …complex selector… })` — I didn't read the full selector but this needs an equality function or a shallow-friendly return, otherwise any account store change causes a header rerender. *Fix:* ensure the selector returns a number primitive and doesn't allocate a new object.
- **`src/components/comment-media/comment-media.tsx:505-522`** — `memo(CommentMedia, (prev, next) => { … 14 identity checks … })` — 14 comparisons on every sibling rerender is fine, but several of the compared props are bound via `setShowThumbnail` which, in the caller, may be a fresh `useState` setter (it's stable, actually, so this is OK). I note it to flag: if callers ever migrate `setShowThumbnail` to a wrapped callback, this memo will silently degrade. *Fix:* consider switching to the simpler default shallow memo once you're confident all callers pass stable references.
- **`src/hooks/use-directories.ts:276-297`** — `getFromLocalStorage` calls `localStorage.getItem` twice plus `JSON.parse` on the cached string on every mount of a `useDirectories` consumer that hasn't yet populated the module-level cache. On a cold tab load this happens once per consumer until the module cache fills, but the read is synchronous on the main thread during React's commit phase. *Fix:* do the `localStorage` read *before* `useState` is called (module load time) and pass the result as the initial state; the effect then only refreshes against GitHub.
## Top 5 Actions
1. **Rewrite `useWindowWidth` as a single rAF-throttled, breakpoint-bucketed global subscription** (and refactor `useIsMobile` to a coarse `'mobile' | 'desktop'` selector). This cuts commits during resize from O(N consumers × resize events) to O(breakpoint flips), and removes a top-10 hotspot that shows up under DevTools Performance as a flurry of `useWindowWidth` state updates.
2. **Memoize pattern regexes and lowercase comment fields.** Module-scope `Map<string, RegExp>`, compile per filter-item mutation, and precompute `${title} ${content}`.toLowerCase() per comment (WeakMap). Rewrite `matchedFilterColors` in `catalog.tsx` to a single pass keyed by the memoized pattern list. Expected: massive reduction in Catalog render cost with any filter active.
3. **Add `loading="lazy"` + `decoding="async"` + explicit numeric `width`/`height` on every feed `<img>`** (`comment-media.tsx`, `catalog-row.tsx`, `post-desktop.tsx`, `post-mobile.tsx`, `home.tsx` logo, decoration GIFs). Direct LCP/CLS improvement; zero behavioural risk.
4. **Module-scope `Intl.DateTimeFormat` cache in `time-utils.ts`** keyed by locale, invalidated on `i18next` language change. Cuts ~N×ICU-formatter constructions per render on any page that shows more than a handful of timestamps (threads, mod queue, archive).
5. **Kill whole-store destructures and the catalog double-footer memo.** Switch Catalog, PopularThreadsBox, ModQueue, BoardsBar, and board-buttons to per-field Zustand selectors; collapse `footerComponents` and `catalogFooter` into one memo so the Virtuoso `components` identity only changes when a footer-visible field changes. Combined with (2) this removes the single largest remaining source of "feed rerenders on unrelated store mutations" after the prior popular-threads fix.