-
Notifications
You must be signed in to change notification settings - Fork 27.4k
fix(ngAnimate): defer DOM operations for changing classes to postDigest #9283
Changes from all commits
c3ce5d4
6233f0c
43dcb36
a6ee572
3e2ad02
bf84fd1
e954d22
cbf7d92
6949e5d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -81,9 +81,57 @@ 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; | ||
|
||
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 = []; | ||
|
||
var hasClasses = {}; | ||
forEach((element.attr('class') || '').replace(/\s+/g, ' ').split(' '), function(className) { | ||
hasClasses[className] = true; | ||
}); | ||
|
||
forEach(cache.classes, function(status, className) { | ||
var hasClass = hasClasses[className] === true; | ||
|
||
// 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); | ||
} | ||
}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If I understand correclty, the class will end u in the E.g. if in the same $digest the following calls are made:
I would expect that the class is not added (removed if present).
which will lead to a Is this the intended behaviour @caitp ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. during digest, that won't happen (it would not appear in either There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @caitp: Sorry, but I didn't get it. What would not happen ? Having classes in both There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. resolveElementClasses() discards items which don't really change There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @caitp: It discards them, if the are "added" and "removed" the same number of times (i.e. if they appear in the My question is basically this: "Shouldn't order matter ?" There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we should also add tests for this scenario that @gkalpak described There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm reading #8946 and I'm not seeing how it's different to what is already implemented in ngAnimate, and what this PR implements --- I am happy to add the extra tests though, although it's basically an alternative version of the tests which are added here There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just FWIW, since the current behaviour does not care about ordering, the test case proposed in that issue does not help much here. I think it would be good to follow up on this and make sure resolveElementClasses() can respect ordering, but for the time being it doesn't. I think fixing that is sort of out of scope for this PR, but I will look at taking that on once this is merged There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I posted a code snippet above with what I think this should look like. do you now see the diff? |
||
|
||
return (toAdd.length + toRemove.length) > 0 && [toAdd.length && toAdd, toRemove.length && toRemove]; | ||
} | ||
|
||
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) { | ||
|
@@ -187,13 +235,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(); | ||
}, | ||
|
||
/** | ||
|
@@ -209,6 +261,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; | ||
|
@@ -231,10 +288,53 @@ 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'; | ||
var createdCache = false; | ||
element = jqLite(element); | ||
|
||
if (runSynchronously) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unfortunately, This is bad because it's a definite difference in behaviour for one special case, so I think we'll want to fix this --- but maybe it can wait a bit? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. obviously, |
||
// TODO(@caitp/@matsko): Remove undocumented `runSynchronously` parameter, and always | ||
// perform DOM manipulation asynchronously or in postDigest. | ||
self.$$addClassImmediately(element, add); | ||
self.$$removeClassImmediately(element, remove); | ||
return asyncPromise(); | ||
} | ||
|
||
var cache = element.data(STORAGE_KEY); | ||
if (!cache) { | ||
cache = { | ||
classes: {} | ||
}; | ||
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]); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think that you need this if. do you? |
||
if (classes[1]) self.$$removeClassImmediately(element, classes[1]); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. same as above There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it saves us a bit of work and it's nice for the tests because it shows that the spies don't get called when they aren't needed There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh. so you are testing if the string is empty? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yes --- now that we aren't joining strings in resolveElementClasses, it's checking that the length of the array is > 0 instead |
||
} | ||
|
||
done(); | ||
}); | ||
element.data(STORAGE_KEY, cache); | ||
} | ||
|
||
return cache.promise; | ||
}, | ||
|
||
enabled : noop, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -477,9 +477,14 @@ angular.module('ngAnimate', ['ng']) | |
}); | ||
}); | ||
|
||
var hasClasses = {}; | ||
forEach((element.attr('class') || '').replace(/\s+/g, ' ').split(' '), function(className) { | ||
hasClasses[className] = true; | ||
}); | ||
|
||
var toAdd = [], toRemove = []; | ||
forEach(cache.classes, function(status, className) { | ||
var hasClass = angular.$$hasClass(element[0], className); | ||
var hasClass = hasClasses[className] === true; | ||
var matchingAnimation = lookup[className] || {}; | ||
|
||
// When addClass and removeClass is called then $animate will check to | ||
|
@@ -979,7 +984,10 @@ angular.module('ngAnimate', ['ng']) | |
element = stripCommentsFromElement(element); | ||
|
||
if (classBasedAnimationsBlocked(element)) { | ||
return $delegate.setClass(element, add, remove); | ||
// TODO(@caitp/@matsko): Don't use private/undocumented API here --- we should not be | ||
// changing the DOM synchronously in this case. The `true` parameter must eventually be | ||
// removed. | ||
return $delegate.setClass(element, add, remove, true); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. can't we use jqLite here instead? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. oh, we need to return the promise which complicates things There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we do, when jQuery is used we'll die on SVG elements There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I do want to fix this up so that it sucks less though (but I think matsko will need to help fix broken tests that fail when we don't do this synchronously) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ok. let's keep it as is for now. can we add a comment that we call it this way via undocumented api. |
||
} | ||
|
||
// we're using a combined array for both the add and remove | ||
|
@@ -1033,7 +1041,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); | ||
}); | ||
}, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
=== true
so that classes liketoString
orconstructor
don't cause problemsThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
use
createMap
instead and then you don't need this