angular / angular.js

AngularJS - HTML enhanced for web apps!
https://angularjs.org
MIT License
58.91k stars 27.55k forks source link

Infinite digest on location change on iOS 9 w/ UIWebView (not in Safari/ WKWebView) #12241

Closed ttopalov closed 8 years ago

ttopalov commented 8 years ago

The following simple HTML demonstrates the issue:

<!DOCTYPE html>
<html>
    <head>
        <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.16/angular.js"></script>
        <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.3.16/angular-route.js"></script>
        <script>
            angular.module('fail', ['ngRoute'])
            .config(function($routeProvider) {
                $routeProvider
                .when('/a', {
                    template: '<a ng-href="#/b">a</a>'
                })
                .when('/b', {
                    template: '<a ng-href="#/a">b</a>'
                })
                .otherwise({
                    redirectTo: '/a'
                });
            });
        </script>
    </head>
    <body ng-app="fail">
        <div ng-view></div>
    </body>
</html>

This runs as expected on most devices, but it throws an infinite digest exception on iOS 9. I'm able to reproduce on both iPad Air 2 and iPad 4th generation with iOS 9 beta 2. I realize it's probably an issue in iOS, but it might still be worth investigating.

cxm01 commented 8 years ago

I met similar issue, which happened on ios 9, but run ok on other devices.

cxm01 commented 8 years ago

I reproduced this issue with the same code provided by santaslow on 1.4.1 / ios 9:

<!DOCTYPE html>
<html>
<head>
    <script src="../static/js/angular/angular.1.4.1.js"></script>
    <script src="../static/js/angular-route/angular-route.1.4.1.js"></script>
    <script>
        angular.module('fail', ['ngRoute'])
                .config(function ($routeProvider) {
                    $routeProvider
                            .when('/a', {
                                template: '<a ng-href="#/b">a</a>'
                            })
                            .when('/b', {
                                template: '<a ng-href="#/a">b</a>'
                            })
                            .otherwise({
                                redirectTo: '/a'
                            });
                }).factory('$exceptionHandler', ['$log', function($log) {
                    return function(exception, cause) {
                        var message = 'angularjs exception: '+exception.message+': caused by "' + cause+ '\njs stack:\n'+exception.stack;
                        $log.error(message);
                    };
                }]);
    </script>
</head>
<body ng-app="fail">
<div ng-view></div>
</body>
</html>

The code above run normally on desktop browser, android and ios 8 webview, but on ios 9 it will throw exception when I click the link:

2015-07-02 11:00:09 ... angularjs exception: [$rootScope:infdig] 10 $digest() iterations reached. Aborting!
Watchers fired in the last 5 iterations: []
http://errors.angularjs.org/1.4.1/$rootScope/infdig?p0=10&p1=%5B%5D: caused by "undefined
js stack:
file:///.../static/js/angular/angular.js:68:32
$digest@file:///.../static/js/angular/angular.js:15705:35
$apply@file:///.../static/js/angular/angular.js:15935:31
file:///.../static/js/angular/angular.js:12070:30
eventHandler@file:///.../static/js/angular/angular.js:3264:25
ttopalov commented 8 years ago

I can no longer reproduce in iOS 9 Beta 3.

aexei commented 8 years ago

I receive the same error with ios9 public beta (13A4293g)

cxm01 commented 8 years ago

I verified the code above on ios 9 beta 3 (13A4293g), no exception anymore. But the app with using ng-view still throw infdig exceptions on ios 9 beta 3.

arkomr commented 8 years ago

I receive the same error with ios9 public beta (13A4293g)

Error: [$rootScope:infdig] 10 $digest() iterations reached. Aborting! Watchers fired in the last 5 iterations: [] http://errors.angularjs.org/1.3.13/$rootScope/infdig?p0=10&p1=%5B%5D file:///Users/mac5/Library/Developer/CoreSimulator/Devices/749DE7E3-D93F-47F9-A1FC-E3D54A1CCEEE/data/Containers/Bundle/Application/9B5EE368-F2A0-4C99-807B-EA17B2479E58/BAShops.app/www/lib/ionic/js/ionic.bundle.js:8762:32 $digest@file:///Users/mac5/Library/Developer/CoreSimulator/Devices/749DE7E3-D93F-47F9-A1FC-E3D54A1CCEEE/data/Containers/Bundle/Application/9B5EE368-F2A0-4C99-807B-EA17B2479E58/BAShops.app/www/lib/ionic/js/ionic.bundle.js:22980:35 $apply@file:///Users/mac5/Library/Developer/CoreSimulator/Devices/749DE7E3-D93F-47F9-A1FC-E3D54A1CCEEE/data/Containers/Bundle/Application/9B5EE368-F2A0-4C99-807B-EA17B2479E58/BAShops.app/www/lib/ionic/js/ionic.bundle.js:23205:31 file:///Users/mac5/Library/Developer/CoreSimulator/Devices/749DE7E3-D93F-47F9-A1FC-E3D54A1CCEEE/data/Containers/Bundle/Application/9B5EE368-F2A0-4C99-807B-EA17B2479E58/BAShops.app/www/lib/ionic/js/ionic.bundle.js:54879:24 eventHandler@file:///Users/mac5/Library/Developer/CoreSimulator/Devices/749DE7E3-D93F-47F9-A1FC-E3D54A1CCEEE/data/Containers/Bundle/Application/9B5EE368-F2A0-4C99-807B-EA17B2479E58/BAShops.app/www/lib/ionic/js/ionic.bundle.js:11713:25 dispatchEvent@[native code] triggerMouseEvent@file:///Users/mac5/Library/Developer/CoreSimulator/Devices/749DE7E3-D93F-47F9-A1FC-E3D54A1CCEEE/data/Containers/Bundle/Application/9B5EE368-F2A0-4C99-807B-EA17B2479E58/BAShops.app/www/lib/ionic/js/ionic.bundle.js:2863:20 tapClick@file:///Users/mac5/Library/Developer/CoreSimulator/Devices/749DE7E3-D93F-47F9-A1FC-E3D54A1CCEEE/data/Containers/Bundle/Application/9B5EE368-F2A0-4C99-807B-EA17B2479E58/BAShops.app/www/lib/ionic/js/ionic.bundle.js:2852:20 tapTouchEnd@file:///Users/mac5/Library/Developer/CoreSimulator/Devices/749DE7E3-D93F-47F9-A1FC-E3D54A1CCEEE/data/Containers/Bundle/Application/9B5EE368-F2A0-4C99-807B-EA17B2479E58/BAShops.app/www/lib/ionic/js/ionic.bundle.js:2975:13

epaga commented 8 years ago

We are also receiving this error on public Beta 3 of iOS 9 with our own Angular application. It does not occur in iOS 8.

cxm01 commented 8 years ago

As a workaround, I wrote a simple directive to replace ng-view and angular-route.js. The solution worked well in our own application, all infdig exceptions disappeared on ios 9 beta/beta 3. Below is the simplified code, which is just for our own application, general use cases are not considered. I DO NOT recommend other people to use this:

(function (window) {
    'use strict';

    var myApp = angular.module("myApp");
    var $route = {};

    // replace $routeProvider.when with the function below:
    window.routeWhen = function(path, route) {
        $route[path] = route;
    };

    myApp.directive("myView", ['$compile', '$controller', '$http', '$rootScope', function ($compile, $controller, $http, $rootScope) {

        return {
            priority: -400,
            link: function (scope, element) {
                var parentScope = scope;
                scope = null;

                window.updateView = function (path) {
                    location.hash = '#'+url;
                    if (scope) scope.$destroy();
                    scope = parentScope.$new();

                    var route = $route[path];

                    var linkView = function(html) {
                        element.html(html);
                        var link = $compile(element.contents());
                        var controller = $controller(route.controller, {$scope: scope});
                        element.data('$ngControllerController', controller);
                        element.children().data('$ngControllerController', controller);
                        link(scope);
                        scope.$emit('$viewContentLoaded');
                        if (!$rootScope.$$phase && !scope.$$phase) scope.$apply();
                    };
                    if (route.templateCache) linkView(route.templateCache)
                    else if (route.template) {
                        route.templateCache = document.getElementById(route.template).innerHTML;
                        linkView(route.templateCache)
                    }
                    else $http.get(route.templateUrl;).success(function(html) {
                        route.templateCache = html;
                        linkView(html);
                    });
                };
                //updateView(initialPath);
                // call updateView(path) to set location at other places of the app
            }
        };
    }]);

})(window)
raftheunis87 commented 8 years ago

Just installed iOS 9 Beta 4 and still have the same issue. Anyone else?

craig-at-rsg commented 8 years ago

I saw it in iOS 9 Beta 3 and I'm still seeing it in iOS 9 Beta 4.

aexei commented 8 years ago

+1

vincentdu101 commented 8 years ago

Yeah we're seeing the same issue as well even with angular ui router. Does anybody have a valid work around for this issue in the meantime?

viattik commented 8 years ago

Seeing the same issue in uiWebView on latest iOS9.

raftheunis87 commented 8 years ago

Does anyone have any updates on this issue?

lgalfaso commented 8 years ago

This is still an issue. Reopening

Narretz commented 8 years ago

It looks like an ios issue. Is this tracked on webkit somewhere?

mike1e commented 8 years ago

+1

RolfDeVries commented 8 years ago

+1

borrull commented 8 years ago

Same here. Our Cordova app runs fine if running as web on the iPad Safari, but the infinite digest happens if it runs as a Cordova app (UIWebView).

raftheunis87 commented 8 years ago

Exactly the same issues as @borrull ! Already experimented with WKWebView and then the issue is non-existing. But we can't use WKWebView as we need Local File Serving (and we don't want to run a local server in our application) and cookies. So it has to do something with UIWebView in combination with Cordova/Mobile Safari on iOS 9. I'm currently debugging the $locationWatch in Angular because I see that our application wants to transition to a different location multiple times and then (after 10 times) the digest error is thrown.

axelsegers commented 8 years ago

same issue here on iOS9 béta4 Angular infinite $digest loop ;(

andyverbunt commented 8 years ago

+1, only in UIWebView, not in WKWebView but we can only use UIWebView in our cordova app.

laurentverbruggen commented 8 years ago

+1

Narretz commented 8 years ago

If this is an iOS specific issue, please open an issue on the webkit issue tracker and provide a demo! +1 here will not likely change anything, as it really really sounds like a browser bug.

aexei commented 8 years ago

Did someone reported this bug to Apple for investigation?

axelsegers commented 8 years ago

I reported this bug to apple. But maybe you all should do the same to get their attention on the bug.

RolfDeVries commented 8 years ago

Can you give us a link so we can +1?

axelsegers commented 8 years ago

It's in our personal apple account via the bug reporter .. so no public link ;(

epaga commented 8 years ago

Could you post it to openradar and share the rdar id so we can dupe it!

axelsegers commented 8 years ago

same on iOS9 béta 5 : works on mobile safari works on WKWebview which we can't use because it can't serve local files and does not support NSProtocol works NOT on UIWebView

mk2988 commented 8 years ago

same here on IOS 9 Beta 5

borrull commented 8 years ago

I filed a bug with Apple as well. The Open Radar link is: https://openradar.appspot.com/22186109 (This should help the ones lazy to file a bug). Please leave comments if you can improve the bug wording/explanation ;-) You can download the Xcode project to attach with the bug filing on the Open Radar ticket (Thanks to @santaslow for the JS in the OP)

raftheunis87 commented 8 years ago

I've created a version of the same Xcode project (from @borrull) but with ui-router instead of ng-route. Exactly the same issue. For the people who are interested, you can find the project here: http://s000.tinyupload.com/index.php?file_id=87281871603760127355

CleverCoder commented 8 years ago

We're also seeing thus issue. I was able to chase down the issue to being that the location.* properties aren't updating immediately when angular is in the mix. If you attempt to assign a value to location.hash (like what is being done behind the location service), then immediately read it back in, the value hasn't changed. There appear to be some side effects occurring as a result of the jqlite handlers attached to popstate and hashchanged events.

I'll try and upload a sample when I'm on a computer.

abrahamrkj commented 8 years ago

+1

raftheunis87 commented 8 years ago

@CleverCoder Any updates on the sample?

CleverCoder commented 8 years ago

I'll have to finish up the repro case in the morning, as the code as I've been tied up all weekend. Thanks for the nudge! As iOS 9 counts down, we have a vested interest in seeing this resolved. I'll upload something as soon as I can.

mk2988 commented 8 years ago

+1

CleverCoder commented 8 years ago

I've reproduced what I believe to be the root cause, where setting the location hash or href properties don't "apply" immediately. Here's a link to the XCode project: https://www.dropbox.com/s/2jkwv2thhm86nly/iOS%209%20Location%20Bug.zip?dl=0 Let me know if you can't access the file.

Observe the resulting value in location.hash, as well as attaching Safari to debug. There seems to be something deferring the change as a result of some event plumbing based on the 'popstate' and 'hashchange' events.

I hope this is helpful.

mike1e commented 8 years ago

We are using window.location.href instead of using state.go and it seems to be working for now. Less buggy.

hober commented 8 years ago

The value of location.hash will be correct after a spin of the runloop. Angular can easily work around this issue by delaying the location.hash get in a setTimeout(..., 0). I think this would be ~2 changes to angular.js/src/ng/location.js.

raftheunis87 commented 8 years ago

@hober Tried the timeout with angular like this:

// update $location when $browser url changes
    $browser.onUrlChange(function(newUrl, newState) {
      $rootScope.$evalAsync(function() {
        var oldUrl = $location.absUrl();
        var oldState = $location.$$state;
        var defaultPrevented;

        $location.$$parse(newUrl);
        $location.$$state = newState;

        defaultPrevented = $rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl,
            newState, oldState).defaultPrevented;

        // if the location was changed by a `$locationChangeStart` handler then stop
        // processing this location change
        if ($location.absUrl() !== newUrl) return;

        if (defaultPrevented) {
          $location.$$parse(oldUrl);
          $location.$$state = oldState;
          setTimeout(function(){ setBrowserUrlWithFallback(oldUrl, false, oldState) }, 0);
        } else {
          initializing = false;
          afterLocationChange(oldUrl, oldState);
        }
      });
      if (!$rootScope.$$phase) $rootScope.$digest();
    });

and

// update browser
    $rootScope.$watch(function $locationWatch() {
      var oldUrl = trimEmptyHash($browser.url());
      var newUrl = trimEmptyHash($location.absUrl());
      var oldState = $browser.state();
      var currentReplace = $location.$$replace;
      var urlOrStateChanged = oldUrl !== newUrl ||
        ($location.$$html5 && $sniffer.history && oldState !== $location.$$state);

      if (initializing || urlOrStateChanged) {
        initializing = false;

        $rootScope.$evalAsync(function() {
          var newUrl = $location.absUrl();
          var defaultPrevented = $rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl,
              $location.$$state, oldState).defaultPrevented;

          // if the location was changed by a `$locationChangeStart` handler then stop
          // processing this location change
          if ($location.absUrl() !== newUrl) return;

          if (defaultPrevented) {
            $location.$$parse(oldUrl);
            $location.$$state = oldState;
          } else {
            if (urlOrStateChanged) {
              setTimeout(function(){ setBrowserUrlWithFallback(newUrl, currentReplace,
                  oldState === $location.$$state ? null : $location.$$state) }, 0);
            }
            afterLocationChange(oldUrl, oldState);
          }
        });
      }

      $location.$$replace = false;

      // we don't need to return anything because $evalAsync will make the digest loop dirty when
      // there is a change
    });

So I added a setTimout around the setBrowserUrlWithFallback method but it doesn't resolve the issue.

hober commented 8 years ago

Here's a reduced test case that doesn't rely on Angular, and that demonstrates the workaround. How to actually implement the workaround in Angular is unclear to me. https://gist.github.com/hober/a29b6c28ac1744c800dd

raftheunis87 commented 8 years ago

Got a little bit further on this one. I've made a change in Angular regarding the location. In the "update browser" part, I've changed the $rootScope.$evalAsync to $rootScope.$applyAsync.

The two methods appear to do exactly the same thing. The difference doesn't become evident until you look at the actual $digest execution. When AngularJS executes a digest, it walks the Scope tree and executes $watch() bindings until no more dirty data is produced. During this lifecycle, both the $applyAsync() queue and the $evalAsync() queue get flushed; but, this happens in two very different places.

The $applyAsync() queue only gets flushed at the top of the $digest before AngularJS starts checking for dirty data. As such, the $applyAsync() queue will be flushed, at most, one time during a $digest and will only get flushed if the queue was already populated before the $digest started.

The $evalAsync() queue, on the other hand, is flushed at the top of the while-loop that implements the "dirty check" inside the $digest. This means that any expression added to the $evalAsync() queue during a digest will be executed at a later point within the same digest.

To make this difference more concrete, it means that asynchronous expressions added by $evalAsync() from within a $watch() binding will execute in the same digest. Asynchronous expressions added by $applyAsync() from within a $watch() binding will execute at a later point in time (~10ms).

Hope this already helps out some of you :-).


// update browser
    $rootScope.$watch(function $locationWatch() {
      var oldUrl = trimEmptyHash($browser.url());
      var newUrl = trimEmptyHash($location.absUrl());
      var oldState = $browser.state();
      var currentReplace = $location.$$replace;
      var urlOrStateChanged = oldUrl !== newUrl ||
        ($location.$$html5 && $sniffer.history && oldState !== $location.$$state);

      if (initializing || urlOrStateChanged) {
        initializing = false;

        $rootScope.$applyAsync(function() {
          var newUrl = $location.absUrl();
          var defaultPrevented = $rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl,
              $location.$$state, oldState).defaultPrevented;

          // if the location was changed by a `$locationChangeStart` handler then stop
          // processing this location change
          if ($location.absUrl() !== newUrl) return;

          if (defaultPrevented) {
            $location.$$parse(oldUrl);
            $location.$$state = oldState;
          } else {
            if (urlOrStateChanged) {
              setBrowserUrlWithFallback(newUrl, currentReplace,
                                        oldState === $location.$$state ? null : $location.$$state);
            }
            afterLocationChange(oldUrl, oldState);
          }
        });
      }

      $location.$$replace = false;

      // we don't need to return anything because $evalAsync will make the digest loop dirty when
      // there is a change
    });
CleverCoder commented 8 years ago

Here's another approach. I'm not that familiar with the Angular codebase, but the logic seems rational. The browser url(...) function currently depends on the location.href immediately returning the correct URL. Because this method is called in the same run loop, within the $digest stabilization cycle, it continues to get the old URL. This patch leverages a 'pendingHref' to track the assignment, returning that value instead, if it is set. Once the value is aligned with location.href, the pending value is cleared. During a set of the url, a timer is set with 0ms to catch the case where a url() get isn't called. It's not perfect, but the logic seems to work. This is mainly to consider an alternative approach that doesn't create delays in performance. This is based on Angular tag v1.4.3.

diff --git a/src/ng/browser.js b/src/ng/browser.js
index 928de95..3b9957e 100644
--- a/src/ng/browser.js
+++ b/src/ng/browser.js
@@ -87,7 +87,9 @@ function Browser(window, document, $log, $sniffer) {
   var cachedState, lastHistoryState,
       lastBrowserUrl = location.href,
       baseElement = document.find('base'),
-      reloadLocation = null;
+      reloadLocation = null,
+      pendingHref = null,
+      pendingHrefTimer = null;

   cacheState();
   lastHistoryState = cachedState;
@@ -124,6 +126,18 @@ function Browser(window, document, $log, $sniffer) {
     if (location !== window.location) location = window.location;
     if (history !== window.history) history = window.history;

+    // Schedule cleaning up pendingHref on the next run loop for setting URL. This is to handle
+    // the case where the browser doesn't update the location.* properties immediately
+    if (!pendingHrefTimer && pendingHref && url) {
+      pendingHrefTimer = setTimeout(function () {
+        if (location.href == pendingHref) {
+          console.log('Actual href updated... setting pendingHref to null from setTimeout');
+          pendingHref = null;
+        }
+        pendingHrefTimer = null;
+      }, 0);
+    }
+
     // setter
     if (url) {
       var sameState = lastHistoryState === state;
@@ -147,6 +161,7 @@ function Browser(window, document, $log, $sniffer) {
         // Do the assignment again so that those two variables are referentially identical.
         lastHistoryState = cachedState;
       } else {
+        pendingHref = url;
         if (!sameBase || reloadLocation) {
           reloadLocation = url;
         }
@@ -161,10 +176,22 @@ function Browser(window, document, $log, $sniffer) {
       return self;
     // getter
     } else {
+      var href = location.href.replace(/%27/g, "'");
+      if (pendingHref) {
+        //console.log('.. using pendingHref for url() return value');
+        href = pendingHref;
+      }
+
+      if (location.href == pendingHref) {
+        console.log('Actual href updated... setting pendingHref to null in getter');
+        pendingHref = null;
+      }
+
+      //var href = location.href.replace(/%27/g,"'");
       // - reloadLocation is needed as browsers don't allow to read out
       //   the new location.href if a reload happened.
       // - the replacement is a workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=407172
-      return reloadLocation || location.href.replace(/%27/g,"'");
+      return reloadLocation || href;
     }
   };
raftheunis87 commented 8 years ago

Thanks @CleverCoder for the solution! Seems to work like a charm! :+1:

viattik commented 8 years ago

@CleverCoder would be great if you do a pull request with this to angular team.

raftheunis87 commented 8 years ago

@viattik I would kind of surprise me if the angular team would adopt a workaround for UIWebView on iOS9 since the bug is in UIWebView (Apple) itself. But you can always try...

viattik commented 8 years ago

@raftheunis87 There are lots of bugs in different browsers and lots of workarounds for those bugs in angular code. Although they're not officially support UIWebView, lots of hybrid apps will be broken and using angular will be impossible in hybrid apps in latest iOS until Apple fix that bug. And that's pretty big issue I would say. So I would give it a try.

raftheunis87 commented 8 years ago

@viattik I totally agree. And btw: Apple communicated to us that it's unlikely that they will fix the UIWebView bug. So indeed: give it a try ;-)