mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(codebase audit): preserve cleanup without regressions
Fix codebase audit regressions while preserving UI/UX behavior and adding review-driven hardening.
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
# Codebase Audit — Summary
|
||||
|
||||
**Date:** 2026-04-23
|
||||
**Branch:** `codex/chore/codebase-audit`
|
||||
**Scope:** `src/` (~50k LOC, 364 files)
|
||||
**Method:** 7 parallel read-only agents, each focused on a distinct angle. No source files were modified.
|
||||
|
||||
## Reports
|
||||
|
||||
| # | Angle | File | Headline |
|
||||
|---|---|---|---|
|
||||
| 01 | React anti-patterns | [01-react-anti-patterns.md](01-react-anti-patterns.md) | Moderate-to-good; effect cascades and copy-paste clusters are the main cost |
|
||||
| 02 | State management | [02-state-management.md](02-state-management.md) | `use-catalog-filters-store` is seriously broken; no `useShallow` anywhere |
|
||||
| 03 | Security | [03-security.md](03-security.md) | RNG polyfill is a critical latent bug; no CSP; embed iframes are under-sandboxed |
|
||||
| 04 | Dead code & duplication | [04-dead-code-duplication.md](04-dead-code-duplication.md) | Codebase is remarkably clean; duplication is the real issue |
|
||||
| 05 | Accessibility | [05-accessibility.md](05-accessibility.md) | Posting/reply surfaces are unlabeled; modals aren't real dialogs |
|
||||
| 06 | Performance | [06-performance.md](06-performance.md) | Three critical re-render hotspots; zero lazy-loaded images in feeds |
|
||||
| 07 | Type safety | [07-type-safety.md](07-type-safety.md) | Good overall; 4 realistic runtime-crash `!` assertions and a `PostProps.post?: any` leak |
|
||||
|
||||
## Criticals Across All Reports
|
||||
|
||||
The 11 findings worth fixing first, consolidated:
|
||||
|
||||
1. **RNG polyfill backed by `Math.random()`** — `src/polyfills.js:20-29`. Catastrophic for keypair/signature derivation if ever hit. _Security._
|
||||
2. **`use-catalog-filters-store` stores a closure in state, mutates during render via `setTimeout`, primes before persist rehydration** — `src/stores/use-catalog-filters-store.ts`. _State mgmt._
|
||||
3. **No Content-Security-Policy** anywhere in `vercel.json` / `index.html`. _Security._
|
||||
4. **Third-party embed scripts inherit 5chan origin** — Twitter/Reddit/TikTok/Instagram widgets in `src/components/embed/embed.tsx` load into `about:srcdoc` iframes without `sandbox` or SRI. _Security._
|
||||
5. **Peer-supplied media URLs pass without a protocol/host allow-list** — only `new URL(url)` validation. _Security._
|
||||
6. **Mod-queue derived-state-via-effect cascade** — `src/views/mod-queue/mod-queue.tsx:672-772`, `ModQueueCountItem` renders null just to lift state upward through effects per feed item. _React patterns._
|
||||
7. **`reply-modal` has 8 `useEffect`s with explicit infinite-loop guard refs** — `src/components/reply-modal/reply-modal.tsx:104-288`. Author's own comments acknowledge the problem. _React patterns._
|
||||
8. **Reply-modal & post-form inputs have no programmatic labels** — placeholders only; not inside `<form>`; close buttons are empty `<button title='Close'>`. _A11y._
|
||||
9. **`useWindowWidth` has no throttling** and fires per-consumer listeners on every resize. _Performance._
|
||||
10. **Four non-null assertions reachable from user input that can realistically throw** — `src/lib/utils/media-utils.ts:182/195/201` and `src/components/settings-modal/account-settings/account-settings.tsx:123`. _Type safety._
|
||||
11. **`PostProps.post?: any` / `reply?: any`** — leaks `any` into the two hottest files (`post-desktop.tsx`, `post-mobile.tsx`). _Type safety._
|
||||
|
||||
## Cross-Cutting Themes
|
||||
|
||||
Patterns that surfaced across multiple audits and likely share a fix:
|
||||
|
||||
- **`post-desktop.tsx` + `post-mobile.tsx`** appear in four of the seven reports (React patterns, state mgmt, dead code, perf, type safety). They share ~500 LOC of effect/memo logic verbatim. **Extracting shared post logic into a hook would move the needle on five audits at once.**
|
||||
- **Whole-store Zustand subscriptions** appear in every reader-of-state audit. Introducing `useShallow` is a two-line import that immediately reduces re-render pressure in `GlobalLayout`, `Catalog`, `PopularThreads`, `BoardsBar`, `FeedCacheContainer`.
|
||||
- **Copy-paste effect patterns** — 10 views each re-implement `document.title` + `window.scrollTo(0,0)`; escape-to-close, click-outside, focus-on-mount, resize-listener, `isAccountMod`, approve/reject moderation handler appear 6+ times each. These become ~5 tiny shared hooks.
|
||||
- **Modal infra is unfinished** — the a11y audit wants `role="dialog"` + focus traps; the React-patterns audit finds orchestration-via-effects; the state-mgmt audit finds three duplicate modal stores (`directory`, `create-board`, `boards-bar-edit`). One proper `<Dialog>` primitive would address all three.
|
||||
- **Peer-content boundary is under-defended** — shows up in security (URL schemes, embeds, ReDoS), perf (regex compilation per comment), and type safety (`any` in `challenge-utils.ts`). A typed `PeerContent` boundary layer would help all three.
|
||||
|
||||
## Confirmed Clean
|
||||
|
||||
Things the audits expected might be problems but weren't:
|
||||
|
||||
- **Zero `dangerouslySetInnerHTML`, zero `document.write`, zero `eval`/`new Function`, zero production `innerHTML =`.** All `target="_blank"` links carry `rel`.
|
||||
- **Zero `@ts-ignore` / `@ts-nocheck`** in production code. One justified test-only `@ts-expect-error`.
|
||||
- **Zero TODO/FIXME/HACK/XXX markers** in `src/` (excluding `generated/` and `e2e/`). No `@deprecated` tags, no commented-out blocks.
|
||||
- **Prior perf audits are fully absorbed** — findings from `popular-threads-rerenders`, `mobile-virtuoso-scroll-jank`, `pretext-feed-sizing` are all present on master; the perf audit avoids re-litigating them.
|
||||
- **Knip output is genuinely short** — only 7 real dead exports across the whole codebase.
|
||||
- **No `useState` used for shared state** in the anti-patterns audit's spot-check. Zustand adoption is consistent.
|
||||
|
||||
## Recommended Order of Operations
|
||||
|
||||
If you want to fix a subset, the cheapest-per-impact ordering:
|
||||
|
||||
1. **Quick wins (1-2 hrs each):** polyfill RNG removal, CSP header, `loading="lazy"`/`width`/`height` on feed `<img>`, `useShallow` introduction, delete the 7 knip-flagged dead exports.
|
||||
2. **Medium cleanups (half-day each):** throttle `useWindowWidth`, fix the 4 runtime-crash `!` assertions, label posting/reply form controls, add `role="dialog"` + focus trap primitive, extract `useApproveRejectModeration` hook.
|
||||
3. **Bigger refactors (1-2 days each):** rewrite `use-catalog-filters-store`, extract `post-desktop`/`post-mobile` shared logic, split `use-mod-queue-store` into data vs UI, retype `challenge-utils.ts` + `PostProps`, harden peer-URL validation at the boundary.
|
||||
|
||||
Everything else in the per-report "Top 5 Actions" sections is fair game once the above are done.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- No code changes yet — this branch (`codex/chore/codebase-audit`) only contains these audit reports under `docs/agent-runs/codebase-audit-2026-04-23/`.
|
||||
- Merging the reports to master is optional; they're useful as a snapshot even if no fixes land.
|
||||
- When ready to act on findings, spin off per-report task branches so review PRs stay focused.
|
||||
@@ -0,0 +1,57 @@
|
||||
# React Anti-Patterns Audit
|
||||
|
||||
## Summary
|
||||
|
||||
Overall React health is moderate-to-good: the codebase mostly follows the repo's architectural rules (Zustand stores cover real shared state, Bitsocial hooks cover data fetching, Router is used for navigation). The anti-pattern concentration is in three places — a pervasive "derived-state via effect" style (scroll, title, keydown, resize), the two large `Post` components (post-desktop/post-mobile duplicate effect-heavy logic), and a handful of hook-level issues in `use-directories`, `use-publish-reply`, and the `ModQueue` subtree. There is almost no shared-state-in-`useState` anti-pattern left; the remaining `useState` calls are legitimately local UI state.
|
||||
|
||||
## Findings
|
||||
|
||||
### Critical
|
||||
|
||||
- **src/hooks/use-directories.ts:331-462** — `useDirectories` and `useDirectoriesState` are effectively the same hook duplicated wholesale (same `useState` + `useEffect` + module-cache + localStorage + GitHub fetch pattern, ~60 lines each). Both fall under the "no `useEffect` for data fetching; no copy-paste logic" rules, and both power virtually every view via `useTheme`, `BoardLayout`, `Board`, `Catalog`, `Post`, `ModQueue`, `BoardsBar`, etc. *Fix:* Collapse into a single Zustand directories store whose `ensureHydrated()` action is called once at app start (or from a thin shared wrapper hook). Remove the duplicated `useState`/`useEffect` bodies; keep `useDirectories`/`useDirectoriesMetadata` as pure selectors. This would also remove the awkward `return cacheCommunities || state.communities || ...` ternary on line 397.
|
||||
- **src/views/mod-queue/mod-queue.tsx:672-772** — `ModQueueCountItem` is a "render-null-just-to-fire-an-effect-into-the-parent" pattern. Each feed item mounts, runs `useEffect(() => onStatusChange(cid, { awaiting, urgent }), [...])` (line 682), and the parent `ModQueueButtonContent` aggregates a `statusMap` `useState<Map>` via `handleStatusChange`. Also has a stale-cleanup effect on line 710. This is textbook derived state pushed up via effects, and causes an O(feed) cascade of renders whenever anything in the feed changes. *Fix:* Compute `normalCount`/`urgentCount` during render in `ModQueueButtonContent` with a plain `.reduce` over `feed` (the awaiting/urgent predicates are pure functions of `comment`, `currentTime`, `alertThresholdSeconds`). Delete `ModQueueCountItem`, `statusMap`, `handleStatusChange`, and the cleanup effect.
|
||||
- **src/components/reply-modal/reply-modal.tsx:104-288** — 8 `useEffect`s in one component, several of which are orchestrating state that could be derived or moved into the existing `useReplyModalStore`/`usePublishReply` hooks. Notable: line 156 syncs `parentCidRef.current.style.width` from DOM measurement (fine), but line 163 mixes imperative textarea focus with an escape-key listener, line 186 is a dead one-shot `useEffect(..., [])` setting selection range on an empty textarea, line 196 imperatively mutates `textRef.current.value` (bypassing React) and calls `setTimeout`, line 226 guards an infinite loop via `lastProcessedQuoteInsertRequestIdRef` (see the comment on line 234 — the author acknowledged the effect fights itself). *Fix:* Split modal state into a `useReplyModalController` hook; model quote-insertion as an imperative handler triggered by store subscription (`useEffect` on a `requestId` is the smell), not an effect driven by changing store values.
|
||||
|
||||
### High
|
||||
|
||||
- **src/components/post-desktop/post-desktop.tsx:930-1102 vs src/components/post-mobile/post-mobile.tsx:651-779** — post-desktop and post-mobile share three near-identical effect blocks: (1) `setResetFunction` when in post-page/pending view (desktop 930, mobile 651), (2) `repliesResetRequestId` reset guard using a ref (desktop 937, mobile 658), (3) Virtuoso scroll-state save listener (desktop 1089, mobile 767). Both files are ~1000+ LOC and most of the reply-rendering logic above these effects is also duplicated (`filterRepliesForDisplay`, `directRepliesByParentCid`, `useQuotedByMap`, `useReplyHeightEstimates`, `useProgressiveRender`, etc.). *Fix:* Extract a `usePostReplies({ isMobile })` hook that owns the shared effects + memos, plus a `useVirtuosoStateSave(key, enabled)` hook for (3). The two components can keep only layout differences.
|
||||
- **src/hooks/use-publish-reply.ts:135-208 vs src/hooks/use-publish-post.ts:86-98** — Both hooks use `useState` for publish-error/state-string that the child component (`PostForm`, `ReplyModal`) only reads back, plus an effect that resets those states when the publish inputs change. This is derived state synced by effect; the reset values are all `null`/`false`, and "when any publish input changes, error goes away" is a render-time computation using the previous error plus the latest input key. The two hooks also share the `abandonPublishRef`/`publishOptionsWithAbandon`/`createBaseOptions`/`setPublishXxxOptions` pattern almost verbatim (~80 LOC of duplication). *Fix:* Extract a `usePublishLifecycle(store, { kind: 'post' | 'reply' })` helper; move the `publishError` reset into the publish action itself (on success/failure) instead of a dependency-array-driven effect. Consolidate the duplicate option-merging boilerplate.
|
||||
- **src/components/boards-bar/boards-bar.tsx:31-62, 367-385** — `SearchBar` (desktop) has three separate effects for (a) focus on mount, (b) click-outside close, (c) escape-key close; `BoardsBarMobile` then repeats a `setShowSearchBar` pattern plus a scroll-listener `useState(visible)` + debounced-scroll-effect for nav hide/show. The click-outside + escape-to-close + focus-on-mount triplet is repeated across `SettingsModal`, `CatalogFilters`, `ChallengeModal`, `ReplyModal`, `CreateBoardModal`. *Fix:* Extract `useEscapeToClose(onClose)`, `useClickOutsideToClose(ref, onClose)`, `useAutoFocus(ref)` into `src/hooks/`. `useScrollDirection()` would replace the hand-rolled scroll listener + `visible` state.
|
||||
- **src/views/board/board.tsx:313-413** — Seven `useEffect`s in close proximity, most of which are route-sync effects guarded by `isVisible`. Several of them only derive pagination / infinite-scroll redirects from `location.pathname` + `effectiveInfiniteScroll` + `totalPages` (lines 378, 386, 396) — candidates for computing the navigation *during the click handler or render* rather than after mount. The "has been visible" scroll-to-top effect (line 549) uses a ref flag to simulate a once-per-mount guard; a `key` on the subtree or `useNavigationType()` at render would be cleaner. Document-title effect on line 579 duplicates the same pattern as in catalog.tsx:779 and post.tsx:333.
|
||||
- **src/views/catalog/catalog.tsx:320-795** — Six `useEffect`s, including a canonical-redirect effect (line 320) that duplicates the board.tsx:378 effect, a `setCurrentCommunityAddress` store-sync effect (line 339), a `reset()` triggered by `filteredComments.length` via a ref latch (line 513) — also duplicated at board.tsx:402 — a `setResetFunction` effect (line 522), and a doc-title effect (line 779). *Fix:* Extract `useCanonicalMultiboardRedirect(location)`, `useFeedResetFunction(reset, isVisible)`, and `useReplayFeedReset(filteredComments, reset)` hooks shared between board and catalog.
|
||||
- **Document title + scroll-to-top duplication across views** (`src/views/blotter/blotter.tsx:15`, `src/views/faq/faq.tsx:7`, `src/views/pass/pass.tsx:32-38`, `src/views/rules/rules.tsx:181`, `src/views/pending-post/pending-post.tsx:19`, `src/views/home/home.tsx:193`, `src/views/archive/archive.tsx:208`, `src/views/board/board.tsx:579`, `src/views/catalog/catalog.tsx:779`, `src/views/post/post.tsx:333`) — 10 views each implement their own `useEffect(() => { document.title = ...; window.scrollTo(0, 0); }, [deps])`. *Fix:* Add `useDocumentTitle(title)` and `useScrollToTopOnMount()` (or combined `useViewInit({ title })`) in `src/hooks/` and replace.
|
||||
- **src/components/catalog-search/catalog-search.tsx:19-26** — URL query param `?q=` is the source of truth, but the effect on line 19 pushes `queryParam` into the `useCatalogFiltersStore` on every change. The component also holds a separate `searchState` `useState` and computes `inputValue` and `openSearch` by mixing URL, local state, and store. This is the "syncing derived state with an effect" + "boolean flag soup" smells combined. *Fix:* Make the store a selector of the URL (compute `searchFilter` from `location.search` during render, or subscribe via a single `useSearchFilterFromUrl()` selector); remove the effect. Model `searchState` as a discriminated `{ mode: 'closed' } | { mode: 'open'; value: string }`.
|
||||
|
||||
### Medium
|
||||
|
||||
- **src/hooks/use-fetch-gif-first-frame.ts:85-135** — `useState` + `useEffect` + module-level `Set<string>` for failed URLs + localForage cache. Classic "data fetching in an effect" that the repo rule says should live in a Bitsocial hook or a Zustand store. Also uses status strings (`'idle'|'loading'|'ready'|'failed'`) which is fine, but the `setGifFirstFrame((prev) => ...)` identity checks (line 90, 95, 99, 126) are a code smell saying the effect is firing too often. *Fix:* Either (a) promote to a dedicated store indexed by URL with a thunk, or (b) turn into a `useSyncExternalStore`-backed cache so React subscribes rather than polls.
|
||||
- **src/hooks/use-comment-media-info.ts:19-66** — Fetches thumbnail dimensions by constructing `new Image()` inside `useEffect`, stores the result in component-local `useState`, then re-derives `CommentMediaInfo` with the stored dimensions in a `useMemo`. This is "data fetch in effect" and the resulting `thumbnailDimensions` is local per-consumer — two components rendering the same `link` will both fetch and double-measure. *Fix:* Move the dimension cache to a module-level `Map<string, { w, h }>` or Zustand store keyed by `link`, driven by an async action invoked during render (suspense-style) or by a Bitsocial hook.
|
||||
- **src/hooks/use-window-width.ts:3-15 + src/hooks/use-is-mobile.ts** — `useState` + resize-listener `useEffect`. Fine in isolation, but every consumer subscribes independently (mobile vs desktop posts both call `useWindowWidth`, as do reply-height estimates, catalog, boards-bar). On resize, every subscriber rerenders. *Fix:* Back with `useSyncExternalStore` so React can bail out when the value hasn't changed, or keep the subscription in a tiny Zustand store so the read surface is selector-based.
|
||||
- **src/views/account-data-editor/account-data-editor.tsx:52-78** — `useState` holds a single `EditorState` object with `phase`, `AceEditor`, `aceOnBeforeLoad`, and `text`. The effect on line 59 runs only when `phase === 'loading'` to import `react-ace`, then `setEditorState` replaces everything. This is a fine state machine, but handler functions mutate only `text` (line 141, 147) while phase is mutated via `handleContinue`/`handleGoBack`. The `account` dependency on line 78 will re-trigger the effect when account changes mid-load, which will double-import Ace. *Fix:* Split into `phase` + `editor` + `text` `useState`s or `useReducer` with explicit actions; guard the dynamic import with a ref so it only runs once per `phase === 'loading'` transition.
|
||||
- **src/components/settings-modal/settings-modal.tsx:45-65** — `expandedSections` (a `Set<string>`) is local `useState` that shadows the URL hash, and an effect on line 60 re-syncs the set from `hash` with an explicit `// eslint-disable-next-line react-hooks/exhaustive-deps`. The disable comment is a red flag: the author silenced the lint rule because the effect is "wrong" by the rules but the fix is deeper. *Fix:* Derive `expandedSections` from `hash` plus a separate "user-added" set; or keep a single Zustand store for expanded sections so the hash is just the deep-linkable entry point.
|
||||
- **src/views/post/post.tsx:293-297, 315-331, 381-429** — Seven effects on the `PostPage` component: `/not-found` redirect (line 293), "scroll to top unless thread-top intent" split across two effects (lines 315, 321) with `consumedThreadTopScrollRef` coordinating them, a cleanup-only `resetThreadLiveUpdates` effect (line 381), a thread-switch reset effect (line 387), and a refresh-by-cid orchestration effect (line 397-429). The split between the two scroll effects is explicitly documented in-file as subtle (comment at line 310) — a sign this is better modeled as one `useLayoutEffect` on `[location.key]` that reads the intent synchronously. *Fix:* Combine the two scroll effects into one keyed on `location.key`; move the refresh-by-cid orchestration into `useThreadLiveUpdatesStore` as an action invoked from a subscription in the store rather than an effect in the view.
|
||||
- **src/components/reply-quote-preview/reply-quote-preview.tsx:133-148, 304-311** — Desktop and mobile variants each run their own `window.addEventListener('resize', handleResize)` effect; same pattern (and same comment style) in `src/components/markdown/external-number-quote-link.tsx:83`, and `src/components/markdown/markdown.tsx:70`. *Fix:* One `useWindowResize(callback)` hook.
|
||||
- **src/components/edit-menu/edit-menu.tsx:45-110, 165-222** — Five `useState` slots plus `defaultPublishEditOptions` `useMemo` plus `publishCommentEditOptions` `useState` initialized from the memo. When `defaultPublishEditOptions` changes (lots of deps on line 92), the local `useState` is *not* reset — so opening the menu after the comment state changes can show stale options. Combined with `banDuration` (line 165) also initialized from `defaultPublishEditOptions.commentModeration?.author?.banExpiresAt` only once, this is a "state initialized from props and never re-synced" bug-magnet. *Fix:* Use a `key={cid}` wrapper or derive during render; for the editable slice of state that must be local, reset it in the "open" handler rather than by effect.
|
||||
|
||||
### Low
|
||||
|
||||
- **src/components/markdown/external-number-quote-link.tsx:53-56** — Four correlated `useState`s (`isResolving`, `isPreviewOpen`, `previewState`, `previewPosition`). A discriminated `previewState` union (`{ kind: 'idle' } | { kind: 'resolving' } | { kind: 'open'; position } | { kind: 'error' }`) would eliminate impossible combinations and the `setIsResolving(false)` bookkeeping.
|
||||
- **src/components/catalog-filters/catalog-filters.tsx:113-116, 240-252** — Two more escape-key-close effects; covered by the suggested `useEscapeToClose` extraction.
|
||||
- **src/components/challenge-modal/challenge-modal.tsx:129-157, 232-305** — Four effects; line 129 sends a theme message post-load (fine), line 135 is a window-message handler (fine), line 232 and 297 are focus-on-mount and escape-to-close — again covered by the shared hooks extraction.
|
||||
- **src/index.tsx:52-58** — `window.history.back()` inside the Capacitor back-button listener. Since the HashRouter is available at that point, this could be `navigate(-1)` via a small bridge, but the Capacitor listener is fired outside React tree, so calling `window.history.back()` directly is defensible. *Fix:* none required; keep the comment explicit and ignore from the "no manual history" rule since it's a native bridge, not in-app nav.
|
||||
- **src/views/pass/pass.tsx:32-38** — Two adjacent `useEffect(() => { ... }, [])` blocks; trivially combinable into one. Covered by `useDocumentTitle`/`useScrollToTopOnMount`.
|
||||
- **src/views/pending-post/pending-post.tsx:19, 28, 34** — Three effects, two of which are navigation side-effects. The `post`-driven redirect on line 34 fires on every `post` identity change; should depend on `post?.cid` + `postCommunityAddress` not on `post` as a whole.
|
||||
- **src/components/boards-bar/boards-bar.tsx:140-142** — `useBoardsBarVisibilityStore.getState().initialize()` called from an empty-deps `useEffect`. If the store's initialize is idempotent this is fine, but it's an implicit "mount once, anywhere" that would be cleaner as a top-level store side-effect on creation.
|
||||
- **src/components/post-form/post-form.tsx:391-476** — Five effects for display-name hydration, publish success navigation, reply success close, and upload callbacks — each fires once per success and sets a `hasInitializedDisplayName` ref to guard re-runs. The ref-gating pattern (also in reply-modal.tsx:282 and use-publish-reply.ts:147) is a known smell that says "this should be an event, not an effect".
|
||||
|
||||
## Top 5 Actions
|
||||
|
||||
1. **Consolidate `useDirectories` / `useDirectoriesState` into a Zustand store.** Removes ~100 LOC of duplicated fetch-in-effect, simplifies the "stable reference for memoization" contract that currently leaks into every consumer, and fixes the rule-violating data-fetch-in-effect at the root of the dependency tree (`src/hooks/use-directories.ts:331-462`).
|
||||
|
||||
2. **Delete the `ModQueueCountItem` / `statusMap` effect cascade.** Compute `normalCount` and `urgentCount` purely during render in `ModQueueButtonContent` (`src/views/mod-queue/mod-queue.tsx:672-772`). This removes a per-feed-item render-null-to-fire-effect pattern that's both a textbook anti-pattern and a real performance hazard on boards with large queues.
|
||||
|
||||
3. **Create shared view-init hooks: `useDocumentTitle`, `useScrollToTopOnMount`, `useEscapeToClose`, `useClickOutsideToClose`, `useAutoFocus`, `useWindowResize`, `useScrollDirection`.** Replace the ~20 scattered `useEffect`s across views and modals (`blotter`, `faq`, `pass`, `rules`, `home`, `archive`, `board`, `catalog`, `post`, `pending-post`, plus `boards-bar`, `settings-modal`, `catalog-filters`, `challenge-modal`, `reply-modal`, `reply-quote-preview`, `markdown`, `external-number-quote-link`). High leverage, low risk — most replacements are mechanical.
|
||||
|
||||
4. **Refactor post-desktop / post-mobile shared logic into `usePostReplies` and `useVirtuosoStateSave` hooks.** Both files are ~1000+ LOC, they duplicate reply filtering, height estimation, progressive rendering, fresh-replies registration, reset-function wiring, and Virtuoso scroll-state save listeners. Extracting the shared tail end (from `freshRepliesForRender` onward) into a hook cuts both files roughly in half and removes the parallel-edit risk.
|
||||
|
||||
5. **Split route-sync effects in `board.tsx` and `catalog.tsx` into shared hooks.** Specifically: `useCanonicalMultiboardRedirect`, `useFeedResetFunction(reset, isVisible)`, `useReplayFeedResetOnAccountComments(filteredComments, reset)`, `useScrollToTopOnFirstVisible(isVisible, navigationType)`. Both views currently each carry 6-9 near-duplicate effects; consolidating them reduces effect-count at the view layer and makes the remaining view code closer to pure render.
|
||||
@@ -0,0 +1,93 @@
|
||||
# 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`** — `updateFilter` stores a new `(comment) => boolean` closure inside the store state. The closure captures `state` via `set((state) => ({ filter: ... }))`, so every subsequent mutation to `filterItems`, `searchText`, or `currentCommunityAddress` requires re-running `updateFilter()` to refresh the closure. Missing any of those paths produces a stale filter. Additionally, inside the filter at line 232 it calls `setTimeout(...)` to schedule `incrementFilterCount`, 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. Expose `matchesFilter(comment, communityAddress)` as a pure selector (or a utility that takes `filterItems` + `searchText` as 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-level `useCatalogFiltersStore.getState().updateFilter();` runs at module import. This executes before React is mounted and before the `persist` rehydration completes, so the initial filter closure sees empty `filterItems` even though persisted filters exist. Consumers then get a stale filter until another action happens to call `updateFilter`. *Fix:* trigger `updateFilter` from `onRehydrateStorage`, or replace with a pure selector so there is nothing to prime.
|
||||
- **`src/stores/use-catalog-filters-store.ts:361-370`** — `partialize` drops all non-text fields (`count`, `filteredCids`, `communityCounts`, `communityFilteredCids`) but the `deserialize` migration on the next load calls `normalizeFilterItem` which re-creates empty `Map`/`Set` instances. That is fine, but the `deserialize` function never accounts for the version, and the branch at line 372 does `JSON.parse` manually — but the default `persist` storage already parses. On repeat calls it is parsing a plain object as a string, swallowing the error in the `typeof persisted === 'string'` check. *Fix:* drop the custom `deserialize` entirely; persisted data is already plain strings, and `Map`/`Set` revival should go through `storage: createJSONStorage(() => localStorage, { reviver, replacer })`.
|
||||
|
||||
### High
|
||||
|
||||
- **`src/app.tsx:183`** — `GlobalLayout` destructures 8 fields off `useReplyModalStore()` with no selector. This is a top-level always-mounted component; it re-renders every time any field (`quoteInsertRequestId` counter, `openEmpty`, `scrollY`, etc.) changes. *Fix:* split into per-field `useReplyModalStore((s) => s.activeCid)` selectors (already the pattern used inside `reply-modal.tsx` itself) or use `useShallow` on 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 })` without `useShallow`. Zustand compares the selector result with `Object.is` by default, so the new object is never equal and the hook re-fires on every store change. *Fix:* wrap with `useShallow` from `zustand/react/shallow`, or split into one-field selectors.
|
||||
- **`src/stores/use-catalog-filters-store.ts:25`, `205`, `208`** — `filter: ((comment: Comment) => boolean) | undefined` is a function stored in state. Every consumer that reads `state.filter` re-subscribes to identity of this function. Combined with `updateFilter` rebuilding 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 via `zustand/middleware` or Zustand's `createSelector` equivalent.
|
||||
- **`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`). Every `rememberCommentsInQueue` mutation re-renders every consumer of `viewMode` across `board-buttons.tsx` and `mod-queue.tsx`. *Fix:* split into `use-mod-queue-data-store` (persisted) and `use-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, `resetPublishReplyStore` sets each `[parentCid]: undefined` instead of deleting, so the dictionaries grow unboundedly for the lifetime of the session. *Fix:* collapse to `drafts: Record<string, ReplyDraft>` with a single `setDraft(parentCid, patch)` and `deleteDraft(parentCid)` that does `const { [parentCid]: _, ...rest } = state.drafts`.
|
||||
- **`src/stores/use-reply-modal-store.ts:82-95`** — `window.innerWidth` read 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 a `use-is-mobile` hook), and accept a `scrollY` argument.
|
||||
- **`src/stores/use-reply-modal-store.ts:53, 79, 99`** — `useReplyModalStore` writes into `useSelectedTextStore` via `.getState()`. Bidirectional coupling: the two stores are really one feature. *Fix:* merge `selectedText` into `useReplyModalStore` (it is only ever used by the reply modal), and delete `use-selected-text-store.ts`.
|
||||
|
||||
### Medium
|
||||
|
||||
- **`src/stores/use-community-offline-store.ts:38-48`** — `initializeCommunityOfflineState` schedules a naked `setTimeout(..., 30_000)` that mutates the store. If the user navigates away, the timer still fires and flips `initialLoad: false` on unmounted communities. And `initialLoad` itself is derivable — `Date.now()/1000 - loadingStartTimestamp < 30` (the hook already computes this at `use-is-community-offline.ts:36`). *Fix:* delete `initialLoad` and the timer; derive from the already-persisted timestamp in `use-communities-loading-start-timestamps-store`.
|
||||
- **`src/stores/use-communities-loading-start-timestamps-store.ts:37-39`** — `communitiesLoadingStartTimestamps` selector returns `communityAddresses.map(addr => timestampsStore[addr])` — a new array every render even when unchanged. The outer `useMemo` prevents re-renders of *this* hook but its consumers still see a new array reference each time `timestampsStore` mutates for any address. *Fix:* select the specific timestamps via `useShallow`, 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-roll `localStorage.getItem/setItem` inside store definitions instead of using the `persist` middleware that's already imported elsewhere. This multiplies the serialization bugs (e.g. `use-boards-bar-visibility-store.ts:54` parses an array as a boolean; `use-popular-threads-options-store.ts:11` has the "default true unless false" logic duplicated). *Fix:* migrate to `persist({ name, version, migrate, partialize })`. The existing keys can be mapped via `storage: createJSONStorage(() => localStorage)` with a `migrate` fn to copy from the legacy keys.
|
||||
- **`src/stores/use-disclaimer-modal-store.ts:10, 36, 63`** — Store actions accept a React Router `NavigateFunction`. 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 tiny `useDisclaimerNavigation` hook subscribe and call `navigate`.
|
||||
- **`src/stores/use-theme-store.ts:32-39`** — `getTheme` is named like a selector but also mutates state (`set({ currentTheme: theme })`). Plus there's a `updateCurrentTheme=true` parameter that silently toggles side-effect behavior. *Fix:* split into a pure `getTheme(category)` selector and an explicit `setCurrentTheme` action.
|
||||
- **`src/stores/use-theme-store.ts:54`** — `useThemeStore.getState().loadThemes()` runs at import time. `loadThemes` is async (reads from `localforage`). 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 an `isLoaded` flag so `useTheme()` can gate rendering, or call `loadThemes` from 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-built `publishCommentOptions` derived from those fields. They can drift. And `setPublishPostStore` re-derives the options on every keystroke even though `use-publish-post.ts:73-81` wraps them again with `useMemo` + `onChallenge`. *Fix:* keep only raw draft fields in state; compute `publishCommentOptions` in `use-publish-post.ts` via `useMemo`.
|
||||
- **`src/stores/use-boards-bar-visibility-store.ts:77-82`** — Store loads `getAllBoardCodes()` 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 a `useEffect` to react to the event. *Fix:* either use a proper event emitter/ref, or at minimum document the pattern; consider a `useReplyQuoteInsertEvents` subscription 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`) and `disclaimer` are structurally identical. *Fix:* one `useModalsStore` with `openModal(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 hideTimeout` at module scope, same concern. *Fix:* move into state or use a ref stored in the store.
|
||||
- **`src/stores/use-popular-threads-options-store.ts`** is aliased as `useHomeFiltersStore` in `src/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`** — `getFilteredCountForCurrentCommunity` is 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 selector `selectFilteredCountForCurrentCommunity(state)`.
|
||||
- **`src/stores/use-mod-queue-store.ts:77-80`** — Same pattern: `getAlertThresholdSeconds` is a pure derivation (`value * 3600` or `value * 60`) stored as an action. Callers like `post-desktop.tsx:247` read 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` + `availableUpdate` is flag soup; only one of (`idle`, `checking`, `available`, `applying`) is true at a time. *Fix:* a discriminated `status` union.
|
||||
- **`src/stores/use-feed-cache-store.ts:22-42`** — `accessFeed` manually sorts + slices an array to simulate an LRU. With `maxCacheSize: 2`, this is fine in practice but the sort-slice semantics are wrong for size > 2 (keeps oldest plus newest). *Fix:* replace with `Map` insertion-order semantics (delete + re-set).
|
||||
|
||||
## Top 5 Actions
|
||||
|
||||
1. **Fix `use-catalog-filters-store`** (Critical). Remove the stored closure `filter`, remove the `setTimeout` that mutates state from inside a predicate, and delete the module-level `.getState().updateFilter()` primer. Make filtering a pure selector over `filterItems`/`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.
|
||||
2. **Introduce `useShallow` as the default pattern for multi-field reads.** Add it to `src/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 ~30 `const { a, b } = useXStore()` call sites in components/views. Low-risk, mechanical, immediately cuts re-renders.
|
||||
3. **Migrate the six hand-rolled-localStorage stores to `persist` middleware.** `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`. Use `migrate` to 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.
|
||||
4. **Collapse the three near-identical modal stores (`use-directory-modal-store`, `use-create-board-modal-store`, `use-boards-bar-edit-modal-store`) into one `useModalsStore`** keyed by modal name. Merge `use-selected-text-store` into `use-reply-modal-store` (it has no other consumer). Net: -4 files, less cross-store wiring.
|
||||
5. **Split `use-mod-queue-store` into persisted data vs session UI, and convert `getAlertThresholdSeconds` + `use-catalog-filters-store.getFilteredCountForCurrentCommunity` from actions to selectors.** Fixes stale-on-change reads in `post-desktop.tsx`, `post-mobile.tsx`, and `mod-queue.tsx`, and stops the `viewMode` toggle from invalidating every consumer of the 500-entry persisted history array.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Security Audit
|
||||
|
||||
## Summary
|
||||
|
||||
5chan's React frontend has a generally defensive posture for an imageboard that renders untrusted peer content: zero `dangerouslySetInnerHTML` usages (prior scan confirmed), custom markdown tokenizer that leans on React's JSX escaping, `target="_blank"` links uniformly paired with `rel="noopener noreferrer"`, and URL parsing routed through the browser `URL` constructor. The highest-impact issues are a severely broken crypto RNG polyfill that silently downgrades `crypto.getRandomValues` to `Math.random`, third-party embed scripts loaded into `about:srcdoc` iframes that inherit the 5chan origin without Subresource Integrity, and the complete absence of a `Content-Security-Policy` header — so any future XSS has no defense-in-depth to contain it. Several medium issues stem from peer-controlled URLs flowing into `<img>`, `<video>`, `<audio>`, `<a href>`, and iframe embedders without origin validation, which is acceptable for many decentralized imageboard designs but warrants an explicit allow-list or protocol filter.
|
||||
|
||||
## Threat Surface Overview
|
||||
|
||||
| Trust boundary | Where it enters the client | Key files |
|
||||
|---|---|---|
|
||||
| Peer post content (title, content, link, reason) | Subplebbit/community feeds via `bitsocial-react-hooks` | `src/components/markdown/markdown.tsx`, `src/components/comment-content/comment-content.tsx`, `src/components/comment-media/comment-media.tsx`, `src/views/archive/archive.tsx` |
|
||||
| Peer-supplied media URLs | Comment `link` field | `src/lib/utils/media-utils.ts`, `src/components/comment-media/comment-media.tsx`, `src/components/catalog-row/catalog-row.tsx`, `src/components/embed/embed.tsx` |
|
||||
| External iframe challenges (captcha) | `challenge` field of type `url/iframe` | `src/components/challenge-modal/challenge-modal.tsx` |
|
||||
| External webpage thumbnails (og:image/first `<img>`) | Community-served link preview fetched via `fetch()`/`CapacitorHttp` | `src/lib/utils/media-utils.ts` (`fetchWebpageThumbnail`) |
|
||||
| GitHub release manifest | `api.github.com` release JSON for auto-update | `src/lib/app-update.ts`, `src/lib/app-update-config.ts` |
|
||||
| GitHub directories list | `raw.githubusercontent.com` JSON | `src/hooks/use-directories.ts` |
|
||||
| Account data / private keys | bitsocial-react-hooks account store; editor reads/writes JSON including `signer` | `src/views/account-data-editor/account-data-editor.tsx`, `src/components/settings-modal/account-settings/account-settings.tsx`, `src/lib/utils/account-editor-utils.ts` |
|
||||
| Local preferences | `localStorage` keys (filters, subscriptions, UI state) | `src/stores/use-*-store.ts`, `src/hooks/use-directories.ts` |
|
||||
| URL query/hash routing | Location search/hash parsed via `URLSearchParams`/`is5chanLink` | `src/lib/utils/url-utils.ts`, `src/components/catalog-search/catalog-search.tsx`, `index.html` |
|
||||
| User regex hide filters | User-typed patterns compiled via `new RegExp` | `src/lib/utils/pattern-utils.ts` |
|
||||
| Native bridges | `window.electronApi.*` (copy clipboard, upload automation, installer) | `src/globals.d.ts`, `src/hooks/use-file-upload.ts`, `src/lib/app-update.ts`, `src/lib/utils/clipboard-utils.ts` |
|
||||
|
||||
## Findings
|
||||
|
||||
### Critical
|
||||
|
||||
- **src/polyfills.js:20-29** — `window.crypto.getRandomValues` is replaced with a `Math.random()`-backed fallback when `window.crypto` is undefined. `Math.random` is a non-cryptographic PRNG (typically xorshift/PCG seeded at startup, fully predictable). Anything that later calls `crypto.getRandomValues` under this polyfill — libsodium, noble-curves/ed25519, IPFS/libp2p key generation, nonces, session IDs inside bundled deps — silently produces guessable output. In modern browsers the branch shouldn't trip (crypto always exists), but the guard also short-circuits *any* environment where `crypto` exists but `getRandomValues` is missing (older SSR shims, some Electron preload corner cases), so the defensive posture is broken rather than safe-by-default. The severity is magnified because a decentralized imageboard derives account keypairs, signs publications, and derives author addresses from this primitive layer. *Fix:* remove the Math.random fallback entirely. If a host truly lacks `crypto.getRandomValues`, `throw` so callers bail; do not fabricate randomness. At minimum, guard the replacement behind a build-time flag so it never ships to production.
|
||||
|
||||
### High
|
||||
|
||||
- **src/components/embed/embed.tsx:113-315** — Third-party embed scripts (`platform.twitter.com/widgets.js`, `embed.reddit.com/widgets.js`, `www.tiktok.com/embed.js`, `//www.instagram.com/embed.js`) are injected via `srcDoc` iframes with no `sandbox` attribute and no Subresource Integrity hash. An `about:srcdoc` iframe inherits the embedding document's origin, so any script it loads executes *as 5chan.app* and can reach `window.parent.document`. A supply-chain compromise of any of those four CDNs (or a routed MITM on a user without HSTS) directly yields full XSS of the 5chan app, including read/write of IndexedDB account keys. This is amplified by the `//www.instagram.com/embed.js` scheme-relative URL, which downgrades to `http://` if the top-level page is ever served non-TLS (Electron `file://`, local dev). *Fix:* (a) add `sandbox="allow-scripts allow-popups allow-same-origin"` carefully chosen per embed type so cross-origin script compromise cannot reach parent; ideally omit `allow-same-origin` and rely on the iframe being a true opaque origin; (b) pin `src="https://..."` absolute for Instagram; (c) consider inlining the embed via the official oEmbed server-side rendering path or proxying through a CSP-constrained iframe host (e.g. a sandbox subdomain).
|
||||
- **index.html (entire file) + vercel.json:36-45** — No `Content-Security-Policy` header is emitted and no `<meta http-equiv="Content-Security-Policy">` exists. Given that the primary threat model is untrusted peer content and third-party scripts load into the origin via srcdoc iframes, a CSP is the most impactful missing control. A single XSS bypass (today, future regression, or supply-chain) would have full DOM access. *Fix:* add a strict CSP via Vercel header: `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; frame-src https:; img-src https: data: blob:; media-src https: blob:; connect-src 'self' https: wss:; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`. Tighten over time (drop `'unsafe-inline'` for styles once CSS-modules replaces any inline styles). Mirror with `Strict-Transport-Security: max-age=31536000; includeSubDomains` and, since the app is hash-routed, consider `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Resource-Policy: same-origin` to block spectre-style side-channels.
|
||||
- **src/components/comment-media/comment-media.tsx:74, 89, 93, 108, 124, 138, 218, 235, 238, 349, 388; src/components/catalog-row/catalog-row.tsx:84-101** — Peer-supplied `url` / `thumbnail` / `gifFrameUrl` are rendered directly as `<img src>`, `<video src>`, and `<audio src>` with no scheme or host allow-list. `getLinkMediaInfo` only validates that `new URL(link)` succeeds (`src/lib/utils/url-utils.ts:13-20`), so `javascript:foo` parses successfully and flows through. React auto-strips `javascript:` for `href` and `src` on `<a>`/`<iframe>`/`<script>`/`<form>` (since React 16.9), but `<img src="javascript:…">` is a no-op in modern browsers and `<video src>`/`<audio src>` likewise ignore javascript URLs — so the immediate XSS risk is low — however `data:` URIs and oversized `blob:`/`file:` URIs are not filtered, which lets a malicious community: (1) use `data:image/svg+xml,<svg onload=…>` style SVGs (browsers suppress script execution in `<img src>`-loaded SVGs but still fetch, and the stored-URL pattern becomes dangerous if ever rendered via `<object>` or `<iframe>`), (2) trigger unbounded memory via `data:` URLs, or (3) exfiltrate the visitor's IP to arbitrary servers simply by being rendered. *Fix:* in `getLinkMediaInfo`, reject URLs whose protocol is not `https:` or `http:` (and optionally `ipfs:`/`ipns:` for decentralized use). Extend the same check to `thumbnail` and `patternThumbnailUrl`. Consider routing all peer media through a rewrite that enforces protocol and normalizes origin.
|
||||
|
||||
### Medium
|
||||
|
||||
- **src/lib/utils/media-utils.ts:149-209 (`fetchWebpageThumbnail`)** — Peer-supplied URLs are fetched directly by the client with no hostname filtering. This is a browser-side SSRF amplifier: a malicious community can set `link` to a private-range URL (`http://192.168.1.1/…`, `http://169.254.169.254/…`, intranet hosts, etc.) that the visitor's browser will fetch, parse, and extract an `og:image` or `<img src>` from — then render that extracted URL as `<img src={thumbnail}>`. On native (`CapacitorHttp`) this bypasses CORS entirely, so the attacker can probe arbitrary intranet resources and exfiltrate the response's first `og:image`/first image through cached thumbnail storage. *Fix:* reject private-range/link-local hostnames before `fetch`, require `https:`, and strip the extracted thumbnail URL down to `https:` only. Set an `AbortController` timeout shorter than the current 5 s on native, and never cache a thumbnail whose hostname differs from the source link's registered domain.
|
||||
- **src/components/challenge-modal/challenge-modal.tsx:27** — `ImageChallenge` renders `data:image/png;base64,${challenge}` where `challenge` is a peer-supplied string. React escapes the attribute so breakout is not possible; however there is no size or base64-sanity check, so a community can ship a multi-megabyte "challenge" that exhausts the decoder, or a non-PNG payload that fails silently and presents a broken image (usability, not XSS). *Fix:* validate `challenge` is `/^[A-Za-z0-9+/=]{0,2000000}$/` before rendering; cap byte length; optionally decode with `atob` and sniff the PNG magic number.
|
||||
- **src/components/challenge-modal/challenge-modal.tsx:81-184** — `IframeChallenge` accepts a peer-provided URL, allows HTTPS plus any `localhost` / `127.0.0.1` / `[::1]` HTTP URL, and then hands it to an iframe with `sandbox="allow-scripts allow-forms allow-popups allow-same-origin allow-top-navigation-by-user-activation"`. `allow-same-origin` + `allow-scripts` + `allow-top-navigation-by-user-activation` is a well-known dangerous combination: the iframe inherits 5chan origin (well, it's a cross-origin iframe so `allow-same-origin` only gives it *its own* origin back, not 5chan's — this is OK), but `allow-top-navigation-by-user-activation` lets the challenge redirect the top frame after a click, enabling phishing attacks disguised as challenges. The `localhost` exemption also lets any community probe services listening on the user's machine. *Fix:* drop `allow-top-navigation-by-user-activation`; either drop the localhost exemption or scope it to an explicit allow-list of known bitsocial services (and show a louder UI warning). Enforce a Permissions-Policy on the iframe (e.g. `allow=""`).
|
||||
- **src/lib/app-update-config.ts:26-30** — `isAllowedDownloadUrl` accepts any URL whose hostname is exactly `github.com` (and any additional hosts via `VITE_APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS`, which is permitted to be `http:`). GitHub release asset downloads begin at `github.com/{owner}/{repo}/releases/download/…` and redirect to `objects.githubusercontent.com`; the native updater may or may not follow that redirect, and an attacker who compromises the Releases API response can return an arbitrary `browser_download_url` pointing at `github.com/<attacker>/<repo>/releases/download/…/evil.exe`. Because the check is hostname-only, any `github.com` owner suffices. *Fix:* tighten to `hostname === 'github.com' && url.pathname.startsWith('/bitsocialnet/5chan/releases/download/')`. Also drop the `http:` fallback in the `configuredDownloadHosts` branch.
|
||||
- **src/lib/utils/pattern-utils.ts:34** — User-authored regex filters (`/pattern/flags`) are compiled with `new RegExp(regexPattern, flags)`. Although these are user-typed for their own client, a combination of a pathological pattern like `/(a+)+$/` and peer content can lock the UI (ReDoS). *Fix:* wrap the `regex.test` call in a worker with an execution budget, or reject patterns that fail a complexity heuristic (nested quantifiers, >1000 chars). Low urgency since only the user harms themselves.
|
||||
- **src/components/settings-modal/account-settings/account-settings.tsx:101** — Export filename comes from `account?.name ?? 'account'` with no sanitization before being assigned to `link.download`. `account.name` is user-controlled, so a user (or imported backup) with a name like `../../etc/passwd.json` or one containing control characters would produce an odd download, and some browsers historically honored path separators. Low severity (self-inflicted, browsers normalize), but worth stripping. *Fix:* `link.download = (account?.name || 'account').replace(/[^\w.\-]/g, '_') + '.json'`.
|
||||
- **src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx:147-154 and src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx:148-155** — Reverse-image-search URLs interpolate peer `url` directly into a template: `` `https://lens.google.com/uploadbyurl?url=${url}` ``, `saucenao.com/search.php?url=${url}`, `yandex.com/images/search?img_url=${url}`. No `encodeURIComponent`; because `url` came from `new URL()`, dangerous characters like `"`, `<`, `#` are percent-encoded, but `&` is not, letting a crafted URL add extra query parameters to the third-party site (e.g. `&` followed by rogue query keys). Not a traditional XSS but a minor integrity/tracking concern. *Fix:* `encodeURIComponent(url)` in all three places.
|
||||
- **src/components/comment-media/comment-media.tsx:163** — `<a href={url} target='_blank' rel='noreferrer'>` uses `rel='noreferrer'` only. Modern browsers treat `noreferrer` as implying `noopener`, but older targets (some WebKit variants, older Android WebViews used via Capacitor) do not. *Fix:* standardize on `rel='noopener noreferrer'` like the other 20+ occurrences in the codebase.
|
||||
- **src/views/account-data-editor/account-data-editor.tsx:85-98** — The account JSON editor accepts free-form JSON and writes it back via `setAccount`. A hostile paste (e.g. a phishing "here's your key, paste this") is handed straight to the hook layer. No warning about what fields are dangerous (e.g. `signer.privateKey`, `subscriptions`), and the error surface is a plain `alert(e.message)` which could leak library internals. *Fix:* enumerate expected fields; warn or refuse to import JSON that mutates `signer` to a different address than the current account.
|
||||
|
||||
### Low
|
||||
|
||||
- **src/stores/*-store.ts** — Many stores persist to `localStorage` (subscriptions, filters, catalog style, disclaimer acceptance, popular threads options, directories cache). None of this is secret, so plaintext is fine; however the underlying account store used by `bitsocial-react-hooks` likely persists the `signer` (private key) to IndexedDB unencrypted. That's upstream of this repo, but it means any XSS trivially exfiltrates identity. Document the threat model and consider an optional passphrase-encrypted vault for key material.
|
||||
- **src/components/settings-modal/account-settings/account-settings.tsx:84, 166** — `console.log(error)` of whole Error objects during account export/import error paths. If the error chain from `exportAccount()` ever includes the raw account JSON (it shouldn't, but is library-dependent), that lands in devtools. *Fix:* log `error.message` only.
|
||||
- **src/components/comment-media/comment-media.tsx:144** — `new URL(url)` is constructed in the middle of render (`linkWithoutThumbnail = url && new URL(url)`). If `url` is malformed the component throws and unmounts the subtree. A malicious peer can crash rendering of a thread by posting a comment with a link value like `"not a url"`. *Fix:* use `isValidURL` guard before constructing.
|
||||
- **index.html:64-76** — Initial-load redirect synthesizes `'/#' + window.location.pathname + window.location.search` and calls `window.location.replace`. Same-origin-only, so this is not an open redirect, but `pathname` could contain arbitrary characters (via DNS or server rewrite) that end up in the hash. Low risk given router validation, but worth normalizing.
|
||||
- **src/components/embed/embed.tsx:163,166** — `parent=${window.location.hostname}` passed to the Twitch player. The hostname is trusted (it's the 5chan origin), but Twitch's embed documentation requires each parent domain to be pre-registered on the Twitch side; a hostname mismatch causes silent failure. Not a security bug but a reliability footgun when the app runs on alt hostnames (`5chan.eth.limo`, Electron `file:`).
|
||||
- **src/sw.ts** — Service worker uses `NetworkFirst` for navigations and `StaleWhileRevalidate` for assets. No validation that cached responses originated from the 5chan origin (the Workbox defaults enforce that via same-origin `registerRoute`), so lookup semantics are fine. However an XSS on 5chan can seed the SW cache with a poisoned response and pin it for up to 30 days (`maxAgeSeconds: 60 * 60 * 24 * 30`). *Fix:* once CSP is in place, also add `self.registration.unregister()` + cache wipe on startup if the SW detects a version mismatch.
|
||||
- **src/lib/media-hosting/*** — Uploads POST peer files to `catbox.moe` and imgur. No hash pinning or response sanitization; the returned URL (text body) is trusted verbatim and flows back into `<img src>`. A compromised host can substitute URLs pointing at attacker content, but the user chose to upload there, so this is expected risk. Document it.
|
||||
- **Static analysis confirmations (no issue found)** — `dangerouslySetInnerHTML` usages in `src/`: 0. `document.write`: 0. `eval` / `new Function`: 0. `innerHTML =`: only in `__tests__`. All `target="_blank"` anchors have `rel` attributes (the handful with only `noreferrer` are noted above). `matchesPattern` uses RegExp only on the user's own inputs. The custom markdown tokenizer renders every text/URL/quote token through JSX children (React-escaped); no raw HTML is produced.
|
||||
|
||||
## Top 5 Actions
|
||||
|
||||
1. **Remove the `Math.random` fallback in `src/polyfills.js`** (and rebuild). This is a latent crypto-weakness with catastrophic blast radius if ever exercised.
|
||||
2. **Add a `Content-Security-Policy` header** in `vercel.json` (and a `<meta>` fallback for Electron/IPFS hosting) that restricts `script-src` to `'self'` plus the specific embed CDNs, and constrains `frame-src`/`img-src`/`media-src`/`connect-src` to the actually-used origins. Pair with HSTS.
|
||||
3. **Harden the third-party embed pipeline** in `src/components/embed/embed.tsx`: add `sandbox` attributes to every srcDoc iframe, pin Instagram to absolute `https://`, and (ideally) move the external-script embeds to a dedicated sandbox origin so widgets.js compromises cannot reach the parent.
|
||||
4. **Add a protocol allow-list in `getLinkMediaInfo`/`isValidURL`** (`src/lib/utils/url-utils.ts`, `src/lib/utils/media-utils.ts`) so only `http:`/`https:` (and intentionally supported `ipfs:`/`ipns:`) URLs flow into `<img>`/`<video>`/`<audio>`/`<a href>` and into `fetchWebpageThumbnail`. Reject private-range hostnames in the thumbnail fetcher to close the browser-side SSRF.
|
||||
5. **Tighten `isAllowedDownloadUrl`** (`src/lib/app-update-config.ts`) to require the github.com path prefix of the official release repo, and drop the `http:` permission in the configurable-hosts branch. Bundle this with a `sandbox` hardening of `IframeChallenge` (drop `allow-top-navigation-by-user-activation`, reconsider the localhost exemption).
|
||||
@@ -0,0 +1,86 @@
|
||||
# Dead Code & Duplication Audit
|
||||
|
||||
## Summary
|
||||
|
||||
The codebase is clean of obvious dead code. There are no `TODO`/`FIXME`/`HACK`/`XXX` markers in any `.ts`/`.tsx` file under `src/` (outside the skipped `src/generated/` and `src/e2e/` trees), no `@deprecated` tags, no commented-out code blocks, and no `if (false)` / unreachable branches. Knip's output is short and all its findings verify as real, though two are "tested-but-not-used" orphans rather than truly dead symbols.
|
||||
|
||||
The genuine signal is concentrated in two places:
|
||||
|
||||
1. A small handful of exports that are only referenced from their own unit tests (archive barrel re-export, `useAccountCommunitiesWithMetadata`, `isArchiveView`/`isSettingsView`/`isNotFoundView`, `isDirectMediaUrl`, `getPreferredOrder`/`getRandomOrder`, `getCatalogPostHeightEstimate`/`getCatalogRowHeightEstimate`). Safe to either delete, make non-exported, or wire up a real consumer.
|
||||
2. Duplicated logic, not duplicated files. The big offenders are `post-desktop.tsx` / `post-mobile.tsx` (they share `PendingModerationActions`, a moderation-options block repeated again in `mod-queue.tsx`, a manual `isAccountMod` computation that already has a hook, and the `.eth`/`.sol` display-name switch), plus the `useEditCommentPrivileges` hook carrying an unused `postCid` parameter.
|
||||
|
||||
Outside those, there is one stale duplicate in `package.json` (`cross-env` listed in both `dependencies` and `devDependencies`) and one stale type shim (`declare module 'react-draggable'` for a package that is no longer installed). Total impact is measured in a handful of files — there is no bit-rot backlog here, just a cleanup pass.
|
||||
|
||||
## knip Findings
|
||||
|
||||
`yarn knip` and `yarn knip:full` produced identical, short output (13 reportable lines, none `--no-exit-code` would have suppressed):
|
||||
|
||||
| Knip category | Finding | Verification |
|
||||
|---|---|---|
|
||||
| Unused files | `src/views/archive/index.ts` | **confirmed**. The file is a barrel `export { default } from './archive';`. `src/app.tsx:39` imports `./views/archive/archive` directly, unlike every other view under `src/views/` which all go through their `index.ts`. |
|
||||
| Unused devDependencies | `cross-env` in `package.json:148` | **confirmed**. `cross-env` is legitimately used in `build`, `build-vercel`, `analyze-bundle`, and both `electron:start*` scripts, but it is declared **twice** — once at `package.json:25` (dependencies) and once at `package.json:148` (devDependencies). The devDependencies copy is redundant. |
|
||||
| Unused exports | `getCommunityIdentifier` in `src/hooks/use-community-identifiers.ts:7` | **confirmed**. Only consumed by the sibling `getCommunityIdentifiers` and `useCommunityIdentifier` in the same file. Not re-exported externally. |
|
||||
| Unused exports | `getCommunityIdentifiers` in `src/hooks/use-community-identifiers.ts:39` | **confirmed**. Only consumed by the sibling `useCommunityIdentifiers` in the same file. A same-named **local** `getCommunityIdentifiers` exists in `src/lib/utils/post-page-resolution.ts:67` (different signature) — knip is not confused, the module-level export really is internal-only. |
|
||||
| Unused exports | `REPLY_HEIGHT_DATA_ATTRIBUTE` in `src/lib/utils/pretext-height-estimates.ts:71` | **confirmed**. Used once inside the same file at line 853 as a DOM selector. No external consumer. |
|
||||
| Unused exports | `getCatalogPostHeightEstimate` in `src/lib/utils/pretext-height-estimates.ts:746` | **confirmed**. Only called internally at line 789 from `getCatalogRowHeightEstimate`. |
|
||||
| Unused exports | `getCatalogRowHeightEstimate` in `src/lib/utils/pretext-height-estimates.ts:780` | **confirmed**. Only called internally at line 804 from `getCatalogRowHeightEstimates`. |
|
||||
| Unused exported types | `ArchiveRouteRenderOptions` in `src/views/archive/__tests__/helpers.ts:10` | **confirmed**. The helper `renderArchiveRoute` is called with object-literal args at 5 sites in `archive.test.tsx`; none of the test files name the type. The `export` keyword on the type alias is surplus. |
|
||||
| Configuration hints | `android/app/src/main/assets/public/**` still in `ignoreFiles` | **likely-false-positive** (harness-specific). This path is listed as a deliberate skip for Android-bundled build output. Knip is offering to remove it because the directory doesn't currently exist in the checkout; leave it pinned unless the Android workflow has been retired. |
|
||||
| Configuration hints | `babel-plugin-react-compiler` still in `ignoreDependencies` | **likely-false-positive** (build tooling). It is loaded by Vite config as a string id and knip cannot trace it; the ignore entry is intentional. |
|
||||
|
||||
Two categories knip did **not** surface but that were checked by hand and are real: test-only orphans (`useAccountCommunitiesWithMetadata`, `isDirectMediaUrl`, etc.) and type-stub drift (`declare module 'react-draggable'`). Knip skips the first because tests import them, and the second because it only inspects JS/TS imports, not ambient `.d.ts` declarations for absent packages.
|
||||
|
||||
## Findings
|
||||
|
||||
### Unused Exports
|
||||
|
||||
- **src/views/archive/index.ts:1** — One-line barrel `export { default } from './archive'` that nobody imports; `src/app.tsx` uses `./views/archive/archive` directly. *Action:* either delete `index.ts` and leave the direct import, or flip `app.tsx` to `import Archive from './views/archive'` (the convention used by every other view). The second is the smaller-surface fix. `confirmed`.
|
||||
- **src/hooks/use-community-identifiers.ts:7,39** — `getCommunityIdentifier` and `getCommunityIdentifiers` are exported but only used by the React-facing `useCommunityIdentifier` / `useCommunityIdentifiers` in the same file. *Action:* drop the `export` keyword on both; keep them as module-private helpers. `confirmed`.
|
||||
- **src/lib/utils/pretext-height-estimates.ts:71** — `REPLY_HEIGHT_DATA_ATTRIBUTE` is only used inside the same file (line 853). *Action:* drop the `export`. `confirmed`.
|
||||
- **src/lib/utils/pretext-height-estimates.ts:746,780** — `getCatalogPostHeightEstimate` and `getCatalogRowHeightEstimate` are exported but only used within the file by `getCatalogRowHeightEstimate` / `getCatalogRowHeightEstimates` respectively. *Action:* drop the `export` on both. Only `getCatalogRowHeightEstimates` (line 799) needs to remain public. `confirmed`.
|
||||
- **src/views/archive/__tests__/helpers.ts:10** — `export type ArchiveRouteRenderOptions` is never referenced by name; callers pass object literals. *Action:* drop the `export` keyword. `confirmed`.
|
||||
- **src/hooks/use-account-communities-with-metadata.ts:5** — `useAccountCommunitiesWithMetadata` has zero production consumers; only `src/hooks/__tests__/selector-hooks.test.tsx:6` imports it. *Action:* delete the hook and its test cases, or wire it into the one plausible consumer (`BoardsBar`'s "my communities" section currently uses `useDirectories` instead). Knip didn't flag this because the test keeps the import alive. `confirmed`.
|
||||
- **src/lib/media-hosting/direct-url.ts:5** — `isDirectMediaUrl` and the `DIRECT_MEDIA_EXTENSIONS` list have no non-test consumer. The extension set is also a subset of `KNOWN_IMAGE_EXTENSIONS`/`KNOWN_VIDEO_EXTENSIONS` in `src/lib/utils/media-utils.ts:77-78`, so the logic duplicates the classifier used by `getLinkMediaInfo`. *Action:* delete `src/lib/media-hosting/direct-url.ts` and its test, or rewrite it as a thin re-export of a shared `classifyUrl()` helper backed by the `media-utils` extension lists. `confirmed`.
|
||||
- **src/lib/media-hosting/provider-order.ts:5,10** — `getPreferredOrder` and `getRandomOrder` are exported only to be exercised by `__tests__/provider-order.test.ts`. `getPreferredOrder` has no internal consumer either; `getRandomOrder` is called once at line 34 by the public `getProviderOrder`. *Action:* drop both `export` keywords and rewrite the tests against `getProviderOrder` with runtime+mode combinations that exercise the preferred and random branches. `confirmed`.
|
||||
- **src/lib/utils/view-utils.ts:85,93,72** — `isArchiveView`, `isNotFoundView`, and `isSettingsView` are only used internally (`isNotFoundView` composes the other two) and by `__tests__/view-utils.test.ts`. No component/route imports them. *Action:* if `NotFoundView` routing is still useful, wire `isNotFoundView` into `src/app.tsx`'s route-resolution logic (it currently duplicates the negation inline). Otherwise drop the `export` on all three and delete the tests. `confirmed`.
|
||||
- **src/hooks/use-author-privileges.ts:11** — The destructured return includes `accountAuthorRole` but no external consumer reads it (the two callers in `edit-menu` and `post-menu-mobile` destructure only `isAccountMod`/`isAccountCommentAuthor`/`isCommentAuthorMod`/`commentAuthorRole`). *Action:* drop `accountAuthorRole` from the returned object. `confirmed`.
|
||||
- **src/hooks/use-author-privileges.ts:5-9** — The `AuthorPrivilegesProps` interface declares `postCid?: string` but the hook body never reads it; `edit-menu/edit-menu.tsx:52` passes a `postCid` in anyway. *Action:* delete the `postCid` field from the interface and the `postCid,` from the destructuring at the edit-menu call site. Dead parameter. `confirmed`.
|
||||
|
||||
### Dead Code
|
||||
|
||||
- **src/modules.d.ts:8** — `declare module 'react-draggable'` type stub for a package that is not installed (`node_modules/react-draggable` does not exist, no `react-draggable` entry in `package.json`, zero `from 'react-draggable'` imports under `src/`). *Action:* delete the line. `confirmed`.
|
||||
- **src/modules.d.ts:6** — `declare module 'lodash'` is a permissive any-typed stub, but `@types/lodash` **is** installed (`package.json` declares it at the top level and `node_modules/@types/lodash/package.json` exists). The stub silently broadens types the installed package would otherwise provide. *Action:* delete the stub. If tsgo complains about a specific submodule import (e.g. `lodash/capitalize`), the correct fix is to import from `lodash` or add a typed helper, not to re-shadow with `any`. `confirmed`.
|
||||
- **package.json:148** — `cross-env` duplicated in `devDependencies` (it is already a direct `dependencies` entry at line 25 at the same version). *Action:* remove the devDependencies line so the package surfaces in exactly one group. `confirmed`.
|
||||
|
||||
Nothing else in this category turned up: no `TODO`/`FIXME`/`HACK`/`XXX` markers, no commented-out logic, no `@deprecated` tags, no `if (false)` / unreachable `default:` branches, no empty `.module.css` files.
|
||||
|
||||
### Duplication Candidates
|
||||
|
||||
- **src/components/post-desktop/post-desktop.tsx:99-209 vs src/components/post-mobile/post-mobile.tsx:115-210 vs src/views/mod-queue/mod-queue.tsx:209-281** — The `usePublishCommentModeration(approve)` + `usePublishCommentModeration(reject)` pair is set up **three times** with the same `onChallenge`/`onChallengeVerification`/`onError` trio and the same `handleApprove`/`handleReject` double-confirm wrapper. Post-desktop additionally wraps it in a `PendingModerationActions` subcomponent, which post-mobile redoes inline inside `PostInfoAndMedia`. *Shared abstraction:* `useApproveRejectModeration({ cid, communityAddress, post })` in `src/hooks/`, returning `{ approve, reject, approveState, approveError, rejectState, rejectError, handleApprove, handleReject }`. Callers render the UI. The hook owns the two `usePublishCommentModeration` calls, the shared onChallenge/onError, and the `window.confirm(t('double_confirm'))` guard. This would delete roughly 90 lines across the three call sites.
|
||||
- **src/components/post-desktop/post-desktop.tsx:253-254 + 109-112 vs src/components/post-mobile/post-mobile.tsx:108-109 + 124-127** — Both files manually compute `const accountRole = roles?.[accountAddress]?.role; const isAccountMod = accountRole === 'admin' || accountRole === 'owner' || accountRole === 'moderator';`. The exact same `isAccountMod` logic already lives in `src/hooks/use-author-privileges.ts` (which is already consumed by `edit-menu` and `post-menu-mobile`). *Shared abstraction:* call `useAuthorPrivileges({ commentAuthorAddress: address, communityAddress })` in both post components instead, destructuring `isAccountMod`. Delete the manual ternary. Bonus: the post components would also pick up `useCommunityField`-level selector memoization instead of reading `roles` off a prop.
|
||||
- **src/components/boards-bar/boards-bar.tsx:195, src/components/board-header/board-header.tsx:98, src/components/post-desktop/post-desktop.tsx:642,857, src/components/post-mobile/post-mobile.tsx:85, src/views/mod-queue/mod-queue.tsx:639** — Six sites inline the check `address.endsWith('.eth') || address.endsWith('.sol') ? address : getShortAddress(address)` to decide whether a community address should be displayed verbatim or truncated. Two sites (`post-desktop`, `post-mobile`) do it twice. *Shared abstraction:* `formatCommunityAddressForDisplay(address)` in `src/lib/utils/string-utils.ts` (or a tiny `is-domain-like-address` predicate used by the existing `getShortAddress` fallback). When the TLD list grows (the community shortlist already includes `.sol`; IPNS keys are a separate branch), you update it once.
|
||||
- **src/components/post-desktop/post-desktop.tsx:72-78 vs src/components/post-mobile/post-mobile.tsx:62-67** — Identical `RepliesFooter` component (`hasMore ? <div className={styles.stateString}><LoadingEllipsis string={loadingString}/></div> : null`) redefined in each file. `styles` resolves to the same `views/post/post.module.css` in both. *Shared abstraction:* move `RepliesFooter` into `src/components/` (e.g. `src/components/post-desktop/replies-footer.tsx` shared via a barrel) or directly inline it under a single `src/components/post-thread-parts/`.
|
||||
- **src/components/post-desktop/post-desktop.tsx:80-97 vs src/components/post-mobile/post-mobile.tsx:69-70** — Both files keep a module-level `const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}` plus (desktop-only) a `useShowOmittedReplies` Zustand store. Desktop also defines an equivalent `setShowOmittedReplies` store inline. *Shared abstraction:* lift the scroll-snapshot map into a shared `src/lib/utils/virtuoso-scroll-memory.ts` helper (`saveSnapshot(key, state)` / `getSnapshot(key)`) so both files consume the same keyed cache. This also avoids two per-instance module caches holding onto (possibly stale) snapshots. The `useShowOmittedReplies` store belongs in `src/stores/use-show-omitted-replies-store.ts` since desktop and mobile are mutually exclusive at runtime anyway.
|
||||
- **src/components/settings-modal/crypto-wallets-setting/crypto-wallets-setting.tsx:65** — Uses `navigator.clipboard.writeText(messageToSign)` directly, bypassing `copyToClipboard` from `src/lib/utils/clipboard-utils.ts` that every other copy site (`error-display`, `post-menu-desktop`, `post-menu-mobile`, `url-utils`) routes through. `clipboard-utils` handles the Electron IPC bridge + fallback that this site silently skips. *Action:* replace with `await copyToClipboard(messageToSign)` and keep the failure alert that sits around it.
|
||||
- **src/lib/media-hosting/direct-url.ts:2 vs src/lib/utils/media-utils.ts:77-78** — `DIRECT_MEDIA_EXTENSIONS` (with leading dots: `.jpg, .jpeg, .png, ...`) is a subset of `KNOWN_IMAGE_EXTENSIONS` + `KNOWN_VIDEO_EXTENSIONS` (without leading dots: `jpg, jpeg, png, ...`). Two independently-maintained lists with different shapes, same intent. *Shared abstraction:* one canonical list in `media-utils.ts` plus a `classifyUrlAsDirectMedia(url)` function that the rare caller needs. Resolves the `direct-url.ts` file's dead-code status at the same time.
|
||||
|
||||
### TODO/FIXME Backlog
|
||||
|
||||
Empty. `grep -rn -i 'TODO\|FIXME\|HACK\|XXX'` under `src/` (excluding `src/generated/` and `src/e2e/`) returns zero markers in any `.ts`, `.tsx`, `.js`, `.jsx`, or `.css` file. The only hit is a narrative comment in `src/app.tsx:126` containing the literal substring "xxx" ("`/thread/xxx`") — not a marker. Nothing to triage.
|
||||
|
||||
### Legacy Code
|
||||
|
||||
- **src/modules.d.ts:8 (`react-draggable`)** — Already flagged under *Dead Code*. This is the only remaining ambient type stub for a package that isn't installed. The two siblings in the file (`*.module.css`, `react-router-hash-link`) are still needed: `@types/react-router-hash-link` is not published on DefinitelyTyped, and CSS-modules typing is a Vite-compatible pattern.
|
||||
- **src/modules.d.ts:6 (`declare module 'lodash'`)** — Already flagged under *Dead Code*. `@types/lodash@4.17.24` is installed in `devDependencies` and provides full typings; the ambient `declare module 'lodash'` shadows them with implicit `any`. Pure legacy residue.
|
||||
- **src/lib/utils/view-utils.ts:81,34,93,85,72** — Several predicate signatures declare `params: ParamsType` but don't read from it (`isSubscriptionsView`, and the bodies of `isCatalogView`/`isSettingsView` only read `boardIdentifier` / `commentCid` / `accountCommentIndex`). `isSubscriptionsView` in particular has 12 call sites all passing `params` needlessly. This is light code smell, not dead code — trimming is optional. *Action (optional):* drop the unused `params` from `isSubscriptionsView`; leave the others (they do read from `params`).
|
||||
- **src/data/5chan-blotter.json** (not opened here, flagged for completeness) — The blotter data file is the source of truth for the blotter UI; not legacy even though it's auto-generated. Left in place.
|
||||
|
||||
No `@deprecated` JSDoc tags, no commented-out code blocks, no feature-flag branches that no longer toggle were found. The codebase has been cleaned recently; the backlog is genuinely thin.
|
||||
|
||||
## Top 5 Actions
|
||||
|
||||
1. **Delete the `react-draggable` type stub and the duplicate `cross-env` devDep.** Two-line net removal in `src/modules.d.ts:8` and `package.json:148`, zero risk, and it clears the only pieces of stale manifest/stub drift.
|
||||
2. **Un-export internal helpers flagged by knip.** Drop the `export` keyword on `getCommunityIdentifier`/`getCommunityIdentifiers` (`src/hooks/use-community-identifiers.ts:7,39`), `REPLY_HEIGHT_DATA_ATTRIBUTE` (`src/lib/utils/pretext-height-estimates.ts:71`), `getCatalogPostHeightEstimate` / `getCatalogRowHeightEstimate` (`pretext-height-estimates.ts:746,780`), and `ArchiveRouteRenderOptions` (`src/views/archive/__tests__/helpers.ts:10`). Also drop the `export` on `getPreferredOrder`/`getRandomOrder` (`src/lib/media-hosting/provider-order.ts:5,10`) and rewrite their tests to go through `getProviderOrder`. Shrinks the public API surface by ~7 symbols without changing behavior.
|
||||
3. **Extract `useApproveRejectModeration({ cid, communityAddress, post })` and collapse the three copies.** Replaces the `usePublishCommentModeration(approve)` + `usePublishCommentModeration(reject)` + `handleApprove`/`handleReject` blocks in `post-desktop.tsx:102-159`, `post-mobile.tsx:115-210`, and `mod-queue.tsx:209-281`. Roughly 90 LOC deleted, the onChallenge/onChallengeVerification/onError error-log strings stop drifting, and the `window.confirm(t('double_confirm'))` gate has one owner.
|
||||
4. **Make the post components reuse `useAuthorPrivileges` and delete the `postCid` ghost parameter.** Replace the manual `isAccountMod` ternaries in `post-desktop.tsx:253-254` and `post-mobile.tsx:108-109` with a `useAuthorPrivileges({ commentAuthorAddress, communityAddress })` call. Simultaneously drop `postCid` from `AuthorPrivilegesProps` in `src/hooks/use-author-privileges.ts:5-9` and from the caller in `edit-menu.tsx:52`. Removes two duplicated logic sites and kills an unused parameter.
|
||||
5. **Delete or wire up the four test-only orphans.** Either delete (preferred if there is no plan to use them) or land a production consumer for: `src/hooks/use-account-communities-with-metadata.ts` (plus its test in `selector-hooks.test.tsx`), `src/lib/media-hosting/direct-url.ts` (plus its test; merge into `media-utils.ts`'s extension lists if a use case returns), `isArchiveView`/`isNotFoundView`/`isSettingsView` in `src/lib/utils/view-utils.ts` (plus their tests; or wire `isNotFoundView` into `app.tsx` as the canonical not-found predicate), and `src/views/archive/index.ts` (either delete, or flip `app.tsx:39` to import through it like every other view).
|
||||
@@ -0,0 +1,90 @@
|
||||
# Accessibility Audit
|
||||
|
||||
Static review of `src/components/` and `src/views/` on 2026-04-23. Read-only pass; no browser/axe verification was performed. Findings focus on the primary interactive patterns (posting, replying, voting/subscribing, navigating threads, opening modals) over niche settings screens.
|
||||
|
||||
## Summary
|
||||
|
||||
5chan leans heavily on non-semantic elements wrapped in `role="button"` plus `tabIndex={0}` and a hand-rolled `onKeyDown` that handles Enter/Space. This pattern is applied consistently enough to be keyboard-operable in most places, but it is brittle and leaks into cases where native `<button>` would trivially solve the problem. Eighty-nine uses of `role='button'` were found across the tree.
|
||||
|
||||
The most material issues are:
|
||||
|
||||
1. **Modals are not dialogs.** None of the custom modals (`reply-modal`, `settings-modal`, `disclaimer-modal`, `directory-modal`, `create-board-modal`, `boards-bar-edit-modal`, `challenge-modal`) set `role="dialog"` / `aria-modal="true"` or label the dialog. Several also lack a focus trap and do not return focus to the trigger. Floating-UI-based popovers (post menu, tooltips) are the well-done exceptions.
|
||||
2. **Form controls have no programmatic labels.** The primary post and reply forms render inputs with visible `<td>` text or placeholders but no `<label htmlFor>`, `<label>` wrap, or `aria-label`/`aria-labelledby`. Screen readers announce these as unlabeled "edit text" fields. Exactly one `htmlFor` exists in the whole tree (`boards-bar-edit-modal`).
|
||||
3. **No landmarks, no h1, no skip link.** There is no `<main>`, `<header>`, or `<nav>` in the main app layout (only `<footer>`). No view except `/blotter` renders an h1. There is no skip-to-content link.
|
||||
4. **Icon-only controls rely on `title` for their accessible name.** The close buttons on most modals are empty `<button>` elements styled as an X with only a `title='Close'`. `title` is not a reliable accessible name and is unusable on touch.
|
||||
5. **Nested interactive elements.** `CatalogButton` and `ReturnButton` render `<button><Link>…</Link></button>`, which is invalid HTML and confuses assistive tech and keyboard nav.
|
||||
6. **`<label>` used without a form control** (settings-modal categories) — labels wrap text only; the click target is the label itself, not an input, so the semantics are misleading.
|
||||
|
||||
Heading hierarchy is skipped in most views (jump straight to h2/h3). Images generally carry `alt=''` where decorative, which is correct; no missing `alt` was found. Keyboard traps were not observed.
|
||||
|
||||
## Findings
|
||||
|
||||
### Critical
|
||||
|
||||
- **src/components/reply-modal/reply-modal.tsx:290-310** — Core reply surface. The outer `<animated.div>` is not a dialog: no `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, or `aria-label`. *Fix:* `<div role="dialog" aria-modal="true" aria-labelledby="reply-modal-title">` and put the title ("Reply to No. 12345") inside an element with that id.
|
||||
- **src/components/reply-modal/reply-modal.tsx:313, 324, 333** — Name, link, and comment inputs have placeholders but no associated `<label>`, `aria-label`, or `aria-labelledby`. Screen readers announce only "edit text". *Fix:* wrap each input in `<label>` with visible text or add `aria-label={t('name')}` / `t('link')` / `t('comment')`.
|
||||
- **src/components/reply-modal/reply-modal.tsx:302-309** — Close button is an empty `<button>` with only `title='close'`. `title` is not exposed as an accessible name reliably; touch users never see it. *Fix:* `aria-label={t('close')}` (and keep the visual X via CSS background).
|
||||
- **src/components/post-form/post-form.tsx:152-165, 183-189, 207, 214-229, 258-263, 272-282** — The posting form (subject, name, comment, link, file, spoiler, board-select) has no label associations. Labels sit in adjacent `<td>` cells but are not wired via `htmlFor`/`id`. The `<select>` for board has no accessible name. *Fix:* give every input a stable `id` and add `<label htmlFor={id}>{t('name')}</label>` on the sibling `<td>`, or wrap the input in the label. Same for the `<select>`.
|
||||
- **src/components/post-form/post-form.tsx:207 & src/components/reply-modal/reply-modal.tsx:333** — The comment `<textarea>` (the primary text entry in the app) is unlabeled. *Fix:* `aria-label={t('comment')}` at minimum.
|
||||
- **src/components/post-form/post-form.tsx (no `<form>` wrapper) & reply-modal.tsx (no `<form>` wrapper)** — Neither posting surface is inside a `<form>`, so Enter does not submit and assistive tech cannot navigate by form. *Fix:* wrap fields in a `<form onSubmit={...}>` with a submit button, even in the `<table>` layout.
|
||||
- **src/components/disclaimer-modal/disclaimer-modal.tsx:34-65** — The backdrop is `role='button' tabIndex={0}` *and* wraps the dialog content; the dialog itself has no `role="dialog"`, no `aria-modal`, no focus trap, and no initial focus. Accept/Cancel are reachable only by tabbing through every control on the (backgrounded) page. *Fix:* move the backdrop to a non-focusable `<div aria-hidden="true">` handled via `onMouseDown`; give the inner dialog `role="dialog" aria-modal="true" aria-labelledby="disclaimer-title"`; set initial focus to the Accept button on open; restore focus to the trigger on close.
|
||||
- **src/components/disclaimer-modal/disclaimer-modal.tsx:49, src/components/create-board-modal/create-board-modal.tsx:34, src/components/directory-modal/directory-modal.tsx:36, src/components/challenge-modal/challenge-modal.tsx:369** — Empty close `<button>` with `title='Close'` only. No accessible name, no text. *Fix:* add `aria-label={t('close')}`.
|
||||
- **src/components/settings-modal/settings-modal.tsx:106, 110, 114** — Settings overlay and close "button" are `<div>`/`<span>` with `role='button'` instead of real `<button>`. The modal itself also has no `role="dialog"`/`aria-modal`/`aria-labelledby`. *Fix:* use `<button>` elements and wrap the modal in `<div role="dialog" aria-modal="true" aria-labelledby="settings-title">`.
|
||||
|
||||
### High
|
||||
|
||||
- **src/components/board-buttons/board-buttons.tsx:83-86, 154-157** — `<button className='button'><Link to=...>{t('catalog')}</Link></button>` and the same for Return. Nested interactive elements — invalid HTML, double-focusable, announced twice, and the button's `onClick` never fires because the Link swallows the click. *Fix:* drop the outer `<button>` and style the `<Link>` directly (`<Link className='button' to=...>`), or use `navigate()` from a real `<button>`.
|
||||
- **src/components/settings-modal/settings-modal.tsx:119-160** — Category toggles use `<label onClick={...}>` without wrapping or associating any input. A `<label>` with no control is inert to assistive tech and has misleading semantics. *Fix:* replace with `<button type="button" aria-expanded={showInterfaceSettings} aria-controls="interface-settings-panel" onClick={...}>`.
|
||||
- **src/components/comment-media/comment-media.tsx:73-86, 107-120, 123-136, 172-185, 217-233, 237-253, 321-335, 348-366, 387-405** — `<img role='button' tabIndex={0} onClick>` for expanding/collapsing media. Keyboard works, but `alt=''` + `role='button'` gives an unnamed button to a screen reader. *Fix:* add `aria-label={t('expand_image')}` / `t('collapse_image')` on the interactive image, or wrap the `<img alt=''>` in a real `<button aria-label=...>`.
|
||||
- **src/components/comment-media/comment-media.tsx:92-103** — `<video>` with `role='button' tabIndex={0} onClick/onKeyDown`. A `<video>` is not a button; hijacking its semantics confuses AT, and native video controls are already focusable. *Fix:* render the thumbnail as `<img>` + a sibling/wrapper `<button aria-label>` instead of abusing the video element.
|
||||
- **src/components/catalog-search/catalog-search.tsx:105-115** — Search input has no `<label>`/`aria-label`, and the closing "✖" is a `<span role='button'>` with a literal Unicode glyph as its name. *Fix:* `aria-label={t('search')}` on the input; `<button type='button' aria-label={t('close_search')}>✖</button>` (or keep it a span but add `aria-label`).
|
||||
- **src/components/style-selector/style-selector.tsx:32-40** — The theme `<select>` has no label. Announced as "combo box" with no name. *Fix:* `<label>{t('style')}:<select aria-label={t('style')}>` (or pair with the `{t('style')}` label already rendered in footer.tsx via `htmlFor`).
|
||||
- **src/views/home/home.tsx:37-45** — Board-jump `<input>` on home has no label, just a placeholder, and the `<form>` wraps it without any accessible name on either. *Fix:* add `aria-label={t('enter_board_address')}` on the input and `aria-label={t('search_boards')}` on the form.
|
||||
- **src/components/post-desktop/post-desktop.tsx:1130-1141** — Thread "hide" toggle is a `<span role='button'>` with no text content, no `aria-label`, and state conveyed only via a class name. *Fix:* `aria-label={hidden ? t('unhide_thread') : t('hide_thread')}` and `aria-pressed={hidden}`; prefer `<button type='button'>`.
|
||||
- **src/components/post-desktop/post-desktop.tsx:1197-1209, post-mobile has the equivalent** — "Show/hide omitted replies" toggle is a `<span role='button'>` with no accessible name (icon-only via CSS). *Fix:* add `aria-label={showOmittedReplies[cid] ? t('hide_omitted_replies') : t('show_omitted_replies')}` and `aria-expanded`.
|
||||
- **src/components/post-desktop/post-desktop.tsx:388-404 & src/components/post-mobile/post-mobile.tsx:315-331** — User ID highlight uses `<span role='button' tabIndex={0}>` with only the truncated ID as visible text, `title={t('highlight_posts')}`, and inline `backgroundColor/color`. Contrast of the random user-ID colors is not guaranteed against the selected theme, and `title` is not a reliable accessible name. *Fix:* `aria-label={t('highlight_posts_by_user', {id})}` and verify colour-pair contrast in the palette generator (or render on a neutral chip).
|
||||
- **src/components/post-desktop/post-desktop.tsx:430-444 & post-mobile 389-404** — The "reply-to-post" number (clicking a post number to quote it) is a `<span role='button'>` on `onMouseDown` (not `onClick`) — mouse-only discoverability and a non-standard activation for keyboards. *Fix:* use `onClick`/keyboard handler consistent with the rest of the app, or a real `<button>`.
|
||||
- **Modal focus management** (all modals) — Most modals do not trap focus or return focus to the trigger on close. `reply-modal` autofocuses the textarea on open (good) and listens for Escape (good), but still returns focus to `<body>` on close. *Fix:* store `document.activeElement` on open and `.focus()` it on close; add a focus trap (simplest: `FocusTrap` from `@floating-ui/react` or `focus-trap-react`). `post-menu-desktop` already uses `FloatingFocusManager` — that pattern can be reused.
|
||||
|
||||
### Medium
|
||||
|
||||
- **src/components/comment-media/comment-media.tsx:266-280** — Mobile media "close" is a `<span className='button' role='button'>`. Pure text button rendered as span. *Fix:* use `<button className='button'>`.
|
||||
- **src/components/post-desktop/post-desktop.tsx:674-688 & 694-708** — Inline "close"/"open" toggles for expanded media use `<span role='button'>`. *Fix:* `<button type='button' className={styles.closeMedia}>`.
|
||||
- **src/components/boards-bar/boards-bar.tsx:226-244, 249-263, 265-279, 284-293, 396** — Five separate `<span role='button'>` nav triggers ("...", "edit", "create board", toggle search). Visible text is present and keydown handlers exist, but each is a `<span>` duplicating what a `<button>` gives for free, and each has its own inline `style={{cursor:'pointer'}}` that buttons don't need. *Fix:* convert to `<button type='button' className={styles.temporaryButton}>`.
|
||||
- **src/components/board-blotter/board-blotter.tsx:45** — Expand/collapse trigger is a `<span role='button'>`. *Fix:* `<button type='button' aria-expanded={...}>`.
|
||||
- **src/components/catalog-filters/catalog-filters.tsx:154, 198, 258, 279, 293, 322** — Six `role='button'` spans for filter controls (add, save, remove, move). Same pattern. *Fix:* real `<button>`s.
|
||||
- **src/components/catalog-filters/highlight-color-picker/highlight-color-picker.tsx:63, 88, 121, 155, 171, 185** — Colour swatch pickers are `<div role='button'>` with no accessible name other than the colour visually. *Fix:* render as `<button type='button' aria-label={`Highlight color ${color}`} aria-pressed={isSelected}>`.
|
||||
- **src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx:228-269 & post-mobile equivalent** — Items in the post-menu popover use `role='button'` rather than `role='menuitem'`, and the wrapper is not `role='menu'`. Focus is trapped (good) and navigation works via Tab, but arrow-key menu semantics don't apply. *Fix:* use `<button role='menuitem'>` inside `<div role='menu' aria-labelledby={headingId}>` and wire arrow-key navigation, or stop calling it a menu.
|
||||
- **src/components/markdown/markdown.tsx:95** — Spoiler-text reveal is `<span role='button'>`. *Fix:* `<button type='button' aria-expanded={revealed}>`.
|
||||
- **src/components/comment-content/comment-content.tsx:204, 246, 268** — Quote/backlink previews are `<span role='button'>`. Fine functionally; would benefit from aria-label describing target post. *Fix:* `aria-label={t('show_reply_preview', {no})}`.
|
||||
- **src/components/subscriptions-setting/subscriptions-setting.tsx:27, 64** — `role='button'` rows for reordering/removing subscriptions. *Fix:* real `<button>` children with `aria-label`.
|
||||
- **src/components/post-form/post-form.tsx:257-263** and **reply-modal.tsx:367-370** — Spoiler checkbox is wrapped in `<label>` (good) but the visible text is interleaved with `[` `]` punctuation that screen readers will read as part of the label ("`[ Spoiler? ]`"). Low-impact but untidy. *Fix:* put the brackets outside the `<label>`.
|
||||
- **src/components/board-header/board-header.tsx:79-100** — The board title is rendered as a styled `<div>`, not an `<h1>`. Every board page therefore has no page-level heading, which is the single most important landmark for screen-reader users. *Fix:* render the board title as `<h1 className={styles.boardTitle}>`.
|
||||
- **Heading hierarchy** — `src/views/home/home.tsx` jumps straight to `h2` (no `h1`); `src/views/faq/faq.tsx`, `src/views/rules/rules.tsx`, `src/views/pass/pass.tsx`, `src/views/not-found/not-found.tsx`, `src/views/not-allowed/not-allowed.tsx`, `src/views/archive/archive.tsx` all start at `h2`/`h4` with no `h1`. `src/views/blotter/blotter.tsx:23` is the sole view with an `h1`. *Fix:* promote the top title of each view to `h1` and demote the subsequent levels.
|
||||
- **No `<main>`, `<header>`, or `<nav>` landmarks** in the app shell (only `<footer>` in `src/components/footer/footer.tsx`). Screen-reader users cannot skip to main content. *Fix:* wrap the primary route outlet in `<main id="main-content">`, the boards bar + board header in `<header>` / `<nav aria-label={t('boards')}>`, and add a visually-hidden `<a href="#main-content" className={styles.skipLink}>{t('skip_to_content')}</a>` as the first focusable element in the tree (commonly in `src/App.tsx` or the top-level layout).
|
||||
- **No skip-to-content link** anywhere in the tree (grep confirms).
|
||||
- **src/components/comment-media/comment-media.tsx:137-138 & 234-235** — `<audio controls>` / `<video controls>` inside a list of posts. Native controls are focusable but the element itself lacks `aria-label` describing the media. *Fix:* `<video aria-label={t('video_from', {host: getHostname(url)})}>`.
|
||||
- **src/views/home/home.tsx:46** — `<button className={styles.searchButton}>{t('go')}</button>` — no `type='submit'`, so it defaults to `submit` inside the `<form>` (works) but is brittle. *Fix:* `type='submit'` explicitly.
|
||||
|
||||
### Low
|
||||
|
||||
- **All modals (`disclaimer-modal`, `create-board-modal`, `directory-modal`, `boards-bar-edit-modal`)** — The backdrop is itself `role='button' tabIndex={0}`, meaning a keyboard user tabs into a full-screen "button" before reaching the dialog content. *Fix:* let backdrop handle `onMouseDown` only and make it `aria-hidden='true'` with no tabindex/role; the Escape key should handle dismissal for keyboard users.
|
||||
- **src/components/board-buttons/board-buttons.tsx:209-219** — Auto-update is a checkbox inside `<label className={isMobile ? 'button' : undefined}>`. The checkbox has `aria-label={t('Auto')}` but the label wraps both the input *and* the visible "Auto" text, which then gets announced twice. *Fix:* drop the redundant `aria-label` — the wrapping `<label>` already names the input.
|
||||
- **src/components/post-form/post-form.tsx:154-165** — Display name input uses the user's previous value as `defaultValue`, and `placeholder={!displayName ? 'anonymous' : undefined}` — placeholder-as-label anti-pattern if a screen reader user clears the field. *Fix:* put "Name" as a real visible label (done if the htmlFor fix above is applied).
|
||||
- **src/components/tooltip/tooltip.tsx** — Tooltips use Floating UI's `useRole('tooltip')`, which is correct, but the trigger wrapper is always a `<span>` regardless of whether the child is interactive. If the child is a non-focusable element, tooltip content is unreachable by keyboard. *Fix:* document that `Tooltip` children must themselves be focusable (or add `tabIndex={0}` on the span when the child isn't interactive).
|
||||
- **src/components/comment-media/comment-media.tsx:170, 346, 385** — `<img src='assets/filedeleted-res.gif' alt='File deleted'>` is correct. Note for the team: this is the right pattern (meaningful alt). Others (`spoiler.png`, sticky/closed icons) correctly use `alt=''`. No change needed; included as a positive reference point.
|
||||
- **src/components/post-desktop/post-desktop.tsx:454, 459, 466 & post-mobile** — Sticky/closed/archived indicator icons use `alt=''` + `title={t('sticky')}`. Information is in the `title` only, which screen readers don't reliably read. *Fix:* either use `alt={t('sticky')}` (meaningful icon) or pair the image with a visually-hidden `<span>` sibling.
|
||||
- **src/components/settings-modal/advanced-settings/advanced-settings.tsx:178** — `<button onClick={() => setShowInfo(!showInfo)}>{showInfo ? 'X' : '?'}</button>` — a question-mark help button with literally "?" as its name. *Fix:* `aria-label={showInfo ? t('hide_info') : t('show_info')}` and `aria-expanded`.
|
||||
- **src/components/challenge-modal/challenge-modal.tsx:341-352** — Disabled inputs render the challenge subject/content/link (read-only display). Using disabled inputs for display removes them from the tab order, which is fine, but they will not be announced as labelled fields. Consider `<output>` or read-only `<pre>` for clarity. Low-impact.
|
||||
- **Touch targets** — `styles.closeButton` / `styles.closeIcon` on modals are likely under 40×40 (CSS not fully reviewed), and the `postMenuBtn` (`▶`) is small. *Fix (follow-up):* verify in browser with DevTools; enforce a `min-width/min-height: 44px` on icon-only controls via a shared utility class.
|
||||
- **Color contrast** — Only obvious static-colour risks are user-ID chips (`src/components/post-desktop/post-desktop.tsx:400`, `post-mobile/post-mobile.tsx:327`), where `userIDBackgroundColor` and `userIDTextColor` come from a generator. Cannot verify contrast statically. *Fix (follow-up):* in the palette generator, enforce WCAG AA (4.5:1) between the generated background and text, or always use a fixed text colour with a constrained background-luminance range.
|
||||
- **src/components/post-mobile/post-mobile.tsx:806** — `<div onClick={unhide}>` without `role='button'`/`tabIndex`/`onKeyDown`. (`div.postHidden` region — keyboard can't unhide.) *Fix:* use a real `<button>` or add the standard `role/tabIndex/onKeyDown` trio.
|
||||
- **src/components/boards-bar-edit-modal/boards-bar-edit-modal.tsx:62** — Directory-code input has `aria-label='Directory codes'` hardcoded in English instead of using `t(...)`. Accessibility + i18n. *Fix:* `aria-label={t('directory_codes')}`.
|
||||
|
||||
## Top 5 Actions
|
||||
|
||||
1. **Label every form control in the posting and reply surfaces.** `post-form.tsx` and `reply-modal.tsx` are the two most-used UIs in the app; their inputs (name, subject, comment, link, board-select, spoiler) are currently unlabeled for AT. Add `htmlFor`/`id` pairs (or `aria-label`) and wrap both surfaces in a real `<form>`. This single change fixes the largest category of critical findings.
|
||||
2. **Promote modals to real dialogs.** Add `role='dialog' aria-modal='true' aria-labelledby=...` to `reply-modal`, `settings-modal`, `disclaimer-modal`, `directory-modal`, `create-board-modal`, `boards-bar-edit-modal`, and `challenge-modal`. Add a focus trap (Floating UI's `FloatingFocusManager` is already used for the post menu — reuse it) and restore focus to the trigger on close. Give every empty close-button an `aria-label={t('close')}`.
|
||||
3. **Add app-level landmarks + skip link + h1.** In the top-level layout (likely `src/App.tsx`): prepend a visually-hidden `<a href='#main-content'>{t('skip_to_content')}</a>`, wrap the route outlet in `<main id='main-content'>`, wrap the boards bar in `<nav aria-label={t('boards')}>`. In `board-header.tsx`, render the board title as `<h1>`, and in each view that starts at `h2`, promote the top title to `h1`.
|
||||
4. **Eliminate nested interactive elements in `board-buttons.tsx`.** Replace `<button><Link>…</Link></button>` (CatalogButton, ReturnButton) with either styled `<Link className='button'>` or `<button onClick={() => navigate(...)}>`. This is invalid HTML and currently makes those buttons keyboard-confusing.
|
||||
5. **Convert the large inventory of `<span role='button'>` / `<div role='button'>` to real `<button>`s.** Start with the most-used ones: hide-thread toggle (`post-desktop.tsx:1130`), show-omitted-replies (`:1197`), post-number quote trigger (`:430`, `post-mobile.tsx:389`), close-media (`:674`, `:694`), `boards-bar.tsx` edit/create/search triggers. Native `<button>` gives focus ring, Enter/Space activation, and `click` semantics for free — no more hand-rolled `onKeyDown` boilerplate. Keep `role='button'` only where the element genuinely cannot be a `<button>` (e.g. the `<img>` click-to-expand in `comment-media.tsx` — which should additionally get an `aria-label`).
|
||||
@@ -0,0 +1,123 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Type Safety Audit
|
||||
|
||||
Scope: `/Users/Tommaso/Desktop/bitsocial/5chan-codebase-audit/src/` (~50k LOC, 366 `.ts`/`.tsx` files, `src/generated/` excluded). Read-only pass on source.
|
||||
|
||||
## Summary
|
||||
|
||||
Rough counts over `src/` excluding `src/generated/`:
|
||||
|
||||
| Signal | Count | Notes |
|
||||
|---|---:|---|
|
||||
| `: any` / `<any>` / `as any` (all) | 79 | 33 in non-test production code |
|
||||
| `as any` in production code | 9 | almost entirely for untyped `window.*` globals or library result reshapes |
|
||||
| `@ts-ignore` | 0 | clean |
|
||||
| `@ts-expect-error` | 1 | single test-only use in `src/lib/utils/__tests__/misc-utils.test.ts:146`, justified |
|
||||
| `@ts-nocheck` | 0 | clean |
|
||||
| Non-null assertions `!` (production, excluding `!=` / `!==`) | ~15 | a few risky ones on `.getAttribute(...)`, `reader` and `e.target`, rest are mostly bailouts for hook-returned shapes |
|
||||
| `as unknown as T` casts | 4 in production | all in `use-post-page-number.ts`, `account-data-editor.tsx` - library shape crutches |
|
||||
| Broad `as string` / `as number` casts | 5 in production | mostly around `react-router` params (`useParams()` returns `Readonly<Params<string>>`) |
|
||||
| Untyped event handlers (`(e) => e.target.value`) | ~34 call sites | inference usually works, but the pattern is consistent throughout forms |
|
||||
| `: Function` erased-shape types | 1 | `src/components/post-form/post-form.tsx:337` |
|
||||
| `@ts-ignore` escape hatches | 0 | a strong signal - the codebase does not lean on ignores |
|
||||
|
||||
The overall state is good: **no `@ts-ignore`, no `@ts-nocheck`**, and explicit `any` in production is isolated to a small number of repeatable patterns (library interop with `bitsocial-react-hooks`, `window.electronApi` extensions, `onChallenge: (...args: any[])` callbacks). Most of the risk is concentrated in a few hot files - `post-desktop.tsx`, `post-mobile.tsx`, `use-post-page-number.ts`, `media-utils.ts`, and `challenge-utils.ts`.
|
||||
|
||||
## Findings
|
||||
|
||||
### Critical
|
||||
|
||||
- **src/lib/utils/media-utils.ts:195, 201** - `ogImage.getAttribute('content')!` and `firstImage.getAttribute('src')!` assert non-null on values that can legitimately be `null` (the attribute may be present as an empty string or may have been removed between the guard and the read). This is inside `fetchWebpageThumbnail`, a user-input (URL) driven code path. *Fix:* capture into a local `const content = ogImage.getAttribute('content'); if (!content) return undefined;`.
|
||||
- **src/lib/utils/media-utils.ts:182** - `await reader!.read()` inside a `while (true)` loop assumes `response.body?.getReader()` returned. If `response.body` is `null` (fetch bodies can be null when the server returns no body) this throws an uninformative runtime error instead of falling back to the no-thumbnail path. *Fix:* guard `if (!reader) return undefined;` before the loop.
|
||||
- **src/components/settings-modal/account-settings/account-settings.tsx:123** - `const fileContent = e.target!.result;` on a `FileReader.onload` handler. `e.target` is typed `FileReader | null`. This is user-driven (import account JSON), and while `onload` almost always has a target, the code should check `e.target?.result` and bail with the existing alert UI. *Fix:* `const fileContent = e.target?.result;` then preserve the `typeof` guard that already exists below.
|
||||
- **src/hooks/use-post-page-number.ts:35-38, 60** - Four non-null assertions plus two `as unknown as` casts on Zustand store shape: `state.feedsOptions as unknown as FeedsOptionsLike`, `communityAddress!`, `postCid!`. The `canResolve` boolean does guard them, but the `as unknown as` pair bypasses type checking on shared store state that is heavily used across the app. If the upstream `bitsocial-react-hooks` shape changes, TS will silently accept it. *Fix:* Define a narrow local type that re-declares the slice you consume from `useFeedsStore.getState()`, and either derive a discriminated `{ canResolve: true; communityAddress: string; postCid: string } | { canResolve: false }` or pass through `as string` after the guard instead of `!`.
|
||||
|
||||
### High
|
||||
|
||||
- **src/components/post-desktop/post-desktop.tsx:879-910 and src/components/post-mobile/post-mobile.tsx:604-629** - Repeated `(fooResult as { updatedReplies?: Comment[] }).updatedReplies!` pattern (6 occurrences). The cast-then-assert pattern hides whether `useReplies()` actually returns `updatedReplies`. If the upstream hook drops or renames the field, TS will not catch it and the `!` will silently yield `undefined` which is then used in `.length`-style checks elsewhere. *Fix:* extend `bitsocial-react-hooks` ambient type augmentation (`src/modules.d.ts`) to declare `updatedReplies?: Comment[]` on the `useReplies` return type, so the three call sites can drop the casts entirely.
|
||||
- **src/lib/utils/challenge-utils.ts:15, 47, 63, 76** - Four exported functions take `publication: any`: `alertChallengeVerificationFailed`, `getPublicationType`, `getVotePreview`, `getPublicationPreview`. These are the central challenge-failure code path, called from **every** `onChallengeVerification` handler in the app (publish post, publish reply, moderation, delete). The shape is actually known: it is the `PublishCommentOptions`-like payload. *Fix:* Introduce a `type ChallengePublication = { vote?: number; parentCid?: string; commentCid?: string; title?: string; content?: string; link?: string; subplebbitAddress?: string; communityAddress?: string; }` and replace the four `any` parameters. Fallbacks (`publication?.vote`) already tolerate missing fields; the type just codifies that.
|
||||
- **src/views/post/post.tsx:137, 139** - `PostProps.post?: any` and `PostProps.reply?: any` on the top-level exported `Post` component. This leaks `any` through the main post rendering surface into every `post-desktop.tsx` / `post-mobile.tsx` reader; downstream code does many `post?.cid`, `post?.content` accesses with no checking. *Fix:* use `Comment | undefined` (or a project-local `ResolvedPost` alias if the component accepts richer shapes) - this is the single highest-leverage `any` removal in the codebase.
|
||||
- **src/components/post-mobile/post-mobile.tsx:444** - `const PostMediaContent = ({ post, link }: { post: any; link: string })` in a hot render path. *Fix:* `post: Comment | undefined` (same change as above, one file).
|
||||
- **src/components/catalog-row/catalog-row.tsx:30** and **src/components/markdown/markdown.tsx:30, 32** - Public component props typed as `commentMediaInfo: any`, `children: any`, `linkMediaInfo: any`. `CommentMediaInfo` is already exported from `src/lib/utils/media-utils.ts` and is the right type. *Fix:* import and use `CommentMediaInfo`. For `children`, use `React.ReactNode`.
|
||||
- **src/hooks/use-communities-stats.ts:7, 8** - `communityStats: { [communityAddress: string]: any }` and `setCommunityStats: (address, stats: any) => void` on a store. Consumers read `stats.allPostCount` etc.; the type is knowable from `useCommunityStats`'s return. *Fix:* import `CommunityStats` type from `bitsocial-react-hooks` or declare a narrow `{ allPostCount?: number; ... }` interface.
|
||||
- **src/hooks/use-stable-community.ts:24, 41, 64** - `Record<string, any>` in `shallowEqual`, `(prev: any, next: any)` in `isCommunityEqual`, and `selector: (community: any) => T` in `useCommunityField`. The value is a `Community`, which is imported at the top of the file but then abandoned. *Fix:* replace the `any` with `Community` (or `Partial<Community>` for `isCommunityEqual`); `shallowEqual` can be `<T extends Record<string, unknown>>(a?: T, b?: T)`.
|
||||
- **src/stores/use-publish-post-store.ts:8** and **src/stores/use-publish-reply-store.ts:8** - `author?: any | undefined` (the `| undefined` is redundant when already optional). Author shape is `Author` or at least `{ displayName?: string; ... }`. The stored object is assembled from `Comment.author` a few lines below. *Fix:* `author?: Comment['author']`.
|
||||
- **src/components/post-desktop/post-desktop.tsx:110, 129; post-mobile.tsx:124, 143; use-publish-post.ts:76; use-publish-reply.ts:125; use-delete-failed-post.ts:86; mod-queue.tsx:217, 236; edit-menu.tsx:63** - Ten `onChallenge: async (...args: any[]) => ...` callbacks. These all follow the same `(...args) => addChallenge([...args, post])` pattern; `addChallenge` is a Zustand action in `use-challenges-store.ts`. Whatever shape `args` takes is fixed by the upstream hook. *Fix:* declare a `type ChallengeCallback = NonNullable<PublishCommentOptions['onChallenge']>` once, then reuse it at every site; `(...args: Parameters<ChallengeCallback>)` makes the list type-safe.
|
||||
|
||||
### Medium
|
||||
|
||||
- **src/components/post-form/post-form.tsx:337** - `debounce((content: string, t: Function) => ...)`. `Function` erases all information. *Fix:* `t: TFunction` imported from `'i18next'` (already used via `useTranslation()` elsewhere in the same file).
|
||||
- **src/lib/utils/media-utils.ts:18** - `getDisplayMediaInfoType(type: string, t: any)`. Same fix: `t: TFunction`. Used by multiple callers that already hold a typed `t`.
|
||||
- **src/lib/utils/clipboard-utils.ts:6, 8** - `(window as any).electronApi?.copyToClipboard`. `window.electronApi` is already declared in `src/globals.d.ts`, so the cast is entirely unnecessary and actively suppresses the declared contract. *Fix:* remove `as any`; `window.electronApi?.copyToClipboard` already typechecks.
|
||||
- **src/lib/react-scan.ts:5, 12, 25, 26, 71** - Dev-only integration (`import.meta.env.DEV` guarded). Five `as any`, including a container object declared as `const elementSourceApi: any`. Because this only runs in DEV it is low runtime risk, but it masks the real API surface. *Fix:* declare the extra window globals (`__PROFILING__`, `__getReactScanReport`, `__ELEMENT_SOURCE__`) in `src/globals.d.ts`; type `elementSourceApi` as a concrete interface that both branches (`notReady` stubs and the resolved-module assignment) conform to.
|
||||
- **src/hooks/use-account-communities-with-metadata.ts:11, 12** - `(community as any).address`, `(community as any).title` inside a `.map()`. The iterated value is `AccountCommunity` from `useAccountCommunities`. *Fix:* remove the casts or narrow once at the top of the map: `const c = community as { address?: string; title?: string };`.
|
||||
- **src/stores/use-catalog-filters-store.ts:369** - `partialize: (state) => ({...}) as any` inside a Zustand `persist` config. The `as any` likely exists because the partialized shape is narrower than the full state. *Fix:* type the return as `Pick<CatalogFiltersStore, 'filterItems'>` or use `partialize`'s generic properly; Zustand's `PersistOptions<S, Partial<S>>` handles this without the cast.
|
||||
- **src/components/catalog-filters/catalog-filters.tsx:118** and **src/components/catalog-filters/highlight-color-picker/highlight-color-picker.tsx:6-9** - `item: any`, `updateLocalFilterItem: (index: number, item: any) => void`, `localFilterItems: any[]` - the `FilterItem` interface is already exported in `use-catalog-filters-store.ts`. *Fix:* import and use `FilterItem` (or a `LocalFilterItem` subset that omits Map/Set state).
|
||||
- **src/components/edit-menu/edit-menu.tsx:63** - `onChallenge = useCallback((...args: any) => addChallenge([...args, latestPostRef.current]), [])`. Note also that `(...args: any)` is a rest with value-type `any` (worse than `...args: any[]`). *Fix:* see the `ChallengeCallback` pattern in the High section; at minimum use `any[]`.
|
||||
- **src/components/post-form/post-form.tsx:401** and **:536** - `const cid = params?.commentCid as string;` and `state.comments[commentCid as string]`. The cast is applied before any null check, so downstream code sees a non-nullable string that might actually be `undefined`. *Fix:* `const cid = params?.commentCid; if (!cid) return null;` - or type the destructured param properly.
|
||||
- **src/lib/utils/pretext-height-estimates.ts:878** - `Math.round(estimate as number)` after `estimate` was presumably typed as `number | undefined`. Rounding `undefined` produces `NaN` silently, then feeds `actualHeight - numericEstimate`. *Fix:* narrow with an explicit `if (typeof estimate !== 'number') return;` earlier.
|
||||
- **src/hooks/use-directories.ts:253, 267, 268** - `getDirectoriesMetadata(directoriesData as unknown)` and `normalizeDirectoriesData(directoriesData as unknown)` - casting **to** `unknown` (not `as unknown as T`). These are effectively no-ops and may be leftovers from a previous refactor. *Fix:* drop the casts or make the functions' parameters `unknown` explicitly.
|
||||
- **src/stores/use-catalog-filters-store.ts:265** - `communityFilteredCids.get(communityAddress)!` immediately after `if (!...has) { set(..., new Set()); }`. Correct logic, but the non-null assertion is readable. *Fix:* use the object returned from `set()` pattern: `let cidSet = communityFilteredCids.get(communityAddress); if (!cidSet) { cidSet = new Set(); communityFilteredCids.set(communityAddress, cidSet); }`.
|
||||
|
||||
### Low
|
||||
|
||||
- **src/components/embed/embed.tsx:145** - `margin: 0!important;` - this is a CSS-in-JS string, not a TS non-null. Listed only because grep-based scans will flag it; no action.
|
||||
- **src/views/faq/faq.tsx:192** and **src/components/directory-modal/directory-modal.tsx:40** - exclamation points are inside JSX text content. No action.
|
||||
- **src/hooks/use-directories.ts:311** - `throw new Error(`HTTP error! status: ${response.status}`)` - exclamation is a literal. No action.
|
||||
- **src/components/boards-bar/boards-bar.tsx:342-343** - `extractDirectoryFromTitle(board.title!)` and `directoryCode!`, `board.title!`. Line 313 filters the array by `sub.title && extractDirectoryFromTitle(sub.title)`, so both values are guaranteed non-null at this point, but the guarantee is spatially distant. *Fix:* compute `{value, label}` objects in the `.filter` pass so the type narrows naturally, or capture into a local variable and narrow with an explicit `if`.
|
||||
- **src/components/board-buttons/board-buttons.tsx:273, 363, 430** - `onChange={(e) => setX(e.target.value as 'Small' | 'Large')}` style on `<select>`. Since `<option>` values are themselves strings, TS won't verify the union - a stray option would slip through. *Fix:* centralize the options array as `const IMAGE_SIZES = ['Small', 'Large'] as const` and use it both for the `<option>` list and the cast source, or switch to a guarded assertion function.
|
||||
- **src/views/account-data-editor/account-data-editor.tsx:11, 20, 35** - `React.ComponentType<any>` for the dynamically imported `react-ace` editor. Legit library-interop `any`; `react-ace`'s props API is broad. Leave; annotate as `AceEditor?: React.ComponentType<IAceEditorProps> | null` if `react-ace` exposes it.
|
||||
- **36 onChange handlers** of the form `(e) => setX(e.target.value)` across `board-buttons.tsx`, `interface-settings.tsx`, `crypto-wallets-setting.tsx`, `post-form.tsx`, `catalog-filters.tsx`, etc. - These **do** typecheck via inference from the JSX `<input onChange>` contract, so `e` is `React.ChangeEvent<HTMLInputElement>` implicitly. This is stylistic only; not a real safety problem. Leave unless a stricter house style is adopted.
|
||||
- **Test files** - 20+ `as any` / `as unknown as` in `__tests__` (e.g. mocking `createElement(Component, props as any)` and stubbing `scrollIntoView as any`). These are standard Vitest/React-test reshape patterns and do not hide production bugs. Skip.
|
||||
|
||||
## Top 5 Actions
|
||||
|
||||
1. **Type `alertChallengeVerificationFailed` and friends in `src/lib/utils/challenge-utils.ts` properly.** Four `any` removals, called from every publish/moderate code path. Ripples upward into `post-desktop.tsx`, `post-mobile.tsx`, `mod-queue.tsx`, and the three publish hooks.
|
||||
2. **Replace `post?: any` / `reply?: any` in `src/views/post/post.tsx` `PostProps`.** Single type change that forces correct annotations in the two biggest files in the app. Expect a handful of collateral fixes in `post-desktop.tsx` / `post-mobile.tsx` (those files already import `Comment` from `bitsocial-react-hooks`, so the cost is small).
|
||||
3. **Declare `updatedReplies?: Comment[]` on `useReplies()`'s return type via `src/modules.d.ts` augmentation.** Removes six `as { updatedReplies?: Comment[] }).updatedReplies!` casts in `post-desktop.tsx` and `post-mobile.tsx`. Brittle upstream-shape guessing becomes a declared contract.
|
||||
4. **Fix the three concrete runtime-risk non-null assertions in `src/lib/utils/media-utils.ts:182/195/201` and `src/components/settings-modal/account-settings/account-settings.tsx:123`.** All four are reachable from user input (link preview fetch, JSON import) and each can realistically throw on a malformed response or empty `FileReader`. Two-minute edits, real stability win.
|
||||
5. **Introduce a single `ChallengeCallback` alias for `onChallenge` handlers.** Ten `(...args: any[])` / `(...args: any)` rest-callback sites in publish, moderation, and edit flows collapse to one type import. This is the most widespread uniform `any` pattern in the codebase; fixing it once removes nearly a third of production `any` usage.
|
||||
Reference in New Issue
Block a user