Custom Elements dramatic optimization

The Elm guide section about using custom elements for JavaScript interoperability has the following custom element example:

customElements.define('intl-date',
    class extends HTMLElement {
        // things required by Custom Elements
        constructor() { super(); }
        connectedCallback() { this.setTextContent(); }
        attributeChangedCallback() { this.setTextContent(); }
        static get observedAttributes() { return ['lang','year','month']; }

        // Our function to set the textContent based on attributes.
        setTextContent() {
            const lang = this.getAttribute('lang');
            const year = this.getAttribute('year');
            const month = this.getAttribute('month');
            this.textContent = localizeDate(lang, year, month);
        }
    }
);

I could not understand why my custom elements written this way were rendered multiple times :grimacing:

The reason is that attributeChangedCallback() is called for every observed attribute at initialization time before connectedCallback() finally attaches the custom element to the DOM.
In the example above, with 3 observed attributes, it means setTextContent() is called 4 times total! Just add a console.log() to believe it.

Here is the code I use now to prevent this redundant rendering:

customElements.define('intl-date',
    class extends HTMLElement {
        #isConnected = false
        connectedCallback() {
            this.#setTextContent();
            this.#isConnected = true;
        }
        attributeChangedCallback() {
            if (this.#isConnected) this.#setTextContent();
        }
        static observedAttributes =  ['lang','year','month'];
        #setTextContent() {
            const lang = this.getAttribute('lang');
            const year = this.getAttribute('year');
            const month = this.getAttribute('month');
            this.textContent = localizeDate(lang, year, month);
        }
    }
);

You’ll noticed a few other optimizations:

  • I’m not using constructor() along with super() as I don’t want to modify the constructor inherited from HTMLElement. And it’s working fine without it.
  • I’m using a static field instead of a getter for observedAttributes: after the release of Safari 14.1 in May 2021 static class fields are available in all major browsers.
  • I’m using #-prefixed private fields so they can be safely mangled by Terser or any other JS minifier.

Don’t you think the docs should be updated to prevent this weird multiple rendering?

8 Likes

Thanks for these notes! The static fields is new to me.

You could also update attributeChangedCallback to only call setTextContent when the value of the attribute has changed. That might help prevent re-renders as well.

1 Like

Thank for the idea @wolfadex! It makes the optimization even simpler :joy:

EDIT: @wolfadex is right, but the code I wrote checking old value nullity does not work when attributes are removed.

Code not working
customElements.define('intl-date',
    class extends HTMLElement {
        connectedCallback() { this.#setTextContent(); }
        attributeChangedCallback(name, oldValue) {
            if (oldValue !== null) this.#setTextContent();
        }
        static observedAttributes =  ['lang','year','month'];
        #setTextContent() {
            const lang = this.getAttribute('lang');
            const year = this.getAttribute('year');
            const month = this.getAttribute('month');
            this.textContent = localizeDate(lang, year, month);
        }
    }
);

Oh, I was thinking

attributeChangedCallback(name, oldValue, newValue) {
  if (oldValue !== newValue) this.#setTextContent();
}

This is a common technique for preventing re-renders even without Elm as a context.

2 Likes

Oh, do you mean attributeChangedCallback() is called even when a custom element attribute is “changed” from a value A to the same value A? JavaScript is so weird, I would not be surprised you are right. I cannot find this information on the web…

@wolfadex I did some tests and you are right, attributeChangedCallback() is called even if the attribute is “changed” to the same value.

Also my second solution with if (oldValue !== null) doesn’t work when the attribute is removed. So the first solution with the #isConnected field is to be used, even better with your optimization added :slightly_smiling_face:

1 Like

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.