Skip to content

Instantly share code, notes, and snippets.

@jwilson8767
Last active October 10, 2024 00:35
Show Gist options
  • Save jwilson8767/db379026efcbd932f64382db4b02853e to your computer and use it in GitHub Desktop.
Save jwilson8767/db379026efcbd932f64382db4b02853e to your computer and use it in GitHub Desktop.
Wait for an element to exist. ES6, Promise, MutationObserver
// MIT Licensed
// Author: jwilson8767
/**
* Waits for an element satisfying selector to exist, then resolves promise with the element.
* Useful for resolving race conditions.
*
* @param selector
* @returns {Promise}
*/
export function elementReady(selector) {
return new Promise((resolve, reject) => {
let el = document.querySelector(selector);
if (el) {
resolve(el);
return
}
new MutationObserver((mutationRecords, observer) => {
// Query for elements matching the specified selector
Array.from(document.querySelectorAll(selector)).forEach((element) => {
resolve(element);
//Once we have resolved we don't need the observer anymore.
observer.disconnect();
});
})
.observe(document.documentElement, {
childList: true,
subtree: true
});
});
}
import { elementReady } from "es6-element-ready";
// Simple usage to delete an element if/when it exists:
elementReady('#someWidget').then((someWidget)=>{someWidget.remove();});
@danielpox
Copy link

Thanks a lot! This greatly improved the stability of a project of mine! Instead of waiting for x ms and then hoping it had appeared. Great work!

@jwilson8767
Copy link
Author

@danielpox, glad it helped! Tweet it out for visibility, I keep hoping some kitchen sink library will want to adopt this.

Copy link

ghost commented Aug 8, 2020

Very useful, thanks 🙂

@asontu
Copy link

asontu commented Dec 29, 2020

Inspired by this Gist I made my own function that takes more parameters to configure the MutationObserver and implements an optional Timeout that @quantuminformation asks about.

@jwilson8767
Copy link
Author

@asontu, looks good! Glad you found this helpful!

@theimpostor
Copy link

Thanks for the great sample! I found this very useful.

Question about this part:

...
document.querySelectorAll(selector)).forEach((element) => {
        resolve(element);
...

If selector matches multiple elements, why call resolve multiple times? Is there any benefit to doing that?

Seems to me you could just resolve on the first element, which means you could use the querySelector (not querySelectorAll), e.g.:

let element = document.querySelector(selector)
if (element) {
  resolve(element)
...

Just curious if there is some advantage to resolving multiple times.

@jwilson8767
Copy link
Author

@theimposter, querySelectorAll always returns an array, which at the time that I wrote this seemed like a useful thing. However, as you point out, using querySelector is just fine too.

As to what happens if querySelectorAll suddenly matches multiple elements, the resolve() being called more than once does nothing after the first call. More recently, I've used RXJS Observables to actually make use of when more than one element is matched and re-combine or filter streams of events / changes in a more "functional" style (rather than handing around a ton of nested callbacks).

@JonahMoses
Copy link

what would this look like with using the querySelector vs querySelectorAll?

    new MutationObserver((mutationRecords, observer) => {
      Array.from(document.querySelectorAll(selector)).forEach((element) => {
        resolve(element);
        observer.disconnect();
      });
    }).observe(document.documentElement, {
      childList: true,
      subtree: true,
    });

@jwilson8767
Copy link
Author

@JonahMoses, I believe it's as simple as this:

export function elementReady(selector) {
  return new Promise((resolve, reject) => {
    let el = document.querySelector(selector);
    if (el) {resolve(el);}
    new MutationObserver((mutationRecords, observer) => {
      // Query for element matching the specified selector
      const element = document.querySelector(selector);
      if (element) {
        resolve(element);
        //Once we have resolved we don't need the observer anymore.
        observer.disconnect();
      }
    })
      .observe(document.documentElement, {
        childList: true,
        subtree: true
      });
  });
}

@ivantacca
Copy link

Hi, I have a question about that querySelector and querySelectorAll.
Assuming that we are looking for multiple elements to exist, this function will return only the first, but replacing the first querySelector with a querySelectorAll will return a nodeList immediately.

export function elementReady(selector) {
  return new Promise((resolve, reject) => {
    let el = document.querySelectorAll(selector);
    if (el) {resolve(el);}
    new MutationObserver((mutationRecords, observer) => {
      // Query for elements matching the specified selector
      Array.from(document.querySelectorAll(selector)).forEach((element) => {
        resolve(element);
        //Once we have resolved we don't need the observer anymore.
        observer.disconnect();
      });
    })
      .observe(document.documentElement, {
        childList: true,
        subtree: true
      });
  });
}

How would you implement this?

@jwilson8767
Copy link
Author

@ivantacca What you're looking for is probably just to use the MutationObserver to give you all the matching elements, either once or perhaps periodically as the page changes. This is a bit different from what elementReady does, which is to give a single element as soon as it's added to the DOM. The first, and simplest way to wait for multiple elements to exist (when you know what their ids are) is to just call elementReady more than once and use Promise.all to wait for all them to exist:

Promise.all([elementReady('#element1'), elementReady('#element2'), elementReady('.element3')) ]).then(()=>{
//callback function body
})

The above only resolves once, so it's good for waiting during a page load where you know what elements need to load, and you don't need to repeat the callback function. For a more complicated case where you want to watch the entire page for new elements matching some selector, and periodically trigger a callback, I recommend the following approach:

/**
 * Watches for one or more elements matching a selector to exist, and calls the provided callback. Returns a function to stop the watcher.
 *
 * @param {string} selector
 * @param {function} callback
 * @param {boolean} once_per_element Set to true to only emit each element once. May leak memory for long-running pages with thousands of matched elements.
 * @returns {function} a function which stops the watcher when called.
 */
export function listenElements(selector, callback, once_per_element=false) {
  let known_elements = [];
  let _listenInnerTimeoutHandle = null;
  let _listenInner = () => {
    _listenInnerTimeoutHandle = null;
    const elements = Array.from(document.querySelectorAll(selector));
    if (elements.length) {
      if (once_per_element){
        const new_elements = elements.filter((el)=>!known_elements.includes(el))
        callback(new_elements)
        known_elements += new_elements;
      }else {
        callback(elements);
      }
    }
  }

  // immediately trigger inner function in case there are already matching elements
  _listenInnerTimeoutHandle = window.setTimeout(_listenInner, 0);

  const observer = new MutationObserver(() => {
    // skip mutations if inner is already scheduled (simple debouncing)
    if (_listenInnerTimeoutHandle) {
      return
    }
    // trigger inner function after a short delay for debouncing batches of mutations
    _listenInnerTimeoutHandle = window.setTimeout(_listenInner, 70);
  });
  observer.observe(document.documentElement, {
    childList: true,
    subtree: true
  });
  return () => {
    observer.disconnect()
  };
}

Sorry if that doesn't work out of the box, I didn't have time to test it fully.

If you want a more robust solution for dealing with this sort of thing, check out the "Observer" pattern and check out RXJS. Since I originally wrote elementReady.js I have switched a large portion of my projects over to using an RXJS as it lets me synchronize user interactions, state changes, and component render cycles without creating hugely jumbled code.

@acropup
Copy link

acropup commented Oct 22, 2021

Thanks for this nice little function. I noticed one fairly insubstantial bug, due to how calling a Promise's resolve() or reject() function doesn't act like a return statement. Code will continue executing, and in this case, a MutationObserver will be created even if the initial querySelector call was successful. The start of the promise should read like this, taking note of the return after the resolve:

let el = document.querySelector(selector);
if (el) { resolve(el); return; }

@jwilson8767
Copy link
Author

@acropup Good point, fixed!

@LouisDeconinck
Copy link

I would be looking to detect if one of two elements is ready, with totally different selectors. How would I go about that?

@jwilson8767
Copy link
Author

@LouisDeconinck It should be as simple as:

Promise.race([elementReady('#element1'), elementReady('#element2'), elementReady('.element3')) ]).then((matched_element)=>{
//callback function body
})

@asontu
Copy link

asontu commented Jun 17, 2022

@LouisDeconinck @jwilson8767 Should work by simply concatenating with a comma same as you would to define a CSS style for two totally different selectors:

elementReady('#element1, #element2, .element3').then((el) => doSomethingWith(el))

@koninpavlik
Copy link

Thank you bro!

@bezborodow
Copy link

This is querying the entire document every time a mutation occurs, rather than matching specifically against the added nodes. This may result in a performance issue.

@acropup
Copy link

acropup commented May 29, 2023

Yes, what @bezborodow is recommending is that instead of doing a document.querySelectorAll(selector) on line 20, it is more efficient to only try to match on the added notes in the mutationRecords parameters (ex. check if mutationRecords[0].addedNodes[0].matches(selector), and repeat for all mutationRecords and addedNodes).

Even if performance is better, I don't think this strategy works in all cases. One reason: adding a node such as <div>This div has <strong>children</strong></div> will put the outer div into the addedNodes list, but none of its children will be in addedNodes. So, matching on 'div > strong' would not succeed. You could instead do addedNode.querySelectorAll(selector) for all added nodes, but there are other CSS selectors for which this is insufficient. One example that comes to mind is '#parentElem:has(div > strong)'. Even if adding the nodes would cause a valid match, #parentElem is never part of addedNodes, because that particular node was never added. I believe using + and ~ in selectors is similarly problematic.

tl;dr Referring to addedNodes might be more performant, but it can fail to match in some scenarios. document.querySelectorAll might be inefficient, but it is always correct.

@bezborodow
Copy link

bezborodow commented May 29, 2023

@acropup, an alternative solution is to match on the unique ID only, and not use selectors. This would be intuitive, since the promise resolves after the first match is found.

However, if there was a way to reliably tokenise a selector, it could be found through introspection what kind combinators are present and adjust the match/query algorithm accordingly. There is a css-selector-tokenizer package, but that seems overkill for something that should be easily achievable.

@bezborodow
Copy link

bezborodow commented May 29, 2023

section:has(div > strong) example using closest(). Adjacent and sibling combinators can also be implemented, but the important aspect in terms of performance is having a tokeniser to introspect and recognise which algorithm to pick based on the complexity of the selector. Although even without that, this should represent a substantial improvement.

const element = (addedNode.matches(selector) && addedNode)
  || addedNode.querySelector(selector)
  || addedNode.closest(selector);

@mdovn
Copy link

mdovn commented Feb 17, 2024

ok boo

@precogtyrant
Copy link

Is it possible to reuse the same MutationObserver multiple times without calling disconnect() on it? If the MutationObserver is observing the document, then if I am able to reuse it for all new node creations on the document, i wont have to keep creating/disconnecting it.

@jwilson8767
Copy link
Author

jwilson8767 commented Aug 31, 2024

@precogtyrant MutationObserver's themselves with a callback function are probably your best bet then instead of using elementReady() since promises can by nature only be resolved once. If you want more robust "reactive" / "observable" behavior, I can't recommend RXJS enough. I've used it to build entire apps, and it's really a game changer.

@jwilson8767
Copy link
Author

jwilson8767 commented Aug 31, 2024

@bezborodow Regarding performance issues, elementReady() should definitely be taken as a convenience function, I still use it occasionally but only where it is expected to run only a few times in the lifecycle of a page (such as to detect when a component's root element has been added to the page). MutationObservers as a whole aren't amazing performance-wise, really. For much better performance (while still working on arbitrary selectors), I'd actually recommend using a sort of long-polling strategy via setInterval. You could either create one setInterval per selector, or a shared setInterval and an array of outstanding selectors to match against. Here's an example of the latter:

// dict of {selector: [promise, resolve]}
const pendingElements = {};
let pendingElementsInterval;
const elementReadyBatchedFrequency = 100; // ms

/**
 * Wait for an element to be ready using a querySelector
 *
 * @param selector {string}
 * @param containerEl {Element} optional container element to search within
 * @returns {Promise<Element>}
 */
async function elementReadyBatched(selector, containerEl = document){
    let el = document.querySelector(selector);
    if (el) {
      return el;
    }
    // group outstanding requests
    if (pendingElements[selector]) {
      const [promise, _] = pendingElements[selector];
      delete pendingElements[selector];
      return promise;
    }
    let resolve;
    const promise = new Promise(r => resolve = r);
    pendingElements[selector] = [promise, resolve];
    if (!pendingElementsInterval) {
      pendingElementsInterval = setInterval(() => {
        for (const [_selector, [_, _resolve]] of Object.entries(pendingElements)) {
          const el = containerEl.querySelector(_selector);
          if (el) {
            _resolve(el);
            delete pendingElements[_selector];
          }
        }
        if (!Object.keys(pendingElements).length) {
          clearInterval(pendingElementsInterval);
          pendingElementsInterval = null;
        }
      }, elementReadyBatchedFrequency);
    }
    return pendingElements[selector][0];
}

edit: yes, I know this is very late in coming. Still <3 you.

@simonjoom
Copy link

Thanks @jwilson8767 for the last script,
it did work for me. also i m not sure about if it s more efficient than using mutationobserver..

Crazy hard to understand this script though, quite complicate.

i personaly removed the async and did a return Promise.resolve(el); for the first return.
also added a settimeout to test if the script run more than 4second to stop it in case it didnt find all matches.

@bezborodow Regarding performance issues, elementReady() should definitely be taken as a convenience function, I still use it occasionally but only where it is expected to run only a few times in the lifecycle of a page (such as to detect when a component's root element has been added to the page). MutationObservers as a whole aren't amazing performance-wise, really. For much better performance (while still working on arbitrary selectors), I'd actually recommend using a sort of long-polling strategy via setInterval. You could either create one setInterval per selector, or a shared setInterval and an array of outstanding selectors to match against. Here's an example of the latter:

// dict of {selector: [promise, resolve]}
const pendingElements = {};
let pendingElementsInterval;
const elementReadyBatchedFrequency = 100; // ms

/**
 * Wait for an element to be ready using a querySelector
 *
 * @param selector {string}
 * @param containerEl {Element} optional container element to search within
 * @returns {Promise<Element>}
 */
async function elementReadyBatched(selector, containerEl = document){
    let el = document.querySelector(selector);
    if (el) {
      return el;
    }
    // group outstanding requests
    if (pendingElements[selector]) {
      const [promise, _] = pendingElements[selector];
      delete pendingElements[selector];
      return promise;
    }
    let resolve;
    const promise = new Promise(r => resolve = r);
    pendingElements[selector] = [promise, resolve];
    if (!pendingElementsInterval) {
      pendingElementsInterval = setInterval(() => {
        for (const [_selector, [_, _resolve]] of Object.entries(pendingElements)) {
          const el = containerEl.querySelector(_selector);
          if (el) {
            _resolve(el);
            delete pendingElements[_selector];
          }
        }
        if (!Object.keys(pendingElements).length) {
          clearInterval(pendingElementsInterval);
          pendingElementsInterval = null;
        }
      }, elementReadyBatchedFrequency);
    }
    return pendingElements[selector][0];
}

edit: yes, I know this is very late in coming. Still <3 you.

@simonjoom
Copy link

Thanks @jwilson8767 for the last script,
it did work for me. also i m not sure about if it s more efficient than using mutationobserver..

Crazy hard to understand this script though, quite complicate.

i personaly removed the async and did a return Promise.resolve(el); for the first return.
also added a settimeout to test if the script run more than 4second to stop it in case it didnt find all matches.

@bezborodow Regarding performance issues, elementReady() should definitely be taken as a convenience function, I still use it occasionally but only where it is expected to run only a few times in the lifecycle of a page (such as to detect when a component's root element has been added to the page). MutationObservers as a whole aren't amazing performance-wise, really. For much better performance (while still working on arbitrary selectors), I'd actually recommend using a sort of long-polling strategy via setInterval. You could either create one setInterval per selector, or a shared setInterval and an array of outstanding selectors to match against. Here's an example of the latter:

// dict of {selector: [promise, resolve]}
const pendingElements = {};
let pendingElementsInterval;
const elementReadyBatchedFrequency = 100; // ms

/**
 * Wait for an element to be ready using a querySelector
 *
 * @param selector {string}
 * @param containerEl {Element} optional container element to search within
 * @returns {Promise<Element>}
 */
async function elementReadyBatched(selector, containerEl = document){
    let el = document.querySelector(selector);
    if (el) {
      return el;
    }
    // group outstanding requests
    if (pendingElements[selector]) {
      const [promise, _] = pendingElements[selector];
      delete pendingElements[selector];
      return promise;
    }
    let resolve;
    const promise = new Promise(r => resolve = r);
    pendingElements[selector] = [promise, resolve];
    if (!pendingElementsInterval) {
      pendingElementsInterval = setInterval(() => {
        for (const [_selector, [_, _resolve]] of Object.entries(pendingElements)) {
          const el = containerEl.querySelector(_selector);
          if (el) {
            _resolve(el);
            delete pendingElements[_selector];
          }
        }
        if (!Object.keys(pendingElements).length) {
          clearInterval(pendingElementsInterval);
          pendingElementsInterval = null;
        }
      }, elementReadyBatchedFrequency);
    }
    return pendingElements[selector][0];
}

edit: yes, I know this is very late in coming. Still <3 you.

@simonjoom
Copy link

Thanks @jwilson8767 for the last script,
it did work for me. also i m not sure about if it s more efficient than using mutationobserver..

Crazy hard to understand this script though, quite complicate.

i personaly removed the async and did a return Promise.resolve(el); for the first return.
also added a settimeout to test if the script run more than 4second to stop it in case it didnt find all matches.

@bezborodow Regarding performance issues, elementReady() should definitely be taken as a convenience function, I still use it occasionally but only where it is expected to run only a few times in the lifecycle of a page (such as to detect when a component's root element has been added to the page). MutationObservers as a whole aren't amazing performance-wise, really. For much better performance (while still working on arbitrary selectors), I'd actually recommend using a sort of long-polling strategy via setInterval. You could either create one setInterval per selector, or a shared setInterval and an array of outstanding selectors to match against. Here's an example of the latter:

// dict of {selector: [promise, resolve]}
const pendingElements = {};
let pendingElementsInterval;
const elementReadyBatchedFrequency = 100; // ms

/**
 * Wait for an element to be ready using a querySelector
 *
 * @param selector {string}
 * @param containerEl {Element} optional container element to search within
 * @returns {Promise<Element>}
 */
async function elementReadyBatched(selector, containerEl = document){
    let el = document.querySelector(selector);
    if (el) {
      return el;
    }
    // group outstanding requests
    if (pendingElements[selector]) {
      const [promise, _] = pendingElements[selector];
      delete pendingElements[selector];
      return promise;
    }
    let resolve;
    const promise = new Promise(r => resolve = r);
    pendingElements[selector] = [promise, resolve];
    if (!pendingElementsInterval) {
      pendingElementsInterval = setInterval(() => {
        for (const [_selector, [_, _resolve]] of Object.entries(pendingElements)) {
          const el = containerEl.querySelector(_selector);
          if (el) {
            _resolve(el);
            delete pendingElements[_selector];
          }
        }
        if (!Object.keys(pendingElements).length) {
          clearInterval(pendingElementsInterval);
          pendingElementsInterval = null;
        }
      }, elementReadyBatchedFrequency);
    }
    return pendingElements[selector][0];
}

edit: yes, I know this is very late in coming. Still <3 you.

@simonjoom
Copy link

Thanks @jwilson8767 for the last script,
it did work for me. also i m not sure about if it s more efficient than using mutationobserver..

Crazy hard to understand this script though, quite complicate.

i personaly removed the async and did a return Promise.resolve(el); for the first return.
also added a settimeout to test if the script run more than 4second to stop it in case it didnt find all matches.

@bezborodow Regarding performance issues, elementReady() should definitely be taken as a convenience function, I still use it occasionally but only where it is expected to run only a few times in the lifecycle of a page (such as to detect when a component's root element has been added to the page). MutationObservers as a whole aren't amazing performance-wise, really. For much better performance (while still working on arbitrary selectors), I'd actually recommend using a sort of long-polling strategy via setInterval. You could either create one setInterval per selector, or a shared setInterval and an array of outstanding selectors to match against. Here's an example of the latter:

// dict of {selector: [promise, resolve]}
const pendingElements = {};
let pendingElementsInterval;
const elementReadyBatchedFrequency = 100; // ms

/**
 * Wait for an element to be ready using a querySelector
 *
 * @param selector {string}
 * @param containerEl {Element} optional container element to search within
 * @returns {Promise<Element>}
 */
async function elementReadyBatched(selector, containerEl = document){
    let el = document.querySelector(selector);
    if (el) {
      return el;
    }
    // group outstanding requests
    if (pendingElements[selector]) {
      const [promise, _] = pendingElements[selector];
      delete pendingElements[selector];
      return promise;
    }
    let resolve;
    const promise = new Promise(r => resolve = r);
    pendingElements[selector] = [promise, resolve];
    if (!pendingElementsInterval) {
      pendingElementsInterval = setInterval(() => {
        for (const [_selector, [_, _resolve]] of Object.entries(pendingElements)) {
          const el = containerEl.querySelector(_selector);
          if (el) {
            _resolve(el);
            delete pendingElements[_selector];
          }
        }
        if (!Object.keys(pendingElements).length) {
          clearInterval(pendingElementsInterval);
          pendingElementsInterval = null;
        }
      }, elementReadyBatchedFrequency);
    }
    return pendingElements[selector][0];
}

edit: yes, I know this is very late in coming. Still <3 you.

@simonjoom
Copy link

Thanks @jwilson8767 for the last script,
it did work for me. also i m not sure about if it s more efficient than using mutationobserver..

Crazy hard to understand this script though, quite complicate.

i personaly removed the async and did a return Promise.resolve(el); for the first return.
also added a settimeout to test if the script run more than 4second to stop it in case it didnt find all matches.

@bezborodow Regarding performance issues, elementReady() should definitely be taken as a convenience function, I still use it occasionally but only where it is expected to run only a few times in the lifecycle of a page (such as to detect when a component's root element has been added to the page). MutationObservers as a whole aren't amazing performance-wise, really. For much better performance (while still working on arbitrary selectors), I'd actually recommend using a sort of long-polling strategy via setInterval. You could either create one setInterval per selector, or a shared setInterval and an array of outstanding selectors to match against. Here's an example of the latter:

// dict of {selector: [promise, resolve]}
const pendingElements = {};
let pendingElementsInterval;
const elementReadyBatchedFrequency = 100; // ms

/**
 * Wait for an element to be ready using a querySelector
 *
 * @param selector {string}
 * @param containerEl {Element} optional container element to search within
 * @returns {Promise<Element>}
 */
async function elementReadyBatched(selector, containerEl = document){
    let el = document.querySelector(selector);
    if (el) {
      return el;
    }
    // group outstanding requests
    if (pendingElements[selector]) {
      const [promise, _] = pendingElements[selector];
      delete pendingElements[selector];
      return promise;
    }
    let resolve;
    const promise = new Promise(r => resolve = r);
    pendingElements[selector] = [promise, resolve];
    if (!pendingElementsInterval) {
      pendingElementsInterval = setInterval(() => {
        for (const [_selector, [_, _resolve]] of Object.entries(pendingElements)) {
          const el = containerEl.querySelector(_selector);
          if (el) {
            _resolve(el);
            delete pendingElements[_selector];
          }
        }
        if (!Object.keys(pendingElements).length) {
          clearInterval(pendingElementsInterval);
          pendingElementsInterval = null;
        }
      }, elementReadyBatchedFrequency);
    }
    return pendingElements[selector][0];
}

edit: yes, I know this is very late in coming. Still <3 you.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment