Search code examples
javascripthtmlshadow-domcustom-element

Iterate over HTMLCollection in custom element


How can I iterate over instances of one custom element within the shadow dom of another custom element? HTMLCollections don't seem to behave as expected. (I'm a jQuerian and a novice when it comes to vanilla js, so I'm sure I'm making an obvious error somewhere).

HTML

<spk-root>
  <spk-input></spk-input>
  <spk-input></spk-input>
</spk-root>

Custom Element Definitions

For spk-input:

class SpektacularInput extends HTMLElement {
  constructor() {
    super();
  }
}
window.customElements.define('spk-input', SpektacularInput);

For spk-root:

let template = document.createElement('template');
template.innerHTML = `
  <canvas id='spektacular'></canvas>
  <slot></slot>
`;

class SpektacularRoot extends HTMLElement {
  constructor() {
    super();
    let shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.appendChild(template.content.cloneNode(true));
  }
  update() {
    let inputs = this.getElementsByTagName('spk-input')
  }
  connectedCallback() {
    this.update();
  }
}
window.customElements.define('spk-root', SpektacularRoot);

Here's the part I don't understand. Inside the update() method: console.log(inputs) returns an HTMLCollection:

console.log(inputs)

// output
HTMLCollection []
  0: spk-input
  1: spk-input
  length: 2
  __proto__: HTMLCollection

However, the HTMLCollection is not iterable using a for loop, because it has no length.

console.log(inputs.length)

// output
0

Searching SO revealed that HTMLCollections are array-like, but not arrays. Trying to make it an array using Array.from(inputs) or the spread operator results in an empty array.

What's going on here? How can I iterate over the spk-input elements within spk-root from the update() method?

I'm using gulp-babel and gulp-concat and using Chrome. Let me know if more info is needed. Thanks in advance.


Edit: To clarify, calling console.log(inputs.length) from within the update() outputs 0 instead of 2.


Solution

  • The reason will be that connectedCallback() of a custom element in certain cases will be called as soon as the browser meets the opening tag of the custom element, with children not being parsed, and thus, unavailable. This does e.g. happen in Chrome if you define the elements up front and the browser then parses the HTML.

    That is why let inputs = this.getElementsByTagName('spk-input') in your update() method of the outer <spk-root> cannot find any elements. Don't let yourself be fooled by misleading console.log output there.

    I've just recently taken a deep dive into this topic, and suggested a solution using a HTMLBaseElement class:

    https://gist.github.com/franktopel/5d760330a936e32644660774ccba58a7

    Andrea Giammarchi (the author of document-register-element polyfill for custom elements in non-supporting browsers) has taken on that solution suggestion and created an npm package from it:

    https://github.com/WebReflection/html-parsed-element

    As long as you don't need dynamic creation of your custom elements, the easiest and most reliable fix is to create the upgrade scenario by putting your element defining scripts at the end of the body.

    If you're interested in the discussion on the topic (long read!):

    https://github.com/w3c/webcomponents/issues/551

    Here's the full gist:

    HTMLBaseElement class solving the problem of connectedCallback being called before children are parsed

    There is a huge practical problem with web components spec v1:

    In certain cases connectedCallback is being called when the element's child nodes are not yet available.

    This makes web components dysfunctional in those cases where they rely on their children for setup.

    See https://github.com/w3c/webcomponents/issues/551 for reference.

    To solve this, we have created a HTMLBaseElement class in our team which serves as the new class to extend autonomous custom elements from.

    HTMLBaseElement in turn inherits from HTMLElement (which autonomous custom elements must derive from at some point in their prototype chain).

    HTMLBaseElement adds two things:

    • a setup method that takes care of the correct timing (that is, makes sure child nodes are accessible) and then calls childrenAvailableCallback() on the component instance.
    • a parsed Boolean property which defaults to false and is meant to be set to true when the components initial setup is done. This is meant to serve as a guard to make sure e.g. child event listeners are never attached more than once.

    HTMLBaseElement

    class HTMLBaseElement extends HTMLElement {
      constructor(...args) {
        const self = super(...args)
        self.parsed = false // guard to make it easy to do certain stuff only once
        self.parentNodes = []
        return self
      }
    
      setup() {
        // collect the parentNodes
        let el = this;
        while (el.parentNode) {
          el = el.parentNode
          this.parentNodes.push(el)
        }
        // check if the parser has already passed the end tag of the component
        // in which case this element, or one of its parents, should have a nextSibling
        // if not (no whitespace at all between tags and no nextElementSiblings either)
        // resort to DOMContentLoaded or load having triggered
        if ([this, ...this.parentNodes].some(el=> el.nextSibling) || document.readyState !== 'loading') {
          this.childrenAvailableCallback();
        } else {
          this.mutationObserver = new MutationObserver(() => {
            if ([this, ...this.parentNodes].some(el=> el.nextSibling) || document.readyState !== 'loading') {
              this.childrenAvailableCallback()
              this.mutationObserver.disconnect()
            }
          });
    
          this.mutationObserver.observe(this, {childList: true});
        }
      }
    }
    

    Example component extending the above:

    class MyComponent extends HTMLBaseElement {
      constructor(...args) {
        const self = super(...args)
        return self
      }
    
      connectedCallback() {
        // when connectedCallback has fired, call super.setup()
        // which will determine when it is safe to call childrenAvailableCallback()
        super.setup()
      }
    
      childrenAvailableCallback() {
        // this is where you do your setup that relies on child access
        console.log(this.innerHTML)
        
        // when setup is done, make this information accessible to the element
        this.parsed = true
        // this is useful e.g. to only ever attach event listeners once
        // to child element nodes using this as a guard
      }
    }