“$apply already in progress” when opening confirm

2019-06-25 08:02发布

问题:

I am (sometimes) getting a weird $apply already in progress error when opening a confirm dialog box in the following and innocent looking situation :

var mod = angular.module('app', []);

mod.controller('ctrl', function($scope, $interval, $http) {
  
  $scope.started = false;
  $scope.counter = 0;
  
  // some business function that is called repeatedly
  // (here: a simple counter increase)
  $interval(function() {
    $scope.counter++;
  }, 1000);
  
  // this function starts some service on the backend
  $scope.start = function() {
    if(confirm('Are you sure ?')) {
      return $http.post('start.do').then(function (res) {
        $scope.started = true;
        return res.data;
      });
    };
  };

  // this function stops some service on the backend
  $scope.stop = function() {
    if(confirm('Are you sure ?')) {
      return $http.post('stop.do').then(function (res) {
        $scope.started = false;
        return res.data;
      });
    };
  };

});

// mock of the $http to cope with snipset sandbox (irrelevant, please ignore)
mod.factory('$http', function ($q) {
  return {
    post: function() {
      return $q.when({data:null});
    }
  }
});
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.2.23/angular.min.js"></script>

<div ng-app="app">
  <div ng-controller="ctrl">
    <button ng-disabled="started" ng-click="start()">Start</button>
    <button ng-disabled="!started" ng-click="stop()">Stop</button>
    <br/><br/>seconds elapsed : {{counter}}
  </div>
</div>

The error message is :

$rootScope:inprog] $apply already in progress http://errors.angularjs.org/1.2.23/$rootScope/inprog?p0=%24apply

And the callstack is :

minErr/<@angular.js:78:12
beginPhase@angular.js:12981:1
$RootScopeProvider/this.$get</Scope.prototype.$apply@angular.js:12770:11
tick@angular.js:9040:25
$scope.start@controller.js:153:8
Parser.prototype.functionCall/<@angular.js:10836:15
ngEventHandler/</<@angular.js:19094:17
$RootScopeProvider/this.$get</Scope.prototype.$eval@angular.js:12673:16
$RootScopeProvider/this.$get</Scope.prototype.$apply@angular.js:12771:18
ngEventHandler/<@angular.js:19093:15
jQuery.event.dispatch@lib/jquery/jquery-1.11.2.js:4664:15
jQuery.event.add/elemData.handle@lib/jquery/jquery-1.11.2.js:4333:6

To reproduce :

  • use Firefox (I could not reproduce it with Chrome or IE)
  • open the javascript console
  • click alternatively the start and stop buttons (and confirm the dialogs)
  • try multiple times (10-20x), it does not occur easily

The problem goes away if I remove the confirm dialog box.

I have read AngularJS documentation about this error (as well as other stackoverflow questions), but I do not see how this situation applies as I do not call $apply nor do I interact directly with the DOM.

回答1:

After some analysis, it seems to be a surprising interaction between the $interval and modal dialog in Firefox.

What is the problem ?

The callstack shows something strange : an AngularJS function tick is called within the controller's start function. How is that possible ?

Well, it seems that Firefox does not suspend the timeout/interval functions when displaying a modal dialog box : this allows configured timeout and intervals callback to be called on the top of the currently executing javascript code.

In the above situation, the start function is called with an $apply sequence (initiated by AngularJS when the button was clicked) and when the $interval callback is executed on the top the start function, a second $apply sequence is initiated (by AngularJS) => boom, an $apply already in progress error is raised.

A possible solution

Define a new confirm service (adapted from this and that blog posts) :

// This factory defines an asynchronous wrapper to the native confirm() method. It returns a
// promise that will be "resolved" if the user agrees to the confirmation; or
// will be "rejected" if the user cancels the confirmation.
mod.factory("confirm", function ($window, $q, $timeout) {

    // Define promise-based confirm() method.
    function confirm(message) {
        var defer = $q.defer();

        $timeout(function () {
            if ($window.confirm(message)) {
                defer.resolve(true);
            }
            else {
                defer.reject(false);
            }
        }, 0, false);

        return defer.promise;
    }

    return confirm;
});

... and use it the following way :

// this function starts some service on the backend
$scope.start = function() {
  confirm('Are you sure ?').then(function () {
    $http.post('start.do').then(function (res) {
      $scope.started = true;
    });
  });
};

// this function stops some service on the backend
$scope.stop = function() {
  confirm('Are you sure ?').then(function () {
    $http.post('stop.do').then(function (res) {
      $scope.started = false;
    });
  });
};

This solution works because the modal dialog box is opened within an interval's callback execution, and (I believe) interval/timeout execution are serialized by the javascript VM.



回答2:

I was having the same problem in Firefox. Using window.confirm rather than just confirm fixed it for me.