fix(agent-graph): stabilize drag and pan interactions

This commit is contained in:
777genius 2026-04-18 18:32:04 +03:00
parent 2fd06fcd48
commit fb3d1ceb27
8 changed files with 692 additions and 69 deletions

View file

@ -4,7 +4,7 @@
* All state in refs no React re-renders. * All state in refs no React re-renders.
*/ */
import { useRef, useCallback } from 'react'; import { useRef, useCallback, useMemo } from 'react';
import type { GraphNode } from '../ports/types'; import type { GraphNode } from '../ports/types';
import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants'; import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants';
import type { WorldBounds } from '../layout/launchAnchor'; import type { WorldBounds } from '../layout/launchAnchor';
@ -170,17 +170,31 @@ export function useGraphCamera(): UseGraphCameraResult {
t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2); t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2);
}, []); }, []);
return { return useMemo(
transformRef, () => ({
screenToWorld, transformRef,
worldToScreen, screenToWorld,
handleWheel, worldToScreen,
handlePanStart, handleWheel,
handlePanMove, handlePanStart,
handlePanEnd, handlePanMove,
zoomToFit, handlePanEnd,
zoomIn, zoomToFit,
zoomOut, zoomIn,
updateInertia, zoomOut,
}; updateInertia,
}),
[
screenToWorld,
worldToScreen,
handleWheel,
handlePanStart,
handlePanMove,
handlePanEnd,
zoomToFit,
zoomIn,
zoomOut,
updateInertia,
]
);
} }

View file

@ -3,7 +3,7 @@
* Delegates hit testing to strategy pattern. * Delegates hit testing to strategy pattern.
*/ */
import { useRef, useCallback } from 'react'; import { useRef, useCallback, useMemo } from 'react';
import type { GraphNode } from '../ports/types'; import type { GraphNode } from '../ports/types';
import { ANIM } from '../constants/canvas-constants'; import { ANIM } from '../constants/canvas-constants';
import { findNodeAt } from '../canvas/hit-detection'; import { findNodeAt } from '../canvas/hit-detection';
@ -81,13 +81,16 @@ export function useGraphInteraction(
return findNodeAt(wx, wy, nodes); return findNodeAt(wx, wy, nodes);
}, []); }, []);
return { return useMemo(
hoveredNodeId, () => ({
dragNodeId, hoveredNodeId,
isDragging, dragNodeId,
handleMouseDown, isDragging,
handleMouseMove, handleMouseDown,
handleMouseUp, handleMouseMove,
handleDoubleClick, handleMouseUp,
}; handleDoubleClick,
}),
[handleDoubleClick, handleMouseDown, handleMouseMove, handleMouseUp]
);
} }

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { ANIM_SPEED, NODE } from '../constants/canvas-constants'; import { ANIM_SPEED, NODE } from '../constants/canvas-constants';
import { getStateColor } from '../constants/colors'; import { getStateColor } from '../constants/colors';
@ -239,19 +239,29 @@ export function useGraphSimulation(): UseGraphSimulationResult {
}; };
}, []); }, []);
return { return useMemo(
stateRef, () => ({
updateData, stateRef,
tick, updateData,
setNodePosition, tick,
clearNodePosition, setNodePosition,
clearTransientOwnerPositions, clearNodePosition,
resolveNearestOwnerSlot, clearTransientOwnerPositions,
getLaunchAnchorWorldPosition: (leadNodeId: string) => resolveNearestOwnerSlot,
launchAnchorPositionsRef.current.get(leadNodeId) ?? null, getLaunchAnchorWorldPosition: (leadNodeId: string) =>
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null, launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
getExtraWorldBounds: () => extraWorldBoundsRef.current, getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
}; getExtraWorldBounds: () => extraWorldBoundsRef.current,
}),
[
updateData,
tick,
setNodePosition,
clearNodePosition,
clearTransientOwnerPositions,
resolveNearestOwnerSlot,
]
);
} }
function applySnapshotToNodes( function applySnapshotToNodes(

View file

@ -32,7 +32,7 @@ import {
findNodeAt, findNodeAt,
getEdgeMidpoint, getEdgeMidpoint,
} from '../canvas/hit-detection'; } from '../canvas/hit-detection';
import { ANIM_SPEED } from '../constants/canvas-constants'; import { ANIM, ANIM_SPEED } from '../constants/canvas-constants';
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor'; import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
export interface GraphViewProps { export interface GraphViewProps {
@ -148,13 +148,6 @@ export function GraphView({
// ─── Hooks ────────────────────────────────────────────────────────────── // ─── Hooks ──────────────────────────────────────────────────────────────
const simulation = useGraphSimulation(); const simulation = useGraphSimulation();
const camera = useGraphCamera(); const camera = useGraphCamera();
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
const simulationRef = useRef(simulation);
simulationRef.current = simulation;
const cameraRef = useRef(camera);
cameraRef.current = camera;
const interaction = useGraphInteraction( const interaction = useGraphInteraction(
useCallback( useCallback(
(nodeId: string, x: number, y: number) => { (nodeId: string, x: number, y: number) => {
@ -164,6 +157,20 @@ export function GraphView({
) )
); );
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
const simulationRef = useRef(simulation);
simulationRef.current = simulation;
const cameraRef = useRef(camera);
cameraRef.current = camera;
const interactionRef = useRef(interaction);
interactionRef.current = interaction;
const processActivePointerMoveRef = useRef<((clientX: number, clientY: number) => boolean) | null>(
null
);
const completePointerInteractionRef = useRef<((clientX: number, clientY: number) => void) | null>(
null
);
const getVisibleNodes = useCallback( const getVisibleNodes = useCallback(
(nodes: GraphNode[]): GraphNode[] => (nodes: GraphNode[]): GraphNode[] =>
nodes.filter((node) => { nodes.filter((node) => {
@ -433,16 +440,16 @@ export function GraphView({
}, []); }, []);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!isSurfaceActive) { if (isSurfaceActive) {
return; return;
} }
interaction.handleMouseUp(); interactionRef.current.handleMouseUp();
simulation.clearTransientOwnerPositions(); simulationRef.current.clearTransientOwnerPositions();
dragPreviewRef.current = null; dragPreviewRef.current = null;
isPanningRef.current = false; isPanningRef.current = false;
edgeMouseDownRef.current = null; edgeMouseDownRef.current = null;
setInteractionGuards(false); setInteractionGuards(false);
}, [interaction, isSurfaceActive, simulation]); }, [isSurfaceActive, setInteractionGuards]);
const handleWheel = useCallback( const handleWheel = useCallback(
(e: WheelEvent) => { (e: WheelEvent) => {
@ -454,7 +461,13 @@ export function GraphView({
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─ // ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
const isPanningRef = useRef(false); const isPanningRef = useRef(false);
const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null); const edgeMouseDownRef = useRef<{
id: string;
worldX: number;
worldY: number;
clientX: number;
clientY: number;
} | null>(null);
const handleMouseDown = useCallback( const handleMouseDown = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@ -491,7 +504,13 @@ export function GraphView({
if (hitEdge) { if (hitEdge) {
markUserInteracted(); markUserInteracted();
isPanningRef.current = false; isPanningRef.current = false;
edgeMouseDownRef.current = { id: hitEdge, x: world.x, y: world.y }; edgeMouseDownRef.current = {
id: hitEdge,
worldX: world.x,
worldY: world.y,
clientX: e.clientX,
clientY: e.clientY,
};
hoveredEdgeIdRef.current = hitEdge; hoveredEdgeIdRef.current = hitEdge;
} else { } else {
// Hit empty space → pan // Hit empty space → pan
@ -518,11 +537,6 @@ export function GraphView({
const processActivePointerMove = useCallback( const processActivePointerMove = useCallback(
(clientX: number, clientY: number) => { (clientX: number, clientY: number) => {
if (!activePrimaryInteractionRef.current) {
dragPreviewRef.current = null;
return false;
}
if (isPanningRef.current) { if (isPanningRef.current) {
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.getSelection()?.removeAllRanges(); document.getSelection()?.removeAllRanges();
@ -531,6 +545,36 @@ export function GraphView({
return true; return true;
} }
const edgeMouseDown = edgeMouseDownRef.current;
if (
edgeMouseDown &&
!interaction.dragNodeId.current &&
!interaction.isDragging.current
) {
const dx = clientX - edgeMouseDown.clientX;
const dy = clientY - edgeMouseDown.clientY;
if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) {
if (typeof document !== 'undefined') {
document.getSelection()?.removeAllRanges();
}
hoveredEdgeIdRef.current = null;
edgeMouseDownRef.current = null;
isPanningRef.current = true;
camera.handlePanStart(edgeMouseDown.clientX, edgeMouseDown.clientY);
camera.handlePanMove(clientX, clientY);
return true;
}
}
if (
!activePrimaryInteractionRef.current &&
!interaction.dragNodeId.current &&
!interaction.isDragging.current
) {
dragPreviewRef.current = null;
return false;
}
const canvas = canvasHandle.current?.getCanvas(); const canvas = canvasHandle.current?.getCanvas();
if (!canvas) { if (!canvas) {
dragPreviewRef.current = null; dragPreviewRef.current = null;
@ -627,8 +671,8 @@ export function GraphView({
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) { if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top); const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
const dx = world.x - edgeMouseDownRef.current.x; const dx = world.x - edgeMouseDownRef.current.worldX;
const dy = world.y - edgeMouseDownRef.current.y; const dy = world.y - edgeMouseDownRef.current.worldY;
if (dx * dx + dy * dy <= 25) { if (dx * dx + dy * dy <= 25) {
clickedEdgeId = edgeMouseDownRef.current.id; clickedEdgeId = edgeMouseDownRef.current.id;
} }
@ -656,6 +700,8 @@ export function GraphView({
}, },
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation] [camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
); );
processActivePointerMoveRef.current = processActivePointerMove;
completePointerInteractionRef.current = completePointerInteraction;
const handleMouseMove = useCallback( const handleMouseMove = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@ -711,36 +757,40 @@ export function GraphView({
if ( if (
!activePrimaryInteractionRef.current && !activePrimaryInteractionRef.current &&
!isPanningRef.current && !isPanningRef.current &&
!interaction.dragNodeId.current && !interactionRef.current.dragNodeId.current &&
!interaction.isDragging.current && !interactionRef.current.isDragging.current &&
!edgeMouseDownRef.current !edgeMouseDownRef.current
) { ) {
return; return;
} }
event.preventDefault(); event.preventDefault();
processActivePointerMove(event.clientX, event.clientY); processActivePointerMoveRef.current?.(event.clientX, event.clientY);
}; };
const handleWindowMouseUp = (event: MouseEvent): void => { const handleWindowMouseUp = (event: MouseEvent): void => {
if ( if (
!activePrimaryInteractionRef.current && !activePrimaryInteractionRef.current &&
!isPanningRef.current && !isPanningRef.current &&
!interaction.dragNodeId.current && !interactionRef.current.dragNodeId.current &&
!interaction.isDragging.current && !interactionRef.current.isDragging.current &&
!edgeMouseDownRef.current !edgeMouseDownRef.current
) { ) {
setInteractionGuards(false); setInteractionGuards(false);
return; return;
} }
completePointerInteraction(event.clientX, event.clientY); completePointerInteractionRef.current?.(event.clientX, event.clientY);
}; };
const clearInteraction = (): void => { const clearInteraction = (): void => {
if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) { if (
!activePrimaryInteractionRef.current &&
!isPanningRef.current &&
!interactionRef.current.isDragging.current
) {
return; return;
} }
interaction.handleMouseUp(); interactionRef.current.handleMouseUp();
camera.handlePanEnd(); cameraRef.current.handlePanEnd();
isPanningRef.current = false; isPanningRef.current = false;
edgeMouseDownRef.current = null; edgeMouseDownRef.current = null;
dragPreviewRef.current = null; dragPreviewRef.current = null;
@ -756,9 +806,14 @@ export function GraphView({
window.removeEventListener('mouseup', handleWindowMouseUp); window.removeEventListener('mouseup', handleWindowMouseUp);
window.removeEventListener('blur', clearInteraction); window.removeEventListener('blur', clearInteraction);
window.removeEventListener('dragstart', clearInteraction); window.removeEventListener('dragstart', clearInteraction);
};
}, [setInteractionGuards]);
useEffect(() => {
return () => {
setInteractionGuards(false); setInteractionGuards(false);
}; };
}, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]); }, [setInteractionGuards]);
const handleDoubleClick = useCallback( const handleDoubleClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {

View file

@ -0,0 +1,400 @@
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { GraphEdge, GraphNode } from '@claude-teams/agent-graph';
import { getEdgeMidpoint } from '../../../../packages/agent-graph/src/canvas/hit-detection';
const hoisted = vi.hoisted(() => ({
handlePanStart: vi.fn(),
handlePanMove: vi.fn(),
handlePanEnd: vi.fn(),
zoomToFit: vi.fn(),
zoomIn: vi.fn(),
zoomOut: vi.fn(),
updateInertia: vi.fn(),
interaction: {
hoveredNodeId: { current: null as string | null },
dragNodeId: { current: null as string | null },
isDragging: { current: false },
handleMouseDown: vi.fn(),
handleMouseMove: vi.fn(),
handleMouseUp: vi.fn(() => null),
handleDoubleClick: vi.fn(() => null),
},
simulationState: {
nodes: [] as GraphNode[],
edges: [] as GraphEdge[],
particles: [],
effects: [],
time: 0,
},
clearTransientOwnerPositions: vi.fn(),
}));
vi.mock('../../../../packages/agent-graph/src/hooks/useGraphCamera', () => ({
useGraphCamera: () => ({
transformRef: { current: { x: 0, y: 0, zoom: 1 } },
screenToWorld: (sx: number, sy: number) => ({ x: sx, y: sy }),
worldToScreen: (wx: number, wy: number) => ({ x: wx, y: wy }),
handleWheel: vi.fn(),
handlePanStart: hoisted.handlePanStart,
handlePanMove: hoisted.handlePanMove,
handlePanEnd: hoisted.handlePanEnd,
zoomToFit: hoisted.zoomToFit,
zoomIn: hoisted.zoomIn,
zoomOut: hoisted.zoomOut,
updateInertia: hoisted.updateInertia,
}),
}));
vi.mock('../../../../packages/agent-graph/src/hooks/useGraphInteraction', () => ({
useGraphInteraction: () => hoisted.interaction,
}));
vi.mock('../../../../packages/agent-graph/src/hooks/useGraphSimulation', () => ({
useGraphSimulation: () => ({
stateRef: { current: hoisted.simulationState },
updateData: vi.fn(),
tick: vi.fn(),
getExtraWorldBounds: vi.fn(() => []),
getLaunchAnchorWorldPosition: vi.fn(() => null),
getActivityWorldRect: vi.fn(() => null),
resolveNearestOwnerSlot: vi.fn(() => null),
clearNodePosition: vi.fn(),
clearTransientOwnerPositions: hoisted.clearTransientOwnerPositions,
setNodePosition: vi.fn(),
}),
}));
vi.mock('../../../../packages/agent-graph/src/ui/GraphControls', () => ({
GraphControls: () => null,
}));
vi.mock('../../../../packages/agent-graph/src/ui/GraphOverlay', () => ({
GraphOverlay: () => null,
}));
vi.mock('../../../../packages/agent-graph/src/ui/GraphEdgeOverlay', () => ({
GraphEdgeOverlay: () => null,
}));
import { GraphView } from '../../../../packages/agent-graph/src/ui/GraphView';
describe('GraphView pan interactions', () => {
let container: HTMLDivElement;
let root: Root;
let originalGetBoundingClientRect: typeof HTMLCanvasElement.prototype.getBoundingClientRect;
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
hoisted.interaction.hoveredNodeId.current = null;
hoisted.interaction.dragNodeId.current = null;
hoisted.interaction.isDragging.current = false;
hoisted.simulationState.nodes = [];
hoisted.simulationState.edges = [];
vi.stubGlobal(
'ResizeObserver',
class {
observe(): void {}
disconnect(): void {}
}
);
vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1));
vi.stubGlobal('cancelAnimationFrame', vi.fn());
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
originalGetBoundingClientRect = HTMLCanvasElement.prototype.getBoundingClientRect;
HTMLCanvasElement.prototype.getBoundingClientRect = function getBoundingClientRect(): DOMRect {
return DOMRect.fromRect({ x: 0, y: 0, width: 800, height: 600 });
};
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
container.remove();
HTMLCanvasElement.prototype.getBoundingClientRect = originalGetBoundingClientRect;
vi.unstubAllGlobals();
});
it('starts panning when dragging from a hit-tested edge instead of getting stuck on edge selection', async () => {
const source: GraphNode = {
id: 'member:alice',
kind: 'member',
label: 'alice',
state: 'idle',
x: 0,
y: 0,
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' },
};
const target: GraphNode = {
id: 'task:1',
kind: 'task',
label: 'Task 1',
state: 'idle',
x: 160,
y: 90,
domainRef: { kind: 'task', teamName: 'demo-team', taskId: 'task:1' },
};
const edge: GraphEdge = {
id: 'edge:blocking',
source: source.id,
target: target.id,
type: 'blocking',
};
hoisted.simulationState.nodes = [source, target];
hoisted.simulationState.edges = [edge];
const midpoint = getEdgeMidpoint(edge, new Map([
[source.id, source],
[target.id, target],
]));
expect(midpoint).not.toBeNull();
await act(async () => {
root.render(
React.createElement(GraphView, {
data: {
teamName: 'demo-team',
nodes: [source, target],
edges: [edge],
particles: [],
},
config: { animationEnabled: false },
})
);
});
const canvas = container.querySelector('canvas');
expect(canvas).not.toBeNull();
await act(async () => {
canvas!.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: midpoint!.x,
clientY: midpoint!.y,
})
);
canvas!.dispatchEvent(
new MouseEvent('mousemove', {
bubbles: true,
buttons: 1,
clientX: midpoint!.x + 24,
clientY: midpoint!.y + 4,
})
);
});
expect(hoisted.handlePanStart).toHaveBeenCalledWith(midpoint!.x, midpoint!.y);
expect(hoisted.handlePanMove).toHaveBeenCalledWith(midpoint!.x + 24, midpoint!.y + 4);
});
it('does not clear pan state on the rerender triggered by interaction lock', async () => {
const source: GraphNode = {
id: 'member:alice',
kind: 'member',
label: 'alice',
state: 'idle',
x: 0,
y: 0,
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' },
};
hoisted.simulationState.nodes = [source];
hoisted.simulationState.edges = [];
await act(async () => {
root.render(
React.createElement(GraphView, {
data: {
teamName: 'demo-team',
nodes: [source],
edges: [],
particles: [],
},
config: { animationEnabled: false },
})
);
});
const canvas = container.querySelector('canvas');
expect(canvas).not.toBeNull();
await act(async () => {
canvas!.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 320,
clientY: 220,
})
);
await Promise.resolve();
});
await act(async () => {
canvas!.dispatchEvent(
new MouseEvent('mousemove', {
bubbles: true,
buttons: 1,
clientX: 352,
clientY: 248,
})
);
});
expect(hoisted.handlePanStart).toHaveBeenCalledWith(320, 220);
expect(hoisted.handlePanMove).toHaveBeenCalledWith(352, 248);
});
it('does not force-handleMouseUp when props rerender during an active member drag', async () => {
const source: GraphNode = {
id: 'member:demo-team:alice',
kind: 'member',
label: 'alice',
state: 'idle',
x: 80,
y: 80,
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' },
};
hoisted.simulationState.nodes = [source];
hoisted.simulationState.edges = [];
hoisted.interaction.handleMouseDown.mockImplementation(() => {
hoisted.interaction.dragNodeId.current = source.id;
});
hoisted.interaction.handleMouseMove.mockImplementation(() => {
hoisted.interaction.isDragging.current = true;
});
const firstEvents = {};
const secondEvents = {};
await act(async () => {
root.render(
React.createElement(GraphView, {
data: {
teamName: 'demo-team',
nodes: [source],
edges: [],
particles: [],
},
events: firstEvents,
config: { animationEnabled: false },
})
);
});
const canvas = container.querySelector('canvas');
expect(canvas).not.toBeNull();
await act(async () => {
canvas!.dispatchEvent(
new MouseEvent('mousedown', {
bubbles: true,
button: 0,
clientX: 80,
clientY: 80,
})
);
canvas!.dispatchEvent(
new MouseEvent('mousemove', {
bubbles: true,
buttons: 1,
clientX: 95,
clientY: 95,
})
);
});
expect(hoisted.interaction.isDragging.current).toBe(true);
await act(async () => {
root.render(
React.createElement(GraphView, {
data: {
teamName: 'demo-team',
nodes: [source],
edges: [],
particles: [],
},
events: secondEvents,
config: { animationEnabled: false },
})
);
});
expect(hoisted.interaction.handleMouseUp).not.toHaveBeenCalled();
await act(async () => {
window.dispatchEvent(
new MouseEvent('mousemove', {
bubbles: true,
buttons: 1,
clientX: 112,
clientY: 112,
})
);
});
expect(hoisted.interaction.handleMouseMove).toHaveBeenCalled();
expect(hoisted.interaction.isDragging.current).toBe(true);
});
it('clears drag state when the graph surface becomes inactive', async () => {
const source: GraphNode = {
id: 'member:demo-team:alice',
kind: 'member',
label: 'alice',
state: 'idle',
x: 80,
y: 80,
domainRef: { kind: 'member', teamName: 'demo-team', memberName: 'alice' },
};
hoisted.simulationState.nodes = [source];
hoisted.simulationState.edges = [];
hoisted.interaction.dragNodeId.current = source.id;
hoisted.interaction.isDragging.current = true;
await act(async () => {
root.render(
React.createElement(GraphView, {
data: {
teamName: 'demo-team',
nodes: [source],
edges: [],
particles: [],
},
config: { animationEnabled: false },
isSurfaceActive: true,
})
);
});
expect(hoisted.interaction.handleMouseUp).not.toHaveBeenCalled();
expect(hoisted.clearTransientOwnerPositions).not.toHaveBeenCalled();
await act(async () => {
root.render(
React.createElement(GraphView, {
data: {
teamName: 'demo-team',
nodes: [source],
edges: [],
particles: [],
},
config: { animationEnabled: false },
isSurfaceActive: false,
})
);
});
expect(hoisted.interaction.handleMouseUp).toHaveBeenCalledTimes(1);
expect(hoisted.clearTransientOwnerPositions).toHaveBeenCalledTimes(1);
});
});

View file

@ -7,15 +7,29 @@ import { useGraphCamera, type UseGraphCameraResult } from '../../../../packages/
import type { GraphNode } from '@claude-teams/agent-graph'; import type { GraphNode } from '@claude-teams/agent-graph';
let capturedCamera: UseGraphCameraResult | null = null; let capturedCamera: UseGraphCameraResult | null = null;
let firstCamera: UseGraphCameraResult | null = null;
let secondCamera: UseGraphCameraResult | null = null;
function CameraHarness(): React.JSX.Element | null { function CameraHarness(): React.JSX.Element | null {
capturedCamera = useGraphCamera(); capturedCamera = useGraphCamera();
return null; return null;
} }
function CameraIdentityHarness({ pass }: { pass: number }): React.JSX.Element | null {
const camera = useGraphCamera();
if (pass === 1) {
firstCamera = camera;
} else {
secondCamera = camera;
}
return null;
}
describe('useGraphCamera zoomToFit', () => { describe('useGraphCamera zoomToFit', () => {
afterEach(() => { afterEach(() => {
capturedCamera = null; capturedCamera = null;
firstCamera = null;
secondCamera = null;
document.body.innerHTML = ''; document.body.innerHTML = '';
}); });
@ -71,4 +85,29 @@ describe('useGraphCamera zoomToFit', () => {
await Promise.resolve(); await Promise.resolve();
}); });
}); });
it('returns a referentially stable result across rerenders', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(CameraIdentityHarness, { pass: 1 }));
await Promise.resolve();
});
await act(async () => {
root.render(React.createElement(CameraIdentityHarness, { pass: 2 }));
await Promise.resolve();
});
expect(firstCamera).toBeTruthy();
expect(secondCamera).toBe(firstCamera);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
}); });

View file

@ -0,0 +1,51 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useGraphInteraction, type UseGraphInteractionResult } from '../../../../packages/agent-graph/src/hooks/useGraphInteraction';
let firstInteraction: UseGraphInteractionResult | null = null;
let secondInteraction: UseGraphInteractionResult | null = null;
function InteractionHarness({ pass }: { pass: number }): React.JSX.Element | null {
const interaction = useGraphInteraction();
if (pass === 1) {
firstInteraction = interaction;
} else {
secondInteraction = interaction;
}
return null;
}
describe('useGraphInteraction', () => {
afterEach(() => {
firstInteraction = null;
secondInteraction = null;
document.body.innerHTML = '';
});
it('returns a referentially stable result across rerenders', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(InteractionHarness, { pass: 1 }));
await Promise.resolve();
});
await act(async () => {
root.render(React.createElement(InteractionHarness, { pass: 2 }));
await Promise.resolve();
});
expect(firstInteraction).toBeTruthy();
expect(secondInteraction).toBe(firstInteraction);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -0,0 +1,51 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useGraphSimulation, type UseGraphSimulationResult } from '../../../../packages/agent-graph/src/hooks/useGraphSimulation';
let firstSimulation: UseGraphSimulationResult | null = null;
let secondSimulation: UseGraphSimulationResult | null = null;
function SimulationHarness({ pass }: { pass: number }): React.JSX.Element | null {
const simulation = useGraphSimulation();
if (pass === 1) {
firstSimulation = simulation;
} else {
secondSimulation = simulation;
}
return null;
}
describe('useGraphSimulation', () => {
afterEach(() => {
firstSimulation = null;
secondSimulation = null;
document.body.innerHTML = '';
});
it('returns a referentially stable result across rerenders', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(SimulationHarness, { pass: 1 }));
await Promise.resolve();
});
await act(async () => {
root.render(React.createElement(SimulationHarness, { pass: 2 }));
await Promise.resolve();
});
expect(firstSimulation).toBeTruthy();
expect(secondSimulation).toBe(firstSimulation);
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});