1

I am trying to replace the whole DOM on page load to do a no-js fallback for a user created knockout page.

I have it replacing the DOM, but when I do the scripts included in the new document aren't running. I was wondering if theres any way of forcing them to run.

<!DOCTYPE html>
<html>
    <head>
        <title>Title1</title>
    </head>
    <body>
        Hello world <!-- No JS enabled content -->
    </body>
    <script type="text/javascript">
        var model = { 'template' : '\u003chtml\u003e\u003chead\u003e\u003ctitle\u003eTitle2\u003c/title\u003e\u003cscript type=\"text/javascript\"\u003ealert(\"test\");\u003c/script\u003e\u003c/head\u003e\u003cbody\u003eHello world2\u003c/body\u003e\u003c/html\u003e' };
        document.documentElement.innerHTML = model.template;
    </script>
</html>

template contains the following encoded

<html>
    <head>
        <title>aaa</title>
        <script type='text/javascript'>alert('hello world');</script>
    </head>
    <body>
        Hello world <!-- JS enabled content -->
    </body>
</html>

how can I get the alert to run?

undefined
  • 33,537
  • 22
  • 129
  • 198
  • 4
    If it's a no JS fallback I assume you're testing it with no JavaScript enabled, so the scripts not running is only logical.. or am I missing something here? – Benjamin Gruenbaum Jun 29 '13 at 13:34
  • 1
    A similar issue here: http://stackoverflow.com/q/1197575/575527 – Joseph Jun 29 '13 at 13:35
  • @BenjaminGruenbaum this is running with with JS enabled, the dom is replaced correctly the alert script doesnt run, the other one does. – undefined Jun 29 '13 at 13:36
  • @LukeMcGregor Ah, so you're doing the other way around. In that case, you can't insert JavaScript like that as text. See the question Joseph linked to. – Benjamin Gruenbaum Jun 29 '13 at 13:38
  • I think i was a little unclear around how the fallback is supposed to work, see edits. The top content should display if there are no scripts enabled, the bottom if there are. – undefined Jun 29 '13 at 13:38
  • @JosephtheDreamer using eval on the scripts seems to sort it out, thanks – undefined Jun 29 '13 at 13:48
  • Instead of `innerHTML` try to use `appendChild` or `replaceChild`, and try to first build documentFragment `createDocumentFragment` and then replace content of `documentElement` – Givi Jun 29 '13 at 13:52
  • 1
    @LukeMcGregor: *"The top content should display if there are no scripts enabled, the bottom if there are."* Why not use `noscript`? – T.J. Crowder Jun 29 '13 at 13:55
  • @T.J.Crowder ive similified this scenario quite a lot, the point is that i want to replace the whole DOM with one I'm giving to users to customize, that may include scripts which they have written. – undefined Jun 29 '13 at 14:03

2 Answers2

5

As you've discovered, the code in the script tags in the text you assign to innerHTML is not executed. Interestingly, though, on every browser I've tried, the script elements are created and placed in the DOM.

This means it's easy to write a function to run them, in order, and without using eval and its weird effect on scope:

function runScripts(element) {
  var scripts;

  // Get the scripts
  scripts = element.getElementsByTagName("script");

  // Run them in sequence (remember NodeLists are live)
  continueLoading();

  function continueLoading() {
    var script, newscript;

    // While we have a script to load...
    while (scripts.length) {
      // Get it and remove it from the DOM
      script = scripts[0];
      script.parentNode.removeChild(script);

      // Create a replacement for it
      newscript = document.createElement('script');

      // External?
      if (script.src) {
        // Yes, we'll have to wait until it's loaded before continuing
        newscript.onerror = continueLoadingOnError;
        newscript.onload = continueLoadingOnLoad;
        newscript.onreadystatechange = continueLoadingOnReady;
        newscript.src = script.src;
      }
      else {
        // No, we can do it right away
        newscript.text = script.text;
      }

      // Start the script
      document.documentElement.appendChild(newscript);

      // If it's external, wait for callback
      if (script.src) {
        return;
      }
    }

    // All scripts loaded
    newscript = undefined;

    // Callback on most browsers when a script is loaded
    function continueLoadingOnLoad() {
      // Defend against duplicate calls
      if (this === newscript) {
        continueLoading();
      }
    }

    // Callback on most browsers when a script fails to load
    function continueLoadingOnError() {
      // Defend against duplicate calls
      if (this === newscript) {
        continueLoading();
      }
    }

    // Callback on IE when a script's loading status changes
    function continueLoadingOnReady() {

      // Defend against duplicate calls and check whether the
      // script is complete (complete = loaded or error)
      if (this === newscript && this.readyState === "complete") {
        continueLoading();
      }
    }
  }
}

Naturally the scripts can't use document.write.

Note how we have to create a new script element. Just moving the existing one elsewhere in the document doesn't work, it's been marked by the browser as having been run (even though it wasn't).

The above will work for most people using innerHTML on an element somewhere in the body of the document, but it won't work for you, because you're actually doing this on the document.documentElement. That means the NodeList we get back from this line:

// Get the scripts
scripts = element.getElementsByTagName("script");

...will keep expanding as we add further scripts to the document.documentElement. So in your particular case, you have to turn it into an array first:

var list, scripts, index;

// Get the scripts
list = element.getElementsByTagName("script");
scripts = [];
for (index = 0; index < list.length; ++index) {
    scripts[index] = list[index];
}
list = undefined;

...and later in continueLoading, you have to manually remove entries from the array:

// Get it and remove it from the DOM
script = scripts[0];
script.parentNode.removeChild(script);
scripts.splice(0, 1); // <== The new line

Here's a complete example for most people (not you), including the scripts doing things like function declarations (which would be messed up if we used eval): Live Copy | Live Source

<!DOCTYPE html>
<html>
<head>
<meta charset=utf-8 />
<title>Run Scripts</title>
</head>
<body>
  <div id="target">Click me</div>
  <script>
    document.getElementById("target").onclick = function() {
      display("Updating div");
      this.innerHTML =
        "Updated with script" +
        "<div id='sub'>sub-div</div>" +
        "<script src='http://ajax.googleapis.com/ajax/libs/jquery/1.10.1/jquery.min.js'></scr" + "ipt>" +
        "<script>" +
        "display('Script one run');" +
        "function foo(msg) {" +
        "    display(msg); " +
        "}" +
        "</scr" + "ipt>" +
        "<script>" +
        "display('Script two run');" +
        "foo('Function declared in script one successfully called from script two');" +
        "$('#sub').html('updated via jquery');" +
        "</scr" + "ipt>";
      runScripts(this);
    };
    function runScripts(element) {
      var scripts;

      // Get the scripts
      scripts = element.getElementsByTagName("script");

      // Run them in sequence (remember NodeLists are live)
      continueLoading();

      function continueLoading() {
        var script, newscript;

        // While we have a script to load...
        while (scripts.length) {
          // Get it and remove it from the DOM
          script = scripts[0];
          script.parentNode.removeChild(script);

          // Create a replacement for it
          newscript = document.createElement('script');

          // External?
          if (script.src) {
            // Yes, we'll have to wait until it's loaded before continuing
            display("Loading " + script.src + "...");
            newscript.onerror = continueLoadingOnError;
            newscript.onload = continueLoadingOnLoad;
            newscript.onreadystatechange = continueLoadingOnReady;
            newscript.src = script.src;
          }
          else {
            // No, we can do it right away
            display("Loading inline script...");
            newscript.text = script.text;
          }

          // Start the script
          document.documentElement.appendChild(newscript);

          // If it's external, wait for callback
          if (script.src) {
            return;
          }
        }

        // All scripts loaded
        newscript = undefined;

        // Callback on most browsers when a script is loaded
        function continueLoadingOnLoad() {
          // Defend against duplicate calls
          if (this === newscript) {
            display("Load complete, next script");
            continueLoading();
          }
        }

        // Callback on most browsers when a script fails to load
        function continueLoadingOnError() {
          // Defend against duplicate calls
          if (this === newscript) {
            display("Load error, next script");
            continueLoading();
          }
        }

        // Callback on IE when a script's loading status changes
        function continueLoadingOnReady() {

          // Defend against duplicate calls and check whether the
          // script is complete (complete = loaded or error)
          if (this === newscript && this.readyState === "complete") {
            display("Load ready state is complete, next script");
            continueLoading();
          }
        }
      }
    }
    function display(msg) {
      var p = document.createElement('p');
      p.innerHTML = String(msg);
      document.body.appendChild(p);
    }
  </script>
</body>
</html>

And here's your fiddle updated to use the above where we turn the NodeList into an array:

HTML:

<body>
    Hello world22
</body>

Script:

var model = {
    'template': '\t\u003chtml\u003e\r\n\t\t\u003chead\u003e\r\n\t\t\t\u003ctitle\u003eaaa\u003c/title\u003e\r\n\t\t\t\u003cscript src=\"http://cdnjs.cloudflare.com/ajax/libs/jquery/1.10.1/jquery.min.js\"\u003e\u003c/script\u003e\r\n\t\t\t\u003cscript type=\u0027text/javascript\u0027\u003ealert($(\u0027body\u0027).html());\u003c/script\u003e\r\n\t\t\u003c/head\u003e\r\n\t\t\u003cbody\u003e\r\n\t\t\tHello world\r\n\t\t\u003c/body\u003e\r\n\t\u003c/html\u003e'
};
document.documentElement.innerHTML = model.template;

function runScripts(element) {
    var list, scripts, index;

    // Get the scripts
    list = element.getElementsByTagName("script");
    scripts = [];
    for (index = 0; index < list.length; ++index) {
        scripts[index] = list[index];
    }
    list = undefined;

    // Run them in sequence
    continueLoading();

    function continueLoading() {
        var script, newscript;

        // While we have a script to load...
        while (scripts.length) {
            // Get it and remove it from the DOM
            script = scripts[0];
            script.parentNode.removeChild(script);
            scripts.splice(0, 1);

            // Create a replacement for it
            newscript = document.createElement('script');

            // External?
            if (script.src) {
                // Yes, we'll have to wait until it's loaded before continuing
                newscript.onerror = continueLoadingOnError;
                newscript.onload = continueLoadingOnLoad;
                newscript.onreadystatechange = continueLoadingOnReady;
                newscript.src = script.src;
            } else {
                // No, we can do it right away
                newscript.text = script.text;
            }

            // Start the script
            document.documentElement.appendChild(newscript);

            // If it's external, wait
            if (script.src) {
                return;
            }
        }

        // All scripts loaded
        newscript = undefined;

        // Callback on most browsers when a script is loaded

        function continueLoadingOnLoad() {
            // Defend against duplicate calls
            if (this === newscript) {
                continueLoading();
            }
        }

        // Callback on most browsers when a script fails to load

        function continueLoadingOnError() {
            // Defend against duplicate calls
            if (this === newscript) {
                continueLoading();
            }
        }

        // Callback on IE when a script's loading status changes

        function continueLoadingOnReady() {

            // Defend against duplicate calls and check whether the
            // script is complete (complete = loaded or error)
            if (this === newscript && this.readyState === "complete") {
                continueLoading();
            }
        }
    }
}
runScripts(document.documentElement);

This approach just occurred to me today when reading your question. I've never seen it used before, but it works in IE6, IE8, Chrome 26, Firefox 20, and Opera 12.15.

T.J. Crowder
  • 1,031,962
  • 187
  • 1,923
  • 1,875
  • Hey, this looks really good and makes my above example work perfectly so im going to mark as an answer. However i do have a bit of an issue with ordering with this method, if I have an external script eg a jquery reference it loads after scripts which execute after it. is there any way to avoid this? heres a JSFiddle to demonstrate what i mean http://jsfiddle.net/6YwnK/ , take a look at the error console – undefined Jun 30 '13 at 05:47
  • @LukeMcGregor: The problem wasn't that the order was off, it was that the function just completely didn't cater for external scripts. I don't know what got into me. I've updated, and also dealt with a problem unique to your use that wouldn't hit most other people. – T.J. Crowder Jun 30 '13 at 08:14
  • Yeah i added the src and type bits to mine when i first tried, it sorted out the load issue, i was missing the continue loading bit to force sequential load. Ill add that into my code, thanks :) – undefined Jun 30 '13 at 09:32
0

To force the scripts the run you can iterate over the script elements and run them using eval or more preferably using Function but it runs outside the scope of the script tag so you will not have access to local variables. (document.currentScript will be null):

    document.querySelectorAll('script').forEach(script => {
        if (!script.src)
            new Function(script.text)()
        else {
            fetch(script.src).then(response => {
                return response.text().then(text => {
                    new Function(text)()
                });
            })
        }
    })