DEV Community

Cover image for Web Component developers do not connect with the connectedCallback (yet)

Web Component developers do not connect with the connectedCallback (yet)

Danny Engelman on January 10, 2024

Disclaimer: This blog post suggests using setTimeout This post was originally written as StackOverflow answer in February 2022: WTF? this.i...
Collapse
 
alaindet profile image
Alain D'Ettorre • Edited

So, the best thing to do is just putting all the JavaScript code on the bottom of the body tag, as always, let the browser create a myriad of UnknownHTMLElement instances, the let it upgrade all instances when customElement.define() triggers. That opens the door to inability to interact and FOUC (en.wikipedia.org/wiki/Flash_of_uns...) for me, and yet it seems to be the best approach.

When I first started to learn about web components, I already knew Angular so I thought "ok connectedCallback() is like ngOnInit()" and actually it is, because if you need to read the parsed children you need to call ngAfterViewInit() and ngAfterContentInit() which are Angular-specific methods to interact with inner HTML, not ngOnInit(). Still, connectedCallback() seemed to be used by other developers more like "run this when the DOM is ready to be queried" more than "run this when the component is attached to the DOM, but maybe it's not ready yet". I guess it's just a little confusing all around and frameworks do a much better job than "standards" in giving simple flows and guarantees for me.

React is different, because a useEffect() without dependencies actually triggers after any first render (I'd say almost like a setTimeout()) and it actually works as intended.

Collapse
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️ • Edited

Wouldn't a much simpler "readystatechange" event listener get you like 98% of the way there?

Like, I imagine something like (untested):

connectedCallback() {
   if (document.readyState === "loading") {
      return document.addEventListener("readystatechange", event => this.connectedCallback())
   }

   console.log("Initialising component:", this)
}
Enter fullscreen mode Exit fullscreen mode

Edit: As I am working on a custom element right now, I decided to try this, and noticed two things:

  • Wrapping the method in a function seems to not be necessary
  • The event listener should be set to run only once
connectedCallback() {
   if (document.readyState === "loading") {
      return document.addEventListener("readystatechange", this.connectedCallback, {once: true})
   }

   console.log("Initialising component:", this)
}
Enter fullscreen mode Exit fullscreen mode

EDIT: Added a console.log to both examples to make it clearer where the actual initialisation code goes.

Collapse
 
Sloan, the sloth mascot
Comment deleted
Collapse
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️

Depends how you do it, I guess?

I assume you're mostly considering using innerHTML here, which I tend to stay away from whenever possible, so to me that falls in the remaining 2%.

Building a custom element in JS (including children) and then inserting it into the page will run the callback on a populated component. If you want to add elements afterwards, then you probably need a MutationObserver anyway, because you'll likely want to continue monitoring for changes for the entirety of the components life-cycle, not just for some initial "setup" phase where you continue adding children manually.

Thread Thread
 
dannyengelman profile image
Danny Engelman

It has nothing to do with innerHTML It doesn't matter how you add DOM to your existing page, readystatechange will never fire again after the first pageload.

Thread Thread
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️

I don't think I understand what requirements you have towards your custom elements then.

I see two settings:

a) A custom element should consider its child elements when it is created and do some stuff with those, but can afterwards become inert because the contents of the element are static at that point.

b) The component should respond to insertions and deletions throughout its entire lifetime and update its internal state accordingly on every change.

In the first case, the site load is the only problematic part, because the element gets inserted into the DOM before its children have been parsed. When creating the element from javascript, the child elements are already there by the time the element is connected, so the connectedCallback will work just fine.

In the latter case, inserting items initially is really just a not-so-special case of inserting items at any point during the object's lifecycle, so no special code is required here.

The dynamic case can usually be achieved relatively easily with a generic MutationObserver that dispatches an event or calls a method on its target.

Thread Thread
 
dannyengelman profile image
Danny Engelman • Edited

There is no requirement, there is a fact.

This blog is (an attempt) to explain why there is no innerHTML when the Web Component is defined before the DOM is parsed

Almost all Web Components posted on X in the last month, fall into this trap.. and don't work in 100% of cases.

<script>
  customElements.define("my-component", class extends HTMLElement {
    connectedCallback() {
        console.log(this.innerHTML); // empty string!
    }
  });
</script>

<my-component>Hello World</my-component>
Enter fullscreen mode Exit fullscreen mode

Yes, you can solve part of the issue with a MutationObserver; that is like taking a sledgehammer to drive in a nail.

Collapse
 
ccardea profile image
Christopher Cardea • Edited

I've spent a lot of time over the past few days researching the problem of how to access the children of a shadow root in a custom element, including the discussions linked in the post and the HTML standard. This blog post is the only thing that pointed me toward a solution, so thank you for that. I tried the readystatechange approach but it didn't work in my application. Even when readystate was complete, the children still were not available. I was reluctant to use setTimeout(), because there's no way to know how long the timeout needs to be. I chose setInterval() instead, like so

async childrenReady(){
        return new Promise((resolve, reject) => {
            const intervalId = setInterval(() => {
                if (this.shadowRoot.children.length > 0) {
                    clearInterval(intervalId);
                    resolve(true);
                }
            }, 2);
        });
    }
Enter fullscreen mode Exit fullscreen mode
Collapse
 
dannyengelman profile image
Danny Engelman

Can you create a JSFiddle where setInterval fires more than once? The only difference with setTimeout is that setInterval runs again.. most likely it uses the same timeout under the hood.

Collapse
 
ccardea profile image
Christopher Cardea • Edited

Ok, so I decided to show more of my code. In the childrenReady method, I added a counter and set the interval to 1 millisecond.

class BHBase extends HTMLElement {
    worker;
    constructor() {
        super()
        const shadowRoot = this.attachShadow({mode: "open"});
        this.worker = new SharedWorker('workers/get-template.js');
    }
    async childrenReady(){
        return new Promise((resolve, reject) => {
            let count = 0;
            const intervalId = setInterval(() => {
                if (this.shadowRoot.children.length > 0) {
                    clearInterval(intervalId);
                    console.log(`setInterval fired ${count} times`)
                    resolve(true);
                } else {
                    count++
                }
            }, 1);
        });
    }

}
Enter fullscreen mode Exit fullscreen mode

After navigating back and forth a few times, this is the result from the console.

setInterval fired 6 times
2base.js:14 setInterval fired 4 times
Enter fullscreen mode Exit fullscreen mode

The main page of this app includes seven components, three of which use the childrenReady method. The console says that 1 component fired 6 times and the other two each fired 4 times before the children were ready.

Thread Thread
 
dannyengelman profile image
Danny Engelman

How is childrenReady triggered? Immediatly from the connectedCallback?
I have a JSFiddle test with all methods and would like to add your code to it.

this.shadowRoot.children.length > 0 is not the same as this.children.length > 0
Which (I presume) can trigger when a large DOM is still being parsed, so it doesn't signal all childrenReady, but some childrenReady?

Collapse
 
ccardea profile image
Christopher Cardea

That's a big ask for me right now. I'll see what I can do. I can tell you that I had to setTimeout to 10 milliseconds in my code before it would work. There's no guarantee that every instance is going to take the same amount of time, especially if it's running on different machines and different browsers. If the children aren't ready when the timeout ends, your code fails. You can't really take that risk. setInterval solves that problem. I hadn't thought of putting in a counter to see how many times it fires.