Roll your own Ember.js identity map

03 December 2013

If you followed along my Build an Ember app screencast series and maybe played around with the app on your own, you might have noticed a subtle bug in the application.

When you load the application on the /artists/ route and then choose one of the artists, the artist link gets highlighted correctly:

Correct active highlight

However, when you reload the app on one of these artists routes (e.g /artists/pearl-jam), the artist link does not get the active highlight:

Incorrect active highlight

Understanding what is wrong

The first step of any debugging process is to understand where the bug lies. In order to do that we have to understand how highlighting a link as active works in Ember.js.

I wrote about this topic in detail in a guest post so let me just quickly summarize it here.

The artist links are rendered using the link-to helper. When active routes change (that includes the moment when the app is loaded from scratch) the isActive property for each link is recomputed. If it is found to be active, the activeClass is added to the view's tag and that enables it to be displayed differently.

When is a link-to active? When its route name matches one of the current route names. This is pretty straightforward for static links but what about dynamic ones with context objects? In those cases, all the context objects have to match in order for the link to be considered active.

Let's see our particular case. We have the following routes:

1App.Router.map(function() {
2  this.resource('artists', function() {
3    this.route('songs', { path: ':slug' });
4  });
5});

And the following template that renders each artist link:

 1<script type="text/x-handlebars" data-template-name="artists">
 2  (...)
 3  {{#each model}}
 4    {{#link-to "artists.songs" this class="list-group-item artist-link"}}
 5      {{name}}
 6      (...)
 7    {{/link-to}}
 8  {{/each}}
 9  (...)
10</script>

Each link is rendered with an artist object that comes from the model hook of the ArtistsRoute:

 1App.ArtistsRoute = Ember.Route.extend({
 2  model: function() {
 3    var artistObjects = Ember.A();
 4    Ember.$.getJSON('http://localhost:9393/artists', function(artists) {
 5      artists.forEach(function(data) {
 6        artistObjects.pushObject(App.Artist.createRecord(data));
 7      });
 8    });
 9    return artistObjects;
10  },
11  (...)
12});

So the artist object that serves as the context for the artists.songs link has to be the exact same object as the one returned from the model hook of the ArtistsSongsRoute:

 1App.ArtistsSongsRoute = Ember.Route.extend({
 2  model: function(params) {
 3    var artist = App.Artist.create();
 4    var url = 'http://localhost:9393/artists/' + params.slug;
 5    Ember.$.getJSON(url, function(data) {
 6      artist.setProperties({
 7        id: data.id,
 8        name: data.name,
 9        songs: App.Artist.extractSongs(data.songs, artist)
10      });
11    });
12    return artist;
13  },
14  (...)
15});

Are they identical? Well, intuitively they are but Ember.js does not care about our intuition and so we should not, either. They are different objects since both were created by calls to App.Artist.create (App.Artist.createRecord calls App.Artist.create under the hood) which returns a new object every time. Bummer.

Replacing intuition with reality

Don't be sad, it's not bad.

What we need is to have the same model object to be returned for the same identifier. In the matter at hand, given an artist slug (that serves as an identifier) we want to get back the same App.Artist object every time we use it.

If you think about it, that's what identity maps are for.

Wiring it up with promises

The identity map needs to be able to retrieve objects and also store them. Most importantly, it has to return the object from its map if it has already created it with the passed in id.

I will dump the code below and then explain what it does:

 1App.IdentityMap = {
 2  map: {},
 3  findAll: function(type) {
 4    var identityMap = this;
 5    return new Ember.RSVP.Promise(function(resolve, reject) {
 6      Ember.$.getJSON('http://localhost:9393/artists', function(artists) {
 7        var artistObjects = Ember.A();
 8        artists.forEach(function(data) {
 9          var artist = App.Artist.createRecord(data);
10          identityMap.store('artist', artist.get('slug'), artist);
11          artistObjects.pushObject(artist);
12        });
13        resolve(artistObjects);
14      });
15    });
16  },
17
18  find: function(type, id) {
19    var identityMap = this;
20    return new Ember.RSVP.Promise(function(resolve, reject) {
21      var artist = identityMap.map[identityMap._key(type, id)];
22      if (artist) {
23        resolve(artist);
24      } else {
25        var url = 'http://localhost:9393/artists/' + id;
26        Ember.$.getJSON(url, function(data) {
27          var artist = App.Artist.create();
28          artist.setProperties({
29            id: data.id,
30            name: data.name,
31            songs: App.Artist.extractSongs(data.songs, artist)
32          });
33          identityMap.store('artist', id, artist);
34          resolve(artist);
35        });
36      }
37    });
38  }
39  (...)
40}

As you can see, I used promises for the retrieval methods of the API.

Promises are a huge improvement over callbacks and deserve their own article book. They represent eventual values that are going to be either resolved (meaning success) or rejected (meaning failure) and can be passed around freely.

Ember.js relies on promises heavily in its routing API and uses the rsvp promise library. If a promise is returned from any model hook, the route transition does not begin until the promise is resolved.

Leveraging that property of Ember routing I return promises from both the findAll and find methods and then use them from the model hooks of the appropriate routes:

 1App.ArtistsRoute = Ember.Route.extend({
 2  model: function() {
 3    return App.IdentityMap.findAll('artist');
 4  },
 5  (...)
 6});
 7
 8App.ArtistsSongsRoute = Ember.Route.extend({
 9  model: function(params) {
10    return App.IdentityMap.find('artist', params.slug);
11  },
12  (...)
13});

When I call App.IdentityMap.findAll from the ArtistsRoute the rendering of the artists template is stopped until the promise is resolved. That happens when the AJAX call has returned with the data for all artists and I call resolve(artistObjects).

Next, the model hook for the ArtistsSongsRoute is evaluated. It returns a promise that has to be resolved in order for the template to be rendered.

The artist is found in the identityMap because it has just been stored there during the findAll in the previous model hook resolution (see the identityMap.store('artist', artist.get('slug'), artist); line). Since it is the same object that was used as the context for the artist link, the bug is squashed.

The link now gets correctly highlighted as active:

Active highlight fixed

Notice we achieved something else, too. Instead of firing two AJAX calls, one to fetch all artists and then one to fetch the one serialized in the URL we now only have one call. We eliminated the second one by returning the object from the identity map.

Furthermore, I also think our code has become better organized. The models for our routes have now become one-liners and can quickly be read and understood at a casual glance instead of being buried under the minutiae of AJAX calls.

Share on Twitter