Search code examples
javascripthtmlcustom-element

How to define all custom elements before any constructors are called?


I have two custom elements: One and Two. One has a function. Two has a child of One and tries to call that function in its constructor. It says that the function does not exist if I call customElements.define() on Two before One. However, if I define One before Two, it works just fine.

In my actual project, I do not have control of the order in which they are defined, and by default they are being defined in the wrong order.

I tried calling the function in the connectedCallback(), but this also failed.

When exactly is the constructor called?

Is there any way that I can make sure they are all defined before any constructors are called?

class One extends HTMLElement {
  constructor() {
    super()
    console.log('one constructor')
  }
  
  myFunc() {
    console.log('it worked!')
  }
}

class Two extends HTMLElement {
  constructor() {
    super()
    console.log('two constructor')
    
    this.innerHTML = '<my-one></my-one>'
    this.myOne = document.querySelector('my-one')
    
    // this part fails
    this.myOne.myFunc()
  }
  
  connectedCallback() {
    // this also fails
    this.myOne.myFunc()
  }
}

// this works
// customElements.define("my-one", One)
// customElements.define("my-two", Two)

// this breaks
customElements.define("my-two", Two)
customElements.define("my-one", One)
<my-two></my-two>


Solution

  • It is all about the life-cycle of a Web Component and when a tag is upgraded from an HTMLUnknownElement to your actual Component

    A component is defined in two steps.

    1) Definition of a class 2) Calling customElements.define

    Yes these two can be written together:

    customElements.define('dog-food', class extends HTMLElement{});
    

    But the class definition still happens before customElements.define is called.

    Elements are only upgraded to a custom element when two things have happened:

    1) The Custom Element must be defined by using customElements.define and 2) The Custom Element must either 1) be instantiated using document.createElement or new MyElement 2) be added to the DOM tree

    This example has the element placed into the DOM but it is not defined for 1 second.

    I display the constructor before it is defined and then, again, after it is defined.

    class One extends HTMLElement {
      constructor() {
        super();
        console.log('one constructor');
      }
    
      connectedCallback() {
        this.innerHTML = "I have upgraded";
      }
    }
    
    let el = document.querySelector('my-one');
    
    setTimeout(() => {
      customElements.define("my-one", One);
      console.log('after: ', el.constructor.toString().substr(0,30));
    }, 1000);
    
    console.log('before: ', el.constructor);
      
    <my-one>I am just a simple element</my-one>

    In your code you use innerHTML to "load" <my-one>. But since <my-two> may not "really" be in the DOM by the time the constructor is called then the innerHTML will not be in the DOM and, thus, <my-one> will not get upgraded yet.

    One thing you can do is to wait until the <my-two> component is really placed into the DOM by waiting to change the innerHTML in the connectedCallback function:

    class One extends HTMLElement {
      constructor() {
        super();
        console.log('one constructor');
      }
      
      myFunc() {
        console.log('it worked!');
      }
    }
    
    class Two extends HTMLElement {
      constructor() {
        super();
        console.log('two constructor');
      }
      
      connectedCallback() {
        this.innerHTML = '<my-one></my-one>';
        setTimeout(() => {
          this.myOne = document.querySelector('my-one');
          this.myOne.myFunc();
        });
      }
    }
    
    customElements.define("my-two", Two)
    customElements.define("my-one", One)
    <my-two></my-two>

    You will notice that I had to still place the call to the function in <my-one> into a setTimeout call. This is because the <my-one> element can not be upgraded until AFTER your connectedCallback function exists. Upgrading needs a chance to run and it will not run in the middle of your function.

    Another way to do it is by calling the constructor for <my-one> directly:

    class One extends HTMLElement {
      constructor() {
        super();
        console.log('one constructor');
      }
    
      connectedCallback() {
        this.innerHTML = "one";
      }
    
      myFunc() {
        console.log('it worked!');
      }
    }
    
    class Two extends HTMLElement {
      constructor() {
        super();
        console.log('two constructor');
      }
      
      connectedCallback() {
        customElements.whenDefined('my-one').then(() => {
          this.myOne = document.createElement('my-one');
          this.append(this.myOne);
          this.myOne.myFunc();
        });
      }
    }
    
    customElements.define("my-two", Two);
    customElements.define("my-one", One);
    <my-two></my-two>

    Here you will notice that I had to add a call to customElements.whenDefined. This will wait until <my-one> to actually be defined before it attempts to instantiate it. Once it is defined then you can create it and call the member function right away.

    One last thing. There are rules for what you should and should not do while in a constructor for a Web Component. They are defined here https://w3c.github.io/webcomponents/spec/custom/#custom-element-conformance

    But one thing I will point out is that you are not supposed to touch or change any attributes or child elements in the constructor. Mainly because there are no attribute or any child elements when the Web Component is constructed. Those are changed and added after the fact.