mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #163 from dhruv465/codex/fix-cache-flow-canvas-drag
fix flow drag responsiveness and stale chunks
This commit is contained in:
commit
47987acb06
@ -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",
|
||||
|
||||
@ -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<NodeData>[] = 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<NodeData>[] = 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<RfNode<NodeData>[]>(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<RfNode<NodeData>>[]) => {
|
||||
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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -97,6 +97,9 @@ export interface FlowEditorContextValue {
|
||||
updateNode: (key: string, patch: Partial<BuilderNode>) => void;
|
||||
updateNodeConfig: (key: string, patch: Record<string, unknown>) => void;
|
||||
updateNodePosition: (key: string, x: number, y: number) => void;
|
||||
updateNodePositions: (
|
||||
positions: Record<string, { x: number; y: number }>,
|
||||
) => void;
|
||||
removeNode: (key: string) => void;
|
||||
|
||||
// Actions
|
||||
@ -186,6 +189,22 @@ export function defaultConfigFor(type: NodeType): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
export function applyNodePositions(
|
||||
nodes: BuilderNode[],
|
||||
positions: Record<string, { x: number; y: number }>,
|
||||
): 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
|
||||
// ============================================================
|
||||
@ -438,6 +457,19 @@ export function FlowEditorProvider({
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateNodePositions = useCallback(
|
||||
(positions: Record<string, { x: number; y: number }>) => {
|
||||
// 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: applyNodePositions(s.nodes, positions),
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const addNode = useCallback(
|
||||
(type: NodeType): string => {
|
||||
const meta = NODE_META[type];
|
||||
@ -498,6 +530,7 @@ export function FlowEditorProvider({
|
||||
updateNode,
|
||||
updateNodeConfig,
|
||||
updateNodePosition,
|
||||
updateNodePositions,
|
||||
removeNode,
|
||||
save,
|
||||
setStatus,
|
||||
@ -518,6 +551,7 @@ export function FlowEditorProvider({
|
||||
updateNode,
|
||||
updateNodeConfig,
|
||||
updateNodePosition,
|
||||
updateNodePositions,
|
||||
removeNode,
|
||||
save,
|
||||
setStatus,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user