skip to Main Content

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);

2

Answers


  1. +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>
    • You do not need that setTimeout when you stuff all data in attributes instead of lightDOM.
      The connectedCallback fires on the opening tag… so can access all attributes

    • ShadowDOM and <slot> might give a better UI experience, but requires more code.

    • CSS :not(:defined) can help, but you are now relying on dependencies outside the Web Component

    • you could also do this.replaceWith(button) at the end, if your Web Component was only about creating that link/button
      Makes for some cleaner HTML

    • If you really want to execute after the constructor and before the connectedCallback you can ABUSE the attributeChangedCallback because that will fire for every declared ObservedAttribute before the connectedCallback fires on the <my-button> opening tag.
      BUT! The lightDOM inside your <my-button> still was not parsed yet.

    • There are "gurus" out there that tell you to "solve" this with async or type="import"
      They have no clue what is actually happening…

    • The other answer doesn’t work always. You can’t access attributes in the constructor when the DOM wasn’t parsed.
      Here is an MVP showing the fault in the other answer: https://jsfiddle.net/WebComponents/970xbetz/

    Login or Signup to reply.
  2. What the OP is looking for … quoting the OP …

    It would’ve been nice if there was a beforeConnectedCallback

    From my above comment …

    @OscarR … I’m with Bergi the attribute changed lifecycle callback in combination with the connectedCallback gives one everything one does need to know. Since your component features a sole type attribute you can build upon this approach.

    function handleBeforeConnected(/* argument/s, */evt) {
      const { type, target } = evt;
    
      console.log(
        'handleBeforeConnected ...', { type, target, attrNames: target.getAttributeNames() }
      );
    }
    
    class MyComponent extends HTMLElement {
      static observedAttributes = ['type', 'trait'];
    
      #expectedInitialChangeCount;
      #initialAttrChangeCount = 0;
      #isInitialized = false;
    
      constructor() {
        super();
    
        this.addEventListener(
          'beforeconnected', handleBeforeConnected/*.bind( <null|this>, <args array> )*/
        );
        const observedSet = new Set(MyComponent.observedAttributes);
        const attrNameSet = new Set(this.getAttributeNames());
    
        // console.log([...attrNameSet.intersection(observedSet)]);
    
        this.#expectedInitialChangeCount = [...attrNameSet.intersection(observedSet)].length;
        this.#isInitialized = this.#initialAttrChangeCount >= this.#expectedInitialChangeCount;
    
        if (this.#isInitialized) {
          this.dispatchEvent(
            new /*Custom*/Event('beforeconnected'/*, { detail: {} } */)
          );
        }
      }
    
      connectedCallback() {
        console.log('connectedCallback ...', { target: this });
      }
      attributeChangedCallback(/*attrName, recentValue, currentValue*/) {
        if (!this.#isInitialized) {
          if (++this.#initialAttrChangeCount >= this.#expectedInitialChangeCount) {
    
            this.#isInitialized = true;
    
            this.dispatchEvent(
              new /*Custom*/Event('beforeconnected'/*, { detail: {} } */)
            );
          }
        }/* else { ... } */
      }
    }
    customElements.define('my-component', MyComponent);
    body {
      margin: 0;
      width: 32%;
    }
    my-component {
      display: inline-block;
      padding: 16px;
      margin: 8px 4px;
      background: #eee;
      border-radius: 16px;
    }
    .as-console-wrapper {
      left: auto!important;
      width: 67%;
      min-height: 100%;
    }
    <my-component type="button" trait="triggers" foo="bar">triggering button</my-component>
    <my-component type="link" bar="baz">basic link</my-component>
    <my-component biz="buzz">unspecified component</my-component>

    Within a next iteration step one could provide an abstraction layer (e.g. an own ConnectedTarget class) which not only encapsulates all the code related to a proper beforeconnected handling, but which likewise incorporates the connected handling.

    The implementation of the OP’s MyComponent class, then extending ConnectedTarget, will be much more lightweight as shown with the following example …

    function handleBeforeConnected(/* argument/s, */evt) {
      const { type, target } = evt;
    
      console.log(
        'handleBeforeConnected ...', { type, target, attrNames: target.getAttributeNames() }
      );
    }
    function handleConnected(/* argument/s, */evt) {
      const { type, target } = evt;
    
      console.log('handleConnected ...', { type, target });
    }
    
    class MyComponent extends ConnectedTarget {
      static observedAttributes = ['type', 'trait'];
    
      constructor() {
        super();
    
        this.addEventListener(
          'beforeconnected', handleBeforeConnected/*.bind( <null|this>, <args array> )*/
        );
        this.addEventListener(
          'connected', handleConnected/*.bind( <null|this>, <args array> )*/
        );
      }
    }
    customElements.define('my-component', MyComponent);
    body {
      margin: 0;
      width: 32%;
    }
    my-component {
      display: inline-block;
      padding: 16px;
      margin: 8px 4px;
      background: #eee;
      border-radius: 16px;
    }
    .as-console-wrapper {
      left: auto!important;
      width: 67%;
      min-height: 100%;
    }
    <my-component type="button" trait="triggers" foo="bar">triggering button</my-component>
    <my-component type="link" bar="baz">basic link</my-component>
    <my-component biz="buzz">unspecified component</my-component>
    
    <script>
    class ConnectedTarget extends HTMLElement {
    
      #expectedCount;
      #initialCount = 0;
      #isInitialized = false;
    
      constructor() {
        super();
    
        const observedSet = new Set(new.target.observedAttributes ?? []);
        const attrNameSet = new Set(this.getAttributeNames());
    
        // console.log([...attrNameSet.intersection(observedSet)]);
    
        this.#expectedCount = [...attrNameSet.intersection(observedSet)].length;
        this.#isInitialized = this.#initialCount >= this.#expectedCount;
    
        if (this.#isInitialized) {
          this.dispatchEvent(
            new /*Custom*/Event('beforeconnected'/*, { detail: {} } */)
          );
        }
      }
    
      attributeChangedCallback() {
        if (!this.#isInitialized) {
          if (++this.#initialCount >= this.#expectedCount) {
    
            this.#isInitialized = true;
    
            this.dispatchEvent(
              new /*Custom*/Event('beforeconnected'/*, { detail: {} } */)
            );
          }
        }
      }
      connectedCallback() {
        this.dispatchEvent(
          new /*Custom*/Event('connected'/*, { detail: {} } */)
        );
      }
    }
    </script>

    The last iteration takes into account every beneath comment of Danny ‘365CSI’ Engelman, regarding script loading, as well as FOUCs and Layout shifts.

    The latter is what the OP does want to avoid.

    Thus the final implementation now is agnostic of script loading order and independent of any particular timing of any valid customElements.define execution.

    All current changes had to be applied exclusively to the ConnectedTarget class, the MyComponent class remained unchanged.

    body {
      margin: 0;
      width: 32%;
    }
    my-component {
      display: inline-block;
      padding: 16px;
      margin: 8px 4px;
      background: #eee;
      border-radius: 16px;
    }
    .as-console-wrapper {
      left: auto!important;
      width: 67%;
      min-height: 100%;
    }
    <script>
    class ConnectedTarget extends HTMLElement {
    
      #expectedCount = null;
      #initialCount = 0;
      #isInitialized = false;
    
      attributeChangedCallback() {
        if (this.#expectedCount === null) {
    
          const observedSet = new Set(this.constructor.observedAttributes ?? []);
          const attrNameSet = new Set(this.getAttributeNames());
    
          // console.log([...attrNameSet.intersection(observedSet)]);
    
          this.#expectedCount = [...attrNameSet.intersection(observedSet)].length;
          this.#isInitialized = this.#initialCount >= this.#expectedCount;
        }
        if (!this.#isInitialized) {
          if (++this.#initialCount >= this.#expectedCount) {
    
            this.#isInitialized = true;
    
            this.dispatchEvent(
              new /*Custom*/Event('beforeconnected'/*, { detail: {} } */)
            );
          }
        }
      }
      connectedCallback() {
        if (!this.#isInitialized && this.#expectedCount === null) {
    
          this.dispatchEvent(
            new /*Custom*/Event('beforeconnected'/*, { detail: {} } */)
          );
        }
        this.dispatchEvent(
          new /*Custom*/Event('connected'/*, { detail: {} } */)
        );
      }
    }
    </script>
    
    <script>
    function handleBeforeConnected(/* argument/s, */evt) {
      const { type, target } = evt;
    
      console.log(
        'handleBeforeConnected ...', { type, target, attrNames: target.getAttributeNames() }
      );
    }
    function handleConnected(/* argument/s, */evt) {
      const { type, target } = evt;
    
      console.log('handleConnected ...', { type, target });
    }
    
    class MyComponent extends ConnectedTarget {
      static observedAttributes = ['type', 'trait'];
    
      constructor() {
        super();
    
        this.addEventListener(
          'beforeconnected', handleBeforeConnected/*.bind( <null|this>, <args array> )*/
        );
        this.addEventListener(
          'connected', handleConnected/*.bind( <null|this>, <args array> )*/
        );
      }
    }
    </script>
    
    <script>
    customElements.define('my-component', MyComponent);
    </script>
    
    
    <my-component type="button" trait="triggers" foo="bar">triggering button</my-component>
    <my-component type="link" bar="baz">basic link</my-component>
    <my-component biz="buzz">unspecified component</my-component>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search