Capabilities vs. Quirks: a look at browser sniffing

Posted in Articles, JavaScript, Prototype

Max Carlson (of OpenLaszlo) recently wrote about his toolkit’s approach to browser quirks, reminding me of a great Dev.Opera article on capability detection.

Both argue for an approach that relies on the individual capabilities and quirks of a browser, rather than one that relies on sniffing as a first option. This is a noble idea and one we’ve started to integrate into Prototype over the last six months. But, like everything else involving DOM scripting, it’s complicated.

I’m going to emphasize the difference between capabilities and quirks because we’ve taken separate approaches for each.

Capabilities

Capabilites are things that some browsers support and others don’t. For a long time, the DOM was a specific capability that mindful script authors always tested for. (Nowadays that’s really not necessary unless you’re supporting ancient browsers.) You might also test for:

  • canvas element support
  • JavaScript getters and setters
  • natively‐defined document.getElementsByClassName (like in Firefox 3)

In other words, these are capabilities because they give us some advantage: a performance increase, code simplification, or enhanced functionality. The capabilities one would test for in a JavaScript library, then, are those whose upside outweighs the added complexity of forking your code.

Also, capabilities are optimistic and forward‐looking — things that all browsers will presumably support one day. A capability is not browser‐specific, even if it’s something that only one browser supports right now.

The simplest sort of capability check is object detection — when the existence of a single function or property will tell you all you need to know about a capability. Checking for document.getElementById is a reasonable way to test simple DOM capabilities: if it returns true, it’s safe to assume a browser supports the most useful parts of DOM Level 1.

Prototype’s Event.observe function does a very common check for adding events:

if (element.addEventListener) {
  this.observers.push([element, name, observer, useCapture]);
  element.addEventListener(name, observer, useCapture);
} else if (element.attachEvent) {
  this.observers.push([element, name, observer, useCapture]);
  element.attachEvent('on' + name, observer);

Some capability checks are more elaborate. But nearly all can be condensed into a simple true or false value. For instance, here’s the object we use to keep track of capabilities:

Prototype.BrowserFeatures = {
  XPath: !!document.evaluate,
  ElementExtensions: !!window.HTMLElement,
  SpecificElementExtensions:

    (document.createElement('div').__proto__ !==
     document.createElement('form').__proto__)
}

As you can see, right now we check for three things: XPath support (for lightning‐fast DOM querying), a mutable HTMLElement prototype (so we can add instance methods directly to all element nodes), and mutable prototypes for specific DOM classes like HTMLDivElement or HTMLImageElement (so we can add instance methods to certain kinds of elements, like form controls).

(One could argue that testing for Prototype.BrowserFeatures.XPath instead of document.evaluate is overkill, but I think the former gives you a better understanding of the code at a glance.)

This approach helps us to contain the complexity of who‐supports‐what down to a simple boolean check.

Also keep in mind that some capabilities aren’t ready “out of the box,” but work just fine if you write some code to fill in the cracks. For instance, all existing versions of Safari have a mutable HTMLElement prototype but don’t give it a name. (This is fixed in the WebKit nightlies.) So we address this later on:

if (!Prototype.BrowserFeatures.ElementExtensions &&
 document.createElement('div').__proto__) {
  window.HTMLElement = {};
  window.HTMLElement.prototype = 
   document.createElement('div').__proto__;
  Prototype.BrowserFeatures.ElementExtensions = true;
}

We had to do a little work to enable the capability, but now Safari can benefit from element instance methods just like Firefox and Opera. And we didn’t have to do any sniffing.

Quirks

A quirk is a more polite name for a bug. Quirks are different from capabilities: they’re unintended deviations from the standard behavior. That IE doesn’t support canvas isn’t a bug; that it leaks memory is.

Quirks are also different because they’re nearly always specific to a certain browser — and because sometimes it’s impossible (or impractical) to detect them programmatically. As an example, I offer up the most heinous browser sniff in Prototype:

/* Force "Connection: close" for older Mozilla browsers to work
 * around a bug where XMLHttpRequest sends an incorrect
 * Content-length header. See Mozilla Bugzilla #246651.
 */
if (this.transport.overrideMimeType &&
    (navigator.userAgent.match(/Gecko\/(\d{4})/) || 
     [0,2005])[1] < 2005)
      headers['Connection'] = 'close';

This makes my eyes water whenever I look at it: we’re parsing out a year from a user agent string. But what else is there to do? Even if it’s possible to detect this quirk on the client side (and I’m not sure it is), it’d involve sending a dummy Ajax request on page load. Checking navigator.userAgent doesn’t look so ridiculous after all.

On the other hand, sometimes it’s easy enough to treat a quirk like a capability. Consider the following:

// Safari iterates over shadowed properties
if (function() {
  var i = 0, Test = function(value) { this.key = value };
  Test.prototype.key = 'foo';
  for (var property in new Test('bar')) i++;
  return i > 1;
}()) Hash.prototype._each = function(iterator) {
  var cache = [];
  for (var key in this) {
    var value = this[key];
    if ((value && value == Hash.prototype[key]) || 
     cache.include(key)) { continue; }
    cache.push(key);
    var pair = [key, value];
    pair.key = key;
    pair.value = value;
    iterator(pair);
  }
};

This is a workaround for a specific bug that occurs in all existing versions of Safari — but which will be fixed in version 3.0. We could have done the same thing here — an ugly match against navigator.userAgent — but it’s no more complicated (and far less fragile) to do a quick test for the quirk.

Internet Explorer, as usual, is the red‐headed stepchild here. We sniff for IE more than for any other browser. We do it to get around memory leaks; we do it to read attributes; we do it to update an element’s contents. We do it because it is prohibitively complicated to test for any of these quirks manually.

One could view this more abstractly as a social contract between web developers and browser makers: if they hold up their end of the bargain, we’ll hold up ours. The more accurately browsers report their capabilities (and the less arcane and bizarre their quirks are) the less we’ll have to resort to impure tactics. For Christ’s sake — IE will respond to foo.getAttribute('rowSpan') but not foo.getAttribute('rowspan'). I have no problem special‐casing IE to get around a bug so plainly ridiculous.

Likewise, as long as developers use sniffing responsibly (only where it’s truly needed, not as an arbitrary edict to save us a few lines of code), then browsers won’t have to keep impersonating each other and diluting the value of the information they report about themselves.

Act in good faith

A few months ago we added some simple boolean properties to the Prototype object:

Prototype.Browser = {
  IE:     !!(window.attachEvent && !window.opera),
  Opera:  !!window.opera,
  WebKit: navigator.userAgent.indexOf('AppleWebKit/') > -1,
  Gecko:  navigator.userAgent.indexOf('Gecko') > -1 && 
          navigator.userAgent.indexOf('KHTML') == -1
};

We didn’t do this because we wanted to embrace browser sniffing willy‐nilly. We did it because there are unavoidable sniffs in Prototype — and, as Carlson points out, it’s much simpler and less costly to check for a boolean than to grep navigator.userAgent every damn time. And a line that says if (Prototype.Browser.IE) is much more comprehensible (and honest) than a line that says if (document.all && !window.opera).

End users should not need to use these properties. I don’t mean to say that Prototype makes all browsers behave exactly the same; I mean that only the most complex of JavaScript will require browser detection past what we do under the hood. As always, there are exceptions, but don’t bend the rules until you know where they’re most flexible.

The code examples I’ve shown you add up to a parable for my general sniffing philosophy: act in good faith. If there’s a reasonable way to work around a quirk without sniffing, then do so, but also be practical. Don’t jump through hoops to set up complex tests for quirks that occur in only one browser. Pick your standards battles — or else the war itself will slowly drive you mad.

In summation

I’ve just given you the long‐winded version of my basic sniffing methodology. So here’s the short version (in order of preference):

  1. Use object detection whenever possible.
  2. Test for capabilities (and some simple quirks), storing the result in a boolean somewhere.
  3. Browser sniff whatever is left over. (But be sheepish about it.)

Comments

  1. Pingback