diff --git a/packages/memory-graph/src/components/graph-canvas.tsx b/packages/memory-graph/src/components/graph-canvas.tsx index 100a9da2..c76bae2d 100644 --- a/packages/memory-graph/src/components/graph-canvas.tsx +++ b/packages/memory-graph/src/components/graph-canvas.tsx @@ -56,7 +56,7 @@ export const GraphCanvas = memo(function GraphCanvas({ ) // Node ID tracking for smart re-init - const prevIdsRef = useRef("") + // All mutable render state in a single ref — the rAF loop reads from here const s = useRef({ @@ -106,18 +106,6 @@ export const GraphCanvas = memo(function GraphCanvas({ renderNeeded.current = true }, [nodes]) - // Track node ID changes for smart simulation re-init - useEffect(() => { - const idKey = nodes - .map((n) => n.id) - .sort() - .join(",") - if (idKey !== prevIdsRef.current) { - prevIdsRef.current = idKey - // IDs changed - full re-init needed (handled by parent) - } - }, [nodes]) - useEffect(() => { s.current.highlightIds = new Set(highlightDocumentIds ?? []) renderNeeded.current = true @@ -200,7 +188,9 @@ export const GraphCanvas = memo(function GraphCanvas({ // Single render loop — runs for component lifetime, reads everything from refs // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally empty deps — all state read via refs inside tick(), not reactive props useEffect(() => { - let lastReportedZoom = 0 + let prevVpX = 0 + let prevVpY = 0 + let prevVpZoom = 0 const tick = () => { rafRef.current = requestAnimationFrame(tick) @@ -247,9 +237,17 @@ export const GraphCanvas = memo(function GraphCanvas({ return renderNeeded.current = false - // Report viewport changes (zoom + pan) so popover positions update - if (vpMoving && cb.current.onViewportChange) { + // Report viewport changes (zoom, pan, or simulation-driven node movement) + // so popover positions update. Covers: momentum, spring zoom, mouse drag, and sim. + const vpChanged = + vp.panX !== prevVpX || + vp.panY !== prevVpY || + vp.zoom !== prevVpZoom + if ((vpChanged || simActive) && cb.current.onViewportChange) { cb.current.onViewportChange(vp.zoom) + prevVpX = vp.panX + prevVpY = vp.panY + prevVpZoom = vp.zoom } renderFrame( diff --git a/packages/memory-graph/src/components/memory-graph.tsx b/packages/memory-graph/src/components/memory-graph.tsx index 89af6a8f..b238b4c7 100644 --- a/packages/memory-graph/src/components/memory-graph.tsx +++ b/packages/memory-graph/src/components/memory-graph.tsx @@ -453,7 +453,7 @@ export function MemoryGraph({ return nodes.find((n) => n.id === activeNodeId) ?? null }, [activeNodeId, nodes]) - // biome-ignore lint/correctness/useExhaustiveDependencies: zoomDisplay intentionally used as proxy for viewport state changes + // biome-ignore lint/correctness/useExhaustiveDependencies: viewportVersion intentionally used as proxy for viewport state changes const activePopoverPosition = useMemo(() => { if (!activeNodeData || !viewportRef.current) return null const vp = viewportRef.current @@ -463,9 +463,9 @@ export function MemoryGraph({ screenY: screen.y, nodeRadius: (activeNodeData.size * vp.zoom) / 2, } - // zoomDisplay triggers re-computation on viewport changes (pan/zoom) + // viewportVersion triggers re-computation on any viewport change (pan + zoom) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeNodeData, zoomDisplay]) + }, [activeNodeData, viewportVersion]) const activeVersionChain = useMemo(() => { if (!activeNodeData || activeNodeData.type !== "memory") return null