jQuery script insertion and its consequences for debugging

October 09, 2011

A while ago, we moved much of the JavaScript functionality on the Stack Exchange sites over to using lazy loading. Only a small part of the required JavaScript is now inserted into the page via <script> tags in the HTML file; all the rest is added via JavaScript itself.

Only keep what you really want

This makes the content of the page available for the user to read much more quickly, so they can get their answers faster – it's fine that, say, voting functionality takes a few extra milliseconds to be available. Additionally, we would only load those JavaScript files we actually need and when we need them, instead of loading them upfront because we might need them.

For example, if you have editing privileges on Stack Overflow and visit a question page, the HTML source contains this:

<script type="text/javascript">
    StackExchange.using("inlineEditing", function () {
        StackExchange.inlineEditing.init();
    });
</script>

StackExchange.using is a pretty simple function that (in this example), checks whether the object StackExchange.inlineEditing is already available. If that's the case, the callback is called right away; otherwise, the file that provides this object is retrieved from the server, executed, and then the callback is called. Standard lazy-loading behavior.

By the way, this functionality makes good use of jQuery's awesome Deferred objects – if you haven't looked at those yet, I strongly encourage you to.

Loading the script file from the server and running it was implemented using one of jQuery's standard methods:

return $.ajax({
    url: path,
    dataType: "script",
    cache: true
}).promise();

Pretty soon after we switched to delayed loading, we noticed a rather annoying issue: You couldn't use Firebug, the Chrome Developer Tools, IE's F12, or the like, to debug the lazily inserted JavaScript (e.g. setting breakpoints or single-stepping).

I assumed that those tools just couldn't handle JavaScript that was inserted after the fact. Interestingly enough, Opera (!) was the notable exception in this case: its dev tools were the only ones that worked here. But Opera isn't a browser that I (or anyone of us) use regularly.

For a while, we coped with this using a workaround: Since StackExchange.using checks if the requested functionality is already available, and doesn't load anything if it is, we could just (temporarily, on our dev machines) add the corresponding <script> tag to the HTML page, and then debug all we wanted.

This worked okay, but wasn't really satisfactory, having to go in and change the views in order to debug some JavaScript. Especially Mr. Efficiency kept yelling at me how much this sucked (and he was right, obviously). In addition, since this was a workaround only for the dev environment, this problem made debugging in production even harder than it already is. Ever single-stepped through minified JavaScript? Yeah, not fun.

So the other day, I took a closer look, and found that it's not actually the late loading that makes the browsers give up; rather, it's the fact that the script element that contains the JS in question is not part of the DOM.

Even when working with nails, a hammer may do the wrong thing

When you ask jQuery to load a script file, it does the following (in jQuery 1.5.2, this starts in line 7205):

  1. create a script element, with its src attribute set to the URL of the file you're loading
  2. attach the necessary handlers to the element, so we're notified when the file is loaded
  3. add the element to the DOM

This is all good and well. The problem is this piece of code within the handler that was attached in step 2:

// Remove the script
if ( head && script.parentNode ) {
    head.removeChild( script );
}

– that's right; after the script has been executed, jQuery removes the element from the DOM again. This is usually not an issue, since the execution environment continues to exist, so the code still runs; however, as noted above, it causes the code to be un-debuggable in most browsers.

The reason for doing this is probably preventing memory leaks. For example, the same script loading method is also used for handling JSONP requests, and you probably don't want those unnecessary script elements polluting your DOM forever.

But in our case, this is really not an issue; after all, we're loading a script file just like we would with a <script> tag, and we don't delete those elements from the DOM either.

Hence we stopped using jQuery's built-in JavaScript loader and started using our own function instead. It's not hugely different; it's almost the same thing that jQuery does, except the element removal code is gone:

var loadScript = function (path) {
    var result = $.Deferred(),
        script = document.createElement("script");
    script.async = "async";
    script.type = "text/javascript";
    script.src = path;
    script.onload = script.onreadystatechange = function(_, isAbort) {
        if (!script.readyState || /loaded|complete/.test(script.readyState)) {
            if (isAbort)
                result.reject();
            else
                result.resolve();
        }
    };
    script.onerror = function () { result.reject(); };
    $("head")[0].appendChild(script);
    return result.promise();
};

So if you're loading JavaScript files with jQuery's $.ajax, $.getScript, etc., and wondering why you're having a hard time debugging it – this is why.


previous post: Look, honey! I injected a dependency!

next post: JavaScript concurrency and locking the HTML5 localStorage

blog comments powered by Disqus