diff --git a/packages/agent-graph/src/hooks/useGraphCamera.ts b/packages/agent-graph/src/hooks/useGraphCamera.ts index 0a842b91..fbfa2b38 100644 --- a/packages/agent-graph/src/hooks/useGraphCamera.ts +++ b/packages/agent-graph/src/hooks/useGraphCamera.ts @@ -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, + ] + ); } diff --git a/packages/agent-graph/src/hooks/useGraphInteraction.ts b/packages/agent-graph/src/hooks/useGraphInteraction.ts index 33862ef3..7fdbf13e 100644 --- a/packages/agent-graph/src/hooks/useGraphInteraction.ts +++ b/packages/agent-graph/src/hooks/useGraphInteraction.ts @@ -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] + ); } diff --git a/packages/agent-graph/src/hooks/useGraphSimulation.ts b/packages/agent-graph/src/hooks/useGraphSimulation.ts index cd4d62ad..db34adde 100644 --- a/packages/agent-graph/src/hooks/useGraphSimulation.ts +++ b/packages/agent-graph/src/hooks/useGraphSimulation.ts @@ -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( diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index c9f1d744..4f26e365 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -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) => { diff --git a/test/renderer/features/agent-graph/GraphView.test.ts b/test/renderer/features/agent-graph/GraphView.test.ts new file mode 100644 index 00000000..e2f1908c --- /dev/null +++ b/test/renderer/features/agent-graph/GraphView.test.ts @@ -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); + }); +}); diff --git a/test/renderer/features/agent-graph/useGraphCamera.test.ts b/test/renderer/features/agent-graph/useGraphCamera.test.ts index b4b518d5..ca804fc3 100644 --- a/test/renderer/features/agent-graph/useGraphCamera.test.ts +++ b/test/renderer/features/agent-graph/useGraphCamera.test.ts @@ -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(); + }); + }); }); diff --git a/test/renderer/features/agent-graph/useGraphInteraction.test.ts b/test/renderer/features/agent-graph/useGraphInteraction.test.ts new file mode 100644 index 00000000..767ece9d --- /dev/null +++ b/test/renderer/features/agent-graph/useGraphInteraction.test.ts @@ -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(); + }); + }); +}); diff --git a/test/renderer/features/agent-graph/useGraphSimulationIdentity.test.ts b/test/renderer/features/agent-graph/useGraphSimulationIdentity.test.ts new file mode 100644 index 00000000..4bbf58cc --- /dev/null +++ b/test/renderer/features/agent-graph/useGraphSimulationIdentity.test.ts @@ -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(); + }); + }); +});