chore(lint): batch 2 of #12146 — resolve the react-hooks compiler violations in dashboard/providers

Resolves all 28 react-hooks/* compiler violations (24 set-state-in-effect,
4 refs) across the 18 dashboard/providers files of batch 2 and removes their
suppression entries — no eslint-disable, no new suppressions.

Techniques per file:
- Fetch-on-mount loaders (CustomModelsSection, ProviderCcAliasSection,
  ProviderInterceptionSection, ProviderParamFilterSection, page.tsx,
  useProviderConnections, useProviderSettings, CliproxyAccountHealthCard,
  DarioAccountPanel, NinerouterModelList): network/parse/error concerns
  extracted to module-level helpers returning error-as-value; the async glue is
  defined INSIDE each effect with every setState after the await. Loaders that
  handlers still need (refresh/retry buttons, exposed hook API) remain as
  callbacks; spinner flags moved into the button handlers.
- Loading flags for provider-keyed sections derived from a loadedProviderId
  marker instead of synchronous setLoading(true) resets.
- Modal init/reset effects (EditConnectionModal, EditCompatibleNodeModal,
  AddCompatibleProviderModal, VolcengineConnectModal state reset,
  useProviderUrlFilters hydration, page.tsx display-mode fallback,
  useProviderSettings per-provider flag reset): converted to render-phase
  adjustments guarded by the previously-seen prop/marker (react.dev "adjusting
  state when a prop changes").
- VolcengineConnectModal: phone prefill via localStorage lazy initializer;
  server-side session cancel + poll stop moved to the cleanup of an
  open-scoped effect reading a session ref mirror.
- ModelCompatPopover refs: render-time ref mirrors removed — headerRowsRef is
  maintained by an applyHeaderRows writer used by all handlers, paramTargetRef
  is mirrored in an effect, and blockText/allowText mirrors were already kept
  in sync by their single writer (applyParamFields).
- ModelCompatPopover state: header-row loading and value-visibility resets
  moved from [open, protocol] effects into the open/protocol/outside-click
  gesture handlers; the closed-popover rect reset was dropped (render is gated
  on open and the rect is recomputed pre-paint on reopen).
- useRiskAcknowledged: localStorage mirrored via useSyncExternalStore with a
  module-level listener set notified by acknowledgeProviderRisk.
- useProviderModels: loading for the empty-providerId case derived at the
  return site instead of a synchronous setLoading in the effect.

Validation: scoped eslint with the suppressions file passes with 0 problems;
check-dashboard-typecheck.mjs OK; node --test batch (14 files) and vitest
batch (9 files, 47 tests) green.

Refs #12146
This commit is contained in:
diegosouzapw
2026-08-30 20:33:44 -03:00
parent 8b7afc0eba
commit 1f8a53fb2d
19 changed files with 768 additions and 487 deletions

View File

@@ -1298,64 +1298,21 @@
"count": 3
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/EmptyConnectionsPlaceholder.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx": {
"react-hooks/refs": {
"count": 4
},
"react-hooks/set-state-in-effect": {
"count": 3
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/PassthroughModelsSection.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 3
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderCcAliasSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderInterceptionSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderParamFilterSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/VolcengineConnectModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/CursorAgentNudge.test.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/ImportCodexAuthModal.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -1374,57 +1331,11 @@
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderSettings.ts": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/providers/components/AddCompatibleProviderModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/hooks/useProviderModels.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/hooks/useProviderUrlFilters.ts": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/providers/hooks/useRiskAcknowledged.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/page.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 4
},
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/app/(dashboard)/dashboard/providers/services/components/CliproxyAccountHealthCard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/services/components/DarioAccountPanel.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/services/components/NinerouterModelList.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/app/(dashboard)/dashboard/providers/utils/buildCurl.ts": {

View File

@@ -91,6 +91,25 @@ function parseContextWindowOverrideInput(raw: string): { value: number | null; i
return { value: Number(trimmed), invalid: false };
}
// Fetch + parse extracted from the component so errors surface as a return
// value (logged here) instead of state writes inside catch/finally blocks —
// the load callback then only sets state after the await, which lets the
// mount effect call it without a synchronous setState.
async function fetchProviderModelsPayload(providerId: string): Promise<{
models: CompatModelRow[];
overrides: Array<CompatModelRow & { id: string }>;
} | null> {
try {
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`);
if (!res.ok) return null;
const data = await res.json();
return { models: data.models || [], overrides: data.modelCompatOverrides || [] };
} catch (e) {
console.error("Failed to fetch custom models:", e);
return null;
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -141,23 +160,28 @@ export default function CustomModelsSection({
const syncedModelIdSet = useMemo(() => new Set(syncedModelIds), [syncedModelIds]);
const fetchCustomModels = useCallback(async () => {
try {
const res = await fetch(`/api/provider-models?provider=${encodeURIComponent(providerId)}`);
if (res.ok) {
const data = await res.json();
setCustomModels(data.models || []);
setModelCompatOverrides(data.modelCompatOverrides || []);
}
} catch (e) {
console.error("Failed to fetch custom models:", e);
} finally {
setLoading(false);
const payload = await fetchProviderModelsPayload(providerId);
if (payload) {
setCustomModels(payload.models);
setModelCompatOverrides(payload.overrides);
}
setLoading(false);
}, [providerId]);
// Initial load: the async work is defined INSIDE the effect (calling the
// component-scope fetchCustomModels callback synchronously from an effect is
// rejected by the compiler rules); every setState here runs after the await.
useEffect(() => {
fetchCustomModels();
}, [fetchCustomModels]);
const run = async () => {
const payload = await fetchProviderModelsPayload(providerId);
if (payload) {
setCustomModels(payload.models);
setModelCompatOverrides(payload.overrides);
}
setLoading(false);
};
void run();
}, [providerId]);
const handleAdd = async () => {
if (!newModelId.trim() || adding) return;
@@ -540,8 +564,8 @@ export default function CustomModelsSection({
FREE
</label>
</div>
</div>
</div>
</div>
{/* List */}
{loading ? (

View File

@@ -168,8 +168,10 @@ export default function ModelCompatPopover({
width: number;
} | null>(null);
const headerRowIdRef = useRef(0);
// Mirror of headerRows, kept in sync by applyHeaderRows below (every state
// write goes through it), so blur/close commits read the freshest rows
// without touching the ref during render.
const headerRowsRef = useRef<HeaderDraftRow[]>([]);
headerRowsRef.current = headerRows;
// Param-filter drafts are mirrored into a ref so the close/unmount save path reads the
// latest typed values instead of the values captured when the handler was created (#8910).
@@ -190,13 +192,18 @@ export default function ModelCompatPopover({
providerId,
modelId,
});
paramTargetRef.current = { key: paramTargetKey, providerId, modelId };
// Mirrored in an effect (never during render): the ref is only read from
// event handlers and the save path, which run after this effect committed.
useEffect(() => {
paramTargetRef.current = { key: paramTargetKey, providerId, modelId };
}, [paramTargetKey, providerId, modelId]);
// Mirrors of the displayed text, so an edit can snapshot both fields synchronously.
// Mirrors of the displayed text, so an edit can snapshot both fields
// synchronously. applyParamFields below is the ONLY writer of
// blockText/allowText and keeps these refs in sync itself, so no render-time
// mirroring is needed (and none is allowed by the compiler's refs rule).
const blockTextRef = useRef("");
const allowTextRef = useRef("");
blockTextRef.current = blockText;
allowTextRef.current = allowText;
// Which target the values currently in the fields belong to. Guards the invariant that
// blockTextRef/allowTextRef never hold content belonging to a target other than the one being
// displayed — the desync that let one model's server values be saved under another (#8910).
@@ -273,14 +280,38 @@ export default function ModelCompatPopover({
};
}, [open, tryCommitHeaderRows]);
useEffect(() => {
if (!open) return;
const rec = getUpstreamHeadersRecord(protocol);
setHeaderRows(recordToHeaderRows(rec, genHeaderRowId));
// Only re-load rows when opening or switching protocol — not when the parent passes a new
// inline callback every render (would wipe in-progress edits).
// eslint-disable-next-line react-hooks/exhaustive-deps -- see above
}, [open, protocol]);
// Rows (re)load from the parent when the popover opens or the protocol
// switches — both user gestures — so the load lives in those handlers
// (handleToggleOpen / handleProtocolChange) instead of an effect. This keeps
// the old guarantee: a new inline parent callback on a re-render never wipes
// in-progress edits.
const applyHeaderRows = (rows: HeaderDraftRow[]) => {
headerRowsRef.current = rows;
setHeaderRows(rows);
};
const loadHeaderRowsFor = (nextProtocol: string) => {
const rec = getUpstreamHeadersRecord(nextProtocol);
applyHeaderRows(recordToHeaderRows(rec, genHeaderRowId));
};
const resetValueVisibility = () => {
setValuePeekRowId(null);
setValueFocusRowId(null);
};
const handleToggleOpen = () => {
const next = !open;
setOpen(next);
resetValueVisibility();
if (next) loadHeaderRowsFor(protocol);
};
const handleProtocolChange = (nextProtocol: string) => {
setProtocol(nextProtocol);
resetValueVisibility();
if (open) loadHeaderRowsFor(nextProtocol);
};
// Load model-level block/allow from param-filters API
useEffect(() => {
@@ -420,30 +451,23 @@ export default function ModelCompatPopover({
};
}, [open, paramTargetKey, saveModelParamFilters]);
useEffect(() => {
setValuePeekRowId(null);
setValueFocusRowId(null);
}, [open, protocol]);
const namedHeaderCount = headerRows.filter((r) => r.name.trim()).length;
const canAddHeaderRow = namedHeaderCount < UPSTREAM_HEADERS_UI_MAX;
const updateHeaderRow = (id: string, patch: Partial<Pick<HeaderDraftRow, "name" | "value">>) => {
setHeaderRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
applyHeaderRows(headerRowsRef.current.map((r) => (r.id === id ? { ...r, ...patch } : r)));
};
const addHeaderRow = () => {
if (!canAddHeaderRow) return;
setHeaderRows((prev) => [...prev, { id: genHeaderRowId(), name: "", value: "" }]);
applyHeaderRows([...headerRowsRef.current, { id: genHeaderRowId(), name: "", value: "" }]);
};
const removeHeaderRow = (id: string) => {
setHeaderRows((prev) => {
const next = prev.filter((r) => r.id !== id);
const normalized = next.length === 0 ? [{ id: genHeaderRowId(), name: "", value: "" }] : next;
queueMicrotask(() => tryCommitHeaderRows(normalized));
return normalized;
});
const next = headerRowsRef.current.filter((r) => r.id !== id);
const normalized = next.length === 0 ? [{ id: genHeaderRowId(), name: "", value: "" }] : next;
applyHeaderRows(normalized);
queueMicrotask(() => tryCommitHeaderRows(normalized));
};
useEffect(() => {
@@ -452,7 +476,11 @@ export default function ModelCompatPopover({
const target = e.target as Node;
const insideTrigger = ref.current?.contains(target);
const insidePanel = panelRef.current?.contains(target);
if (!insideTrigger && !insidePanel) setOpen(false);
if (!insideTrigger && !insidePanel) {
setOpen(false);
setValuePeekRowId(null);
setValueFocusRowId(null);
}
};
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
@@ -478,10 +506,10 @@ export default function ModelCompatPopover({
}, [open]);
useLayoutEffect(() => {
if (!open) {
setPortalPanelRect(null);
return;
}
// No rect reset on close: the portal render is gated on `open`, and
// reopening recomputes the rect below before the browser paints, so a
// stale rect is never visible.
if (!open) return;
updatePortalPanelRect();
window.addEventListener("resize", updatePortalPanelRect);
window.addEventListener("scroll", updatePortalPanelRect, true);
@@ -498,7 +526,7 @@ export default function ModelCompatPopover({
<div className="relative inline-flex" ref={ref}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
onClick={handleToggleOpen}
disabled={disabled}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-lg border border-border bg-background text-text-muted hover:bg-muted hover:text-text-main disabled:opacity-50 transition-colors"
title={t("compatAdjustmentsTitle")}
@@ -535,7 +563,7 @@ export default function ModelCompatPopover({
</label>
<select
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
onChange={(e) => handleProtocolChange(e.target.value)}
disabled={disabled}
className="mb-4 w-full rounded-lg border border-zinc-200 bg-white px-2.5 py-2 text-xs text-text-main focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-zinc-600 dark:bg-zinc-900"
>

View File

@@ -77,30 +77,46 @@ function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
// Wraps the GET so a failure comes back as a value: the load callback then only
// sets state after the await (no synchronous setState reachable from the effect).
async function fetchCcAliasStateSafe(
providerId: string
): Promise<{ ok: boolean; state?: CcAliasState; error?: string }> {
try {
return { ok: true, state: await fetchCcAliasState(providerId) };
} catch (err) {
return { ok: false, error: errorMessage(err) };
}
}
/** Loads the provider's alias settings once and reports a load failure to the operator. */
function useCcAliasData(providerId: string, t: ProviderMessageTranslator) {
const notify = useNotificationStore();
const [state, setState] = useState<CcAliasState>(DEFAULT_STATE);
const [loading, setLoading] = useState(true);
const loadState = useCallback(async () => {
setLoading(true);
try {
setState(await fetchCcAliasState(providerId));
} catch (err) {
notify.error(
providerText(t, "ccAliasLoadError", "Failed to load discovery-alias settings: {error}", {
error: errorMessage(err),
})
);
} finally {
setLoading(false);
}
}, [providerId, notify, t]);
// Loading is derived: true until a load attempt for the CURRENT provider
// settles — this also re-shows the skeleton when providerId changes.
const [loadedProviderId, setLoadedProviderId] = useState<string | null>(null);
const loading = loadedProviderId !== providerId;
// The async work is defined INSIDE the effect (a component-scope loader
// called synchronously from an effect is rejected by the compiler rules);
// every setState here runs after the await.
useEffect(() => {
loadState();
}, [loadState]);
const run = async () => {
const outcome = await fetchCcAliasStateSafe(providerId);
if (outcome.ok) {
setState(outcome.state);
} else {
notify.error(
providerText(t, "ccAliasLoadError", "Failed to load discovery-alias settings: {error}", {
error: outcome.error,
})
);
}
setLoadedProviderId(providerId);
};
void run();
}, [providerId, notify, t]);
return { state, setState, loading };
}

View File

@@ -63,26 +63,42 @@ function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
// Wraps the GET so a failure comes back as a value: the load callback then only
// sets state after the await (no synchronous setState reachable from the effect).
async function fetchInterceptionTogglesSafe(
providerId: string
): Promise<{ ok: boolean; toggles?: InterceptionToggles; error?: string }> {
try {
return { ok: true, toggles: await fetchInterceptionToggles(providerId) };
} catch (err) {
return { ok: false, error: errorMessage(err) };
}
}
function useProviderInterceptionToggles(providerId: string, t: Translate) {
const notify = useNotificationStore();
const [toggles, setToggles] = useState<InterceptionToggles>(DEFAULT_TOGGLES);
const [loading, setLoading] = useState(true);
// Loading is derived: true until a load attempt for the CURRENT provider
// settles — this also re-shows the skeleton when providerId changes.
const [loadedProviderId, setLoadedProviderId] = useState<string | null>(null);
const loading = loadedProviderId !== providerId;
const [savingKey, setSavingKey] = useState<keyof InterceptionToggles | null>(null);
const loadToggles = useCallback(async () => {
setLoading(true);
try {
setToggles(await fetchInterceptionToggles(providerId));
} catch (err) {
notify.error(t("interceptionLoadError", { error: errorMessage(err) }));
} finally {
setLoading(false);
}
}, [providerId, notify, t]);
// The async work is defined INSIDE the effect (a component-scope loader
// called synchronously from an effect is rejected by the compiler rules);
// every setState here runs after the await.
useEffect(() => {
loadToggles();
}, [loadToggles]);
const run = async () => {
const outcome = await fetchInterceptionTogglesSafe(providerId);
if (outcome.ok) {
setToggles(outcome.toggles);
} else {
notify.error(t("interceptionLoadError", { error: outcome.error }));
}
setLoadedProviderId(providerId);
};
void run();
}, [providerId, notify, t]);
const handleToggle = useCallback(
async (key: keyof InterceptionToggles, value: boolean) => {
@@ -130,9 +146,7 @@ export default function ProviderInterceptionSection({
<h2 className="text-base font-semibold text-text-main mb-1">
{t("interceptionSectionTitle")}
</h2>
<p className="text-xs text-text-muted mb-4 leading-relaxed">
{t("interceptionSectionHint")}
</p>
<p className="text-xs text-text-muted mb-4 leading-relaxed">{t("interceptionSectionHint")}</p>
<div className="flex flex-col gap-4">
<Toggle
size="sm"

View File

@@ -74,6 +74,18 @@ function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
// Wraps the GET so a failure comes back as a value: the load callback then only
// sets state after the await (no synchronous setState reachable from the effect).
async function fetchParamFilterConfigSafe(
providerId: string
): Promise<{ ok: boolean; config?: ParamFilterConfig; error?: string }> {
try {
return { ok: true, config: await fetchParamFilterConfig(providerId) };
} catch (err) {
return { ok: false, error: errorMessage(err) };
}
}
// ---------------------------------------------------------------------------
// State hook — owns config load/save/reset so the component body stays JSX-only.
// ---------------------------------------------------------------------------
@@ -95,7 +107,10 @@ function useDirtySetter<T>(setValue: (value: T) => void, setDirty: (value: boole
function useProviderParamFilterConfig(providerId: string, t: Translate) {
const notify = useNotificationStore();
const [, setConfig] = useState<ParamFilterConfig>({ block: [], allow: [], autoLearn: false });
const [loading, setLoading] = useState(true);
// Loading is derived: true until a load attempt for the CURRENT provider
// settles — this also re-shows the skeleton when providerId changes.
const [loadedProviderId, setLoadedProviderId] = useState<string | null>(null);
const loading = loadedProviderId !== providerId;
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [blockText, setBlockTextState] = useState("");
@@ -106,24 +121,24 @@ function useProviderParamFilterConfig(providerId: string, t: Translate) {
const setAllowText = useDirtySetter(setAllowTextState, setDirty);
const setAutoLearn = useDirtySetter(setAutoLearnState, setDirty);
const loadConfig = useCallback(async () => {
setLoading(true);
try {
const cfg = await fetchParamFilterConfig(providerId);
setConfig(cfg);
setBlockTextState(formatCommaList(cfg.block));
setAllowTextState(formatCommaList(cfg.allow));
setAutoLearnState(cfg.autoLearn);
} catch (err) {
notify.notify(t("paramFiltersLoadError", { error: errorMessage(err) }), "error");
} finally {
setLoading(false);
}
}, [providerId, notify, t]);
// The async work is defined INSIDE the effect (a component-scope loader
// called synchronously from an effect is rejected by the compiler rules);
// every setState here runs after the await.
useEffect(() => {
loadConfig();
}, [loadConfig]);
const run = async () => {
const outcome = await fetchParamFilterConfigSafe(providerId);
if (outcome.ok) {
setConfig(outcome.config);
setBlockTextState(formatCommaList(outcome.config.block));
setAllowTextState(formatCommaList(outcome.config.allow));
setAutoLearnState(outcome.config.autoLearn);
} else {
notify.notify(t("paramFiltersLoadError", { error: outcome.error }), "error");
}
setLoadedProviderId(providerId);
};
void run();
}, [providerId, notify, t]);
const handleSave = useCallback(async () => {
setSaving(true);

View File

@@ -88,7 +88,16 @@ export default function VolcengineConnectModal({
notify,
t,
}: VolcengineConnectModalProps) {
const [phone, setPhone] = useState("");
// Prefilled from the last successful login via a lazy initializer — reading
// localStorage inside the open effect required a synchronous setState there.
const [phone, setPhone] = useState(() => {
if (typeof window === "undefined") return "";
try {
return localStorage.getItem(PHONE_STORAGE_KEY) ?? "";
} catch {
return "";
}
});
const [code, setCode] = useState("");
const [captcha, setCaptcha] = useState("");
const [session, setSession] = useState<SessionView | null>(null);
@@ -99,6 +108,12 @@ export default function VolcengineConnectModal({
const [resendCountdown, setResendCountdown] = useState(0);
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
// Latest session, mirrored for the close/unmount cleanup below — that effect
// only depends on isOpen, so reading the state directly would be stale.
const sessionRef = useRef<SessionView | null>(null);
useEffect(() => {
sessionRef.current = session;
}, [session]);
// ── lifecycle ────────────────────────────────────────────────────────────
@@ -117,22 +132,35 @@ export default function VolcengineConnectModal({
setResendCountdown(0);
}, [stopTimers]);
// Leaving the modal cancels an in-flight session server-side and stops
// polling. Runs as the cleanup of this open-scoped effect (no setState here).
useEffect(() => {
if (!isOpen) {
// Leaving the modal cancels an in-flight session server-side.
const active = session && !isTerminal(session.phase) ? session : null;
if (!isOpen) return;
return () => {
const current = sessionRef.current;
const active = current && !isTerminal(current.phase) ? current : null;
if (active) {
void fetch(`/api/providers/volcengine-plan/connect/${active.sessionId}/cancel`, {
method: "POST",
}).catch(() => {});
}
reset();
return;
stopTimers();
};
}, [isOpen, stopTimers]);
// Local state reset on close — a render-phase adjustment guarded by the
// previous isOpen value (react.dev "adjusting state when a prop changes")
// instead of a synchronous setState inside an effect.
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen);
if (!isOpen) {
setSession(null);
setCode("");
setCaptcha("");
setResendCountdown(0);
}
const saved = typeof window !== "undefined" ? localStorage.getItem(PHONE_STORAGE_KEY) : null;
if (saved) setPhone(saved);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
}
useEffect(() => stopTimers, [stopTimers]);

View File

@@ -1,5 +1,5 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Badge, Input, Modal, Select, Toggle } from "@/shared/components";
import { isValidProviderIconUrl } from "@/shared/validation/iconUrl";
@@ -62,8 +62,23 @@ export default function EditCompatibleNodeModal({
const [iconUrlError, setIconUrlError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
if (isOpen && node) {
// Modal-open form initialization from the node being edited — applied as a
// render-phase adjustment guarded by the previously initialized node
// (react.dev "adjusting state when a prop changes") instead of a
// synchronous-setState effect. Closing clears the marker so the next open
// re-initializes again.
const [initializedFor, setInitializedFor] = useState<{
node: EditCompatibleNodeModalNode;
isAnthropic?: boolean;
isCcCompatible?: boolean;
} | null>(null);
if (isOpen && node) {
if (
initializedFor?.node !== node ||
initializedFor.isAnthropic !== isAnthropic ||
initializedFor.isCcCompatible !== isCcCompatible
) {
setInitializedFor({ node, isAnthropic, isCcCompatible });
const psd = (node.providerSpecificData || {}) as Record<string, unknown>;
setFormData({
name: node.name || "",
@@ -94,7 +109,9 @@ export default function EditCompatibleNodeModal({
)
);
}
}, [isOpen, node, isAnthropic, isCcCompatible]);
} else if (initializedFor !== null) {
setInitializedFor(null);
}
const apiTypeOptions = [
{ value: "chat", label: t("chatCompletions") },

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Badge, Input, Modal, Toggle, Select } from "@/shared/components";
import {
@@ -262,8 +262,19 @@ export default function EditConnectionModal({
: apiKeyOptional
? t("apiKeyOptionalHint")
: t("leaveBlankKeepCurrentApiKey");
useEffect(() => {
if (isOpen && connection) {
// Modal-open form initialization from the loaded connection — applied as a
// render-phase adjustment guarded by the previously initialized connection
// (react.dev "adjusting state when a prop changes") instead of the former
// synchronous-setState effect. Remounting the 30+ field form per connection
// id stays out of scope (#11251 follow-up, #9985); closing clears the marker
// so the next open re-initializes again.
const [initializedFor, setInitializedFor] = useState<{
connection: EditConnectionModalConnection;
providerId: string;
} | null>(null);
if (isOpen && connection) {
if (initializedFor?.connection !== connection || initializedFor.providerId !== providerId) {
setInitializedFor({ connection, providerId });
const effectiveProvider = connection.provider || providerId;
const existingBaseUrl = stringField(connection.providerSpecificData?.baseUrl);
const existingTargetFormat = stringField(connection.providerSpecificData?.targetFormat);
@@ -293,13 +304,6 @@ export default function EditConnectionModal({
connection.providerSpecificData?.quotaPerUnit != null
? String(connection.providerSpecificData.quotaPerUnit)
: "";
// Modal-open form initialization from the loaded connection (sync with an
// external system on `isOpen`); remounting the 30+ field form per
// connection id is a behavior-risking restructure out of scope here
// (#11251 follow-up, #9985).
// NOTE: no react-hooks/set-state-in-effect suppression needed — the rule
// only fires on unconditional synchronous setState, and this one is
// guarded by the isOpen/connection condition above.
setFormData({
name: connection.name || "",
priority: connection.priority || 1,
@@ -437,15 +441,9 @@ export default function EditConnectionModal({
setValidatedProviderSpecificData(undefined);
setSaveError(null);
}
}, [
isOpen,
connection,
providerId,
defaultBaseUrl,
showsRegion,
defaultRegion,
setOpenRouterPreset,
]);
} else if (initializedFor !== null) {
setInitializedFor(null);
}
const handleTest = async () => {
if (!provider) return;
setTesting(true);

View File

@@ -42,6 +42,95 @@ import {
const MAX_BULK_IDS = 100;
const PAGE_SIZE = 50;
// ──── module-level fetch helpers ────────────────────────────────────────────
// The network/parse/retry concerns live outside the hook so the callbacks
// below only set state after the await — the mount effect can then call them
// without a synchronous setState (errors come back as values, not as state
// writes inside catch/finally blocks).
interface ProviderConnectionsFetchResult {
connections: ConnectionRowConnection[] | null;
node: any;
nodeResolved: boolean;
}
async function loadProviderConnectionsData(
providerId: string,
isCompatible: boolean
): Promise<ProviderConnectionsFetchResult | null> {
try {
const connectionsUrl = getProviderConnectionsRequestUrl(providerId);
const [connectionsRes, nodesRes] = await Promise.all([
fetch(connectionsUrl, { cache: "no-store" }),
fetch("/api/provider-nodes", { cache: "no-store" }),
]);
const connectionsData = await connectionsRes.json();
const nodesData = await nodesRes.json();
const connections = connectionsRes.ok
? (connectionsData.connections || []).filter((c: any) =>
connectionBelongsToProviderPage(c.provider, providerId)
)
: null;
let node = null;
let nodeResolved = false;
if (nodesRes.ok) {
nodeResolved = true;
node = (nodesData.nodes || []).find((entry: any) => entry.id === providerId) || null;
// Newly created compatible nodes can be briefly unavailable on one worker.
if (!node && isCompatible) {
for (let attempt = 0; attempt < 3; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 150));
const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" });
if (!retryRes.ok) continue;
const retryData = await retryRes.json();
node = (retryData.nodes || []).find((entry: any) => entry.id === providerId) || null;
if (node) break;
}
}
}
return { connections, node, nodeResolved };
} catch (error) {
console.log("Error fetching connections:", error);
return null;
}
}
async function loadProxyConfigData(): Promise<{ config: any } | null> {
try {
const res = await fetch("/api/settings/proxy", { cache: "no-store" });
if (res.ok) return { config: await res.json() };
return { config: null };
} catch {
// Proxy indicators are best-effort — keep whatever is currently shown.
return null;
}
}
async function resolveConnectionProxies(
conns: { id?: string }[]
): Promise<Record<string, { proxy: any; level: string } | null> | null> {
try {
const results = await Promise.all(
conns
.filter((c) => c.id)
.map((c) =>
fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((data) => [c.id!, data] as [string, any])
.catch(() => [c.id!, null] as [string, any])
)
);
const map: Record<string, { proxy: any; level: string } | null> = {};
for (const [id, data] of results) {
map[id] = data?.proxy ? data : null;
}
return map;
} catch {
return null;
}
}
// ──── types ─────────────────────────────────────────────────────────────────
/**
@@ -210,93 +299,50 @@ export function useProviderConnections(
// ────────────────────────────────────────────────────────────────────────
const fetchProxyConfig = useCallback(async () => {
try {
const res = await fetch("/api/settings/proxy", { cache: "no-store" });
if (res.ok) {
setProxyConfig(await res.json());
} else {
setProxyConfig(null);
}
} catch {
// Proxy indicators are best-effort.
}
const result = await loadProxyConfigData();
if (result) setProxyConfig(result.config);
}, []);
const fetchConnections = useCallback(async () => {
try {
const connectionsUrl = getProviderConnectionsRequestUrl(providerId);
const [connectionsRes, nodesRes] = await Promise.all([
fetch(connectionsUrl, { cache: "no-store" }),
fetch("/api/provider-nodes", { cache: "no-store" }),
]);
const connectionsData = await connectionsRes.json();
const nodesData = await nodesRes.json();
if (connectionsRes.ok) {
const filtered = (connectionsData.connections || []).filter((c: any) =>
connectionBelongsToProviderPage(c.provider, providerId)
);
setConnections(filtered);
}
if (nodesRes.ok) {
let node = (nodesData.nodes || []).find((entry: any) => entry.id === providerId) || null;
// Newly created compatible nodes can be briefly unavailable on one worker.
if (!node && isCompatible) {
for (let attempt = 0; attempt < 3; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 150));
const retryRes = await fetch("/api/provider-nodes", { cache: "no-store" });
if (!retryRes.ok) continue;
const retryData = await retryRes.json();
node = (retryData.nodes || []).find((entry: any) => entry.id === providerId) || null;
if (node) break;
}
}
setProviderNode(node);
}
} catch (error) {
console.log("Error fetching connections:", error);
} finally {
setLoading(false);
const result = await loadProviderConnectionsData(providerId, isCompatible);
if (result) {
if (result.connections) setConnections(result.connections);
if (result.nodeResolved) setProviderNode(result.node);
}
setLoading(false);
}, [providerId, isCompatible]);
const loadConnProxies = useCallback(async (conns: { id?: string }[]) => {
if (!conns.length) return;
try {
const results = await Promise.all(
conns
.filter((c) => c.id)
.map((c) =>
fetch(`/api/settings/proxy?resolve=${encodeURIComponent(c.id!)}`, { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((data) => [c.id!, data] as [string, any])
.catch(() => [c.id!, null] as [string, any])
)
);
const map: Record<string, { proxy: any; level: string } | null> = {};
for (const [id, data] of results) {
map[id] = data?.proxy ? data : null;
}
setConnProxyMap(map);
} catch {
// ignore
}
}, []);
// ── effects ──────────────────────────────────────────────────────────────
// The async work is defined INSIDE each effect (a component-scope loader
// called synchronously from an effect is rejected by the compiler rules);
// every setState below runs after an await.
useEffect(() => {
fetchConnections();
void fetchProxyConfig();
}, [fetchConnections, fetchProxyConfig]);
const run = async () => {
const result = await loadProviderConnectionsData(providerId, isCompatible);
if (result) {
if (result.connections) setConnections(result.connections);
if (result.nodeResolved) setProviderNode(result.node);
}
setLoading(false);
};
void run();
const runProxyConfig = async () => {
const result = await loadProxyConfigData();
if (result) setProxyConfig(result.config);
};
void runProxyConfig();
}, [providerId, isCompatible]);
// Per-connection proxy (handles registry assignments)
useEffect(() => {
if (!loading && connections.length > 0) {
void loadConnProxies(connections);
}
}, [loading, connections, loadConnProxies]);
if (loading || connections.length === 0) return;
const run = async () => {
const map = await resolveConnectionProxies(connections);
if (map) setConnProxyMap(map);
};
void run();
}, [loading, connections]);
// Upstream proxy routing config (native / CLIProxyAPI / Dario / fallback)
useEffect(() => {
@@ -546,7 +592,11 @@ export function useProviderConnections(
const data = await res.json().catch(() => ({}));
notify.error(
data.error ||
providerText(t, "failedUpdateCliproxyRouting", "Failed to update upstream proxy routing")
providerText(
t,
"failedUpdateCliproxyRouting",
"Failed to update upstream proxy routing"
)
);
return;
}

View File

@@ -26,6 +26,32 @@ import {
providerText,
} from "../providerPageHelpers";
// Shared /api/settings fetch with error-as-value semantics so the loaders
// below only touch state after the await (no synchronous setState reachable
// from the load effects).
async function fetchSettingsPayload(): Promise<{
ok: boolean;
data?: Record<string, unknown>;
message?: string;
}> {
try {
const response = await fetch("/api/settings", { cache: "no-store" });
if (!response.ok) {
throw new Error(`Settings request failed with HTTP ${response.status}`);
}
const data = await response.json();
if (!data || typeof data !== "object") {
throw new Error("Settings response was empty");
}
return { ok: true, data };
} catch (error) {
return {
ok: false,
message: error instanceof Error ? error.message : "Failed to load settings",
};
}
}
// ──── types ─────────────────────────────────────────────────────────────────
export interface UseProviderSettingsReturn {
@@ -73,6 +99,19 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
>(null);
const [savingClaudeRoutingPreference, setSavingClaudeRoutingPreference] = useState(false);
// Reset the per-provider load flags when the provider changes — a
// render-phase adjustment guarded by the previous providerId (react.dev
// "adjusting state when a prop changes"), replacing the synchronous resets
// that used to run inside the load effects.
const [settingsProviderId, setSettingsProviderId] = useState(providerId);
if (settingsProviderId !== providerId) {
setSettingsProviderId(providerId);
setCodexSettingsLoaded(false);
setCodexSettingsLoadError(null);
setClaudeRoutingSettingsLoaded(false);
setClaudeRoutingSettingsLoadError(null);
}
// ── derived ──────────────────────────────────────────────────────────────
const codexGlobalServiceModeOptions = useMemo(
() =>
@@ -89,75 +128,86 @@ export function useProviderSettings(providerId: string): UseProviderSettingsRetu
codexSettingsRequestSeqRef.current = requestSeq;
const isCurrentRequest = () => codexSettingsRequestSeqRef.current === requestSeq;
if (providerId !== "codex") {
// Non-codex providers keep the initial false/null flags (also restored by
// the render-phase reset above when providerId changes).
if (providerId !== "codex") return;
const outcome = await fetchSettingsPayload();
if (!isCurrentRequest()) return;
if (!outcome.ok) {
setCodexSettingsLoaded(false);
setCodexSettingsLoadError(null);
setCodexSettingsLoadError(outcome.message);
return;
}
setCodexSettingsLoaded(false);
const resolvedCodexServiceTier = resolveCodexGlobalFastServiceTier(outcome.data);
setCodexGlobalServiceMode(getCodexGlobalServiceMode(outcome.data));
setCodexGlobalSupportedModels([...resolvedCodexServiceTier.supportedModels]);
setCodexSettingsLoadError(null);
try {
const response = await fetch("/api/settings", { cache: "no-store" });
if (!response.ok) {
throw new Error(`Settings request failed with HTTP ${response.status}`);
}
const data = await response.json();
if (!data || typeof data !== "object") {
throw new Error("Settings response was empty");
}
if (!isCurrentRequest()) return;
const resolvedCodexServiceTier = resolveCodexGlobalFastServiceTier(data);
setCodexGlobalServiceMode(getCodexGlobalServiceMode(data));
setCodexGlobalSupportedModels([...resolvedCodexServiceTier.supportedModels]);
setCodexSettingsLoaded(true);
} catch (error) {
if (!isCurrentRequest()) return;
setCodexSettingsLoaded(false);
setCodexSettingsLoadError(error instanceof Error ? error.message : "Failed to load settings");
}
setCodexSettingsLoaded(true);
}, [providerId]);
// The async work is duplicated INSIDE the effect (calling the exposed
// loadCodexSettings callback synchronously from an effect is rejected by the
// compiler rules); every setState here runs after the await.
useEffect(() => {
void loadCodexSettings();
}, [loadCodexSettings]);
if (providerId !== "codex") return;
const requestSeq = codexSettingsRequestSeqRef.current + 1;
codexSettingsRequestSeqRef.current = requestSeq;
const isCurrentRequest = () => codexSettingsRequestSeqRef.current === requestSeq;
const run = async () => {
const outcome = await fetchSettingsPayload();
if (!isCurrentRequest()) return;
if (!outcome.ok) {
setCodexSettingsLoaded(false);
setCodexSettingsLoadError(outcome.message);
return;
}
const resolvedCodexServiceTier = resolveCodexGlobalFastServiceTier(outcome.data);
setCodexGlobalServiceMode(getCodexGlobalServiceMode(outcome.data));
setCodexGlobalSupportedModels([...resolvedCodexServiceTier.supportedModels]);
setCodexSettingsLoadError(null);
setCodexSettingsLoaded(true);
};
void run();
}, [providerId]);
// ── Claude routing settings loader ───────────────────────────────────────
const loadClaudeRoutingSettings = useCallback(async () => {
if (providerId !== "claude") {
// Non-claude providers keep the initial false/null flags (also restored by
// the render-phase reset above when providerId changes).
if (providerId !== "claude") return;
const outcome = await fetchSettingsPayload();
if (!outcome.ok) {
setClaudeRoutingSettingsLoaded(false);
setClaudeRoutingSettingsLoadError(null);
setClaudeRoutingSettingsLoadError(outcome.message);
return;
}
setClaudeRoutingSettingsLoaded(false);
setPreferClaudeCodeForUnprefixedClaudeModels(
outcome.data.preferClaudeCodeForUnprefixedClaudeModels === true
);
setClaudeRoutingSettingsLoadError(null);
try {
const response = await fetch("/api/settings", { cache: "no-store" });
if (!response.ok) {
throw new Error(`Settings request failed with HTTP ${response.status}`);
}
const data = await response.json();
if (!data || typeof data !== "object") {
throw new Error("Settings response was empty");
}
setPreferClaudeCodeForUnprefixedClaudeModels(
data.preferClaudeCodeForUnprefixedClaudeModels === true
);
setClaudeRoutingSettingsLoaded(true);
} catch (error) {
setClaudeRoutingSettingsLoaded(false);
setClaudeRoutingSettingsLoadError(
error instanceof Error ? error.message : "Failed to load settings"
);
}
setClaudeRoutingSettingsLoaded(true);
}, [providerId]);
// Same inline-in-effect shape as the codex loader above.
useEffect(() => {
void loadClaudeRoutingSettings();
}, [loadClaudeRoutingSettings]);
if (providerId !== "claude") return;
const run = async () => {
const outcome = await fetchSettingsPayload();
if (!outcome.ok) {
setClaudeRoutingSettingsLoaded(false);
setClaudeRoutingSettingsLoadError(outcome.message);
return;
}
setPreferClaudeCodeForUnprefixedClaudeModels(
outcome.data.preferClaudeCodeForUnprefixedClaudeModels === true
);
setClaudeRoutingSettingsLoadError(null);
setClaudeRoutingSettingsLoaded(true);
};
void run();
}, [providerId]);
// ── Codex service mode handler ───────────────────────────────────────────
const handleChangeCodexGlobalServiceMode = async (mode: CodexGlobalServiceMode) => {

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Badge, Button, Input, Modal, Select, Toggle } from "@/shared/components";
@@ -133,15 +133,25 @@ export default function AddCompatibleProviderModal({
[t]
);
useEffect(() => {
if (!isOpen) return;
setFormData(createInitialForm(mode));
setValidationResult(null);
setCheckKey("");
setShowAdvanced(false);
setSaveError(null);
setIconUrlError(null);
}, [isOpen, mode]);
// Fresh form on every open (and on a mode switch while open) — applied as a
// render-phase adjustment guarded by the previously initialized mode
// (react.dev "adjusting state when a prop changes") instead of a
// synchronous-setState effect. Closing clears the marker so the next open
// re-initializes again.
const [initializedFor, setInitializedFor] = useState<{ mode: CompatibleMode } | null>(null);
if (isOpen) {
if (initializedFor?.mode !== mode) {
setInitializedFor({ mode });
setFormData(createInitialForm(mode));
setValidationResult(null);
setCheckKey("");
setShowAdvanced(false);
setSaveError(null);
setIconUrlError(null);
}
} else if (initializedFor !== null) {
setInitializedFor(null);
}
const modalTitle =
title ||

View File

@@ -133,10 +133,7 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
}, [providerId, t]);
useEffect(() => {
if (!providerId) {
setLoading(false);
return;
}
if (!providerId) return;
return load();
}, [providerId, load]);
@@ -152,5 +149,7 @@ export function useProviderModels(providerId: string): UseProviderModelsResult {
load();
}, [providerId, load]);
return { models, loading, error, retry };
// Without a providerId nothing ever loads, so the exposed loading flag is
// derived instead of being reset synchronously inside the effect above.
return { models, loading: providerId ? loading : false, error, retry };
}

View File

@@ -51,26 +51,32 @@ export function useProviderUrlFilters({
activeServiceKind,
setActiveServiceKind,
}: UseProviderUrlFiltersArgs): { displayModePreferenceReady: boolean } {
const [displayModePreferenceReady, setDisplayModePreferenceReady] = useState(false);
const [filtersHydrated, setFiltersHydrated] = useState(false);
// Snapshot of the stored display-mode preference, read once via a lazy
// initializer (localStorage must not be read during render). After the first
// hydration the URL always carries the mode, so the fallback is mount-only.
const [storedDisplayModePreference] = useState<ProviderDisplayMode>(() =>
readProviderDisplayModePreference()
);
const [hydratedFromParams, setHydratedFromParams] = useState<ReadonlyURLSearchParams | null>(
null
);
useEffect(() => {
const urlMode = readProviderFiltersFromUrl(searchParams).displayMode;
setProviderDisplayMode(urlMode ?? readProviderDisplayModePreference());
setDisplayModePreferenceReady(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
useEffect(() => {
// URL → state hydration as a render-phase adjustment guarded by the
// previously hydrated params object (react.dev "adjusting state when a prop
// changes") — replaces the two synchronous setState effects keyed on
// searchParams, and removes the transient default-state first paint.
if (hydratedFromParams !== searchParams) {
setHydratedFromParams(searchParams);
const urlFilters = readProviderFiltersFromUrl(searchParams);
setProviderDisplayMode(urlFilters.displayMode ?? storedDisplayModePreference);
setSearchQuery(urlFilters.searchQuery ?? "");
setModelSearchQuery(urlFilters.modelSearchQuery ?? "");
setActiveCategory(urlFilters.category ?? null);
setShowFreeOnly(urlFilters.showFreeOnly ?? false);
setActiveServiceKind(urlFilters.mediaKind ?? null);
setFiltersHydrated(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
}
const displayModePreferenceReady = hydratedFromParams !== null;
const filtersHydrated = displayModePreferenceReady;
useEffect(() => {
if (!filtersHydrated || !displayModePreferenceReady) return;

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useSyncExternalStore } from "react";
export const RISK_ACKNOWLEDGED_STORAGE_KEY = "omniroute-risk-acknowledged";
@@ -53,22 +53,38 @@ export function isRiskAcknowledged(providerId: string): boolean {
return readRiskAcknowledgedMap()[providerId] === true;
}
// localStorage is a mutable external store, so the hook below subscribes to it
// through useSyncExternalStore instead of mirroring it into component state
// with an effect (which required a synchronous setState on providerId change).
const riskAcknowledgedListeners = new Set<() => void>();
function subscribeToRiskAcknowledged(listener: () => void): () => void {
riskAcknowledgedListeners.add(listener);
return () => {
riskAcknowledgedListeners.delete(listener);
};
}
function emitRiskAcknowledgedChange(): void {
for (const listener of riskAcknowledgedListeners) listener();
}
export function acknowledgeProviderRisk(providerId: string): void {
const map = readRiskAcknowledgedMap();
map[providerId] = true;
writeRiskAcknowledgedMap(map);
emitRiskAcknowledgedChange();
}
export function useRiskAcknowledged(providerId: string) {
const [acknowledged, setAcknowledged] = useState(() => isRiskAcknowledged(providerId));
useEffect(() => {
setAcknowledged(isRiskAcknowledged(providerId));
}, [providerId]);
const acknowledged = useSyncExternalStore(
subscribeToRiskAcknowledged,
() => isRiskAcknowledged(providerId),
() => false
);
const acknowledge = useCallback(() => {
acknowledgeProviderRisk(providerId);
setAcknowledged(true);
}, [providerId]);
return { acknowledged, acknowledge };

View File

@@ -54,9 +54,7 @@ const AddCompatibleProviderModal = dynamic(
import { CategoryDot } from "./components/CategoryDot";
const ImportProvidersFromFileModal = dynamic(
() =>
import("./components/ImportProvidersFromFileModal").then(
(m) => m.ImportProvidersFromFileModal
),
import("./components/ImportProvidersFromFileModal").then((m) => m.ImportProvidersFromFileModal),
{ ssr: false }
);
import NoAuthProvidersSection from "./components/NoAuthProvidersSection";
@@ -192,6 +190,27 @@ function getConnectionErrorTag(connection, t: ProviderMessageTranslator) {
return "ERR";
}
// OAuth-env repair status fetch, extracted so the callback below only sets
// state after the await (errors come back as `null` instead of a setState
// inside the catch block, which the react-hooks compiler rules reject when the
// callback is invoked from an effect).
async function loadOauthEnvRepairStatus(): Promise<{
available: boolean;
missingCount: number;
} | null> {
try {
const res = await fetch("/api/system/env/repair", { cache: "no-store" });
const data = await res.json();
if (!res.ok) return null;
return {
available: Boolean(data.available),
missingCount: Number(data.missingCount || 0),
};
} catch {
return null;
}
}
export default function ProvidersPage() {
const router = useRouter();
const [connections, setConnections] = useState<any[]>([]);
@@ -297,33 +316,30 @@ export default function ProvidersPage() {
writeProviderDisplayModePreference(storedDisplayMode);
}, [connections.length, displayModePreferenceReady, providerDisplayMode, loading]);
useEffect(() => {
if (!shouldSyncProviderDisplayMode(displayModePreferenceReady, loading)) return;
if (connections.length === 0 && providerDisplayMode === "configured") {
setProviderDisplayMode("all");
}
}, [connections.length, displayModePreferenceReady, providerDisplayMode, loading]);
// "No connections → fall back to the 'all' view" is a state adjustment
// derived from other state, applied during render (self-invalidating guard,
// converges in one extra pass) instead of a synchronous setState effect.
if (
shouldSyncProviderDisplayMode(displayModePreferenceReady, loading) &&
connections.length === 0 &&
providerDisplayMode === "configured"
) {
setProviderDisplayMode("all");
}
const fetchOauthEnvRepairStatus = useCallback(async () => {
try {
const res = await fetch("/api/system/env/repair", { cache: "no-store" });
const data = await res.json();
if (res.ok) {
setOauthEnvRepairStatus({
available: Boolean(data.available),
missingCount: Number(data.missingCount || 0),
});
} else {
setOauthEnvRepairStatus(null);
}
} catch {
setOauthEnvRepairStatus(null);
}
setOauthEnvRepairStatus(await loadOauthEnvRepairStatus());
}, []);
// Inline-in-effect (calling the component-scope callback synchronously from
// an effect is rejected by the compiler rules); setState runs after the await.
useEffect(() => {
void fetchOauthEnvRepairStatus();
}, [fetchOauthEnvRepairStatus]);
const run = async () => {
const status = await loadOauthEnvRepairStatus();
setOauthEnvRepairStatus(status);
};
void run();
}, []);
const handleRepairEnv = async () => {
if (!oauthEnvRepairStatus?.available || repairingEnv) return;

View File

@@ -18,7 +18,11 @@ const STATE_LABELS: Record<CliproxyAccountHealthResult["state"], string> = {
};
function AccountRow({ account }: { account: CliproxyAccountHealth }) {
const state = account.disabled ? "Disabled" : account.unavailable ? "Unavailable" : account.status;
const state = account.disabled
? "Disabled"
: account.unavailable
? "Unavailable"
: account.status;
return (
<li className="flex flex-wrap items-center justify-between gap-3 border-t border-border py-3 first:border-t-0">
<div className="min-w-0">
@@ -44,33 +48,50 @@ function AccountRow({ account }: { account: CliproxyAccountHealth }) {
);
}
// Network + parse concerns live outside the component so the load callback only
// sets state after the await (no synchronous setState reachable from the effect).
async function fetchAccountHealth(): Promise<CliproxyAccountHealthResult> {
try {
const response = await fetch("/api/services/cliproxy/accounts", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch {
return { state: "unreachable", accounts: [], version: null };
}
}
export function CliproxyAccountHealthCard() {
const [result, setResult] = useState<CliproxyAccountHealthResult | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
const response = await fetch("/api/services/cliproxy/accounts", { cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
setResult(await response.json());
} catch {
setResult({ state: "unreachable", accounts: [], version: null });
} finally {
setLoading(false);
}
const next = await fetchAccountHealth();
setResult(next);
setLoading(false);
}, []);
// Inline-in-effect (calling the component-scope `load` callback synchronously
// from an effect is rejected by the compiler rules); setState is post-await.
useEffect(() => {
const run = async () => {
const next = await fetchAccountHealth();
setResult(next);
setLoading(false);
};
void run();
}, []);
const handleRefresh = () => {
setLoading(true);
void load();
}, [load]);
};
return (
<Card
title="CLIProxyAPI accounts"
subtitle="Read-only status from the authenticated management API"
action={
<Button variant="secondary" size="sm" onClick={() => void load()} loading={loading}>
<Button variant="secondary" size="sm" onClick={handleRefresh} loading={loading}>
Refresh
</Button>
}
@@ -87,7 +108,9 @@ export function CliproxyAccountHealthCard() {
)
) : (
<p className="text-sm text-text-muted">
{loading && !result ? "Loading account health…" : STATE_LABELS[result?.state ?? "unreachable"]}
{loading && !result
? "Loading account health…"
: STATE_LABELS[result?.state ?? "unreachable"]}
</p>
)}
</Card>

View File

@@ -65,9 +65,43 @@ function formatExpiry(acc: DarioAccount): string {
return "";
}
// Network + parse concerns extracted so the refresh callbacks only set state
// after the await — the mount effect can then call them without a synchronous
// setState (errors come back as values instead of catch-block state writes).
async function fetchDarioAccounts(): Promise<{ accounts: DarioAccount[] } | { error: string }> {
try {
const res = await fetch("/api/services/dario/admin/accounts");
const json = (await res.json().catch(() => null)) as {
accounts?: DarioAccount[];
error?: string;
} | null;
if (!res.ok) {
throw new Error(json?.error || `HTTP ${res.status}`);
}
return { accounts: Array.isArray(json?.accounts) ? json!.accounts : [] };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
async function fetchOmniConnectionsList(): Promise<OmniConnection[] | null> {
try {
const res = await fetch("/api/services/dario/admin/import-from-omniroute");
const json = (await res.json().catch(() => null)) as {
connections?: OmniConnection[];
error?: string;
} | null;
if (!res.ok) return null;
return Array.isArray(json?.connections) ? json!.connections : [];
} catch {
/* non-fatal — import section just stays empty */
return null;
}
}
export function DarioAccountPanel() {
const [accounts, setAccounts] = useState<DarioAccount[]>([]);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState<PendingLogin | null>(null);
@@ -77,51 +111,48 @@ export function DarioAccountPanel() {
const [notice, setNotice] = useState<string | null>(null);
const [omniConnections, setOmniConnections] = useState<OmniConnection[]>([]);
const [omniLoading, setOmniLoading] = useState(false);
const [omniLoading, setOmniLoading] = useState(true);
const [importBusyId, setImportBusyId] = useState<string | null>(null);
const refreshAccounts = useCallback(async () => {
const outcome = await fetchDarioAccounts();
if ("accounts" in outcome) {
setAccounts(outcome.accounts);
setError(null);
} else {
setError(outcome.error);
}
setLoading(false);
}, []);
// Inline-in-effect (calling the component-scope refresh callbacks
// synchronously from an effect is rejected by the compiler rules); every
// setState here runs after an await.
useEffect(() => {
const run = async () => {
const outcome = await fetchDarioAccounts();
if ("accounts" in outcome) {
setAccounts(outcome.accounts);
setError(null);
} else {
setError(outcome.error);
}
setLoading(false);
};
void run();
const runOmni = async () => {
const list = await fetchOmniConnectionsList();
if (list) setOmniConnections(list);
setOmniLoading(false);
};
void runOmni();
}, []);
const handleRefreshAccountsClick = () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/services/dario/admin/accounts");
const json = (await res.json().catch(() => null)) as {
accounts?: DarioAccount[];
error?: string;
} | null;
if (!res.ok) {
throw new Error(json?.error || `HTTP ${res.status}`);
}
setAccounts(Array.isArray(json?.accounts) ? json!.accounts : []);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
const refreshOmniConnections = useCallback(async () => {
setOmniLoading(true);
try {
const res = await fetch("/api/services/dario/admin/import-from-omniroute");
const json = (await res.json().catch(() => null)) as {
connections?: OmniConnection[];
error?: string;
} | null;
if (res.ok) {
setOmniConnections(Array.isArray(json?.connections) ? json!.connections : []);
}
} catch {
/* non-fatal — import section just stays empty */
} finally {
setOmniLoading(false);
}
}, []);
useEffect(() => {
void refreshAccounts();
void refreshOmniConnections();
}, [refreshAccounts, refreshOmniConnections]);
};
async function importFromOmniroute(connectionId: string) {
setImportBusyId(connectionId);
@@ -344,7 +375,7 @@ export function DarioAccountPanel() {
size="sm"
variant="outline"
disabled={loading}
onClick={() => void refreshAccounts()}
onClick={handleRefreshAccountsClick}
>
Refresh
</Button>

View File

@@ -32,6 +32,22 @@ export function paginateModels(
return models.slice(start, start + pageSize);
}
// Fetch + parse extracted from the component so errors surface as a return
// value instead of state mutations inside catch/finally blocks.
async function fetchServiceModels(
refresh: boolean
): Promise<{ ok: boolean; data?: ServiceModel[]; message?: string | null }> {
try {
const url = `/api/services/${NAME}/models${refresh ? "?refresh=true" : ""}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
return { ok: true, data: Array.isArray(body?.data) ? body.data : [] };
} catch (err) {
return { ok: false, message: err instanceof Error ? err.message : null };
}
}
// ── Component ─────────────────────────────────────────────────────────────────
export function NinerouterModelList() {
@@ -42,35 +58,48 @@ export function NinerouterModelList() {
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchModels = useCallback(
const loadModels = useCallback(
async (refresh = false) => {
if (refresh) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const url = `/api/services/${NAME}/models${refresh ? "?refresh=true" : ""}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
const data: ServiceModel[] = Array.isArray(body?.data) ? body.data : [];
setModels(data);
// All setState calls stay after the await so the mount effect below can
// call this loader without a synchronous setState inside the effect; the
// refresh button sets its spinner flags in its own handler instead.
const outcome = await fetchServiceModels(refresh);
if (outcome.ok) {
setModels(outcome.data);
setPage(1);
} catch (err) {
setError(err instanceof Error ? err.message : t("modelsLoadFailed"));
} finally {
setLoading(false);
setRefreshing(false);
setError(null);
} else {
setError(outcome.message ?? t("modelsLoadFailed"));
}
setLoading(false);
setRefreshing(false);
},
[t]
);
// Inline-in-effect (calling the component-scope loadModels callback
// synchronously from an effect is rejected by the compiler rules); every
// setState here runs after the await.
useEffect(() => {
void fetchModels(false);
}, [fetchModels]);
const run = async () => {
const outcome = await fetchServiceModels(false);
if (outcome.ok) {
setModels(outcome.data);
setPage(1);
setError(null);
} else {
setError(outcome.message ?? t("modelsLoadFailed"));
}
setLoading(false);
};
void run();
}, [t]);
const handleRefresh = () => {
setRefreshing(true);
setError(null);
void loadModels(true);
};
const totalPages = Math.max(1, Math.ceil(models.length / PAGE_SIZE));
const visibleModels = paginateModels(models, page, PAGE_SIZE);
@@ -92,7 +121,7 @@ export function NinerouterModelList() {
<Button
variant="secondary"
size="sm"
onClick={() => fetchModels(true)}
onClick={handleRefresh}
disabled={loading || refreshing}
className="shrink-0"
>