chore(lint): batch 4 of #12146 — shared/components react-hooks violations resolved (#12159)

* chore(lint): batch 4 of #12146 — resolve the react-hooks compiler violations in shared/components

Real refactors (no suppressions, no eslint-disable) for the 21 react-hooks/*
violations across the 11 src/shared/components files of this batch:

- set-state-in-effect (prop/state mirror or modal open/close reset):
  replaced with guarded render-time adjustments (react.dev "You Might Not
  Need an Effect" prev-tracking pattern) — KiroAuthModal,
  ModelSelectModal, ProxyConfigModal, OAuthModal (provider-change, close
  and open resets; ref invalidation split into ref-only effects),
  RequestLoggerDetail.sections (liveDetail mirror),
  ComboCompressionModeSelect (initialCompressionMode mirror).
- set-state-in-effect (fetch+set effects calling component-scope
  functions): moved the async loader inside the effect (ModelSelectModal
  fetchCombos/fetchProviderNodes/fetchCustomModels, PricingModal
  loadPricing, useProviderDailyUsage fetchRows — now with a cancelled
  guard) or wrapped the call in an effect-local async runner
  (ReasoningRoutingRules load, UsageStats fetchStats, OAuthModal
  startOAuthFlow) with every setState on the async path.
- OAuthModal device-code countdown: deviceCodeSecondsRemaining state
  deleted and derived from deviceCodeExpiresAt plus a `now` tick state
  updated by the interval (re-anchored when polling starts).
- Sidebar localStorage hydration: reads moved into useSyncExternalStore
  snapshots (server snapshot null) applied via render-time adjustment;
  skipInitialActiveExpansion ref converted to state; the active-section
  expansion effect became a render-time adjustment keyed on the old
  effect deps; persistence consolidated into one saveToStorage effect
  (removes the saves that ran inside setState updaters and drops a
  pre-existing eslint-disable for exhaustive-deps).
- immutability (use-before-declare): PricingModal loadPricing inlined
  into its effect; ProxyConfigModal resetFields hoisted above the load
  effect as a dependency-free useCallback.
- exhaustive-deps (ProxyConfigModal): effect now depends on the stable
  resetFields and on hoisted translated strings (socks5HiddenError,
  levelGlobalLabel) instead of the `t` identity.
- preserve-manual-memoization (UsageStats sortedAccounts): optional
  chains destructured into locals so the memo deps match the usage.

config/quality/eslint-suppressions.json: removed every react-hooks/*
entry for the 11 files (other-rule entries preserved).

Validation: eslint gate (--suppressions-location, --max-warnings 0) green
on all 11 files; typecheck:core clean; node unit sweep 373/373; vitest
sweep 547/550 with the 3 fails being 5s-timeout flakes under parallel
load (all pass isolated 8/8, one in an untouched file).

Refs #12146

* test(mutation): register search-432-plan-limit-cooldown in tap.testFiles

The test (merged with the DuckDuckGo cooldown fix) covers accountFallback.ts and
auth.ts but was not listed, so check:mutation-test-coverage --strict reds any PR
whose merge ref includes it. Base also merged in.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-30 23:06:27 -03:00
committed by GitHub
parent 7f49b342b5
commit bbbcc79384
13 changed files with 366 additions and 276 deletions

View File

@@ -2595,9 +2595,6 @@
"src/shared/components/KiroAuthModal.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/LanguageSelector.tsx": {
@@ -2605,11 +2602,6 @@
"count": 1
}
},
"src/shared/components/ModelSelectModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 4
}
},
"src/shared/components/NotificationToast.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -2618,28 +2610,11 @@
"src/shared/components/OAuthModal.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 3
},
"react-hooks/set-state-in-effect": {
"count": 4
}
},
"src/shared/components/PricingModal.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
}
},
"src/shared/components/ProxyConfigModal.tsx": {
"react-hooks/exhaustive-deps": {
"count": 1
},
"react-hooks/immutability": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/ProxyLogDetail.tsx": {
@@ -2647,16 +2622,6 @@
"count": 1
}
},
"src/shared/components/ReasoningRoutingRules.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/RequestLoggerDetail.sections.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/RequestLoggerV2.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 3
@@ -2676,17 +2641,6 @@
},
"@typescript-eslint/no-unused-vars": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/shared/components/UsageStats.tsx": {
"react-hooks/preserve-manual-memoization": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/analytics/charts.tsx": {
@@ -2699,16 +2653,6 @@
"count": 1
}
},
"src/shared/components/analytics/useProviderDailyUsage.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/compression/ComboCompressionModeSelect.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/shared/components/docs/CodeBlock.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1

View File

@@ -37,16 +37,21 @@ export default function KiroAuthModal({
const [importingApiKey, setImportingApiKey] = useState(false);
const [autoDetecting, setAutoDetecting] = useState(false);
useEffect(() => {
if (isOpen) return;
setSelectedMethod(null);
setIdcStartUrl("");
setIdcRegion("us-east-1");
setRefreshToken("");
setApiKey("");
setApiKeyRegion("us-east-1");
setError(null);
}, [isOpen]);
// Reset the form when the modal closes (render-time adjustment per react.dev
// "You Might Not Need an Effect" — replaces the old reset effect).
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (!isOpen) {
setSelectedMethod(null);
setIdcStartUrl("");
setIdcRegion("us-east-1");
setRefreshToken("");
setApiKey("");
setApiKeyRegion("us-east-1");
setError(null);
}
}
// Auto-detect token when import method is selected
useEffect(() => {

View File

@@ -148,64 +148,68 @@ export default function ModelSelectModal({
const [testProgress, setTestProgress] = useState<{ done: number; total: number } | null>(null);
const [modelTestStatus, setModelTestStatus] = useState<Record<string, "ok" | "error">>({});
const fetchCombos = async () => {
try {
const res = await fetch("/api/combos");
if (!res.ok) throw new Error(`Failed to fetch combos: ${res.status}`);
const data = await res.json();
setCombos(data.combos || []);
} catch (error) {
console.error("Error fetching combos:", error);
setCombos([]);
}
};
useEffect(() => {
if (isOpen) fetchCombos();
if (!isOpen) return;
const fetchCombos = async () => {
try {
const res = await fetch("/api/combos");
if (!res.ok) throw new Error(`Failed to fetch combos: ${res.status}`);
const data = await res.json();
setCombos(data.combos || []);
} catch (error) {
console.error("Error fetching combos:", error);
setCombos([]);
}
};
fetchCombos();
}, [isOpen]);
// Reset provider-test bookkeeping whenever the modal closes so the next
// open starts from a clean selection / progress state.
// open starts from a clean selection / progress state (render-time
// adjustment per react.dev "You Might Not Need an Effect").
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (!isOpen) {
setSelectedProviderIds(new Set());
setTestingProviders(false);
setTestProgress(null);
setModelTestStatus({});
}
}
useEffect(() => {
if (isOpen) return;
setSelectedProviderIds(new Set());
setTestingProviders(false);
setTestProgress(null);
setModelTestStatus({});
if (!isOpen) return;
const fetchProviderNodes = async () => {
try {
const res = await fetch("/api/provider-nodes");
if (!res.ok) throw new Error(`Failed to fetch provider nodes: ${res.status}`);
const data = await res.json();
setProviderNodes(data.nodes || []);
} catch (error) {
console.error("Error fetching provider nodes:", error);
setProviderNodes([]);
}
};
fetchProviderNodes();
}, [isOpen]);
const fetchProviderNodes = async () => {
try {
const res = await fetch("/api/provider-nodes");
if (!res.ok) throw new Error(`Failed to fetch provider nodes: ${res.status}`);
const data = await res.json();
setProviderNodes(data.nodes || []);
} catch (error) {
console.error("Error fetching provider nodes:", error);
setProviderNodes([]);
}
};
useEffect(() => {
if (isOpen) fetchProviderNodes();
}, [isOpen]);
const fetchCustomModels = async () => {
try {
const res = await fetch("/api/provider-models");
if (!res.ok) throw new Error(`Failed to fetch custom models: ${res.status}`);
const data = await res.json();
setCustomModels(data.models || {});
// #9203: keep the unified hidden-model map in sync with the model list.
setHiddenModelsByProvider(parseHiddenModelsByProvider(data.hiddenModelsByProvider));
} catch (error) {
console.error("Error fetching custom models:", error);
setCustomModels({});
}
};
useEffect(() => {
if (isOpen) fetchCustomModels();
if (!isOpen) return;
const fetchCustomModels = async () => {
try {
const res = await fetch("/api/provider-models");
if (!res.ok) throw new Error(`Failed to fetch custom models: ${res.status}`);
const data = await res.json();
setCustomModels(data.models || {});
// #9203: keep the unified hidden-model map in sync with the model list.
setHiddenModelsByProvider(parseHiddenModelsByProvider(data.hiddenModelsByProvider));
} catch (error) {
console.error("Error fetching custom models:", error);
setCustomModels({});
}
};
fetchCustomModels();
}, [isOpen]);
// Fetch the live model catalog for one custom provider from its connection's

View File

@@ -144,7 +144,9 @@ export default function OAuthModal({
const [gheUrl, setGheUrl] = useState("");
const [polling, setPolling] = useState(false);
const [deviceCodeExpiresAt, setDeviceCodeExpiresAt] = useState<number | null>(null);
const [deviceCodeSecondsRemaining, setDeviceCodeSecondsRemaining] = useState<number | null>(null);
// Wall-clock tick driving the device-code countdown; ticked by the interval
// effect below and re-anchored whenever a device flow (re)starts.
const [now, setNow] = useState(() => Date.now());
// API-key paste mode for direct-token providers.
const [showPasteToken, setShowPasteToken] = useState(IMPORT_TOKEN_ONLY_PROVIDERS.has(provider));
const [pasteToken, setPasteToken] = useState("");
@@ -204,7 +206,6 @@ export default function OAuthModal({
deviceFlowRunRef.current += 1;
setPolling(false);
setDeviceCodeExpiresAt(null);
setDeviceCodeSecondsRemaining(null);
}, []);
// Define all useCallback hooks BEFORE the useEffects that reference them
@@ -323,6 +324,7 @@ export default function OAuthModal({
setPolling(true);
setDeviceCodeExpiresAt(deadline);
setNow(Date.now());
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, currentInterval * 1000));
@@ -633,33 +635,50 @@ export default function OAuthModal({
);
useEffect(() => {
if (!deviceCodeExpiresAt) {
setDeviceCodeSecondsRemaining(null);
return;
}
const updateRemaining = () => {
setDeviceCodeSecondsRemaining(
Math.max(0, Math.ceil((deviceCodeExpiresAt - Date.now()) / 1000))
);
};
updateRemaining();
const timer = window.setInterval(updateRemaining, 1000);
if (!deviceCodeExpiresAt) return;
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [deviceCodeExpiresAt]);
useEffect(() => {
invalidateDeviceFlow();
flowStartedRef.current = false;
// Derived countdown (replaces the old mirrored deviceCodeSecondsRemaining
// state): `now` is re-anchored when the device flow starts and ticked by the
// interval effect above.
const deviceCodeSecondsRemaining =
deviceCodeExpiresAt == null ? null : Math.max(0, Math.ceil((deviceCodeExpiresAt - now) / 1000));
// When the provider changes, reset the flow state during render (react.dev
// "You Might Not Need an Effect") and invalidate any in-flight device flow
// in a ref-only effect (refs must not be written during render).
const [prevProvider, setPrevProvider] = useState(provider);
if (provider !== prevProvider) {
setPrevProvider(provider);
setPolling(false);
setDeviceCodeExpiresAt(null);
setGrokBrowserMode(false);
}, [provider, invalidateDeviceFlow]);
}
useEffect(() => {
deviceFlowRunRef.current += 1;
flowStartedRef.current = false;
}, [provider]);
// Same split when the modal closes: state reset during render, ref
// invalidation in a ref-only effect.
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (!isOpen) {
setPolling(false);
setDeviceCodeExpiresAt(null);
}
}
useEffect(() => {
if (!isOpen) {
invalidateDeviceFlow();
deviceFlowRunRef.current += 1;
flowStartedRef.current = false;
}
}, [isOpen, invalidateDeviceFlow]);
}, [isOpen]);
useEffect(
() => () => {
@@ -668,26 +687,43 @@ export default function OAuthModal({
[]
);
// Reset state and start OAuth when modal opens
// Reset state and start OAuth when modal opens. The synchronous state resets
// moved from the old effect into this render-time adjustment (react.dev
// "You Might Not Need an Effect"); the flow itself starts in the effect below.
const [prevStartKey, setPrevStartKey] = useState<string | null>(null);
const startKey = isOpen && provider ? String(provider) : null;
if (startKey !== prevStartKey) {
setPrevStartKey(startKey);
if (startKey) {
setShowPasteToken(IMPORT_TOKEN_ONLY_PROVIDERS.has(provider));
setGrokBrowserMode(false);
setAuthData(null);
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
// #8688: show GitLab Duo OAuth app / env setup before authorize error.
if (provider === "gitlab-duo") {
setStep("gitlab-duo-setup");
}
}
}
useEffect(() => {
if (!isOpen || !provider || flowStartedRef.current) return;
flowStartedRef.current = true;
const startsInPasteMode = IMPORT_TOKEN_ONLY_PROVIDERS.has(provider);
// #8688: show GitLab Duo OAuth app / env setup before authorize error.
const startsInGitlabDuoSetup = provider === "gitlab-duo";
setShowPasteToken(startsInPasteMode);
setGrokBrowserMode(false);
setAuthData(null);
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
if (startsInGitlabDuoSetup) {
setStep("gitlab-duo-setup");
// Auto-start is skipped — setStep("gitlab-duo-setup") already happened
// in the render-time adjustment above (#8688).
return;
}
if (!startsInPasteMode) startOAuthFlow();
flowStartedRef.current = true;
const run = async () => {
if (!startsInPasteMode) startOAuthFlow();
};
run();
}, [isOpen, provider, startOAuthFlow]);
// Listen for OAuth callback via multiple methods

View File

@@ -11,31 +11,29 @@ export default function PricingModal({ isOpen, onClose, onSave }) {
const [saving, setSaving] = useState(false);
useEffect(() => {
if (isOpen) {
loadPricing();
}
}, [isOpen]);
const loadPricing = async () => {
setLoading(true);
try {
const response = await fetch("/api/pricing");
if (response.ok) {
const data = await response.json();
setPricingData(data);
} else {
// Fallback to defaults
if (!isOpen) return;
const loadPricing = async () => {
setLoading(true);
try {
const response = await fetch("/api/pricing");
if (response.ok) {
const data = await response.json();
setPricingData(data);
} else {
// Fallback to defaults
const defaults = getDefaultPricing();
setPricingData(defaults);
}
} catch (error) {
console.error("Failed to load pricing:", error);
const defaults = getDefaultPricing();
setPricingData(defaults);
} finally {
setLoading(false);
}
} catch (error) {
console.error("Failed to load pricing:", error);
const defaults = getDefaultPricing();
setPricingData(defaults);
} finally {
setLoading(false);
}
};
};
loadPricing();
}, [isOpen]);
const handlePricingChange = (provider, model, field, value) => {
const numValue = parseFloat(value);

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import Modal from "./Modal";
import Button from "./Button";
@@ -23,7 +23,9 @@ const BUILD_TIME_SOCKS5 = !["false", "0", "no", "off"].includes(
(process.env.NEXT_PUBLIC_ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase()
);
export function buildProxyTypes(socks5Enabled: boolean) {
return socks5Enabled ? ALL_PROXY_TYPES : ALL_PROXY_TYPES.filter((type) => type.value !== "socks5");
return socks5Enabled
? ALL_PROXY_TYPES
: ALL_PROXY_TYPES.filter((type) => type.value !== "socks5");
}
type ProxyConfigLevel = "global" | "provider" | "combo" | "key";
@@ -152,12 +154,40 @@ export default function ProxyConfigModal({
return "8080";
};
// Reset transient state when the modal opens (render-time adjustment per
// react.dev "You Might Not Need an Effect" — replaces the synchronous
// setStates the load effect used to issue).
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (isOpen) {
setTestResult(null);
setFormError(null);
setLoading(true);
}
}
const resetFields = useCallback(() => {
// ALL_PROXY_TYPES[0] is "http" whether or not SOCKS5 is enabled, so this
// reset has no reactive dependencies and stays referentially stable.
setProxyType(ALL_PROXY_TYPES[0].value);
setHost("");
setPort("");
setUsername("");
setPassword("");
setShowAuth(false);
setFormError(null);
}, []);
// Translated strings the load effect needs, hoisted so the effect can depend
// on stable string values instead of the `t` function identity (an unstable
// `t` — e.g. the test mock — would otherwise re-run the load loop forever).
const socks5HiddenError = t("errorSocks5Hidden");
const levelGlobalLabel = t("levelGlobal");
// Load existing proxy config when modal opens
useEffect(() => {
if (!isOpen) return;
setTestResult(null);
setFormError(null);
setLoading(true);
const loadProxy = async () => {
try {
@@ -196,7 +226,9 @@ export default function ProxyConfigModal({
const assignedProxy = registryItems.find((item) => item.id === target.proxyId);
if (assignedProxy?.source === DASHBOARD_CUSTOM_PROXY_SOURCE) {
const normalizedType = String(assignedProxy.type || "http").toLowerCase();
const hasTypeOption = runtimeProxyTypes.some((entry) => entry.value === normalizedType);
const hasTypeOption = runtimeProxyTypes.some(
(entry) => entry.value === normalizedType
);
setMode("custom");
setProxyType(hasTypeOption ? normalizedType : runtimeProxyTypes[0]?.value || "http");
setHost(assignedProxy.host || "");
@@ -209,7 +241,7 @@ export default function ProxyConfigModal({
);
setShowAuth(!!(assignedProxy.username || assignedProxy.password));
if (normalizedType === "socks5" && !runtimeSocks5) {
setFormError(t("errorSocks5Hidden"));
setFormError(socks5HiddenError);
}
} else {
setMode("saved");
@@ -242,7 +274,7 @@ export default function ProxyConfigModal({
setShowAuth(!!(proxy.username || proxy.password));
setHasOwnProxy(true);
if (normalizedType === "socks5" && !runtimeSocks5) {
setFormError(t("errorSocks5Hidden"));
setFormError(socks5HiddenError);
}
if (!hasSavedAssignment) setMode("custom");
} else {
@@ -263,14 +295,14 @@ export default function ProxyConfigModal({
if (level === "key") {
// Check combo, provider, global
if (config.global)
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
setInheritedFrom({ level: levelGlobalLabel, proxy: config.global });
// Provider info requires more context, showing global as fallback
} else if (level === "combo") {
if (config.global)
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
setInheritedFrom({ level: levelGlobalLabel, proxy: config.global });
} else if (level === "provider") {
if (config.global)
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
setInheritedFrom({ level: levelGlobalLabel, proxy: config.global });
}
}
}
@@ -282,17 +314,7 @@ export default function ProxyConfigModal({
};
loadProxy();
}, [isOpen, level, levelId]);
const resetFields = () => {
setProxyType(proxyTypes[0]?.value || "http");
setHost("");
setPort("");
setUsername("");
setPassword("");
setShowAuth(false);
setFormError(null);
};
}, [isOpen, level, levelId, resetFields, socks5HiddenError, levelGlobalLabel]);
const handleSave = async () => {
if (mode === "saved" && !selectedProxyId) {

View File

@@ -132,7 +132,14 @@ export default function ReasoningRoutingRules({ apiKeyId }: { apiKeyId?: string
}, [t]);
useEffect(() => {
load().catch(() => setMessage(t("loadError")));
const run = async () => {
try {
await load();
} catch {
setMessage(t("loadError"));
}
};
run();
}, [load, t]);
const visibleRules = useMemo(() => {

View File

@@ -155,9 +155,13 @@ export function ConversationContextSection({ log, detail }) {
});
const turnsBoxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Adjust state when the `detail` prop changes (render-time adjustment per
// react.dev "You Might Not Need an Effect" — replaces the old mirror effect).
const [prevDetail, setPrevDetail] = useState(detail);
if (detail !== prevDetail) {
setPrevDetail(detail);
setLiveDetail(detail);
}, [detail]);
}
// Same live-poll pattern as the SSE Events section (StreamSection below),
// but gated on liveRefresh too: an active request keeps generating either

View File

@@ -1,6 +1,13 @@
"use client";
import { useState, useEffect, useRef, useCallback, type CSSProperties } from "react";
import {
useState,
useEffect,
useRef,
useCallback,
useSyncExternalStore,
type CSSProperties,
} from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/shared/utils/cn";
@@ -59,11 +66,10 @@ type SidebarProps = {
type HoveredItem = { id: string; label: string; x: number; y: number } | null;
function loadFromStorage<T>(key: string, fallback: T): T {
function parseStoredArray<T>(raw: string | null, fallback: T): T {
try {
const stored = localStorage.getItem(key);
if (stored) {
const parsed = JSON.parse(stored);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed as T;
}
} catch {}
@@ -76,6 +82,29 @@ function saveToStorage(key: string, value: unknown) {
} catch {}
}
// useSyncExternalStore plumbing for the one-shot localStorage hydration reads:
// nothing to subscribe to (the values are only read once, before
// sidebarExpansionLoaded flips), and the server snapshot is always null so the
// SSR/hydration render matches the server output.
const noopSubscribe = () => () => {};
const getServerSnapshotNull = () => null;
const getHydratedSnapshot = () => true;
const getServerHydratedSnapshot = () => false;
function readStoredExpandedRaw() {
try {
return localStorage.getItem(EXPANDED_SECTIONS_KEY);
} catch {
return null;
}
}
function readStoredPinnedRaw() {
try {
return localStorage.getItem(PINNED_SECTIONS_KEY);
} catch {
return null;
}
}
export default function Sidebar({
onClose,
collapsed = false,
@@ -115,35 +144,47 @@ export default function Sidebar({
);
const [pinnedSections, setPinnedSections] = useState<Set<SidebarSectionId>>(new Set());
const [sidebarExpansionLoaded, setSidebarExpansionLoaded] = useState(false);
const skipInitialActiveExpansion = useRef(false);
const [skipInitialActiveExpansion, setSkipInitialActiveExpansion] = useState(false);
const [hoveredItem, setHoveredItem] = useState<HoveredItem>(null);
const [searchQuery, setSearchQuery] = useState("");
// Load persisted state on mount. A stored [] intentionally means "all sections collapsed".
useEffect(() => {
const storedExpanded = loadFromStorage<SidebarSectionId[]>(EXPANDED_SECTIONS_KEY, [
// Load persisted state once the client has hydrated. A stored [] intentionally
// means "all sections collapsed". localStorage is read through
// useSyncExternalStore snapshots (server snapshot: null) and the states are
// adjusted during render (react.dev "You Might Not Need an Effect") so the
// stored expansion applies before paint without a synchronous effect setState.
const hydrated = useSyncExternalStore(
noopSubscribe,
getHydratedSnapshot,
getServerHydratedSnapshot
);
const storedExpandedRaw = useSyncExternalStore(
noopSubscribe,
readStoredExpandedRaw,
getServerSnapshotNull
);
const storedPinnedRaw = useSyncExternalStore(
noopSubscribe,
readStoredPinnedRaw,
getServerSnapshotNull
);
if (hydrated && !sidebarExpansionLoaded) {
const storedExpanded = parseStoredArray<SidebarSectionId[]>(storedExpandedRaw, [
DEFAULT_EXPANDED,
]);
const pinnedRaw = (() => {
try {
return localStorage.getItem(PINNED_SECTIONS_KEY);
} catch {
return null;
}
})();
const storedPinned: SidebarSectionId[] =
pinnedRaw !== null
? (JSON.parse(pinnedRaw) as SidebarSectionId[])
storedPinnedRaw !== null
? parseStoredArray<SidebarSectionId[]>(storedPinnedRaw, [])
: (SIDEBAR_SECTIONS.filter((s) => s.defaultPinned).map((s) => s.id) as SidebarSectionId[]);
const initialPinned = new Set<SidebarSectionId>(storedPinned);
const initialExpanded = hydrateExpandedSections(storedExpanded, initialPinned);
skipInitialActiveExpansion.current = storedExpanded.length === 0;
setSkipInitialActiveExpansion(storedExpanded.length === 0);
setExpandedSections(initialExpanded);
setPinnedSections(initialPinned);
setSidebarExpansionLoaded(true);
}, []);
}
useEffect(() => {
const applySettings = (data) => {
@@ -292,38 +333,51 @@ export default function Sidebar({
? filterSidebarSectionsByQuery(visibleSections, searchQuery)
: visibleSections;
// Keep the active page visible while preserving accordion semantics for unpinned sections.
useEffect(() => {
if (collapsed || !sidebarExpansionLoaded) return;
if (skipInitialActiveExpansion.current) {
skipInitialActiveExpansion.current = false;
return;
}
for (const section of visibleSections) {
const sectionItems = section.children.flatMap((child: any) =>
child.type === "group" ? child.items : [child]
);
if (sectionItems.some((item: any) => !item.external && item.href === activeHref)) {
setExpandedSections((prev) => {
const next = expandActiveSection(pinnedSections, section.id as SidebarSectionId);
if ([...next].every((id) => prev.has(id)) && next.size === prev.size) return prev;
saveToStorage(EXPANDED_SECTIONS_KEY, [...next]);
return next;
});
break;
// Keep the active page visible while preserving accordion semantics for
// unpinned sections. Render-time adjustment (react.dev "You Might Not Need
// an Effect"): the composite key mirrors the old effect's
// [activeHref, collapsed, pinnedSections, sidebarExpansionLoaded] deps.
const activeExpansionKey = `${collapsed}|${sidebarExpansionLoaded}|${activeHref ?? ""}|${[
...pinnedSections,
]
.sort()
.join(",")}`;
const [prevActiveExpansionKey, setPrevActiveExpansionKey] = useState<string | null>(null);
if (activeExpansionKey !== prevActiveExpansionKey) {
setPrevActiveExpansionKey(activeExpansionKey);
if (!collapsed && sidebarExpansionLoaded) {
if (skipInitialActiveExpansion) {
setSkipInitialActiveExpansion(false);
} else {
for (const section of visibleSections) {
const sectionItems = section.children.flatMap((child: any) =>
child.type === "group" ? child.items : [child]
);
if (sectionItems.some((item: any) => !item.external && item.href === activeHref)) {
setExpandedSections((prev) => {
const next = expandActiveSection(pinnedSections, section.id as SidebarSectionId);
if ([...next].every((id) => prev.has(id)) && next.size === prev.size) return prev;
return next;
});
break;
}
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeHref, collapsed, pinnedSections, sidebarExpansionLoaded]);
}
// Persist the expanded-section set whenever it changes after hydration —
// single writer replacing the saveToStorage calls that used to run inside
// setState updaters (side effects belong outside updaters).
useEffect(() => {
if (!sidebarExpansionLoaded) return;
saveToStorage(EXPANDED_SECTIONS_KEY, [...expandedSections]);
}, [expandedSections, sidebarExpansionLoaded]);
// Accordion toggle: opening a section closes all non-pinned sections
const toggleSection = useCallback(
(sectionId: SidebarSectionId) => {
setExpandedSections((prev) => {
const next = toggleExpandedSection(prev, pinnedSections, sectionId);
saveToStorage(EXPANDED_SECTIONS_KEY, [...next]);
return next;
});
setExpandedSections((prev) => toggleExpandedSection(prev, pinnedSections, sectionId));
},
[pinnedSections]
);
@@ -340,7 +394,6 @@ export default function Sidebar({
if (prevExp.has(sectionId)) return prevExp;
const nextExp = new Set(prevExp);
nextExp.add(sectionId);
saveToStorage(EXPANDED_SECTIONS_KEY, [...nextExp]);
return nextExp;
});
}

View File

@@ -113,13 +113,15 @@ export default function UsageStats() {
() => sortData(stats?.byModel, stats?.pending?.byModel),
[stats?.byModel, stats?.pending?.byModel, sortData]
);
const statsByAccount = stats?.byAccount;
const statsPendingByAccount = stats?.pending?.byAccount;
const sortedAccounts = useMemo(() => {
// For accounts, pendingMap is by connectionId, but dataMap is by accountKey
// We need to map connectionId pending counts to accountKeys
const accountPendingMap: Record<string, any> = {};
if (stats?.pending?.byAccount) {
Object.entries(stats.byAccount || {}).forEach(([accountKey, data]: [string, any]) => {
const connPending = stats.pending.byAccount[data.connectionId];
if (statsPendingByAccount) {
Object.entries(statsByAccount || {}).forEach(([accountKey, data]: [string, any]) => {
const connPending = statsPendingByAccount[data.connectionId];
if (connPending) {
// Get modelKey (rawModel (provider))
const modelKey = data.provider ? `${data.rawModel} (${data.provider})` : data.rawModel;
@@ -127,11 +129,12 @@ export default function UsageStats() {
}
});
}
return sortData(stats?.byAccount, accountPendingMap);
}, [stats?.byAccount, stats?.pending?.byAccount, sortData]);
return sortData(statsByAccount, accountPendingMap);
}, [statsByAccount, statsPendingByAccount, sortData]);
// Note: no synchronous setLoading(true) here — `loading` starts as true and the
// only showLoading=true call happens on mount, so the skeleton is already up.
const fetchStats = useCallback(async (showLoading = true): Promise<void> => {
if (showLoading) setLoading(true);
try {
const res = await fetch("/api/usage/history");
if (res.ok) {
@@ -157,7 +160,10 @@ export default function UsageStats() {
}, []);
useEffect(() => {
fetchStats();
const run = async () => {
await fetchStats();
};
run();
}, [fetchStats]);
useEffect(() => {

View File

@@ -6,7 +6,7 @@
* max-lines-per-function complexity gate.
*/
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { readFetchErrorMessage } from "@/shared/utils/fetchError";
import type { ProviderDailyUsageRow } from "./RequestCountTable";
@@ -17,30 +17,34 @@ export function useProviderDailyUsage(range: string, dateFilter: string) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchRows = useCallback(async () => {
try {
setLoading(true);
const params = new URLSearchParams();
if (dateFilter) {
params.set("date", dateFilter);
} else {
params.set("range", range);
}
const res = await fetch(`/api/usage/requests-by-provider-date?${params.toString()}`);
if (!res.ok) throw new Error(await readFetchErrorMessage(res, tCommon("error")));
const data = await res.json();
setRows(Array.isArray(data.rows) ? data.rows : []);
setError(null);
} catch (err) {
setError((err as Error).message);
} finally {
setLoading(false);
}
}, [range, dateFilter, tCommon]);
useEffect(() => {
let cancelled = false;
const fetchRows = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (dateFilter) {
params.set("date", dateFilter);
} else {
params.set("range", range);
}
const res = await fetch(`/api/usage/requests-by-provider-date?${params.toString()}`);
if (!res.ok) throw new Error(await readFetchErrorMessage(res, tCommon("error")));
const data = await res.json();
if (cancelled) return;
setRows(Array.isArray(data.rows) ? data.rows : []);
setError(null);
} catch (err) {
if (!cancelled) setError((err as Error).message);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchRows();
}, [fetchRows]);
return () => {
cancelled = true;
};
}, [range, dateFilter, tCommon]);
return { rows, loading, error };
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import { useTranslations } from "next-intl";
export interface ComboCompressionModeSelectCombo {
@@ -53,9 +53,15 @@ export function ComboCompressionModeSelect({
const [compressionOverride, setCompressionOverride] = useState(initialCompressionMode);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
// Re-sync the select when the combo's persisted mode changes (render-time
// adjustment per react.dev "You Might Not Need an Effect" — replaces the old
// mirror effect that called setState synchronously).
const [prevInitialCompressionMode, setPrevInitialCompressionMode] =
useState(initialCompressionMode);
if (initialCompressionMode !== prevInitialCompressionMode) {
setPrevInitialCompressionMode(initialCompressionMode);
setCompressionOverride(initialCompressionMode);
}, [initialCompressionMode]);
}
const handleChange = async (value: string) => {
setCompressionOverride(value);

View File

@@ -265,6 +265,7 @@
"tests/unit/kimi-quota-reset-recovery.test.ts",
"tests/unit/least-used-rotation-10945.test.ts",
"tests/unit/lkgp-stale-pin-exhaustion-11911.test.ts",
"tests/unit/search-432-plan-limit-cooldown.test.ts",
"tests/unit/livews-forward-backoff-4604.test.ts",
"tests/unit/management-auth-hardening.test.ts",
"tests/unit/mark-account-unavailable-numeric-epoch-guard.test.ts",