Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Why doesn't Angular ignore subsequent $digest invocations?

The following must be the most common error Angular.js developers have to troubleshoot at some point or another.

Error: $apply already in progress

Sometimes, a $scope.$apply call wasn't required after all, and removing it fixes the issue. Some other times, it might be needed, and so developers resort to the following pattern.

if(!$scope.$$phase) {
    $scope.$apply();
}

My question is, why doesn't Angular merely check for $scope.$$phase and return without doing anything when a $digest loop is in invoked, or when $scope.$apply is called, rather than complain and throw an error so insidiously hard to track down.

Couldn't Angular just check for $$phase itself at the entry points of any methods which trigger a $digest and save the consumers from having to go through the trouble?

Are there any implications in leaving misplaced $scope.$apply calls around, other than unwanted watches firing, which the check for $$phase would prevent?

Why isn't a$$phase check the default, rather than an oddity?

Background

I'm upgrading Angular from 1.2.1 to 1.2.6 and that exception is being thrown when the page loads, so I have no idea where it's originating from. Hence the question, why is it that we even have to track down this obscure sort of bug with no idea of where in our code to look for it?

like image 872
bevacqua Avatar asked Feb 15 '23 06:02

bevacqua


1 Answers

If you ever get this error, then you're using $scope.$apply() in the wrong place. I think the reasoning behind this error is as follows: if you're calling $apply in the wrong place, you probably don't fully understand the reason it exists, and you may have missed places where it actually should be called.

If such lapses are allowed to go by silently (by always testing $scope.$$phase first, for example), it would encourage sloppy usage, and people might get holes in their "$apply coverage". That kind of bug (where a $scope change is not reflected in the view) is a lot harder to track down than this error message, which you should be able to debug by examining the stack-trace (see below).

Why does $apply exist?

The purpose of $apply is to enable automated two-way data binding between $scope and view, one of the main features of Angular. This feature requires that all code that may potentially contain a $scope modification (so, basically every piece of user-code) is run below an $apply call in the call-stack. Doing this properly is actually not that complicated, but I think it's not documented very well.

The main 'problem' is that Javascript can have any number of active call-stacks, and Angular is not automatically notified about new ones. A new call-stack is created whenever an asynchronous callback is triggered, e.g., from a click, a timeout, a file-access, a network response, and so on. It's very common to want to modify the $scope inside such a callback function. If the corresponding event was provided by Angular itself, everything will just work. But there are times when you'll want to subscribe to 'outside events'. Google Maps events, for example:

function Controller($scope) {
    $scope.coordinates = [];
    //...
    var map = new google.maps.Map(mapElement, mapOptions);
    google.maps.event.addDomListener(map, 'dblclick', function (mouseEvent) {
        $scope.coordinates.push(mouseEvent.latLng);
    });
}

http://jsfiddle.net/mhelvens/XLPY9/1/

A double-click on the map of this example will not trigger an update to the view because the coordinates are added to $scope.coordinates from an '$applyless' call-stack. In other words, Angular does not know about Google Maps events.

How and where to use $apply?

We can inform Angular about the event by using $scope.$apply():

function Controller($scope) {
    //...
    google.maps.event.addDomListener(map, 'dblclick', function (mouseEvent) {
        $scope.$apply(function () {
            $scope.coordinates.push(mouseEvent.latLng);
        });
    });
}

http://jsfiddle.net/mhelvens/XLPY9/2/

The rule is to do this first thing inside every callback function for an outside event. The "$apply already in progress" error is an indication that you're not following this rule. If you have to handle Google Maps events often, it makes sense to wrap this boilerplate code in a service:

app.factory('onGoogleMapsEvent', function ($rootScope) {
    return function (element, event, callback) {
        google.maps.event.addDomListener(element, event, function (e) {
            $rootScope.$apply(function () { callback(e); });
        });
    };
});

function Controller($scope, onGoogleMapsEvent) {
    //...
    onGoogleMapsEvent(map, 'dblclick', function (mouseEvent) {
        $scope.coordinates.push(mouseEvent.latLng);
    });
}

http://jsfiddle.net/mhelvens/XLPY9/3/

The onGoogleMapsEvent events are now Angular-aware, or 'inside events' if you will (I'm totally making these terms up, by the way). This makes your code more readable and allows you to forget about $apply in your day-to-day programming; just call the wrapper instead of the original event subscriber.

For a number of events, Angular has already done this for us. With the $timeout service, for example.

Debugging the "$apply already in progress" error

So let's say I use the wrapper, but, absentminded as I am, I still call $apply manually:

onGoogleMapsEvent(map, 'dblclick', function (mouseEvent) {
    $scope.$apply(function () { // Error: $apply already in progress
        $scope.coordinates.push(mouseEvent.latLng);
    });
});

http://jsfiddle.net/mhelvens/XLPY9/4/

This $apply call does not follow our placement rule (onGoogleMapsEvent events are not outside events, after all; we granted them 'insideness'). If you double-click on the map, you'll see the error appear in the logs, together with a stack-trace:

Error: [$rootScope:inprog] $apply already in progress
    ...
    at Scope.$apply (.../angular.js:11675:11)
    at http://fiddle.jshell.net/mhelvens/XLPY9/4/show/:50:16
    ...
    at Scope.$apply (.../angular.js:11676:23)
    at dl.ondblclick (http://fiddle.jshell.net/mhelvens/XLPY9/4/show/:33:24)
    ...

I've left only the relevant lines: the ones that refer to Scope.$apply. When you get this error message, you should always find two Scope.$apply calls on the stack (or Scope.$digest calls, which serve a similar function; $digest is called by $apply).

The topmost call indicates where we got the error. The other one indicates why. Go ahead and run the Fiddle with your Javascript console opened up. In this case we can conclude: "oh, this was an 'inside event' subscriber, I can just remove my manual $apply call". But in other situations you may find out that the call lower down on the stack is also not positioned properly.

And that's precisely why I believe the error is useful. Ideally you'd only get an error when you neglect to call $apply altogether. But if Angular could do that, there would be no need to call the function manually in the first place.

like image 91
mhelvens Avatar answered Feb 17 '23 02:02

mhelvens