mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Fix codebase audit regressions while preserving UI/UX behavior and adding review-driven hardening.
124 lines
29 KiB
Markdown
124 lines
29 KiB
Markdown
# 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.
|