From 3854502652eb3d56857253c0cd20afbb942f31f9 Mon Sep 17 00:00:00 2001 From: dhruv465 Date: Sat, 30 May 2026 17:01:14 +0530 Subject: [PATCH 1/2] fix: smooth flow dragging and refresh chunks --- next.config.ts | 17 ++----- src/components/flows/flow-canvas.tsx | 59 +++++++++++++++++----- src/components/flows/flow-editor-state.tsx | 24 +++++++++ 3 files changed, 75 insertions(+), 25 deletions(-) diff --git a/next.config.ts b/next.config.ts index 02bd16f..bf07554 100644 --- a/next.config.ts +++ b/next.config.ts @@ -67,9 +67,9 @@ const nextConfig: NextConfig = { * did nothing because the cache is server-side. * * Strategy: - * - /_next/static/* — immutable for a year. Filenames are - * content-hashed, so a new build produces new filenames; the - * old ones are safe to keep indefinitely in caches. + * - /_next/static/* — leave to Next. Turbopack dev chunks can go + * stale if we force immutable caching here; Next already emits + * the correct production headers for hashed assets. * - /api/* — no-store. API responses are per-user and * must never be shared across requests at the edge. * - Everything else — public, brief s-maxage + generous @@ -93,21 +93,12 @@ const nextConfig: NextConfig = { */ async headers() { return [ - { - source: "/_next/static/:path*", - headers: [ - { - key: "Cache-Control", - value: "public, max-age=31536000, immutable", - }, - ], - }, { source: "/api/:path*", headers: [{ key: "Cache-Control", value: "no-store" }], }, { - source: "/:path*", + source: "/:path((?!_next/static|_next/image|api).*)", headers: [ { key: "Cache-Control", diff --git a/src/components/flows/flow-canvas.tsx b/src/components/flows/flow-canvas.tsx index a1639be..76f9fe3 100644 --- a/src/components/flows/flow-canvas.tsx +++ b/src/components/flows/flow-canvas.tsx @@ -36,8 +36,9 @@ * list view reads. */ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + applyNodeChanges, Background, Controls, Handle, @@ -50,6 +51,7 @@ import { type Connection, type Node as RfNode, type Edge as RfEdge, + type NodeChange, type NodeProps, type OnNodeDrag, } from "@xyflow/react"; @@ -227,6 +229,7 @@ function FlowCanvasInner() { setState, updateNodeConfig, updateNodePosition, + updateNodePositions, removeNode, flashKey, } = useFlowEditor(); @@ -246,15 +249,10 @@ function FlowCanvasInner() { [selectedNodeKey, builderNodes], ); - const { rfNodes, rfEdges } = useMemo(() => { + const autoLayoutPositions = useMemo(() => { const canvasEdges = deriveCanvasEdges(builderNodes); - // Decide whether to auto-layout. The helper guards against - // overwriting a user's manual arrangement (only fires when ALL - // nodes sit at the origin), so we can safely call it - // unconditionally — if any node has been positioned, this is a - // no-op. - const positions = shouldAutoLayout(builderNodes) + return shouldAutoLayout(builderNodes) ? autoLayout( builderNodes.map((n) => ({ id: n.node_key, @@ -265,9 +263,26 @@ function FlowCanvasInner() { { direction: "TB" }, ) : null; + }, [builderNodes]); - const rfNodes: RfNode[] = builderNodes.map((n) => { - const fallback = positions?.get(n.node_key); + // If dagre had to place an all-zero flow, persist the generated + // positions into editor state once. Otherwise the next drag would + // save only the dragged node and every other node would fall back + // to (0,0), which feels like nodes teleporting around the canvas. + const persistedAutoLayoutRef = useRef(false); + useEffect(() => { + if (!autoLayoutPositions || persistedAutoLayoutRef.current) return; + persistedAutoLayoutRef.current = true; + updateNodePositions( + Object.fromEntries( + [...autoLayoutPositions].map(([key, pos]) => [key, pos]), + ), + ); + }, [autoLayoutPositions, updateNodePositions]); + + const derivedRfNodes = useMemo(() => { + const nodes: RfNode[] = builderNodes.map((n) => { + const fallback = autoLayoutPositions?.get(n.node_key); return { id: n.node_key, type: "flow", @@ -283,6 +298,18 @@ function FlowCanvasInner() { }; }); + return nodes; + }, [builderNodes, entryNodeId, flashKey, autoLayoutPositions]); + + const [rfNodes, setRfNodes] = useState[]>(derivedRfNodes); + + useEffect(() => { + setRfNodes(derivedRfNodes); + }, [derivedRfNodes]); + + const rfEdges = useMemo(() => { + const canvasEdges = deriveCanvasEdges(builderNodes); + // sourceHandle is now wired up — the FlowNodeCard renders a Handle // per slot whose id matches the scheme in edges.ts, so React-Flow // can hang the arrow off the right place on each card. @@ -299,8 +326,15 @@ function FlowCanvasInner() { style: { stroke: "#475569", strokeWidth: 1.5 }, })); - return { rfNodes, rfEdges }; - }, [builderNodes, entryNodeId, flashKey]); + return rfEdges; + }, [builderNodes]); + + const handleNodesChange = useCallback( + (changes: NodeChange>[]) => { + setRfNodes((nodes) => applyNodeChanges(changes, nodes)); + }, + [], + ); // Drag-to-position: React-Flow tracks the visual drag internally and // fires this once on release. We write the final coordinate back to @@ -439,6 +473,7 @@ function FlowCanvasInner() { fitView fitViewOptions={{ padding: 0.2, maxZoom: 1 }} proOptions={{ hideAttribution: true }} + onNodesChange={handleNodesChange} onNodeDragStop={handleNodeDragStop} onNodeClick={handleNodeClick} onConnect={handleConnect} diff --git a/src/components/flows/flow-editor-state.tsx b/src/components/flows/flow-editor-state.tsx index f377fd6..6a9af29 100644 --- a/src/components/flows/flow-editor-state.tsx +++ b/src/components/flows/flow-editor-state.tsx @@ -97,6 +97,9 @@ export interface FlowEditorContextValue { updateNode: (key: string, patch: Partial) => void; updateNodeConfig: (key: string, patch: Record) => void; updateNodePosition: (key: string, x: number, y: number) => void; + updateNodePositions: ( + positions: Record, + ) => void; removeNode: (key: string) => void; // Actions @@ -438,6 +441,25 @@ export function FlowEditorProvider({ [setState], ); + const updateNodePositions = useCallback( + (positions: Record) => { + setState((s) => ({ + ...s, + nodes: s.nodes.map((n) => { + const next = positions[n.node_key]; + return next + ? { + ...n, + position_x: Math.round(next.x), + position_y: Math.round(next.y), + } + : n; + }), + })); + }, + [setState], + ); + const addNode = useCallback( (type: NodeType): string => { const meta = NODE_META[type]; @@ -498,6 +520,7 @@ export function FlowEditorProvider({ updateNode, updateNodeConfig, updateNodePosition, + updateNodePositions, removeNode, save, setStatus, @@ -518,6 +541,7 @@ export function FlowEditorProvider({ updateNode, updateNodeConfig, updateNodePosition, + updateNodePositions, removeNode, save, setStatus, From 242e2c20734bdb40b22809a67c270fa84d806ffe Mon Sep 17 00:00:00 2001 From: dhruv465 Date: Sun, 31 May 2026 21:49:14 +0530 Subject: [PATCH 2/2] fix: keep auto-layout hydration clean --- .../flows/flow-editor-state.test.ts | 43 +++++++++++++++++++ src/components/flows/flow-editor-state.tsx | 34 +++++++++------ 2 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/components/flows/flow-editor-state.test.ts b/src/components/flows/flow-editor-state.test.ts index afc506a..45b204e 100644 --- a/src/components/flows/flow-editor-state.test.ts +++ b/src/components/flows/flow-editor-state.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { + applyNodePositions, defaultConfigFor, uniqueNodeKey, } from "./flow-editor-state"; @@ -33,6 +34,48 @@ describe("uniqueNodeKey", () => { }); }); +describe("applyNodePositions", () => { + it("rounds and applies positions without changing unrelated nodes", () => { + const nodes: BuilderNode[] = [ + { + node_key: "start", + node_type: "start", + config: {}, + position_x: 0, + position_y: 0, + }, + { + node_key: "message", + node_type: "send_message", + config: {}, + position_x: 10, + position_y: 20, + }, + ]; + + expect( + applyNodePositions(nodes, { + start: { x: 10.4, y: 20.6 }, + }), + ).toEqual([ + { + node_key: "start", + node_type: "start", + config: {}, + position_x: 10, + position_y: 21, + }, + { + node_key: "message", + node_type: "send_message", + config: {}, + position_x: 10, + position_y: 20, + }, + ]); + }); +}); + describe("defaultConfigFor", () => { // The hook's addNode and the validator both depend on these defaults // being self-consistent. A broken default would surface as a diff --git a/src/components/flows/flow-editor-state.tsx b/src/components/flows/flow-editor-state.tsx index 6a9af29..453995f 100644 --- a/src/components/flows/flow-editor-state.tsx +++ b/src/components/flows/flow-editor-state.tsx @@ -189,6 +189,22 @@ export function defaultConfigFor(type: NodeType): Record { } } +export function applyNodePositions( + nodes: BuilderNode[], + positions: Record, +): BuilderNode[] { + return nodes.map((n) => { + const next = positions[n.node_key]; + return next + ? { + ...n, + position_x: Math.round(next.x), + position_y: Math.round(next.y), + } + : n; + }); +} + // ============================================================ // Context // ============================================================ @@ -443,21 +459,15 @@ export function FlowEditorProvider({ const updateNodePositions = useCallback( (positions: Record) => { - setState((s) => ({ + // Initial Dagre layout hydration should not dirty the editor: + // opening a legacy all-zero flow must not enable Save or arm + // beforeunload before the user actually edits anything. + setStateRaw((s) => ({ ...s, - nodes: s.nodes.map((n) => { - const next = positions[n.node_key]; - return next - ? { - ...n, - position_x: Math.round(next.x), - position_y: Math.round(next.y), - } - : n; - }), + nodes: applyNodePositions(s.nodes, positions), })); }, - [setState], + [], ); const addNode = useCallback(