Fix codebase audit regressions while preserving UI/UX behavior and adding review-driven hardening.
29 KiB
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:
- a single
useWindowWidththat re-renders 20+ components on everyresizeevent without throttling, matchesPattern(and catalog filter color mapping) which compiles RegExps per comment per enabled filter on every render,- feed
<img>elements that never setloading="lazy",decoding="async", or explicitwidth/height, and Intl.DateTimeFormatconstructed per post per render insidegetFormattedDate.
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 disableduseCurrentTimetimer updates for frozen consumers. The currentuse-popular-posts.tsconfirms this: it callsuseCurrentTime(cacheEntry.revealed ? false : 5)and freezescacheEntry.postsonce revealed.docs/agent-runs/mobile-virtuoso-scroll-jank/— raised the mobile multiboarddefaultItemHeightand 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 inCommentMedia. All three changes are visible insrc/views/board/board.tsx(thepagehidesave +minOverscanItemCount) andsrc/components/comment-media/comment-media.tsx(singleuseFetchGifFirstFramecall).docs/agent-runs/pretext-feed-sizing/— introduced Pretext height estimates for catalog/feed/reply surfaces, split preview-reply from thread-reply calibrations, disabledcontent-visibility:autoon virtualized reply roots under theitem-sizepath, compressed theitemSizelookup, and — critically — fixed theitemSize={undefined}footgun by only passing theitemSizeprop when the explicititem-sizemode is active (src/views/board/board.tsxL375,src/views/catalog/catalog.tsxL735). 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 ownresizelistener onwindowand callssetWindowWidth(window.innerWidth)on every single resize event with norequestAnimationFrame/debounce/throttle.useIsMobileis a thin wrapper around it, and 22 components/hooks calluseIsMobile()(plus Catalog callsuseWindowWidthdirectly forcolumnCount). 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 singleuseSyncExternalStorehook with aResizeObserverattached todocument.documentElement), throttle viarequestAnimationFrame, and snap to a coarse breakpoint token ('mobile' | 'desktop') souseIsMobileonly re-renders when the breakpoint actually crosses 640px instead of on every pixel change. -
src/lib/utils/pattern-utils.ts:30-92—matchesPatterncompiles a newRegExp(\\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'screateCombinedFilter,processedFeed(L649 — top filter), andmatchedFilterColors(L674-695 — colored filters over the entirecatalogRenderFeed). 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-levelMap<string, RegExp>keyed by${pattern}\0${flags}, and/or precompile perFilterItemwhen 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—matchedFilterColorsiterates the fullcatalogRenderFeedand callscommentMatchesPatternfor each colored filter on every render in whichcatalogRenderFeedorfilterItemschanges. The Map is also returned fresh each render and threaded intoCatalogRowviarenderCatalogRow, which flows into everyCatalogPost. 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 oncidthat is only re-run whenfilterItemschanges (use the feed length and the last cid as a cheap invalidation token, or move the matched-color assignment into the already-memoizedrowspass so the Map is built once per feed mutation rather than once per render).
High
-
src/lib/utils/time-utils.ts:3-35—getFormattedDateconstructs a brand-newnew Intl.DateTimeFormat(locale, { … })on every call. There are 29getFormattedDate/getFormattedTimeAgocall sites across posts, replies, tooltips, archive, and mod queue; a thread with 200 visible replies re-instantiatesIntl.DateTimeFormat200 times per render just for the timestamp strings.Intl.DateTimeFormatconstruction is measurably expensive (sub-ms but never free; it allocates an ICU formatter). Fix: module-scope aMap<string, Intl.DateTimeFormat>keyed by locale and memoize; invalidate oni18next.on('languageChanged'). The.format(new Date(ts*1000))call is fine to keep. -
src/views/catalog/catalog.tsx:532-584and585-634— The component builds two near-identical footer memo objects,footerComponentsandcatalogFooter, 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 includelocation.searchandcappedFeed.length, so the memo churns on every feed append and every query-string change, re-minting thecomponents={{Footer: () => …}}identity and forcing Virtuoso to swap its footer component on scroll-triggered loads. Fix: keep a single memoized footer (dropcatalogFooter, renderfooterComponents.Footer()in the non-virtualized branch), and split the footer into stable leaf components so thatCatalogFooter,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,328andsrc/views/home/popular-threads-box/popular-threads-box.tsx:69— Whole-store destructuringconst { 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 — includingfilteredCount,filteredCids,matchedFilters,currentCommunityAddress,filterText, etc., whichincrementFilterCountmutates as the feed loads. Catalog re-rendering in turn re-runs the per-commentprocessedFeed/matchedFilterColorsloops. Fix: replace each destructure with atomic selectors, e.g.useCatalogFiltersStore(s => s.filterItems)anduseCatalogFiltersStore(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 theThumbnailrender 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 becauselinkWithoutThumbnailis then checked for truthiness — malformed urls will throw). Fix: wrap in thesafeParseUrlhelper thatmarkdown.tsxalready defines and call it outside the render path (memoize onurl).fallbackLinkLabelshould be computed from the memoized URL. -
src/components/post-desktop/post-desktop.tsx:632and:662— InsidePostMedia:const embedUrl = url && new URL(url);(unconditional per render) andconst filename = new URL(url).pathname.split('/').pop();inside an IIFE that runs every render. Every feed card with a link reconstructsURLtwice per commit. Fix: move both behind a module-scopesafeParseUrlmemoized byurl, or compute once in auseMemo. -
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 setsloading="lazy",decoding="async", orfetchpriority. I grepped the wholesrc/tree — zero matches. In Catalog this means every thumbnail in the initial column grid starts decoding on the main thread at mount (Chrome'sloading="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>incomment-media.tsx,catalog-row.tsx(CatalogPostMedia),post-desktop.tsx,post-mobile.tsx, addloading="lazy"anddecoding="async"(except the first card's thumbnail, which should be eager to preserve LCP). Also forwardwidth/heightattrs to the thumbnail<img>inThumbnail(the wrapper already computesdisplayWidth/displayHeightbut 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—Imagerenders the expanded<img>withoutloading="lazy"ordecoding="async"and withoutwidth/heightattrs when expanded. The entire thumbnail scaffold writes--width/--heightCSS vars but never a numericwidth/heighton the element; browsers can't reserve space, so every image load is a guaranteed CLS increment.CatalogPostMediaatcatalog-row.tsx:84-99already computesnumericWidth/numericHeightfor this reason — the rest of the codebase should follow. Fix: compute numeric pixel dimensions once and pass them as realwidth/heightattributes (plus CSS for the stylistic scaling). -
src/views/board/board.tsx:480-492— In mobilePageFooterMobile,Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => …)allocates a new array on every render and emits a<Link>per page. For popular boardsmaxGuiPagescan be 15, so it's a small allocation, but the footer is part offooterComponentswhose dep list is 14 items; many of those deps change during scroll-triggered loads. Fix: either hoist this page list into a memo keyed bytotalPages, 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 mutateslastAccessedinsideaccessFeed(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 grabaccessFeedviauseFeedCacheStore.getState()inside the effect. Better: split the store socachedFeedslives in its own atom andaccessFeedis a plain exported function. -
src/components/markdown/markdown.tsx:184—tokenize()callsnew RegExp(COMBINED_REGEX.source, 'g')per invocation. It's called once per non-empty line per post render inside the memoizedrendered, so it's shielded for stable posts, but for any post whosecontentchanges the rebuild cost is O(lines × RegExp-compile). Fix: tokenize can reuse a module-scopedRegExpandregex.lastIndex = 0between lines; the current allocation per line is unnecessary becausegregex state is local toexecloops that finish before the next line starts. -
src/lib/utils/pattern-utils.ts:213-268—commentMatchesPatternrebuildstitleLower + ' ' + contentLower(twotoLowerCasecalls per comment per call) every time. With three to five enabled filters over 500 comments per render, that's 2000+toLowerCaseallocations 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 ownsetInterval(…, 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 (andcurrentTimeis only used ifisInModQueueView— otherwise the value is read but unused). For feed posts outside the mod queue the entire interval + state is dead weight. Fix: passuseCurrentTime(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 usesdebounce(..., 50ms)from lodash but then callssetVisible(...)on every debounced tick, re-renderingBoardsBarMobile. Debounce of 50ms is effectively no debounce at 60 fps (~3 frames). Fix: switch torequestAnimationFramecoalescing (trackscrollYin a ref, schedule a singlerAFif not pending, compare direction against last recordedscrollY) and onlysetVisiblewhen 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 forcatalogMetrics. 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 existinguse-theme-store/useThemehook 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-levelcacheCommunitiesvariable, and bothuseDirectoriesanduseDirectoriesStatemount their ownfetchDirectoriesFromGitHubDeduped()effect. Every component that callsuseDirectories(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.inFlightGitHubFetchdedupes 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 wherefeedcan be 500+ entries and mutations are frequent. Fix: maintain the Set incrementally inside the store that producesfeed, or fall back to a linear scan when the number ofrecentAccountCommentsis 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—CatalogPostconfigures a Floating UI floating with amiddlewarearray containing asize({ apply })that readswindow.innerWidthandgetBoundingClientRect()on every resize. With N=500 catalog cards all registeringuseFloating, every catalog card installs a Floating UI autoUpdate listener. Fix: only calluseFloatingwhenshowPortalis 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— EachuseFetchGifFirstFramecall holds its own state (IDLE/LOADING/READY/FAILED) and, on first mount with a cache miss, runsparseGifwhich 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 insideCommentMedia, but the cross-component duplication remains:comment-media.tsx:428andpost-desktop.tsx:623both call the hook with the same URL for the same post. Fix: consolidate to a single call at theCommentMedialayer and pass thegifFrameStatedown via props (already the pattern insidecomment-media.tsxbetweenCommentMediaandThumbnail, it just isn't respected across the post shell). -
src/components/catalog-row/catalog-row.tsx:37-116—CatalogPostMediais not wrapped inmemo, yet it's the per-thumbnail leaf in the catalog grid. Its only internal state isisLoaded/hasError, and its props are all primitives (exceptcommentMediaInfo, which is memoized upstream viauseCommentMediaInfo). Withoutmemo, when the parentCatalogPostre-renders (e.g. whenmatchedFilterColorflips), everyCatalogPostMediare-renders and re-creates inlineloadingStyle/CSSPropertiesobjects. Fix:export default memo(CatalogPostMedia)with a shallow prop equality check. -
src/views/catalog/catalog.tsx:697-717— Therowsmemo 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 oncolumnCountorisFeedLoadedchanges, which means a single window resize throughuseWindowWidthrepaints the entire cache. Fix: debouncecolumnCountto the nearest column-width breakpoint (e.g. bucketwindowWidth / columnWidthwith 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— EverysetIntervaltick callssetCurrentTime(Date.now() / 1000)even when the value would round to the same second for many consumers. Low-impact becauseReact.memofilters downstream, but still forces a commit in every subscriber. Fix: onlysetCurrentTimeifMath.floor(newTime) !== Math.floor(prev). -
src/views/home/home.tsx:87-132—StatsreceivesdirectoryAddressesas 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 ownuseCommunity({ 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'/>— nowidth/heightorloading="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 CSSbackground-imagewith 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 theshallowhelper fromzustand/shallowis 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()constructsnew Date()inside a helper called from several renders (posts, board header, archive, catalog row). For 500 catalog cards that's 500new 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 ? … : …;andconst boardMinOverscanItemCount = …;are inline objects allocated every render and handed to Virtuoso'sincreaseViewportBy/minOverscanItemCountprops. 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 theisMultiboardView && isMobilebranch writes{ bottom: 4, top: 8 }which is always allocated fresh. Fix:useMemothese two objects explicitly (belt and suspenders — some compiler heuristics bail on conditional returns). -
src/stores/use-feed-cache-store.ts:20—maxCacheSize: 2means 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 infeed-cache-container.module.csshides 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-1262and:1235— The thread-replies<Virtuoso>is passed a fresh inlineitemContent={(index, reply) => …}and a freshincreaseViewportBy={{ 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:useCallbacktheitemContentfactory, hoistincreaseViewportByinto a module constant (oruseMemo), and guard the audit ref withimport.meta.env.DEVso production scroll is audit-free. Same pattern likely inpost-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 ownuseRepliescall (inside the downstream components). EveryPostDesktopre-render also re-renders all previews unlessReplyis itselfmemoed. Fix: verifyReplyis wrapped inmemowith a cid-based equality check; if not, wrap it. The Reply props includequotedByMapanddirectRepliesByParentCidwhich come fromuseMemoin the parent — make sure those memos are stable across unrelated parent rerenders. -
src/hooks/use-count-links-in-replies.ts:5-18—useCountLinksInRepliesruns a linear scan over every reply on every call, and its loop runs outside theuseMemo(onlyflattenedRepliesis memoized). Theforloop andlinkCountallocation happens every render. It's called fromCatalogPost(catalog-row.tsx:126),PostDesktop(viaPostInfo), andPostMobile. For a thread OP with 500 replies the count re-runs on every memo miss. Fix: wrap the count itself inuseMemo([flattenedReplies, firstXReplies])so only reply-list changes re-count. -
src/components/catalog-row/catalog-row.tsx:360-390—CatalogRowhas a custommemoequality that includes a for-loop overrow.lengthcomparing 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 thematchedFilterColorschurn it amplifies. Fix:matchedFilterColorsshould 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—CatalogPostMediaallocatesloadingStyle = { opacity: isLoaded ? 1 : 0 },CSSProperties = {...}, and the mergedstyle={{ ...CSSProperties, ...(matchedFilterColor ? { border: ... } : {}) }}on every render. React will see a newstyleobject 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 viasetShowThumbnailwhich, in the caller, may be a freshuseStatesetter (it's stable, actually, so this is OK). I note it to flag: if callers ever migratesetShowThumbnailto 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—getFromLocalStoragecallslocalStorage.getItemtwice plusJSON.parseon the cached string on every mount of auseDirectoriesconsumer 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 thelocalStorageread beforeuseStateis called (module load time) and pass the result as the initial state; the effect then only refreshes against GitHub.
Top 5 Actions
-
Rewrite
useWindowWidthas a single rAF-throttled, breakpoint-bucketed global subscription (and refactoruseIsMobileto 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 ofuseWindowWidthstate updates. -
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). RewritematchedFilterColorsincatalog.tsxto a single pass keyed by the memoized pattern list. Expected: massive reduction in Catalog render cost with any filter active. -
Add
loading="lazy"+decoding="async"+ explicit numericwidth/heighton every feed<img>(comment-media.tsx,catalog-row.tsx,post-desktop.tsx,post-mobile.tsx,home.tsxlogo, decoration GIFs). Direct LCP/CLS improvement; zero behavioural risk. -
Module-scope
Intl.DateTimeFormatcache intime-utils.tskeyed by locale, invalidated oni18nextlanguage change. Cuts ~N×ICU-formatter constructions per render on any page that shows more than a handful of timestamps (threads, mod queue, archive). -
Kill whole-store destructures and the catalog double-footer memo. Switch Catalog, PopularThreadsBox, ModQueue, BoardsBar, and board-buttons to per-field Zustand selectors; collapse
footerComponentsandcatalogFooterinto one memo so the Virtuosocomponentsidentity 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.