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:
parent
dc305fbc08
commit
721a5d4b67
11 changed files with 72 additions and 44 deletions
14
AGENTS.md
14
AGENTS.md
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue