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.
20 KiB
20 KiB
State Management Audit
Summary
The Zustand layer is mostly a loose collection of 32 single-purpose stores, many of which are healthy (tiny modal stores, preference toggles). The biggest systemic problems are: (1) near-universal whole-store destructuring in consumers — there is no useShallow anywhere in the codebase, so every call to const { a, b } = useXStore() re-renders on any state change, (2) use-catalog-filters-store has ballooned into a 400 LOC kitchen-sink with a live filter function cached in state, a setTimeout that mutates state from a render-time selector path, and an imperative top-level self-kick on module import, and (3) ~7 stores roll their own ad-hoc localStorage handlers instead of using the persist middleware that the project already depends on.
Store Inventory
| Store | Responsibility | LOC | Main concern |
|---|---|---|---|
use-catalog-filters-store.ts |
Catalog filter text list, per-community counts, search, match highlights | 400 | Kitchen-sink; stores a (comment) => boolean function; setTimeout side-effect inside selector; top-level .getState().updateFilter() at module load |
use-boards-bar-visibility-store.ts |
Boards-bar directory/subscription visibility | 128 | Hand-rolled localStorage reads/writes (4 keys + legacy fallbacks); should use persist |
use-reply-modal-store.ts |
Reply modal open state + quote-insertion signals | 117 | Mixes transient reply target (cid/number/community) and quote-insert event stream; DOM read (window.innerWidth) inside store action; cross-store write to useSelectedTextStore on close |
use-mod-queue-store.ts |
Mod queue alert threshold, dismissed cids, queue history, view mode, selected board | 115 | Mixes unrelated concerns: ephemeral UI (viewMode, selectedBoardFilter) with persisted data (queuedCommentHistory, dismissedCommentCids, thresholds); action getAlertThresholdSeconds is a derived getter masquerading as a method |
use-post-number-store.ts |
Post number <-> cid maps keyed by community | 86 | Reasonable; but getScopedNumberToCidMap is a non-trivial selector computed outside memoization boundary |
use-publish-post-store.ts |
Draft state for a single top-level post | 85 | Singleton draft; ties publish-option construction to state shape; publishCommentOptions cached in state duplicates fields already in state |
use-disclaimer-modal-store.ts |
NSFW disclaimer modal + acceptance | 84 | Takes a NavigateFunction into store actions (coupling); hand-rolled localStorage |
use-publish-reply-store.ts |
Draft reply state keyed by parentCid | 80 | 6 parallel dictionaries keyed by cid (should be one map of objects); leaks memory — resetPublishReplyStore sets undefined but never deletes keys |
use-feed-cache-store.ts |
LRU of recent feed keys (size 2) | 61 | Tiny LRU over an array; maxCacheSize is state but never changed |
use-theme-store.ts |
Current theme per sfw/nsfw category | 56 | Side-effect loadThemes() called at import; getTheme is an action that also mutates state (currentTheme) |
use-community-offline-store.ts |
Per-community offline state + 30s initial-load window | 52 | setTimeout(..., 30_000) inside store action leaks if community unmounts; initialLoad flag is derivable from timestamp |
use-boards-filter-store.ts |
Boards page sfw/nsfw filter + catalog-link toggle | 51 | Hand-rolled localStorage; two unrelated concerns in one store |
use-media-hosting-store.ts |
Media host provider + upload mode | 46 | Good — uses persist with version+migrate |
use-communities-loading-start-timestamps-store.ts |
First-seen timestamp per community | 44 | Exports a hook wrapper with its own useEffect + useMemo; selector returns a new array every render (.map()), defeats memo |
use-app-update-store.ts |
App update availability/apply state | 41 | Clean; minor — 3 booleans where a discriminated status would be clearer |
use-thread-live-updates-store.ts |
Thread auto-update enable + request id counter | 40 | Clean |
use-challenges-store.ts |
Queue of active challenges | 38 | Module-level nextChallengeId mutable counter outside store |
use-external-quote-status-store.ts |
Temporary error banner for external quote resolution | 37 | Module-level hideTimeout outside store |
use-special-theme-store.ts |
Christmas theme toggle | 34 | Clean |
use-publish-post-store.ts+use-publish-reply-store.ts |
— | — | Two stores that are 90% the same logic |
use-post-number-store.ts |
— | — | See above |
use-popular-threads-options-store.ts |
Home popular-threads sfw/nsfw toggles | 24 | Hand-rolled localStorage; imported under a misleading useHomeFiltersStore alias |
use-directory-modal-store.ts |
Directory modal open/close | 26 | Duplicate of two other modal stores |
use-create-board-modal-store.ts |
Create-board modal open/close | 26 | Duplicate |
use-boards-bar-edit-modal-store.ts |
Boards-bar edit modal open/close | 25 | Duplicate |
use-blotter-visibility-store.ts |
Blotter show/hide | 25 | Clean — uses persist |
use-expanded-media-store.ts |
Image fit + video mute prefs | 26 | Clean — uses persist |
use-feed-view-settings-store.ts |
Infinite-scroll toggle | 22 | Clean — uses persist |
use-catalog-style-store.ts |
Image size + show OP comment | 24 | Hand-rolled localStorage |
use-all-feed-filter-store.ts |
All-feed sfw/nsfw filter | 32 | Hand-rolled localStorage |
use-sorting-store.ts |
Catalog sort type | 17 | Clean |
use-feed-reset-store.ts |
Stores a reset function reference | 14 | Stores a function — this is a ref/pub-sub, not state |
use-selected-text-store.ts |
Selected-text string | 16 | Clean, but could be useReplyModalStore field |
Findings
Critical
src/stores/use-catalog-filters-store.ts:206-250—updateFilterstores a new(comment) => booleanclosure inside the store state. The closure capturesstateviaset((state) => ({ filter: ... })), so every subsequent mutation tofilterItems,searchText, orcurrentCommunityAddressrequires re-runningupdateFilter()to refresh the closure. Missing any of those paths produces a stale filter. Additionally, inside the filter at line 232 it callssetTimeout(...)to scheduleincrementFilterCount, which mutates the store from render-time consumers (the filter is called during a render pass by whoever uses it). That is a store mutation as a render side-effect — exactly the Zustand anti-pattern the React 19 rules call out. Fix: stop storing a closure in state. ExposematchesFilter(comment, communityAddress)as a pure selector (or a utility that takesfilterItems+searchTextas arguments). Track per-community filtered cids in a separate update path triggered by the owning component after it has filtered its own list, not inside a predicate called during render.src/stores/use-catalog-filters-store.ts:398— Top-leveluseCatalogFiltersStore.getState().updateFilter();runs at module import. This executes before React is mounted and before thepersistrehydration completes, so the initial filter closure sees emptyfilterItemseven though persisted filters exist. Consumers then get a stale filter until another action happens to callupdateFilter. Fix: triggerupdateFilterfromonRehydrateStorage, or replace with a pure selector so there is nothing to prime.src/stores/use-catalog-filters-store.ts:361-370—partializedrops all non-text fields (count,filteredCids,communityCounts,communityFilteredCids) but thedeserializemigration on the next load callsnormalizeFilterItemwhich re-creates emptyMap/Setinstances. That is fine, but thedeserializefunction never accounts for the version, and the branch at line 372 doesJSON.parsemanually — but the defaultpersiststorage already parses. On repeat calls it is parsing a plain object as a string, swallowing the error in thetypeof persisted === 'string'check. Fix: drop the customdeserializeentirely; persisted data is already plain strings, andMap/Setrevival should go throughstorage: createJSONStorage(() => localStorage, { reviver, replacer }).
High
src/app.tsx:183—GlobalLayoutdestructures 8 fields offuseReplyModalStore()with no selector. This is a top-level always-mounted component; it re-renders every time any field (quoteInsertRequestIdcounter,openEmpty,scrollY, etc.) changes. Fix: split into per-fielduseReplyModalStore((s) => s.activeCid)selectors (already the pattern used insidereply-modal.tsxitself) or useuseShallowon the destructured object.src/components/catalog-filters/catalog-filters.tsx:50-54,src/hooks/use-publish-post.ts:12-19,src/hooks/use-publish-reply.ts:25-31— Selectors that return a new object literal(state) => ({ a: state.a, b: state.b })withoutuseShallow. Zustand compares the selector result withObject.isby default, so the new object is never equal and the hook re-fires on every store change. Fix: wrap withuseShallowfromzustand/react/shallow, or split into one-field selectors.src/stores/use-catalog-filters-store.ts:25,205,208—filter: ((comment: Comment) => boolean) | undefinedis a function stored in state. Every consumer that readsstate.filterre-subscribes to identity of this function. Combined withupdateFilterrebuilding it on every filter/search/community change, anything reading it re-renders unnecessarily. Fix: as above, make it a pure selector —selectCatalogFilterPredicate(state)returns a stable-ish function based on current state, memoized viazustand/middlewareor Zustand'screateSelectorequivalent.src/stores/use-mod-queue-store.ts:9-23— One store carries (a) persisted moderation data (dismissed cids, 500-entry history, thresholds) and (b) ephemeral UI (viewMode: 'compact'|'feed',selectedBoardFilter). EveryrememberCommentsInQueuemutation re-renders every consumer ofviewModeacrossboard-buttons.tsxandmod-queue.tsx. Fix: split intouse-mod-queue-data-store(persisted) anduse-mod-queue-view-store(session UI); alternatively, partialize the persist layer so UI fields are not saved and subscribers can select precisely.src/stores/use-publish-reply-store.ts:8-13, 69-77— State shape is 6 parallel{ [parentCid]: T }dictionaries. Any reply draft update spreads all 6 objects. Worse,resetPublishReplyStoresets each[parentCid]: undefinedinstead of deleting, so the dictionaries grow unboundedly for the lifetime of the session. Fix: collapse todrafts: Record<string, ReplyDraft>with a singlesetDraft(parentCid, patch)anddeleteDraft(parentCid)that doesconst { [parentCid]: _, ...rest } = state.drafts.src/stores/use-reply-modal-store.ts:82-95—window.innerWidthread inside the store action; the store action is therefore non-deterministic across test environments and SSR. Fix: move the mobile check to the caller (there is already ause-is-mobilehook), and accept ascrollYargument.src/stores/use-reply-modal-store.ts:53, 79, 99—useReplyModalStorewrites intouseSelectedTextStorevia.getState(). Bidirectional coupling: the two stores are really one feature. Fix: mergeselectedTextintouseReplyModalStore(it is only ever used by the reply modal), and deleteuse-selected-text-store.ts.
Medium
src/stores/use-community-offline-store.ts:38-48—initializeCommunityOfflineStateschedules a nakedsetTimeout(..., 30_000)that mutates the store. If the user navigates away, the timer still fires and flipsinitialLoad: falseon unmounted communities. AndinitialLoaditself is derivable —Date.now()/1000 - loadingStartTimestamp < 30(the hook already computes this atuse-is-community-offline.ts:36). Fix: deleteinitialLoadand the timer; derive from the already-persisted timestamp inuse-communities-loading-start-timestamps-store.src/stores/use-communities-loading-start-timestamps-store.ts:37-39—communitiesLoadingStartTimestampsselector returnscommunityAddresses.map(addr => timestampsStore[addr])— a new array every render even when unchanged. The outeruseMemoprevents re-renders of this hook but its consumers still see a new array reference each timetimestampsStoremutates for any address. Fix: select the specific timestamps viauseShallow, or compute a stable signature outside.src/stores/use-boards-bar-visibility-store.ts(whole file),src/stores/use-boards-filter-store.ts,src/stores/use-catalog-style-store.ts,src/stores/use-all-feed-filter-store.ts,src/stores/use-popular-threads-options-store.ts,src/stores/use-disclaimer-modal-store.ts— All hand-rolllocalStorage.getItem/setIteminside store definitions instead of using thepersistmiddleware that's already imported elsewhere. This multiplies the serialization bugs (e.g.use-boards-bar-visibility-store.ts:54parses an array as a boolean;use-popular-threads-options-store.ts:11has the "default true unless false" logic duplicated). Fix: migrate topersist({ name, version, migrate, partialize }). The existing keys can be mapped viastorage: createJSONStorage(() => localStorage)with amigratefn to copy from the legacy keys.src/stores/use-disclaimer-modal-store.ts:10, 36, 63— Store actions accept a React RouterNavigateFunction. That makes the store coupled to the call site's router context and forces the component to thread navigate through props. Fix: have the store expose target state (acceptedTargetPath) and let a tinyuseDisclaimerNavigationhook subscribe and callnavigate.src/stores/use-theme-store.ts:32-39—getThemeis named like a selector but also mutates state (set({ currentTheme: theme })). Plus there's aupdateCurrentTheme=trueparameter that silently toggles side-effect behavior. Fix: split into a puregetTheme(category)selector and an explicitsetCurrentThemeaction.src/stores/use-theme-store.ts:54—useThemeStore.getState().loadThemes()runs at import time.loadThemesis async (reads fromlocalforage). Any component that renders before that promise resolves sees the default{ nsfw: 'yotsuba', sfw: 'yotsuba-b' }, flickers, then re-renders when the real theme loads. Fix: expose anisLoadedflag souseTheme()can gate rendering, or callloadThemesfrom the root provider where the hydration lifecycle is explicit.src/stores/use-publish-post-store.ts:28-71— Store holds both the raw draft fields (title,content,link) and a pre-builtpublishCommentOptionsderived from those fields. They can drift. AndsetPublishPostStorere-derives the options on every keystroke even thoughuse-publish-post.ts:73-81wraps them again withuseMemo+onChallenge. Fix: keep only raw draft fields in state; computepublishCommentOptionsinuse-publish-post.tsviauseMemo.src/stores/use-boards-bar-visibility-store.ts:77-82— Store loadsgetAllBoardCodes()on first-render of any consumer. If the board-codes module changes size between releases (which it does; 5chan ships a new board list regularly), existing users keep their old set and new boards are invisibly hidden. Fix: store only the hidden set, not the visible one; default "visible" becomes "not in hidden set", and new boards appear automatically.src/stores/use-reply-modal-store.ts:14-16, 65-96— Event-stream fields (quoteInsertRequestId,quoteInsertNumber,quoteInsertSelectedText) are abusing persisted state as a pub/sub channel. Consumers (reply-modal.tsx:59-61) diff the request id in auseEffectto react to the event. Fix: either use a proper event emitter/ref, or at minimum document the pattern; consider auseReplyQuoteInsertEventssubscription helper.src/components/boards-bar/boards-bar.tsx:100-103— 4 modal stores opened in one component. These three modal stores (directory,create-board,boards-bar-edit) anddisclaimerare structurally identical. Fix: oneuseModalsStorewithopenModal(name)/closeModal()— drops ~80 LOC and three files.
Low
src/stores/use-challenges-store.ts:4—let nextChallengeId = 0;is module-level mutable state that survives HMR inconsistently. Fix: move into the store.src/stores/use-external-quote-status-store.ts:9—let hideTimeoutat module scope, same concern. Fix: move into state or use a ref stored in the store.src/stores/use-popular-threads-options-store.tsis aliased asuseHomeFiltersStoreinsrc/views/home/box-modal/box-modal.tsx:3. Misleading. Fix: rename the store or the import.src/stores/use-feed-reset-store.ts— stores a function to cross-cut-call "reset the current feed". This is a ref/command bus, not state. Fix: Zustand is OK for this, but document it, or switch to a simple module-level{ reset?: () => void }with a setter to avoid the re-render on every subscribe.src/stores/use-catalog-filters-store.ts:311-325—getFilteredCountForCurrentCommunityis a pure function over state but it is an action on the store. Callers that depend on it won't re-render when state changes. Fix: expose as a selectorselectFilteredCountForCurrentCommunity(state).src/stores/use-mod-queue-store.ts:77-80— Same pattern:getAlertThresholdSecondsis a pure derivation (value * 3600orvalue * 60) stored as an action. Callers likepost-desktop.tsx:247read it at render and compute against it, missing re-renders when the threshold changes. Fix: make it a selector(s) => s.alertThresholdUnit === 'hours' ? s.alertThresholdValue * 3600 : s.alertThresholdValue * 60.src/stores/use-app-update-store.ts:6-10—isApplyingUpdate+isCheckingForUpdate+availableUpdateis flag soup; only one of (idle,checking,available,applying) is true at a time. Fix: a discriminatedstatusunion.src/stores/use-feed-cache-store.ts:22-42—accessFeedmanually sorts + slices an array to simulate an LRU. WithmaxCacheSize: 2, this is fine in practice but the sort-slice semantics are wrong for size > 2 (keeps oldest plus newest). Fix: replace withMapinsertion-order semantics (delete + re-set).
Top 5 Actions
- Fix
use-catalog-filters-store(Critical). Remove the stored closurefilter, remove thesetTimeoutthat mutates state from inside a predicate, and delete the module-level.getState().updateFilter()primer. Make filtering a pure selector overfilterItems/searchText/communityAddress, and move per-community counting into the catalog view where filtering already happens. This one store change eliminates the biggest re-render and correctness hazards in the codebase. - Introduce
useShallowas the default pattern for multi-field reads. Add it tosrc/app.tsx:183(Reply modal wrapping everything),src/components/catalog-filters/catalog-filters.tsx:50,src/hooks/use-publish-post.ts:12,src/hooks/use-publish-reply.ts:25, and the ~30const { a, b } = useXStore()call sites in components/views. Low-risk, mechanical, immediately cuts re-renders. - Migrate the six hand-rolled-localStorage stores to
persistmiddleware.use-boards-bar-visibility-store,use-boards-filter-store,use-catalog-style-store,use-all-feed-filter-store,use-popular-threads-options-store,use-disclaimer-modal-store. Usemigrateto copy values from the legacy keys once. Deletes serialization code, unifies hydration semantics with the rest of the codebase, and removes a class of "default-true vs stored-false" bugs. - Collapse the three near-identical modal stores (
use-directory-modal-store,use-create-board-modal-store,use-boards-bar-edit-modal-store) into oneuseModalsStorekeyed by modal name. Mergeuse-selected-text-storeintouse-reply-modal-store(it has no other consumer). Net: -4 files, less cross-store wiring. - Split
use-mod-queue-storeinto persisted data vs session UI, and convertgetAlertThresholdSeconds+use-catalog-filters-store.getFilteredCountForCurrentCommunityfrom actions to selectors. Fixes stale-on-change reads inpost-desktop.tsx,post-mobile.tsx, andmod-queue.tsx, and stops theviewModetoggle from invalidating every consumer of the 500-entry persisted history array.