Don't Tab Me Bro… (or jQuery's preventDefault vs. browser tabs)

The project I’m currently working just had a big update, and part of that update was a reworking of the integration with Mixpanel to track usage of the site. Mixpanel is great, except that they don’t provide a synchronous Javascript API for tracking events. This generally isn’t a problem as most of the events we want to track do not result in a full page reload. But. But. To track links to external pages correctly, we need to use Mixpanel’s callback API to log the event before the browser gets sent on its merry way:

// Hypothetical jQuery binding
$(".navigation_tab").click(function(event) {
  var link = this;
  event.preventDefault(); // Slow down, big boy, we have to log the event first

  mpq.track("clicked on some navigation tab", { the_tab: this.id }, function() {
    // This function is called by Mixpanel after the event has been logged.
    document.location = link.href;
  });
});

Those of you in the audience who haven’t dozed off may have already picked up on the problem: event.preventDefault(); + document.location = a_href; = inability to open said link in a new tab/window – unless you do a context-menu dance.

  1. We need to preventDefault() because if we don’t, the browser heads off to the link, possibly aborting our attempt to track the click.

  2. We need to pass the callback to mpq() because of #1, so that the browser eventually goes to the link.

  3. The browser is sent to the original href programmatically by the callback – after Mixpanel does it’s thing. This action is only indirectly in response to the original click, the native event that triggered this whole process has been discarded.

  4. If the user originally ⌘-clicked (ctrl-click for the non-Mac people) to open the link in the new tab, they lose. The link opens in the current window/tab. The user gets annoyed.

Ironically I’ve seen similar behaviour on other sites, and just thought the developers were incompetent, or perhaps slightly malicious. Now I’ve become what I hate…

A demo

Try to open the following link in a new tab:

An annoying link

What happens is, as described earlier, the browser’s default response to the link is suppressed via preventDefault(). Then, after our hypothetical asynchronous logging method has completed, the callback is, er, called back, setting the browser’s location:

jQuery("#doc-loc").click(function(e) {
  var link = this;
  e.preventDefault(); // BOOM!
  setTimeout(function() {
    document.location = link.href; // Open, but not in a new tab >_<
  }, 500);
});

Well, that just won’t do, so we need to come up with a workaround. How about just retriggering the event? The first time I tried this, I just ended up with Javascript errors, so I deleted that code. But for this writing I tried dispatchEvent() which actually retriggers the jQuery handlers:

Double-triggered link

jQuery("#dispatch").click(function(e) {
  if (!e.originalEvent.retriggered) {
    event = e;
    var link = this;
    e.preventDefault();
    setTimeout(function() {
      e.originalEvent.retriggered = true; // To avoid retriggering to ∞.
      link.dispatchEvent(e.originalEvent); // Try the event again.
    }, 500);
  }
});      

But since we called preventDefault() the first time round, the retriggered event also skips the browser’s default action. But, we’re on the right track, and when you try something, and it doesn’t work, then the next step is always do the same thing with a copy of the original:

Trigger a copy

jQuery("#copy").click(function(e) {
  if (!$(this).data("delayed-event")) {
    var link = this;
    e.preventDefault();
    setTimeout(function() {
      // create a copy of the original event
      var newEvent = document.createEvent("MouseEvent");
      newEvent.initMouseEvent(e.type, e.bubbles, e.cancelable, e.view, 
                 e.detail, e.screenX, e.screenY, e.clientX, e.clientY, 
                 e.ctrlKey, e.altKey, e.shiftKey, e.metaKey, 
                 e.button, e.relatedTarget);
      $(link).data("delayed-event", true); // Avoid infinite death spiral
      link.dispatchEvent(newEvent); // Trigger 'new' event
      $(link).data("delayed-event", false);
    }, 500);
  }
});      

Cool! It works. Wrapping it up in a proper little function is left as an exercise to the reader. Another method to try would be triggering the callback via plain old jQuery:

Plain old jQuery trigger

jQuery("#trigger").click(function(e) {
  if (!$(this).data("delayed-event")) {
    var link = this;
    e.preventDefault();
    setTimeout(function() {
      $(link)
        .data("delayed-event", true)
        .trigger("click") // click the link again
        .data("delayed-event", false);
    }, 500);
  }
});      

It doesn’t work, and from reading the documentation:

Although .trigger() simulates an event activation, complete with a synthesized event object, it does not perfectly replicate a naturally-occurring event.

To trigger handlers bound via jQuery without also triggering the native event, use .triggerHandler() instead.

(Emphasis mine) I’m not really sure if should work or not. The first paragraph would indicate maybe, the second implies yes. I’m probably doing something wrong.

I imagine this is documented somewhere in one of those Javascript: all the awesome stuff, none of the crap–type books, but I don’t have copies of them, so I can’t check. If you can point me to a good discussion about this, please do. As for my project, I’ll be reworking the tracking code to get my open-in-new-tab functionality back as soon as I get time, and test in Internet Explorer, and…