Search code examples
javascriptweb-componentnative-web-component

Browser parsing DOM vs JavaScript execution - Ensuring props are always found by web component


I have created the following pattern to initialize native web components with JSON information (props). Naming things is hard, so the advanatage of this pattern is that one does not have to add ID's to the script tags, or the web component, to be able to apply the props to the web component.

How it works is that the web component has a slot named initprops. If this slot is slotted with a <script> tag, then parse it as JSON and apply the result as props to the web component.

The following example shows how it works (will discuss the event listener for slotchange below):

class Component extends HTMLElement {
    constructor() {
        super().attachShadow({ mode: "open" });
        const template = document.getElementById("TEMPLATE");
        this.shadowRoot.appendChild(template.content.cloneNode(true));
    }
    connectedCallback() {
        this.initProps();
    }
    set props(json) {
        console.log("Props set to", json);
        for (const value of json) {
            const el = document.createElement("div");
            el.textContent = `Created ${value}`;
            this.appendChild(el);
        }
    }
    // Init props _____________________________________________________________
    getInitPropsSlot() {
        return this.shadowRoot.querySelector('slot[name="initprops"]');
    }
    getInitPropsScriptEl() {
        this.initPropsScriptEl = this.initPropsScriptEl || this.querySelector('[slot="initprops"]');
        return this.initPropsScriptEl;
    }
    applyInitProps() {
        const initPropsScriptEl = this.getInitPropsScriptEl();
        let props = JSON.parse(initPropsScriptEl.textContent);
        this.props = props;
    }
    initProps() {
        if (!this.getInitPropsSlot()) return;
        if (this.getInitPropsScriptEl()) {
            this.applyInitProps();
        } else {
            this.getInitPropsSlot().addEventListener("slotchange", this.applyInitProps.bind(this));
            alert("slot changed saved the day");
        }
    }
}
window.customElements.define("wc-foo", Component);
<template id="TEMPLATE">
  <slot></slot>
  <slot name="initprops"></slot>
</template>

<wc-foo>
   <script slot="initprops" type="application/json">[1, 2, 3]</script>  
</wc-foo>

The problem I had, is that it worked fine on my machine, but in deployment, for some people, not me, sometimes the props would not be applied, and I have seen a screen shot of it. I could reproduce the issue. My thoery is that before the script tag was added to the web component, connectedCallback was executed, meaning it did not see the script tag.

According to MDN:

connectedCallback: Invoked each time the custom element is appended into a document-connected element. This will happen each time the node is moved, and may happen before the element's contents have been fully parsed.

Emphasis mine, on that connectedCallback may happen before the element's contents have been fully parsed. So to solve this issue I added the event listener for slotchange.

Now my question is will the web component always detect and apply the props within the <script> tag now?

I ask this because I was wondering about the following: Does the browser populate the tree asynchronously to running the JavaScript (sounds like it from the MDN quote above)? Could there be a race condition that happens like this:

  1. connectedCallback fired, but <script> tag not parsed and inserted into the DOM yet, hence the props are not applied.
  2. While the JavaScript creates the event listener for slotchange, the browser parsers the <script> tag and appends it to the DOM.
  3. Now the event listener never fires because in the short time between when it did not see the slotchange and adding the slotchange event listener, the <script> was added to the DOM.

Solution

  • Per comments; you have to wait till DOM is parsed:

    customElements.define("wc-foo", class extends HTMLElement {
        connectedCallback() { // fires on the *opening* tag
           setTimeout(()=>{ // so wait till the Event Loop is done (==DOM is parsed)
             this.props = JSON.parse(this.innerHTML);
           });
        }
        set props(json) {
            console.log("Props set to", json);
            for (const value of json) {
                this
                 .appendChild(document.createElement("div"))
                 .textContent = `Created ${value}`;
            }
        }
    });
    <wc-foo>
       [1, 2, 3]
    </wc-foo>