javascriptrenderingweb-componentlifecyclefouc

How to render my web component before connectedCallback but after constructor?


I am making a custom web component using vanilla JavaScript and am having some issues with when the component should be rendered.

At first I had my render() and hydrate() calls inside the constructor of the component. This worked well when the component was already part of the DOM to begin with, however, if the component was created using document.createElement("my-button"), then the render() call would end up executing before I would have the chance to add attributes and child elements ect. which was a major problem.

The other alternative, which is what my code below shows, is it have the render() and hydrate() calls inside the connectedCallback() method. This fixes the document.createElement("my-button") problem, however, it introduces a new one. Because the connectedCallback() only executes after the element is added to the DOM, I could potentially get a FOUC (Flash of unstyled content) before the component is finished rendering. It would've been nice if there was a beforeConnectedCallback() so I can execute the code before it is added to the DOM, but this does not seem to exist.

So what should I do to get my component to automatically render and hydrate before it is added to the DOM?

Here is my component:

class MYButton extends HTMLElement {

    constructor() {
        super();
    }

    connectedCallback() {
        this.render();
        this.hydrate();
    }

    render() {
        let type = this.getAttribute("type");
        if (type === "link") {
            this.elmButton = document.createElement("a");
            this.elmButton.setAttribute("href", this.getAttribute("href"));
        } else {
            this.elmButton = document.createElement("button");
            this.elmButton.setAttribute("type", type);
        }
        while (this.firstChild) {
            this.elmButton.appendChild(this.firstChild);
        }
        this.appendChild(this.elmButton);
    }

    hydrate() {
        this.elmButton.addEventListener("click", () => alert("hello world"));
    }
}

customElements.define('my-button', MYButton);

Solution

  • +1 for your constructor conclusion, you can't add DOM there
    8 out of 10 developers get this wrong... because they define their components with async or type=module after all DOM is parsed

    -1 for your connectedCallback conclusion, it does NOT fire after DOM is created.

    The connectedCallback fires on the opening tag <my-button>
    lightDOM is not parsed yet.

    For long read see my blogpost:
    Web Component developers do not connect with the connectedCallback (yet)

    The challenge with your Web Component is, you both want to create new lightDOM (your button code) AND read existing lightDOM (your firstChild)
    Your create after read is perfectly fine, otherwise you would be endlessy adding that new button.

    But there will always be a FOUC/Layout Shift, since you can't have the cake and eat it...

    if you want that (unstyled) lightDOM you will have to wait for it.

    But you can have the Web Component be hidden and display it when all work is done,
    or add a fancy opacity transition yourself.

    Without the render and hydrate mumbo-jumbo, the Web Component is:

    const createElement = 
            (tag, props = {}) => Object.assign(document.createElement(tag), props);
    
    customElements.define("my-button", class extends HTMLElement {
      connectedCallback() {
        this.setAttribute("hidden", true);
        setTimeout(() => { // wait for lightDOM to be parsed
          let type = this.getAttribute("type");
          let [el, attr] = (type == "link") ? ["a", "href"] : ["button", "type"];
          let button = createElement(el, {
                [attr]: this.getAttribute(attr),
                onclick: (evt) => alert(`Web Component ${type||"button"} clicked`)
          });
          // .children will not include textNodes, all Nodes are *moved*
          button.append(...this.childNodes);
          // lightDOM is empty now
          this.append(button);
          this.removeAttribute("hidden");
        });
      }
    });
    <style>
      [type="link"] {
        display: inline-block;
        background: pink;
        padding: 1em;
      }
    </style>
    
    <my-button>
      I am the button Label
      <div style="font-size:60px">😃</div>
    </my-button>
    
    <my-button type="link">
      Another label
      <div style="font-size:40px">🔗</div>
    </my-button>