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);
- }));
-
});