textContent empty in connectedCallback() of a custom HTMLElement
Asked Answered
M

4

7

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...

Monster answered 29/1, 2018 at 10:0 Comment(1)
github.com/w3c/webcomponents/issues/551Kayo
K
14

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.

Kayo answered 19/10, 2018 at 0:36 Comment(1)
Would checking the document.readyState === "complete", and calling back the components work? I am going to try as this stack/observe bookkeeping seems to be heavy.Hodges
D
8

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>
Distraught answered 24/1, 2019 at 22:0 Comment(1)
This doesn't work when children are custom elements that use shadow dom too. The nodes would be empty in the callback.Rhettrhetta
M
2

I managed to work around this by only calling customElements.define('my-component', MyComponent); after the DOMContentLoaded event had fired.

document.addEventListener('DOMContentLoaded', function() {
    customElements.define('my-component', MyComponent);   
}

This behaviour seems a little odd as you'd expect that connectedCallback would only fire once the node has been inserted into the DOM and is fully ready to be manipulated.

Monster answered 2/2, 2018 at 10:39 Comment(1)
This is a (bad) workaround, the other answers are solutionsDaisy
C
0

The issue is actually way simpler than it seems. When the constructor of custom elements is called, the document is not fully parsed yet.

Why you should avoid using DOMContentLoaded for custom elements: Eventhandlers are executed in the order they are registered. Meaning, that other developers might run into un-initialized components, if they register their eventhandler to early.

The solution is actually very simple: Add the "defer" attribute to your script tag.

Quote: This Boolean attribute is set to indicate to a browser that the script is meant to be executed after the document has been parsed, but before firing DOMContentLoaded.

<script src="/my-component.js" defer>

Unfortunately defer does not work on modules. In that case you might have to actually use DOMContentLoaded

Claycomb answered 24/5, 2023 at 8:9 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.