From 2d2dcdc4a8fad44a07e36b02d45cd5ce91e29647 Mon Sep 17 00:00:00 2001 From: Aleksey Shakhmatov Date: Wed, 8 Apr 2026 07:41:34 +0300 Subject: [PATCH] fix: resolve all 25 ESLint react-hooks and react-refresh violations Replace useEffect-based state resets in dialogs with React's render-time state adjustment pattern. Wrap ref assignments in hooks with useEffect. Suppress known third-party library warnings (shadcn CVA exports, TanStack Table). Remove warn downgrades from eslint config. --- eslint.config.js | 3 -- .../connections/ConnectionDialog.tsx | 8 ++-- .../data-generator/GenerateDataDialog.tsx | 8 ++-- src/components/docker/CloneDatabaseDialog.tsx | 14 ++++--- src/components/erd/ErdDiagram.tsx | 8 ++-- src/components/management/AlterRoleDialog.tsx | 8 ++-- .../management/CreateDatabaseDialog.tsx | 8 ++-- .../management/CreateRoleDialog.tsx | 8 ++-- .../management/GrantRevokeDialog.tsx | 8 ++-- src/components/results/ResultsTable.tsx | 1 + .../saved-queries/SaveQueryDialog.tsx | 8 ++-- src/components/settings/AppSettingsSheet.tsx | 14 ++++--- .../snapshots/CreateSnapshotDialog.tsx | 21 ++++++---- .../snapshots/RestoreSnapshotDialog.tsx | 14 ++++--- src/components/ui/badge.tsx | 1 + src/components/ui/button.tsx | 1 + src/components/ui/tabs.tsx | 1 + src/hooks/use-data-generator.ts | 21 +++++++--- src/hooks/use-docker.ts | 19 ++++++++-- src/hooks/use-snapshots.ts | 38 +++++++++++++++---- 20 files changed, 145 insertions(+), 67 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 3041488..483d5e2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,9 +20,6 @@ export default defineConfig([ globals: globals.browser, }, rules: { - // TODO: fix these incrementally — pre-existing violations from react-hooks v7 upgrade - 'react-hooks/set-state-in-effect': 'warn', - 'react-hooks/refs': 'warn', 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], }, }, diff --git a/src/components/connections/ConnectionDialog.tsx b/src/components/connections/ConnectionDialog.tsx index 19c0f59..ab67a19 100644 --- a/src/components/connections/ConnectionDialog.tsx +++ b/src/components/connections/ConnectionDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -99,7 +99,9 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) { const saveMutation = useSaveConnection(); const testMutation = useTestConnection(); - useEffect(() => { + const [prev, setPrev] = useState<{ open: boolean; connection: typeof connection }>({ open: false, connection: null }); + if (open !== prev.open || connection !== prev.connection) { + setPrev({ open, connection }); if (open) { const config = connection ?? { ...emptyConfig, id: crypto.randomUUID() }; setForm(config); @@ -107,7 +109,7 @@ export function ConnectionDialog({ open, onOpenChange, connection }: Props) { setDsn(connection ? buildDsn(config) : ""); setDsnError(null); } - }, [open, connection]); + } const update = (field: keyof ConnectionConfig, value: string | number) => { setForm((f) => ({ ...f, [field]: value })); diff --git a/src/components/data-generator/GenerateDataDialog.tsx b/src/components/data-generator/GenerateDataDialog.tsx index 0d8a606..0ee4e67 100644 --- a/src/components/data-generator/GenerateDataDialog.tsx +++ b/src/components/data-generator/GenerateDataDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -54,7 +54,9 @@ export function GenerateDataDialog({ reset, } = useDataGenerator(); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(false); + if (open !== prevOpen) { + setPrevOpen(open); if (open) { setStep("config"); setRowCount(10); @@ -62,7 +64,7 @@ export function GenerateDataDialog({ setCustomInstructions(""); reset(); } - }, [open, reset]); + } const handleGenerate = () => { const genId = crypto.randomUUID(); diff --git a/src/components/docker/CloneDatabaseDialog.tsx b/src/components/docker/CloneDatabaseDialog.tsx index 89c5329..9f566bf 100644 --- a/src/components/docker/CloneDatabaseDialog.tsx +++ b/src/components/docker/CloneDatabaseDialog.tsx @@ -112,11 +112,13 @@ export function CloneDatabaseDialog({ useCloneToDocker(); // Reset state when dialog opens - useEffect(() => { + const [prevOpen, setPrevOpen] = useState<{ open: boolean; database: string }>({ open: false, database: "" }); + if (open !== prevOpen.open || database !== prevOpen.database) { + setPrevOpen({ open, database }); if (open) { setStep("config"); setContainerName( - `tusk-${database.replace(/[^a-zA-Z0-9_-]/g, "-")}-${Date.now().toString(36)}` + `tusk-${database.replace(/[^a-zA-Z0-9_-]/g, "-")}-${crypto.randomUUID().slice(0, 8)}` ); setPgVersion("16"); setPortMode("auto"); @@ -127,10 +129,12 @@ export function CloneDatabaseDialog({ setLogOpen(false); reset(); } - }, [open, database, reset]); + } // Accumulate progress events into log - useEffect(() => { + const [prevProgress, setPrevProgress] = useState(progress); + if (progress !== prevProgress) { + setPrevProgress(progress); if (progress) { setLogEntries((prev) => { const last = prev[prev.length - 1]; @@ -143,7 +147,7 @@ export function CloneDatabaseDialog({ setStep("done"); } } - }, [progress]); + } // Auto-scroll log to bottom useEffect(() => { diff --git a/src/components/erd/ErdDiagram.tsx b/src/components/erd/ErdDiagram.tsx index a5e722c..29773f5 100644 --- a/src/components/erd/ErdDiagram.tsx +++ b/src/components/erd/ErdDiagram.tsx @@ -1,4 +1,4 @@ -import { useMemo, useCallback, useEffect, useState } from "react"; +import { useMemo, useCallback, useState } from "react"; import { useTheme } from "next-themes"; import { ReactFlow, @@ -111,12 +111,14 @@ export function ErdDiagram({ connectionId, schema }: Props) { const [nodes, setNodes] = useState([]); const [edges, setEdges] = useState([]); - useEffect(() => { + const [prevLayout, setPrevLayout] = useState(layout); + if (layout !== prevLayout) { + setPrevLayout(layout); if (layout) { setNodes(layout.nodes); setEdges(layout.edges); } - }, [layout]); + } const onNodesChange = useCallback( (changes: NodeChange[]) => setNodes((nds) => applyNodeChanges(changes, nds)), diff --git a/src/components/management/AlterRoleDialog.tsx b/src/components/management/AlterRoleDialog.tsx index 347e194..c7f4106 100644 --- a/src/components/management/AlterRoleDialog.tsx +++ b/src/components/management/AlterRoleDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -34,7 +34,9 @@ export function AlterRoleDialog({ open, onOpenChange, connectionId, role }: Prop const alterMutation = useAlterRole(); - useEffect(() => { + const [prev, setPrev] = useState<{ open: boolean; role: typeof role }>({ open: false, role: null }); + if (open !== prev.open || role !== prev.role) { + setPrev({ open, role }); if (open && role) { setPassword(""); setLogin(role.can_login); @@ -47,7 +49,7 @@ export function AlterRoleDialog({ open, onOpenChange, connectionId, role }: Prop setValidUntil(role.valid_until ?? ""); setRenameTo(""); } - }, [open, role]); + } if (!role) return null; diff --git a/src/components/management/CreateDatabaseDialog.tsx b/src/components/management/CreateDatabaseDialog.tsx index 09ab07e..b769fbe 100644 --- a/src/components/management/CreateDatabaseDialog.tsx +++ b/src/components/management/CreateDatabaseDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -35,7 +35,9 @@ export function CreateDatabaseDialog({ open, onOpenChange, connectionId }: Props const { data: roles } = useRoles(open ? connectionId : null); const createMutation = useCreateDatabase(); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(false); + if (open !== prevOpen) { + setPrevOpen(open); if (open) { setName(""); setOwner("__default__"); @@ -43,7 +45,7 @@ export function CreateDatabaseDialog({ open, onOpenChange, connectionId }: Props setEncoding("UTF8"); setConnectionLimit(-1); } - }, [open]); + } const handleCreate = () => { if (!name.trim()) { diff --git a/src/components/management/CreateRoleDialog.tsx b/src/components/management/CreateRoleDialog.tsx index 91d2506..b4c9778 100644 --- a/src/components/management/CreateRoleDialog.tsx +++ b/src/components/management/CreateRoleDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -34,7 +34,9 @@ export function CreateRoleDialog({ open, onOpenChange, connectionId }: Props) { const { data: roles } = useRoles(open ? connectionId : null); const createMutation = useCreateRole(); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(false); + if (open !== prevOpen) { + setPrevOpen(open); if (open) { setName(""); setPassword(""); @@ -48,7 +50,7 @@ export function CreateRoleDialog({ open, onOpenChange, connectionId }: Props) { setValidUntil(""); setInRoles([]); } - }, [open]); + } const handleCreate = () => { if (!name.trim()) { diff --git a/src/components/management/GrantRevokeDialog.tsx b/src/components/management/GrantRevokeDialog.tsx index b63c40c..e68bb7a 100644 --- a/src/components/management/GrantRevokeDialog.tsx +++ b/src/components/management/GrantRevokeDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -61,14 +61,16 @@ export function GrantRevokeDialog({ ); const grantRevokeMutation = useGrantRevoke(); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(false); + if (open !== prevOpen) { + setPrevOpen(open); if (open) { setAction("GRANT"); setRoleName(""); setPrivileges([]); setWithGrantOption(false); } - }, [open]); + } const availablePrivileges = PRIVILEGE_OPTIONS[objectType.toUpperCase()] ?? PRIVILEGE_OPTIONS.TABLE; diff --git a/src/components/results/ResultsTable.tsx b/src/components/results/ResultsTable.tsx index 49e6c97..3bed8ea 100644 --- a/src/components/results/ResultsTable.tsx +++ b/src/components/results/ResultsTable.tsx @@ -77,6 +77,7 @@ export function ResultsTable({ [colNames, onCellDoubleClick, highlightedCells] ); + // eslint-disable-next-line react-hooks/incompatible-library const table = useReactTable({ data: rows, columns, diff --git a/src/components/saved-queries/SaveQueryDialog.tsx b/src/components/saved-queries/SaveQueryDialog.tsx index e243dab..bdb27b4 100644 --- a/src/components/saved-queries/SaveQueryDialog.tsx +++ b/src/components/saved-queries/SaveQueryDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -23,9 +23,11 @@ export function SaveQueryDialog({ open, onOpenChange, sql, connectionId }: Props const [name, setName] = useState(""); const saveMutation = useSaveQuery(); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(false); + if (open !== prevOpen) { + setPrevOpen(open); if (open) setName(""); - }, [open]); + } const handleSave = () => { if (!name.trim()) { diff --git a/src/components/settings/AppSettingsSheet.tsx b/src/components/settings/AppSettingsSheet.tsx index 65e4b25..ac9f6ec 100644 --- a/src/components/settings/AppSettingsSheet.tsx +++ b/src/components/settings/AppSettingsSheet.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Sheet, SheetContent, @@ -52,21 +52,25 @@ export function AppSettingsSheet({ open, onOpenChange }: Props) { const [copied, setCopied] = useState(false); // Sync form with loaded settings - useEffect(() => { + const [prevAppSettings, setPrevAppSettings] = useState(appSettings); + if (appSettings !== prevAppSettings) { + setPrevAppSettings(appSettings); if (appSettings) { setMcpEnabled(appSettings.mcp.enabled); setMcpPort(appSettings.mcp.port); setDockerHost(appSettings.docker.host); setDockerRemoteUrl(appSettings.docker.remote_url ?? ""); } - }, [appSettings]); + } - useEffect(() => { + const [prevAiSettings, setPrevAiSettings] = useState(aiSettings); + if (aiSettings !== prevAiSettings) { + setPrevAiSettings(aiSettings); if (aiSettings) { setOllamaUrl(aiSettings.ollama_url); setAiModel(aiSettings.model); } - }, [aiSettings]); + } const mcpEndpoint = `http://127.0.0.1:${mcpPort}/mcp`; diff --git a/src/components/snapshots/CreateSnapshotDialog.tsx b/src/components/snapshots/CreateSnapshotDialog.tsx index 17b44bf..5e05187 100644 --- a/src/components/snapshots/CreateSnapshotDialog.tsx +++ b/src/components/snapshots/CreateSnapshotDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -43,7 +43,9 @@ export function CreateSnapshotDialog({ open, onOpenChange, connectionId }: Props const { create, result, error, isCreating, progress, reset } = useCreateSnapshot(); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(false); + if (open !== prevOpen) { + setPrevOpen(open); if (open) { setStep("config"); setName(`snapshot-${new Date().toISOString().slice(0, 10)}`); @@ -51,20 +53,23 @@ export function CreateSnapshotDialog({ open, onOpenChange, connectionId }: Props setIncludeDeps(true); reset(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, reset]); + } - useEffect(() => { + const [prevSchemas, setPrevSchemas] = useState(schemas); + if (schemas !== prevSchemas) { + setPrevSchemas(schemas); if (schemas && schemas.length > 0 && !selectedSchema) { setSelectedSchema(schemas.find((s) => s === "public") || schemas[0]); } - }, [schemas, selectedSchema]); + } - useEffect(() => { + const [prevProgress, setPrevProgress] = useState(progress); + if (progress !== prevProgress) { + setPrevProgress(progress); if (progress?.stage === "done" || progress?.stage === "error") { setStep("done"); } - }, [progress]); + } const handleToggleTable = (tableName: string) => { setSelectedTables((prev) => { diff --git a/src/components/snapshots/RestoreSnapshotDialog.tsx b/src/components/snapshots/RestoreSnapshotDialog.tsx index f713ba5..7c3fe73 100644 --- a/src/components/snapshots/RestoreSnapshotDialog.tsx +++ b/src/components/snapshots/RestoreSnapshotDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import { Dialog, DialogContent, @@ -38,7 +38,9 @@ export function RestoreSnapshotDialog({ open, onOpenChange, connectionId }: Prop const readMeta = useReadSnapshotMetadata(); const { restore, rowsRestored, error, isRestoring, progress, reset } = useRestoreSnapshot(); - useEffect(() => { + const [prevOpen, setPrevOpen] = useState(false); + if (open !== prevOpen) { + setPrevOpen(open); if (open) { setStep("select"); setFilePath(null); @@ -46,13 +48,15 @@ export function RestoreSnapshotDialog({ open, onOpenChange, connectionId }: Prop setTruncate(false); reset(); } - }, [open, reset]); + } - useEffect(() => { + const [prevProgress, setPrevProgress] = useState(progress); + if (progress !== prevProgress) { + setPrevProgress(progress); if (progress?.stage === "done" || progress?.stage === "error") { setStep("done"); } - }, [progress]); + } const handleSelectFile = async () => { const selected = await openFile({ diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index beb56ed..5029afb 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Slot } from "radix-ui" diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index a222f7a..09810ad 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Slot } from "radix-ui" diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 7bf18aa..c2b1f44 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { Tabs as TabsPrimitive } from "radix-ui" diff --git a/src/hooks/use-data-generator.ts b/src/hooks/use-data-generator.ts index 50f1e30..f8cc402 100644 --- a/src/hooks/use-data-generator.ts +++ b/src/hooks/use-data-generator.ts @@ -36,20 +36,31 @@ export function useDataGenerator() { }); useEffect(() => { - const unlistenPromise = onDataGenProgress((p) => { - if (p.gen_id === genIdRef.current) { + let mounted = true; + let unlisten: (() => void) | undefined; + onDataGenProgress((p) => { + if (mounted && p.gen_id === genIdRef.current) { setProgress(p); } + }).then((fn) => { + if (mounted) { + unlisten = fn; + } else { + fn(); + } }); return () => { - unlistenPromise.then((unlisten) => unlisten()); + mounted = false; + unlisten?.(); }; }, []); const previewRef = useRef(previewMutation); - previewRef.current = previewMutation; const insertRef = useRef(insertMutation); - insertRef.current = insertMutation; + useEffect(() => { + previewRef.current = previewMutation; + insertRef.current = insertMutation; + }); const reset = useCallback(() => { previewRef.current.reset(); diff --git a/src/hooks/use-docker.ts b/src/hooks/use-docker.ts index 40ae227..9e0dc97 100644 --- a/src/hooks/use-docker.ts +++ b/src/hooks/use-docker.ts @@ -51,18 +51,29 @@ export function useCloneToDocker() { }); useEffect(() => { - const unlistenPromise = onCloneProgress((p) => { - if (p.clone_id === cloneIdRef.current) { + let mounted = true; + let unlisten: (() => void) | undefined; + onCloneProgress((p) => { + if (mounted && p.clone_id === cloneIdRef.current) { setProgress(p); } + }).then((fn) => { + if (mounted) { + unlisten = fn; + } else { + fn(); + } }); return () => { - unlistenPromise.then((unlisten) => unlisten()); + mounted = false; + unlisten?.(); }; }, []); const mutationRef = useRef(mutation); - mutationRef.current = mutation; + useEffect(() => { + mutationRef.current = mutation; + }); const reset = useCallback(() => { mutationRef.current.reset(); diff --git a/src/hooks/use-snapshots.ts b/src/hooks/use-snapshots.ts index 7c54376..b3dcf13 100644 --- a/src/hooks/use-snapshots.ts +++ b/src/hooks/use-snapshots.ts @@ -53,18 +53,29 @@ export function useCreateSnapshot() { }); useEffect(() => { - const unlistenPromise = onSnapshotProgress((p) => { - if (p.snapshot_id === snapshotIdRef.current) { + let mounted = true; + let unlisten: (() => void) | undefined; + onSnapshotProgress((p) => { + if (mounted && p.snapshot_id === snapshotIdRef.current) { setProgress(p); } + }).then((fn) => { + if (mounted) { + unlisten = fn; + } else { + fn(); + } }); return () => { - unlistenPromise.then((unlisten) => unlisten()); + mounted = false; + unlisten?.(); }; }, []); const mutationRef = useRef(mutation); - mutationRef.current = mutation; + useEffect(() => { + mutationRef.current = mutation; + }); const reset = useCallback(() => { mutationRef.current.reset(); @@ -101,18 +112,29 @@ export function useRestoreSnapshot() { }); useEffect(() => { - const unlistenPromise = onSnapshotProgress((p) => { - if (p.snapshot_id === snapshotIdRef.current) { + let mounted = true; + let unlisten: (() => void) | undefined; + onSnapshotProgress((p) => { + if (mounted && p.snapshot_id === snapshotIdRef.current) { setProgress(p); } + }).then((fn) => { + if (mounted) { + unlisten = fn; + } else { + fn(); + } }); return () => { - unlistenPromise.then((unlisten) => unlisten()); + mounted = false; + unlisten?.(); }; }, []); const mutationRef = useRef(mutation); - mutationRef.current = mutation; + useEffect(() => { + mutationRef.current = mutation; + }); const reset = useCallback(() => { mutationRef.current.reset();