diff --git a/eslint.config.mjs b/eslint.config.mjs index 70534d4..aee08c9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,17 +13,6 @@ const eslintConfig = [ version: '19.2', }, }, - rules: { - // These two rules were newly enabled by eslint-plugin-react-hooks@7, - // which ships with eslint-config-next@16 (Next.js 16 upgrade). They flag - // ~20 pre-existing, idiomatic sites (e.g. `setMounted(true)` hydration - // guards, FocusTimer's interdependent timer state, self-referencing retry - // callbacks) — none are bugs introduced by the upgrade. Disabled here to - // keep the dependency bump free of behavior-sensitive refactors; tracked - // for a dedicated follow-up to enforce incrementally. See PR/upgrade notes. - 'react-hooks/set-state-in-effect': 'off', - 'react-hooks/immutability': 'off', - }, }, ]; diff --git a/src/components/archive-mission-dialog.tsx b/src/components/archive-mission-dialog.tsx index c1566b2..e861cfa 100644 --- a/src/components/archive-mission-dialog.tsx +++ b/src/components/archive-mission-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useCommandOpsStore } from '@/store/command-ops-store'; import { Dialog, @@ -34,13 +34,16 @@ export function ArchiveMissionDialog({ const [isSubmitting, setIsSubmitting] = useState(false); const [reportError, setReportError] = useState(''); - // Reset form when dialog opens/closes - useEffect(() => { + // Reset form when the dialog closes (adjust state during render on the + // open->closed transition instead of in an effect). + const [prevOpen, setPrevOpen] = useState(open); + if (open !== prevOpen) { + setPrevOpen(open); if (!open) { setAfterActionReport(''); setReportError(''); } - }, [open]); + } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/src/components/archive/date-range-selector.tsx b/src/components/archive/date-range-selector.tsx index 87854b4..7d9002a 100644 --- a/src/components/archive/date-range-selector.tsx +++ b/src/components/archive/date-range-selector.tsx @@ -1,12 +1,6 @@ 'use client'; -import React, { - useState, - useEffect, - useRef, - useCallback, - useMemo, -} from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { Button } from '@/components/ui/button'; import { Popover, @@ -107,14 +101,14 @@ export const DateRangeSelector: React.FC = ({ const [tempRange, setTempRange] = useState(range); - useEffect(() => { + // Sync tempRange when the incoming range changes (render-time). + const [prevRange, setPrevRange] = useState(range); + if (range !== prevRange) { + setPrevRange(range); setTempRange(range); - }, [range]); + } const openedRangeRef = useRef(undefined); - const [selectedPreset, setSelectedPreset] = useState( - undefined - ); const [isSmallScreen, setIsSmallScreen] = useState(false); @@ -197,10 +191,10 @@ export const DateRangeSelector: React.FC = ({ setTempRange(newRange); }; - const checkPreset = useCallback((): void => { + // Derive the matching preset name from the current range (render-time). + const selectedPreset = useMemo(() => { if (!range.from) { - setSelectedPreset(undefined); - return; + return undefined; } for (const preset of PRESETS) { @@ -222,18 +216,13 @@ export const DateRangeSelector: React.FC = ({ normalizedRangeFrom.getTime() === normalizedPresetFrom.getTime() && normalizedRangeTo?.getTime() === normalizedPresetTo?.getTime() ) { - setSelectedPreset(preset.name); - return; + return preset.name; } } - setSelectedPreset(undefined); + return undefined; }, [range]); - useEffect(() => { - checkPreset(); - }, [checkPreset]); - const PresetButton = ({ preset, label, diff --git a/src/components/archive/filters/quest-filters.tsx b/src/components/archive/filters/quest-filters.tsx index f367a68..56da857 100644 --- a/src/components/archive/filters/quest-filters.tsx +++ b/src/components/archive/filters/quest-filters.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { Search, X } from 'lucide-react'; import { useArchiveStore } from '@/store/archive-store'; import { Input } from '@/components/ui/input'; @@ -36,8 +36,12 @@ export const QuestFilters: React.FC = () => { // Track applied filters to detect changes const [appliedFilters, setAppliedFilters] = useState(questFilters); - // Sync applied filters when filters are reset externally - useEffect(() => { + // Sync applied filters when filters are reset externally. Runs during render + // on a questFilters change instead of in an effect. + const [prevQuestFilters, setPrevQuestFilters] = useState(questFilters); + if (questFilters !== prevQuestFilters) { + setPrevQuestFilters(questFilters); + // If all filters match defaults, sync the applied state const isDefaultState = JSON.stringify(questFilters) === @@ -55,7 +59,7 @@ export const QuestFilters: React.FC = () => { ) { setAppliedFilters({ ...questFilters }); } - }, [questFilters, appliedFilters]); + } const handleSearchChange = (e: React.ChangeEvent) => { setQuestFilters({ searchQuery: e.target.value }); diff --git a/src/components/edit-mission-dialog.tsx b/src/components/edit-mission-dialog.tsx index 730a173..e5b24fe 100644 --- a/src/components/edit-mission-dialog.tsx +++ b/src/components/edit-mission-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useCommandOpsStore } from '@/store/command-ops-store'; import { Dialog, @@ -39,13 +39,16 @@ export function EditMissionDialog({ const [titleError, setTitleError] = useState(''); const [descriptionError, setDescriptionError] = useState(''); - // Populate form when mission changes - useEffect(() => { + // Populate form when the mission changes (adjust state during render on the + // mission-identity change instead of in an effect). + const [prevMissionId, setPrevMissionId] = useState(mission?.id ?? null); + if ((mission?.id ?? null) !== prevMissionId) { + setPrevMissionId(mission?.id ?? null); if (mission) { setTitle(mission.title); setDescription(mission.objective || ''); } - }, [mission]); + } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/src/components/focus-mode/FocusTimer.tsx b/src/components/focus-mode/FocusTimer.tsx index 19ce80b..10c9089 100644 --- a/src/components/focus-mode/FocusTimer.tsx +++ b/src/components/focus-mode/FocusTimer.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Play, Pause, RotateCcw } from 'lucide-react'; import { posthogCapture } from '@/lib/posthog-utils'; @@ -12,38 +12,49 @@ interface FocusTimerProps { export function FocusTimer({ timerType, duration }: FocusTimerProps) { const [isRunning, setIsRunning] = useState(true); const [timeElapsed, setTimeElapsed] = useState(0); // in seconds - const [timeRemaining, setTimeRemaining] = useState(0); // in seconds + const [timeRemaining, setTimeRemaining] = useState(() => + timerType === 'pomodoro' + ? (duration || 25) * 60 + : timerType === 'countdown' && duration + ? duration * 60 + : 0 + ); // in seconds const [pausedAt, setPausedAt] = useState(null); const [totalPausedTime, setTotalPausedTime] = useState(0); // in seconds - const [timerStartTime, setTimerStartTime] = useState(null); - const [sessionCompleted, setSessionCompleted] = useState(false); + const [timerStartTime, setTimerStartTime] = useState( + () => new Date() + ); + // Guard flag (never rendered) so the completion effect fires analytics once. + const sessionCompletedRef = useRef(false); - // Reset timer when mode or duration changes - useEffect(() => { - const now = new Date(); - setTimerStartTime(now); + // Reset the timer when the mode or duration changes (render-time, on the + // config change; initial values are seeded in useState above for mount). + const [prevConfig, setPrevConfig] = useState({ timerType, duration }); + if (prevConfig.timerType !== timerType || prevConfig.duration !== duration) { + setPrevConfig({ timerType, duration }); + setTimerStartTime(new Date()); setTimeElapsed(0); setTotalPausedTime(0); setPausedAt(null); setIsRunning(true); - setSessionCompleted(false); - // Set initial time remaining if (timerType === 'pomodoro') { - const totalSeconds = (duration || 25) * 60; - setTimeRemaining(totalSeconds); + setTimeRemaining((duration || 25) * 60); } else if (timerType === 'countdown' && duration) { - const totalSeconds = duration * 60; - setTimeRemaining(totalSeconds); + setTimeRemaining(duration * 60); } else { setTimeRemaining(0); } + } - // Track focus session start + // Track focus session start (on mount and whenever the config changes). + // Also resets the completion guard for the new session. + useEffect(() => { + sessionCompletedRef.current = false; posthogCapture('focus_session_started', { timer_type: timerType, duration_minutes: duration, - session_start_time: now.toISOString(), + session_start_time: new Date().toISOString(), }); }, [timerType, duration]); @@ -78,26 +89,29 @@ export function FocusTimer({ timerType, duration }: FocusTimerProps) { return () => clearInterval(interval); }, [timerStartTime, timerType, duration, isRunning, totalPausedTime]); - // Handle pause/resume logic - useEffect(() => { - if (!isRunning && !pausedAt) { + // Handle pause/resume logic on the isRunning transition (render-time). + const [prevIsRunning, setPrevIsRunning] = useState(isRunning); + if (isRunning !== prevIsRunning) { + setPrevIsRunning(isRunning); + if (!isRunning) { setPausedAt(new Date()); - } else if (isRunning && pausedAt) { + } else if (pausedAt) { const pauseDuration = Math.floor( (new Date().getTime() - pausedAt.getTime()) / 1000 ); setTotalPausedTime(prev => prev + pauseDuration); setPausedAt(null); } - }, [isRunning, pausedAt]); + } - // Track timer completion + // Track timer completion (analytics side effect; sessionCompletedRef guards + // against firing more than once). useEffect(() => { if ( (timerType === 'pomodoro' || timerType === 'countdown') && timeRemaining === 0 && timeElapsed > 0 && - !sessionCompleted + !sessionCompletedRef.current ) { posthogCapture('focus_session_completed', { timer_type: timerType, @@ -106,9 +120,9 @@ export function FocusTimer({ timerType, duration }: FocusTimerProps) { total_paused_seconds: totalPausedTime, completion_time: new Date().toISOString(), }); - setSessionCompleted(true); + sessionCompletedRef.current = true; } - }, [timeRemaining, timerType, duration, timeElapsed, totalPausedTime, sessionCompleted]); + }, [timeRemaining, timerType, duration, timeElapsed, totalPausedTime]); const formatTime = (seconds: number): string => { const hours = Math.floor(seconds / 3600); @@ -155,7 +169,7 @@ export function FocusTimer({ timerType, duration }: FocusTimerProps) { setTimeElapsed(0); setTotalPausedTime(0); setPausedAt(null); - setSessionCompleted(false); + sessionCompletedRef.current = false; // Reset time remaining based on current timer type if (timerType === 'pomodoro') { diff --git a/src/components/focus-mode/InterruptCapture.tsx b/src/components/focus-mode/InterruptCapture.tsx index e19e348..08b6908 100644 --- a/src/components/focus-mode/InterruptCapture.tsx +++ b/src/components/focus-mode/InterruptCapture.tsx @@ -17,30 +17,41 @@ export function InterruptCapture({ isOpen, onClose }: InterruptCaptureProps) { const inputRef = useRef(null); const { addQuest } = useCommandOpsStore(); - useEffect(() => { + // Reset capture state when the modal opens (render-time, on the open + // transition) so the effect below only handles focus + the countdown timer. + const [prevIsOpen, setPrevIsOpen] = useState(isOpen); + if (isOpen !== prevIsOpen) { + setPrevIsOpen(isOpen); if (isOpen) { setTitle(''); setCountdown(15); setIsSubmitting(false); + } + } - // Focus input after a brief delay - setTimeout(() => { - inputRef.current?.focus(); - }, 100); - - // Start countdown - const timer = setInterval(() => { - setCountdown(prev => { - if (prev <= 1) { - onClose(); - return 0; - } - return prev - 1; - }); - }, 1000); + useEffect(() => { + if (!isOpen) return; + + // Focus input after a brief delay + const focusTimeout = setTimeout(() => { + inputRef.current?.focus(); + }, 100); + + // Start countdown + const timer = setInterval(() => { + setCountdown(prev => { + if (prev <= 1) { + onClose(); + return 0; + } + return prev - 1; + }); + }, 1000); - return () => clearInterval(timer); - } + return () => { + clearTimeout(focusTimeout); + clearInterval(timer); + }; }, [isOpen, onClose]); const handleSubmit = useCallback(async () => { diff --git a/src/components/live-time.tsx b/src/components/live-time.tsx index bccc688..5a60bdd 100644 --- a/src/components/live-time.tsx +++ b/src/components/live-time.tsx @@ -1,9 +1,22 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useState, useSyncExternalStore } from 'react'; import { format } from 'date-fns'; import { formatInTimeZone, toZonedTime } from 'date-fns-tz'; import { enUS } from 'date-fns/locale/en-US'; +import { useIsMounted } from '@/hooks/use-is-mounted'; + +const emptySubscribe = () => () => {}; + +// Resolved once on the client; 'UTC' during SSR/hydration to avoid mismatch. +function getClientTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + console.warn('Unable to detect timezone, falling back to UTC'); + return 'UTC'; + } +} interface LiveTimeProps { className?: string; @@ -23,21 +36,14 @@ export function LiveTime({ hideSeconds = false, }: LiveTimeProps) { const [currentTime, setCurrentTime] = useState(new Date()); - const [mounted, setMounted] = useState(false); - const [localTimezone, setLocalTimezone] = useState('UTC'); + const mounted = useIsMounted(); + const localTimezone = useSyncExternalStore( + emptySubscribe, + getClientTimezone, + () => 'UTC' + ); useEffect(() => { - setMounted(true); - - // Get user's local timezone - try { - const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - setLocalTimezone(timezone); - } catch { - console.warn('Unable to detect timezone, falling back to UTC'); - setLocalTimezone('UTC'); - } - const timer = setInterval(() => { setCurrentTime(new Date()); }, 1000); diff --git a/src/components/new-quest-dialog.tsx b/src/components/new-quest-dialog.tsx index e1e050e..8a24b0a 100644 --- a/src/components/new-quest-dialog.tsx +++ b/src/components/new-quest-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { format } from 'date-fns'; import { Calendar as CalendarIcon, X } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -68,8 +68,14 @@ export function NewQuestDialog({ setBriefingError(''); }; - // Initialize form with edit data when in edit mode - useEffect(() => { + // Initialize the form when the dialog opens or the target quest changes + // (adjust state during render instead of in an effect). + const [prevInit, setPrevInit] = useState({ + open, + editQuestId: editQuest?.id ?? null, + }); + if (prevInit.open !== open || prevInit.editQuestId !== (editQuest?.id ?? null)) { + setPrevInit({ open, editQuestId: editQuest?.id ?? null }); if (open) { if (isEditMode && editQuest) { setDesignation(editQuest.title); @@ -81,7 +87,7 @@ export function NewQuestDialog({ resetForm(); } } - }, [open, isEditMode, editQuest]); + } const roundToNext15Minutes = (date: Date) => { const minutes = date.getMinutes(); @@ -115,15 +121,14 @@ export function NewQuestDialog({ ); }; - // Set default deadline when modal opens (only for new quests) - useEffect(() => { - if (open && !deadline && !isEditMode) { - const now = new Date(); - now.setHours(now.getHours() + 1); - const rounded = roundToNext15Minutes(now); - setDeadline(rounded); - } - }, [open, deadline, isEditMode]); + // Default the deadline to +1h (rounded) for new quests whenever it is unset + // while the dialog is open. Runs during render; converges once deadline is set + // and re-applies if the user clears it (matching the previous effect). + if (open && !deadline && !isEditMode) { + const now = new Date(); + now.setHours(now.getHours() + 1); + setDeadline(roundToNext15Minutes(now)); + } const handleDateSelect = (date: Date | undefined) => { if (!date) { diff --git a/src/components/quest-board-client.tsx b/src/components/quest-board-client.tsx index 5fb6648..ebc936a 100644 --- a/src/components/quest-board-client.tsx +++ b/src/components/quest-board-client.tsx @@ -74,7 +74,12 @@ function QuestBoardContent({ initialQuests }: QuestBoardClientProps) { [searchParams] ); - // Sync URL parameters with state + // Sync URL parameters with state. This intentionally stays in an effect: it + // writes to the external Zustand store (setKanbanView) as well as local + // state, and doing that during render could update other store subscribers + // mid-render. react-hooks@7's set-state-in-effect is a false positive for + // this external-store synchronization. + /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { try { const { filter, missionId } = urlParams; @@ -100,6 +105,7 @@ function QuestBoardContent({ initialQuests }: QuestBoardClientProps) { }); } }, [urlParams, setKanbanView]); + /* eslint-enable react-hooks/set-state-in-effect */ // Filter missions based on search term const filteredMissions = useMemo(() => { diff --git a/src/components/radar-view-dialog.tsx b/src/components/radar-view-dialog.tsx index 58c2189..93e0995 100644 --- a/src/components/radar-view-dialog.tsx +++ b/src/components/radar-view-dialog.tsx @@ -74,15 +74,17 @@ function RadarViewContent({ return noise; }; - setNoisePattern(generateNoise()); - - // Animate noise (flicker effect) + // Animate noise (flicker effect). The first tick generates the pattern + // (client only, so it never runs during SSR/hydration); later ticks + // flicker the opacity. Avoids a synchronous setState in the effect body. const noiseInterval = setInterval(() => { setNoisePattern(prev => - prev.map(dot => ({ - ...dot, - opacity: Math.random() * 0.5 + 0.15, // More visible flickering - })) + prev.length === 0 + ? generateNoise() + : prev.map(dot => ({ + ...dot, + opacity: Math.random() * 0.5 + 0.15, // More visible flickering + })) ); }, 150); // Faster flicker for more dynamic feel diff --git a/src/components/theme-selector.tsx b/src/components/theme-selector.tsx index fbd65fd..18e2b5b 100644 --- a/src/components/theme-selector.tsx +++ b/src/components/theme-selector.tsx @@ -1,8 +1,8 @@ 'use client'; -import { useEffect, useState } from 'react'; import { useTheme } from 'next-themes'; import { posthogCapture } from '@/lib/posthog-utils'; +import { useIsMounted } from '@/hooks/use-is-mounted'; import { Select, SelectContent, @@ -34,13 +34,9 @@ const themes = [ ]; export function ThemeSelector() { - const [mounted, setMounted] = useState(false); + const mounted = useIsMounted(); const { theme, setTheme } = useTheme(); - useEffect(() => { - setMounted(true); - }, []); - // Get current theme data const currentTheme = themes.find(t => t.value === theme) || themes[0]; const CurrentIcon = currentTheme.icon; diff --git a/src/components/ui/searchable-mission-selector.tsx b/src/components/ui/searchable-mission-selector.tsx index 5d74275..f7c9897 100644 --- a/src/components/ui/searchable-mission-selector.tsx +++ b/src/components/ui/searchable-mission-selector.tsx @@ -55,6 +55,10 @@ export function SearchableMissionSelector({ const [isInitialized, setIsInitialized] = useState(false); const debounceTimerRef = useRef(null); + // Latest-callback refs so the retry handlers don't reference their own + // useCallback before it is declared (react-hooks/immutability). + const loadMissionsRef = useRef<() => void>(undefined); + const performSearchRef = useRef<(query: string) => void>(undefined); const loadMissions = useCallback(async () => { setIsLoading(true); @@ -71,7 +75,7 @@ export function SearchableMissionSelector({ console.error('Failed to load missions:', error); showEnhancedErrorToast(error, { context: 'Mission Loading', - onRetry: () => loadMissions(), + onRetry: () => loadMissionsRef.current?.(), }); } finally { setIsLoading(false); @@ -102,7 +106,7 @@ export function SearchableMissionSelector({ setSearchResults([]); showEnhancedErrorToast(error, { context: 'Mission Search', - onRetry: () => performSearch(query), + onRetry: () => performSearchRef.current?.(query), }); } finally { setIsLoading(false); @@ -110,6 +114,17 @@ export function SearchableMissionSelector({ }, 300); }, []); + // Keep the latest-callback refs current for the retry handlers above. + useEffect(() => { + loadMissionsRef.current = loadMissions; + performSearchRef.current = performSearch; + }, [loadMissions, performSearch]); + + // Data-fetching effects: load missions when the popover opens, and run the + // debounced search when the query changes. These call the async loaders + // (which setState); set-state-in-effect is a false positive for on-demand + // data fetching. + /* eslint-disable react-hooks/set-state-in-effect */ // Initialize missions when popover opens useEffect(() => { if (isOpen && !isInitialized) { @@ -125,6 +140,7 @@ export function SearchableMissionSelector({ setSearchResults([]); } }, [searchQuery, performSearch]); + /* eslint-enable react-hooks/set-state-in-effect */ // Get missions to display const displayMissions = useMemo(() => { diff --git a/src/components/welcome-dialog-wrapper.tsx b/src/components/welcome-dialog-wrapper.tsx index eaf1ad8..527d094 100644 --- a/src/components/welcome-dialog-wrapper.tsx +++ b/src/components/welcome-dialog-wrapper.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { WelcomeDialog } from './welcome-dialog'; import { NewQuestDialog } from './new-quest-dialog'; @@ -8,27 +8,29 @@ import { NewQuestDialog } from './new-quest-dialog'; export function WelcomeDialogWrapper() { const router = useRouter(); const searchParams = useSearchParams(); - const [showDialog, setShowDialog] = useState(false); + const showHelp = searchParams.get('showHelp'); + const [showDialog, setShowDialog] = useState(() => showHelp === 'true'); const [showNewQuest, setShowNewQuest] = useState(false); - const [urlCleaned, setUrlCleaned] = useState(false); - - useEffect(() => { - // Check if the URL has the showHelp parameter - const showHelp = searchParams.get('showHelp'); - + const urlCleanedRef = useRef(false); + + // Open the help dialog when the showHelp param appears (render-time, on a + // showHelp change; compares the primitive value, not the searchParams object, + // so it converges even if useSearchParams returns a new reference). + const [prevShowHelp, setPrevShowHelp] = useState(showHelp); + if (showHelp !== prevShowHelp) { + setPrevShowHelp(showHelp); if (showHelp === 'true') { setShowDialog(true); } - }, [searchParams]); + } - // Clean up URL when dialog is displayed + // Clean up the URL once the dialog is shown (navigation side effect). useEffect(() => { - if (showDialog && !urlCleaned) { - const newUrl = window.location.pathname; - router.replace(newUrl); - setUrlCleaned(true); + if (showDialog && !urlCleanedRef.current) { + router.replace(window.location.pathname); + urlCleanedRef.current = true; } - }, [showDialog, urlCleaned, router]); + }, [showDialog, router]); const handleClose = () => { setShowDialog(false); diff --git a/src/hooks/use-is-mounted.ts b/src/hooks/use-is-mounted.ts new file mode 100644 index 0000000..07eddf4 --- /dev/null +++ b/src/hooks/use-is-mounted.ts @@ -0,0 +1,19 @@ +'use client'; + +import { useSyncExternalStore } from 'react'; + +const emptySubscribe = () => () => {}; + +/** + * Returns `false` during SSR and the initial hydration render, then `true` + * once mounted on the client. SSR-safe replacement for the + * `const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), [])` + * pattern, which `react-hooks/set-state-in-effect` (react-hooks@7) flags. + */ +export function useIsMounted(): boolean { + return useSyncExternalStore( + emptySubscribe, + () => true, + () => false + ); +}