Search code examples
javascriptangularangularjsng-upgrade

Angular ng-upgrade stuck in infinite loop with singleton provider


We're performing a AngularJS-Angular migration for a big client with a huge, messy codebase (794k+ JS LoC, 396k+ JSP LoC) and have hit an issue we're struggling to solve. It's a bit of a contrived issue so I'll try to explain the context a bit.

Until recently they had a couple dozen different manually copied versions of AngularJS, all 1.5.6 or lower. We managed to get them onto a single, NPM-managed copy of AngularJS 1.8.2. However, some of the breaking changes were so big that it was felt we couldn't afford to fix them everywhere- eg the $http breaking changes affected thousands and thousands of places across this codebase.

So, what we did instead was to backport the HTTP service (and a couple others) from 1.5.6 into 1.8.2. We use the .provider(...) function to pass a copy of the old provider into the new version of AngularJS. We only do this where there are no known security fixes in the services we're backporting. This solved the issue but much later down the line we encountered another problem: some providers like the HTTP provider have state. This state can be lost when the provider is used multiple times- AngularJS uses new to instantiate the provider from our constructor, at which point any previous state of the HTTP provider is wiped out. In the client's app there is a lot of complex misdirection so it is very possible for the same provider to get backported twice in the same session. Hence, we have an issue: the provider gets constructed twice and on the second construction wipes out the state of the HTTP provider that may have been changed before the second construction.

So, to avoid this happening (I feel a bit like I'm at a confession box here...) we added a layer of abstraction to turn it into a singleton:

let innerHttpProviderInstance = null;
function $HttpProvider_V_1_5_6() {
  if(innerHttpProviderInstance == null) {
    innerHttpProviderInstance = new $HttpProvider_V_1_5_6_inner();
  }
  return innerHttpProviderInstance;
}

//The original HttpProvider from 1.5.6
function $HttpProvider_V_1_5_6_inner() { ... }

Which is then used like so (in numerous places):

const app = angular.module('app', mainAppDependencies).config(['$provide', '$controllerProvider','$locationProvider', function($provide, $controllerProvider,$locationProvider) {
    ...
}])
  .provider('$http', $HttpProvider_V_1_5_6)

Now, we're finally close to having the upgrade to AngularJS 1.8.2 completed and are looking at migrating to Angular using ng-upgrade. We've got quite a neat hybrid architecture setup: the Angular application upgrades the root AngularJS node, which in turn downgrades Angular leaf nodes. We hope to upgrade a few neaf nodes to start with, then one parent of those nodes at a time until we have entire branches on Angular. This is largely based on Victor Savkin's "Upgrading Angular Applications". That seemed to work well, until the above singleton change was introduced. Now, whenever the application loads it will get stuck in an infinite loop reloading the page and adding !#%2F to the start of the URL. This seems similar to the following GitHub issue, though it was apparently fixed: https://github.com/angular/angular/issues/5271

When we remove the singleton from our provider backport, it works fine. When we reintroduce it in any fashion (we've tried numerous approaches), it breaks again. I think it has something to do with binding, and ng-upgrade trying to reload the page because it thinks the state has changed but I'm really not clear. So, this is the nest on gundarks we find ourselves in. Any suggestions for our next steps?

Edit: We happened to stumble across setuplocationsync and it seems this may be relevant to what's going on here. If I understand correctly it is supposed to solve a known bug in which Angular/AngularJS trigger one another's routing, causing them to loop. When we call this function in our setup it almost solves the problem- the page will eventually load (whereas before it reloaded indefinitely) but it still goes through a couple dozen iterations of the reload, and instead of just adding !#%2F to the URL it now repeats the full URL like so (where we're trying to reach the page /full-url/): /full-url/full-url/full-url/full-url/.../full-url?params=vals


Solution

  • After a lot of debugging I eventually solved this. If you find yourself with a routing loop or hashprefix problems despite having set $locationProvider.hashPrefix(''), check that you don't have two AngularJS applications on the same page.

    I thought that there was just the AngularJS application, and the Angular application and they were somehow fighting over the address resource creating this recursive loop. Turned out, the Angular (2+) application had nothing to do with it. There was an additional AngularJS application on the page and the two AngularJS apps were fighting over the address resource. The code only bootstraps one application, but there is a well-hidden div somewhere with an ng-app attribute which causes AngularJS to automatically bootstrap it in the background.

    The second AngularJS app (initialised automatically via ng-app) didn't have $locationProvider.hashPrefix(''), while the first app did. So they disagreed on what the address should be and kept changing it. The solution was just to add $locationProvider.hashPrefix('') to the second application, which can be done via the controller like so:

    The template:

    <div ng-app="myApp" ng-controller="myController">
    ...
    

    The controller:

    var app = angular.module('myApp', [])
      .config(['$locationProvider', function($locationProvider) {
        $locationProvider.hashPrefix('');
      }]);
    app.controller('myController',function () {...})