Bullet proof AngularJS migration using iframes

TL;DR: I’m proposing an alernative solution for migrating from AngularJS which uses iframes, there is a demo you can try here http://alt-angularjs-migration-using-iframes-demo.azurewebsites.net/ and the source code is here https://github.com/jawache/alt-angularjs-migration-using-iframes-demo

The official AngularJS migration path is an amazing solution, I’ve never seen any framework ever put this much effort into helping its users move from one version to the next. I’ve given lots and lots of talks about it.

But the same thing happens at the end of every conference talk, I get mobbed by developers asking me why it doesn’t work for them? Developers who’ve tried and failed to migrate using the official strategy. Sometimes polite, sometimes aggressive but always frustrated. I can understand their pain, I really can I’ve felt it myself.

In the official migration strategy we create one app, a hybrid application, that runs both the Angular and AngularJS code side by side. The idea is that slowly, over time, we migrate components from AngularJS to Angular.

This approach is a technical marvel, but when migrating some AngularJS applications it either causes or doesn’t deal with a few important issues:

  • There is always other baggage to deal with.

  • You have to clean house before you migrate.

There is always other baggage

The official migration strategy doesn’t take into account how to deal with the dozens of other 3rd party libraries we are using with our AngularJS apps. What happens to those during the migration?

Well nothing happens, they get merged into the hybrid application. So your beautiful new Angular app is forced to use the same legacy modules you are trying to run away from in the first place.

By default we fall to the lowest common denominator, if your AngularJS application is using Bootstrap 1, your hybrid Angular application is forced to use Bootstrap 1 as well.

This gives you two impossible choices:

  1. Update to Angular leaving the baggage as it is

That means using Angular with your old UI framework, old modules etc…​

This feels like migrating from a legacy AngularJS application to a legacy Angular application.

  1. Update your AngularJS code to get rid of the baggage first

Perform an initial refactor of your AngularJS app to use newer modules, update the UI library etc…​ this also means updating all your HTML templates.

That’s a lot of throw away effort.

You have to clean house before you migrate

My girlfriend always cleans the house before the cleaner comes over, what’s the point right?

When AngularJS first came out it was the wild west. There was very little information about how best to build and architect an AngularJS application.

There were these crazy people you heard about who used directives to the max, like the entire application was composed of a bunch of directives, crazy right? No not crazy they were right, that became components and component based architectures and now we write everything as components.

But basically my point is this, we didn’t know how best to write an AngularJS application, we do now.

  • We used to use controllers over components.

  • We relied on scope inheritance instead of controller-as syntax.

  • We used $scope.$watch way too much

  • We used emit and broadcast far too much as well.

To even attempt to migrate to Angular your app has to be brought up to modern standards.

Imagine if you were to build an AngularJS app today, with everything we now know about how best to build an AngularJS application. That’s where you need to get to before you start your migration journey.

However most of us are staring at complex architectures, with years of investment, where it’s not so simple to refactor. Investing all that time in your legacy application just to get to the starting point of migration is at best depressing and at worst impossible.

That effort, that mammoth initial refactor, is sometimes so high that its indistinguishable from just re-writing from scratch. So what’s the point?

The iframe solution


If all the other methods have failed you, if you are about to throw in the towel and argue for a re-write, then try this approach first.

I didn’t come up with this solution, In fact it was my replacement at my last engagement. After I patiently explained the work I’d done over the last 6 months to bring a clients app to the point where we were using Angular he suggested another, much simpler, method using iframes.

My first reaction was to strongly object, iframes felt dumb, a handover from the birth of the web, but after giving it some thought I eventually felt dumb for not thinking of it before.

If your application:

  • Is already architected using current AngularJS best practices.

  • Is already a Single Page Application.

  • Is already using modern 3rd party modules.

Then the official migration path is the right solution for you.

However if your application:

  • Has a poor architecture.

  • Is not an SPA, e.g. is an MPA, a multi page server-side rendered application.

  • Relies on a lot of legacy 3rd party modules

Then iframes might be the right solution for you!

Important

Angular was written from the ground up to only function as an SPA. Although it’s possible to write an Angular application as an MPA, it involves some trickery and I don’t recommend it. So It’s only worth embarking on your Angular application journey if you are prepared to become an SPA.

The solution is so simple it barely warrants a paragraph to explain.

We create a modern Angular SPA application, this is the host application and we iframe in the legacy AngularJS application.

We then:

  • Migrate our legacy AngularJS application one route at a time over to our new Angular host app.

  • If we have migrated a route to the Angular host app then we want the host app to handle the route and render the component.

  • Otherwise we want to fall back to our AngularJS application and iframe the content in.

Emm… That’s it!

Well that’s not totally it, there are two things we need to deal with in order to get it to work.

Route Ownership
  • Some routes are going to be owned by the Angular app and some by the AngularJS app.

  • As you migrate your application more and more routes are going to change ownership from AngularJS to Angular.

  • Nothing special needs to be done if a route is triggered and handled by the same app.

  • We need to handle when the user triggers a cross-app route. A route triggered in one app but handled in another app.

Shared State
  • We also need to be able to share state between the two apps, for instance login state.

Route Ownership

Who owns the route, Angular or AngularJS and how do we communicate between those two?

If the Angular app triggers a navigation to a URL then we need to figure out if the Angular app handles the route or delegates to the AngularJS app. The same goes vice-versa, if the AngularJS app triggers a navigation we need to figure out if the AngularJS app handles the route or delegates navigation to the Angular app.

Angular triggers navigation and Angular owns the route.

angular angular

Nothing needs to be done here, everything is handled inside the same application.

AngularJS triggers navigation and AngularJS owns the route.

angularjs angularjs

Again, nothing needs to be done here, everything is handled inside the same application.

Angular triggers navigation and AngularJS owns the route.

angular angularjs

A route is requested from the Angular app but needs to be handled by the AngularJS app.

In order for this to work we need the Angular app to delegate the URL to AngularJS by `iframe`ing in the URL from AngularJS into it’s main content area.

To solve this we create an IFrameComponent.

Note

In the example below the AngularJS and Angular applications are hosted on the same domain, the legacy application is hosted from /legacy/ and the main Angular application is hosted from /.

The responsibilities of the IFrameComponent are:

  • To figure out which URL is being requested.

  • Transform that into the URL the legacy AngularJS application expects.

  • Add an iframe element to the dom pointing to that URL.

The source code looks like so:

Listing 1. IFrameComponent
@Component({
  selector: 'app-iframe',
  templateUrl: './iframe.component.html',
  styleUrls: ['./iframe.component.css']
})
export class IFrameComponent implements OnInit {
  public url: SafeResourceUrl;
  private counter = 0;

  constructor(private route: ActivatedRoute,
              private sanitizer: DomSanitizer) {
    this.route.url.subscribe(urlSegments => { (1)
      this.counter += 1;
      const requestedUrl = '/legacy/?counter=' + this.counter + '#!/' + urlSegments.join(''); (2)
      this.url = this.sanitizer.bypassSecurityTrustResourceUrl(requestedUrl); (3)
    });
  }
}
1 When this component is created we grab the current route being requested from the ActivatedRoute service.
2 We transform the requested URL into the format required by the AngularJS app, in this case we prepend the url with \legacy.
3 We need to bypass Angulars default security here, normally it doesn’t let you just add a URL without sanitising it first which in this case strips out too much useful information, so we use the bypassSecurityTrustResourceUrl function of the DomSanitizer service.

Note

We are using hash based navigation for both the Angular and AngularJS applications here.

iframes don’t refresh when just the hash fragment changes so to force it to update we add an incrementing counter to the URL.

The template for this component looks like so:

<iframe [attr.src]="url"></iframe>

We want the iframe to take up the full height and width of the page so we also have some component css like so:

iframe {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  border:0;
}

Finally we add this component as the fallback option in the Angular route configuration, like so:

const routes: Routes = [
  {path: '', redirectTo: 'angular', pathMatch: 'full'},
  {path: 'angular', component: CounterComponent},
  {component: IFrameComponent, path: "**"} (1)
];
1 The ** denotes any URL, so as long as it’s the final item in the routes array, if nothing else matches then it will render the IFrameComponent.

When Angular can’t handle the route it renders the IFrameComponent instead, which renders an iframe and sets the src property to whatever the fallback URL was.

For example:

  1. If we clicked on /angular/ in the Angular app.

  2. Then the Angular router would handle the route and render the CounterComponent.

  3. If however we tried to navigate to /foobar/ in the Angular app.

  4. This will trigger the last fallback route and render the IFrameComponent instead.

AngularJS triggers navigation and Angular owns the route.

angularjs angular

If AngularJS initiated the navigation we do something similar. If the AngularJS router does not handle the route then we want to pass the route to the host Angular app.

In my AngularJS application i’m using uiRouter which has the concept of a fallback route, but unlike the Angular side we can’t just iframe in the Angular app. Instead we can take advantage of the PostMessage API to send messages between parent and child iframes.

The fallback function in the AngularJS app’s uiRouter route definition looks like so:

$urlRouterProvider.otherwise(function ($injector, $location) { (1)
  var path = $location.path(); (2)
  parent.postMessage({navigateTo: path}, "*"); (2)
});
1 The otherwise function in uiRouter allows us to provide some fallback code which will be called if no other routes are matched.
2 We grab the path that is being requested.

Note

The * at the end is a security hole, normally we should provide the list of allowed domains but for simplicity i’ve left it as *.
1 parent is a global variable that is available if this page was created as an iframe, we use the postMessage function to pass a message to the parent iframe.

We also need some code on the Angular side which listens to these postMessage events and triggers an Angular app navigation instead. I’ve placed this code in the IFrameComponent, like so:

export class IFrameComponent implements OnInit {
  ...


  constructor(...) {
    ...
    this.listenForFallbackRoutingEvents(); (1)
  }


  listenForFallbackRoutingEvents() {
    // Create IE + others compatible event handler (2)
    const eventMethod = window.addEventListener ? "addEventListener" : "attachEvent";
    const eventer = window[eventMethod];
    const messageEvent = eventMethod == "attachEvent" ? "onmessage" : "message";


    eventer(messageEvent, (e) => { (3)
      if (e.data.navigateTo) {
        let url = e.data.navigateTo;
        this.router.navigateByUrl(url); (4)
      }
    }, false);
  }
}
1 We start listening for events from the child iframe.
2 This is a cross-browser safe way to generate an event listener.
3 We start listening to message events from the child iframe.
4 If we get something that looks like a navigation request we call this.router.navigateByUrl passing it the URL that was requested.

So when AngularJS can’t handle the route it passes a message to the parent iframe, which is the host Angular app. The Angular app listens for these messages and when it receives one it issues a navigation using it’s own router.

Warning

If we request a route which doesn’t exist in both apps we will go into an infinite loop. A simple solution might be a check for last requested routed and if it’s the same as the current then don’t route there and throw an error instead, open to ideas!

For example:

  1. If we clicked on /foobar/ in the AngularJS app

  2. This will trigger the fallback route function which call the postMessage API to send an event to the parent Angular app.

  3. The Angular app is listening for these messages and when it receives one it issues a navigation locally to /foobar/.

Sharing State

Another issue of concern is how to share state? We’ll have state in both our apps which we need to sync, at the most basic level we’ll have authentication information, we don’t want the user to login twice, but we might want to share other things like returned data from API requests etc…​

If you can run both new and legacy applications on the same domain then this simplifies a lot of the issues, e.g. in our example we are running the main application under http://alt-angularjs-migration-using-iframes-demo.azurewebsites.net and then the legacy AngularJS application under http://alt-angularjs-migration-using-iframes-demo.azurewebsites.net/legacy/.

If you can keep both apps on the same domain then:

  • Both apps automatically share the same cookies, so if you store login information in a cookie then both apps are authenticated.

  • If you use localStorage to store data then this again is automatically shared between both apps.

Note

There are size limitations to localStorage which change from browser to browser, so use it carefully. If we are talking about a lot of data then might just be easier to request the data from the API twice once in the Angular app and once in the AngularJS app.

The next step to figure out is how to notify an app if the data has changed, so if we save some state in our Angular app we want our AngularJS app to be notified as well. This is another reason for using localStorage, it has StorageEvents.

We can take advantage of it pretty easily like so:

localStorage.setItem("counter", "1") (1)
1 This sets a key of “counter” with value of “1” in localStorage.
window.addEventListener('storage', (e) => { (1)
  if (e.key == "counter") { (2)
    this.counter = parseInt(e.newValue); (3)
  }
});
1 We add an event listener for storage events on the window object.
2 We check to see if the storage event matches the key we are interested in.
3 We convert the data back to the datatype we expect, in this case a number.

We can use the above method however we want, it’s pretty flexible. Want to store a whole object into localStorage and keep it in sync between both the Angular and AngularJS apps? Sure that’s possible. Or we can use it just to store key bits of information like ID’s and the events trigger the other app to reload the data from the API.

In the demo app I’m using local storage to store a counter which the user increments by clicking on a number, the number is kept in sync between both the Angular and AngularJS applications by using StorageEvents.

So how does this all help?

By migrating through the official strategy we merge both apps into one, run Angular and AngularJS side by side so everything is shared. They share the same libraries, the same CSS, the same routes, the good, the bad and the ugly.

By migrating through an iframe however we keep both apps separate, they have separate environments, separate libraries, separate CSS, nothing is shared.

With the iframe approach you don’t need to clean up your AngularJS code before you start. You can leave your baggage in the past, your legacy AngularJS code can remain as your legacy AngularJS code and you can start afresh with Angular.

This goes for all 3rd party libraries as well, your AngularJS and Angular app can use different versions of the same libraries. For example AngularJS can use Bootstrap 2 and Angular can use Bootstrap 4, at the same time!

Bu best all the process is iterative, at every step in the migration process you have a production ready, releasable application.

As an added bonus this solution can be used for migrating anything to Angular. Just as we fell back to iframe’ing in AngularJS we can fall back to iframe’ing in anything, we can for instance migrate a legacy Java server side rendered multi page application over to an Angular SPA using the same method. We simply iframe in the legacy Java application in instead of AngularJS!

Thoughts ask me on twitter or in the discussion below, also finally wanted to thank Sander and Marcin for their contributions and inspiration for this idea.