chore(lint): batch 5 of #12146 — combos, endpoint, provider-stats, api-manager and costs react-hooks violations resolved (#12174)

* chore(lint): batch 5 of #12146 — resolve the react-hooks compiler violations in combos, endpoint, provider-stats, api-manager and costs

40 violations across 14 files, all real refactors (no eslint-disable, no new
suppressions; the areas' react-hooks entries are deleted from the freeze):

- set-state-in-effect (30): fetch-on-mount effects moved behind an async
  continuation (usePools, usePoolUsage, useApiKeyUsageLimits, Notion/Obsidian
  source cards, A2A/MCP dashboards, ComboControlCenterClient, provider-stats,
  combos modal loaders, ApiManager initial load); prop/state sync converted to
  state adjustment during render with prev tracking (ApiKeyUsageLimitCard,
  PoolWizard dimensions/reset/group snap, combos sortMethod, builder reset,
  builder stage guard, single-provider default, stale intelligent selection);
  localStorage reads became lazy useState initializers (combos usage guide).
- immutability / TDZ (8): effects that scheduled fetchers declared below them
  moved after the declarations (EndpointPageClient, ApiManagerPageClient,
  combos mount load); fetchData relocated below the per-key fetchers it calls.
- static-components (7): provider-stats SortIcon hoisted to module level.
- preserve-manual-memoization (2): ApiManager blockedModels dep destructured to
  a local; provider-scope derivation memoized so downstream memos see a stable
  dependency.

Validation: eslint (CI command with suppressions, --max-warnings 0) clean on the
14 files; dashboard typecheck within baseline; mutation gate no drift; area
tests 208/208 (node) + 29/29 (vitest).

Refs #12146

* chore(lint): batch 5 follow-up — hoist the render-adjustment predicates so the new-code complexity gates stay flat

The render adjustments added one cyclomatic branch to combos/page.tsx and one
cognitive point to PoolWizard (caught by the new-code gate on the committed
work); the compound conditions now live in pure module-level predicates.

* chore(lint): batch 5 follow-up 2 — PoolWizard render adjustments live in two small hooks

One consolidated hook tripped max-lines-per-function (>80) and the cognitive
budget; the dimensions and open/close adjustments now live in two focused hooks
with a shared WizardSetters type, and the group snap stays inline (one branch).
complexityNewCode=0, cognitiveComplexityNewCode=0.

* test(quota): repoint the two PoolWizard structural pins at the render-adjustment hook

quota-edit-opens-wizard anchored the pre-fill block on the old '} else if (editPool)'
effect literal and quota-pool-wizard-edit expected a bare 'if (editPool)' that only
existed there; both now anchor on the batch-5 structure (submit still branches via
if (!editPool)).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-31 03:10:49 -03:00
committed by GitHub
parent 78fd3504dd
commit b7a0c54139
17 changed files with 467 additions and 365 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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(

View File

@@ -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<SortMethod>(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>(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) {

View File

@@ -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<string | null>(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<typeof payload>(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;

View File

@@ -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<string, { dimensions: QuotaDimension[] } | undefined>,
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 <select> to a real,
// selectable option in create mode once groups load.
function poolWizardNeedsGroupSnap(
open: boolean,
isEditing: boolean,
groups: Array<{ id: string }>,
groupId: string
): boolean {
if (!open || isEditing || groups.length === 0) return false;
return groupId === "all" || !groups.some((g) => g.id === groupId);
}
type WizardSetters = {
setStep: Dispatch<SetStateAction<1 | 2 | 3>>;
setConnectionIds: Dispatch<SetStateAction<string[]>>;
setPoolName: Dispatch<SetStateAction<string>>;
setDefaultPolicy: Dispatch<SetStateAction<Policy>>;
setEditDimensions: Dispatch<SetStateAction<QuotaDimension[]>>;
setDimensionsEdited: Dispatch<SetStateAction<boolean>>;
setAllocations: Dispatch<SetStateAction<PoolAllocation[]>>;
setExclusive: Dispatch<SetStateAction<boolean>>;
setError: Dispatch<SetStateAction<string | null>>;
setSaving: Dispatch<SetStateAction<boolean>>;
setGroupId: Dispatch<SetStateAction<string>>;
};
// Render-time state adjustment (react-hooks/set-state-in-effect): reload the editable
// dimensions when the primary connection changes. Lives in a hook so the component's
// complexity budget stays flat (check:complexity new-code mode).
function useWizardDimensionsAdjustment({
primaryConnectionId,
selectedProvider,
plans,
setEditDimensions,
setDimensionsEdited,
}: {
primaryConnectionId: string | undefined;
selectedProvider: string | undefined;
plans: Record<string, { dimensions: QuotaDimension[] } | undefined>;
} & Pick<WizardSetters, "setEditDimensions" | "setDimensionsEdited">) {
const [prevPrimaryConnectionId, setPrevPrimaryConnectionId] = useState(primaryConnectionId);
if (primaryConnectionId !== prevPrimaryConnectionId) {
setPrevPrimaryConnectionId(primaryConnectionId);
const catalogPlan = selectedProvider ? getKnownPlan(selectedProvider) : null;
setEditDimensions(
pickWizardDimensions(primaryConnectionId, plans, catalogPlan?.dimensions ?? null)
);
setDimensionsEdited(false);
}
}
// Render-time state adjustment keyed on the (open, editPool) pair — reset on close,
// pre-fill on edit open — mirroring the old effect's deps (react-hooks/set-state-in-effect).
function useWizardOpenCloseAdjustment({
open,
editPool,
editPoolExclusive,
initialGroupId,
plans,
...set
}: {
open: boolean;
editPool?: QuotaPool;
editPoolExclusive?: boolean;
initialGroupId: string;
plans: Record<string, { dimensions: QuotaDimension[] } | undefined>;
} & WizardSetters) {
const [prevWizardKey, setPrevWizardKey] = useState<{ open: boolean; editPool?: QuotaPool }>({
open,
editPool,
});
const changed = prevWizardKey.open !== open || prevWizardKey.editPool !== editPool;
if (changed) setPrevWizardKey({ open, editPool });
if (changed && !open) {
// Closing: always reset to defaults.
set.setStep(1);
set.setConnectionIds([]);
set.setPoolName("");
set.setDefaultPolicy("hard");
set.setEditDimensions([]);
set.setDimensionsEdited(false);
set.setAllocations([]);
set.setExclusive(false);
set.setError(null);
set.setSaving(false);
set.setGroupId(initialGroupId);
}
if (changed && open && editPool) {
// Opening in edit mode: pre-fill from the existing pool. Preserves the pool's
// exclusivity so editing an exclusive pool does not silently un-exclusive it;
// dimensionsEdited stays false so the PUT is skipped unless the user edits.
const ids =
Array.isArray(editPool.connectionIds) && editPool.connectionIds.length > 0
? editPool.connectionIds
: [editPool.connectionId];
const existingPlan = plans[ids[0]];
set.setConnectionIds(ids);
set.setPoolName(editPool.name);
set.setGroupId(editPool.groupId ?? initialGroupId);
set.setAllocations(editPool.allocations ?? []);
set.setExclusive(editPoolExclusive ?? false);
set.setEditDimensions(
existingPlan && existingPlan.dimensions.length > 0 ? [...existingPlan.dimensions] : []
);
set.setDimensionsEdited(false);
set.setError(null);
set.setSaving(false);
set.setStep(1);
}
// When open && !editPool (create mode): the close-reset above already restored defaults.
}
export default function PoolWizard({
open,
onClose,
@@ -248,83 +374,36 @@ export default function PoolWizard({
[connections, existingPoolConnectionIds, lockedProvider]
);
// ── Load dimensions when primary connection changes ───────────────────────
useEffect(() => {
if (!primaryConnectionId) {
setEditDimensions([]);
setDimensionsEdited(false);
return;
}
const existingPlan = plans[primaryConnectionId];
if (existingPlan && existingPlan.dimensions.length > 0) {
setEditDimensions([...existingPlan.dimensions]);
} else {
const catalogPlan = selectedConn ? getKnownPlan(selectedConn.provider) : null;
setEditDimensions(catalogPlan ? [...catalogPlan.dimensions] : []);
}
setDimensionsEdited(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [primaryConnectionId]);
// ── Reset wizard on open/close ────────────────────────────────────────────
useEffect(() => {
if (!open) {
// Closing: always reset to defaults.
setStep(1);
setConnectionIds([]);
setPoolName("");
setDefaultPolicy("hard");
setEditDimensions([]);
setDimensionsEdited(false);
setAllocations([]);
setExclusive(false);
setError(null);
setSaving(false);
setGroupId(initialGroupId);
} else if (editPool) {
// Opening in edit mode: pre-fill from the existing pool.
const ids =
Array.isArray(editPool.connectionIds) && editPool.connectionIds.length > 0
? editPool.connectionIds
: [editPool.connectionId];
setConnectionIds(ids);
setPoolName(editPool.name);
setGroupId(editPool.groupId ?? initialGroupId);
setAllocations(editPool.allocations ?? []);
// Preserve the pool's current exclusivity state so editing an exclusive pool
// doesn't silently un-exclusive it. Falls back to false only when not provided.
setExclusive(editPoolExclusive ?? false);
// Pre-load plan dimensions for the primary connection (same as create path).
// dimensionsEdited stays false so the PUT is skipped unless the user actively edits.
const primaryId = ids[0];
const existingPlan = plans[primaryId];
if (existingPlan && existingPlan.dimensions.length > 0) {
setEditDimensions([...existingPlan.dimensions]);
} else {
setEditDimensions([]);
}
setDimensionsEdited(false);
setError(null);
setSaving(false);
setStep(1);
}
// When open && !editPool (create mode): the existing create-reset on close handles defaults.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, editPool, initialGroupId]);
// Keep the group <select> 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 <select> on a real, selectable option (create mode) — self-
// extinguishing state adjustment during render (react-hooks/set-state-in-effect).
if (poolWizardNeedsGroupSnap(open, Boolean(editPool), groups, groupId)) {
setGroupId(groups[0].id);
}
// ── Step 2 — dimension editors ────────────────────────────────────────────

View File

@@ -38,7 +38,11 @@ export function usePoolUsage(poolId: string, pollIntervalMs = 15_000): UsePoolUs
useEffect(() => {
mountedRef.current = true;
void fetchUsage();
// Async continuation: the compiler only accepts setter-capturing callbacks from an
// effect when the call sits behind an async boundary (react-hooks/set-state-in-effect).
void (async () => {
await fetchUsage();
})();
const interval = setInterval(() => {
void fetchUsage();

View File

@@ -42,7 +42,10 @@ export function usePools(): UsePoolsResult {
useEffect(() => {
mountedRef.current = true;
void fetchPools();
// Async continuation — see usePoolUsage (react-hooks/set-state-in-effect).
void (async () => {
await fetchPools();
})();
return () => {
mountedRef.current = false;
};

View File

@@ -44,7 +44,11 @@ export function useApiKeyUsageLimits(selectedApiKeyId: string | null) {
);
useEffect(() => {
void load();
// Async continuation — the compiler rejects a sync call to a setter-capturing
// callback from the effect body (react-hooks/set-state-in-effect).
void (async () => {
await load();
})();
}, [load]);
return { payload, loading, save };

View File

@@ -299,52 +299,6 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
[translateOrFallback]
);
useEffect(() => {
let mounted = true;
const loadPage = async () => {
const tunnelVisibility = await loadCloudSettings(() => mounted);
if (!mounted) return;
setLoading(false);
runEndpointBackgroundTask("models", fetchModels);
runEndpointBackgroundTask("protocol-status", fetchProtocolStatus);
runEndpointBackgroundTask("search-providers", fetchSearchProviders);
runEndpointBackgroundTask("network-info", async () => {
try {
const res = await fetch("/api/network/info");
if (res.ok) {
const data = await res.json();
if (mounted) {
if (data.localUrl) setLocalApiUrl(data.localUrl);
setLanUrls(data.lanUrls ?? []);
if (data.tailscaleIpUrl) setTailscaleIpUrl(data.tailscaleIpUrl);
}
}
} catch {
// non-critical
}
});
if (tunnelVisibility.showCloudflaredTunnel) {
runEndpointBackgroundTask("cloudflared-status", () => fetchCloudflaredStatus(true));
}
if (tunnelVisibility.showTailscaleFunnel) {
runEndpointBackgroundTask("tailscale-status", () => fetchTailscaleStatus(true));
}
if (tunnelVisibility.showNgrokTunnel) {
runEndpointBackgroundTask("ngrok-status", () => fetchNgrokStatus(true));
}
};
void loadPage();
return () => {
mounted = false;
};
}, [fetchCloudflaredStatus, fetchTailscaleStatus, fetchNgrokStatus]);
const fetchModels = async () => {
setModelsLoading(true);
try {
@@ -507,6 +461,55 @@ export default function APIPageClient({ machineId }: Readonly<APIPageClientProps
return DEFAULT_TUNNEL_VISIBILITY;
};
// Moved below the loader/fetcher declarations it schedules — referencing them from
// an effect declared above their `const` bindings is a TDZ read the compiler rejects
// (react-hooks/immutability).
useEffect(() => {
let mounted = true;
const loadPage = async () => {
const tunnelVisibility = await loadCloudSettings(() => mounted);
if (!mounted) return;
setLoading(false);
runEndpointBackgroundTask("models", fetchModels);
runEndpointBackgroundTask("protocol-status", fetchProtocolStatus);
runEndpointBackgroundTask("search-providers", fetchSearchProviders);
runEndpointBackgroundTask("network-info", async () => {
try {
const res = await fetch("/api/network/info");
if (res.ok) {
const data = await res.json();
if (mounted) {
if (data.localUrl) setLocalApiUrl(data.localUrl);
setLanUrls(data.lanUrls ?? []);
if (data.tailscaleIpUrl) setTailscaleIpUrl(data.tailscaleIpUrl);
}
}
} catch {
// non-critical
}
});
if (tunnelVisibility.showCloudflaredTunnel) {
runEndpointBackgroundTask("cloudflared-status", () => fetchCloudflaredStatus(true));
}
if (tunnelVisibility.showTailscaleFunnel) {
runEndpointBackgroundTask("tailscale-status", () => fetchTailscaleStatus(true));
}
if (tunnelVisibility.showNgrokTunnel) {
runEndpointBackgroundTask("ngrok-status", () => fetchNgrokStatus(true));
}
};
void loadPage();
return () => {
mounted = false;
};
}, [fetchCloudflaredStatus, fetchTailscaleStatus, fetchNgrokStatus]);
const handleCustomSystemPromptEnabledChange = (value: boolean) => {
setCustomSystemPromptEnabled(value);
void fetch("/api/settings", {
@@ -2485,4 +2488,3 @@ function EndpointCard({
</div>
);
}

View File

@@ -133,7 +133,12 @@ export default function A2ADashboardPage() {
}, [offset, stateFilter, skillFilter]);
useEffect(() => {
Promise.allSettled([refreshStatus(), refreshTasks()]).finally(() => setLoading(false));
// 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.allSettled([refreshStatus(), refreshTasks()]);
setLoading(false);
})();
const interval = setInterval(() => {
void refreshStatus();
void refreshTasks();
@@ -142,7 +147,10 @@ export default function A2ADashboardPage() {
}, [refreshStatus, refreshTasks]);
useEffect(() => {
void refreshTasks();
// Async continuation — see above.
void (async () => {
await refreshTasks();
})();
}, [refreshTasks]);
const availableSkills = useMemo(() => {

View File

@@ -266,13 +266,19 @@ export default function McpDashboardPage() {
}, [auditOffset, toolFilter, successFilter, apiKeyFilter]);
useEffect(() => {
refreshSummary();
// Async continuation — see react-hooks/set-state-in-effect.
void (async () => {
await refreshSummary();
})();
const interval = setInterval(refreshSummary, 30000);
return () => clearInterval(interval);
}, [refreshSummary]);
useEffect(() => {
refreshAudit();
// Async continuation — see react-hooks/set-state-in-effect.
void (async () => {
await refreshAudit();
})();
}, [refreshAudit]);
const handleSwitchCombo = async () => {
@@ -425,7 +431,9 @@ export default function McpDashboardPage() {
<p>
{t("scopesEnforced")}:{" "}
<span className="font-semibold">
{(status?.scopesEnforced ?? status?.heartbeat?.scopesEnforced) ? t("yes") : t("no")}
{(status?.scopesEnforced ?? status?.heartbeat?.scopesEnforced)
? t("yes")
: t("no")}
</span>
</p>
<p>

View File

@@ -37,7 +37,10 @@ export default function NotionSourceCard() {
}, []);
useEffect(() => {
void fetchConfig();
// Async continuation — see react-hooks/set-state-in-effect.
void (async () => {
await fetchConfig();
})();
}, [fetchConfig]);
useEffect(() => {

View File

@@ -50,8 +50,11 @@ export default function ObsidianSourceCard() {
}, []);
useEffect(() => {
void fetchConfig();
void fetchWebdavStatus();
// Async continuation — see react-hooks/set-state-in-effect.
void (async () => {
await fetchConfig();
await fetchWebdavStatus();
})();
}, [fetchConfig, fetchWebdavStatus]);
useEffect(() => {

View File

@@ -47,6 +47,25 @@ type SortKey =
| "avgGapAfterToolMs";
type SortDir = "asc" | "desc";
// Hoisted out of the page component so a stable component type survives re-renders
// (react-hooks/static-components).
function SortIcon({
column,
sortKey,
sortDir,
}: {
column: SortKey;
sortKey: SortKey;
sortDir: SortDir;
}) {
if (sortKey !== column) return null;
return (
<span className="material-symbols-outlined text-[14px] ml-1 align-middle text-primary">
{sortDir === "desc" ? "arrow_downward" : "arrow_upward"}
</span>
);
}
function formatNumber(n: number | null): string {
if (n == null) return "0";
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
@@ -95,7 +114,10 @@ export default function ProviderStatsPage() {
}, [t]);
useEffect(() => {
fetchData();
// Async continuation — see react-hooks/set-state-in-effect.
void (async () => {
await fetchData();
})();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, [fetchData]);
@@ -140,16 +162,6 @@ export default function ProviderStatsPage() {
modelsByProvider.get(m.provider)!.push(m);
}
// Sort helper icon
const SortIcon = ({ column }: { column: SortKey }) => {
if (sortKey !== column) return null;
return (
<span className="material-symbols-outlined text-[14px] ml-1 align-middle text-primary">
{sortDir === "desc" ? "arrow_downward" : "arrow_upward"}
</span>
);
};
if (!data && !error) {
return (
<div className="flex items-center justify-center min-h-[400px]">
@@ -262,44 +274,51 @@ export default function ProviderStatsPage() {
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("totalRequests")}
>
{t("requests")} <SortIcon column="totalRequests" />
{t("requests")}{" "}
<SortIcon column="totalRequests" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("successfulRequests")}
>
{t("success")} <SortIcon column="successfulRequests" />
{t("success")}{" "}
<SortIcon column="successfulRequests" sortKey={sortKey} sortDir={sortDir} />
</th>
<th className="text-right py-2 px-3 text-text-muted font-medium">{t("rate")}</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("avgLatencyMs")}
>
{t("avgLatency")} <SortIcon column="avgLatencyMs" />
{t("avgLatency")}{" "}
<SortIcon column="avgLatencyMs" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("totalTokensIn")}
>
{t("tokensIn")} <SortIcon column="totalTokensIn" />
{t("tokensIn")}{" "}
<SortIcon column="totalTokensIn" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("totalTokensOut")}
>
{t("tokensOut")} <SortIcon column="totalTokensOut" />
{t("tokensOut")}{" "}
<SortIcon column="totalTokensOut" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("avgTtftAfterToolMs")}
>
{t("ttftAfterTool")} <SortIcon column="avgTtftAfterToolMs" />
{t("ttftAfterTool")}{" "}
<SortIcon column="avgTtftAfterToolMs" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="text-right py-2 px-3 text-text-muted font-medium cursor-pointer hover:text-text-main transition-colors select-none"
onClick={() => handleSort("avgGapAfterToolMs")}
>
{t("gapAfterTool")} <SortIcon column="avgGapAfterToolMs" />
{t("gapAfterTool")}{" "}
<SortIcon column="avgGapAfterToolMs" sortKey={sortKey} sortDir={sortDir} />
</th>
<th className="py-2 px-3 w-8" />
</tr>

View File

@@ -121,10 +121,15 @@ test("PoolWizard.tsx: pre-fill uses editPoolExclusive ?? false (not setExclusive
);
// The literal `setExclusive(false)` must NOT appear in the edit-mode pre-fill block
// (it may still appear in the close-reset block, which is correct)
const editFillIdx = wizardSrc.indexOf("} else if (editPool)");
assert.ok(editFillIdx >= 0, "Expected '} else if (editPool)' block in PoolWizard");
const closingBrace = wizardSrc.indexOf("\n }", editFillIdx);
const editFillBlock = wizardSrc.slice(editFillIdx, closingBrace > 0 ? closingBrace + 6 : editFillIdx + 1200);
// #12146 batch 5: the pre-fill moved from the reset effect ("} else if (editPool)")
// into useWizardOpenCloseAdjustment's render adjustment.
const editFillIdx = wizardSrc.indexOf("if (changed && open && editPool)");
assert.ok(editFillIdx >= 0, "Expected the editPool pre-fill adjustment block in PoolWizard");
const closingBrace = wizardSrc.indexOf("\n }", editFillIdx);
const editFillBlock = wizardSrc.slice(
editFillIdx,
closingBrace > 0 ? closingBrace + 4 : editFillIdx + 1200
);
assert.ok(
!editFillBlock.includes("setExclusive(false)"),
"setExclusive(false) must not appear in the editPool pre-fill block — must use editPoolExclusive"

View File

@@ -35,10 +35,7 @@ const ptBrJson = JSON.parse(fs.readFileSync(PT_BR_JSON_PATH, "utf-8")) as Record
// ── PoolWizardProps: editPool field ───────────────────────────────────────────
test("PoolWizard.tsx: declares editPool in PoolWizardProps", () => {
assert.ok(
wizardSrc.includes("editPool?"),
"Expected optional editPool field in PoolWizardProps"
);
assert.ok(wizardSrc.includes("editPool?"), "Expected optional editPool field in PoolWizardProps");
});
test("PoolWizard.tsx: imports QuotaPool type", () => {
@@ -69,11 +66,10 @@ test("PoolWizard.tsx: submit handler branches on editPool", () => {
wizardSrc.includes("editPool"),
"Expected editPool to appear in PoolWizard source (branching in submit)"
);
// The branching condition inside handleFinish
assert.ok(
wizardSrc.includes("if (editPool)"),
"Expected if (editPool) branch in handleFinish"
);
// The branching condition inside handleFinish (create path guards on !editPool;
// the old "} else if (editPool)" literal lived in the reset effect that #12146
// batch 5 turned into a render adjustment).
assert.ok(wizardSrc.includes("if (!editPool)"), "Expected the editPool branch in handleFinish");
});
// ── Pre-fill references ───────────────────────────────────────────────────────
@@ -112,17 +108,17 @@ test("PoolWizard.tsx: pre-fills groupId from editPool.groupId", () => {
// ── i18n key usage ────────────────────────────────────────────────────────────
test("PoolWizard.tsx: uses t(\"saveChanges\") for the submit button in edit mode", () => {
test('PoolWizard.tsx: uses t("saveChanges") for the submit button in edit mode', () => {
assert.ok(
wizardSrc.includes('t("saveChanges")'),
"Expected t(\"saveChanges\") used in submit button (edit mode)"
'Expected t("saveChanges") used in submit button (edit mode)'
);
});
test("PoolWizard.tsx: uses t(\"editPoolTitle\") for the modal title in edit mode", () => {
test('PoolWizard.tsx: uses t("editPoolTitle") for the modal title in edit mode', () => {
assert.ok(
wizardSrc.includes('t("editPoolTitle")'),
"Expected t(\"editPoolTitle\") used in modal title (edit mode)"
'Expected t("editPoolTitle") used in modal title (edit mode)'
);
});