This is part three in a series. You should also read part 1 and part 2.
To recap, we have a solution which appears to work but we have two competing runtimes trying to control our location and this is causing two serious problems. Firstly, when angular triggers the navigation we end up with two entries in the history queue which messes up the back button. Secondly, when Elm triggers the navigation, angular’s $location service loses track of what page we are on so if your angular code calls something like $location.path() it may get the wrong answer. I want a solution that does not break my angular code so this is not ok.
And this is where it gets a bit (ok a lot) hacky :( Let’s take these issues one by one. When angular triggers a navigation either by a call to $location service methods or by clicking an anchor tag within an angular component, it will update the browser url and call history.pushState (among other things). Our problem is that we then listen for the $locationChange event and instruct Elm to change route which will trigger another call to history.pushState.
So one of these calls has to be prevented. My initial instinct was to prevent angular from inserting pushState. But if we do that angular gets into an infinite digest loop as it continuously tries to resolve the difference between what $browser is telling it and what $location is telling it. So it is better to allow angular to do its thing, after all angular is running the show in this navigation. In which case we have to prevent Elm from subsequently adding the second entry.
We can do this by subscribing to $locationChangeSuccess rather than $locationChangeStart. When the success event fires, we know that angular has already added its history entry and we know that the next entry will be triggered by Elm. So at this point we set a flag to indicate that the next call to pushState should be ignored.
$rootScope.$on('$locationChangeSuccess', function(e, newUrl, oldUrl){if(!listenForRouteChanges) {return;}app.ports.newUrl.send(newUrl);//at this point angular will have added a history state//already so we should ignore the next oneignoreNextPushState = true;});
In order to actually drop the next call to pushState we have to intercept / decorate the history api as follows:
var ps = window.history.pushState;window.history.pushState = function() {if(ignoreNextPushState) {ignoreNextPushState = false;return;}ps.apply(window.history, arguments);}
I warned you this was hacky! But it does take care of our first new problem. But we still face the second problem that angular loses track of the location when Elm initiates the navigation. To solve that will require more hacks I’m afraid.
When our mutation observer tells us we need to compile the root node we know that we are in an Elm-initiated route change and therefore that angular’s $location service is going to be put out of sync. To avoid this I have found that we can use a private angular api to synchronise it with the real url (yuck) like this:
$location.$$parseLinkUrl(window.location.href);
Obviously I don’t feel great about this and it has all got a bit more complicated than it originally seemed it would be. But it works and the important thing is that I haven’t had to do anything weird in the Elm code or in the existing angular code. The hackery is self contained in one place and when the time comes it can simply be deleted.
We are allowing Elm to remove elements from the DOM that represent angular components. This means that angular is not doing any of its normal cleanup and this means memory leaks! In part four I will see if we can deal with that.
As before, full source for this proof of concept can be found here.