Dev Blog: How Singlewire Migrated 195,000 Lines of AngularJS to React

Tags: 
software-development-angularjs-react-migration

Each month, we have a member from the Singlewire Software development team present a deep-dive, behind-the-scenes look at a recent project. This month, one of our software engineers, Dan, walks us through our recent move from AngularJS to React.

Migrating from AngularJS to REACT

At Singlewire, the main customer-facing application in our InformaCast mass notification cloud offering is a single page web application (SPA) for administering our system. It was originally written in AngularJS in 2012, but we began porting it to React in August 2018. As of March 2020, we have ported 80 percent of the codebase. All our new code is in Typescript. You can see our progress in the snapshot below.


| Language   | Before  | After   |
|------------+---------+---------|
| Typescript | 0       | 136,608 |
| Javascript | 160,475 | 18,235  |
| HTML       | 34,788  | 17,949  |
|------------+---------+---------|
| Total      | 195,263 | 172,792 |

But this wasn’t a stop-the-world rewrite. In addition to removing more than 20,000 lines of code, we added more than 3000 tests across 667 test suites, added strict type checking, and delivered 22 new features.

Why leave?

Rewriting code and changing frameworks isn't the sort of work that companies undertake for fun, and the same was true for us. There were a number of reasons we started looking around for alternatives to AngularJS in 2018.

AngularJS was created by Google in 2010, but by 2016, Google wanted to keep up with the many technological improvements other frameworks had made in the intervening years that were difficult to implement in AngularJS. They created a new frontend framework called (confusingly) Angular and renamed their old framework to AngularJS. In January 2018, Google announced that they would be phasing out AngularJS and would only support it until June 2021.

Our experience with AngularJS matched Google's. It was an old framework with a lot of interesting, but not optimal, design decisions that gave its users (us) a lot of complexity to manage. Combine that with the fact that this application was our first foray into SPAs, and you have a recipe for architectural regret. We had accumulated a lot of technical debt over six years, and the state of the art had advanced enough that we thought we could pay down a good chunk of it by switching frameworks.

There was one other factor, and that was our small contributor count. At the time, we have 22 developers at Singlewire, but only a handful of them had ever meaningfully contributed to our administration application. One of them authored more than half the commits on the whole project. In effect, this meant anytime someone new needed to make a change, they would either have to take the great deal of time necessary to learn about all the AngularJS components (controllers, templates, directives, services, dependency injection, digest cycles, two-way bindings, etc.), or they would have to sit down with one of the three or so developers who was more-or-less up to date on everything. This was stressing out those developers, who had other work to do and was slowing down our ability to deliver value. It was also difficult to recruit new people who could work with AngularJS. The number of candidates who listed familiarity with AngularJS on their résumé was very low and trending downward.

Evaluating alternatives

Taking all of these factors into account we started to evaluate possible alternatives:

  • Angular: We first considered Angular, the Google-blessed alternative. We were hoping this would allow us to keep large amounts of our code unchanged, but that didn't really work out as well as we had hoped. It did turn us onto Typescript, though, which is a wonderful language and a great tool for frontend development.
  • Angular + Redux: Then we looked into Angular+Redux, mainly because we wanted an easier testing story and Redux is a more functional approach. By the time we got through looking at this, it looked a lot like React with extra steps.
  • React: So, we decided to do a proof of concept with React. We ported our core services - permission checking, search pagers, local storage, modals, etc. By the end of it, we felt pretty confident that React was a better approach for us.

Strict is nice

In the years since 2012, we'd learned a lot about how to organize a large SPA, and we wanted to have a consistent project structure with good documentation, thorough tests, strict typechecking, a linter with teeth, and automatic code formatting. In order to do that we started with Create React App. We generated a new project, created a `legacy/` directory and pasted our whole legacy application in there.

src/
└── app
	├── legacy <-- all our AngularJS
	├── core
	├── init
	└── views

We set our `tsconfig.json` to ignore all Javascript, set `strict` to true and set as many other type strictness settings as far towards the "strict" side as we could get them.

We set up Prettier to lint all our files and set up our CI pipeline to fail if there were any linter errors. Our developers use several different editors (Emacs, VSCode, Webstorm), but everyone has been able to find a Prettier plugin that can reformat code on save or on command.

We also decided to make testing a priority. We use Jest to write tests and use the Create React app test runner to run them. This gives developers fast feedback on tests, which increases their utility and leads to more tests.

Most importantly, we sat down and talked about how the project should be organized, what conventions we were going to follow, and we documented the results. Documentation is great, but it quickly falls out of date, especially when porting a large and complex application. So we arranged for a group of contributors across multiple teams to meet every couple weeks to discuss lessons learned and any architectural changes that we were considering. Over time we’ve been able to refactor the codebase effectively without leaving (too many) inconsistent patterns scattered throughout, mainly because we take the time to communicate with everyone who would be affected before doing it.

Strangling works well

One thing we wanted to avoid was a stop-the-world rewrite, where everything gets put on pause while the entire application is rewritten. While our AngularJS code wasn't always pretty, it did work and was battle-tested. Just because we can rewrite something doesn't make it a good reason to rush out and do so. We had to figure out a way to allow a gradual approach.

We chose to use the classic "strangler" strategy. In our first couple weeks, we rewrote the core bits in React so that the new code would handle initializing the application, including starting up the AngularJS bits after the React application was bootstrapped. Then, for every route that wasn't ported to React, the AngularJS application would continue to render its pages.

We start with our top-level component, which renders the always-visible components, like the header, sidebar, and footer, and declare all of our routes:

export class App extends React.PureComponent<AppProps> {
  componentDidMount() {
	this.props.initialize(this.props.token);
  }

  render() {
	const { session, initialized, angularJSRoutes } = this.props;
	return (
  	<div>
    	<HeaderContainer />
    	<ModalsContainer />
    	<SideBarContainer />
    	{/* hash router so it plays nicely with our AngularJS routing */}
    	<HashRouter>
      	<Route render={props => (
        	<ErrorBoundary currentPath={props.location.pathname}>
          	<Prompt when={true} message={dirtyFormPrompt} />
          	<Switch>
            	{/* ...all our React routes go in here */}
            	<AuthenticatedRoute path={Routes.Home} component={AsyncHome} />
            	{/* we render an empty div for all the AngularJS routes */}
            	{angularJSRoutes &&
               	angularJSRoutes.map(url => (
                 	<AuthenticatedRoute
                   	key={url}
                   	path={url}
                   	exact={true}
                   	component={EmptyComponent}
                 	/>
               	))
             	}
          	</Switch>
        	</ErrorBoundary>
       	)}/>
    	</HashRouter>

    	{/*This is where AngularJS will be mounted, bootstrapped after login.*/}
    	<div
      	id="legacy-angularjs-stub"
      	style={{ display: isLoggedIn ? undefined : 'none' }}
    	/>

    	<Footer />
  	</div>
	)
  }
}

Notice what's going on in the `<HashRouter>` section - we're rendering our React routes (with React Router), taking care to render an empty div (`EmptyComponent`) for each of the AngularJS routes.

The AngularJS router is a bit dumber since it doesn't know about React at all. All it does is render an empty div as its default route.

(() => {
  angular.module('Base').config( /*@ngInject*/['$routeProvider', $routeProvider => {
	$routeProvider.when('/:catch*', {
  	template: '<div style="display: none;">Empty AngularJS Component</div>',
  	controller: 'DefaultCtrl',
  	controllerAs: 'DefaultCtrl',
  	reloadOnSearch: false
	});
  }]).controller('DefaultCtrl', /*@ngInject*/function () {});
})();

When our application is launched after React has rendered itself and created the mount point for the AngularJS half, we bootstrap AngularJS:

const bootstrapAngular = () => {
  log.info('Bootstrapping AngularJS');
  const angularElement = document.getElementById('legacy-angularjs-stub');
  window.angular.bootstrap(angularElement, ['Base'], { strictDi: true });
}

Then we can start it up and pull the routes out:

addLegacyAngularRouter: (): string[] => {
  log.info('Dynamically adding our Legacy AngularJS Router...');
  const angularElement = document.getElementById('legacy-angularjs-stub');
  const $scope = window.angular.element(angularElement!).scope();
  const $compile = window.angular
	.element(angularElement!)
	.injector()
	.get('$compile');
  window.angular
	.element('#legacy-angularjs-stub')
	.append($compile('<div ng-controller="BaseCtrl"><div ng-view/></div>')($scope));
  $scope.$apply();
  return Object.keys(
	window.angular
  	.element(angularElement!)
  	.injector()
  	.get('$route').routes
  ).slice(0, -2); // We want to remove the two catch-all AngularJS routes.
}

Which we can stick into the Redux store and use it in our React router.

This integration was the hardest part to figure out, especially dirty form checking so we could warn users before they navigate away from a form with unsaved changes. It took a lot of iterations and testing before we finally got it working well. But in the end, we had all the ingredients we need to strangle AngularJS route by route.

Steady as she goes

That’s the basic outline of our project. We started with the simplest route, to prove that it was feasible - very basic CRUD stuff. Then we ported our most complex route, to prove that it could handle the tricky stuff.

At that point we did a cutoff - no more AngularJS development. If you're developing a feature that needs frontend work, your scope includes porting the relevant route(s) to React - "you touch it you port it," in effect. Work proceeded route by route, with a handful every few releases.

Along the way, we found a lot of minor bugs and inconsistencies that had existed for a long time. We also found space to do some UI improvements and revisit some designs to relieve some user annoyances.

Lessons learned

We have been very pleased with our results following the switch. Here are some lessons we learned during the process:

Communication and coordination matters. In our case, there was a period while we were evaluating alternatives where the end goal wasn't clear. This led to confusion and extra work on the teams that were working on features with frontend components. In addition, once we started the process of porting and implemented the "you touch it, you port it" policy, one team with a project that touched a lot of different pages bore the brunt of the porting effort. It would have been worth it to spread that work around more.

Since increasing the number of contributors was a goal, investing in education was critical. We recommended that new frontend developers complete The Complete React Developer Course on Udemy, and we made sure to give people time to do so. We also had a few "introduction to the new codebase" presentations for all of the developers, so that the architectural choices and codebase organization were broadly understood by contributors. In addition, we made sure that people who were familiar with the codebase were available to answer questions, pair program, and review code for new contributors.

Keeping the new codebase "clean" was also a priority, but codebases change over time. We started a React forum that met every two weeks, where different projects that were using the same technology could share architectural choices, library evaluations, and solutions to common problems. These could be as short as 15 minutes if nothing big needed to be discussed but have proven to be a great way to keep knowledge out of silos.

Conclusion

Today, we are very happy with our decision to move over. By the numbers, the migration has been a success - lines of code, number of tests, and number of contributors. The development experience is drastically better. Our tooling is nicer, our codebase is nicer, there's a larger community of developers internally who can help. All of that adds up to the ability to make changes confidently and quickly, which helps us deliver more value to our customers.

Come join our amazing team of software developers!

 

 

InformaCast Online Demo