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`) —
|
- **Lazy routes**: every route uses `lazyWithRetry` (`src/lib/lazyWithRetry.ts`) —
|
||||||
retries, then one full reload per session, so a chunk-load failure self-heals
|
retries, then one full reload per session, so a chunk-load failure self-heals
|
||||||
instead of showing the error boundary.
|
instead of showing the error boundary.
|
||||||
- **Baselines** (verified 2026-09-26): `npm test` → 0 errors, **60 warnings**
|
- **Baselines** (verified 2026-09-26): `npm test` → 0 errors, **46 warnings**
|
||||||
(39 `no-unused-vars` — mostly destructured bindings/params, 14
|
(39 `no-unused-vars`, 7 `react-refresh`), **101 tests**, build ok.
|
||||||
`react-hooks/exhaustive-deps`, 7 `react-refresh`), **101 tests**, build ok.
|
|
||||||
`package.json` version **1.4.5**.
|
`package.json` version **1.4.5**.
|
||||||
- ⚠️ Lint: `no-explicit-any` is **0**. Do **not** mass-fix `no-unused-vars` by
|
- ✅ `no-explicit-any` = 0; ✅ `react-hooks/exhaustive-deps` = 0 (shared getters
|
||||||
name or by blanket rename — shadowed names (`isUnfollowing`) and typed
|
are `useCallback`-memoised; intentional mount-only effects carry a justified
|
||||||
shorthand destructuring make it unsafe. Fix file-by-file, position-precise.
|
`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/`,
|
- **Untracked local junk — do NOT commit**: `assets/`, `resources/`,
|
||||||
`cvm-server/`, `bun.lock`.
|
`cvm-server/`, `bun.lock`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,9 @@ export function AppProvider(props: AppProviderProps) {
|
||||||
if (needsUpdate) {
|
if (needsUpdate) {
|
||||||
updateConfig((current) => ({ ...current, ...updates }));
|
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
|
// 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.
|
// Unify whichever transport is active into one message list + send + state.
|
||||||
const messages: DirectMessage[] = usingCordn
|
// 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))
|
? cordn.messages.map((m) => cordnToDirect(m, cordnEndpoint!.groupId))
|
||||||
: (nip17Messages ?? []);
|
: (nip17Messages ?? []),
|
||||||
|
[usingCordn, cordn.messages, cordnEndpoint, nip17Messages],
|
||||||
|
);
|
||||||
const isLoading = usingCordn ? cordn.isLoading : nip17Loading;
|
const isLoading = usingCordn ? cordn.isLoading : nip17Loading;
|
||||||
const isPending = usingCordn ? cordn.isSending : nip17Sending;
|
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.
|
// `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 info = useMemo(() => computeStatus(start, end), [start, end, tick]);
|
||||||
|
|
||||||
const styles: Record<EventStatus, string> = {
|
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);
|
const id = setInterval(() => forceTick((n) => n + 1), 30_000);
|
||||||
return () => clearInterval(id);
|
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]);
|
return useMemo(() => computeStatus(start, end).status, [start, end, tick]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1305,7 +1305,7 @@ Instructions:
|
||||||
setIsThinking(false);
|
setIsThinking(false);
|
||||||
setStreamingText('');
|
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
|
// Listen for external events from FAQ/About pages
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,9 @@ export function SpamFeePayment({
|
||||||
if (invoice && paymentState === 'waiting' && (webln || activeNWC)) {
|
if (invoice && paymentState === 'waiting' && (webln || activeNWC)) {
|
||||||
handleAutoPay();
|
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]);
|
}, [invoice, paymentState, webln, activeNWC]);
|
||||||
|
|
||||||
const generateInvoice = async () => {
|
const generateInvoice = async () => {
|
||||||
|
|
|
||||||
|
|
@ -974,6 +974,9 @@ export function SpotMap({
|
||||||
map.remove();
|
map.remove();
|
||||||
mapInstanceRef.current = null;
|
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
|
// 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 { useSeoMeta } from '@unhead/react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/Layout';
|
||||||
|
|
@ -848,7 +848,7 @@ export default function EventsPage() {
|
||||||
}, [events, tours]);
|
}, [events, tours]);
|
||||||
const { data: trustScores } = useBatchTrustScores(allPubkeys);
|
const { data: trustScores } = useBatchTrustScores(allPubkeys);
|
||||||
// `trustScores` is a plain Map<pubkey, score> (see useBatchTrustScores).
|
// `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 ────────────────────────────────────────────────────────────
|
// ── Comment counts ────────────────────────────────────────────────────────────
|
||||||
const allEventIds = useMemo(() => {
|
const allEventIds = useMemo(() => {
|
||||||
|
|
@ -858,7 +858,7 @@ export default function EventsPage() {
|
||||||
}, [events, tours]);
|
}, [events, tours]);
|
||||||
const { data: commentCounts } = useCommentCounts(allEventIds);
|
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) ─────────
|
// ── Batch RSVP counts (one query for the whole grid, not one per card) ─────────
|
||||||
const eventCoords = useMemo(() => (events ?? []).map((e) => eventCoord(e)), [events]);
|
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));
|
past.sort((a, b) => sortBy === 'recent' ? b.start - a.start : sortFn(a, b));
|
||||||
|
|
||||||
return { upcoming, past };
|
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 ──────────────────────────────────────────────────────────────
|
// ── Filter tours ──────────────────────────────────────────────────────────────
|
||||||
const filteredTours = useMemo(() => {
|
const filteredTours = useMemo(() => {
|
||||||
|
|
@ -996,7 +996,7 @@ export default function EventsPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [tours, tourSearchQuery, tourLocationQuery, tourCategory, tourSkillLevel, tourTrustFilter, trustScores, tourSortBy, commentCounts]);
|
}, [tours, tourSearchQuery, tourLocationQuery, tourCategory, tourSkillLevel, tourTrustFilter, getTrustScore, trustScores, tourSortBy, getCommentCount]);
|
||||||
|
|
||||||
// ── Active filter counts ──────────────────────────────────────────────────────
|
// ── Active filter counts ──────────────────────────────────────────────────────
|
||||||
const activeEventFilterCount =
|
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 { useSeoMeta } from '@unhead/react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/Layout';
|
||||||
|
|
@ -1154,7 +1154,7 @@ export default function LabPage() {
|
||||||
|
|
||||||
const profilePubkeys = useMemo(() => profiles?.map((p) => p.pubkey) || [], [profiles]);
|
const profilePubkeys = useMemo(() => profiles?.map((p) => p.pubkey) || [], [profiles]);
|
||||||
const { data: trustScores } = useBatchTrustScores(profilePubkeys);
|
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
|
// Fetch all authors for search-by-name support
|
||||||
// We collect metadata from ProfileCard's useAuthor calls via a shared memo approach
|
// We collect metadata from ProfileCard's useAuthor calls via a shared memo approach
|
||||||
|
|
@ -1252,7 +1252,7 @@ export default function LabPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
return filtered;
|
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
|
// Active filter count (skills + trust + sort) — location and search are separate quick filters
|
||||||
const activeFilterCount =
|
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 { useSeoMeta } from '@unhead/react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/Layout';
|
||||||
|
|
@ -846,7 +846,7 @@ export default function MapPage() {
|
||||||
const locationPubkeys = useMemo(() => allLocations.map((l) => l.pubkey), [allLocations]);
|
const locationPubkeys = useMemo(() => allLocations.map((l) => l.pubkey), [allLocations]);
|
||||||
const { data: trustScores } = useBatchTrustScores(locationPubkeys);
|
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.
|
// Fetch community status reports for all spots (not shops), batched in one query.
|
||||||
const statusSpotRefs = useMemo(
|
const statusSpotRefs = useMemo(
|
||||||
|
|
@ -859,15 +859,17 @@ export default function MapPage() {
|
||||||
const spotIds = useMemo(() => allLocations.map((l) => l.event.id), [allLocations]);
|
const spotIds = useMemo(() => allLocations.map((l) => l.event.id), [allLocations]);
|
||||||
const { data: commentCounts } = useCommentCounts(spotIds);
|
const { data: commentCounts } = useCommentCounts(spotIds);
|
||||||
|
|
||||||
const getCommentCount = (eventId: string) => {
|
const getCommentCount = useCallback((eventId: string) => {
|
||||||
return commentCounts?.get(eventId) ?? 0;
|
return commentCounts?.get(eventId) ?? 0;
|
||||||
};
|
}, [commentCounts]);
|
||||||
|
|
||||||
// Batched zap totals for "Most Zapped" sorting (ONE query for every location).
|
// Batched zap totals for "Most Zapped" sorting (ONE query for every location).
|
||||||
const locationEvents = useMemo(() => allLocations.map((l) => l.event), [allLocations]);
|
const locationEvents = useMemo(() => allLocations.map((l) => l.event), [allLocations]);
|
||||||
const { data: zapTotals } = useZapTotals(locationEvents);
|
const { data: zapTotals } = useZapTotals(locationEvents);
|
||||||
const getZapTotal = (event: (typeof locationEvents)[number]) =>
|
const getZapTotal = useCallback(
|
||||||
zapTotals?.get(zapTargetKey(event))?.totalSats ?? 0;
|
(event: (typeof locationEvents)[number]) => zapTotals?.get(zapTargetKey(event))?.totalSats ?? 0,
|
||||||
|
[zapTotals],
|
||||||
|
);
|
||||||
|
|
||||||
// Get geohashes for weather fetching (only non-shop spots)
|
// Get geohashes for weather fetching (only non-shop spots)
|
||||||
const spotGeohashes = useMemo(() => {
|
const spotGeohashes = useMemo(() => {
|
||||||
|
|
@ -988,7 +990,7 @@ export default function MapPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
return filtered;
|
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
|
// Spots shown as MAP MARKERS. Hide spots the community has confirmed as
|
||||||
// "gone" (demolished) so they don't clutter the map — they still appear
|
// "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
|
* 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 { useSeoMeta } from '@unhead/react';
|
||||||
import { nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
// QRCode is imported lazily inside drawCardToCanvas() to avoid canvas API
|
// QRCode is imported lazily inside drawCardToCanvas() to avoid canvas API
|
||||||
|
|
@ -992,7 +992,8 @@ export default function RiderCardPage() {
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const cardPubkey = cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey;
|
const cardPubkey = cardType === 'rider' ? (user?.pubkey ?? '') : recipientPubkey;
|
||||||
const cardData: CardData = {
|
const cardData: CardData = useMemo(
|
||||||
|
() => ({
|
||||||
type: cardType, theme, holographic,
|
type: cardType, theme, holographic,
|
||||||
name: cardType === 'rider' ? displayName : recipientName,
|
name: cardType === 'rider' ? displayName : recipientName,
|
||||||
nip05: metadata?.nip05,
|
nip05: metadata?.nip05,
|
||||||
|
|
@ -1009,7 +1010,13 @@ export default function RiderCardPage() {
|
||||||
: new Date().getFullYear().toString(),
|
: new Date().getFullYear().toString(),
|
||||||
receivedBadges,
|
receivedBadges,
|
||||||
issuerName, recipientName, accessLevel, validThru,
|
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> => {
|
const renderCardToImage = useCallback(async (): Promise<string | null> => {
|
||||||
setRendering(true);
|
setRendering(true);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue