fix: prevent stale closure in pipeline step callbacks

All step mutation callbacks (addStep, removeStep, moveStep,
updateStepSettings) captured `steps` in their useCallback closures.
When React batched state updates, rapid interactions could use a stale
steps array, causing clicks to silently fail.

Fix: use a stepsRef that always holds the latest value. Callbacks read
from stepsRef.current instead of the captured closure variable, and no
longer need `steps` in their dependency arrays.
This commit is contained in:
Siddharth Kumar Sah
2026-03-28 16:42:59 +08:00
parent 6edaaba935
commit 8b20fef2d8
@@ -13,7 +13,7 @@ import {
Upload, Upload,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SearchBar } from "@/components/common/search-bar"; import { SearchBar } from "@/components/common/search-bar";
import { apiGet } from "@/lib/api"; import { apiGet } from "@/lib/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -67,6 +67,13 @@ export function PipelineBuilder({
const [pipelineToolIds, setPipelineToolIds] = useState<string[] | null>(null); const [pipelineToolIds, setPipelineToolIds] = useState<string[] | null>(null);
const [toolSearch, setToolSearch] = useState(""); const [toolSearch, setToolSearch] = useState("");
// Keep a ref to steps so callbacks always read the latest value
// without needing steps in their dependency arrays (prevents stale closures).
const stepsRef = useRef(steps);
useEffect(() => {
stepsRef.current = steps;
});
useEffect(() => { useEffect(() => {
apiGet<{ settings: Record<string, string> }>("/v1/settings") apiGet<{ settings: Record<string, string> }>("/v1/settings")
.then((data) => { .then((data) => {
@@ -105,40 +112,43 @@ export function PipelineBuilder({
toolId, toolId,
settings: {}, settings: {},
}; };
onStepsChange([...steps, step]); onStepsChange([...stepsRef.current, step]);
setShowToolPicker(false); setShowToolPicker(false);
setToolSearch(""); setToolSearch("");
setExpandedStep(step.id); setExpandedStep(step.id);
}, },
[steps, onStepsChange], [onStepsChange],
); );
const removeStep = useCallback( const removeStep = useCallback(
(id: string) => { (id: string) => {
onStepsChange(steps.filter((s) => s.id !== id)); onStepsChange(stepsRef.current.filter((s) => s.id !== id));
if (expandedStep === id) setExpandedStep(null); setExpandedStep((prev) => (prev === id ? null : prev));
}, },
[steps, onStepsChange, expandedStep], [onStepsChange],
); );
const moveStep = useCallback( const moveStep = useCallback(
(id: string, direction: "up" | "down") => { (id: string, direction: "up" | "down") => {
const idx = steps.findIndex((s) => s.id === id); const current = stepsRef.current;
const idx = current.findIndex((s) => s.id === id);
if (idx < 0) return; if (idx < 0) return;
const newIdx = direction === "up" ? idx - 1 : idx + 1; const newIdx = direction === "up" ? idx - 1 : idx + 1;
if (newIdx < 0 || newIdx >= steps.length) return; if (newIdx < 0 || newIdx >= current.length) return;
const newSteps = [...steps]; const newSteps = [...current];
[newSteps[idx], newSteps[newIdx]] = [newSteps[newIdx], newSteps[idx]]; [newSteps[idx], newSteps[newIdx]] = [newSteps[newIdx], newSteps[idx]];
onStepsChange(newSteps); onStepsChange(newSteps);
}, },
[steps, onStepsChange], [onStepsChange],
); );
const updateStepSettings = useCallback( const updateStepSettings = useCallback(
(id: string, newSettings: Record<string, unknown>) => { (id: string, newSettings: Record<string, unknown>) => {
onStepsChange(steps.map((s) => (s.id === id ? { ...s, settings: newSettings } : s))); onStepsChange(
stepsRef.current.map((s) => (s.id === id ? { ...s, settings: newSettings } : s)),
);
}, },
[steps, onStepsChange], [onStepsChange],
); );
const handleFileSelect = useCallback(() => { const handleFileSelect = useCallback(() => {