I was playing around with a Angular and Google Maps, and suddenly the world fell apart. Ok, maybe I'm a bit dramatic here, but angular behaved quite a bit different than I expected.
This is a simplified version of the code that cracked the foundations of my beloved planet:
$scope.geoCode = function () {
$scope.geocoder.geocode({ 'address': $scope.data.search }, function (results, status) {
var loc = results[0].geometry.location;
$scope.data.search = results[0].formatted_address;
$scope.data.location = { lat: loc.lat(), lon: loc.lng() };
});
};
This piece of code is part of a controller, when clicking a button it searches for a address and a geolocation based upon the search query.
Now the surprising thing here is that I had to press that button twice to make it work. And here's why: only code that executes in the Angular Execution Context is being watched. Angular does dirty checking to inform the watchers (the ones that call $watch) that something has changed, so the watcher can do things like re-render, re-calculate, etc.
We can explicitly enter the Angular Execution Context(AEC) by calling $apply(fn), but most of the time this is not necessary since Angular calls that behind the scene for us. And this is what's causing the problem. Angular executes the code in my controller in the AEC, but the asynchronous callback is not, and Angular remains unaware of any changes.
We have found the poison, but we also have the remedy, right? Just call $apply!
$scope.geoCode = function () {
$scope.geocoder.geocode({ 'address': $scope.data.search }, function (results, status) {
var loc = results[0].geometry.location;
$scope.data.search = results[0].formatted_address;
$scope.data.location = { lat: loc.lat(), lon: loc.lng() };
$scope.$apply("data.location");
});
};
Super! except that now, it works half of the time. And the other half, well..., it sends me this little message:
It seems that the $digest loop was still ongoing and you can not call $apply as long as it is running. How long the $digest loop takes is unpredictable. As unpredictable like world-shaking earthquakes! When a model’s value changes, the watchers may respond with even more changes. The $digest loops continues until the model(s) stabilize. For more information about $digest click here.
Hold on! I'm probably not the first guy to ever use an async callback in a controller, what about web service calls for example. Well what about them? Here is an example:
$http.get(url).
success(function (data) {
$scope.model.country = data.geonames[0].countryName;
}).
error(function (data, status) {
$scope.model.country = 'server answered with: ' + status;
});
And this always works. Apparently these callbacks are always called in the AEC without the risk of a re-entry of the $apply. Let's dive into the library itself, and see how it's done.
After scuba diving through the internals of angular for a while I found this little gem:
if (!$rootScope.$$phase) $rootScope.$apply();
It seems that this $$phase flag can hold on of three values, either '$apply', '$digest' or undefined. If undefined, the digest loop has already stopped and we can safely call $apply(). If the digest loop is still ongoing, then my added change to the scope will cause a de-stabilization in the model, and the current digest phase will process my change.
Here is an updated version of my code:
$scope.geoCode = function () {
$scope.geocoder.geocode({ 'address': $scope.data.search }, function (results, status) {
var loc = results[0].geometry.location;
$scope.data.search = results[0].formatted_address;
$scope.data.location = { lat: loc.lat(), lon: loc.lng() };
if (!$scope.$$phase) $scope.$apply("data.location");
});
};
And guess what, it works!
The rumbling sound of colliding rocks grow silent and all is well. Until I suddenly realized that this might not be the best solution ever. $$phase is nowhere to be found in the documentation, and there might be a good reason for that. Also going into the internals of a library is kind of against the rules of encapsulation. Angular might decide to change its internals. As long as the API stays the same, nothing can prevent them. (except for an earthquake, that can stop anything)
So, one final attempt:
$scope.geoCode = function () {
$scope.geocoder.geocode({ 'address': $scope.data.search }, function (results, status) {
$timeout(function () {
var loc = results[0].geometry.location;
$scope.data.search = results[0].formatted_address;
$scope.data.location = { lat: loc.lat(), lon: loc.lng() };
});
});
};
Basically I'm high jacking the $timeout service. The callback always executes nicely in the AEC and I don't have to get into the guts of Angular.
The only problem with this last one is that it might not be clear to another developer why someone would wrap this callback into a $timeout call. It's just something that you have to know.
Well now you know, and knowing is half the battle. If you want to learn more about AngularJS check out our course.
Here is something to look at while letting it sink: