diff --git a/eslint.config.js b/eslint.config.js
index 784b80ac3..cf59f25b6 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,5 +1,6 @@
import pluginJs from "@eslint/js";
import pluginReact from "eslint-plugin-react";
+import reactHooks from "eslint-plugin-react-hooks";
import simpleImportSort from "eslint-plugin-simple-import-sort";
import globals from "globals";
import tseslint from "typescript-eslint";
@@ -11,8 +12,20 @@ export default [
pluginJs.configs.recommended,
...tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
+ reactHooks.configs.flat.recommended,
{
rules: {
+ "react-hooks/exhaustive-deps": [
+ "warn",
+ // I left this commented out because it causes infinite loops in the codebase,
+ // but may useful for mass-refactoring.
+ // {
+ // enableDangerousAutofixThisMayCauseInfiniteLoops: true,
+ // },
+ ],
+ "react-hooks/set-state-in-effect": "warn",
+ "react-hooks/refs": "warn",
+ "react-hooks/immutability": "warn",
"@typescript-eslint/no-explicit-any": "off",
"react/react-in-jsx-scope": "off",
"@typescript-eslint/no-empty-object-type": "off",
diff --git a/package-lock.json b/package-lock.json
index bcd8779bb..fbd0c8e2a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "oasis-app",
+ "name": "tangleml/tangle-ui",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "oasis-app",
+ "name": "tangleml/tangle-ui",
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
@@ -75,6 +75,7 @@
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^7.0.1",
"globals": "^16.5.0",
"jsdom": "^27.2.0",
"knip": "^5.63.1",
@@ -6301,6 +6302,26 @@
"eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7"
}
},
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
+ "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -7181,6 +7202,23 @@
"node": ">= 0.4"
}
},
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
"node_modules/html-encoding-sniffer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
diff --git a/package.json b/package.json
index a84f2830c..b97ad2fc5 100644
--- a/package.json
+++ b/package.json
@@ -35,7 +35,8 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
- "lint": "eslint src --config eslint.config.js",
+ "lint": "eslint --quiet src --config eslint.config.js",
+ "lint:all": "eslint src --config eslint.config.js",
"format": "prettier --write .",
"typecheck": "tsc --noEmit",
"gh-pages": "npm run build:ghpages && gh-pages -d dist",
@@ -115,6 +116,7 @@
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^9.39.1",
"eslint-plugin-react": "^7.37.5",
+ "eslint-plugin-react-hooks": "^7.0.1",
"globals": "^16.5.0",
"jsdom": "^27.2.0",
"knip": "^5.63.1",
diff --git a/src/components/Editor/IOEditor/InputValueEditor/InputValueEditor.tsx b/src/components/Editor/IOEditor/InputValueEditor/InputValueEditor.tsx
index 066e3ed74..f4584c008 100644
--- a/src/components/Editor/IOEditor/InputValueEditor/InputValueEditor.tsx
+++ b/src/components/Editor/IOEditor/InputValueEditor/InputValueEditor.tsx
@@ -141,7 +141,7 @@ export const InputValueEditor = ({
setValidationError(null);
},
- [input.name, currentSubgraphSpec],
+ [input.name, componentSpec],
);
const hasChanges = useCallback(() => {
@@ -203,7 +203,7 @@ export const InputValueEditor = ({
void navigator.clipboard.writeText(inputValue.trim());
notify("Input value copied to clipboard", "success");
}
- }, [inputValue]);
+ }, [inputValue, notify]);
const deleteNode = useCallback(async () => {
if (!currentSubgraphSpec.inputs) return;
@@ -258,17 +258,21 @@ export const InputValueEditor = ({
}, []);
useEffect(() => {
- setInputValue(initialInputValue);
- setInputName(input.name);
- setInputType(input.type?.toString() ?? "any");
- setInputOptional(initialIsOptional);
- setValidationError(null);
+ queueMicrotask(() => {
+ setInputValue(initialInputValue);
+ setInputName(input.name);
+ setInputType(input.type?.toString() ?? "any");
+ setInputOptional(initialIsOptional);
+ setValidationError(null);
+ });
}, [input, initialInputValue, initialIsOptional]);
useEffect(() => {
if (triggerSave) {
- saveChanges();
- setTriggerSave(false);
+ queueMicrotask(() => {
+ saveChanges();
+ setTriggerSave(false);
+ });
}
}, [triggerSave, saveChanges]);
diff --git a/src/components/Editor/IOEditor/OutputNameEditor/OutputNameEditor.tsx b/src/components/Editor/IOEditor/OutputNameEditor/OutputNameEditor.tsx
index da4258339..91eb476c0 100644
--- a/src/components/Editor/IOEditor/OutputNameEditor/OutputNameEditor.tsx
+++ b/src/components/Editor/IOEditor/OutputNameEditor/OutputNameEditor.tsx
@@ -122,7 +122,7 @@ export const OutputNameEditor = ({
setValidationError(null);
},
- [currentSubgraphSpec, output.name],
+ [componentSpec, output.name],
);
const deleteNode = useCallback(async () => {
@@ -161,7 +161,9 @@ export const OutputNameEditor = ({
]);
useEffect(() => {
- setOutputName(output.name);
+ queueMicrotask(() => {
+ setOutputName(output.name);
+ });
}, [output.name]);
return (
diff --git a/src/components/Home/PipelineSection/BulkActionsBar.tsx b/src/components/Home/PipelineSection/BulkActionsBar.tsx
index 34cd4fc0f..da07281be 100644
--- a/src/components/Home/PipelineSection/BulkActionsBar.tsx
+++ b/src/components/Home/PipelineSection/BulkActionsBar.tsx
@@ -37,7 +37,7 @@ const BulkActionsBar = ({
const errorMessage = getErrorMessage(error);
notify("Failed to delete some pipelines: " + errorMessage, "error");
}
- }, [selectedPipelines, onDeleteSuccess]);
+ }, [selectedPipelines, onDeleteSuccess, notify]);
return (
diff --git a/src/components/Home/PipelineSection/PipelineRow.tsx b/src/components/Home/PipelineSection/PipelineRow.tsx
index ae71d66e2..47f943c10 100644
--- a/src/components/Home/PipelineSection/PipelineRow.tsx
+++ b/src/components/Home/PipelineSection/PipelineRow.tsx
@@ -74,7 +74,7 @@ const PipelineRow = ({
};
await deletePipeline(name, deleteCallback);
- }, [name]);
+ }, [name, onDelete]);
const handleClick = useCallback((e: MouseEvent) => {
// Prevent row click when clicking on the checkbox
diff --git a/src/components/Home/RunSection/RunRow.tsx b/src/components/Home/RunSection/RunRow.tsx
index da817b76a..80c71df1f 100644
--- a/src/components/Home/RunSection/RunRow.tsx
+++ b/src/components/Home/RunSection/RunRow.tsx
@@ -41,7 +41,7 @@ const RunRow = ({ run }: { run: PipelineRunResponse }) => {
navigator.clipboard.writeText(createdBy);
notify(`"${createdBy}" copied to clipboard`, "success");
},
- [createdBy],
+ [createdBy, notify],
);
const statusCounts = convertExecutionStatsToStatusCounts(
diff --git a/src/components/PipelineRun/components/CancelPipelineRunButton.tsx b/src/components/PipelineRun/components/CancelPipelineRunButton.tsx
index 4be968e43..cb22f51e6 100644
--- a/src/components/PipelineRun/components/CancelPipelineRunButton.tsx
+++ b/src/components/PipelineRun/components/CancelPipelineRunButton.tsx
@@ -53,7 +53,7 @@ export const CancelPipelineRunButton = ({
} catch (error) {
notify(`Error cancelling run: ${error}`, "error");
}
- }, [runId, available]);
+ }, [runId, available, notify, cancelPipeline]);
const onClick = useCallback(() => {
setIsOpen(true);
diff --git a/src/components/PipelineRun/components/RerunPipelineButton.tsx b/src/components/PipelineRun/components/RerunPipelineButton.tsx
index 27e01e039..ab8766539 100644
--- a/src/components/PipelineRun/components/RerunPipelineButton.tsx
+++ b/src/components/PipelineRun/components/RerunPipelineButton.tsx
@@ -28,9 +28,12 @@ export const RerunPipelineButton = ({
const { awaitAuthorization, isAuthorized } = useAwaitAuthorization();
const { getToken } = useAuthLocalStorage();
- const onSuccess = useCallback((response: PipelineRun) => {
- navigate({ to: `${APP_ROUTES.RUNS}/${response.id}` });
- }, []);
+ const onSuccess = useCallback(
+ (response: PipelineRun) => {
+ navigate({ to: `${APP_ROUTES.RUNS}/${response.id}` });
+ },
+ [navigate],
+ );
const onError = useCallback(
(error: Error | string) => {
diff --git a/src/components/shared/Authentication/AuthorizedUserProfile.tsx b/src/components/shared/Authentication/AuthorizedUserProfile.tsx
index 04060e28f..37db7d70a 100644
--- a/src/components/shared/Authentication/AuthorizedUserProfile.tsx
+++ b/src/components/shared/Authentication/AuthorizedUserProfile.tsx
@@ -1,6 +1,6 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { LogOutIcon } from "lucide-react";
-import { useEffectEvent, useSyncExternalStore } from "react";
+import { useCallback, useSyncExternalStore } from "react";
import { Icon } from "@/components/ui/icon";
import { Spinner } from "@/components/ui/spinner";
@@ -52,10 +52,11 @@ export function AuthorizedUserProfile() {
);
const profile = localTokenStorage.getJWT();
- const onLogoutSuccess = useEffectEvent(() => {
+ const onLogoutSuccess = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ["user"] });
localTokenStorage.clear();
- });
+ }, [queryClient, localTokenStorage]);
+
const { mutate: logout, isPending } = useLogout({
onSuccess: onLogoutSuccess,
});
diff --git a/src/components/shared/ComponentEditor/components/PythonComponentEditor.tsx b/src/components/shared/ComponentEditor/components/PythonComponentEditor.tsx
index 1229944d3..a085d1657 100644
--- a/src/components/shared/ComponentEditor/components/PythonComponentEditor.tsx
+++ b/src/components/shared/ComponentEditor/components/PythonComponentEditor.tsx
@@ -77,7 +77,12 @@ export const PythonComponentEditor = withSuspenseWrapper(
setValidationErrors(errors);
}
},
- [yamlGenerator, onComponentTextChange, yamlGeneratorOptions],
+ [
+ yamlGenerator,
+ yamlGeneratorOptions,
+ onComponentTextChange,
+ onErrorsChange,
+ ],
);
useEffect(() => {
diff --git a/src/components/shared/ComponentEditor/components/YamlComponentEditor.tsx b/src/components/shared/ComponentEditor/components/YamlComponentEditor.tsx
index b3142a4db..b8ce8f55c 100644
--- a/src/components/shared/ComponentEditor/components/YamlComponentEditor.tsx
+++ b/src/components/shared/ComponentEditor/components/YamlComponentEditor.tsx
@@ -39,7 +39,7 @@ export const YamlComponentEditor = withSuspenseWrapper(
setValidationErrors([]);
onErrorsChange([]);
},
- [onComponentTextChange, validateComponentSpec],
+ [onComponentTextChange, onErrorsChange, validateComponentSpec],
);
return (
diff --git a/src/components/shared/Dialogs/BackendConfigurationDialog.tsx b/src/components/shared/Dialogs/BackendConfigurationDialog.tsx
index 0d517df2d..77ff3405d 100644
--- a/src/components/shared/Dialogs/BackendConfigurationDialog.tsx
+++ b/src/components/shared/Dialogs/BackendConfigurationDialog.tsx
@@ -109,14 +109,18 @@ const BackendConfigurationDialog = ({
}, [isConfiguredFromEnv, isConfiguredFromRelativePath, setOpen]);
useEffect(() => {
- setIsEnvConfig(isConfiguredFromEnv);
- setIsRelativePathConfig(isConfiguredFromRelativePath);
+ queueMicrotask(() => {
+ setIsEnvConfig(isConfiguredFromEnv);
+ setIsRelativePathConfig(isConfiguredFromRelativePath);
+ });
}, [isConfiguredFromEnv, isConfiguredFromRelativePath]);
useEffect(() => {
- setInputBackendUrl(
- isConfiguredFromEnv || isConfiguredFromRelativePath ? "" : backendUrl,
- );
+ queueMicrotask(() => {
+ setInputBackendUrl(
+ isConfiguredFromEnv || isConfiguredFromRelativePath ? "" : backendUrl,
+ );
+ });
}, [isConfiguredFromEnv, isConfiguredFromRelativePath, backendUrl]);
const hasBackendConfigured =
diff --git a/src/components/shared/Dialogs/ComponentDetailsDialog.tsx b/src/components/shared/Dialogs/ComponentDetailsDialog.tsx
index e6879bccb..1abafc53b 100644
--- a/src/components/shared/Dialogs/ComponentDetailsDialog.tsx
+++ b/src/components/shared/Dialogs/ComponentDetailsDialog.tsx
@@ -201,12 +201,15 @@ const ComponentDetails = ({
setIsEditDialogOpen(false);
}, []);
- const onOpenChange = useCallback((open: boolean) => {
- setOpen(open);
- if (!open) {
- onClose?.();
- }
- }, []);
+ const onOpenChange = useCallback(
+ (open: boolean) => {
+ setOpen(open);
+ if (!open) {
+ onClose?.();
+ }
+ },
+ [onClose],
+ );
const handleEditComponent = useCallback(() => {
setIsEditDialogOpen(true);
@@ -227,7 +230,7 @@ const ComponentDetails = ({
);
return [...actions, EditButton];
- }, [actions, hasEnabledInAppEditor, handleEditComponent]);
+ }, [hasEnabledInAppEditor, actions, handleEditComponent, displayName]);
return (
<>
diff --git a/src/components/shared/Dialogs/ComponentDuplicateDialog.tsx b/src/components/shared/Dialogs/ComponentDuplicateDialog.tsx
index 164c4f8a8..3ccaf2dec 100644
--- a/src/components/shared/Dialogs/ComponentDuplicateDialog.tsx
+++ b/src/components/shared/Dialogs/ComponentDuplicateDialog.tsx
@@ -60,7 +60,7 @@ const ComponentDuplicateDialog = ({
);
setNewDigest(digest);
}
- }, [newComponent, newName]);
+ }, [newComponent, newComponentDigest, newName]);
const handleOnOpenChange = useCallback(
(open: boolean) => {
@@ -82,7 +82,7 @@ const ComponentDuplicateDialog = ({
setClose();
},
- [handleImportComponent, setClose],
+ [handleImportComponent, newComponent, setClose],
);
const handleReplaceAndImport = useCallback(async () => {
@@ -94,7 +94,7 @@ const ComponentDuplicateDialog = ({
handleImportComponent(yamlString);
setClose();
- }, [handleImportComponent, setClose]);
+ }, [existingComponent?.name, handleImportComponent, newComponent, setClose]);
const handleCancel = useCallback(() => {
setClose();
@@ -118,7 +118,7 @@ const ComponentDuplicateDialog = ({
}
generateNewDigest();
- }, [existingComponent, newComponent]);
+ }, [existingComponent, newComponent, newComponentDigest]);
return (