diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index c7e6c174ba..4ac8f00716 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -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 diff --git a/src/shared/components/KiroAuthModal.tsx b/src/shared/components/KiroAuthModal.tsx index 62c8917766..47b03f035d 100644 --- a/src/shared/components/KiroAuthModal.tsx +++ b/src/shared/components/KiroAuthModal.tsx @@ -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(() => { diff --git a/src/shared/components/ModelSelectModal.tsx b/src/shared/components/ModelSelectModal.tsx index 13d241d3cd..6430825b42 100644 --- a/src/shared/components/ModelSelectModal.tsx +++ b/src/shared/components/ModelSelectModal.tsx @@ -148,64 +148,68 @@ export default function ModelSelectModal({ const [testProgress, setTestProgress] = useState<{ done: number; total: number } | null>(null); const [modelTestStatus, setModelTestStatus] = useState>({}); - 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 diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index 2a885f7573..bf6d47b57b 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -144,7 +144,9 @@ export default function OAuthModal({ const [gheUrl, setGheUrl] = useState(""); const [polling, setPolling] = useState(false); const [deviceCodeExpiresAt, setDeviceCodeExpiresAt] = useState(null); - const [deviceCodeSecondsRemaining, setDeviceCodeSecondsRemaining] = useState(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(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 diff --git a/src/shared/components/PricingModal.tsx b/src/shared/components/PricingModal.tsx index 2c02ca0564..c06260b371 100644 --- a/src/shared/components/PricingModal.tsx +++ b/src/shared/components/PricingModal.tsx @@ -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); diff --git a/src/shared/components/ProxyConfigModal.tsx b/src/shared/components/ProxyConfigModal.tsx index ae873f9c73..aec0036fd1 100644 --- a/src/shared/components/ProxyConfigModal.tsx +++ b/src/shared/components/ProxyConfigModal.tsx @@ -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) { diff --git a/src/shared/components/ReasoningRoutingRules.tsx b/src/shared/components/ReasoningRoutingRules.tsx index c087a83593..5e4df1ab80 100644 --- a/src/shared/components/ReasoningRoutingRules.tsx +++ b/src/shared/components/ReasoningRoutingRules.tsx @@ -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(() => { diff --git a/src/shared/components/RequestLoggerDetail.sections.tsx b/src/shared/components/RequestLoggerDetail.sections.tsx index 4a8d79ea6a..e7a145638d 100644 --- a/src/shared/components/RequestLoggerDetail.sections.tsx +++ b/src/shared/components/RequestLoggerDetail.sections.tsx @@ -155,9 +155,13 @@ export function ConversationContextSection({ log, detail }) { }); const turnsBoxRef = useRef(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 diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index fd91e4222c..27ab62e0d0 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -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(key: string, fallback: T): T { +function parseStoredArray(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>(new Set()); const [sidebarExpansionLoaded, setSidebarExpansionLoaded] = useState(false); - const skipInitialActiveExpansion = useRef(false); + const [skipInitialActiveExpansion, setSkipInitialActiveExpansion] = useState(false); const [hoveredItem, setHoveredItem] = useState(null); const [searchQuery, setSearchQuery] = useState(""); - // Load persisted state on mount. A stored [] intentionally means "all sections collapsed". - useEffect(() => { - const storedExpanded = loadFromStorage(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(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(storedPinnedRaw, []) : (SIDEBAR_SECTIONS.filter((s) => s.defaultPinned).map((s) => s.id) as SidebarSectionId[]); const initialPinned = new Set(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(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; }); } diff --git a/src/shared/components/UsageStats.tsx b/src/shared/components/UsageStats.tsx index d616d6f26c..c949c98241 100644 --- a/src/shared/components/UsageStats.tsx +++ b/src/shared/components/UsageStats.tsx @@ -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 = {}; - 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 => { - 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(() => { diff --git a/src/shared/components/analytics/useProviderDailyUsage.ts b/src/shared/components/analytics/useProviderDailyUsage.ts index da2a5265f3..c2277bfe49 100644 --- a/src/shared/components/analytics/useProviderDailyUsage.ts +++ b/src/shared/components/analytics/useProviderDailyUsage.ts @@ -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(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 }; } diff --git a/src/shared/components/compression/ComboCompressionModeSelect.tsx b/src/shared/components/compression/ComboCompressionModeSelect.tsx index d74e266de1..d76dd8dc8a 100644 --- a/src/shared/components/compression/ComboCompressionModeSelect.tsx +++ b/src/shared/components/compression/ComboCompressionModeSelect.tsx @@ -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); diff --git a/stryker.conf.json b/stryker.conf.json index c391cb3c9e..7d8f96d4c6 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -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",