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

Danny Engelman @dannyengelman

About: Online since 1990 Yes! I started with Gopher. I do modern Web Component Development with technologies supported by **all** WHATWG partners (Apple, Google, Microsoft & Mozilla)

Location:
Amsterdam, the Netherlands
Joined:
Oct 20, 2018

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

Publish Date: Jan 10 '24
9 16

Disclaimer: This blog post suggests using setTimeout


This post was originally written as StackOverflow answer in February 2022:

Ping backs:


When was DOM parsed?

Key is to understand when DOM was parsed

In the script below, everyone will agree the result tab will show:
because the first script executed before the remaining DOM was parsed.


the connectedCallback fires on the opening tag!

Then how do you think the DOM Parser handles this code?

  • component-1 is defined before DOM is parsed

  • component-2 is defined after DOM is parsed

Read the code below, then click the Result Tab

If you understand why the answer is:

You can stop reading here.



component-1 connectedCallback()

Because the connectedCallback fires on the opening tag! all following DOM (DIVs in lightDOM) is NOT parsed yet

That means all component-1 attributes (id in above code) are available ON the Web Component,

But NOT its three <div> child elements IN lightDOM.

Wait till lightDOM is parsed

Simplest method to get that lightDOM content is to delay execution till the Event Loop is empty again, and you know more (and most likely all) of your lightDOM was parsed.

With a setTimeout

Optional background knowledge:

Youtube: Jake Archibald on the web browser event loop, setTimeout, requestAnimationFrame


component-1 setTimeout executes after DOM is parsed (the DIVs in lightDOM)

Also note (in the above Results Tab) that component-1 wrote its output after component-2, because of the setTimeout in component-1



BUT!

Because the Event Loop can be(come) empty when the (large) DOM is still being parsed!

This gets you the next N elements parsed,

not ALL elements in a large lightDOM!

Rough tests show around N=1000 (1000 lightDOM elements) are safe to work with.

but your mileage may vary for complex CPU consuming elements

Maybe just increase to 10 milliseconds setTimeout delay

Sidenote: Should a Web Component with a 1000 Childnodes really be one Web Component?


requestAnimationFrame (rAF)

requestAnimationFrame can also be used. Read!:
https://stackoverflow.com/questions/71523029/settimeout-vs-requestanimationframe

rAF fires before OR after setTimeout. Sometimes even after the DOMContentLoaded Event

In my tests setTimeout could always access that lightDOM.

Do watch Jakes video before using rAF!
https://www.youtube.com/watch?v=cCOL7MC4Pl0


Potential pitfall: the attributeChangedCallback

!!! The attributedChangedCallback fires BEFORE the connectedCallback for every attribute defined as an observed attribute in static get observedAttributes() which is declared as initial attribute on your Custom Element.

If none of those Observed attributes exist on the Element in the DOM, attributeChangedCallback will not execute.


setTimeout gets you the next N elements

N can be scary for a developer only used to digital 0 and 1 states

If you can't deal with N


get ALL children - parsedCallback()

For getting all Child nodes, there is parsedCallback() by WebReflection.

But LOC (Lines Of Code) now goes from 1 to 77 :

https://github.com/WebReflection/html-parsed-element/blob/master/index.js

Maybe good to add this to your own BaseClass.
But for small components you are adding more overhead than a setTimeout or rAF takes.


Lifecycle methods in Lit, Stencil, FAST, Hybirds and 61 other tools

Almost all Tools add their own parsedCallback like lifecycle methods:

Saving unexperienced developers headaches

Biggest drawback; you learn a Tool, not the Technology.

And your code now executes (way) later than that setTimeout, so more (potential) FOUCs and Layout Shifts to deal with.


What the experts said

Experts discussion has been going on since 2016

That is nearly a decade now

The issue is obviously not that big it needs a solution

Not that everyone is aware of the issue...


Old Mozilla/FireFox bug

Closed bug report: https://bugzilla.mozilla.org/show_bug.cgi?id=1673811

Up until Spring 2021 there where issues with connectedCallback in FireFox always firing late, so all above mentioned issues never happened in FireFox... but do now.


Escaping all issues

9 out 10 devs will not understand why they fixed the "bug"

When Web Components are defined AFTER DOM was created you don't have any of these connectedCallback issues; because all DOM was parsed

So a <script defer src="yourelement.js"> does the job; but will run after all DOM is created,

your components are now created (very) late. So you now have to deal with (more) FOUCs.

This also applies to <script type="module"> and other ways of importing modules.

Your script will most likely be executed after DOM was parsed.

Best advice is to just make your Web Components work for the before scenario.


Online IDEs

CodePen, JSFiddle and all those online IDEs run the JavaScript AFTER the DOM is created!

So you never experience any issues there.

Test your code outside of these online IDEs before you take it to production!



Conclusion

the connectedCallback fires on the opening tag!

I use setTimeout in Web Components where I really need that lightDOM

Never had an issue, since I started with Web Components in 2017

There are plenty of other workarounds using readystatechange, MutationObserver, Promisses possible.
Use whatever you think suits you best.

It is all about when DOM is parsed!!!









Took me 3 hours to format this blog-post. No need to buy me coffee, or send me money. I am happy enough if you just send some positive karma into this f*ing world.





Comments 16 total

  • 𒎏Wii 🏳️‍⚧️
    𒎏Wii 🏳️‍⚧️Jan 11, 2024

    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.

    • АнонимJan 11, 2024

      [deleted]

      • 𒎏Wii 🏳️‍⚧️
        𒎏Wii 🏳️‍⚧️Jan 11, 2024

        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.

        • Danny Engelman
          Danny EngelmanJan 11, 2024

          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.

          • 𒎏Wii 🏳️‍⚧️
            𒎏Wii 🏳️‍⚧️Jan 11, 2024

            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.

            • Danny Engelman
              Danny EngelmanJan 11, 2024

              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.

              • 𒎏Wii 🏳️‍⚧️
                𒎏Wii 🏳️‍⚧️Jan 12, 2024

                I think this conversation is going in circles.

                To reiterate:

                • A simple readystatechange event listener can fix the problem where custom elements appear in the HTML sent from the server
                • If you insert them using JavaScript, you can populate them before insertion
                • If you need to insert elements after connecting to the DOM, you need the sledge hammer

                Most of your post makes perfect sense, but towards the end you start to consider a weird case that I don't exactly get where you insert a custom element from client-side code, but you talk about it like DOM parsing is still a factor here, which I don't understand how that would be the case.

                Going back to the code in my original comment:

                connectedCallback() {
                   if (document.readyState === "loading") {
                      return document.addEventListener("readystatechange", this.connectedCallback, {once: true})
                   }
                }
                
                Enter fullscreen mode Exit fullscreen mode

                and the example you provided

                const body = document.body;
                body.addEventListener("click", (evt) => {
                   body.append( document.createElement("my-cool-component") );
                }
                
                Enter fullscreen mode Exit fullscreen mode

                Maybe you could explain what would have to happen for this to fail? There's no child-elements being added to the component in your example, so I don't see where the problem is supposed to come from.

                • Danny Engelman
                  Danny EngelmanJan 12, 2024

                  Yes, that is a a wrong answer in my comment, I re-read the blog post; looks fine to me.

                  I added a better reply to your readystatechange .

                  Full working (and readystatechange failing) JSFiddle is: jsfiddle.net/WebComponents/d9sbzcex/

                  Note: As I said in the blog, WebReflection wrote a parsedCallback that fires when all child Nodes are available.
                  github.com/WebReflection/html-pars...
                  It uses the document readyState, MutationObserver and lost more MJ... and is 77 lines of code

                  The one liner setTimeout will get you the next N Child Nodes

                  N can be scary for developers who only believe in digital 0 and 1 values, and can't deal with quarks or Schrodingers cat when programming.

    • Danny Engelman
      Danny EngelmanJan 12, 2024

      readystatechage will not work

      Here is a full working JSFiddle: jsfiddle.net/WebComponents/d9sbzcex/

      Extract of your solution:

          customElements.define("your-component", class extends BaseClass {
              connectedCallback() {
                  LOG("Init", this); // Empty innerHTML
                  if (document.readyState == "loading") {
                      document.addEventListener("readystatechange", () => {
                          LOG("Access", this); // Hello World!
                      }, {
                          once: true
                      })
                  }
              }
          });
      
      Enter fullscreen mode Exit fullscreen mode

      Issues

      • (minor) You can not call this.connectedCallback in the handler, it will have the wrong lexical scope. so a .bind(this) is required

      • But you can't just run connectedCallback again because it would run any other (initialization) code again also So I changed your code as above

      Code now runs fine

      Although your workaround runs "late" after the readystatechange Event

      But now another issue occurs

      Because your code relies on an Event that happens only once, that code will never fire again.

      So if you move DOM around, or append DOM, that Event will never fire.

      Your component never did the "Access" again

      • 𒎏Wii 🏳️‍⚧️
        𒎏Wii 🏳️‍⚧️Jan 12, 2024

        That isn't my work-around though. Here's what that should look like:

        customElements.define("your-component", class extends BaseClass {
            connectedCallback() {
                if (document.readyState === "loading") {
                    return document.addEventListener("readystatechange", this.connectedCallback.bind(this), {once: true})
                }
                LOG("Access", this); // Hello World!
            }
        });
        
        Enter fullscreen mode Exit fullscreen mode

        When the component is loaded before the DOM is fully loaded, it will defer its connectedCallback to the next readystatechange event by attaching an event listener and returning early, but when the DOM has already loaded, it will jump over the early return and do its initialisation as usual.

        This works in all three cases:

        • When the component is defined before the DOM finishes loading (via the event handler)
        • When the component is defined after the DOM finishes loading (skipping the early return)
        • When the component is inserted via javascript (again, skipping the early return)
  • Alain D'Ettorre
    Alain D'EttorreJan 27, 2024

    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.

  • Christopher Cardea
    Christopher CardeaNov 16, 2024

    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
    • Danny Engelman
      Danny EngelmanNov 17, 2024

      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.

      • Christopher Cardea
        Christopher CardeaNov 18, 2024

        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.

      • Christopher Cardea
        Christopher CardeaNov 19, 2024

        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.

        • Danny Engelman
          Danny EngelmanNov 21, 2024

          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?

Add comment