From b6849cd641f864cf035a84f880e3df1e4b97f1d0 Mon Sep 17 00:00:00 2001 From: snax y-not Date: Thu, 1 Oct 2026 07:29:47 -0400 Subject: [PATCH 1/9] chore(lint): turn off no-img-element (arbitrary flyer hosts), ignore local migration script Co-Authored-By: Claude Opus 5.5 --- eslint.config.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d1..60b32e85 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,6 +5,14 @@ import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, + { + rules: { + // Event flyers/avatars are hot-linked from arbitrary third-party hosts + // (Luma, Google, Twitter CDNs, ...), so next/image's remotePatterns + // allow-list and optimizer don't fit. Plain is intentional. + "@next/next/no-img-element": "off", + }, + }, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: @@ -12,6 +20,8 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + // Gitignored local one-off script (may exist in some checkouts). + "scripts/migrate-itinerary-ids.ts", ]), ]); From 40991cb6f4cbe8070ba33a18ec33422e8eda6cc0 Mon Sep 17 00:00:00 2001 From: snax y-not Date: Thu, 1 Oct 2026 07:30:50 -0400 Subject: [PATCH 2/9] chore(lint): fix no-explicit-any, prefer-const, empty interface, unescaped entity Co-Authored-By: Claude Opus 5.5 --- packages/mcp-server/src/api.ts | 2 +- src/app/api/ads/report/route.ts | 26 ++++++++++++++++++++++++-- src/components/RsvpOverlay.tsx | 2 +- src/lib/fetch-events.ts | 2 +- src/lib/sponsor-extraction.ts | 2 +- tests/debug-ids.spec.ts | 6 +++--- 6 files changed, 31 insertions(+), 9 deletions(-) diff --git a/packages/mcp-server/src/api.ts b/packages/mcp-server/src/api.ts index 23e62d24..716014b1 100644 --- a/packages/mcp-server/src/api.ts +++ b/packages/mcp-server/src/api.ts @@ -44,7 +44,7 @@ export interface DateCount { count: number; } -export interface ItineraryEvent extends SheeetsEvent {} +export type ItineraryEvent = SheeetsEvent; export interface Friend { user_id: string; diff --git a/src/app/api/ads/report/route.ts b/src/app/api/ads/report/route.ts index 5c8c9ec5..ae113ea0 100644 --- a/src/app/api/ads/report/route.ts +++ b/src/app/api/ads/report/route.ts @@ -2,6 +2,28 @@ import { createClient } from '@supabase/supabase-js'; import { NextRequest, NextResponse } from 'next/server'; import { isAdminPassword } from '@/lib/admin-auth'; +interface AdReportRpcRow { + ad_id: string; + ad_name: string | null; + placement: string; + impressions: number | string; + unique_impressions: number | string; + clicks: number | string; + unique_clicks: number | string; + ctr: number | string; + first_seen: string; + last_seen: string; +} + +interface AdEventRow { + ad_id: string; + ad_name: string | null; + placement: string; + event_type: string; + visitor_id: string | null; + created_at: string; +} + function getSupabase() { return createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, @@ -35,7 +57,7 @@ export async function GET(req: NextRequest) { }); if (!rpcError && rpcData) { - const ads = (rpcData as any[]).map(a => ({ + const ads = (rpcData as AdReportRpcRow[]).map(a => ({ ad_id: a.ad_id, ad_name: a.ad_name || a.ad_id, placement: a.placement, @@ -63,7 +85,7 @@ export async function GET(req: NextRequest) { } // Fallback: client-side aggregation with pagination if RPC not available - let allEvents: any[] = []; + let allEvents: AdEventRow[] = []; let offset = 0; const pageSize = 1000; diff --git a/src/components/RsvpOverlay.tsx b/src/components/RsvpOverlay.tsx index e2ef3fc7..aaa92106 100644 --- a/src/components/RsvpOverlay.tsx +++ b/src/components/RsvpOverlay.tsx @@ -188,7 +188,7 @@ export function RsvpOverlay({ onClick={onConfirm} className="flex-1 px-4 py-2.5 rounded-lg bg-green-600 hover:bg-green-500 text-white text-sm font-semibold transition-colors cursor-pointer" > - Done — I RSVP'd + Done — I RSVP'd diff --git a/src/lib/fetch-events.ts b/src/lib/fetch-events.ts index 0069557b..6515cce6 100644 --- a/src/lib/fetch-events.ts +++ b/src/lib/fetch-events.ts @@ -89,7 +89,7 @@ export async function fetchEvents(runtimeAddresses?: GeoAddressMap, tabs?: TabCo // Find header row, events start right after. // If no header row found in data, check if Google Sheets already consumed it // as column labels (happens when the sheet has no promo rows above the header). - let headerIdx = findHeaderIndex(allRows); + const headerIdx = findHeaderIndex(allRows); if (headerIdx === -1) { // If Google Sheets already consumed the header as column labels // (sheet has no promo rows above the header), all rows are data. diff --git a/src/lib/sponsor-extraction.ts b/src/lib/sponsor-extraction.ts index 95f9f709..3aaa3551 100644 --- a/src/lib/sponsor-extraction.ts +++ b/src/lib/sponsor-extraction.ts @@ -183,8 +183,8 @@ export async function fetchLumaApi(slug: string): Promise { // Layer 1: Luma API extraction // --------------------------------------------------------------------------- -// eslint-disable-next-line @typescript-eslint/no-explicit-any export async function extractSponsorsFromLuma( + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- untyped Luma API JSON, probed defensively below apiData: any, openaiClient?: OpenAI, ): Promise { diff --git a/tests/debug-ids.spec.ts b/tests/debug-ids.spec.ts index 68f269c5..31c424ab 100644 --- a/tests/debug-ids.spec.ts +++ b/tests/debug-ids.spec.ts @@ -18,7 +18,7 @@ test('check for duplicate event IDs in the DOM', async ({ page }) => { // Try to get the React key from the fiber const fiberKey = Object.keys(row).find(k => k.startsWith('__reactFiber')); if (fiberKey) { - const fiber = (row as any)[fiberKey]; + const fiber = (row as unknown as Record)[fiberKey]; reactKeys.push(fiber?.key || 'no-key'); } else { reactKeys.push('no-fiber'); @@ -43,7 +43,7 @@ test('check for duplicate event IDs in the DOM', async ({ page }) => { rows.forEach((row) => { const fiberKey = Object.keys(row).find(k => k.startsWith('__reactFiber')); if (fiberKey) { - const fiber = (row as any)[fiberKey]; + const fiber = (row as unknown as Record)[fiberKey]; reactKeys.push(fiber?.key || 'no-key'); } else { reactKeys.push('no-fiber'); @@ -66,7 +66,7 @@ test('check for duplicate event IDs in the DOM', async ({ page }) => { const fiberKey = Object.keys(row).find(k => k.startsWith('__reactFiber')); let key = 'no-fiber'; if (fiberKey) { - const fiber = (row as any)[fiberKey]; + const fiber = (row as unknown as Record)[fiberKey]; key = fiber?.key || 'no-key'; } results.push({ index: i, key, text: row.textContent?.substring(0, 100) || '' }); From c5afc21d2333f92df14189701daf62bc879377ca Mon Sep 17 00:00:00 2001 From: snax y-not Date: Thu, 1 Oct 2026 07:30:50 -0400 Subject: [PATCH 3/9] chore(lint): use next/link for internal page links /data stays a plain (targeted disable): it is a redirect to an external Google Sheet and needs a full-page navigation. Co-Authored-By: Claude Opus 5.5 --- src/app/admin/page.tsx | 5 +++-- src/app/ads/AdvertiseContent.tsx | 9 +++++---- src/app/page.tsx | 1 + src/components/Header.tsx | 3 ++- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5196c94a..5fdb97ec 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useMemo } from 'react'; +import Link from 'next/link'; import { Search, ArrowLeft } from 'lucide-react'; import { fetchEvents } from '@/lib/fetch-events'; import { FALLBACK_TABS } from '@/lib/constants'; @@ -168,9 +169,9 @@ export default function AdminPage() {
- + - +

Admin

{activeTab === 'featured' && ( diff --git a/src/app/ads/AdvertiseContent.tsx b/src/app/ads/AdvertiseContent.tsx index 3ccc2a35..d9f779ee 100644 --- a/src/app/ads/AdvertiseContent.tsx +++ b/src/app/ads/AdvertiseContent.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useRef, useMemo } from 'react'; +import Link from 'next/link'; import { useAdminConfig } from '@/hooks/useAdminConfig'; import { useABTest } from '@/hooks/useABTest'; import { EVENT_TABS } from '@/lib/constants'; @@ -482,18 +483,18 @@ export function AdvertiseContent() {

diff --git a/src/app/page.tsx b/src/app/page.tsx index 54585133..88101ce9 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -282,6 +282,7 @@ export default async function Home() { {/* Spreadsheet link */}

Looking for the old sheeets.xyz?{' '} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages -- /data is a redirect (next.config.ts) to an external Google Sheet, so it needs a full-page navigation */} Find it at plan.wtf/data diff --git a/src/components/Header.tsx b/src/components/Header.tsx index af2efbe5..d3f67b5f 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -2,6 +2,7 @@ import { memo, useState } from 'react'; import Image from 'next/image'; +import Link from 'next/link'; import { User, MapPin, Loader2 } from 'lucide-react'; import { trackAuthPrompt } from '@/lib/analytics'; import { ETHDenverEvent } from '@/lib/types'; @@ -40,7 +41,7 @@ export const Header = memo(function Header({

{/* Left: Branding */}
- plan.wtf + plan.wtf
{/* Right: Controls */} From 610fe474abf7da57531056aeed54b833bee1980d Mon Sep 17 00:00:00 2001 From: snax y-not Date: Thu, 1 Oct 2026 07:39:24 -0400 Subject: [PATCH 4/9] refactor(react-hooks): read browser state via useSyncExternalStore Replace mount-time effect+setState hydration of browser state with hydration-safe useSyncExternalStore hooks (server snapshot = previous SSR default, client value applied right after hydration): - useLocalStorageState (new): view mode persistence (useViewMode) - useMediaQuery (new): CommentSection mobile detection - useIsClient (new): OnboardingWizard portal gate - useNow (new): FriendMarker staleness (no Date.now() in render) - admin page: sessionStorage session restore; loading flags derived Co-Authored-By: Claude Opus 5.5 --- src/app/admin/page.tsx | 38 +++++++++------ src/components/CommentSection.tsx | 12 +---- src/components/FriendMarker.tsx | 4 +- src/components/OnboardingWizard.tsx | 7 +-- src/hooks/useIsClient.ts | 14 ++++++ src/hooks/useLocalStorageState.ts | 75 +++++++++++++++++++++++++++++ src/hooks/useMediaQuery.ts | 24 +++++++++ src/hooks/useNow.ts | 44 +++++++++++++++++ src/hooks/useViewMode.ts | 51 +++++++++++--------- 9 files changed, 215 insertions(+), 54 deletions(-) create mode 100644 src/hooks/useIsClient.ts create mode 100644 src/hooks/useLocalStorageState.ts create mode 100644 src/hooks/useMediaQuery.ts create mode 100644 src/hooks/useNow.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5fdb97ec..91771f2d 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useSyncExternalStore } from 'react'; import Link from 'next/link'; import { Search, ArrowLeft } from 'lucide-react'; import { fetchEvents } from '@/lib/fetch-events'; @@ -24,6 +24,18 @@ import { useAdminConfigEditor } from '@/components/admin/hooks/useAdminConfigEdi const SESSION_KEY = 'sheeets-admin-auth'; +const noopSubscribe = () => () => {}; +/** Admin password remembered for this tab (null during SSR/hydration). */ +function useSessionPassword(): string | null { + const saved = useSyncExternalStore( + noopSubscribe, + () => sessionStorage.getItem(SESSION_KEY), + () => null + ); + // Legacy sessions stored the literal 'true' instead of the password. + return saved && saved !== 'true' ? saved : null; +} + type AdminTab = 'submissions' | 'featured' | 'conferences' | 'sponsors' | 'nativeAds' | 'upsell' | 'adInventory' | 'theme' | 'abTests' | 'adReports' | 'eventAnalytics' | 'sponsorData'; const TAB_LABELS: { key: AdminTab; label: string }[] = [ @@ -42,11 +54,17 @@ const TAB_LABELS: { key: AdminTab; label: string }[] = [ ]; export default function AdminPage() { - const [password, setPassword] = useState(''); + const [typedPassword, setPassword] = useState(''); const [loginError, setLoginError] = useState(''); - const [authed, setAuthed] = useState(false); + const [loggedIn, setAuthed] = useState(false); + // Restore the session (sessionStorage) after hydration, without an effect. + const sessionPassword = useSessionPassword(); + const authed = loggedIn || sessionPassword !== null; + const password = loggedIn || sessionPassword === null ? typedPassword : sessionPassword; const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(false); + // Events are loading while authed and the fetch hasn't settled yet. + const [eventsSettled, setEventsSettled] = useState(false); + const loading = authed && !eventsSettled; // Featured tab filters live here because their controls sit in the sticky header const [conference, setConference] = useState(FALLBACK_TABS[0]?.name || ''); const [search, setSearch] = useState(''); @@ -66,22 +84,12 @@ export default function AdminPage() { setConferences, } = useAdminConfigEditor(authed, password); - // Check session on mount - useEffect(() => { - const saved = sessionStorage.getItem(SESSION_KEY); - if (saved && saved !== 'true') { - setAuthed(true); - setPassword(saved); - } - }, []); - // Fetch events when authed useEffect(() => { if (!authed) return; - setLoading(true); fetchEvents() .then(setEvents) - .finally(() => setLoading(false)); + .finally(() => setEventsSettled(true)); }, [authed]); // Merged conference tabs: FALLBACK_TABS + dynamic conferences from DB diff --git a/src/components/CommentSection.tsx b/src/components/CommentSection.tsx index fb3dc61d..12f31b37 100644 --- a/src/components/CommentSection.tsx +++ b/src/components/CommentSection.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect } from 'react'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; import { createPortal } from 'react-dom'; import { Send, Trash2, MessageCircle, X } from 'lucide-react'; import { useAuth } from '@/contexts/AuthContext'; @@ -20,7 +21,7 @@ interface CommentSectionProps { export function CommentSection({ eventId, commentCount = 0, eventName }: CommentSectionProps) { const { user } = useAuth(); const [expanded, setExpanded] = useState(false); - const [isMobile, setIsMobile] = useState(false); + const isMobile = useMediaQuery('(max-width: 639px)'); const { comments, loading, addComment, deleteComment } = useEventComments( expanded ? eventId : null ); @@ -37,15 +38,6 @@ export function CommentSection({ eventId, commentCount = 0, eventName }: Comment telegramHandle?: string | null; } | null>(null); - // Detect mobile viewport - useEffect(() => { - const mq = window.matchMedia('(max-width: 639px)'); - setIsMobile(mq.matches); - const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches); - mq.addEventListener('change', handler); - return () => mq.removeEventListener('change', handler); - }, []); - // Body scroll lock when modal is open on mobile useEffect(() => { if (expanded && isMobile) { diff --git a/src/components/FriendMarker.tsx b/src/components/FriendMarker.tsx index 35d86f41..786fb55c 100644 --- a/src/components/FriendMarker.tsx +++ b/src/components/FriendMarker.tsx @@ -5,6 +5,7 @@ import type { FriendLocation } from '@/lib/types'; import { timeAgo } from '@/lib/time-parse'; import { getDisplayName } from '@/lib/user-display'; import UserAvatar from './UserAvatar'; +import { useNow } from '@/hooks/useNow'; interface FriendMarkerProps { location: FriendLocation; @@ -16,7 +17,8 @@ export function FriendMarker({ location, zoom = 12 }: FriendMarkerProps) { const showLabel = zoom >= 13; // Stale if >1h old - const ageMs = Date.now() - new Date(location.updated_at).getTime(); + const now = useNow(); + const ageMs = now - new Date(location.updated_at).getTime(); const isStale = ageMs > 60 * 60 * 1000; const isRecent = ageMs < 5 * 60 * 1000; diff --git a/src/components/OnboardingWizard.tsx b/src/components/OnboardingWizard.tsx index eba0e83f..8fd42f8c 100644 --- a/src/components/OnboardingWizard.tsx +++ b/src/components/OnboardingWizard.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useMemo } from 'react'; +import { useIsClient } from '@/hooks/useIsClient'; import { createPortal } from 'react-dom'; import Image from 'next/image'; import { X, CirclePlus, Clock, Users, Map, Plus, ChevronLeft, ChevronRight } from 'lucide-react'; @@ -76,11 +77,7 @@ export function OnboardingWizard({ const [currentStep, setCurrentStep] = useState(0); const [selectedConference, setSelectedConference] = useState(conferenceTabs[0]?.name || availableConferences[0] || ''); const [selectedTags, setSelectedTags] = useState>(new Set()); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); + const mounted = useIsClient(); useEffect(() => { if (isOpen) { diff --git a/src/hooks/useIsClient.ts b/src/hooks/useIsClient.ts new file mode 100644 index 00000000..5c229fdc --- /dev/null +++ b/src/hooks/useIsClient.ts @@ -0,0 +1,14 @@ +'use client'; + +import { useSyncExternalStore } from 'react'; + +const noopSubscribe = () => () => {}; + +/** + * `false` during SSR and hydration, `true` once rendering on the client. + * Hydration-safe replacement for the `useEffect(() => setMounted(true), [])` pattern + * (e.g. to gate `createPortal(…, document.body)`). + */ +export function useIsClient(): boolean { + return useSyncExternalStore(noopSubscribe, () => true, () => false); +} diff --git a/src/hooks/useLocalStorageState.ts b/src/hooks/useLocalStorageState.ts new file mode 100644 index 00000000..c8e784b6 --- /dev/null +++ b/src/hooks/useLocalStorageState.ts @@ -0,0 +1,75 @@ +'use client'; + +import { useCallback, useSyncExternalStore } from 'react'; + +// Same-tab subscribers per key, notified when this hook writes the key. +const listeners = new Map void>>(); + +function notify(key: string) { + listeners.get(key)?.forEach((l) => l()); +} + +// Values written in this tab. Read first so state still updates when +// localStorage is unavailable (private mode / quota errors). +const written = new Map(); + +function readRaw(key: string): string | null { + if (written.has(key)) return written.get(key)!; + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +/** + * localStorage-backed state that is safe for SSR + hydration. + * + * Renders `serverValue` on the server and during hydration, then switches to the + * stored value on the client (React re-renders right after hydration when they + * differ) — the useSyncExternalStore replacement for the + * `useEffect(() => setState(localStorage.getItem(...)), [])` pattern. + * + * `parse` maps the raw stored string (or null) to a value. It runs on every + * render, so it must return a stable (primitive or cached) value for the same input. + */ +export function useLocalStorageState( + key: string, + parse: (raw: string | null) => T, + serverValue: T, + serialize: (value: T) => string = String +): [T, (value: T) => void] { + const subscribe = useCallback( + (onChange: () => void) => { + let set = listeners.get(key); + if (!set) listeners.set(key, (set = new Set())); + set.add(onChange); + return () => { + set.delete(onChange); + }; + }, + [key] + ); + + const value = useSyncExternalStore( + subscribe, + () => parse(readRaw(key)), + () => serverValue + ); + + const setValue = useCallback( + (next: T) => { + const raw = serialize(next); + written.set(key, raw); + try { + localStorage.setItem(key, raw); + } catch { + // Storage unavailable (private mode / quota) — nothing to persist to. + } + notify(key); + }, + [key, serialize] + ); + + return [value, setValue]; +} diff --git a/src/hooks/useMediaQuery.ts b/src/hooks/useMediaQuery.ts new file mode 100644 index 00000000..23173a26 --- /dev/null +++ b/src/hooks/useMediaQuery.ts @@ -0,0 +1,24 @@ +'use client'; + +import { useCallback, useSyncExternalStore } from 'react'; + +/** + * Subscribes to a CSS media query. Returns `false` during SSR and hydration, + * then the live match state on the client (React re-renders after hydration + * if it differs), so it is hydration-safe. + */ +export function useMediaQuery(query: string): boolean { + const subscribe = useCallback( + (onChange: () => void) => { + const mq = window.matchMedia(query); + mq.addEventListener('change', onChange); + return () => mq.removeEventListener('change', onChange); + }, + [query] + ); + return useSyncExternalStore( + subscribe, + () => window.matchMedia(query).matches, + () => false + ); +} diff --git a/src/hooks/useNow.ts b/src/hooks/useNow.ts new file mode 100644 index 00000000..e7d2f553 --- /dev/null +++ b/src/hooks/useNow.ts @@ -0,0 +1,44 @@ +'use client'; + +import { useSyncExternalStore } from 'react'; + +const TICK_MS = 30_000; + +let now = 0; +const listeners = new Set<() => void>(); +let timer: ReturnType | null = null; + +function tick() { + now = Date.now(); + listeners.forEach((l) => l()); +} + +function subscribe(listener: () => void) { + listeners.add(listener); + if (!timer) { + now = Date.now(); + timer = setInterval(tick, TICK_MS); + } + return () => { + listeners.delete(listener); + if (listeners.size === 0 && timer) { + clearInterval(timer); + timer = null; + } + }; +} + +function getSnapshot() { + // First read happens before any subscription: initialise lazily. + if (now === 0) now = Date.now(); + return now; +} + +/** + * Current time (ms), shared across components and refreshed every 30s. + * Keeps render pure (no Date.now() in the component body) while relative + * "x minutes ago" UI still updates over time. Returns 0 on the server. + */ +export function useNow(): number { + return useSyncExternalStore(subscribe, getSnapshot, () => 0); +} diff --git a/src/hooks/useViewMode.ts b/src/hooks/useViewMode.ts index f07e26f9..fef5cf3a 100644 --- a/src/hooks/useViewMode.ts +++ b/src/hooks/useViewMode.ts @@ -1,43 +1,48 @@ 'use client'; -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback } from 'react'; import type { ViewMode } from '@/lib/types'; import { STORAGE_KEYS } from '@/lib/storage-keys'; +import { useLocalStorageState } from './useLocalStorageState'; + +// Width-based default for visitors with no saved view, decided once per page +// load (on the first client read) so it doesn't flip when the window resizes. +let defaultViewMode: ViewMode | null = null; + +function parseViewMode(saved: string | null): ViewMode { + if (saved === 'map' || saved === 'list' || saved === 'table' || saved === 'gallery') { + return saved; + } + if (!defaultViewMode) defaultViewMode = window.innerWidth >= 768 ? 'table' : 'list'; + return defaultViewMode; +} /** * Manages view mode (map/list/table) with localStorage persistence. * Returns the current mode, setter, and scroll-tracking refs. */ export function useViewMode() { - // Use 'list' as the SSR-safe default (most common on mobile) - const [viewMode, setViewModeState] = useState('list'); + // 'list' is the SSR/hydration value (most common on mobile); the saved or + // width-based mode is applied right after hydration (avoids a mismatch). + const [viewMode, persistViewMode] = useLocalStorageState( + STORAGE_KEYS.VIEW_MODE, + parseViewMode, + 'list' + ); const [contentScrolled, setContentScrolled] = useState(false); - const restoredRef = useRef(false); - // Restore from localStorage after mount (avoids hydration mismatch) - useEffect(() => { - const saved = localStorage.getItem(STORAGE_KEYS.VIEW_MODE); - if (saved === 'map' || saved === 'list' || saved === 'table' || saved === 'gallery') { - setViewModeState(saved); - } else { - setViewModeState(window.innerWidth >= 768 ? 'table' : 'list'); - } - restoredRef.current = true; - }, []); + // List view scroll tracking (mirrors TableView's onScrolledChange) + const listMainRef = useRef(null); + const listLastScrollTopRef = useRef(0); + const listScrolledRef = useRef(false); - // Persist to localStorage on change (after initial restore) + // Persist to localStorage on change const setViewMode = useCallback((mode: ViewMode) => { - setViewModeState(mode); + persistViewMode(mode); setContentScrolled(false); listScrolledRef.current = false; listLastScrollTopRef.current = 0; - localStorage.setItem(STORAGE_KEYS.VIEW_MODE, mode); - }, []); - - // List view scroll tracking (mirrors TableView's onScrolledChange) - const listMainRef = useRef(null); - const listLastScrollTopRef = useRef(0); - const listScrolledRef = useRef(false); + }, [persistViewMode]); const handleListScroll = useCallback(() => { const container = listMainRef.current; From 37cd206c60e0f915ba2cbe4748099bd358b3c6fa Mon Sep 17 00:00:00 2001 From: snax y-not Date: Thu, 1 Oct 2026 07:39:24 -0400 Subject: [PATCH 5/9] refactor(react-hooks): derive state during render instead of effect+setState - Compute derived values in render (itinerary conference default, theme, friend status, OG image cache hit, check-in toast visibility, loading flags) - Use the 'adjust state when a prop changes' pattern for resets keyed on props/user (TableView date label, admin Theme/AdInventory tabs, SubmitEventModal conference, comments/POIs/RSVP resets) - ItineraryPanel: fixes a bug where picking a conference tab was immediately reset to the main view's active conference by the sync effect - AddressAutocomplete: drop unused isLoaded state Co-Authored-By: Claude Opus 5.5 --- src/app/itinerary/page.tsx | 12 +++---- src/components/AddressAutocomplete.tsx | 11 ++---- src/components/CheckInFAB.tsx | 13 +++---- src/components/ItineraryPanel.tsx | 23 ++++++------- src/components/OGImage.tsx | 21 +++++------- src/components/ProfileCardModal.tsx | 33 ++++++++++-------- src/components/SubmitEventModal.tsx | 15 +++++--- src/components/TableView.tsx | 9 +++-- src/components/admin/AdInventoryTab.tsx | 12 ++++--- src/components/admin/ThemeTab.tsx | 12 ++++--- .../admin/hooks/useAdminConfigEditor.ts | 7 ++-- src/contexts/ThemeContext.tsx | 34 ++++++------------- src/hooks/useEventComments.ts | 16 +++++---- src/hooks/usePOIs.ts | 15 +++++--- src/hooks/useRsvp.ts | 9 ++++- 15 files changed, 125 insertions(+), 117 deletions(-) diff --git a/src/app/itinerary/page.tsx b/src/app/itinerary/page.tsx index 570f8e35..94434d8a 100644 --- a/src/app/itinerary/page.tsx +++ b/src/app/itinerary/page.tsx @@ -91,14 +91,10 @@ export default function ItineraryPage() { () => [...new Set(allItineraryEvents.map((e) => e.conference).filter(Boolean))], [allItineraryEvents] ); - const [activeConference, setActiveConference] = useState(''); - - // Auto-select first conference when data loads - useMemo(() => { - if (conferences.length > 0 && !activeConference) { - setActiveConference(conferences[0]); - } - }, [conferences, activeConference]); + // Conference the user explicitly picked; until then, default to the first one + // that has itinerary events (derived during render, no state sync needed). + const [selectedConference, setActiveConference] = useState(''); + const activeConference = selectedConference || conferences[0] || ''; const itineraryEvents = useMemo( () => allItineraryEvents.filter((e) => !activeConference || e.conference === activeConference), diff --git a/src/components/AddressAutocomplete.tsx b/src/components/AddressAutocomplete.tsx index 2dd5c780..63a3f336 100644 --- a/src/components/AddressAutocomplete.tsx +++ b/src/components/AddressAutocomplete.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef } from 'react'; import { MapPin } from 'lucide-react'; interface AddressAutocompleteProps { @@ -17,7 +17,6 @@ export function AddressAutocomplete({ placeholder = '1234 Market St, Denver', }: AddressAutocompleteProps) { const inputRef = useRef(null); - const [isLoaded, setIsLoaded] = useState(false); const onChangeRef = useRef(onChange); const onCoordsChangeRef = useRef(onCoordsChange); @@ -31,10 +30,7 @@ export function AddressAutocomplete({ useEffect(() => { const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY; - if (!apiKey) { - setIsLoaded(true); - return; - } + if (!apiKey) return; const initAutocomplete = () => { if (!inputRef.current || !window.google?.maps?.places) return; @@ -61,8 +57,6 @@ export function AddressAutocomplete({ }); } }); - - setIsLoaded(true); }; // Already loaded @@ -90,7 +84,6 @@ export function AddressAutocomplete({ script.async = true; script.defer = true; script.onload = () => initAutocomplete(); - script.onerror = () => setIsLoaded(true); document.head.appendChild(script); }, []); diff --git a/src/components/CheckInFAB.tsx b/src/components/CheckInFAB.tsx index 8c8e5640..db5b7ed2 100644 --- a/src/components/CheckInFAB.tsx +++ b/src/components/CheckInFAB.tsx @@ -11,14 +11,15 @@ interface CheckInFABProps { } export function CheckInFAB({ liveItineraryCount, onCheckIn, loading, result }: CheckInFABProps) { - const [showResult, setShowResult] = useState(false); + // Each new result is shown for 3s; once its timer fires it is remembered as + // expired so the toast hides without a synchronous setState in the effect. + const [expiredResult, setExpiredResult] = useState(null); + const showResult = result !== null && result !== expiredResult; useEffect(() => { - if (result) { - setShowResult(true); - const timer = setTimeout(() => setShowResult(false), 3000); - return () => clearTimeout(timer); - } + if (!result) return; + const timer = setTimeout(() => setExpiredResult(result), 3000); + return () => clearTimeout(timer); }, [result]); if (liveItineraryCount <= 0) return null; diff --git a/src/components/ItineraryPanel.tsx b/src/components/ItineraryPanel.tsx index c434f2d5..df24d991 100644 --- a/src/components/ItineraryPanel.tsx +++ b/src/components/ItineraryPanel.tsx @@ -60,18 +60,17 @@ export function ItineraryPanel({ [allItineraryEvents] ); - // Default to the active conference from the main view, fall back to first conference with events - const [selectedConference, setSelectedConference] = useState(''); - // Sync selectedConference when the active conference or available conferences change. - // Using useEffect instead of useMemo to avoid setState during render (which causes - // cascading re-renders and was a contributing factor to the row duplication bug). - useEffect(() => { - if (activeConference && conferences.includes(activeConference)) { - setSelectedConference(activeConference); - } else if (conferences.length > 0 && !selectedConference) { - setSelectedConference(conferences[0]); - } - }, [activeConference, conferences, selectedConference]); + // Default to the active conference from the main view, fall back to first conference with events. + // A tab the user picks wins until the main view's active conference changes. + // Derived during render (no effect) to avoid cascading re-renders. + const [picked, setPicked] = useState<{ conference: string; forActive: string | undefined } | null>(null); + const selectedConference = + picked && picked.forActive === activeConference && conferences.includes(picked.conference) + ? picked.conference + : activeConference && conferences.includes(activeConference) + ? activeConference + : conferences[0] ?? ''; + const setSelectedConference = (conference: string) => setPicked({ conference, forActive: activeConference }); // Filter to selected conference const itineraryEvents = useMemo( diff --git a/src/components/OGImage.tsx b/src/components/OGImage.tsx index 72e90311..6c4155b7 100644 --- a/src/components/OGImage.tsx +++ b/src/components/OGImage.tsx @@ -22,20 +22,18 @@ interface OGImageProps { export const imageCache = new Map(); export function OGImage({ url, eventId, rsvpUrl, onOpenLightbox, isInItinerary, onItineraryToggle, friendsGoing }: OGImageProps) { - const [imageUrl, setImageUrl] = useState( - imageCache.get(url) ?? null - ); - const [loaded, setLoaded] = useState(imageCache.has(url)); + // Result of the last /api/og fetch, tagged with the url it was for so a url + // change falls back to the shared cache (or "loading") without an effect. + const [fetched, setFetched] = useState<{ url: string; imageUrl: string | null } | null>(null); + const fetchedForUrl = fetched?.url === url; + const imageUrl = fetchedForUrl ? fetched.imageUrl : imageCache.get(url) ?? null; + const loaded = fetchedForUrl || imageCache.has(url); const [error, setError] = useState(false); const [lightboxOpen, setLightboxOpen] = useState(false); const ref = useRef(null); useEffect(() => { - if (imageCache.has(url)) { - setImageUrl(imageCache.get(url) ?? null); - setLoaded(true); - return; - } + if (imageCache.has(url)) return; const observer = new IntersectionObserver( ([entry]) => { @@ -49,12 +47,11 @@ export function OGImage({ url, eventId, rsvpUrl, onOpenLightbox, isInItinerary, .then((res) => res.json()) .then((data) => { imageCache.set(url, data.imageUrl); - setImageUrl(data.imageUrl); - setLoaded(true); + setFetched({ url, imageUrl: data.imageUrl }); }) .catch(() => { imageCache.set(url, null); - setLoaded(true); + setFetched({ url, imageUrl: null }); }); }, { rootMargin: '200px' } diff --git a/src/components/ProfileCardModal.tsx b/src/components/ProfileCardModal.tsx index 67d1f267..c7092bbf 100644 --- a/src/components/ProfileCardModal.tsx +++ b/src/components/ProfileCardModal.tsx @@ -37,7 +37,17 @@ export default function ProfileCardModal({ telegramHandle, }: ProfileCardModalProps) { const { user } = useAuth(); - const [friendStatus, setFriendStatus] = useState('loading'); + // Friend status fetched from Supabase, tagged with the viewer/profile pair it + // belongs to. 'not-logged-in' / 'self' / 'loading' are derived during render. + const fetchKey = isOpen && user && user.id !== userId ? `${user.id}:${userId}` : null; + const [fetchedStatus, setFetchedStatus] = useState<{ key: string; status: FriendStatus } | null>(null); + const friendStatus: FriendStatus = !user + ? 'not-logged-in' + : user.id === userId + ? 'self' + : fetchedStatus && fetchedStatus.key === fetchKey + ? fetchedStatus.status + : 'loading'; const [incomingRequestId, setIncomingRequestId] = useState(null); const [actionLoading, setActionLoading] = useState(false); @@ -61,20 +71,11 @@ export default function ProfileCardModal({ // Fetch friend status useEffect(() => { - if (!isOpen) return; - - if (!user) { - setFriendStatus('not-logged-in'); - return; - } - - if (user.id === userId) { - setFriendStatus('self'); - return; - } + if (!fetchKey || !user) return; + const key = fetchKey; + const setFriendStatus = (status: FriendStatus) => setFetchedStatus({ key, status }); async function checkFriendStatus() { - setFriendStatus('loading'); const currentUserId = user!.id; // Check if already friends @@ -129,7 +130,11 @@ export default function ProfileCardModal({ } checkFriendStatus(); - }, [isOpen, user, userId]); + }, [fetchKey, user, userId]); + + const setFriendStatus = (status: FriendStatus) => { + if (fetchKey) setFetchedStatus({ key: fetchKey, status }); + }; const handleSendRequest = async () => { setActionLoading(true); diff --git a/src/components/SubmitEventModal.tsx b/src/components/SubmitEventModal.tsx index 14ba081b..7b8dab65 100644 --- a/src/components/SubmitEventModal.tsx +++ b/src/components/SubmitEventModal.tsx @@ -61,12 +61,17 @@ export function SubmitEventModal({ isOpen, onClose, upsellCopy, initialConferenc const [hasBar, setHasBar] = useState(false); const [addressCoords, setAddressCoords] = useState<{ lat: number; lng: number } | null>(null); - // Sync conference when modal opens + // Sync conference when modal opens (adjust state during render on prop + // change instead of in an effect — https://react.dev/learn/you-might-not-need-an-effect) + const [prevOpenKey, setPrevOpenKey] = useState(null); + const openKey = isOpen ? `open:${initialConference ?? ''}` : null; + if (openKey !== prevOpenKey) { + setPrevOpenKey(openKey); + if (isOpen && initialConference) setConference(initialConference); + } + useEffect(() => { - if (isOpen) { - trackSubmitEventOpen(); - if (initialConference) setConference(initialConference); - } + if (isOpen) trackSubmitEventOpen(); }, [isOpen, initialConference]); function resetForm() { diff --git a/src/components/TableView.tsx b/src/components/TableView.tsx index 3e6fd28d..eb09c64a 100644 --- a/src/components/TableView.tsx +++ b/src/components/TableView.tsx @@ -253,10 +253,13 @@ export const TableView = memo(function TableView({ return () => document.removeEventListener('keydown', handleKeyDown); }, [selectedEvent]); - // Reset to "Time" when groups change (e.g. filter change) - useEffect(() => { + // Reset to "Time" when groups change (e.g. filter change). Adjusted during + // render rather than in an effect to avoid an extra commit. + const [prevGroups, setPrevGroups] = useState(groups); + if (groups !== prevGroups) { + setPrevGroups(groups); setCurrentDateLabel('Time'); - }, [groups]); + } // Track which date separator is at/near the top using IntersectionObserver useEffect(() => { diff --git a/src/components/admin/AdInventoryTab.tsx b/src/components/admin/AdInventoryTab.tsx index 456f8683..c491327c 100644 --- a/src/components/admin/AdInventoryTab.tsx +++ b/src/components/admin/AdInventoryTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useMemo } from 'react'; import { Loader2, Plus, Trash2, Pencil, X, Copy, MapPin, ChevronDown } from 'lucide-react'; import { FALLBACK_TABS } from '@/lib/constants'; import type { AdminConfig, AdInventoryItem, AdvertisePageConfig } from '@/lib/types'; @@ -55,9 +55,11 @@ export default function AdInventoryTab({ adminConfig, allConferenceTabs, configL )); }, [adminConfig, adConference, adConferenceList]); - // Load per-conference ad config when conference changes - useEffect(() => { - if (!adminConfig) return; + // Load per-conference ad config when conference / adminConfig change + // (adjusted during render instead of in an effect). + const [loadedFor, setLoadedFor] = useState<{ conference: string; config: AdminConfig } | null>(null); + if (adminConfig && (loadedFor?.conference !== adConference || loadedFor.config !== adminConfig)) { + setLoadedFor({ conference: adConference, config: adminConfig }); const inv = adminConfig[`ad_inventory:${adConference}`] as AdInventoryItem[] | undefined; setAdInventory(inv && Array.isArray(inv) ? inv : []); const page = adminConfig[`advertise_page:${adConference}`] as AdvertisePageConfig | undefined; @@ -68,7 +70,7 @@ export default function AdInventoryTab({ adminConfig, allConferenceTabs, configL }); setEditingInventoryId(null); setShowCopyFrom(false); - }, [adConference, adminConfig]); + } function handleCopyFrom(sourceConf: string) { if (!adminConfig) return; diff --git a/src/components/admin/ThemeTab.tsx b/src/components/admin/ThemeTab.tsx index 116a10ee..9d69134a 100644 --- a/src/components/admin/ThemeTab.tsx +++ b/src/components/admin/ThemeTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { Loader2 } from 'lucide-react'; import { FALLBACK_TABS } from '@/lib/constants'; import { THEME_OPTIONS, type ThemeId } from '@/lib/themes'; @@ -21,9 +21,11 @@ export default function ThemeTab({ adminConfig, allConferenceTabs, saving, saveM const [themeConference, setThemeConference] = useState(FALLBACK_TABS[0]?.name || ''); const [selectedTheme, setSelectedTheme] = useState('dark'); - // Load per-conference theme when themeConference changes - useEffect(() => { - if (!adminConfig) return; + // Load per-conference theme when themeConference / adminConfig change + // (adjusted during render instead of in an effect). + const [loadedFor, setLoadedFor] = useState<{ conference: string; config: AdminConfig } | null>(null); + if (adminConfig && (loadedFor?.conference !== themeConference || loadedFor.config !== adminConfig)) { + setLoadedFor({ conference: themeConference, config: adminConfig }); const t = adminConfig[`theme:${themeConference}`] as string | undefined; const validIds = THEME_OPTIONS.map(o => o.id) as string[]; if (t && validIds.includes(t)) { @@ -31,7 +33,7 @@ export default function ThemeTab({ adminConfig, allConferenceTabs, saving, saveM } else { setSelectedTheme('dark'); } - }, [themeConference, adminConfig]); + } return (
diff --git a/src/components/admin/hooks/useAdminConfigEditor.ts b/src/components/admin/hooks/useAdminConfigEditor.ts index 7d18910a..f992421b 100644 --- a/src/components/admin/hooks/useAdminConfigEditor.ts +++ b/src/components/admin/hooks/useAdminConfigEditor.ts @@ -22,7 +22,9 @@ export type SaveConfigFn = (key: string, value: unknown) => Promise; */ export function useAdminConfigEditor(authed: boolean, password: string) { const [adminConfig, setAdminConfig] = useState(null); - const [configLoading, setConfigLoading] = useState(false); + // Loading while authed and the (single) config fetch hasn't settled yet. + const [configSettled, setConfigSettled] = useState(false); + const configLoading = authed && !configSettled; const [saving, setSaving] = useState(false); const [saveMessage, setSaveMessage] = useState(''); const [conferences, setConferences] = useState([]); @@ -30,7 +32,6 @@ export function useAdminConfigEditor(authed: boolean, password: string) { // Fetch admin config when authed useEffect(() => { if (!authed) return; - setConfigLoading(true); fetch('/api/admin/config') .then(res => res.json()) .then((data: AdminConfig) => { @@ -52,7 +53,7 @@ export function useAdminConfigEditor(authed: boolean, password: string) { } }) .catch(() => {}) - .finally(() => setConfigLoading(false)); + .finally(() => setConfigSettled(true)); }, [authed]); async function saveConfig(key: string, value: unknown) { diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx index 0369f034..5172b320 100644 --- a/src/contexts/ThemeContext.tsx +++ b/src/contexts/ThemeContext.tsx @@ -1,6 +1,6 @@ 'use client'; -import { createContext, useContext, useEffect, useState, useCallback, useRef, type ReactNode } from 'react'; +import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react'; import { ThemeId, DEFAULT_THEME, THEME_OPTIONS } from '@/lib/themes'; const STORAGE_KEY = 'user-theme-override'; @@ -35,28 +35,15 @@ function readStoredTheme(): ThemeId | null { } export function ThemeProvider({ children, adminConfig, conference }: ThemeProviderProps) { - const [theme, setThemeState] = useState(() => readStoredTheme() ?? DEFAULT_THEME); - const userOverride = useRef(readStoredTheme() !== null); + // Theme the user picked manually (persisted); wins over the admin config. + const [userTheme, setUserTheme] = useState(readStoredTheme); - // Read theme from admin config when conference changes, - // but only if user hasn't manually toggled - useEffect(() => { - if (userOverride.current) return; - - if (!adminConfig || !conference) { - setThemeState(DEFAULT_THEME); - return; - } - - const configKey = `theme:${conference}`; - const configTheme = adminConfig[configKey] as string | undefined; - - if (configTheme && validIds.includes(configTheme)) { - setThemeState(configTheme as ThemeId); - } else { - setThemeState(DEFAULT_THEME); - } - }, [adminConfig, conference]); + // Otherwise use the per-conference theme from admin config (derived during render). + const configTheme = adminConfig && conference + ? (adminConfig[`theme:${conference}`] as string | undefined) + : undefined; + const theme: ThemeId = userTheme + ?? (configTheme && validIds.includes(configTheme) ? (configTheme as ThemeId) : DEFAULT_THEME); // Apply data-theme attribute to document useEffect(() => { @@ -67,8 +54,7 @@ export function ThemeProvider({ children, adminConfig, conference }: ThemeProvid }, [theme]); const setTheme = useCallback((newTheme: ThemeId) => { - userOverride.current = true; - setThemeState(newTheme); + setUserTheme(newTheme); try { localStorage.setItem(STORAGE_KEY, newTheme); } catch {} diff --git a/src/hooks/useEventComments.ts b/src/hooks/useEventComments.ts index 916cd445..26c5cbb6 100644 --- a/src/hooks/useEventComments.ts +++ b/src/hooks/useEventComments.ts @@ -14,13 +14,17 @@ export function useEventComments(eventId: string | null) { const [comments, setComments] = useState([]); const [loading, setLoading] = useState(false); - useEffect(() => { - if (!eventId) { - setComments([]); - return; - } + // Reset when the event (or viewer) changes — adjusted during render rather + // than with a synchronous setState in the fetch effect. + const [prevKey, setPrevKey] = useState<{ eventId: string | null; user: typeof user } | null>(null); + if (prevKey?.eventId !== eventId || prevKey.user !== user) { + setPrevKey({ eventId, user }); + if (!eventId) setComments([]); + else setLoading(true); + } - setLoading(true); + useEffect(() => { + if (!eventId) return; async function fetchComments() { const { data, error } = await supabase diff --git a/src/hooks/usePOIs.ts b/src/hooks/usePOIs.ts index d6a26708..90f1a3c3 100644 --- a/src/hooks/usePOIs.ts +++ b/src/hooks/usePOIs.ts @@ -12,15 +12,22 @@ export function usePOIs() { const [loading, setLoading] = useState(true); const [ownerNames, setOwnerNames] = useState>(new Map()); - // Fetch on mount when authenticated - useEffect(() => { + // Reset when the user changes (adjusted during render, not in an effect). + const [prevUser, setPrevUser] = useState(undefined); + if (user !== prevUser) { + setPrevUser(user); if (!user) { setPois([]); setOwnerNames(new Map()); setLoading(false); - return; + } else { + setLoading(true); } - setLoading(true); + } + + // Fetch on mount when authenticated + useEffect(() => { + if (!user) return; (async () => { const { data } = await supabase .from('pois') diff --git a/src/hooks/useRsvp.ts b/src/hooks/useRsvp.ts index 6a936f62..a820715f 100644 --- a/src/hooks/useRsvp.ts +++ b/src/hooks/useRsvp.ts @@ -9,9 +9,16 @@ export function useRsvp() { const [confirmedIds, setConfirmedIds] = useState>(new Set()); const [activeRsvp, setActiveRsvp] = useState<{ eventId: string; lumaUrl: string; eventName: string } | null>(null); + // Clear on sign-out (adjusted during render, not in an effect). + const [prevUser, setPrevUser] = useState(user); + if (user !== prevUser) { + setPrevUser(user); + if (!user) setConfirmedIds(new Set()); + } + // Load user's RSVPs on mount useEffect(() => { - if (!user) { setConfirmedIds(new Set()); return; } + if (!user) return; supabase .from('rsvps') .select('event_id') From 58bb765e4a60195b12c0fde6bc340384f2779c3f Mon Sep 17 00:00:00 2001 From: snax y-not Date: Thu, 1 Oct 2026 07:39:24 -0400 Subject: [PATCH 6/9] fix(react-hooks): refs/immutability/memoization errors; targeted disables - GalleryView: precompute group start indices instead of mutating a render var - POISearchBar: declare handleClose before the Escape effect and depend on it - useDragReorder: update latest-callback ref in an effect, not during render - useABTest: useCallback on primitive ids (compiler can preserve memoization) - useFriendRequests: clear on sign-out during render; add missing searchResults dep to sendRequest (stale closure meant the optimistic outgoing request was skipped) - Targeted set-state-in-effect disables (with reasons) for auth-driven deferred actions and async false positives Co-Authored-By: Claude Opus 5.5 --- src/app/itinerary/s/[code]/page.tsx | 1 + src/components/GalleryView.tsx | 11 ++++++---- src/components/POISearchBar.tsx | 19 +++++++++--------- src/hooks/useABTest.ts | 31 +++++++++++++++++------------ src/hooks/useAuthGatedActions.ts | 1 + src/hooks/useDragReorder.ts | 6 ++++-- src/hooks/useFriendCode.ts | 1 + src/hooks/useFriendRequests.ts | 18 +++++++++++++---- src/hooks/useItinerary.ts | 1 + 9 files changed, 57 insertions(+), 32 deletions(-) diff --git a/src/app/itinerary/s/[code]/page.tsx b/src/app/itinerary/s/[code]/page.tsx index 7021b4b6..939c36c8 100644 --- a/src/app/itinerary/s/[code]/page.tsx +++ b/src/app/itinerary/s/[code]/page.tsx @@ -76,6 +76,7 @@ export default function SharedItineraryPage() { useEffect(() => { if (pendingCopy && user && sharedEventIds) { addMany(sharedEventIds); + // eslint-disable-next-line react-hooks/set-state-in-effect -- completes a copy deferred until external auth (sign-in) finishes setCopyStatus('copied'); setPendingCopy(false); setTimeout(() => setCopyStatus('idle'), 2500); diff --git a/src/components/GalleryView.tsx b/src/components/GalleryView.tsx index f0e4fb14..ffd712d4 100644 --- a/src/components/GalleryView.tsx +++ b/src/components/GalleryView.tsx @@ -332,13 +332,17 @@ export function GalleryView({ ); } - // Track a running global index across date groups for lightbox - let globalIndex = 0; + // Global (flattened) index of each date group's first event, for the lightbox + const groupStartIndices: number[] = []; + for (let i = 0, n = 0; i < dateGroups.length; i++) { + groupStartIndices.push(n); + n += dateGroups[i].events.length; + } return (
{dateGroups.map((group, groupIdx) => { - const startIndex = globalIndex; + const startIndex = groupStartIndices[groupIdx]; const cards = group.events.map((event, eventIdx) => { const idx = startIndex + eventIdx; return ( @@ -353,7 +357,6 @@ export function GalleryView({ /> ); }); - globalIndex += group.events.length; return (
diff --git a/src/components/POISearchBar.tsx b/src/components/POISearchBar.tsx index a8033285..679a3ce0 100644 --- a/src/components/POISearchBar.tsx +++ b/src/components/POISearchBar.tsx @@ -70,15 +70,6 @@ export function POISearchBar({ onAddPOI, mapRef, onSignIn }: POISearchBarProps) if (expanded) inputRef.current?.focus(); }, [expanded]); - // Close on Escape - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') handleClose(); - }; - if (expanded) window.addEventListener('keydown', handler); - return () => window.removeEventListener('keydown', handler); - }, [expanded]); // eslint-disable-line react-hooks/exhaustive-deps - const handleClose = useCallback(() => { setExpanded(false); setSelectedResult(null); @@ -88,6 +79,16 @@ export function POISearchBar({ onAddPOI, mapRef, onSignIn }: POISearchBarProps) clear(); }, [clear]); + // Close on Escape + useEffect(() => { + if (!expanded) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [expanded, handleClose]); + const handleSelectResult = useCallback( async (result: GeocoderResult) => { const resolved = await select(result.mapbox_id); diff --git a/src/hooks/useABTest.ts b/src/hooks/useABTest.ts index 6e1a857d..d49ec32e 100644 --- a/src/hooks/useABTest.ts +++ b/src/hooks/useABTest.ts @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useEffect, useRef } from 'react'; +import { useMemo, useEffect, useRef, useCallback } from 'react'; import type { ABTest, ABTestVariant } from '@/lib/types'; import { getVisitorId, assignVariant, trackImpressionOnce, trackABEvent } from '@/lib/ab-testing'; @@ -55,19 +55,24 @@ export function useABTest({ test, trackImpression = true }: UseABTestOptions): U const config = useMemo(() => variant?.config ?? {}, [variant]); - const trackClick = useMemo(() => { - if (!test || !variant) return () => {}; - return (metadata?: Record) => { - trackABEvent(test.id, variant.id, 'click', metadata); - }; - }, [test, variant]); + const testId = test?.id; + const variantId = variant?.id; - const trackConversion = useMemo(() => { - if (!test || !variant) return () => {}; - return (metadata?: Record) => { - trackABEvent(test.id, variant.id, 'conversion', metadata); - }; - }, [test, variant]); + const trackClick = useCallback( + (metadata?: Record) => { + if (!testId || !variantId) return; + trackABEvent(testId, variantId, 'click', metadata); + }, + [testId, variantId] + ); + + const trackConversion = useCallback( + (metadata?: Record) => { + if (!testId || !variantId) return; + trackABEvent(testId, variantId, 'conversion', metadata); + }, + [testId, variantId] + ); return { variant, config, trackClick, trackConversion, isActive }; } diff --git a/src/hooks/useAuthGatedActions.ts b/src/hooks/useAuthGatedActions.ts index e2a0b1b3..b695bf48 100644 --- a/src/hooks/useAuthGatedActions.ts +++ b/src/hooks/useAuthGatedActions.ts @@ -64,6 +64,7 @@ export function useAuthGatedActions({ if (user && itineraryReady && pendingStarRef.current) { toggleItinerary(pendingStarRef.current); pendingStarRef.current = null; + // eslint-disable-next-line react-hooks/set-state-in-effect -- completes a deferred action once external auth + itinerary sync finish (pending action lives in a ref) setShowAuthForStar(false); } }, [user, itineraryReady, toggleItinerary]); diff --git a/src/hooks/useDragReorder.ts b/src/hooks/useDragReorder.ts index 00532635..f9e64337 100644 --- a/src/hooks/useDragReorder.ts +++ b/src/hooks/useDragReorder.ts @@ -1,6 +1,6 @@ 'use client'; -import { useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; interface UseDragReorderOptions { /** Called with the new ordered array when a drop completes */ @@ -50,7 +50,9 @@ export function useDragReorder({ onReorder }: UseDragReorderOptions) { const itemRefs = useRef>(new Map()); const orderedIdsRef = useRef([]); const onReorderRef = useRef(onReorder); - onReorderRef.current = onReorder; + useEffect(() => { + onReorderRef.current = onReorder; + }, [onReorder]); /** Keep the ordered IDs in sync so drag handlers can access them */ const setOrderedIds = useCallback((ids: string[]) => { diff --git a/src/hooks/useFriendCode.ts b/src/hooks/useFriendCode.ts index 3173c180..56b12aaa 100644 --- a/src/hooks/useFriendCode.ts +++ b/src/hooks/useFriendCode.ts @@ -121,6 +121,7 @@ export function useFriendCode({ openAuth, refreshFriends }: UseFriendCodeOptions window.history.replaceState(null, '', newUrl); if (user) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- async: its setState calls all run after an await, not synchronously in the effect redeemCode(code); } else { // Store pending code and trigger auth diff --git a/src/hooks/useFriendRequests.ts b/src/hooks/useFriendRequests.ts index ad5c9869..c4f1f4b5 100644 --- a/src/hooks/useFriendRequests.ts +++ b/src/hooks/useFriendRequests.ts @@ -25,6 +25,18 @@ export function useFriendRequests({ refreshFriends }: UseFriendRequestsOptions) const [searchLoading, setSearchLoading] = useState(false); const initialFetchDone = useRef(false); + // Clear request lists on sign-out (adjusted during render, not in an effect). + const signedOut = !authLoading && !user; + const [prevSignedOut, setPrevSignedOut] = useState(signedOut); + if (signedOut !== prevSignedOut) { + setPrevSignedOut(signedOut); + if (signedOut) { + setIncomingRequests([]); + setOutgoingRequests([]); + setPendingIncomingCount(0); + } + } + const fetchRequests = useCallback(async () => { if (!user) return; @@ -77,15 +89,13 @@ export function useFriendRequests({ refreshFriends }: UseFriendRequestsOptions) if (authLoading) return; if (!user) { - setIncomingRequests([]); - setOutgoingRequests([]); - setPendingIncomingCount(0); initialFetchDone.current = false; return; } if (initialFetchDone.current) return; + // eslint-disable-next-line react-hooks/set-state-in-effect -- async: its setState calls all run after an await, not synchronously in the effect fetchRequests().then(() => { initialFetchDone.current = true; }); @@ -178,7 +188,7 @@ export function useFriendRequests({ refreshFriends }: UseFriendRequestsOptions) // Then refresh from server in the background fetchRequests(); refreshFriends(); - }, [user, fetchRequests, refreshFriends]); + }, [user, searchResults, fetchRequests, refreshFriends]); const respondToRequest = useCallback(async (requestId: string, accept: boolean) => { if (!user) return; diff --git a/src/hooks/useItinerary.ts b/src/hooks/useItinerary.ts index 13656372..3a4f4abd 100644 --- a/src/hooks/useItinerary.ts +++ b/src/hooks/useItinerary.ts @@ -57,6 +57,7 @@ export function useItinerary() { if (authLoading) return; if (user) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- syncs from external auth state + localStorage, coupled to the Supabase sync state machine below (refs); refactor is high-risk setReady(false); // Wait for Supabase sync before ready try { const saved = localStorage.getItem(STORAGE_KEYS.ITINERARY); From fd33a8137cb7e274f6c9058eb17ac4a1d90354d1 Mon Sep 17 00:00:00 2001 From: snax y-not Date: Thu, 1 Oct 2026 07:40:11 -0400 Subject: [PATCH 7/9] fix(react-hooks): add missing effect/callback deps that caused stale values - ShareCardModal handleDownload: missing cardTitle dep made the downloaded filename use a stale (initial) title after the user edited it - ShareCardModal reset-on-close: missing displayName/conferenceName deps left the default card title pointing at the previous conference - EventCard impression observers: include compact Co-Authored-By: Claude Opus 5.5 --- src/components/EventCard.tsx | 4 ++-- src/components/ShareCardModal.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx index fbc0aff1..9d4deaa3 100644 --- a/src/components/EventCard.tsx +++ b/src/components/EventCard.tsx @@ -178,7 +178,7 @@ export const EventCard = memo(function EventCard({ observer.observe(el); return () => observer.disconnect(); - }, [event.id, event.name, event.isFeatured, conference]); + }, [event.id, event.name, event.isFeatured, conference, compact]); // Track event impressions via IntersectionObserver (all events in list view) useEffect(() => { @@ -205,7 +205,7 @@ export const EventCard = memo(function EventCard({ observer.observe(el); return () => observer.disconnect(); - }, [event.id, event.name, conference]); + }, [event.id, event.name, conference, compact]); const handleCopyLink = (e: React.MouseEvent) => { e.stopPropagation(); diff --git a/src/components/ShareCardModal.tsx b/src/components/ShareCardModal.tsx index 25f161a8..f737ab08 100644 --- a/src/components/ShareCardModal.tsx +++ b/src/components/ShareCardModal.tsx @@ -148,7 +148,7 @@ export function ShareCardModal({ setPreviewUrl(null); setCopyStatus('idle'); } - }, [isOpen, hiddenEventIds]); + }, [isOpen, hiddenEventIds, displayName, conferenceName]); const handleCopy = useCallback(async () => { if (!cardRef.current || selectedEvents.length === 0) return; @@ -198,7 +198,7 @@ export function ShareCardModal({ } catch (err) { console.error('Download failed:', err); } - }, [selectedEvents.length, conferenceName]); + }, [selectedEvents.length, cardTitle, conferenceName]); if (!isOpen) return null; From 7f40f2913a4e778dc0ec3e6217a7c9882d1f1587 Mon Sep 17 00:00:00 2001 From: snax y-not Date: Thu, 1 Oct 2026 07:41:59 -0400 Subject: [PATCH 8/9] chore(lint): remove unused imports, variables and props destructures Also drops a stale eslint-disable directive in EventApp. Left as-is: the legacy description-mining fallback in sponsor-extraction.ts (explicitly kept) and ListView's useVirtualizer incompatible-library notice. Co-Authored-By: Claude Opus 5.5 --- scripts/ga4-dashboard.mjs | 1 - src/app/page.tsx | 4 ---- src/components/AuthModal.tsx | 8 ++------ src/components/EmojiReactions.tsx | 5 ----- src/components/EventApp.tsx | 10 +--------- src/components/EventCard.tsx | 4 ---- src/components/GalleryView.tsx | 4 ---- src/components/ListView.tsx | 2 -- src/components/LockScreenTemplate.tsx | 7 ------- src/components/MapView.tsx | 2 -- src/components/SubmitEventModal.tsx | 1 - src/components/TableView.tsx | 3 --- 12 files changed, 3 insertions(+), 48 deletions(-) diff --git a/scripts/ga4-dashboard.mjs b/scripts/ga4-dashboard.mjs index 20c72aab..a7bd409d 100644 --- a/scripts/ga4-dashboard.mjs +++ b/scripts/ga4-dashboard.mjs @@ -247,7 +247,6 @@ async function fetchAudiences(accessToken) { // ---------- HTML generation ---------- function formatDate(yyyymmdd) { - const y = yyyymmdd.slice(0, 4); const m = yyyymmdd.slice(4, 6); const d = yyyymmdd.slice(6, 8); return `${m}/${d}`; diff --git a/src/app/page.tsx b/src/app/page.tsx index 88101ce9..cccabbfa 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,6 @@ import Image from 'next/image'; import { getConferenceTabs, getUpcomingConferences } from '@/lib/get-conferences'; import { fetchEventsCached } from '@/lib/fetch-events-cached'; -import { conferenceToTab } from '@/lib/conferences'; -import type { TabConfig } from '@/lib/conferences'; -import type { ConferenceConfig } from '@/lib/types'; import { NotifyForm } from '@/components/NotifyForm'; // Revalidate every 60s so new conferences appear without a redeploy @@ -299,7 +296,6 @@ export default async function Home() {
{upcoming.map((conf) => { - const tab = conferenceToTab(conf); const city = getCity(conf.center, conf.timezone); const dateRange = formatConfDateRange(conf.startDate, conf.endDate); const daysLabel = getDaysAwayLabel(conf.startDate, conf.endDate); diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx index b8483313..1ef99429 100644 --- a/src/components/AuthModal.tsx +++ b/src/components/AuthModal.tsx @@ -18,9 +18,8 @@ import { useFriends } from '@/hooks/useFriends'; import { useFriendRequests } from '@/hooks/useFriendRequests'; import type { ETHDenverEvent, NativeAd, UserSearchResult, FriendRequest } from '@/lib/types'; import { useAdminConfig } from '@/hooks/useAdminConfig'; -import { trackAdClick, trackAdImpression } from '@/lib/analytics'; +import { trackAdClick } from '@/lib/analytics'; import { trackAdEvent } from '@/lib/ad-tracking'; -import { ExternalLink } from 'lucide-react'; import { isSafeHttpUrl } from '@/lib/utils'; interface AuthModalProps { @@ -372,7 +371,7 @@ interface UserMenuProps { activeConference?: string; } -export function UserMenu({ events, itinerary, onOpenFriends, onSubmitEvent, pendingIncomingCount: externalCount, externalRefreshFriends, activeConference }: UserMenuProps) { +export function UserMenu({ events, itinerary, onOpenFriends, pendingIncomingCount: externalCount, externalRefreshFriends, activeConference }: UserMenuProps) { const { user, signOut } = useAuth(); const { profile, updateProfile, uploadAvatar } = useProfile(); const { friendCount, refreshFriends: localRefreshFriends } = useFriends(); @@ -405,7 +404,6 @@ export function UserMenu({ events, itinerary, onOpenFriends, onSubmitEvent, pend const [company, setCompany] = useState(''); const [linkedinUrl, setLinkedinUrl] = useState(''); const [jobTitle, setJobTitle] = useState(''); - const [saving, setSaving] = useState(false); const [saveStatus, setSaveStatus] = useState<'idle' | 'saved'>('idle'); const saveTimeout = useRef | null>(null); @@ -527,7 +525,6 @@ export function UserMenu({ events, itinerary, onOpenFriends, onSubmitEvent, pend if (saveTimeout.current) clearTimeout(saveTimeout.current); setSaveStatus('idle'); saveTimeout.current = setTimeout(async () => { - setSaving(true); await updateProfile({ display_name: displayName.trim() || null, x_handle: xHandle.trim() || null, @@ -537,7 +534,6 @@ export function UserMenu({ events, itinerary, onOpenFriends, onSubmitEvent, pend linkedin_url: linkedinUrl.trim() || null, job_title: jobTitle.trim() || null, }); - setSaving(false); setSaveStatus('saved'); setTimeout(() => setSaveStatus('idle'), 2000); }, 800); diff --git a/src/components/EmojiReactions.tsx b/src/components/EmojiReactions.tsx index a45bbd80..a4ae5178 100644 --- a/src/components/EmojiReactions.tsx +++ b/src/components/EmojiReactions.tsx @@ -27,11 +27,6 @@ export function EmojiReactions({ }: EmojiReactionsProps) { const [showPicker, setShowPicker] = useState(false); - // Emojis that already have reactions - const activeEmojis = new Set(reactions.map((r) => r.emoji)); - // Emojis available to add - const availableEmojis = REACTION_EMOJIS.filter((e) => !activeEmojis.has(e)); - const gap = compact ? 'gap-1' : 'gap-1.5'; return ( diff --git a/src/components/EventApp.tsx b/src/components/EventApp.tsx index 293bfcef..7d10487d 100644 --- a/src/components/EventApp.tsx +++ b/src/components/EventApp.tsx @@ -107,10 +107,7 @@ export function EventApp({ initialConference, initialEvents }: { initialConferen const { itinerary, toggle: toggleItinerary, - count: itineraryCount, ready: itineraryReady, - hiddenEvents, - toggleHidden, } = useItinerary(); const [filtersExpanded, setFiltersExpanded] = useState(false); @@ -140,7 +137,6 @@ export function EventApp({ initialConference, initialEvents }: { initialConferen availableTypes, availableVibes, conferenceEventCount, - conferenceItineraryCount, friendsForFilter, selectedFriendEventIds, friendsCountByEvent, @@ -216,7 +212,6 @@ export function EventApp({ initialConference, initialEvents }: { initialConferen // Events filtered by everything EXCEPT vibes — used to compute tag counts const baseFilteredEvents = useMemo( () => applyFilters(events, filters, itinerary, filters.timeMode !== 'off' ? getConferenceNow(filters.conference).getTime() : undefined, selectedFriendEventIds, { skipVibes: true, orgEventIds, eventIdToOrgs }), - // eslint-disable-next-line react-hooks/exhaustive-deps [events, filters, itinerary, selectedFriendEventIds, orgEventIds, eventIdToOrgs] ); @@ -232,7 +227,6 @@ export function EventApp({ initialConference, initialEvents }: { initialConferen checkInToNearbyEvents, loading: checkInLoading, result: checkInResult, - clearResult: clearCheckInResult, } = useEventCheckIn(); const { getRsvpStatus, openRsvp, confirmRsvp, closeRsvp, activeRsvp } = useRsvp(); @@ -391,13 +385,11 @@ export function EventApp({ initialConference, initialEvents }: { initialConferen } = useABTest({ test: adFrequencyTest }); const { - config: sponsorConfig, trackClick: trackSponsorClick, isActive: sponsorTestActive, } = useABTest({ test: sponsorCopyTest }); const { - config: nativeAdConfig, trackClick: trackNativeAdClick, isActive: nativeAdTestActive, } = useABTest({ test: nativeAdContentTest }); @@ -409,7 +401,7 @@ export function EventApp({ initialConference, initialEvents }: { initialConferen // Ad impression/click tracking for A/B tests - const handleAdImpression = useCallback((_adId: string) => { + const handleAdImpression = useCallback(() => { // Impressions are tracked via the useABTest hook automatically }, []); diff --git a/src/components/EventCard.tsx b/src/components/EventCard.tsx index 9d4deaa3..8f23880c 100644 --- a/src/components/EventCard.tsx +++ b/src/components/EventCard.tsx @@ -16,7 +16,6 @@ import { TagBadge } from './TagBadge'; import { OGImage } from './OGImage'; import { EmojiReactions } from './EmojiReactions'; import UserAvatar from './UserAvatar'; -import { CommentSection } from './CommentSection'; import { FriendAvatarStack } from './FriendAvatarStack'; import { RsvpButton } from './RsvpButton'; @@ -67,7 +66,6 @@ function FriendsGoingModal({ const avatarBg = accentColor === 'green' ? 'bg-green-500/20' : ''; const avatarBgStyle = accentColor === 'green' ? undefined : { backgroundColor: 'color-mix(in srgb, var(--friend-blue) 20%, transparent)' }; - const avatarText = accentColor === 'green' ? 'text-green-400' : ''; return createPortal(
= { - x: 'X / Twitter', - telegram: 'Telegram', - linkedin: 'LinkedIn', - friend: 'plan.wtf', -}; - const PLATFORM_LOGOS: Record = { x: `data:image/svg+xml,${encodeURIComponent('')}`, telegram: `data:image/svg+xml,${encodeURIComponent('')}`, diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index f3435311..9f2a2d9b 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -60,7 +60,6 @@ function coordKey(lat: number, lng: number): string { export function MapView({ events, - onEventSelect, itinerary, onItineraryToggle, isItineraryView = false, @@ -93,7 +92,6 @@ export function MapView({ ? 'mapbox://styles/mapbox/streets-v12' : 'mapbox://styles/mapbox/dark-v11'; const mapRef = useRef(null); - const hasFittedRef = useRef(false); // Compute center from events with coordinates, excluding outliers // Returns null when no located events (e.g. "Now" filters everything out) diff --git a/src/components/SubmitEventModal.tsx b/src/components/SubmitEventModal.tsx index 7b8dab65..95c90325 100644 --- a/src/components/SubmitEventModal.tsx +++ b/src/components/SubmitEventModal.tsx @@ -35,7 +35,6 @@ const FORMAT_TAGS = TYPE_TAGS.filter((t) => !EXCLUDED_TAGS.includes(t)); const TOPIC_TAGS = Object.keys(VIBE_COLORS).filter( (t) => !TYPE_TAGS.includes(t) && t !== 'default' && !EXCLUDED_TAGS.includes(t) ); -const ALL_TAGS = [...FORMAT_TAGS, ...TOPIC_TAGS]; export function SubmitEventModal({ isOpen, onClose, upsellCopy, initialConference, conferenceTabs = [] }: SubmitEventModalProps) { const [step, setStep] = useState('input'); diff --git a/src/components/TableView.tsx b/src/components/TableView.tsx index eb09c64a..c40baaf5 100644 --- a/src/components/TableView.tsx +++ b/src/components/TableView.tsx @@ -141,7 +141,6 @@ const COLUMN_COUNT = 7; // star, friends, time, organizer, event, location, tags export const TableView = memo(function TableView({ events, - totalCount, itinerary, onItineraryToggle, onScrolledChange, @@ -957,13 +956,11 @@ function DateGroup({ itinerary, onItineraryToggle, setSeparatorRef, - friendsCountByEvent, friendsByEvent, checkInCounts, onSelectEvent, conference, featuredEvents, - selectedEventId, isSignedIn, onSignIn, liveEventIds, From 789f36cdeb7d7d2b34fa3efcc047c8f912d8606f Mon Sep 17 00:00:00 2001 From: snax y-not Date: Thu, 1 Oct 2026 07:41:59 -0400 Subject: [PATCH 9/9] ci: make lint blocking Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 720b62d7..18a62b63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,9 +28,7 @@ jobs: - name: Test run: npm test - # TODO: remove continue-on-error once the ~50 pre-existing lint errors are fixed. - name: Lint run: npm run lint - continue-on-error: true # `next build` is intentionally not run here: it needs Supabase/Mapbox secrets.