diff --git a/src/ng/directive/ngEventDirs.js b/src/ng/directive/ngEventDirs.js index d228c76077c0..c37ec695706c 100644 --- a/src/ng/directive/ngEventDirs.js +++ b/src/ng/directive/ngEventDirs.js @@ -37,6 +37,14 @@ * Events that are handled via these handler are always configured not to propagate further. */ var ngEventDirectives = {}; + +// For events that might fire synchronously during DOM manipulation +// we need to execute their event handlers asynchronously using $evalAsync, +// so that they are not executed in an inconsistent state. +var forceAsyncEvents = { + 'blur': true, + 'focus': true +}; forEach( 'click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste'.split(' '), function(name) { @@ -47,10 +55,16 @@ forEach( compile: function($element, attr) { var fn = $parse(attr[directiveName]); return function ngEventHandler(scope, element) { - element.on(lowercase(name), function(event) { - scope.$apply(function() { + var eventName = lowercase(name); + element.on(eventName, function(event) { + var callback = function() { fn(scope, {$event:event}); - }); + }; + if (forceAsyncEvents[eventName] && scope.$$phase) { + scope.$evalAsync(callback); + } else { + scope.$apply(callback); + } }); }; } @@ -367,6 +381,10 @@ forEach( * @description * Specify custom behavior on focus event. * + * Note: As the `focus` event is executed synchronously when calling `input.focus()` + * AngularJS executes the expression using `scope.$evalAsync` if the event is fired + * during an `$apply` to ensure a consistent state. + * * @element window, input, select, textarea, a * @priority 0 * @param {expression} ngFocus {@link guide/expression Expression} to evaluate upon @@ -383,6 +401,11 @@ forEach( * @description * Specify custom behavior on blur event. * + * Note: As the `blur` event is executed synchronously also during DOM manipulations + * (e.g. removing a focussed input), + * AngularJS executes the expression using `scope.$evalAsync` if the event is fired + * during an `$apply` to ensure a consistent state. + * * @element window, input, select, textarea, a * @priority 0 * @param {expression} ngBlur {@link guide/expression Expression} to evaluate upon diff --git a/test/ng/directive/ngEventDirsSpec.js b/test/ng/directive/ngEventDirsSpec.js index 5b73c2dd6a8b..52043000701c 100644 --- a/test/ng/directive/ngEventDirsSpec.js +++ b/test/ng/directive/ngEventDirsSpec.js @@ -39,4 +39,77 @@ describe('event directives', function() { expect($rootScope.formSubmitted).toEqual('foo'); })); }); + + describe('focus', function() { + + it('should call the listener asynchronously during $apply', + inject(function($rootScope, $compile) { + element = $compile('')($rootScope); + $rootScope.focus = jasmine.createSpy('focus'); + // need to add to document so that the event fires + document.body.appendChild(element[0]); + + $rootScope.$apply(function() { + element[0].focus(); + expect($rootScope.focus).not.toHaveBeenCalled(); + }); + + expect($rootScope.focus).toHaveBeenCalledOnce(); + })); + + it('should call the listener synchronously inside of $apply if outside of $apply', + inject(function($rootScope, $compile) { + element = $compile('')($rootScope); + // need to add to document so that the event fires + document.body.appendChild(element[0]); + + $rootScope.focus = jasmine.createSpy('focus').andCallFake(function() { + $rootScope.value = 'newValue'; + }); + + element[0].focus(); + + expect($rootScope.focus).toHaveBeenCalledOnce(); + expect(element.val()).toBe('newValue'); + })); + + }); + + describe('blur', function() { + + it('should call the listener asynchronously during $apply', + inject(function($rootScope, $compile) { + element = $compile('')($rootScope); + // need to add to document so that the event fires + document.body.appendChild(element[0]); + element[0].focus(); + + $rootScope.blur = jasmine.createSpy('blur'); + + $rootScope.$apply(function() { + element[0].blur(); + expect($rootScope.blur).not.toHaveBeenCalled(); + }); + + expect($rootScope.blur).toHaveBeenCalledOnce(); + })); + + it('should call the listener synchronously inside of $apply if outside of $apply', + inject(function($rootScope, $compile) { + element = $compile('')($rootScope); + // need to add to document so that the event fires + document.body.appendChild(element[0]); + element[0].focus(); + + $rootScope.blur = jasmine.createSpy('blur').andCallFake(function() { + $rootScope.value = 'newValue'; + }); + + element[0].blur(); + + expect($rootScope.blur).toHaveBeenCalledOnce(); + expect(element.val()).toBe('newValue'); + })); + + }); }); diff --git a/test/ng/directive/ngKeySpec.js b/test/ng/directive/ngKeySpec.js index ef5addd507ed..c7b989a48b14 100644 --- a/test/ng/directive/ngKeySpec.js +++ b/test/ng/directive/ngKeySpec.js @@ -34,23 +34,5 @@ describe('ngKeyup and ngKeydown directives', function() { expect($rootScope.touched).toEqual(true); })); - it('should get called on focus', inject(function($rootScope, $compile) { - element = $compile('')($rootScope); - $rootScope.$digest(); - expect($rootScope.touched).toBeFalsy(); - - browserTrigger(element, 'focus'); - expect($rootScope.touched).toEqual(true); - })); - - it('should get called on blur', inject(function($rootScope, $compile) { - element = $compile('')($rootScope); - $rootScope.$digest(); - expect($rootScope.touched).toBeFalsy(); - - browserTrigger(element, 'blur'); - expect($rootScope.touched).toEqual(true); - })); - });