chore(lint): clear all react-hooks/exhaustive-deps warnings

Triage per file (not a blanket sweep):
- Memoise shared getters with useCallback and depend on them:
  EventsPage/LabPage/MapPage getTrustScore+getCommentCount (+MapPage getZapTotal)
- Memoise derived values that were recreated each render: CrewChat 'messages',
  RiderCardPage 'cardData'
- Justified eslint-disable for intentional mount-only effects with a comment:
  AppProvider (seed defaults/theme), SpotMap (Leaflet init once),
  SpamFeePayment (auto-pay keyed to invoice/state, not the recreated handler)
- EventCountdown: keep the intentional 'tick' recompute dep (documented disable)

exhaustive-deps: 14 -> 0. Warnings 60 -> 46 (39 unused-vars, 7 react-refresh).
Gate: 101 tests, 0 errors, boot smoke green. AGENTS.md baseline updated.
This commit is contained in:
SkateSpots 2026-09-26 22:09:43 +07:00
parent dc305fbc08
commit 721a5d4b67
11 changed files with 72 additions and 44 deletions

View file

@ -79,13 +79,15 @@ session with **no other memory of this project**. So:
- **Lazy routes**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) —
retries, then one full reload per session, so a chunk-load failure self-heals
instead of showing the error boundary.
- **Baselines** (verified 2026-09-26): `npm test` → 0 errors, **60 warnings**
(39 `no-unused-vars` — mostly destructured bindings/params, 14
`react-hooks/exhaustive-deps`, 7 `react-refresh`), **101 tests**, build ok.
- **Baselines** (verified 2026-09-26): `npm test` → 0 errors, **46 warnings**
(39 `no-unused-vars`, 7 `react-refresh`), **101 tests**, build ok.
`package.json` version **1.4.5**.
- ⚠️ Lint: `no-explicit-any` is **0**. Do **not** mass-fix `no-unused-vars` by
name or by blanket rename — shadowed names (`isUnfollowing`) and typed
shorthand destructuring make it unsafe. Fix file-by-file, position-precise.
- ✅ `no-explicit-any` = 0; ✅ `react-hooks/exhaustive-deps` = 0 (shared getters
are `useCallback`-memoised; intentional mount-only effects carry a justified
`eslint-disable-next-line` with a comment).
- ⚠️ Do **not** mass-fix `no-unused-vars` by name or blanket rename — shadowed
names (`isUnfollowing`) and typed shorthand destructuring make it unsafe.
Fix file-by-file, position-precise, opportunistically.
- **Untracked local junk — do NOT commit**: `assets/`, `resources/`,
`cvm-server/`, `bun.lock`.

View file

@ -84,6 +84,9 @@ export function AppProvider(props: AppProviderProps) {
if (needsUpdate) {
updateConfig((current) => ({ ...current, ...updates }));
}
// Intentionally run once on mount (seed defaults / force dark theme).
// Adding config/updateConfig deps would re-run the effect after it writes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Ensure relay list is never empty - fallback to default if no relays configured

View file

@ -201,9 +201,14 @@ export function CrewChat({
});
// Unify whichever transport is active into one message list + send + state.
const messages: DirectMessage[] = usingCordn
? cordn.messages.map((m) => cordnToDirect(m, cordnEndpoint!.groupId))
: (nip17Messages ?? []);
// Memoised so its identity is stable for the useMemo/useEffect deps below.
const messages: DirectMessage[] = useMemo(
() =>
usingCordn
? cordn.messages.map((m) => cordnToDirect(m, cordnEndpoint!.groupId))
: (nip17Messages ?? []),
[usingCordn, cordn.messages, cordnEndpoint, nip17Messages],
);
const isLoading = usingCordn ? cordn.isLoading : nip17Loading;
const isPending = usingCordn ? cordn.isSending : nip17Sending;

View file

@ -58,6 +58,7 @@ export function EventCountdown({
}, []);
// `tick` is a dependency so the label actually recomputes on each interval.
// eslint-disable-next-line react-hooks/exhaustive-deps
const info = useMemo(() => computeStatus(start, end), [start, end, tick]);
const styles: Record<EventStatus, string> = {
@ -93,5 +94,7 @@ export function useEventStatus(start: number, end?: number): EventStatus {
const id = setInterval(() => forceTick((n) => n + 1), 30_000);
return () => clearInterval(id);
}, []);
// `tick` forces recompute on the interval (it is not used inside the memo).
// eslint-disable-next-line react-hooks/exhaustive-deps
return useMemo(() => computeStatus(start, end).status, [start, end, tick]);
}

View file

@ -1305,7 +1305,7 @@ Instructions:
setIsThinking(false);
setStreamingText('');
}
}, [userInput, isThinking, chatMessages, user, cvm, personal, isPro, appendMessage, cvmConnected, runTripPlanner]);
}, [userInput, isThinking, chatMessages, cvm, personal, isPro, appendMessage, runTripPlanner]);
// Listen for external events from FAQ/About pages
useEffect(() => {

View file

@ -37,6 +37,9 @@ export function SpamFeePayment({
if (invoice && paymentState === 'waiting' && (webln || activeNWC)) {
handleAutoPay();
}
// Intentionally omit handleAutoPay: it is recreated each render, so adding
// it would re-trigger auto-pay on every render. Keyed to invoice/state.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [invoice, paymentState, webln, activeNWC]);
const generateInvoice = async () => {

View file

@ -974,6 +974,9 @@ export function SpotMap({
map.remove();
mapInstanceRef.current = null;
};
// Map init runs once on mount; center/zoom/click handlers are applied by
// later effects, so they must not re-create the Leaflet map.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Fix map size when container resizes

View file

@ -1,4 +1,4 @@
import { useState, useMemo, useRef, useEffect } from 'react';
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { Layout } from '@/components/Layout';
@ -848,7 +848,7 @@ export default function EventsPage() {
}, [events, tours]);
const { data: trustScores } = useBatchTrustScores(allPubkeys);
// `trustScores` is a plain Map<pubkey, score> (see useBatchTrustScores).
const getTrustScore = (pubkey: string): number | undefined => trustScores?.get(pubkey);
const getTrustScore = useCallback((pubkey: string): number | undefined => trustScores?.get(pubkey), [trustScores]);
// ── Comment counts ────────────────────────────────────────────────────────────
const allEventIds = useMemo(() => {
@ -858,7 +858,7 @@ export default function EventsPage() {
}, [events, tours]);
const { data: commentCounts } = useCommentCounts(allEventIds);
const getCommentCount = (eventId: string) => commentCounts?.get(eventId) ?? 0;
const getCommentCount = useCallback((eventId: string) => commentCounts?.get(eventId) ?? 0, [commentCounts]);
// ── Batch RSVP counts (one query for the whole grid, not one per card) ─────────
const eventCoords = useMemo(() => (events ?? []).map((e) => eventCoord(e)), [events]);
@ -946,7 +946,7 @@ export default function EventsPage() {
past.sort((a, b) => sortBy === 'recent' ? b.start - a.start : sortFn(a, b));
return { upcoming, past };
}, [events, searchQuery, locationQuery, selectedTypes, trustFilter, trustScores, sortBy, dateWindow, now, commentCounts]);
}, [events, searchQuery, locationQuery, selectedTypes, trustFilter, getTrustScore, trustScores, sortBy, dateWindow, now, getCommentCount]);
// ── Filter tours ──────────────────────────────────────────────────────────────
const filteredTours = useMemo(() => {
@ -996,7 +996,7 @@ export default function EventsPage() {
});
return filtered;
}, [tours, tourSearchQuery, tourLocationQuery, tourCategory, tourSkillLevel, tourTrustFilter, trustScores, tourSortBy, commentCounts]);
}, [tours, tourSearchQuery, tourLocationQuery, tourCategory, tourSkillLevel, tourTrustFilter, getTrustScore, trustScores, tourSortBy, getCommentCount]);
// ── Active filter counts ──────────────────────────────────────────────────────
const activeEventFilterCount =

View file

@ -1,4 +1,4 @@
import { useState, useMemo, useRef, useEffect } from 'react';
import { useState, useMemo, useRef, useEffect, useCallback } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link, useSearchParams } from 'react-router-dom';
import { Layout } from '@/components/Layout';
@ -1154,7 +1154,7 @@ export default function LabPage() {
const profilePubkeys = useMemo(() => profiles?.map((p) => p.pubkey) || [], [profiles]);
const { data: trustScores } = useBatchTrustScores(profilePubkeys);
const getTrustScore = (pubkey: string) => trustScores?.get(pubkey);
const getTrustScore = useCallback((pubkey: string) => trustScores?.get(pubkey), [trustScores]);
// Fetch all authors for search-by-name support
// We collect metadata from ProfileCard's useAuthor calls via a shared memo approach
@ -1252,7 +1252,7 @@ export default function LabPage() {
});
return filtered;
}, [profiles, searchQuery, locationQuery, selectedSkills, serviceType, trustFilter, trustScores, sortBy, displayNameMap]);
}, [profiles, searchQuery, locationQuery, selectedSkills, serviceType, trustFilter, getTrustScore, trustScores, sortBy, displayNameMap]);
// Active filter count (skills + trust + sort) — location and search are separate quick filters
const activeFilterCount =

View file

@ -1,4 +1,4 @@
import { useState, useMemo, useEffect, lazy, Suspense } from 'react';
import { useState, useMemo, useEffect, lazy, Suspense, useCallback } from 'react';
import { useSeoMeta } from '@unhead/react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { Layout } from '@/components/Layout';
@ -846,7 +846,7 @@ export default function MapPage() {
const locationPubkeys = useMemo(() => allLocations.map((l) => l.pubkey), [allLocations]);
const { data: trustScores } = useBatchTrustScores(locationPubkeys);
const getTrustScore = (pubkey: string) => trustScores?.get(pubkey);
const getTrustScore = useCallback((pubkey: string) => trustScores?.get(pubkey), [trustScores]);
// Fetch community status reports for all spots (not shops), batched in one query.
const statusSpotRefs = useMemo(
@ -859,15 +859,17 @@ export default function MapPage() {
const spotIds = useMemo(() => allLocations.map((l) => l.event.id), [allLocations]);
const { data: commentCounts } = useCommentCounts(spotIds);
const getCommentCount = (eventId: string) => {
const getCommentCount = useCallback((eventId: string) => {
return commentCounts?.get(eventId) ?? 0;
};
}, [commentCounts]);
// Batched zap totals for "Most Zapped" sorting (ONE query for every location).
const locationEvents = useMemo(() => allLocations.map((l) => l.event), [allLocations]);
const { data: zapTotals } = useZapTotals(locationEvents);
const getZapTotal = (event: (typeof locationEvents)[number]) =>
zapTotals?.get(zapTargetKey(event))?.totalSats ?? 0;
const getZapTotal = useCallback(
(event: (typeof locationEvents)[number]) => zapTotals?.get(zapTargetKey(event))?.totalSats ?? 0,
[zapTotals],
);
// Get geohashes for weather fetching (only non-shop spots)
const spotGeohashes = useMemo(() => {
@ -988,7 +990,7 @@ export default function MapPage() {
});
return filtered;
}, [allLocations, searchQuery, selectedCategories, weatherEnabled, weatherData, weatherFilter, showSpots, showShops, trustFilter, trustScores, sortBy, userLocation, commentCounts, zapTotals]);
}, [allLocations, searchQuery, selectedCategories, weatherEnabled, weatherData, weatherFilter, showSpots, showShops, trustFilter, getTrustScore, trustScores, sortBy, userLocation, getCommentCount, getZapTotal]);
// Spots shown as MAP MARKERS. Hide spots the community has confirmed as
// "gone" (demolished) so they don't clutter the map — they still appear

View file

@ -13,7 +13,7 @@
* Print-ready: credit-card size (85.6×54mm), @media print isolates card
*/
import { useState, useRef, useCallback } from 'react';
import { useState, useRef, useCallback, useMemo } from 'react';
import { useSeoMeta } from '@unhead/react';
import { nip19 } from 'nostr-tools';
// QRCode is imported lazily inside drawCardToCanvas() to avoid canvas API
@ -992,24 +992,31 @@ export default function RiderCardPage() {
})();
const cardPubkey = cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey;
const cardData: CardData = {
type: cardType, theme, holographic,
name: cardType === 'rider' ? displayName : recipientName,
nip05: metadata?.nip05,
avatar: metadata?.picture,
pubkey: cardPubkey,
npub: cardPubkey ? fullNpub(cardPubkey) : '',
style, skills, sponsors, ridesFor,
showSpots, showZaps, showEvents,
spotsAdded: stats?.spotsAdded ?? 0,
zapsReceived: stats?.zapsReceived ?? 0,
eventsCreated: stats?.eventsCreated ?? 0,
memberYear: metadata?.created_at
? memberSince(metadata.created_at as number)
: new Date().getFullYear().toString(),
receivedBadges,
issuerName, recipientName, accessLevel, validThru,
};
const cardData: CardData = useMemo(
() => ({
type: cardType, theme, holographic,
name: cardType === 'rider' ? displayName : recipientName,
nip05: metadata?.nip05,
avatar: metadata?.picture,
pubkey: cardPubkey,
npub: cardPubkey ? fullNpub(cardPubkey) : '',
style, skills, sponsors, ridesFor,
showSpots, showZaps, showEvents,
spotsAdded: stats?.spotsAdded ?? 0,
zapsReceived: stats?.zapsReceived ?? 0,
eventsCreated: stats?.eventsCreated ?? 0,
memberYear: metadata?.created_at
? memberSince(metadata.created_at as number)
: new Date().getFullYear().toString(),
receivedBadges,
issuerName, recipientName, accessLevel, validThru,
}),
[
cardType, theme, holographic, displayName, recipientName, metadata,
cardPubkey, style, skills, sponsors, ridesFor, showSpots, showZaps,
showEvents, stats, receivedBadges, issuerName, accessLevel, validThru,
],
);
const renderCardToImage = useCallback(async (): Promise<string | null> => {
setRendering(true);