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

View file

@ -3,7 +3,7 @@
* Delegates hit testing to strategy pattern.
*/
import { useRef, useCallback } from 'react';
import { useRef, useCallback, useMemo } from 'react';
import type { GraphNode } from '../ports/types';
import { ANIM } from '../constants/canvas-constants';
import { findNodeAt } from '../canvas/hit-detection';
@ -81,13 +81,16 @@ export function useGraphInteraction(
return findNodeAt(wx, wy, nodes);
}, []);
return {
hoveredNodeId,
dragNodeId,
isDragging,
handleMouseDown,
handleMouseMove,
handleMouseUp,
handleDoubleClick,
};
return useMemo(
() => ({
hoveredNodeId,
dragNodeId,
isDragging,
handleMouseDown,
handleMouseMove,
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 { getStateColor } from '../constants/colors';
@ -239,19 +239,29 @@ export function useGraphSimulation(): UseGraphSimulationResult {
};
}, []);
return {
stateRef,
updateData,
tick,
setNodePosition,
clearNodePosition,
clearTransientOwnerPositions,
resolveNearestOwnerSlot,
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
getExtraWorldBounds: () => extraWorldBoundsRef.current,
};
return useMemo(
() => ({
stateRef,
updateData,
tick,
setNodePosition,
clearNodePosition,
clearTransientOwnerPositions,
resolveNearestOwnerSlot,
getLaunchAnchorWorldPosition: (leadNodeId: string) =>
launchAnchorPositionsRef.current.get(leadNodeId) ?? null,
getActivityWorldRect: (nodeId: string) => activityRectByNodeIdRef.current.get(nodeId) ?? null,
getExtraWorldBounds: () => extraWorldBoundsRef.current,
}),
[
updateData,
tick,
setNodePosition,
clearNodePosition,
clearTransientOwnerPositions,
resolveNearestOwnerSlot,
]
);
}
function applySnapshotToNodes(

View file

@ -32,7 +32,7 @@ import {
findNodeAt,
getEdgeMidpoint,
} 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';
export interface GraphViewProps {
@ -148,13 +148,6 @@ export function GraphView({
// ─── Hooks ──────────────────────────────────────────────────────────────
const simulation = useGraphSimulation();
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(
useCallback(
(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(
(nodes: GraphNode[]): GraphNode[] =>
nodes.filter((node) => {
@ -433,16 +440,16 @@ export function GraphView({
}, []);
useLayoutEffect(() => {
if (!isSurfaceActive) {
if (isSurfaceActive) {
return;
}
interaction.handleMouseUp();
simulation.clearTransientOwnerPositions();
interactionRef.current.handleMouseUp();
simulationRef.current.clearTransientOwnerPositions();
dragPreviewRef.current = null;
isPanningRef.current = false;
edgeMouseDownRef.current = null;
setInteractionGuards(false);
}, [interaction, isSurfaceActive, simulation]);
}, [isSurfaceActive, setInteractionGuards]);
const handleWheel = useCallback(
(e: WheelEvent) => {
@ -454,7 +461,13 @@ export function GraphView({
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
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(
(e: React.MouseEvent) => {
@ -491,7 +504,13 @@ export function GraphView({
if (hitEdge) {
markUserInteracted();
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;
} else {
// Hit empty space → pan
@ -518,11 +537,6 @@ export function GraphView({
const processActivePointerMove = useCallback(
(clientX: number, clientY: number) => {
if (!activePrimaryInteractionRef.current) {
dragPreviewRef.current = null;
return false;
}
if (isPanningRef.current) {
if (typeof document !== 'undefined') {
document.getSelection()?.removeAllRanges();
@ -531,6 +545,36 @@ export function GraphView({
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();
if (!canvas) {
dragPreviewRef.current = null;
@ -627,8 +671,8 @@ export function GraphView({
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top);
const dx = world.x - edgeMouseDownRef.current.x;
const dy = world.y - edgeMouseDownRef.current.y;
const dx = world.x - edgeMouseDownRef.current.worldX;
const dy = world.y - edgeMouseDownRef.current.worldY;
if (dx * dx + dy * dy <= 25) {
clickedEdgeId = edgeMouseDownRef.current.id;
}
@ -656,6 +700,8 @@ export function GraphView({
},
[camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation]
);
processActivePointerMoveRef.current = processActivePointerMove;
completePointerInteractionRef.current = completePointerInteraction;
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
@ -711,36 +757,40 @@ export function GraphView({
if (
!activePrimaryInteractionRef.current &&
!isPanningRef.current &&
!interaction.dragNodeId.current &&
!interaction.isDragging.current &&
!interactionRef.current.dragNodeId.current &&
!interactionRef.current.isDragging.current &&
!edgeMouseDownRef.current
) {
return;
}
event.preventDefault();
processActivePointerMove(event.clientX, event.clientY);
processActivePointerMoveRef.current?.(event.clientX, event.clientY);
};
const handleWindowMouseUp = (event: MouseEvent): void => {
if (
!activePrimaryInteractionRef.current &&
!isPanningRef.current &&
!interaction.dragNodeId.current &&
!interaction.isDragging.current &&
!interactionRef.current.dragNodeId.current &&
!interactionRef.current.isDragging.current &&
!edgeMouseDownRef.current
) {
setInteractionGuards(false);
return;
}
completePointerInteraction(event.clientX, event.clientY);
completePointerInteractionRef.current?.(event.clientX, event.clientY);
};
const clearInteraction = (): void => {
if (!activePrimaryInteractionRef.current && !isPanningRef.current && !interaction.isDragging.current) {
if (
!activePrimaryInteractionRef.current &&
!isPanningRef.current &&
!interactionRef.current.isDragging.current
) {
return;
}
interaction.handleMouseUp();
camera.handlePanEnd();
interactionRef.current.handleMouseUp();
cameraRef.current.handlePanEnd();
isPanningRef.current = false;
edgeMouseDownRef.current = null;
dragPreviewRef.current = null;
@ -756,9 +806,14 @@ export function GraphView({
window.removeEventListener('mouseup', handleWindowMouseUp);
window.removeEventListener('blur', clearInteraction);
window.removeEventListener('dragstart', clearInteraction);
};
}, [setInteractionGuards]);
useEffect(() => {
return () => {
setInteractionGuards(false);
};
}, [camera, completePointerInteraction, interaction, processActivePointerMove, setInteractionGuards]);
}, [setInteractionGuards]);
const handleDoubleClick = useCallback(
(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';
let capturedCamera: UseGraphCameraResult | null = null;
let firstCamera: UseGraphCameraResult | null = null;
let secondCamera: UseGraphCameraResult | null = null;
function CameraHarness(): React.JSX.Element | null {
capturedCamera = useGraphCamera();
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', () => {
afterEach(() => {
capturedCamera = null;
firstCamera = null;
secondCamera = null;
document.body.innerHTML = '';
});
@ -71,4 +85,29 @@ describe('useGraphCamera zoomToFit', () => {
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();
});
});
});