Unbound Listeners

Table of Contents

The Setup

One of the trickiest things to get right in javascript is this. To illustrate this point, let’s look at a situation we came across with one of my favorite browser APIs: addEventListener.

Attaching a load event listener to the window object is pretty straight forward:

1
2
3
window.addEventListener("load", function (e) {
  // handle `load` event
});

People start to get themselves into trouble when they assign references to instance methods and later want to execute those instance methods. Too frequently, I see code that looks like:

1
2
3
4
var addEventListener = window.addEventListener;
addEventListener("load", function callback() {
  console.assert(window === this);
});

this, as seen from inside callback(), should be a reference to the window object. And that works, mostly - but only because you are getting lucky. The assert passes, but only because this has to be something:

The moment you assign a reference to an instance method to a local variable, you’ve essentially unbound the method. And when invoked, you’ve supplied an undefined this value to the context of the callback. this will fall back to it’s original value - window - which is why the assert passes.

These two patterns show the same issue, and might be more obviously problematic:

1
2
var indexOf = String.prototype.indexOf;
indexOf(search);
1
2
3
var car = new Car();
var start = Car.prototype.start;
start();

You can make this work by using call (or apply or bind), but it’s fairly pathological:

1
2
var addEventListener = window.addEventListener;
addEventListener.call(window, "load", callback);

So, why is this a problem? Well, because we can’t guarantee that the callback executes in the top window context.

Enter third-party analytics

Most third party analytics libraries instrument the EventTarget.prototype.addEventListener method to give themselves greater insight into stack traces and also to measure various aspects of performance. My favorite third party analytics library, boomerang, prefers to load itself into a same-origin child IFRAME of the base page so as to be non-blocking in a legacy-browser-friendly way (more on this soon!). That means that our instrumentation code needs to run on top.EventTarget, so it looks something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
top.EventTarget.prototype.addEventListener = (function (_addEventListener) {
  return function instrument() {
    var args = Array.prototype.slice.call(arguments);
    var callback = args[1];
    args[1] = function intercede() {
      // measure the things
      return callback.apply(this, arguments);
    };
    return _addEventListener.apply(this, args);
  };
})(top.EventTarget.prototype.addEventListener);

And now, you might see the problem that exists if the window.addEventListener method is unbound from window:

  • the this in the instrument() method above will resort to being a reference to the window of the IFRAME
  • which means listeners will be bound to the wrong window
    • load events will fire with regard to the inner IFRAME, not the top window
    • you won’t get the message events that were postMessageed to the top window
  • for the events you do get, because the this in the intercede method is the child IFRAME, they too will have the wrong context - potentially tricking up developer code

We saw this so frequently that we now defensively work around it in instrument() - built on the presumption that no one actually intends to bind a listener to the boomerang child IFRAME:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
top.EventTarget.prototype.addEventListener = (function (_addEventListener) {
  return function instrument() {
    var args = Array.prototype.slice.call(arguments);
    var callback = args[1];
    args[1] = function intercede() {
      // measure the things
      return callback.apply(this, arguments);
    };

    var context = this;
    if (context === window) context = top;
    return _addEventListener.apply(context, args);
  };
})(top.EventTarget.prototype.addEventListener);

Two libraries conspiring to unbind all window listeners

On numerous occasions, we have been entangled with a particular polyfill library (thousands of downloads per week from npm) that patches addEventListener in a way such that, when coexisting in the page with boomerang, user code cannot not bind listeners to the wrong window. The code of the polyfill library was basically:

1
2
3
4
5
var addEvent = window.addEventListener;
window.addEventListener = function (event, listener, capture) {
  console.assert(this === window); // this passes
  addEvent(event, listener, capture);
};

This essentially assigns an unbound reference to the bound window.addEventListener method. Even if you explicitly call window.addEventListener(...), that just ends up invoking the unbound method.

Like I showed, we are now working around this from our end - and we also patched up the polyfill library with a PR - but for months, when boomerang and this polyfill library coexisted on a site, users were unable to bind listeners to the top window. 😢

With your console open, check out the broken interaction here, fixed interaction here.

The Lesson

Don’t assign or pass around references to methods that are context-aware because you can’t guarantee that they will be given the correct context when they are ultimately executed.