diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 56c09a5ccf..3a1e9a720f 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -889,15 +889,6 @@ "src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 - }, - "react-hooks/immutability": { - "count": 4 - }, - "react-hooks/preserve-manual-memoization": { - "count": 2 - }, - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx": { @@ -983,20 +974,9 @@ "count": 2 } }, - "src/app/(dashboard)/dashboard/combos/ComboControlCenterClient.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/(dashboard)/dashboard/combos/page.tsx": { "@typescript-eslint/no-unused-vars": { "count": 6 - }, - "react-hooks/immutability": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 8 } }, "src/app/(dashboard)/dashboard/conductor/ConductorPageClient.tsx": { @@ -1019,11 +999,6 @@ "count": 1 } }, - "src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/(dashboard)/dashboard/costs/costExplorerUtils.ts": { "no-restricted-syntax": { "count": 1 @@ -1037,24 +1012,6 @@ "src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx": { "@typescript-eslint/no-unused-vars": { "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 3 - } - }, - "src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolUsage.ts": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePools.ts": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/costs/useApiKeyUsageLimits.ts": { - "react-hooks/set-state-in-effect": { - "count": 1 } }, "src/app/(dashboard)/dashboard/discovery/DiscoveryPageClient.tsx": { @@ -1062,31 +1019,6 @@ "count": 1 } }, - "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": { - "react-hooks/immutability": { - "count": 3 - } - }, - "src/app/(dashboard)/dashboard/endpoint/components/A2ADashboard.tsx": { - "react-hooks/set-state-in-effect": { - "count": 2 - } - }, - "src/app/(dashboard)/dashboard/endpoint/components/MCPDashboard.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/endpoint/components/NotionSourceCard.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/(dashboard)/dashboard/endpoint/components/ObsidianSourceCard.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/app/(dashboard)/dashboard/free-provider-rankings/page.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -1178,14 +1110,6 @@ "count": 1 } }, - "src/app/(dashboard)/dashboard/provider-stats/page.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - }, - "react-hooks/static-components": { - "count": 7 - } - }, "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": { "@typescript-eslint/no-unused-vars": { "count": 2 diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx index 2e32238662..ab837efa2a 100644 --- a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -263,13 +263,6 @@ export default function ApiManagerPageClient() { input?.focus({ preventScroll: true }); }, [newKeyNameInputId]); - useEffect(() => { - fetchData(); - fetchModels(); - fetchCombos(); - fetchConnections(); - }, []); // eslint-disable-line react-hooks/exhaustive-deps -- initial dashboard load only - useEffect(() => { if (!showAddModal || !nameError) return; requestAnimationFrame(() => { @@ -278,7 +271,12 @@ export default function ApiManagerPageClient() { }, [nameError, showAddModal]); useEffect(() => { - setActiveOnly(readActiveOnlyPreference()); + // Hydrate the persisted preference after mount, behind an async boundary + // (react-hooks/set-state-in-effect) — same post-hydration timing as before. + void (async () => { + await Promise.resolve(); + setActiveOnly(readActiveOnlyPreference()); + })(); }, []); useEffect(() => { @@ -424,25 +422,6 @@ export default function ApiManagerPageClient() { } }; - const fetchData = async () => { - try { - const res = await fetch("/api/keys"); - if (res.ok) { - const data = await res.json(); - setKeys(data.keys || []); - setAllowKeyReveal(data.allowKeyReveal === true); - // Fetch usage stats after keys are loaded - fetchUsageStats(data.keys || []); - fetchSessionCounts(data.keys || []); - fetchDeviceCounts(data.keys || []); - } - } catch (error) { - console.log("Error fetching keys:", error); - } finally { - setLoading(false); - } - }; - const fetchUsageStats = async (apiKeys: ApiKey[]) => { if (apiKeys.length === 0) return; try { @@ -545,6 +524,37 @@ export default function ApiManagerPageClient() { } }; + // fetchData calls the three per-key fetchers above — declared after them so the + // calls are not TDZ reads (react-hooks/immutability). + const fetchData = async () => { + try { + const res = await fetch("/api/keys"); + if (res.ok) { + const data = await res.json(); + setKeys(data.keys || []); + setAllowKeyReveal(data.allowKeyReveal === true); + // Fetch usage stats after keys are loaded + fetchUsageStats(data.keys || []); + fetchSessionCounts(data.keys || []); + fetchDeviceCounts(data.keys || []); + } + } catch (error) { + console.log("Error fetching keys:", error); + } finally { + setLoading(false); + } + }; + + // Initial dashboard load — placed after the fetcher declarations so the effect does + // not read them in their TDZ (react-hooks/immutability), behind an async boundary + // (react-hooks/set-state-in-effect). + useEffect(() => { + void (async () => { + await Promise.all([fetchData(), fetchModels(), fetchCombos(), fetchConnections()]); + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- initial dashboard load only + }, []); + const clearPageError = useCallback(() => setPageError(null), []); const keyCounts = useMemo(() => computeApiKeyCounts(keys), [keys]); @@ -1738,9 +1748,12 @@ const PermissionsModal = memo(function PermissionsModal({ // Initialize state from props - component remounts when key prop changes const initialModels = Array.isArray(apiKey?.allowedModels) ? apiKey.allowedModels : []; + // Destructured to a local so the memo dep matches what the compiler infers + // (react-hooks/preserve-manual-memoization). + const blockedModelsProp = apiKey?.blockedModels; const initialBlockedModels = useMemo( - () => (Array.isArray(apiKey?.blockedModels) ? apiKey.blockedModels : []), - [apiKey?.blockedModels] + () => (Array.isArray(blockedModelsProp) ? blockedModelsProp : []), + [blockedModelsProp] ); const initialCombos = Array.isArray(apiKey?.allowedCombos) ? apiKey.allowedCombos.filter((combo) => combo !== ALL_COMBOS_ACCESS_RULE) @@ -2080,8 +2093,12 @@ const PermissionsModal = memo(function PermissionsModal({ // Provider wildcards ("ollama-cloud/*") are counted as providers, not models. // Inherited children render selected via the owner lookup inside the list component. - const { providerWildcards: selectedProviderScopes, exactModels: selectedExactModels } = - restoreProviderScopeSelection(selectedModels); + // Memoized so downstream memos see a stable, non-mutated dependency + // (react-hooks/preserve-manual-memoization). + const { providerWildcards: selectedProviderScopes, exactModels: selectedExactModels } = useMemo( + () => restoreProviderScopeSelection(selectedModels), + [selectedModels] + ); const selectedProviderCount = selectedProviderScopes.length; const selectedModelCount = selectedExactModels.length; const selectedCount = selectedModels.length; diff --git a/src/app/(dashboard)/dashboard/combos/ComboControlCenterClient.tsx b/src/app/(dashboard)/dashboard/combos/ComboControlCenterClient.tsx index 6e4cea303b..9513e05ef3 100644 --- a/src/app/(dashboard)/dashboard/combos/ComboControlCenterClient.tsx +++ b/src/app/(dashboard)/dashboard/combos/ComboControlCenterClient.tsx @@ -279,7 +279,10 @@ export default function ComboControlCenterClient({ comboId }: { comboId: string }, [comboId, range, t]); useEffect(() => { - void load(); + // Async continuation — see react-hooks/set-state-in-effect. + void (async () => { + await load(); + })(); }, [load]); const summary = useMemo( diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index ecc001db33..ef7ec4995c 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -386,6 +386,17 @@ const STRATEGY_RECOMMENDATIONS_FALLBACK = { }; const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide"; + +// Pure predicate hoisted out of the page component to keep its cyclomatic budget flat +// (check:complexity new-code mode). +function isStaleIntelligentSelection( + intelligentCombos: Array<{ id: string }>, + selectedId: string | null +): boolean { + if (selectedId === null) return false; + if (intelligentCombos.length === 0) return true; + return !intelligentCombos.some((combo) => combo.id === selectedId); +} const COMBO_FORM_STAGE_META = [ { id: "basics", @@ -749,7 +760,15 @@ export default function CombosPage() { const [proxyConfig, setProxyConfig] = useState(null); const { comboProxyAssignedIds, fetchComboProxyAssignments } = useComboProxyAssignments(); const [providerNodes, setProviderNodes] = useState([]); - const [showUsageGuide, setShowUsageGuide] = useState(true); + const [showUsageGuide, setShowUsageGuide] = useState(() => { + // Lazy initializer instead of a mount effect (react-hooks/set-state-in-effect). + try { + return globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) !== "1"; + } catch { + // Ignore storage access errors (privacy mode / restricted environments) + return true; + } + }); const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState(""); const [creatingKimiPreset, setCreatingKimiPreset] = useState(false); const [comboDragIndex, setComboDragIndex] = useState(null); @@ -781,45 +800,11 @@ export default function CombosPage() { return activeFilter === "intelligent" ? intelligentCombos[0] : null; }, [activeFilter, intelligentCombos, selectedIntelligentComboId]); - useEffect(() => { - if (intelligentCombos.length === 0) { - setSelectedIntelligentComboId(null); - return; - } - - if ( - selectedIntelligentComboId && - !intelligentCombos.some((combo) => combo.id === selectedIntelligentComboId) - ) { - setSelectedIntelligentComboId(null); - } - }, [intelligentCombos, selectedIntelligentComboId]); - - useEffect(() => { - fetchData(); - fetch("/api/settings") - .then((r) => (r.ok ? r.json() : null)) - .then((settings) => setComboConfigMode(normalizeComboConfigMode(settings?.comboConfigMode))) - .catch(() => setComboConfigMode("guided")); - fetch("/api/settings/compression") - .then((r) => (r.ok ? r.json() : null)) - .then((settings) => setPromptCompressionEnabled(settings?.enabled === true)) - .catch(() => setPromptCompressionEnabled(false)); - fetch("/api/settings/proxy") - .then((r) => (r.ok ? r.json() : null)) - .then((c) => setProxyConfig(c)) - .catch(() => {}); - }, []); - - useEffect(() => { - try { - if (globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) === "1") { - setShowUsageGuide(false); - } - } catch { - // Ignore storage access errors (privacy mode / restricted environments) - } - }, []); + // Drop a stale selection when the list no longer contains it — state adjustment + // during render (react-hooks/set-state-in-effect). + if (isStaleIntelligentSelection(intelligentCombos, selectedIntelligentComboId)) { + setSelectedIntelligentComboId(null); + } const fetchData = async () => { try { @@ -848,6 +833,27 @@ export default function CombosPage() { } }; + // Mount load — placed after fetchData so the effect does not read the binding in its + // TDZ (react-hooks/immutability); the call sits behind an async boundary + // (react-hooks/set-state-in-effect). + useEffect(() => { + void (async () => { + await fetchData(); + })(); + fetch("/api/settings") + .then((r) => (r.ok ? r.json() : null)) + .then((settings) => setComboConfigMode(normalizeComboConfigMode(settings?.comboConfigMode))) + .catch(() => setComboConfigMode("guided")); + fetch("/api/settings/compression") + .then((r) => (r.ok ? r.json() : null)) + .then((settings) => setPromptCompressionEnabled(settings?.enabled === true)) + .catch(() => setPromptCompressionEnabled(false)); + fetch("/api/settings/proxy") + .then((r) => (r.ok ? r.json() : null)) + .then((c) => setProxyConfig(c)) + .catch(() => {}); + }, []); + const handleCreate = async (data) => { try { const res = await fetch("/api/combos", { @@ -2041,13 +2047,15 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo (config.modelSort as { method?: unknown } | undefined)?.method ); const [sortMethod, setSortMethod] = useState(initialSortMethod); - useEffect(() => { - // Sync point: when the combo identity changes, re-derive sort method. - // Manual edits via handleSortChange already set sortMethod inside resetFormForCombo, - // but this guards the case where the modal is reused (edit-A→close→edit-B without unmount). + // Sync point: when the combo identity changes, re-derive sort method — state + // adjustment during render (react-hooks/set-state-in-effect). Manual edits via + // handleSortChange already set sortMethod inside resetFormForCombo; this guards the + // modal-reuse case (edit-A→close→edit-B without unmount). + const [prevSortComboId, setPrevSortComboId] = useState(combo?.id); + if (combo?.id !== prevSortComboId) { + setPrevSortComboId(combo?.id); setSortMethod(normalizeSortMethod(combo?.config?.modelSort?.method)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [combo?.id]); + } const modelsRef = useRef(models); const sortMethodRef = useRef(sortMethod); const resetSortGenerationRef = useRef(0); @@ -2158,11 +2166,11 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo contextLength, ]); - useEffect(() => { - if (!comboBuilderStages.includes(builderStage)) { - setBuilderStage("strategy"); - } - }, [builderStage, comboBuilderStages]); + // Keep the stage on a real option — self-extinguishing state adjustment during + // render (react-hooks/set-state-in-effect). + if (!comboBuilderStages.includes(builderStage)) { + setBuilderStage("strategy"); + } const hasPricingForModel = useCallback( (modelValue) => { @@ -2395,37 +2403,40 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo }; useEffect(() => { - if (isOpen) fetchModalData(); + // Async continuation — see react-hooks/set-state-in-effect. + if (isOpen) { + void (async () => { + await fetchModalData(); + })(); + } }, [isOpen]); - useEffect(() => { - if (!isOpen) return; - setBuilderProviderId(""); - setBuilderModelId(""); - setBuilderConnectionId(COMBO_BUILDER_AUTO_CONNECTION); - setBuilderAllowedConnectionIds([]); - setManualModelInput(""); - setManualModelError(""); - setBuilderComboRefName(""); - setBuilderError(""); - setBuilderStage("basics"); - }, [combo?.id, isOpen]); + // Reset the builder inputs whenever the modal (re)opens or switches combos — + // state adjustment during render (react-hooks/set-state-in-effect). + const [prevBuilderResetKey, setPrevBuilderResetKey] = useState<{ + comboId: string | undefined; + isOpen: boolean; + }>({ comboId: combo?.id, isOpen }); + if (prevBuilderResetKey.comboId !== combo?.id || prevBuilderResetKey.isOpen !== isOpen) { + setPrevBuilderResetKey({ comboId: combo?.id, isOpen }); + if (isOpen) { + setBuilderProviderId(""); + setBuilderModelId(""); + setBuilderConnectionId(COMBO_BUILDER_AUTO_CONNECTION); + setBuilderAllowedConnectionIds([]); + setManualModelInput(""); + setManualModelError(""); + setBuilderComboRefName(""); + setBuilderError(""); + setBuilderStage("basics"); + } + } useEffect(() => { if (!isOpen) return; let cancelled = false; - if (combo) { - resetFormForCombo(combo); - return () => { - cancelled = true; - }; - } - - createDraftStateRef.current = getEmptyCreateDraftSnapshot(); - resetFormForCombo(null, null); - const loadDefaults = async () => { try { const response = await fetch("/api/settings/combo-defaults"); @@ -2451,20 +2462,30 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo } }; - loadDefaults(); + // Async continuation — the compiler rejects sync calls to setter-capturing + // callbacks from the effect body (react-hooks/set-state-in-effect). + void (async () => { + await Promise.resolve(); + if (cancelled) return; + if (combo) { + resetFormForCombo(combo); + return; + } + createDraftStateRef.current = getEmptyCreateDraftSnapshot(); + resetFormForCombo(null, null); + await loadDefaults(); + })(); return () => { cancelled = true; }; }, [combo, getEmptyCreateDraftSnapshot, isExpertMode, isOpen, resetFormForCombo]); - useEffect(() => { - if (!isOpen) return; - if (builderProviderId) return; - if (builderProviders.length === 1) { - setBuilderProviderId(builderProviders[0].providerId); - } - }, [builderProviderId, builderProviders, isOpen]); + // Default to the only available provider — self-extinguishing state adjustment + // during render (react-hooks/set-state-in-effect). + if (isOpen && !builderProviderId && builderProviders.length === 1) { + setBuilderProviderId(builderProviders[0].providerId); + } useEffect(() => { if (!strategyChangeMountedRef.current) { diff --git a/src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx b/src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx index 8ee25fa2b5..bf7e1feb10 100644 --- a/src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx +++ b/src/app/(dashboard)/dashboard/costs/components/ApiKeyUsageLimitCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import { useTranslations } from "next-intl"; import { Card } from "@/shared/components"; @@ -88,8 +88,11 @@ export function ApiKeyUsageLimitCard({ const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - useEffect(() => { - if (!payload) return; + // Reset the form when a new payload arrives — state adjustment during render + // (react-hooks/set-state-in-effect; see react.dev "adjusting state when a prop changes"). + const [prevPayload, setPrevPayload] = useState(null); + if (payload && payload !== prevPayload) { + setPrevPayload(payload); setEnabled(payload.key.usageLimitEnabled); setDailyLimit( typeof payload.key.dailyUsageLimitUsd === "number" @@ -102,7 +105,7 @@ export function ApiKeyUsageLimitCard({ : "" ); setError(null); - }, [payload]); + } const formatter = useMemo(() => createCurrencyFormatter(locale), [locale]); const status = payload?.status; diff --git a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx index 66dfa6d2f8..750b6f0e6d 100644 --- a/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx +++ b/src/app/(dashboard)/dashboard/costs/quota-share/components/PoolWizard.tsx @@ -17,7 +17,7 @@ * Phase C1 — Quota Share Redesign. */ -import { useEffect, useMemo, useState } from "react"; +import { useMemo, useState, type Dispatch, type SetStateAction } from "react"; import { useTranslations } from "next-intl"; import { Button, Modal } from "@/shared/components"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; @@ -172,6 +172,132 @@ function Stepper({ currentStep }: { currentStep: 1 | 2 | 3 }) { // Main component // ──────────────────────────────────────────────────────────────────────────── +// Pure helpers hoisted out of the component to keep its cognitive budget flat +// (check:cognitive-complexity new-code mode). +function pickWizardDimensions( + primaryConnectionId: string | undefined, + plans: Record, + catalogDimensions: QuotaDimension[] | null +): QuotaDimension[] { + if (!primaryConnectionId) return []; + const existingPlan = plans[primaryConnectionId]; + if (existingPlan && existingPlan.dimensions.length > 0) return [...existingPlan.dimensions]; + return catalogDimensions ? [...catalogDimensions] : []; +} + +// Pure predicate hoisted out of the component to keep its cognitive budget flat +// (check:cognitive-complexity new-code mode): snap the group on a real, selectable option: if the inherited page - // filter was "all" (or an unknown id), snap to the first real group once groups - // load. Prevents persisting groupId="all" (which renders under no group → B1). - useEffect(() => { - if (!open || editPool) return; - if (groups.length === 0) return; - if (groupId === "all" || !groups.some((g) => g.id === groupId)) { - setGroupId(groups[0].id); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, editPool, groups]); + useWizardDimensionsAdjustment({ + primaryConnectionId, + selectedProvider: selectedConn?.provider, + plans, + setEditDimensions, + setDimensionsEdited, + }); + useWizardOpenCloseAdjustment({ + open, + editPool, + editPoolExclusive, + initialGroupId, + plans, + setStep, + setConnectionIds, + setPoolName, + setDefaultPolicy, + setEditDimensions, + setDimensionsEdited, + setAllocations, + setExclusive, + setError, + setSaving, + setGroupId, + }); + // Keep the group