Skip to content

Commit

Permalink
fix(ngAnimate): defer DOM operations for changing classes to postDigest
Browse files Browse the repository at this point in the history
When ngAnimate is used, it will defer changes to classes until postDigest. Previously,
AngularJS (when ngAnimate is not loaded) would always immediately perform these DOM
operations.

Now, even when the ngAnimate module is not used, if $rootScope is in the midst of a
digest, class manipulation is deferred. This helps reduce jank in browsers such as
IE11.

BREAKING CHANGE:

The $animate class API will always defer changes until the end of the next digest. This allows ngAnimate
to coalesce class changes which occur over a short period of time into 1 or 2 DOM writes, rather than
many. This prevents jank in browsers such as IE, and is generally a good thing.

If you're finding that your classes are not being immediately applied, be sure to invoke $digest().

Closes angular#8234
Closes angular#9263
  • Loading branch information
caitp authored and bullgare committed Oct 9, 2014
1 parent c320764 commit 96fa23e
Show file tree
Hide file tree
Showing 6 changed files with 571 additions and 11 deletions.
117 changes: 111 additions & 6 deletions src/ng/animate.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,65 @@ var $AnimateProvider = ['$provide', function($provide) {
return this.$$classNameFilter;
};

this.$get = ['$$q', '$$asyncCallback', function($$q, $$asyncCallback) {
this.$get = ['$$q', '$$asyncCallback', '$rootScope', function($$q, $$asyncCallback, $rootScope) {

var currentDefer;
var ELEMENT_NODE = 1;

function extractElementNodes(element) {
var elements = new Array(element.length);
var count = 0;
for(var i = 0; i < element.length; i++) {
var elm = element[i];
if (elm.nodeType == ELEMENT_NODE) {
elements[count++] = elm;
}
}
elements.length = count;
return jqLite(elements);
}

function runAnimationPostDigest(fn) {
var cancelFn, defer = $$q.defer();
defer.promise.$$cancelFn = function ngAnimateMaybeCancel() {
cancelFn && cancelFn();
};

$rootScope.$$postDigest(function ngAnimatePostDigest() {
cancelFn = fn(function ngAnimateNotifyComplete() {
defer.resolve();
});
});

return defer.promise;
}

function resolveElementClasses(element, cache) {
var toAdd = [], toRemove = [];
forEach(cache.classes, function(status, className) {
var hasClass = jqLiteHasClass(element[0], className);

// If the most recent class manipulation (via $animate) was to remove the class, and the
// element currently has the class, the class is scheduled for removal. Otherwise, if
// the most recent class manipulation (via $animate) was to add the class, and the
// element does not currently have the class, the class is scheduled to be added.
if (status === false && hasClass) {
toRemove.push(className);
} else if (status === true && !hasClass) {
toAdd.push(className);
}
});

return (toAdd.length + toRemove.length) > 0 && [toAdd.join(' '), toRemove.join(' ')];
}

function cachedClassManipulation(cache, classes, op) {
for (var i=0, ii = classes.length; i < ii; ++i) {
var className = classes[i];
cache[className] = op;
}
}

function asyncPromise() {
// only serve one instance of a promise in order to save CPU cycles
if (!currentDefer) {
Expand Down Expand Up @@ -187,13 +243,17 @@ var $AnimateProvider = ['$provide', function($provide) {
* @return {Promise} the animation callback promise
*/
addClass : function(element, className) {
return this.setClass(element, className, []);
},

$$addClassImmediately : function addClassImmediately(element, className) {
element = jqLite(element);
className = !isString(className)
? (isArray(className) ? className.join(' ') : '')
: className;
forEach(element, function (element) {
jqLiteAddClass(element, className);
});
return asyncPromise();
},

/**
Expand All @@ -209,6 +269,11 @@ var $AnimateProvider = ['$provide', function($provide) {
* @return {Promise} the animation callback promise
*/
removeClass : function(element, className) {
return this.setClass(element, [], className);
},

$$removeClassImmediately : function removeClassImmediately(element, className) {
element = jqLite(element);
className = !isString(className)
? (isArray(className) ? className.join(' ') : '')
: className;
Expand All @@ -231,10 +296,50 @@ var $AnimateProvider = ['$provide', function($provide) {
* @param {string} remove the CSS class which will be removed from the element
* @return {Promise} the animation callback promise
*/
setClass : function(element, add, remove) {
this.addClass(element, add);
this.removeClass(element, remove);
return asyncPromise();
setClass : function(element, add, remove, runSynchronously) {
var self = this;
var STORAGE_KEY = '$$animateClasses';
element = extractElementNodes(jqLite(element));

if (runSynchronously) {
self.$$addClassImmediately(element, add);
self.$$removeClassImmediately(element, remove);
return asyncPromise();
}

var cache = element.data(STORAGE_KEY);
if (!cache) {
cache = {
classes: {}
};
var createdCache = true;
}

var classes = cache.classes;

add = isArray(add) ? add : add.split(' ');
remove = isArray(remove) ? remove : remove.split(' ');
cachedClassManipulation(classes, add, true);
cachedClassManipulation(classes, remove, false);

if (createdCache) {
cache.promise = runAnimationPostDigest(function(done) {
var cache = element.data(STORAGE_KEY);
element.removeData(STORAGE_KEY);

var classes = cache && resolveElementClasses(element, cache);

if (classes) {
if (classes[0]) self.$$addClassImmediately(element, classes[0]);
if (classes[1]) self.$$removeClassImmediately(element, classes[1]);
}

done();
});
element.data(STORAGE_KEY, cache);
}

return cache.promise;
},

enabled : noop,
Expand Down
5 changes: 3 additions & 2 deletions src/ngAnimate/animate.js
Original file line number Diff line number Diff line change
Expand Up @@ -979,7 +979,7 @@ angular.module('ngAnimate', ['ng'])
element = stripCommentsFromElement(element);

if (classBasedAnimationsBlocked(element)) {
return $delegate.setClass(element, add, remove);
return $delegate.setClass(element, add, remove, true);
}

// we're using a combined array for both the add and remove
Expand Down Expand Up @@ -1033,7 +1033,8 @@ angular.module('ngAnimate', ['ng'])
return !classes
? done()
: performAnimation('setClass', classes, element, parentElement, null, function() {
$delegate.setClass(element, classes[0], classes[1]);
if (classes[0]) $delegate.$$addClassImmediately(element, classes[0]);
if (classes[1]) $delegate.$$removeClassImmediately(element, classes[1]);
}, done);
});
},
Expand Down
Loading

0 comments on commit 96fa23e

Please sign in to comment.