From 1f8a53fb2d98f08f7e475e133a90380146790d24 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:33:44 -0300 Subject: [PATCH] =?UTF-8?q?chore(lint):=20batch=202=20of=20#12146=20?= =?UTF-8?q?=E2=80=94=20resolve=20the=20react-hooks=20compiler=20violations?= =?UTF-8?q?=20in=20dashboard/providers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- config/quality/eslint-suppressions.json | 89 -------- .../[id]/components/CustomModelsSection.tsx | 52 +++-- .../[id]/components/ModelCompatPopover.tsx | 94 +++++--- .../components/ProviderCcAliasSection.tsx | 52 +++-- .../ProviderInterceptionSection.tsx | 48 +++-- .../components/ProviderParamFilterSection.tsx | 51 +++-- .../components/VolcengineConnectModal.tsx | 48 ++++- .../modals/EditCompatibleNodeModal.tsx | 25 ++- .../components/modals/EditConnectionModal.tsx | 36 ++-- .../[id]/hooks/useProviderConnections.ts | 202 +++++++++++------- .../[id]/hooks/useProviderSettings.ts | 154 ++++++++----- .../components/AddCompatibleProviderModal.tsx | 30 ++- .../providers/hooks/useProviderModels.ts | 9 +- .../providers/hooks/useProviderUrlFilters.ts | 32 +-- .../providers/hooks/useRiskAcknowledged.ts | 30 ++- .../(dashboard)/dashboard/providers/page.tsx | 66 +++--- .../components/CliproxyAccountHealthCard.tsx | 51 +++-- .../services/components/DarioAccountPanel.tsx | 113 ++++++---- .../components/NinerouterModelList.tsx | 73 +++++-- 19 files changed, 768 insertions(+), 487 deletions(-) diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index 7228fdd51e..bd78aa561d 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -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": { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx index 59297a1d27..18705df45f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx @@ -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; +} | 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 - + {/* List */} {loading ? ( diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx index 22d0c8b203..c52e51e3bc 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx @@ -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([]); - 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>) => { - 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({