javascripthtmlrenderingweb-componentlifecycle

Web Components custom parsedCallback method is not working


Important NOTE: almost all IDEs will defer the execution of javascript automatically which means you might not be able to replicate this issue using them

When working with the Web Components API in a scenario where the script defining the component executes before the documents HTML is parsed, you end up with the unfortunate issue that the element's innerHTML will not yet be parsed during the native connectedCallback lifecycle method. This is because the method executes on the opening tag of the custom element before parsing of its children has begun.

<script>
  customElements.define('my-element', MYElement);
</script>
<my-element>
  <span>Content</span>
</my-element>

In order to fix this, I wish to implement my own parsedCallback method, however, what I have so far is not working.

The idea is that because the browser will parse the HTML synchronously, we can know that everything inside the component is done parsing as soon a "younger" sibling element is parsed.

Therefore, I am adding a mutation observer to the parent element of the component in order to listen to when a new child node, which is not the component itself, has been added.

Furthermore, because it could be there are no "natural" siblings in the markup, I also append a commentNode after the component to ensure it always works.

class MYElement extends HTMLElement {

    static counter = 0;

    constructor() {
        super();
        this._id = MYElement.counter++;
    }

    connectedCallback() {
        console.log(`${this._id} connected`);
        console.log(`${this._id} has innerHTML on connected = ${this.innerHTML !== ""}`);
        const parent = this.parentElement;
        const marker = document.createComment(this._id);
        const observer = new MutationObserver((mutations) => {
            mutations.forEach(mutation => {
                mutation.addedNodes.forEach(node => {
                    if (node !== this) {
                        console.log(`${this._id} younger sibling detected`);
                        this.parsedCallback();
                        marker.remove();
                        observer.disconnect();
                    }
                });
            });
        });
        observer.observe(parent, { childList: true });
        parent.appendChild(marker);
    }

    parsedCallback() {
        console.log(`${this._id} parsed`);
        console.log(`${this._id} has innerHTML on parsed = ${this.innerHTML !== ""}`);
    }
}

My expected output would of course be

0 connected
0 has innerHTML on connected = false
0 younger sibling detected
0 parsed
0 has innerHTML on parsed = true

However, instead I am seeing the same except for the last console log being:

0 has innerHTML on parsed = false

Solution

  • I wrote a (very) long Dev.to blogpost about this connectedCallback behavior:
    Web Component developers do not connect with the connectedCallback (yet)

    Given your wording, I have an inkling you already read that blogpost.... where I mention MutationObserver but don't show any code. (and don't here)

    Andrea his solution (77 Lines of Code) I refer to in the blogpost, does use MutationObserver


    But the simplest "solution" is:

    connectedCallback() {
      setTimeout(() => parsedCallback())
    }
    

    workes for me since 2017

    There is a snag, doesn't work if your Web Component lightDOM has too many DOM nodes, when the setTimeout still fires before all of lightDOM was parsed.
    But should such a lightDOM loaded beast (over 100s of DOM nodes) be one Web Component then????

    One more alternative solution not in the blog (there is too much info in there already)
    Is:

    <my-component>
    
      Muchos HTMLos
    
      <img src="" onerror="this.getRootNode().host.parsedCallback()">
    </my-component>
    

    But that only works for Web Components with shadowRoot

    Maybe (I stopped exploring because even for me this is too hacky) onerror="this.parentNode.parsedCallback()" otherwise

    You could use your <marker> trick with this <img src="" **onerror**="..."> trick

    But I wouldn't like it, because you are now adding content to my DOM (not in lightDOM or shadowDOM); what if you now add something to my display:grid, causing re-flows, or potentially breaking my code???

    I have explored many other options, still sticking with setTimeout
    Only once had to add a 100ms

    connectedCallback() {
      setTimeout(() => parsedCallback(),100)
    }
    

    But that was because a recursive .append() in combo with DocumentFragment adds DOM asynchronously
    Never explored how Lit, Stencil, Shoelace and friends would have handled that.

    Addendum

    Here is a decent playground: https://jsfiddle.net/WebComponents/ovfk7pbc/

    <script>
      class MYHTMLElement extends HTMLElement {
        //constructor() {
        //super() // a single super() calls its parent by default
        //}
        connectedCallback() {
          this.append(
            " ", this.parentNode.nodeName, 
            " > ",this.nodeName, 
            " id:", this.id || "No ID!",
            " : ",this.innerHTML.length ? this.innerHTML.length + " chars" : "innerHTML empty! because lightDOM isn't parsed yet",
            document.createElement("hr")
            )
        }
      }
      customElements.define("element-1", class extends MYHTMLElement {})
    </script>
    
    <element-1 id="ONE">lightDOM</element-1>
    <element-2 id="TWO">lightDOM</element-2>
    <element-3 id="THREE">lightDOM</element-3>
    
    <script>
      customElements.define("element-2", class extends MYHTMLElement {})
      customElements.define("element-3", class extends MYHTMLElement {})
      TWO.innerHTML = `ADDED HTML `
      const TEST1 = document.createElement("element-1")
      const TEST2 = document.createElement("element-1")
      TEST2.id = "FOUR";
      TEST2.innerHTML = `<element-2>Nested element</element-2>`
      document.body.append(TEST1, TEST2) // append is new, not available in Internet Explorer
    </script>