Skip to content

Two years ago, before LeanIX acquired Cleanshelf (now LeanIX SMP), we were faced with the challenge of migrating our AngularJS web app to a more modern framework. After some research, our team chose Vue as our future framework. Vue is now the framework that powers the SaaS Management Platform (SMP), while EAM and VSM are built using Angular.

But then we were faced with the next challenge. How can we migrate our web app to Vue without freezing development on the AngularJS app during the time of the rewrite? We couldn’t afford to put everything on hold until we are finished. And even if we could, any urgent changes on the old codebase would also need to be applied to the new codebase which means double effort. And if things went bad during this phase and we would get a lot of urgent changes, then our rewrite effort could be stalled and the release postponed.

We needed a better solution than a “development freeze”. A gradual migration with both frameworks in production was the only way. This way we could migrate our whole app one page at a time.

We evaluated three possible approaches to a gradual migration

1. Serving the AngularJS app on one path and the Vue app on another path.

During the migration, some pages would be served in one app and others in the other app. This has some serious drawbacks.

Pros:

  • Simple initial setup.

  • No need to integrate one app into the other or to combine the build process of one with the other (we used gulp on AngularJS and Vue CLI with Webpack on Vue).

Cons:

  • The user can't avoid a full page load when transitioning from one app to the other.

  • Links would have to be maintained to point to the correct app.

  • Difficult to maintain the same state between the two apps.

2. Vue app embedded in AngularJS pages

Pros:

  • This enables you to rewrite just a part of the page, like a table or chart.

  • Seamless transition between apps.

Cons:

  • After rewriting all pages to Vue we would still need to get rid of AngularJS and everything that it encapsulates: user session management, routing, build process … This way we would be prone to any big potential issues right at the end. We decided that we would prefer to take care of these things at the start of our effort while we still have support from the management.

3. AngularJS app embedded in an iframe in Vue

Pros:

  • Setting up the new app can be done at the start of the effort and getting rid of AngularJS at the end would not be very difficult or error-prone.

  • Seamless transition between apps.

Cons:

  • Difficult to figure out how to do it properly.

We chose the third option because we wanted to tackle the biggest challenges at the start of our effort. It also allowed us to make the transition between the apps so seamless that even we couldn’t tell which version we were viewing.

To start we implemented a proof of concept to validate the solution. The idea was to migrate page by page from AngularJS to Vue where the rewritten pages would be served by Vue Router and the rest would be displayed by our AngularJS app which would get loaded in an omnipresent <iframe> that takes up the whole width and height of the viewport. A very questionable solution.

So how do the two apps communicate? What about the browser history? Are there any scrolling issues? Is session management going to work? Does the user notice any lag or flickering when switching between the two apps?

Amazingly, the proof of concept worked just fine. We couldn’t believe we are going to do this.

iFrame solution

We planned to allow the Vue app to try to handle every new window location. If the location can be matched to a route in the Vue router config, the route would get displayed, otherwise, an iframe would display the AngularJS app. This iframe would stay rendered throughout the whole user session and when not needed it was hidden with v-show instead of v-if to keep the old app alive.

The parent Vue app can signal to the child iframe that it needs to handle a new location because it can’t be matched by Vue Router.

iframeElement.contentWindow.postMessage({ newLocation: '/#/reports' });

The AngularJS app served in the iframe then reacts to this message and updates the location.

window.addEventListener('message', function(event) {
if (event.data.newLocation) {
// explanation for using replace instead of pushing to history stack will be explained later
location.replace(event.data.newLocation);
}
});

So this covers all browser navigation that can happen inside the parent window:

  • The user clicks on a link in the Vue app.

    • If this link leads to another Vue page it simply gets handled by Vue Router.

    • If it leads to a page that is still in the AngularJS app then the parent Vue app will send a message to the child (code example above). The child AngularJS app should then match the route or display a 404 page.

  • The user visits a page directly with a complete page load.

But what if the user clicks a link on an AngularJS page that leads to a page that was already migrated to Vue? To handle that, the iframe should notify the parent about all navigation events.

$rootScope.$on('$stateChangeStart', function () {
window.parent.postMessage({
newLocation: $location.url()
});
});

The parent frame then reacts by navigating to the provided location. From here on, Vue Router will handle routing and if it doesn’t match the location to a known route, AngularJS can match it or display 404.

window.addEventListener('message', (event: MessageEvent) => {
this.$router.push(event.data.newLocation);
});
Schema of the two-way communication between AngularJS and Vue
Schema of the two-way communication

This means that all navigation (even when navigating from an AngularJS page to another AngularJS page) would trigger the Vue Router matching cycle.

Our AngularJS router is configured as a hash router which means that all routes are prefixed with /#/. This is useful for figuring out if a path should be served by Vue or AngularJS. But it is also problematic because we need to update links that lead to a page after we migrate it. For example links to /#/reports should be changed to /reports after the reports page would get migrated to Vue. If a user bookmarked that URL, they should get redirected to the new page.

To solve these problems we need to link the new page route config to the old page route config with a meta property oldPath.

{
name: 'reports',
path: '/reports',
meta: {
oldRoute: '/#/reports'
},
// ...
}

This way we can redirect the user to the new version of the reports page if we find a route in the Vue Router config that has a matching oldRoute meta property.

const routes = [{
name: 'reports',
path: '/reports',
meta: {
oldRoute: '/#/reports'
},
},
...];

const router = new VueRouter({
routes,
});

router.beforeEach((toRoute, fromRoute, next) => {
// toRoute === { fullPath: '/#/reports' }
routes.forEach((route) => {
if (route.meta.oldRoute && route.meta.oldRoute === toRoute.fullPath) {
next(route);
}
});
});

This means that when you create a new page in Vue, you just have to add the matching oldRoute and it will redirect all users to the newly migrated version of the page instead.

Conclusion

This was the basic description of the mechanism that allowed us to serve both web apps side by side. Below are some related topics that I left out for now for the sake of simplicity. They may be useful to you depending on the complexity of the web app that you plan to migrate.

Page migration controlled by feature flags

We figured out that it is very simple to also check if the user has a feature flag turned on that allows them to visit the migrated version of the page. This way we could easily release a migrated page only to a beta user.

Comparing new and old routes with dynamic parameters

We must also take into account that route paths are not always as simple as /#/reports, e.g. they can contain parameters like /#/services/:serviceID/invoices/:invoiceID. To detect that the new browser location matches an old route assigned to a Vue page, we need to copy the param values from the target route to the oldRoute to make the string comparison.

Browser history

Two frames inside one window also mean two browser history stacks. If the user navigates pages inside the iframe, the location change isn’t reflected in the browser’s address bar because that only shows the location of the top frame. But since every navigation in the AngularJS iframe would trigger a message up to the Vue app, we changed the browser’s location (and address bar) every time. Jump to code snippet above that demonstrates this.

Style discrepancies

It’s easy to match the style of the old and new versions of a page since both apps use Bootstrap and it’s customized the same way in both apps.

Room for improvement

There are some things that we could improve in the process. For example, we should try to measure the effort to develop a feature in the old and new framework or measure the number of bugs or uncaught errors. This would help us justify the rewrite sooner to the management and not only for the sole reason that end of life for AngularJS was approaching.
Another possible improvement would be to set up acceptance tests before the migration. But these topics are big enough for a future blog post.

Image of the author

Published by Nejc Štebe

Visit author page