Exploring $q And Scope $digest Integration In AngularJS
Lately, I've been doing a lot of Promise exploration in AngularJS. Promises are an amazing mechanism for handling asynchronous processing, which is basically what JavaScript is all about. In AngularJS, the $q service - Angular's promise library - is tightly integrated with the "model observation mechanism." This means that it triggers a $digests at appropriate times. But, when exactly does that happen?
Run this demo in my JavaScript Demos project on GitHub.
If you think about the "model observation mechanism" in AngularJS, the entire point is to find data that has changed and then update the View accordingly. As such, it would make sense for the $q service to only trigger a digest when it's possible that a view-model could have changed. As such, some of the $q methods and contexts will trigger a digest, others won't.
To see this in action, I've created a demo that interacts with AngularJS promises inside of various directives. I've chosen to wire this up using custom directives, rather than the ngClick directive, in order to ensure that AngularJS won't know about my actions. Remember, once you're inside of a custom directive, it's up to you as the developer to tell AngularJS when something has changed. Using custom directives allows us to isolate the $q-initiated digests.
In the following code, each directive demonstrates a different $q-interaction in a unique context:
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Exploring $q And Scope $digest Integration In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="demo.css"></link>
</head>
<body ng-controller="AppController">
<h1>
Exploring $q And Scope $digest Integration In AngularJS
</h1>
<!--
NOTE: All of our click-handlers are being defined inside DIRECTIVES rather than
in ng-click handlers so that we are sure to be outside of an "Angular Context".
This way, we can isolate the $q / $digest interaction.
-->
<p>
<a bn-bind-unresolved>Bind to unresolved promise</a>
<a bn-bind-resolved>Bind to resolved promise</a>
<a bn-resolve>Resolve promise</a>
<a bn-re-resolve>Re-Resolve an already-resolved promise</a>
<a bn-resolve-chain>Resolved chained-promise</a>
<a bn-resolve-timeout-chain>Resolved timeout-chained-promise</a>
<a bn-notify>Notify a pending-promise</a>
<a bn-notify-resolved>Notify a resolved-promise</a>
</p>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.3.12.min.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 ) {
// Here's we're binding to the $digest lifecycle. By Supplying a $watch()
// function, we can get a hook into each digest iteration. We're going to
// log out each digest so we can see how it mixes with the other data
// that we log around the promise binding and resolution.
$scope.$watch(
function logDigest() {
console.log( "Such $digest! Much triggered." );
}
);
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I test $q and $digest interactions.
app.directive(
"bnBindUnresolved",
function( $q ) {
// In this demo, we're going to bind a promise handler to a deferred
// value that is in a pending state. Since the binding has no chance of
// changing the view-model, NO DIGEST is triggered.
return({
link: function( scope, element ) {
var unresolved = $q.defer();
element.click(
function( event ) {
console.info( "Binding to unresolved promise." );
unresolved.promise.then( angular.noop );
}
);
}
});
}
);
// I test $q and $digest interactions.
app.directive(
"bnBindResolved",
function( $q ) {
// In this demo, we're going to bind a promise handler to a RESOLVED
// deferred value. Since this causes the bound callback to be invoked
// immediately (yet asynchronously), it means that the view-model has
// a chance to change. As such, this will TRIGGER A DIGEST.
return({
link: function( scope, element ) {
var resolved = $q.when( true );
element.click(
function( event ) {
console.info( "Binding to resolved promise." );
resolved.then(
function() {
console.log( "( 1 ) Callback invoked." );
}
);
// NOTE: Here to demonstrate that it will be output in
// the console before the log above (due to the
// asynchronous nature of promises).
console.log( "( 2 ) Asynchronous callback test." );
}
);
}
});
}
);
// I test $q and $digest interactions.
app.directive(
"bnResolve",
function( $q ) {
// In this demo, we're going to resolve a deferred value that is in a
// pending state. Since this will cause the promise callbacks to be
// invoked, it means that there is a chance the view-model will be
// changed. As such, this will TRIGGER A DIGEST.
return({
link: function( scope, element ) {
var deferred = $q.defer();
// NOTE: We have to assign at least one promise handler
// otherwise, AngularJS won't bother scheduling anything.
deferred.promise.then( angular.noop );
element.click(
function( event ) {
console.info( "Resolving a promise." );
deferred.resolve( true );
}
);
}
});
}
);
// I test $q and $digest interactions.
app.directive(
"bnReResolve",
function( $q ) {
// In this demo, we're going to try to resolve an already-resolved
// deferred value. Since a promise cannot be resolved (or rejected) more
// than once, this will NOT trigger a digest.
return({
link: function( scope, element ) {
var deferred = $q.defer();
// Resolve it ahead of time.
deferred.resolve();
// NOTE: We have to assign at least one promise handler
// otherwise, AngularJS won't bother scheduling anything.
deferred.promise.then( angular.noop );
element.click(
function( event ) {
console.info( "Resolving an already-resolved promise." );
deferred.resolve( true );
}
);
}
});
}
);
// I test $q and $digest interactions.
app.directive(
"bnResolveChain",
function( $q ) {
// In this demo, we're going to resolve a deferred value that has a
// long promise-chain attached to it. We already know that a DIGEST
// WILl BE TRIGGERED (based on demo above); this is to see if a digest
// is triggered per-promise or, after the entire promise chain is
// executed.
return({
link: function( scope, element ) {
// I simply pass-through the resolved value.
function passThrough( value ) {
return( value );
}
var deferred = $q.defer();
deferred.promise
.then( passThrough )
.then( passThrough )
.then( passThrough )
.then( passThrough )
;
element.click(
function( event ) {
console.info( "Resolving a promise chain (4)." );
deferred.resolve( true );
}
);
}
});
}
);
// I test $q and $digest interactions.
app.directive(
"bnResolveTimeoutChain",
function( $q, $timeout ) {
// In this demo, we're going to resolve a deferred value that has a
// long promise-chain attached to it. However, unlike the previous demo,
// each of these promise callbacks will return a new promise that is
// delayed by some given timeout. This is to see when a digest is
// triggered - after the entire chain? Or after each delayed promise.
return({
link: function( scope, element ) {
// I return a new promise that will be resolved after the given
// timeout delay.
function delay( time ) {
return( $timeout( angular.noop, time ) );
}
var deferred = $q.defer();
deferred.promise
.then( delay )
.then( delay )
.then( delay )
.then( delay )
;
element.click(
function( event ) {
console.info( "Resolving a $timeout promise chain (4)." );
deferred.resolve( 50 );
}
);
}
});
}
);
// I test $q and $digest interactions.
app.directive(
"bnNotify",
function( $q, $timeout ) {
// In this demo, we're going to notify a pending deferred value. Since a
// notification may cause a view-model change, this will TRIGGER A DIGEST.
return({
link: function( scope, element ) {
var deferred = $q.defer();
// NOTE: We have to assign at least one promise handler
// otherwise, AngularJS won't bother scheduling anything.
deferred.promise.then( angular.noop );
element.click(
function( event ) {
console.info( "Notifying a pending promise." );
deferred.notify( "update" );
}
);
}
});
}
);
// I test $q and $digest interactions.
app.directive(
"bnNotifyResolved",
function( $q, $timeout ) {
// In this demo, we're going to notify a pending deferred value. Since a
// notification may cause a view-model change, this will TRIGGER A DIGEST.
return({
link: function( scope, element ) {
var deferred = $q.defer();
// Resolve it ahead of time.
deferred.resolve();
// NOTE: We have to assign at least one promise handler
// otherwise, AngularJS won't bother scheduling anything.
deferred.promise.then( angular.noop );
element.click(
function( event ) {
console.info( "Notifying a resolved promise." );
deferred.notify( "update" );
}
);
}
});
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I create an element plugin to allow for "click" to be used a short-hand for
// binding element events. This is just to reduce noise in the demo.
app.config(
function() {
angular.element.prototype.click = function( callback ) {
return( this.on( "click", callback ) );
};
}
);
</script>
</body>
</html>
If I run down the list of links, and click each one in turn, here is the console output that we get:
As you can see, the $q service only triggers a $digest when there is a possibility that the view-model has changed.
In this exploration, I'm looking at the $q service explicitly. As such, it might be unclear as to how powerful this integration actually is. Not only does $q provide promises, it is also the mechanism through which all timeouts and HTTP transportation works. That is why we never have to worry about manually triggering a $digest when executing AJAX (Asynchronous JavaScript and JSON) requests in an AngularJS application.
Want to use code from this post? Check out the license.
Reader Comments
In bnResolveTimeoutChain, are you sure it's the delay between each resolving which trigger a $digest ? I think it's the $timeout which trigger the $digest, if I replace it with a setTimeout, $digest is trigger just one time.
@Vincent,
First of all, awesome catch! You are totally right that $timeout() does trigger a $digest. However, to some degree we are both correct. What I really should have done is added a "false" as the third argument to the $timeout example:
function delay( time ) {
// NOTE: False is here to prevent digest triggered by timeout.
return( $timeout( angular.noop, time, false ) );
}
... by using "false", it will cause the $timeout() service to act more like the native setTimeout() method.
BUT, when I add that, I still get multiple digests being triggered.
This was a bit of a head-scratcher for a minute. Then, I realized! The reason you are only getting one digest, not multiple digests when using setTimeout(), is because setTimeout() does NOT return a promise -- it returns the reference to the timer you just created. As such, that value is used to immediately resolve the .then()-derived promise. This lets the entire chain resolve in one-go.
The $timeout() service, on the other hand, returns a promise which is used to eventually resolve/reject the derived promise.
So, you are correct in that I *should* have been using the "false" parameter to prevent $timeout() from triggering a $digest. But, as it turns out, it doesn't quite change the output. But, it does prevent me from triggering twice-as-many digests.
Excellent catch!!
good team work !
hi,
i have one question to ask you.
can you tell me that, what will happen if multiple deffered calls resolves at same point of time and trying to execute their then function ?
Thanks !!
@Amitpal,
Great question! In JavaScript, there is an "event loop" which manages when every piece of code executes. While JavaScript, in general, is considered highly asynchronous, the actual execution of the code is extremely *synchronous*. This means that you will **never** have two asynchronous pieces of code (in the same event-loop) execute at the same time. One will always come before the other... always.
So, the next question is, how does that tie into the AngularJS digest and general workflow? That's a bit of a tricky question, and I think will depend a little bit on what is happening in AngularJS. For example, in the $http service, you can tell AngularJS to use $applyAsync(), which will resolve the $http promise after a small delay - this would theoretically allow multiple promises to all resolve in the same digest. However, my *guess* is that most parts of the code use $evalAsync() which will just evaluate later in the same $digest.
Sorry, if my answer is not so clear - it's because I don't have a perfect grasp of how asynchronous code and the digest all fit together. But, my best guess is that, in most cases, each asynchronous action will lead its own unique $digest cycle since the event-loop will not let two asynchronous actions "finish" in the same event-loop cycle... at least, that is how I understand it.
@Ben,
Thanks for reply and yes I totally agree with you.
Thanks for explaining.
So in bnReResolve, why is a digest not triggered when you call deferred.promise.then( angular.noop ) inside the link function?