Run script tags in innerHTML content

When inserting HTML content in the DOM using innerHTML, script tags inside it will not load or run. This applies to both inline scripts and external ones using the src attribute.

document.write

One way to load the scripts is to use document.write. The problem with it is that Internet Explorer 9 does not respect execution order. Each script tags will execute when loaded.

If you’re writing to an iframe, you can set the src attribute to javascript: '<script>...<\/script>'. This will work even in Internet Explorer 9 with the correct execution order.

eval

Another way is to load the external scripts using XMLHttpRequest, and run both inline and external ones with eval. One major drawback of this approach is that the same-origin policy restricts requests to other domains. eval also brings in a series of security issues.

createContextualFragment

Hat tip to Jake Archibald for this.

Using createContextualFragment to insert script tags will execute them. But, same as with document.write, the scripts will run as they are loaded and not in the specific order.

var range = document.createRange()
range.setStart($container, 0)
$container.appendChild(
  range.createContextualFragment('<script src="..."><\/script><script>...<\/script>')
)

document.createElement

The other solution is to re-create and re-insert each script tag into the DOM using document.createElement. This doesn’t handle execution order by itself, but we can insert the tags sequentially.

type attribute

Following the HTML spec for script tags, browsers execute only script tags with no type attribute, or with a valid JavaScript MIME type.

Some JavaScript libraries use inline script tags with custom types for precompiled code. For example, Babel’s (5.x) browser build can compile code from script tags with a text/babel type.

To match the browser behavior we must run only tags with an omitted or valid type attribute.

DOMContentLoaded

Browsers fire the DOMContentLoaded event when the document is loaded and parsed. This includes having loaded and ran all script tags.

Because some of the scripts we run could rely on the event, we must trigger the event manually after all tags load.

For Internet Explorer 9 support we use createEvent, instead of the Event constructor.

// trigger DOMContentLoaded
function scriptsDone () {
  var DOMContentLoadedEvent = document.createEvent('Event')
  DOMContentLoadedEvent.initEvent('DOMContentLoaded', true, true)
  document.dispatchEvent(DOMContentLoadedEvent)
}

Execution order

To preserve execution order we need to insert each script tag after the previous one has finished loading.

We also need to trigger DOMContentLoaded after all scripts are loaded.

For this we use a small helper function.

// runs an array of async functions in sequential order
function seq (arr, callback, index) {
  // first call, without an index
  if (typeof index === 'undefined') {
    index = 0
  }

  arr[index](function () {
    index++
    if (index === arr.length) {
      callback()
    } else {
      seq(arr, callback, index)
    }
  })
}

This runs an array of async functions, moving to the next one when the previous reaches the callback.

When it’s all done it runs the main callback function.

Script running

Putting it all together, here’s the script running code:

function insertScript ($script, callback) {
  var s = document.createElement('script')
  s.type = 'text/javascript'
  if ($script.src) {
    s.onload = callback
    s.onerror = callback
    s.src = $script.src
  } else {
    s.textContent = $script.innerText
  }

  // re-insert the script tag so it executes.
  document.head.appendChild(s)

  // clean-up
  $script.parentNode.removeChild($script)

  // run the callback immediately for inline scripts
  if (!$script.src) {
    callback()
  }
}

// https://html.spec.whatwg.org/multipage/scripting.html
var runScriptTypes = [
  'application/javascript',
  'application/ecmascript',
  'application/x-ecmascript',
  'application/x-javascript',
  'text/ecmascript',
  'text/javascript',
  'text/javascript1.0',
  'text/javascript1.1',
  'text/javascript1.2',
  'text/javascript1.3',
  'text/javascript1.4',
  'text/javascript1.5',
  'text/jscript',
  'text/livescript',
  'text/x-ecmascript',
  'text/x-javascript'
]

function runScripts ($container) {
  // get scripts tags from a node
  var $scripts = $container.querySelectorAll('script')
  var runList = []
  var typeAttr

  [].forEach.call($scripts, function ($script) {
    typeAttr = $script.getAttribute('type')

    // only run script tags without the type attribute
    // or with a javascript mime attribute value
    if (!typeAttr || runScriptTypes.indexOf(typeAttr) !== -1) {
      runList.push(function (callback) {
        insertScript($script, callback)
      })
    }
  })

  // insert the script tags sequentially
  // to preserve execution order
  seq(runList, scriptsDone)
}

It’s possible that some some external scripts will not load, so we also handle the onerror listener.

Here’s a demo of how it works: