Jelajahi Sumber

Merge pull request #190 from ckeditor/t/144-new

Other: Aligned behaviors of `EmitterMixin` methods responsible for adding end removing listeners. Closes #144.

The `emitter.on()` now has the same behavior as `emitter.listenTo( emitter )` as well as `emitter.off()` is the same as `emitter.stopListening( emitter )`. This made `emitter.stopListening()` correctly remove all listeners added in any way to it which prevents memory leaks.
Piotrek Koszuliński 8 tahun lalu
induk
melakukan
1bf552e210

+ 23 - 35
packages/ckeditor5-utils/src/dom/emittermixin.js

@@ -40,6 +40,7 @@ const DomEmitterMixin = extend( {}, EmitterMixin, {
 	 * Registers a callback function to be executed when an event is fired in a specific Emitter or DOM Node.
 	 * It is backwards compatible with {@link module:utils/emittermixin~EmitterMixin#listenTo}.
 	 *
+	 * @method module:utils/dom/emittermixin~EmitterMixin#listenTo
 	 * @param {module:utils/emittermixin~Emitter|Node} emitter The object that fires the event.
 	 * @param {String} event The name of the event.
 	 * @param {Function} callback The function to be called on event.
@@ -49,20 +50,20 @@ const DomEmitterMixin = extend( {}, EmitterMixin, {
 	 * order they were added.
 	 * @param {Boolean} [options.useCapture=false] Indicates that events of this type will be dispatched to the registered
 	 * listener before being dispatched to any EventTarget beneath it in the DOM tree.
-	 *
-	 * @method module:utils/dom/emittermixin~EmitterMixin#listenTo
 	 */
-	listenTo( ...args ) {
-		const emitter = args[ 0 ];
-
+	listenTo( emitter, ...rest ) {
 		// Check if emitter is an instance of DOM Node. If so, replace the argument with
 		// corresponding ProxyEmitter (or create one if not existing).
 		if ( isDomNode( emitter ) || isWindow( emitter ) ) {
-			args[ 0 ] = this._getProxyEmitter( emitter ) || new ProxyEmitter( emitter );
+			const proxy = this._getProxyEmitter( emitter ) || new ProxyEmitter( emitter );
+
+			proxy.attach( ...rest );
+
+			emitter = proxy;
 		}
 
 		// Execute parent class method with Emitter (or ProxyEmitter) instance.
-		EmitterMixin.listenTo.apply( this, args );
+		EmitterMixin.listenTo.call( this, emitter, ...rest );
 	},
 
 	/**
@@ -74,17 +75,14 @@ const DomEmitterMixin = extend( {}, EmitterMixin, {
 	 * * To stop listening to all events fired by a specific object.
 	 * * To stop listening to all events fired by all object.
 	 *
+	 * @method module:utils/dom/emittermixin~EmitterMixin#stopListening
 	 * @param {module:utils/emittermixin~Emitter|Node} [emitter] The object to stop listening to. If omitted, stops it for all objects.
 	 * @param {String} [event] (Requires the `emitter`) The name of the event to stop listening to. If omitted, stops it
 	 * for all events from `emitter`.
 	 * @param {Function} [callback] (Requires the `event`) The function to be removed from the call list for the given
 	 * `event`.
-	 *
-	 * @method module:utils/dom/emittermixin~EmitterMixin#stopListening
 	 */
-	stopListening( ...args ) {
-		const emitter = args[ 0 ];
-
+	stopListening( emitter, event, callback ) {
 		// Check if emitter is an instance of DOM Node. If so, replace the argument with corresponding ProxyEmitter.
 		if ( isDomNode( emitter ) || isWindow( emitter ) ) {
 			const proxy = this._getProxyEmitter( emitter );
@@ -94,11 +92,15 @@ const DomEmitterMixin = extend( {}, EmitterMixin, {
 				return;
 			}
 
-			args[ 0 ] = proxy;
+			emitter = proxy;
 		}
 
 		// Execute parent class method with Emitter (or ProxyEmitter) instance.
-		EmitterMixin.stopListening.apply( this, args );
+		EmitterMixin.stopListening.call( this, emitter, event, callback );
+
+		if ( emitter instanceof ProxyEmitter ) {
+			emitter.detach( event );
+		}
 	},
 
 	/**
@@ -173,21 +175,14 @@ extend( ProxyEmitter.prototype, EmitterMixin, {
 	 * It attaches a native DOM listener to the DOM Node. When fired,
 	 * a corresponding Emitter event will also fire with DOM Event object as an argument.
 	 *
+	 * @method module:utils/dom/emittermixin~ProxyEmitter#attach
 	 * @param {String} event The name of the event.
 	 * @param {Function} callback The function to be called on event.
 	 * @param {Object} [options={}] Additional options.
-	 * @param {module:utils/priorities~PriorityString|Number} [options.priority='normal'] The priority of this event callback. The higher
-	 * the priority value the sooner the callback will be fired. Events having the same priority are called in the
-	 * order they were added.
 	 * @param {Boolean} [options.useCapture=false] Indicates that events of this type will be dispatched to the registered
 	 * listener before being dispatched to any EventTarget beneath it in the DOM tree.
-	 *
-	 * @method module:utils/dom/emittermixin~ProxyEmitter#on
 	 */
-	on( event, callback, options = {} ) {
-		// Execute parent class method first.
-		EmitterMixin.on.call( this, event, callback, options );
-
+	attach( event, callback, options = {} ) {
 		// If the DOM Listener for given event already exist it is pointless
 		// to attach another one.
 		if ( this._domListeners && this._domListeners[ event ] ) {
@@ -211,20 +206,15 @@ extend( ProxyEmitter.prototype, EmitterMixin, {
 	/**
 	 * Stops executing the callback on the given event.
 	 *
+	 * @method module:utils/dom/emittermixin~ProxyEmitter#detach
 	 * @param {String} event The name of the event.
-	 * @param {Function} callback The function to stop being called.
-	 *
-	 * @method module:utils/dom/emittermixin~ProxyEmitter#off
 	 */
-	off( event, callback ) {
-		// Execute parent class method first.
-		EmitterMixin.off.call( this, event, callback );
-
+	detach( event ) {
 		let events;
 
 		// Remove native DOM listeners which are orphans. If no callbacks
 		// are awaiting given event, detach native DOM listener from DOM Node.
-		// See: {@link on}.
+		// See: {@link attach}.
 
 		if ( this._domListeners[ event ] && ( !( events = this._events[ event ] ) || !events.callbacks.length ) ) {
 			this._domListeners[ event ].removeListener();
@@ -232,13 +222,11 @@ extend( ProxyEmitter.prototype, EmitterMixin, {
 	},
 
 	/**
-	 * Create a native DOM listener callback. When the native DOM event
+	 * Creates a native DOM listener callback. When the native DOM event
 	 * is fired it will fire corresponding event on this ProxyEmitter.
 	 * Note: A native DOM Event is passed as an argument.
 	 *
 	 * @private
-	 * @param {String} event
-	 *
 	 * @method module:utils/dom/emittermixin~ProxyEmitter#_createDomListener
 	 * @param {String} event The name of the event.
 	 * @param {Boolean} useCapture Indicates whether the listener was created for capturing event.
@@ -251,7 +239,7 @@ extend( ProxyEmitter.prototype, EmitterMixin, {
 
 		// Supply the DOM listener callback with a function that will help
 		// detach it from the DOM Node, when it is no longer necessary.
-		// See: {@link off}.
+		// See: {@link detach}.
 		domListener.removeListener = () => {
 			this._domNode.removeEventListener( event, domListener, useCapture );
 			delete this._domListeners[ event ];

+ 77 - 63
packages/ckeditor5-utils/src/emittermixin.js

@@ -24,21 +24,8 @@ const EmitterMixin = {
 	/**
 	 * Registers a callback function to be executed when an event is fired.
 	 *
-	 * Events can be grouped in namespaces using `:`.
-	 * When namespaced event is fired, it additionally fires all callbacks for that namespace.
-	 *
-	 *		myEmitter.on( 'myGroup', genericCallback );
-	 *		myEmitter.on( 'myGroup:myEvent', specificCallback );
-	 *
-	 *		// genericCallback is fired.
-	 *		myEmitter.fire( 'myGroup' );
-	 *		// both genericCallback and specificCallback are fired.
-	 *		myEmitter.fire( 'myGroup:myEvent' );
-	 *		// genericCallback is fired even though there are no callbacks for "foo".
-	 *		myEmitter.fire( 'myGroup:foo' );
-	 *
-	 * An event callback can {@link module:utils/eventinfo~EventInfo#stop stop the event} and
-	 * set the {@link module:utils/eventinfo~EventInfo#return return value} of the {@link #fire} method.
+	 * Shorthand for {@link #listenTo `this.listenTo( this, event, callback, options )`} (it makes the emitter
+	 * listen on itself).
 	 *
 	 * @method #on
 	 * @param {String} event The name of the event.
@@ -49,34 +36,7 @@ const EmitterMixin = {
 	 * order they were added.
 	 */
 	on( event, callback, options = {} ) {
-		createEventNamespace( this, event );
-		const lists = getCallbacksListsForNamespace( this, event );
-		const priority = priorities.get( options.priority );
-
-		callback = {
-			callback,
-			priority
-		};
-
-		// Add the callback to all callbacks list.
-		for ( const callbacks of lists ) {
-			// Add the callback to the list in the right priority position.
-			let added = false;
-
-			for ( let i = 0; i < callbacks.length; i++ ) {
-				if ( callbacks[ i ].priority < priority ) {
-					callbacks.splice( i, 0, callback );
-					added = true;
-
-					break;
-				}
-			}
-
-			// Add at the end, if right place was not found.
-			if ( !added ) {
-				callbacks.push( callback );
-			}
-		}
+		this.listenTo( this, event, callback, options );
 	},
 
 	/**
@@ -101,33 +61,41 @@ const EmitterMixin = {
 		};
 
 		// Make a similar on() call, simply replacing the callback.
-		this.on( event, onceCallback, options );
+		this.listenTo( this, event, onceCallback, options );
 	},
 
 	/**
 	 * Stops executing the callback on the given event.
+	 * Shorthand for {@link #stopListening `this.stopListening( this, event, callback )`}.
 	 *
 	 * @method #off
 	 * @param {String} event The name of the event.
 	 * @param {Function} callback The function to stop being called.
 	 */
 	off( event, callback ) {
-		const lists = getCallbacksListsForNamespace( this, event );
-
-		for ( const callbacks of lists ) {
-			for ( let i = 0; i < callbacks.length; i++ ) {
-				if ( callbacks[ i ].callback == callback ) {
-					// Remove the callback from the list (fixing the next index).
-					callbacks.splice( i, 1 );
-					i--;
-				}
-			}
-		}
+		this.stopListening( this, event, callback );
 	},
 
 	/**
 	 * Registers a callback function to be executed when an event is fired in a specific (emitter) object.
 	 *
+	 * Events can be grouped in namespaces using `:`.
+	 * When namespaced event is fired, it additionally fires all callbacks for that namespace.
+	 *
+	 *		// myEmitter.on( ... ) is a shorthand for myEmitter.listenTo( myEmitter, ... ).
+	 *		myEmitter.on( 'myGroup', genericCallback );
+	 *		myEmitter.on( 'myGroup:myEvent', specificCallback );
+	 *
+	 *		// genericCallback is fired.
+	 *		myEmitter.fire( 'myGroup' );
+	 *		// both genericCallback and specificCallback are fired.
+	 *		myEmitter.fire( 'myGroup:myEvent' );
+	 *		// genericCallback is fired even though there are no callbacks for "foo".
+	 *		myEmitter.fire( 'myGroup:foo' );
+	 *
+	 * An event callback can {@link module:utils/eventinfo~EventInfo#stop stop the event} and
+	 * set the {@link module:utils/eventinfo~EventInfo#return return value} of the {@link #fire} method.
+	 *
 	 * @method #listenTo
 	 * @param {module:utils/emittermixin~Emitter} emitter The object that fires the event.
 	 * @param {String} event The name of the event.
@@ -137,7 +105,7 @@ const EmitterMixin = {
 	 * the priority value the sooner the callback will be fired. Events having the same priority are called in the
 	 * order they were added.
 	 */
-	listenTo( emitter, event, callback, options ) {
+	listenTo( emitter, event, callback, options = {} ) {
 		let emitterInfo, eventCallbacks;
 
 		// _listeningTo contains a list of emitters that this object is listening to.
@@ -180,7 +148,34 @@ const EmitterMixin = {
 		eventCallbacks.push( callback );
 
 		// Finally register the callback to the event.
-		emitter.on( event, callback, options );
+		createEventNamespace( emitter, event );
+		const lists = getCallbacksListsForNamespace( emitter, event );
+		const priority = priorities.get( options.priority );
+
+		const callbackDefinition = {
+			callback,
+			priority
+		};
+
+		// Add the callback to all callbacks list.
+		for ( const callbacks of lists ) {
+			// Add the callback to the list in the right priority position.
+			let added = false;
+
+			for ( let i = 0; i < callbacks.length; i++ ) {
+				if ( callbacks[ i ].priority < priority ) {
+					callbacks.splice( i, 0, callbackDefinition );
+					added = true;
+
+					break;
+				}
+			}
+
+			// Add at the end, if right place was not found.
+			if ( !added ) {
+				callbacks.push( callbackDefinition );
+			}
+		}
 	},
 
 	/**
@@ -189,7 +184,7 @@ const EmitterMixin = {
 	 * * To stop listening to a specific callback.
 	 * * To stop listening to a specific event.
 	 * * To stop listening to all events fired by a specific object.
-	 * * To stop listening to all events fired by all object.
+	 * * To stop listening to all events fired by all objects.
 	 *
 	 * @method #stopListening
 	 * @param {module:utils/emittermixin~Emitter} [emitter] The object to stop listening to. If omitted, stops it for all objects.
@@ -211,13 +206,14 @@ const EmitterMixin = {
 
 		// All params provided. off() that single callback.
 		if ( callback ) {
-			emitter.off( event, callback );
+			removeCallback( emitter, event, callback );
 		}
 		// Only `emitter` and `event` provided. off() all callbacks for that event.
 		else if ( eventCallbacks ) {
 			while ( ( callback = eventCallbacks.pop() ) ) {
-				emitter.off( event, callback );
+				removeCallback( emitter, event, callback );
 			}
+
 			delete emitterInfo.callbacks[ event ];
 		}
 		// Only `emitter` provided. off() all events for that emitter.
@@ -246,7 +242,7 @@ const EmitterMixin = {
 	 * @param {String|module:utils/eventinfo~EventInfo} eventOrInfo The name of the event or `EventInfo` object if event is delegated.
 	 * @param {...*} [args] Additional arguments to be passed to the callbacks.
 	 * @returns {*} By default the method returns `undefined`. However, the return value can be changed by listeners
-	 * through modification of the {@link module:utils/eventinfo~EventInfo#return}'s value (the event info
+	 * through modification of the {@link module:utils/eventinfo~EventInfo#return `evt.return`}'s property (the event info
 	 * is the first param of every callback).
 	 */
 	fire( eventOrInfo, ...args ) {
@@ -277,7 +273,7 @@ const EmitterMixin = {
 					// Remove the called mark for the next calls.
 					delete eventInfo.off.called;
 
-					this.off( event, callbacks[ i ].callback );
+					removeCallback( this, event, callbacks[ i ].callback );
 				}
 
 				// Do not execute next callbacks if stop() was called.
@@ -508,7 +504,6 @@ function createEventNamespace( source, eventName ) {
 // Gets an array containing callbacks list for a given event and it's more specific events.
 // I.e. if given event is foo:bar and there is also foo:bar:abc event registered, this will
 // return callback list of foo:bar and foo:bar:abc (but not foo).
-// Returns empty array if given event has not been yet registered.
 function getCallbacksListsForNamespace( source, eventName ) {
 	const eventNode = getEvents( source )[ eventName ];
 
@@ -570,6 +565,25 @@ function fireDelegatedEvents( destinations, eventInfo, fireArgs ) {
 	}
 }
 
+// Removes callback from emitter for given event.
+//
+// @param {module:utils/emittermixin~Emitter} emitter
+// @param {String} event
+// @param {Function} callback
+function removeCallback( emitter, event, callback ) {
+	const lists = getCallbacksListsForNamespace( emitter, event );
+
+	for ( const callbacks of lists ) {
+		for ( let i = 0; i < callbacks.length; i++ ) {
+			if ( callbacks[ i ].callback == callback ) {
+				// Remove the callback from the list (fixing the next index).
+				callbacks.splice( i, 1 );
+				i--;
+			}
+		}
+	}
+}
+
 /**
  * Interface representing classes which mix in {@link module:utils/emittermixin~EmitterMixin}.
  *

+ 103 - 0
packages/ckeditor5-utils/tests/dom/emittermixin.js

@@ -116,6 +116,46 @@ describe( 'DomEmitterMixin', () => {
 	} );
 
 	describe( 'stopListening', () => {
+		it( 'should stop listening to EmitterMixin events, specific event', () => {
+			const spy1 = testUtils.sinon.spy();
+			const spy2 = testUtils.sinon.spy();
+
+			domEmitter.listenTo( emitter, 'foo', spy1 );
+			domEmitter.listenTo( emitter, 'foo:bar', spy2 );
+
+			emitter.fire( 'foo:bar' );
+
+			sinon.assert.calledOnce( spy1 );
+			sinon.assert.calledOnce( spy2 );
+
+			domEmitter.stopListening( emitter, 'foo' );
+
+			emitter.fire( 'foo:bar' );
+
+			sinon.assert.calledOnce( spy1 );
+			sinon.assert.calledTwice( spy2 );
+		} );
+
+		it( 'should stop listening to EmitterMixin events, specific emitter', () => {
+			const spy1 = testUtils.sinon.spy();
+			const spy2 = testUtils.sinon.spy();
+
+			domEmitter.listenTo( emitter, 'foo', spy1 );
+			domEmitter.listenTo( emitter, 'foo:bar', spy2 );
+
+			emitter.fire( 'foo:bar' );
+
+			sinon.assert.calledOnce( spy1 );
+			sinon.assert.calledOnce( spy2 );
+
+			domEmitter.stopListening( emitter );
+
+			emitter.fire( 'foo:bar' );
+
+			sinon.assert.calledOnce( spy1 );
+			sinon.assert.calledOnce( spy2 );
+		} );
+
 		it( 'should stop listening to a specific event callback', () => {
 			const spy1 = testUtils.sinon.spy();
 			const spy2 = testUtils.sinon.spy();
@@ -135,6 +175,34 @@ describe( 'DomEmitterMixin', () => {
 			sinon.assert.calledTwice( spy2 );
 		} );
 
+		it( 'should stop listening to a specific event callback (only from the given node)', () => {
+			const spy1 = testUtils.sinon.spy();
+			const spy2 = testUtils.sinon.spy();
+
+			const node1 = document.createElement( 'div' );
+			const node2 = document.createElement( 'div' );
+
+			domEmitter.listenTo( node1, 'event1', spy1 );
+			domEmitter.listenTo( node1, 'event2', spy2 );
+			domEmitter.listenTo( node2, 'event1', spy1 );
+
+			node1.dispatchEvent( new Event( 'event1' ) );
+			node1.dispatchEvent( new Event( 'event2' ) );
+			node2.dispatchEvent( new Event( 'event1' ) );
+
+			sinon.assert.calledTwice( spy1 );
+			sinon.assert.calledOnce( spy2 );
+
+			domEmitter.stopListening( node1, 'event1', spy1 );
+
+			node1.dispatchEvent( new Event( 'event1' ) );
+			node1.dispatchEvent( new Event( 'event2' ) );
+			node2.dispatchEvent( new Event( 'event1' ) );
+
+			sinon.assert.calledThrice( spy1 );
+			sinon.assert.calledTwice( spy2 );
+		} );
+
 		it( 'should stop listening to a specific event callback (re–listen)', () => {
 			const spy = testUtils.sinon.spy();
 
@@ -192,26 +260,53 @@ describe( 'DomEmitterMixin', () => {
 			sinon.assert.calledOnce( spy2 );
 		} );
 
+		it( 'should stop listening to all events from a specific node (only that node)', () => {
+			const spy1 = testUtils.sinon.spy();
+			const spy2 = testUtils.sinon.spy();
+
+			const node1 = document.createElement( 'div' );
+			const node2 = document.createElement( 'div' );
+
+			domEmitter.listenTo( node1, 'event', spy1 );
+			domEmitter.listenTo( node2, 'event', spy2 );
+
+			node1.dispatchEvent( new Event( 'event' ) );
+			node2.dispatchEvent( new Event( 'event' ) );
+
+			domEmitter.stopListening( node1 );
+
+			node1.dispatchEvent( new Event( 'event' ) );
+			node2.dispatchEvent( new Event( 'event' ) );
+
+			sinon.assert.calledOnce( spy1 );
+			sinon.assert.calledTwice( spy2 );
+		} );
+
 		it( 'should stop listening to everything', () => {
 			const spy1 = testUtils.sinon.spy();
 			const spy2 = testUtils.sinon.spy();
+			const spy3 = testUtils.sinon.spy();
 
 			const node1 = document.createElement( 'div' );
 			const node2 = document.createElement( 'div' );
 
 			domEmitter.listenTo( node1, 'event1', spy1 );
 			domEmitter.listenTo( node2, 'event2', spy2 );
+			domEmitter.listenTo( emitter, 'event3', spy3 );
 
 			node1.dispatchEvent( new Event( 'event1' ) );
 			node2.dispatchEvent( new Event( 'event2' ) );
+			emitter.fire( 'event3' );
 
 			domEmitter.stopListening();
 
 			node1.dispatchEvent( new Event( 'event1' ) );
 			node2.dispatchEvent( new Event( 'event2' ) );
+			emitter.fire( 'event3' );
 
 			sinon.assert.calledOnce( spy1 );
 			sinon.assert.calledOnce( spy2 );
+			sinon.assert.calledOnce( spy3 );
 		} );
 
 		it( 'should stop listening to everything what left', () => {
@@ -413,28 +508,35 @@ describe( 'DomEmitterMixin', () => {
 			const spy1b = testUtils.sinon.spy();
 			const spy1c = testUtils.sinon.spy();
 			const spy1d = testUtils.sinon.spy();
+			const spyEl2 = testUtils.sinon.spy();
+			const node2 = document.createElement( 'div' );
 
 			domEmitter.listenTo( node, 'test1', spy1a );
 			domEmitter.listenTo( node, 'test2', spy1b );
+			domEmitter.listenTo( node2, 'test1', spyEl2 );
 
 			const proxyEmitter = domEmitter._getProxyEmitter( node );
 			const spy2 = testUtils.sinon.spy( proxyEmitter, 'fire' );
 
 			node.dispatchEvent( new Event( 'test1' ) );
 			node.dispatchEvent( new Event( 'test2' ) );
+			node2.dispatchEvent( new Event( 'test1' ) );
 
 			sinon.assert.calledOnce( spy1a );
 			sinon.assert.calledOnce( spy1b );
 			sinon.assert.calledTwice( spy2 );
+			sinon.assert.calledOnce( spyEl2 );
 
 			domEmitter.stopListening();
 
 			node.dispatchEvent( new Event( 'test1' ) );
 			node.dispatchEvent( new Event( 'test2' ) );
+			node2.dispatchEvent( new Event( 'test1' ) );
 
 			sinon.assert.calledOnce( spy1a );
 			sinon.assert.calledOnce( spy1b );
 			sinon.assert.calledTwice( spy2 );
+			sinon.assert.calledOnce( spyEl2 );
 
 			// Attach same event again.
 			domEmitter.listenTo( node, 'test1', spy1c );
@@ -455,6 +557,7 @@ describe( 'DomEmitterMixin', () => {
 			sinon.assert.calledOnce( spy1d );
 			sinon.assert.calledTwice( spy2 );
 			sinon.assert.calledTwice( spy3 );
+			sinon.assert.calledOnce( spyEl2 );
 		} );
 
 		// #187

+ 168 - 6
packages/ckeditor5-utils/tests/emittermixin.js

@@ -280,7 +280,7 @@ describe( 'EmitterMixin', () => {
 	} );
 
 	describe( 'once', () => {
-		it( 'should be called just once', () => {
+		it( 'should be called just once for general event', () => {
 			const spy1 = sinon.spy();
 			const spy2 = sinon.spy();
 			const spy3 = sinon.spy();
@@ -297,6 +297,23 @@ describe( 'EmitterMixin', () => {
 			sinon.assert.calledTwice( spy3 );
 		} );
 
+		it( 'should be called just once for namespaced event', () => {
+			const spy1 = sinon.spy();
+			const spy2 = sinon.spy();
+			const spy3 = sinon.spy();
+
+			emitter.on( 'foo:bar', spy1 );
+			emitter.once( 'foo:bar', spy2 );
+			emitter.on( 'foo:bar', spy3 );
+
+			emitter.fire( 'foo:bar' );
+			emitter.fire( 'foo:bar' );
+
+			sinon.assert.calledTwice( spy1 );
+			sinon.assert.calledOnce( spy2 );
+			sinon.assert.calledTwice( spy3 );
+		} );
+
 		it( 'should have proper arguments', () => {
 			const spy = sinon.spy();
 
@@ -306,6 +323,22 @@ describe( 'EmitterMixin', () => {
 
 			sinon.assert.calledWithExactly( spy, sinon.match.instanceOf( EventInfo ), 1, 2, 3 );
 		} );
+
+		it( 'should be removed only after exact event fired', () => {
+			const spy1 = sinon.spy();
+			const spy2 = sinon.spy();
+
+			emitter.on( 'foo', spy1 );
+			emitter.once( 'foo', spy2 );
+
+			emitter.fire( 'foo:bar' );
+			emitter.fire( 'foo' );
+			emitter.fire( 'foo:bar' );
+			emitter.fire( 'foo' );
+
+			sinon.assert.callCount( spy1, 4 );
+			sinon.assert.calledTwice( spy2 );
+		} );
 	} );
 
 	describe( 'off', () => {
@@ -330,8 +363,30 @@ describe( 'EmitterMixin', () => {
 			sinon.assert.calledThrice( spy3 );
 		} );
 
+		it( 'should remove all callbacks for event', () => {
+			const spy1 = sinon.spy();
+			const spy2 = sinon.spy();
+
+			emitter.on( 'test', spy1 );
+			emitter.on( 'test', spy2 );
+
+			emitter.fire( 'test' );
+
+			emitter.off( 'test' );
+
+			emitter.fire( 'test' );
+			emitter.fire( 'test' );
+
+			sinon.assert.calledOnce( spy1 );
+			sinon.assert.calledOnce( spy2 );
+		} );
+
 		it( 'should not fail with unknown events', () => {
-			emitter.off( 'test', () => {} );
+			emitter.off( 'foo', () => {} );
+			emitter.off( 'foo:bar', () => {} );
+
+			emitter.off( 'foo' );
+			emitter.off( 'foo:bar' );
 		} );
 
 		it( 'should remove all entries for the same callback', () => {
@@ -353,6 +408,34 @@ describe( 'EmitterMixin', () => {
 			sinon.assert.callCount( spy2, 4 );
 		} );
 
+		it( 'should not remove all namespaced entries when removing namespace inner group', () => {
+			const spy1 = sinon.spy().named( 'foo' );
+			const spy2 = sinon.spy().named( 'foo:bar' );
+			const spy3 = sinon.spy().named( 'foo:bar:baz' );
+			const spy4 = sinon.spy().named( 'foo:bar:baz:abc' );
+
+			emitter.on( 'foo', spy1 );
+			emitter.on( 'foo:bar', spy2 );
+			emitter.on( 'foo:bar:baz', spy3 );
+			emitter.on( 'foo:bar:baz:abc', spy4 );
+
+			emitter.fire( 'foo:bar:baz:abc' );
+
+			sinon.assert.calledOnce( spy1 );
+			sinon.assert.calledOnce( spy2 );
+			sinon.assert.calledOnce( spy3 );
+			sinon.assert.calledOnce( spy4 );
+
+			emitter.off( 'foo:bar' );
+
+			emitter.fire( 'foo:bar:baz:abc' );
+
+			sinon.assert.calledTwice( spy1 );
+			sinon.assert.calledOnce( spy2 );
+			sinon.assert.calledTwice( spy3 );
+			sinon.assert.calledTwice( spy4 );
+		} );
+
 		it( 'should properly remove callbacks for namespaced events', () => {
 			const spyFoo = sinon.spy();
 			const spyAbc = sinon.spy();
@@ -403,19 +486,29 @@ describe( 'EmitterMixin', () => {
 		it( 'should correctly listen to namespaced events', () => {
 			const spyFoo = sinon.spy();
 			const spyBar = sinon.spy();
+			const spyBaz = sinon.spy();
 
 			listener.listenTo( emitter, 'foo', spyFoo );
 			listener.listenTo( emitter, 'foo:bar', spyBar );
+			listener.listenTo( emitter, 'foo:bar:baz', spyBaz );
 
-			emitter.fire( 'foo:bar' );
+			emitter.fire( 'foo:bar:baz' );
 
 			sinon.assert.calledOnce( spyFoo );
 			sinon.assert.calledOnce( spyBar );
+			sinon.assert.calledOnce( spyBaz );
 
-			emitter.fire( 'foo' );
+			emitter.fire( 'foo:bar' );
 
 			sinon.assert.calledTwice( spyFoo );
-			sinon.assert.calledOnce( spyBar );
+			sinon.assert.calledTwice( spyBar );
+			sinon.assert.calledOnce( spyBaz );
+
+			emitter.fire( 'foo' );
+
+			sinon.assert.calledThrice( spyFoo );
+			sinon.assert.calledTwice( spyBar );
+			sinon.assert.calledOnce( spyBaz );
 		} );
 	} );
 
@@ -464,42 +557,62 @@ describe( 'EmitterMixin', () => {
 		it( 'should stop listening to all events from given emitter', () => {
 			const spy1 = sinon.spy();
 			const spy2 = sinon.spy();
+			const spy3 = sinon.spy();
+			const spy4 = sinon.spy();
 
 			listener.listenTo( emitter, 'event1', spy1 );
 			listener.listenTo( emitter, 'event2', spy2 );
+			listener.listenTo( emitter, 'foo', spy3 );
+			listener.listenTo( emitter, 'foo:bar:baz', spy4 );
 
 			emitter.fire( 'event1' );
 			emitter.fire( 'event2' );
+			emitter.fire( 'foo:bar:baz' );
 
 			listener.stopListening( emitter );
 
 			emitter.fire( 'event1' );
 			emitter.fire( 'event2' );
+			emitter.fire( 'foo:bar:baz' );
 
 			sinon.assert.calledOnce( spy1 );
 			sinon.assert.calledOnce( spy2 );
+			sinon.assert.calledOnce( spy3 );
+			sinon.assert.calledOnce( spy4 );
 		} );
 
 		it( 'should stop listening to everything', () => {
 			const spy1 = sinon.spy();
 			const spy2 = sinon.spy();
+			const spy3 = sinon.spy();
+			const spy4 = sinon.spy();
 
 			const emitter1 = getEmitterInstance();
 			const emitter2 = getEmitterInstance();
 
 			listener.listenTo( emitter1, 'event1', spy1 );
 			listener.listenTo( emitter2, 'event2', spy2 );
+			listener.listenTo( emitter1, 'foo', spy3 );
+			listener.listenTo( emitter1, 'foo:bar:baz', spy4 );
 
 			emitter1.fire( 'event1' );
 			emitter2.fire( 'event2' );
+			emitter1.fire( 'foo' );
+			emitter1.fire( 'foo:bar' );
+			emitter1.fire( 'foo:bar:baz' );
 
 			listener.stopListening();
 
 			emitter1.fire( 'event1' );
 			emitter2.fire( 'event2' );
+			emitter1.fire( 'foo' );
+			emitter1.fire( 'foo:bar' );
+			emitter1.fire( 'foo:bar:baz' );
 
 			sinon.assert.calledOnce( spy1 );
 			sinon.assert.calledOnce( spy2 );
+			sinon.assert.calledThrice( spy3 );
+			sinon.assert.calledOnce( spy4 );
 		} );
 
 		it( 'should not stop other emitters when a non-listened emitter is provided', () => {
@@ -520,16 +633,65 @@ describe( 'EmitterMixin', () => {
 		it( 'should correctly stop listening to namespaced events', () => {
 			const spyFoo = sinon.spy();
 			const spyBar = sinon.spy();
+			const spyBaz = sinon.spy();
 
 			listener.listenTo( emitter, 'foo', spyFoo );
 			listener.listenTo( emitter, 'foo:bar', spyBar );
+			listener.listenTo( emitter, 'foo:bar:baz', spyBaz );
 
 			listener.stopListening( emitter, 'foo' );
 
-			emitter.fire( 'foo:bar' );
+			emitter.fire( 'foo:bar:baz' );
 
 			sinon.assert.notCalled( spyFoo );
 			sinon.assert.calledOnce( spyBar );
+			sinon.assert.calledOnce( spyBaz );
+		} );
+
+		it( 'should correctly stop listening to namespaced events when removing specialised event', () => {
+			const spyFoo = sinon.spy();
+			const spyBar = sinon.spy();
+			const spyBaz = sinon.spy();
+
+			listener.listenTo( emitter, 'foo', spyFoo );
+			listener.listenTo( emitter, 'foo:bar', spyBar );
+			listener.listenTo( emitter, 'foo:bar:baz', spyBaz );
+
+			listener.stopListening( emitter, 'foo:bar' );
+
+			emitter.fire( 'foo:bar:baz' );
+
+			sinon.assert.calledOnce( spyFoo );
+			sinon.assert.notCalled( spyBar );
+			sinon.assert.calledOnce( spyBaz );
+		} );
+
+		it( 'should not fail with unknown events', () => {
+			listener.stopListening( emitter, 'foo', () => {} );
+			listener.stopListening( emitter, 'foo:bar', () => {} );
+			listener.stopListening( emitter, 'foo' );
+			listener.stopListening( emitter, 'foo:bar' );
+		} );
+
+		it( 'should not fail with unknown emitter', () => {
+			listener.listenTo( emitter, 'foo:bar', () => {} );
+
+			listener.stopListening( {}, 'foo', () => {} );
+			listener.stopListening( {}, 'foo:bar', () => {} );
+			listener.stopListening( {}, 'foo' );
+			listener.stopListening( {}, 'foo:bar' );
+			listener.stopListening( {} );
+		} );
+
+		it( 'should not fail with unknown callbacks', () => {
+			const spy = sinon.spy();
+
+			listener.listenTo( emitter, 'foo', spy );
+			listener.stopListening( emitter, 'foo', () => {} );
+
+			emitter.fire( 'foo' );
+
+			sinon.assert.calledOnce( spy );
 		} );
 	} );