HTML5 Script Execution Changes in Firefox 4

In Firefox 4, script execution changed to be more HTML5-compliant than before. This means that in some cases sites that sniff for Firefox or Gecko may break.

If you use LABjs

You should update to LABjs 1.0.4 (or later).

If you use the “order” plug-in for RequireJS

You should update to RequireJS 0.15.0 (or later).

If you use the multi-file version OpenLayers

You should switch to the single-file version of OpenLayers. (The upcoming 2.11 release is expected to fix the multi-file version.)

If your site/app works cross-browser without browser sniffing…

You don’t need to read further.

(However, if you triage bugs on bugzilla.mozilla.org, you might still want to read on.)

Note: This post is about when and how the script loader decides to evaluate scripts. This isn’t about what happens in the JavaScript engine once the script evaluation starts. Nothing discussed here has anything to do with JavaScript engine changes in Firefox 4.

Terminology

To be able to discuss this topic precisely, let’s define some terminology.

External script

An HTML script element that has the src attribute or an SVG script element that has the href attribute in the XLink namespace.

Inline script

An HTML or SVG script element that is not an external script.

Parser-inserted script

An HTML or SVG script element that has been instantiated by HTML parser parsing a script tag from source code coming from the network stream or document.write(), by the XML parser parsing a script tag from source code coming from the network stream or by the XSLT processor when performing a transformation triggered by <?xml-stylesheet?> or by XSLTProcessor.transformToDocument().

A parser-inserted script can lose its parser-insertedness and become a script-inserted script if the script doesn’t start when the parser tries to run the script. This happens if the script is not in a document at that time or if the script is an inline script that doesn’t have non-whitespace text content.

Script-inserted script

An HTML or SVG script element that is not parser-inserted as defined above (including scripts created using Range.createContextualFragment() and XSLTProcessor.transformToFragment()).

Async script

An external script whose .async DOM property getter returns true.

Defer script

A parser-inserted external script that is not an async script and whose .defer DOM property getter returns true.

Running a script

The process of attempting to start a script. This may involve fetching the script text over the network before executing the script. (Blame the HTML5 spec for giving distinct meanings to “run” and “execute”.) Parser-inserted scripts are run when the parser processes the </script> end tag. Script-inserted scripts are run when the script element node is inserted into a document.

Executing a script

The act of passing the script text to the JavaScript engine and asking the JavaScript engine to evaluate the text as JavaScript.

The Old Firefox Behavior

The old Firefox behavior was to execute non-async, non-defer scripts in the order in which they were run. (See above for the definitions of “execute” and “run”.) This may seem logical, but it caused some problems, because it meant that:

  1. Script-inserted inline scripts didn’t execute synchronously if there were external scripts being fetched. This caused undesirable effects when inserting an inline script in order to evaluate some JavaScript in the global scope and there were pending external script fetches. (E.g. jQuery uses this method of evaluating JavaScript text in the global scope.) In IE and WebKit, this trick for evaluating some text as JavaScript in the global scope would always evaluate the script text synchronously.

  2. Script-inserted scripts could block the parser upon the next parser-inserted script. This meant that sites that tried to use script-inserted external scripts instead of parser-inserted external scripts as a performance trick to overlap script fetching and parsing wouldn’t get the performance benefit they got in IE and WebKit if there was a subsequent parser-inserted script in their document.

  3. Script-inserted external non-async, non-defer scripts executed in the order they were inserted into the document.

The last point is actually desirable in some cases.

The Opera Behavior

Opera behaves like Firefox 3.6 except script-inserted external scripts block the parser even if there are no subsequent parser-inserted scripts.

The Old IE/WebKit Behavior

In IE and in Safari releases (but no longer in WebKit nightlies), the behavior for script-inserted scripts is as follows:

  1. Script-inserted inline scripts are executed synchronously upon insertion.

  2. Script-inserted external scripts are executed as soon as the script file has been fetched. This means that script-inserted external scripts can execute in any order depending on network conditions.

  3. If a script inserts an external script with a bogus MIME type in the type attribute, the external file is fetched and the onload event is fired for it, but the file isn’t executed as a script.

The HTML5 Behavior

HTML5 standardizes the intersection of the guarantees provided by the legacy versions of the top four browser engines. That is, the HTML5 behavior is like the old IE/WebKit behavior except scripts with bogus types aren’t fetched.

Specifically, the HTML5 behavior is as follows when the interaction with pending style sheet loads is omitted from discussion for simplicity:

This is fine, because it is the simplest and the most performant solution that is compatible with existing sites that are compatible with existing browsers without browser sniffing. After all, cross-browser-compatible sites (that don’t sniff) can’t rely on characteristics of script loading that aren’t provided by all browsers.

Firefox 4 implements the HTML5 behavior. The main reason for making the change now in Firefox was that the interaction of the old Firefox behavior and HTML5-compliant document.write() was problematic.

The Problem

So the HTML5 behavior works for existing sites that use Same Markup (to use Microsoft’s term) for all browsers. The problem is that some sites don’t. That is, sites that sniff for Firefox/Gecko (and Opera) can break.

So far, I’ve seen three classes of sniffing:

  1. Sniffing for Firefox/Gecko and Opera and running HTML5-incompatible code in Firefox and Opera and cross-browser / HTML5-compatible code otherwise.

  2. Sniffing for IE and WebKit and running and cross-browser / HTML5-compatible code in IE and WebKit and running HTML5-incomptible code otherwise.

  3. Sniffing for Firefox/Gecko and Opera and running HTML5-incompatible code in Firefox and Opera and differently HTML5-incompatible code that works in IE and old WebKit otherwise. (Seen in the LABjs library (in versions earlier than 1.0.4) and in the “order” plug-in for the RequireJS library in (in versions earlier that 0.15.0). LABjs 1.0.4 and RequireJS 0.15.0 has been updated to support Firefox nightlies.)

Note that the third kind of sniffing breaks in WebKit nightlies, too.

The Solutions

If you want to add an external script from a script during the parse and you want the script to block subsequent scripts appearing in the network stream…

Use document.write("\u003Cscript src='foo.js'>\u003C/script>");. This solution is cross-browser-compatible without sniffing. If you write multiple scripts at once, Firefox 4 downloads the scripts in parallel, so concern about parallel downloads is no longer a reason to avoid document.write in Firefox.

Alternatively, you could use plain <script> tags in the HTML source transferred over HTTP. This way Firefox (and other browsers) that implement speculative script loading will start fetching the scripts even earlier and will load the scripts in parallel.

If you want to run a script-inserted inline script after a script-inserted external script…

Set an onload event handler on the external script and insert the inline script from the event handler. This solution is cross-browser-compatible without sniffing.

If you want to load multiple interdependent script-inserted external scripts in parallel after the HTML parser has finished (and onload has fired) and want them to execute in a certain order…

In Firefox 4, in compliance with the latest HTML5 drafts, the .async DOM property returns true for script-inserted scripts by default. However, script-inserted external scripts whose .async DOM property returns false execute in the insertion order relative to other such scripts.

Thus, you can feature-detect if document.createElement("script").async evaluates to true and assume that if it does, setting .async=false on multiple script-inserted external scripts makes those scripts execute in the insertion order.

To make your code work robustly and as performantly as possible in both Firefox 4 and Firefox 3.6, I encourage you not only to set .async=false for script-inserted external scripts that you want to execute in the insertion order but also to explicitly set .async=true on script-inserted external scripts that don’t have ordering dependencies.

Unfortunately, this solution won’t work cross-browser until other browser implement this behavior, so for the time being, if document.createElement("script").async does not evaluate to true, you need to fall back onto whatever sniffing-based code you had (which probably fails with WebKit trunk) or onto fetching scripts one by one.

Note: Be sure to have the latest beta or nightly for testing your code.