How I optimized Minesweeper using Angular 2 and Immutable.js to make it insanely fast

How I optimized Minesweeper using Angular 2 and Immutable.js to make it insanely fast
Photo by Bill Jelen / Unsplash

In my previous article I showed you how to build Minesweeper using Angular 2 and Immutable.js. You can try out the game right here.

One thing that bugged me is that it turned out to be slow on mobile. After all we are using Angular 2, which is supposed to be really fast. And we use Immutable.js, which should help in making dirty checking blazing fast. So why is it slow on mobile?

In this article I will show you what I did to figure out what was wrong and how I optimized it. Turns out we can make it faster, a whole lot faster, as in: "insanely fast"!

Quick hint: it was not Angular 2's fault and it was not Immutable.js's fault!

So let's get started!

The problem

After adding some log statements I quickly discovered what was causing the performance issues. Whenever a tile was clicked, the entire board was re-rendered:

So with every click, all 256 tiles were re-rendered. Imagine a board with 10.000 tiles!

Surely this shouldn't be happening. After all I was using Immutable.js. Maybe I was missing something? What did I do wrong?

Effort 1: Angular 2 and immutability

Luckily, Victor Savkin of the Angular team at Google had just posted a really informative article on Angular 2 and immutability.

The article describes that if you use immutable data, you have to let your angular Angular component know about it, so it knows how to optimize the rendering process.

So I added this line to the tile component:

@Component({
  selector: 'tile',
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush // ⇐⇐⇐
});

I refreshed the browser and boom! No difference in number of tiles rendered :-(

If this didn't make a difference, it had to be higher up. So I looked at the row component and added the same line:

@Component({
  selector: 'row',
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush // ⇐⇐⇐
});

Refreshed again and still no difference in number of tiles rendered. So I looked higher up again at the minesweeper component:

import {Component, Input, CORE_DIRECTIVES} from 'angular2/angular2';
import {partition} from './util';
import {revealTile, isGameOver} from './game';
import {RowComponent} from './row.component';

@Component({
  selector: 'minesweeper',
  template: `
  <div class="board">
    <row *ng-for="#row of rows" [row]="row" (tile-click)="handleTileClick($event)"></row>
  </div>
  `,
  directives: [CORE_DIRECTIVES, RowComponent]
})
export class MinesweeperComponent {
  @Input() game: any;
  rows;
  history = Immutable.List();
  
  onChanges(changes){
    
    // Only update game when game has actually changed
    if(changes.hasOwnProperty('game')){
      this.updateGame()
    }
  }
  
  updateGame(updateHistory = true){
    this.rows = partition(this.game.get('cols'), this.game.get('tiles'));
    if(updateHistory){
      this.history = this.history.push(this.game);
    }
  }
  
  handleTileClick(tile){
    if(!tile){
      return;
    }
    if (isGameOver(this.game)) {
      return;
    }
    const newGame = revealTile(this.game, tile.get('id'));
    if (newGame !== this.game) {
      this.game = newGame;
      this.updateGame();
    }
    if (isGameOver(this.game)) {
      window.alert('GAME OVER!');
    }
  }
  
  undo(){
    if (this.canUndo()) {
      this.history = this.history.pop();
      this.game = this.history.last();
      
      // Don't update the history so we don't end up with
      // the same game twice in the end of the list
      this.updateGame(false);
    }
  }
  
  canUndo(){
    return this.history.size > 1;
  }
}

I added the same line again:

@Component({
  selector: 'minesweeper',
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush // ⇐⇐⇐
});

Refreshed and again no difference in number of tiles rendered. What's even more is that this time the Undo button stopped working.

Why did the Undo button stop working? How could this be affected by adding ChangeDetectionStrategy.OnPush?

It turns out ChangeDetectionStrategy.OnPush tells Angular that it should not re-render the component unless the input property has changed. This allows Angular to optimize the rendering process for the component.

In case of the minesweeper component, we have an input property for game:

@Input() game: any;

This property allows us to pass the game to the component as a property:

<minesweeper [game]="game"></minesweeper>

So whenever the input property game changes, the view will be re-rendered. This works fine, even with ChangeDetectionStrategy.OnPush.

But we also change the game programmatically in the undo() method:

undo(){
  if (this.canUndo()) {
    this.history = this.history.pop();
    this.game = this.history.last(); // ⇐⇐⇐
    
    // Don't update the history so we don't end up with
    // the same game twice in the end of the list
    this.updateGame(false);
  }
}

Adding ChangeDetectionStrategy.OnPush told Angular only to re-render when the input property changes, so it does no longer re-render when we change the property programmatically from within the controller.

Even though adding ChangeDetectionStrategy.OnPush didn't do any good for the minesweeper component, it definitely learned us something about how it works and what it does.

Since we change the game programmatically in the undo() method, I removed the ChangeDetectionStrategy.OnPush again from the minesweeper component.

Having to remove ChangeDetectionStrategy.OnPush in a component that uses an immutable value as input property is probably a sign that the component is not optimally structured. I will get into this in my next article. If you want to receive a quick note when this article is available, leave your email address at the bottom of this article or follow me on Twitter.

Although we had now optimized the rendering process for row and tile, the changes had still not solved the fact the every tile got re-rendered on every click.

Effort 2: remove row partitioning

If every tile was re-rendered upon every tile click, it also implied that every row was re-rendered upon every click. The view part in the minesweeper component that renders the rows looks like this:

<div class="board">
  <row *ng-for="#row of rows" [row]="row" (tile-click)="handleTileClick($event)"></row>
</div>

and the updateGame() method in the minesweeper component is responsible for updating the rows:

updateGame(updateHistory = true){
  this.rows = partition(this.game.get('cols'), this.game.get('tiles'));
  if(updateHistory){
    this.history = this.history.push(this.game);
  }
}

where partition() is a function supplied by the original Minesweeper code of the React.js version:

function partition(size, coll) {
  var res = [];
  for (var i = 0, l = coll.size || coll.length; i < l; i += size) {
    res.push(coll.slice(i, i + size));
  }
  return fromJS(res);
}

The official Angular 2 ng-for directive documentation says:

Angular uses object identity to track insertions and deletions within the iterator and reproduce those changes in the DOM.

Wait! So the partition function generates new row objects every time the game data changes and ng-for uses strict equality (===) to determine which rows to update.

So even though only one tile is updated in our game data, the partition() function breaks up the game in 16 new row objects where each row object is different than its previous version (!==).

This will cause all rows to be updated every single time. Which is exactly what we are seeing!

So how do we fix this?

Easy! We remove the row partitioning and using the game data directly to display our tiles.

So instead of:

<div class="board">
  <row *ng-for="#row of rows" [row]="row" (tile-click)="handleTileClick($event)"></row>
</div>

we now use:

<div class="board">
  <tile *ng-for="#tile of getTiles()" [tile]="tile" (tile-click)="handleTileClick($event)"></tile>
</div>

Instead of using row components to display rows, we simple display all tiles and use CSS width of the board to make sure the tiles are shown correctly.

We could also use the template syntax <template ng-for #item [ng-for-of]="items" #i="index">...</template> to construct more complicated markup and generate row divs if needed, but here a simple ng-for of tiles is sufficient.

Let's save the changes and refresh the browser. Boom!

Now only the affected tiles are updated whenever a tile is clicked!

So instead of 256 tiles being re-rendered when 1 tile had changed, only 1 tile is re-rendered!

How is that for a performance gain!!

You can try out the optimized version right here.

Here is a visualization of the change we made:

Summary

By updating the way our data is passed into the components, we were able to optimize the performance exponentially!

The problem was not caused by Angular. The problem was not caused by Immutable.js.

The problem was caused by partitioning the data into rows and creating new immutable row objects every time the game data changed.

We also added changeDetection: ChangeDetectionStrategy.OnPush to the tile component to improve the Angular rendering process even more, as explained in Angular 2 and immutability by Victor Savkin.

Conclusion

When working with immutable data it is a good idea to align your datastructure with the logical hierarchy of your components and to avoid operations that may cause strict object equality to be false, especially when using ng-for.

If you perform operations on your immutable data causing new objects to be created, you may end up with a serious performance hit that may not be immediately visible.

You can add logging to the onChanges() method of your components to see if they render as expected.

If you have a component that only expects immutable data to be passed in via a property, you can further optimize rendering speed by adding changeDetection: ChangeDetectionStrategy.OnPush to the component.

All code from this article can be found here:

In the next article I will restructure Minesweeper to make use of Redux.

If you are interested in receiving a note when the article is ready, feel free to leave your email address below or follow me on Twitter.

Have a great one!!