Merge pull request #163 from dhruv465/codex/fix-cache-flow-canvas-drag

fix flow drag responsiveness and stale chunks
This commit is contained in:
Arnas Donauskas 2026-06-02 13:39:03 +03:00 committed by GitHub
commit 47987acb06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 128 additions and 25 deletions

View File

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

View File

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

View File

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

View File

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