Possible Bug: Empty Animations Are Cached In AngularJS 1.4
After several hours of writing "debugger" and "console.log()" statements, I think I figured out why I was getting unexpected animation behaviors in AngularJS 1.4. And, I think I am going to consider this a bug. It seems that there's an edge-case in which an empty-animation will fail to flush the animation cache, which can prevent subsequent animations from taking place on the same element.
Run this demo in my JavaScript Demos project on GitHub.
This will be way easier to see in the video, but when AngularJS 1.4 is calculating the animation requirements for Enter and Leave events (and presumably other event types as well), it caches the animation settings for a given type of element. I assume it does this so that when it is animating several elements under the same parent, it doesn't have to recalculate the animation configuration for each sibling. Typically, at the end of the animation, this cache is flushed. But, if AngularJS detects that no animation should take place, it returns a No-Op (No Operation) animator which doesn't touch the cache.
This can lead to a problem if the behavior of the animation is driven by an ancestor of the animating element. The reason that this creates an edge-case is that the class names of the animating element don't change, they just rely on cascading styles. This causes a problem because the animation cache, internally to AngularJS 1.4, is driven by class names.
To see this in action, you really have to watch the video; but, here is the code that I am testing with:
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Possible Bug: Empty Animations Are Cached In AngularJS 1.4
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController">
<h1>
Possible Bug: Empty Animations Are Cached In AngularJS 1.4
</h1>
<p>
<a ng-click="toggleWithAnimation()">Toggle With Animation</a>
—
<a ng-click="toggleWithoutAnimation()">Toggle Without Animation</a>
—
<span ng-switch="isUsingAnimation">
(
<span ng-switch-when="true">Using</span>
<span ng-switch-when="false">Not using</span>
Animations
)
</span>
<!--
Any animation, with duration, will cause the animation cache to be be
flushed. As such, once our cache gets "stuck" we can flush it by triggering
an unrelated animation.
-->
<a ng-click="flushCache()">Flush cache w/ Animation</a>
<span ng-if="( flushCount % 2 )" class="flusher"></span>
</p>
<!--
While we are removing the embedded DIV tag, it's the parent class that will
drive the animation in the CSS (via contextual CSS properties).
--
NOTE: This means that the CLASS attribute on the embedded DIV never changes
even if the expected animation behavior changes. This is what will break this
particular approach as it is short-circuited by the animation cache.
-->
<div ng-class="{ animated: isUsingAnimation }">
<!-- This box will animate based on parent class state. -->
<div ng-if="isShowingBox" class="box">
The thing is, Bob, it's not that I'm lazy, it's that I just don't care.
</div>
</div>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.3.js"></script>
<script type="text/javascript" src="./angular-animate-1.4.3.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
angular.module( "Demo", [ "ngAnimate" ] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the demo.
angular.module( "Demo" ).controller(
"AppController",
function AppController( $scope ) {
// I determine if the dynamic element is currently visible.
$scope.isShowingBox = false;
// I flag the use of animations when hiding / showing the box.
$scope.isUsingAnimation = false;
// I am used to trigger the "flushing" animation.
$scope.flushCount = 0;
// ---
// PUBLIC METHODS.
// ---
// I increment the flush count, which triggers a new animation.
$scope.flushCache = function() {
$scope.flushCount++;
};
// I show / hide the box using animation.
$scope.toggleWithAnimation = function() {
$scope.isUsingAnimation = true;
$scope.isShowingBox = ! $scope.isShowingBox;
};
// I show / hide the box using instant transitions.
$scope.toggleWithoutAnimation = function() {
$scope.isUsingAnimation = false;
$scope.isShowingBox = ! $scope.isShowingBox;
};
}
);
</script>
</body>
</html>
And, here's the CSS that is using the cascading nature of the classes to enable animations:
a[ ng-click ] {
color: red ;
cursor: pointer ;
text-decoration: underline ;
user-select: none ;
-moz-user-select: none ;
-webkit-user-select: none ;
}
div.box {
background-color: #F0F0F0 ;
border: 1px solid #CCCCCC ;
border-radius: 3px 3px 3px 3px ;
font-size: 20px ;
line-height: 25px ;
padding: 40px 40px 40px 40px ;
}
div.animated div.box.ng-enter {
opacity:0;
transition: 1s opacity ease ;
}
div.animated div.box.ng-enter-active {
opacity:1;
}
div.animated div.box.ng-leave {
opacity:1;
transition: 1s opacity ease ;
}
div.animated div.box.ng-leave-active {
opacity:0;
}
/* Used to create an animation that flushes the cache. */
span.flusher {
background-color: #FF0099 ;
border-radius: 5px 5px 5px 5px ;
display: inline-block ;
height: 10px ;
width: 10px ;
}
span.flusher.ng-enter {
opacity: 0 ;
transition: 300ms opacity ease ;
}
span.flusher.ng-enter-active {
opacity: 1 ;
}
span.flusher.ng-leave {
opacity: 1 ;
transition: 300ms opacity ease ;
}
span.flusher.ng-leave-active {
opacity: 0 ;
}
The reason that I think that this may be a bug is that an unrelated animation will cause the animation cache to be flushed. As such, there seems to be no reason that the cache would have to persist beyond the duration of the event, which may or may not be instantaneous. And, in fact, we can fix this by simply flushing the cache in the No-Op animator.
That said, there might be performance considerations, staggering considerations, or other interaction considerations as to why the cache is not flushed in the No-Op animator. To be fair, I don't know enough about the animation implementation to say one way or the other. But, you should be able to work around this edge-case by adding a dynamic class to the actual animating element so as to make sure that different animation behaviors have different cache keys.
Want to use code from this post? Check out the license.
Reader Comments
I filed this as a possible issue. https://github.com/angular/angular.js/issues/12518