mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 02:42:24 +03:00
* 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:
committed by
GitHub
parent
7f49b342b5
commit
bbbcc79384
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user