Lab Safety: Integrating 3rd Party Libraries in your Ember Application

By: Lisa Backer
Lab instruments

We all need to rely on 3rd party JavaScript libraries in our Ember projects. Oftentimes we utilize addons provided by the community that wrap those libraries and provide a stable, Ember-friendly API for us developers. Sometimes, however, it makes more sense for the project to utilize a library directly. Maybe the library is part of your corporate ecosystem or your usage is more specific than what could be provided by a generalized addon. Maybe you are the Ember addon maintainer who is making a JavaScript library available for others. In any case, this article serves to help you with your integration by reminding you to insulate yourself from change by making a clear division between Ember and the third party library in order to provide ease of maintenance, readability, and to prevent unintended consequences.

Muppet Labs Logo

We’ll examine a fictitious audio player library provided by our zany friends at Muppet Labs. Given the volatility of products that emerge from the labs, we know to expect change at best, or explosions at worst. We need to ensure that our application is safe from these side-effects while still allowing us to take full advantage of their innovations.

Insulate yourself from change (or exploding earmuffs)

Our Muppet Labs audio player expects a queue of audio items in a specific Muppet Media format. In order to use the player we need to 1) initialize the player with our secret token; 2) convert our songs from Ember data models to Muppet Media items; 3) use these items to call a function on the player.

// component.js
import ThePlayer from 'muppet-labs';

const { MuppetMedia, Player } = ThePlayer;

…
init() {
  this._super(...arguments);
  this.player = Player.init({ secretToken: 'some-access-token' });
}

actions: {
  playSongs(songs) {
    let mediaItems = songs.map((song) => {
      return new MuppetMedia({
        id: song.id,
        name: song.title,
        url: song.previewUrl,
        beekerFriendly: song.genre === 'Irish Ballad'
      });
    });
    this.player.playQueue(mediaItems);
  }
}

There are a number of problems with the above.

  • The initialization of the player happens within this component, but what if we want to use it elsewhere in our application? Can the player be reliably initialized multiple times?
  • The logic to convert our Ember data song models is localized here and would have to be repeated every place we want to load up songs in the player.

Ember services are a great solution to this type of problem. They are singletons (meaning there can be only one) and available via dependency injection throughout your application. In our case they also make a great way to wrap your library in an application-friendly public API.

// app/services/audio-player.js
import ThePlayer from 'muppet-labs';

const { MuppetMedia, Player } = ThePlayer;

…
init() {
  this._super(...arguments);
  this.player = Player.init({ secretToken: 'some-access-token' });
}

/**
 * Start playback on a group of songs in order
 *
 * @method playSongs
 * @public
 * @param {Array} songs An array of Song models
 */
playSongs(songs) {
  this.player.playQueue(songs.map(this._convertSongToMedia));
};

/**
 * Convert a song model into the MuppetMedia format expected by the player
 *
 * @method _convertSongToMedia
 * @private
 * @param {Song} song The song model to convert
 * @returns {MuppetMedia}
 */
_convertSongToMedia(song) {
  return new MuppetMedia({
    id: song.id,
    name: song.title,
    url: song.previewUrl,
    beekerFriendly: song.genre === 'Irish Ballad'
  });
}
…

// component.js
import { inject } from @ember/service;

…
audioPlayer: inject(),

actions: {
  playSongs(songs) {
    this.audioPlayer.playSongs(songs);
  }
}

Now we have managed to do a few things:

  1. We have moved the initialization of our 3rd party library into a service. This means it will only be called once for the lifetime of the application.
  2. We have converted the actions in our component to our application’s vernacular which makes it easier to read and understand. Before we had a generic “queue”. A queue could be any number of things, but to the 3rd party library it is the perfect word for their MuppetMedia strategy. In our application we may have queues of songs, videos, surveys, whatever. Now it is clear in your application that we are playing songs.
  3. We have moved the complicated logic into a helper function in the service where it is easily modified. Now if an upgrade of your 3rd party service adds additional functionality like artwork to be displayed while a song is playing, we can easily add this field in one place by modifiying your helper function to:
_convertSongToMedia(song) {
  return new MuppetMedia({
    id: song.id,
    name: song.title,
    url: song.previewUrl,
    beekerFriendly: song.genre === 'Irish Ballad',
    artwork: song.artwork
  });
}

Of course, libraries don’t just add functionality that we love —— sometimes they change things that we used to love. We can protect our application from this as well. If Beeker suddenly has a change of heart after one too many incidents with an overheating nose warmer, we can update our function accordingly.

_convertSongToMedia(song) {
  return new MuppetMedia({
    id: song.id,
    name: song.title,
    url: song.previewUrl,
    beekerFriendly: song.genre === 'Death Metal',
    artwork: song.artwork
  });
}

Read State from your Service

Many services, particularly media players, manage internal state that you want to listen and react to in your view later. For example, our audio player may provide a currentState with an enumeration of possible state constants. You likely want to reflect these state changes in your UI by changing a play icon to a pause icon or showing a loading spinner, etc. Your first instinct may be just to bind a property directly to your 3rd party’s state, but that won’t necessarily pick up changes as it is not part of Ember’s system listeners through custom getters/setters. You may also be tempted to make your own isLoading, isPlaying style helpers that you set upon user interaction with play and pause, but that can provide a duplication of the state tracking already within the library. Here, notifyPropertyChange is your friend. This function tells your application that a specific property has changed and that any cached values should be cleared and recalculated.

Let’s look at a slightly different portion of our service:

import ThePlayer from 'muppet-labs';
import { equal } from '@ember/object/computed';

const { MuppetMedia, Player, Events, States } = ThePlayer;

…
// some helper CPs that our application can listen to
isLoading: equal('player.currentState', States.loading),
isPlaying: equal('player.currentState', States.playing),
isPaused: equal('player.currentState', States.paused),

this.init() {
  this._super(...arguments);
  this.player = Player.init({ secretToken: 'some-access-token' });
  this._boundChangeHandler = this._onStateChange.bind(this);
  this.player.addEventListener(Events.StateChange, this._boundChangeHandler);
}

willDestroy() {
  this.player.removeEventListener(Events.StateChange, this._boundChangeHandler);
}

_onStateChange(event) {
  this.notifyPropertyChange('isLoading');
  this.notifyPropertyChange('isPaused');
  this.notifyPropertyChange('isPlaying');
}

There are a few new items here so let’s examine more closely. First we have our three helper computed properties for isLoading, isPlaying, and isPaused. These are setting their values by comparing the current player state to one of the provided state enumeration values. This means that we are still reading state from the library and using its own definitions. When the player state changes, our application will expect these three bindable properties to change.

In order to find out when the player state changes we are listening to an event in our init function.

>Side note: We aren’t listening directly with our function, but with a bound function. This allows us to ensure when our function is called the context (or this) is still the this of the service. Each call of bind returns a new function so when we remove our event listener we need to make sure we are removing the bound function previously added which is why we keep track of it in a local variable. If we simply called bind again in the willDestroy hook we’d be creating another instance and therefore a memory leak in our application.

Finally, in our listener we are utilizing Ember’s notifyPropertyChange function to let the rest of the application know that these properties could have potential new values. According to the docs, “Calling this method will notify all observers that the property has potentially changed value”. Essentially, it is similar to executing the binding updates that would occur with a call to Ember.set.

Remember to draw a divider line between “Ember” and “Not Ember”

Ember provides us a lot of great tools for binding our data and presentation layers, but these are intended to be used within the Ember ecosystem - not necessarily sprinkled throughout your libraries. This sounds obvious, but unintentional side-effects can occur if you aren’t careful.

Continuing with our Muppet Labs audio player, let’s also bind to the currently playing media item in our service so that we can use it to display metadata in a special component about the current song. We simply add a computed property in our player to read from the internal player library.

currentlyPlaying: reads('player.currentGuestStar')

Then in our display component for a song we can check to see if the player is currently playing back our song:

isPlayingSong: computed('audioPlayer.{isPlaying,currentlyPlaying.id}', 'song.id', function() {
  return this.audioPlayer.isPlaying && this.'audioPlayer.currentlyPlaying.id === this.song.id;
})

This seems perfectly reasonable but there are unintended consequences here. In order to watch the id property of audioPlayer.currentlyPlaying Ember will attach its own getters and setters to that object. This object, however, is actually a MuppetMedia instance from the 3rd party library. As this object gets passed around by reference, eventually the 3rd party library is likely to update data on it using an implicit setter, e.g.

item.exploading = ['earmuffs', 'neckties', 'hats'];

When this happens you will see what is known as the mandatory setter assertion or “You must use Ember.set() to set the property…”

Boom

When you first see this in your callstack you may think “but it’s not an Ember object so why is Ember’s setter being called in the first place? How would a third party library know to use Ember.set and more importantly why should it care?

As alluded to before, however, Ember has basically hijacked your non-Ember object in order to observe the id property. Before you get mad at Ember, it is working as intentioned. There is no other way for it to observe the property. The fault lies in not making a clear enough line in the sand between the world of Ember observed application objects and third-party classes.

Rather than directly reading from the 3rd party class that is being passed around by reference, update the Ember service to set a copy of the object that can be safely observed and is only passed around in the Ember application.

Our code becomes:

import { assign } from '@ember/polyfills';
…
currentlyPlaying: null,

_onStateChange(event) {
  this.notifyPropertyChange('isLoading');
  this.notifyPropertyChange('isPaused');
  this.notifyPropertyChange('isPlaying');
  if (this.isLoading || this.isPaused || this.isPlaying) {
    set(this, 'currentlyPlaying', assign({}, this.player.currentGuestStar));
  } else {
    set(this, 'currentlyPlaying', null);
  }
}

Now Muppet Labs has their MuppetMedia and our Ember application has a standard JavaScript object instance. Think of it as keeping the Muppet Labs experiments safe from the public as well as the other way around.

There is a huge community out there of JavaScript developers that are not specifically writing Ember. The key to utilizing them is being sure that we protect our application from changes in the library, but also to protect the library from our application.