diff --git a/AGENTS.md b/AGENTS.md index 2b928f1..6f1ec8b 100644 --- a/AGENTS.md +++ b/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`. diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 04eb4d2..24e5919 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -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 diff --git a/src/components/CrewChat.tsx b/src/components/CrewChat.tsx index d3de85e..540e687 100644 --- a/src/components/CrewChat.tsx +++ b/src/components/CrewChat.tsx @@ -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; diff --git a/src/components/EventCountdown.tsx b/src/components/EventCountdown.tsx index ae98c4b..b829b88 100644 --- a/src/components/EventCountdown.tsx +++ b/src/components/EventCountdown.tsx @@ -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 = { @@ -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]); } diff --git a/src/components/SasquatchGuide.tsx b/src/components/SasquatchGuide.tsx index c1cbfe6..5467ecb 100644 --- a/src/components/SasquatchGuide.tsx +++ b/src/components/SasquatchGuide.tsx @@ -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(() => { diff --git a/src/components/SpamFeePayment.tsx b/src/components/SpamFeePayment.tsx index a75db6c..9e1767e 100644 --- a/src/components/SpamFeePayment.tsx +++ b/src/components/SpamFeePayment.tsx @@ -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 () => { diff --git a/src/components/SpotMap.tsx b/src/components/SpotMap.tsx index 39f3996..5e95f48 100644 --- a/src/components/SpotMap.tsx +++ b/src/components/SpotMap.tsx @@ -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 diff --git a/src/pages/EventsPage.tsx b/src/pages/EventsPage.tsx index 6ae78b8..e36b7c6 100644 --- a/src/pages/EventsPage.tsx +++ b/src/pages/EventsPage.tsx @@ -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 (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 = diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx index 2338e2b..81a7450 100644 --- a/src/pages/LabPage.tsx +++ b/src/pages/LabPage.tsx @@ -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 = diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index a02ad53..0ad6a72 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -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 diff --git a/src/pages/RiderCardPage.tsx b/src/pages/RiderCardPage.tsx index bf004f5..24b92e2 100644 --- a/src/pages/RiderCardPage.tsx +++ b/src/pages/RiderCardPage.tsx @@ -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 => { setRendering(true);