ngShow / ngHide Classes Get Applied In The $$postDigest Phase In AngularJS 1.3
The other day, when I was working on the AngularJS version of Absolute Grid, I noticed a small change in behavior in the way that the ngShow and ngHide directives were working. After digging into the source code a bit, it looks like there was a change in the default implementation of the $animate service in AngularJS 1.3. Now, when the "ng-hide" class is added, it is done in the $$postDigest phase of the $digest. To the user, this doesn't matter; but, to peripheral directives, this has an impact on when the DOM (Document Object Model) is updated.
Run this demo in my JavaScript Demos project on GitHub.
Imagine that you have a directive that has to inspect the dimensions of its associated element in order to communicate with the view-model, such as to tell it how many items can fit on a screen. To do this, the directive would have to know when the associated element becomes visible, and then, it would have to query for the dimensions.
NOTE: A directive can constantly poll the DOM for element dimensions; but, this is a horrible horrible horrible idea as it is expensive and will lead to many unintended repaints which will have a negative impact on the user experience (such as accidentally scrolling the user to the top of the page). Do not do this.
The problem that I ran into, in AngularJS 1.3, is that even if the directive knows when the element is supposed to be visible (based on the view-model), the element doesn't actually become visible until later on in the $digest, after all of the $watch() bindings have been executed. To see the difference, let's look at an AngularJS 1.2 demo first.
Here, we have a link that will toggle the visibility of a Div. And, we have a directive on that Div that will log-out the dimensions of the Div once it becomes visible. Notice that we are explicitly telling the directive which view-model value to observe - vm.isContainerVisible - in order to determine element visibility:
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
ngShow / ngHide Classes Get Applied In The $$postDigest Phase In AngularJS 1.3
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController as vm">
<h1>
ngShow / ngHide Classes Get Applied In The $$postDigest Phase In AngularJS 1.3
</h1>
<h2>
AngularJS 1.2.28 Example
</h2>
<p>
<a ng-click="vm.toggleContainer()">Toggle Container</a>
</p>
<!--
Imagine that this directive has a need to auto-adjust its dimensions after the
element becomes visible. As such, we are using the ng-show directive to manage
the visibility and then ALSO telling the custom directive, bn-container, which
scope-value to watch in order for it to determine when it's visible.
NOTE: The more elegant way to solve this might be to have the bn-container
directive manage its own visibility (ie, subsume the responsibility of the
ng-show directive). But, that's a bit beyond the point of this demo.
-->
<div
ng-show="vm.isContainerVisible"
bn-container="vm.isContainerVisible"
class="container">
Peek-a-boo!
</div>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs/angular-1.2.28.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the application.
app.controller(
"AppController",
function( $scope ) {
// Define the controller as the view-model.
var vm = this;
// I determine if the container is visible.
vm.isContainerVisible = true;
// Expose the public API.
vm.toggleContainer = toggleContainer;
// ---
// PUBLIC METHODS.
// ---
// I toggle the visibility of the container.
function toggleContainer() {
vm.isContainerVisible = ! vm.isContainerVisible;
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I manage the container element.
app.directive(
"bnContainer",
function() {
// Return the directive configuration object.
// --
// NOTE: Using priority 1 to make sure that this link function (post-link),
// is executed after the ng-show link function (priority 0 - they are
// linked in reverse order). That way, the ng-show $watch() bindings are
// bound first, which means that our $watch() handler will execute after
// the ng-show $watch() handler.
return({
link: link,
priority: 1,
restrict: "A"
});
// I bind the JavaScript events to the local view-model.
function link( scope, element, attributes ) {
// The calling context is passing in an expression that will determine
// when the container changes its visibility. When it changes, we want
// to inspect the dimensions of the element.
scope.$watch( attributes.bnContainer, testContainerDimensions );
// I log the dimensions of the element when the visibility changes.
function testContainerDimensions( newValue ) {
var height = element.height();
var width = element.width();
console.log( newValue ? "Visible" : "Hidden" );
console.log( "Dimensions:", width, "x", height );
}
}
}
);
</script>
</body>
</html>
When we run this page and toggle the Div, we get the following console output:
Notice that the observed dimensions of the element are accurate.
Ok, now let's do the same thing, but with AngularJS 1.3. In this version, I'm going to add an additional asynchronous check for element dimensions since the application of the ng-hide class is different in AngularJS 1.3:
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
ngShow / ngHide Classes Get Applied In The $$postDigest Phase In AngularJS 1.3
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController as vm">
<h1>
ngShow / ngHide Classes Get Applied In The $$postDigest Phase In AngularJS 1.3
</h1>
<h2>
AngularJS 1.3.16 Example
</h2>
<p>
<a ng-click="vm.toggleContainer()">Toggle Container</a>
</p>
<!--
Imagine that this directive has a need to auto-adjust its dimensions after the
element becomes visible. As such, we are using the ng-show directive to manage
the visibility and then ALSO telling the custom directive, bn-container, which
scope-value to watch in order for it to determine when it's visible.
NOTE: The more elegant way to solve this might be to have the bn-container
directive manage its own visibility (ie, subsume the responsibility of the
ng-show directive). However, ultimately, the same problem would still exist and
the internals of our directive wouldn't change all that much.
-->
<div
ng-show="vm.isContainerVisible"
bn-container="vm.isContainerVisible"
class="container">
Peek-a-boo!
</div>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs/angular-1.3.16.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the application.
app.controller(
"AppController",
function( $scope ) {
// Define the controller as the view-model.
var vm = this;
// I determine if the container is visible.
vm.isContainerVisible = true;
// Expose the public API.
vm.toggleContainer = toggleContainer;
// ---
// PUBLIC METHODS.
// ---
// I toggle the visibility of the container.
function toggleContainer() {
vm.isContainerVisible = ! vm.isContainerVisible;
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I manage the container element.
app.directive(
"bnContainer",
function( $timeout ) {
// Return the directive configuration object.
// --
// NOTE: Using priority 1 to make sure that this link function (post-link),
// is executed after the ng-show link function (priority 0 - they are
// linked in reverse order). That way, the ng-show $watch() bindings are
// bound first, which means that our $watch() handler will execute after
// the ng-show $watch() handler.
return({
link: link,
priority: 1,
restrict: "A"
});
// I bind the JavaScript events to the local view-model.
function link( scope, element, attributes ) {
// The calling context is passing in an expression that will determine
// when the container changes its visibility. When it changes, we want
// to inspect the dimensions of the element.
scope.$watch( attributes.bnContainer, testContainerDimensions );
// I log the dimensions of the element when the visibility changes.
function testContainerDimensions( newValue ) {
var height = element.height();
var width = element.width();
console.log( newValue ? "Visible" : "Hidden" );
console.log( "Dimensions:", width, "x", height );
// Since the default $animate service is adding and removing the
// ng-hide class in the $$postDigest phase, we need to check the
// dimensions AFTER that happens, which would be in the next
// tick of the event-loop. We'll use $timeout to do this in the
// next tick.
// --
// NOTE: The $animate service can return promises; but, that
// wouldn't help us in this case - those resolve when the animation
// is complete; we don't care about when it is complete, we only
// care about when it *started*, which is when the DOM element is
// actually, physically, visible.
$timeout(
function asyncCheck() {
var height = element.height();
var width = element.width();
console.log( "Async Dimensions:", width, "x", height );
},
0,
false // Don't trigger a digest.
);
}
}
}
);
</script>
</body>
</html>
This time, when we run the page and toggle the link, we get the following console output:
Notice that, this time, the first query of the element dimensions is inaccurate - it reports the dimensions of the previous state. This is because, at the time our $watch() bindings fire, the "ng-hide" class hasn't actually been applied. Since it is now being applied in the $$postDigest phase, the element node isn't actually updated until later on in the same event-loop. As such, we can only get the correct dimensions by waiting until the next tick of the event-loop, at which point the CSS class has been applied and our DOM query can force a useful repaint.
Right now, the responsibilities of visibility are spread across two directives: ngShow and our custom directive. It would probably be more elegant to let the custom directive subsume the responsibility of hiding and showing the element. That said, you would still have the same problem; only, the custom directive would now have a lot more insight and would be able to make better decisions about how and when to do things.
Because the DOM (Document Object Model) reacts to the view-model, in AngularJS, it's never a straightforward task to know when the DOM has been synchronized with the view-model. As such, it's important to understand these small differences across the various versions of AngularJS so that you can program your directives accordingly.
Want to use code from this post? Check out the license.
Reader Comments
I like this blog.
@Lokesh,
Thank you very much, kind sir!
Thank you. Thank you. This is the only place I've found a clear explanation of this. My app is being iFramed in a third party site. I use a $watch to notify the outer frame when my page height changes. But I noticed that if my page has collapsable sections using ng-hide, the page height measurement is always one click behind. So, when I expand the page, it returns a shorter height. Then when I collapse it, it returns a taller height. This explains why.
Any idea how I can catch the offsetHeight of my outer element during or after the $$postDigest?
Another example where this change can be annoying is when you have a google map toggled by a button and the div where the map should be plotted is not yet visible.