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.
This commit was merged in pull request #1.
This commit is contained in:
2026-04-08 07:41:34 +03:00
parent 9237c7dd8e
commit 2d2dcdc4a8
20 changed files with 145 additions and 67 deletions

View File

@@ -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 }],
},
},

View File

@@ -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 }));

View File

@@ -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();

View File

@@ -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(() => {

View File

@@ -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<Node[]>([]);
const [edges, setEdges] = useState<Edge[]>([]);
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)),

View File

@@ -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;

View File

@@ -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()) {

View File

@@ -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()) {

View File

@@ -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;

View File

@@ -77,6 +77,7 @@ export function ResultsTable({
[colNames, onCellDoubleClick, highlightedCells]
);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: rows,
columns,

View File

@@ -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()) {

View File

@@ -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`;

View File

@@ -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) => {

View File

@@ -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({

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();