Promise-Based Detection of Element Injection

Written by RommelSantor | Published 2017/06/15
Tech Story Tags: javascript | promises | es6 | smarter-javascript | javascript-tips

TLDRvia the TL;DR App

Stop Polling the DOM for Dynamically Inserted Elements

There are a lot of situations where your page might need to sit waiting for specific manipulations in the DOM to occur. Typically, one might just rely on the document ready or window load event to execute some static initialization or if an Ajax call needs to be completed first, you would obviously initialize after receiving a successful response.

The part where things can get sloppy is when your initialization is dependent on an unknown event that is outside of your control but is responsible for inserting content into the DOM. For example, you might have a third-party <script> that needs to load and execute or perhaps you might need to react to a vendor’s plugin code modifying the DOM based on some kind of user interaction.

Ancient History

In the past, there have always been two common approaches developers would begrudgingly use to deal with this situation:

  1. Poll the DOMIt’s a simple enough solution, right? Just call setInterval() and keep checking the DOM every few milliseconds to see if your elements have been added.

  2. Set a long timeout“I know, I will guess at the longest it should take the <script> to load, then I’ll setTimeout() for even longer and we’ll be in good shape!”

The polling solution usually takes the following form with an interval or a self-executing timeout:

const myDynamicElementHandler = () => {/* ...do something... */}

// Poll the DOM every 100msconst uglyDomCheckerInterval = setInterval(() => {if ($('#some-dynamic-element-id').length === 0) return

clearInterval(uglyDomCheckerInterval)

// Yay, our element now exists! ... do something with itmyDynamicElementHandler()}, 100)

The timeout “solution” is similar, but even sloppier… and should make you feel ashamed of yourself:

// "I guess my element should exist after 10 seconds, so// I'll just set a timeout... Problem solved!"

setTimeout(myDynamicElementHandler, 10000)

If these solutions don’t at the very least make you feel a bit icky inside… you’re wrong.

Ideally, if you are dealing with third-party content (whether it be a <script> or a plugin, etc.) you would like their code to execute a callback you can define or trigger an event you can hook into; you would just want some way your code can respond to a specific change in the DOM. If such options are just not provided, we would at least like to have some other way to execute our handler just once when we know the related content does exist in the DOM.

Thanks to modern browsers, our days of dirty DOM polling are over and our days of Promise-based DOM notifications have just begun.

MutationObserver

There once existed a series of DOM Mutation Events, which were a great idea, but poorly executed, in that they came with performance concerns and inconsistency in browser support and implementation… And then came MutationObserver.

[MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) allows you to define a callback that will be executed when the specific types of DOM mutations you are interested in occur. There is a lot to it, so I would suggest reading up on it in case it is ever of general purpose use to you. One important thing to note is that it is supported across all modern browsers, down to IE11.

For my intents and purposes, MutationObserver is a beautiful offering and helped me accomplish my goal acceptably well.

awaitSelector()

const awaitSelector = (selector, rootNode, fallbackDelay) => new Promise((resolve, reject) => {try {const root = rootNode || documentconst ObserverClass = MutationObserver || WebKitMutationObserver || nullconst mutationObserverSupported = typeof ObserverClass === 'function'

let observer

const stopWatching = () => {if (observer) {if (mutationObserverSupported) {observer.disconnect()} else {clearInterval(observer)}

observer = null}}

const findAndResolveElements = () => {const allElements = root.querySelectorAll(selector)

if (allElements.length === 0) return

const newElements = []

const attributeForBypassing = 'data-awaitselector-resolved'

allElements.forEach((el, i) => {if (typeof el[attributeForBypassing] === 'undefined') {allElements[i][attributeForBypassing] = ''newElements.push(allElements[i])}})

if (newElements.length > 0) {stopWatching()resolve(newElements)}}

if (mutationObserverSupported) {observer = new ObserverClass(mutationRecords => {const nodesWereAdded = mutationRecords.reduce((found, record) => found || (record.addedNodes && record.addedNodes.length > 0),false)

if (nodesWereAdded) {findAndResolveElements()}})

observer.observe(root, {childList: true,subtree: true,})} else {observer = setInterval(findAndResolveElements, fallbackDelay || 250)}

findAndResolveElements()} catch (exception) {reject(exception)}})

const watchAwaitSelector = (callback, selector, rootNode, fallbackDelay) => {(function awaiter(continueWatching = true) {if (continueWatching === false) return

awaitSelector(selector, rootNode, fallbackDelay).then(callback).then(awaiter)}())}

I used MutationObserver as the basis for this rather simple awaitSelector() function, which allows you to specify an element selector and returns a Promise that will resolve with new elements inserted into the DOM matching your selector. If your elements already exist at the time awaitSelector() is called, then the Promise will resolve with those immediately, otherwise, it will listen for element insertions into the DOM and resolve if new elements matching your selector ever appear. Here is a simple example of the function in action:

awaitSelector() accepts the following three parameters:

  • selector — the CSS selector string to match elements against
  • [rootNode] — the element within which elements are to be searched; default: document
  • [fallbackDelay] — timeout delay to use if MutationObserver is unsupported; default: 250ms

To be thorough, we do not want to assume MutationObserver is always going to be available, nor make awaitSelector() unusable for developers who need to support older browsers, so built into it is a fallback to use the aforementioned old polling method to keep checking for new matching elements in the DOM.

One of the tricky things here is that we only want our Promise to resolve if new elements matching our selector are discovered; we want the function to be explicitly non-idempotent. In other words, if we execute awaitSelector() a second time with the same parameters, we do not want it to immediately resolve with just the same elements it resolved with in its first execution. Instead, we want it to wait for any new matching DOM elements before returning those new, matching DOM elements.

Another consideration is how to handle the initial execution of awaitSelector() with regard to matching elements that already exist in the DOM. It does as you would probably expect and resolves immediately with any such elements.

watchAwaitSelector()

It is a common use case that you want to execute a callback an unlimited number of times on a page whenever any matching elements are inserted, so to help with that, there is also a wrapper function called watchAwaitSelector(). This function takes as its first parameter a callback, and the rest of its parameters match awaitSelector(). It will listen for new elements matching your selector and execute your callback with those elements then continue listening for more. If you ever need to stop listening, your callback can just return false and it will stop watching.

Conclusion

There is still endless legacy code most developers have to work around, but with modern standards and wider, reliable browser support, there are more and more great solutions where once existed only hacks and dirty workarounds as potential solutions. Polling might still have its place in some contexts where a better solution is not yet available, but at least when it comes to awaiting DOM element insertion, you can easily create a helpful Promise that will keep your hands from getting too dirty.

For full transparency, it should be noted that the MutationObserver is still going to cause the internal logic of awaitSelector() to look for the desired selector when an element is inserted into the specified DOM subtree, but this is still clearly worlds better than any polling-based approach.

Note: As the default await-selector.js script linked above in my GitHub is written with ES6, I also provide an ES5 version for anyone who would like to use it in a non-Babel context (though it does still use a Promise, so if necessary, you may need to polyfill that yourself).


Published by HackerNoon on 2017/06/15