Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

textContent empty in connectedCallback() of a custom HTMLElement

Within the connectedCallback() method of my custom element the textContent is returned as an empty string.

Essentially my code boils down to the following...

class MyComponent extends HTMLElement{
    constructor() {
        super()

        console.log(this.textContent) // not available here, but understandable
    }           

    connectedCallback() {
        super.connectedCallback() // makes no difference if present or not

        console.log(this.textContent) // not available here either, but why?!
    }
}

customElements.define('my-component', MyComponent);     

And the HTML...

<my-component>This is the content I need to access</my-component>

From reading about connectedCallback() it sounds like it's called once the element has been added to the DOM so I would expect that the textContent property should be valid.

I'm using Chrome 63 if it helps...

like image 441
Jonathan Williamson Avatar asked Jan 29 '18 10:01

Jonathan Williamson


2 Answers

The issue you're facing is essentially the same our team has run into in our current project:

connectedCallback in Chrome does not guarantee children are parsed. Specifically, relying on children works in the upgrade case, but does not work if the element is known upfront when the browser parses it. So if you place your webcomponents.js bundle at the end of the body, it at least reliably works for the static document you have up until then (but will still fail if you create the element programmatically after DOMContentLoaded using document.write(which you shouldn't anyway)). This is basically what you have posted as your solution.

To make matters worse, there is no lifecycle hook that does guarantee child element access in Custom Elements spec v1.

So if your custom element relies on children to setup (and a simple textNode like your textContent is a child node), this is what we were able to extract after a week of excessive research and testing (which is what the Google AMP team does as well):

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});
    }
  }
}

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 to child
    // elements once using this as a guard
  }
}

customElements.define('my-component', MyComponent)
<my-component>textNode here</my-component>

Update: Already quite a while ago Andrea Giammarchi (@webreflection), the author of the custom elements polyfill document-register-element (which e.g. is being used by Google AMP), who is a strong advocate of introducing such a parsedCallback to the custom elements' API, has taken the above code and created a package html-parsed-element from it, which might help you:

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

You simply derive your elements from the HTMLParsedElement base class that package provides (instead of HTMLElement). That base class, in turn, inherits from HTMLElement.

like image 67
connexo Avatar answered Oct 30 '22 04:10

connexo


You can access the content using a slot and the slotchange event (the slot gets the host tag content.)

(function(){
    
    class MyComponent extends HTMLElement {
        
        constructor() {
            super();
            
            let slot = document.createElement('slot') ;

            slot.addEventListener('slotchange', function(e) {
                let nodes = slot.assignedNodes();
                console.log('host text: ',nodes[0].nodeValue);                               
            });
  
            const shadowRoot = this.attachShadow({mode: 'open'});
            shadowRoot.appendChild(slot);     
        }
        
    }
            
    window.customElements.define('my-component', MyComponent);
})();
<my-component>This is the content I need to access</my-component>
like image 40
Bob Avatar answered Oct 30 '22 03:10

Bob