When is a stylesheet really loaded?

March 17th, 2011. Tagged: browsers, CSS, performance

Often we want to load a CSS file on-demand by inserting a link node. And we want to know when the file finished loading in order to call a callback function for example.

Long story short: turns out this is harder than it should be and really unnecessary hard in Firefox. I hereby beg on behalf of many frustrated developers: please, Firefox 4, please fire a load event when a stylesheet loads.

Update:
If you think this is "a change we can believe in", tell the browser vendors to fire a load event on link elements as the HTML5 standard mandates. Here's the list of bugs for each browser to comment on and point people to:

Here's what the standard says:

"Once the attempts to obtain the resource and its critical subresources are complete, the user agent must, if the loads were successful, queue a task to fire a simple event named load at the link element"

With that out of the way, let's see what we have here.

// my callback function
// which relies on CSS being loaded
function CSSDone() {
  alert('zOMG, CSS is done');
}
  
 
// load me some stylesheet
var url = "http://tools.w3clubs.com/pagr/1.sleep-1.css",
    head = document.getElementsByTagName('head')[0];
    link = document.createElement('link');
 
link.type = "text/css";
link.rel = "stylesheet"
link.href = url;
 
// MAGIC
// call CSSDone() when CSS arrives
 
head.appendChild(link);

Options for the magic part, sorted from nice-and-easy to ridiculous

  1. listen to link.onload
  2. listen to link.addEventListener('load')
  3. listen to link.onreadystatechange
  4. setTimeout and check for changes in document.styleSheets
  5. setTimeout and check for changes in the styling of a specific element you create but style with the new CSS

5th option is too crazy and assumes you have control over the content of the CSS, so forget it. Plus it checks for current styles in a timeout meaning it will flush the reflow queue and can be potentially slow. The slower the CSS to arrive, the more reflows. So, really, forget it.

So how about implementing the magic?

  // MAGIC
  // #1
  link.onload = function () {
    CSSDone('onload listener');
  }
  // #2
  if (link.addEventListener) {
    link.addEventListener('load', function() {
      CSSDone("DOM's load event");
    }, false);
  }
  // #3
  link.onreadystatechange = function() {
    var state = link.readyState;
    if (state === 'loaded' || state === 'complete') {
      link.onreadystatechange = null;
      CSSDone("onreadystatechange");
    }
  };
  
  // #4
  var cssnum = document.styleSheets.length;
  var ti = setInterval(function() {
    if (document.styleSheets.length > cssnum) {
      // needs more work when you load a bunch of CSS files quickly
      // e.g. loop from cssnum to the new length, looking
      // for the document.styleSheets[n].href === url
      // ...
      
      // FF changes the length prematurely :()
      CSSDone('listening to styleSheets.length change');
      clearInterval(ti);
      
    }
  }, 10);
  
  // MAGIC ends

Test

Test page - riiiight here. I'm loading a CSS file delayed two seconds on the server. Attaching all those event listeners and timeouts above. Adding another timeout that just says "... and two seconds later ... " after (you guessed it!) two seconds. Now observing los resultados...

Results

  • IE fires readystatechange and load (tested years ago, too lazy to test now again). Now with IE9 maybe addEventListener will work too?
  • Firefox (like before) fires nothing. It updates the length of document.styleSheets immediately, not waiting for the file to actually arrive. So the outcome in my test log is:
    zOMG, CSS #1 is done: listening to styleSheets.length change
    ... and two seconds later ...
    
  • Opera fires load via onload and via addEventListener too. Like FF it also increments document.styleSheets.length imm√©diatement. The outcome:
    zOMG, CSS #1 is done: listening to styleSheets.length change
    ... and two seconds later ...
    zOMG, CSS #1 is done: onload listener
    zOMG, CSS #1 is done: DOM's load event
  • Chrome and Safari will not fire events but will update document.styleSheets only when the file arrives, yey!
    ... and two seconds later ...
    zOMG, CSS #1 is done: listening to styleSheets.length change

All in all, there's at least one way to tell when the stylesheet is loaded in each browser, except Firefox. Now that's embarrassing.

Is there really no hope for Firefox?

If you go really crazy than yes - implement magic #5 but it has serious drawbacks.

Otherwise the object trick should do - all browsers seem to fire load and/or readystatechange event consistently. Obviously it's a little more complicated. Although probably not as complicated as monitoring the document.styleSheets collection

I also tried MozAfterPaint - it might work but didn't for me, because my CSS didn't change anything on the page that required repaint. Obviously not a fit-all solution.

Another thing that failed was checking document.styleSheets[n].cssRules. Although document.styleSheets.length is updated immediately, I was thinking FF cannot update document.styleSheets[n].cssRules (document.styleSheets[n].sheet.cssRules) until the CSS actually arrives. However since FF 3.5 (or thereabouts) you don't have access to cssRules collection when the file is hosted on a different domain (CDN say). Security thing, you see. Even cssRules.length would've been enough, but nah.

Takeaways

  1. Any ideas about a clever (or not so clever) workaround that lets us figure out when CSS is loaded in FF? Please comment.
  2. Libraries can benefit from document.styleSheets.length trick to support Chrome and Safari. I know at least that YUI3 doesn't support callbacks on Y.Get.css() in Safari (nor FF)
  3. Firefox 4 must implement load on stylesheets :) No doubt about it. IMO all browsers should fire load on everything related to external resources.

UPDATE: FF solved!

Thanks to Ryan's comment below, Zach and Oleg, turns out there is something that works. And that is:

  1. you create a style element, not a link
  2. add @import "URL"
  3. poll for access to that style node's cssRules collection

It just happens so that Firefox will not populate this collection until the file arrives!

var style = document.createElement('style');
style.textContent = '@import "' + url + '"';
 
var fi = setInterval(function() {
  try {
    style.sheet.cssRules; // <--- MAGIC: only populated when file is loaded
    CSSDone('listening to @import-ed cssRules');
    clearInterval(fi);
  } catch (e){}
}, 10);  
 
head.appendChild(style);

Updated my test and seems to work just fine.

Whew!

I still maintain that this is unnecessarily complex and all browsers should simply fire load event. Seems to me this FF behavior might change at any time, as well as Safari's not populating document.styleSheet

One thing to note: this access to cssRules is not a failure of the same security check that doesn't allow cssRules access with outside domains. In this case we're accessing cssRules of the inline style and that's fine. We still cant access the styles of the @import-ed file from a different domain.

Side note: makes me wonder - this could be a technique to preload JS without executing too :)

Tell your friends about this post: Facebook, Twitter, Google+

Sorry, comments disabled and hidden due to excessive spam. Working on restoring the existing comments...

Meanwhile, hit me up on twitter @stoyanstefanov