Search code examples
javascripthtmlweb-component

Why are latent functions, such as callbacks registered with `addEventListener("input, ...")` in my nested webcomponents not being executed?


Hopefully the snippet is self explanatory, most of the (albeit rather broken) magic happens in conntectedCallback(). Essentially, if you type into the first text field at the very top you will see a string being printed to the console. But typing into the second editable text field (which unlike the first one is nested inside another webcomponent) nothing gets printed.

Any thoughts or pointers would be most appreciated.

<!DOCTYPE html>
<html>
<head>
    <script>
class HtmlGenMachinery extends HTMLElement {
    disconnectedCallback() {
        console.log("disconnectedCallback for '" + String(this.tagName) + "' element");
    }
    attributeChangedCallback() {
        console.log("attributeChangedCallback for '" + String(this.tagName) + "' element");
    }
    adoptedCallback() {
        console.log("adoptedCallback for '" + String(this.tagName) + "' element");
    }
    constructor() {
        super();
        console.log("constructor for '" + String(this.tagName) + "' element");
        this.attachShadow({ mode: "open" });
    }
    connectedCallback() {
        console.log("connectedCallback for '" + String(this.tagName) + "' element");
        setTimeout(() => {
            console.log("connectedCallback setTimeout lambda for '" + String(this.tagName) + "' element");

            // Parse html
            this.sourceHtml = this.sourceHtml
            .replaceAll('{shadowRoot}', '(el{id}.shadowRoot)')
            .replaceAll('¤%&/📍$', '`')
            .replaceAll('¤%&/🧭$', '</sc' + 'ript>')
            .replaceAll(`{content}`, this.innerHTML);
            if (this.sourceHtml.includes('el{id}')) {
                this.sourceHtml = "<script>var el{id}=document.getElementById('{id}');</sc" + "ript>" + this.sourceHtml;
            }
            for (const argStr of this.arguments) {
                if (Array.isArray(argStr)) {
                    this.sourceHtml = this.sourceHtml.replaceAll(`{${argStr[0]}}`, this.getAttribute(argStr[1]));
                } else {
                    this.sourceHtml = this.sourceHtml.replaceAll(`{${argStr}}`, this.getAttribute(argStr));
                }
            }

            const newContent = document.createRange().createContextualFragment(this.sourceHtml);
            this.shadowRoot.appendChild(newContent);
        }, 0)
    }
} 
class HtmlGen extends HtmlGenMachinery { sourceHtml = `{content}`; arguments = []; }
window.customElements.define('f-html-gen', HtmlGen);
class HtmlGen_Box extends HtmlGenMachinery { 
    sourceHtml = `<div style="background-color: {color}">{content}</div>`; arguments = ["color"]; 
}
window.customElements.define('f-html-gen-box', HtmlGen_Box);
class HtmlGen_StringProperty extends HtmlGenMachinery {
    sourceHtml = `<blockquote><p contenteditable=true id=propertyParagraph>{text}</p></blockquote>
<script type="module">
let el={shadowRoot}.getElementById("propertyParagraph");
console.log("howdy from {id} shadowRoot: " + String(Object.getOwnPropertyNames({shadowRoot})));        
setTimeout(()=>{
    console.log("Latent message from {id}");
},0)
el.addEventListener("input", function() {
    console.log("Contents of {id} is ->\\n" + el.innerHTML + "<-\\n");
    {changedCallback}
}, false);
¤%&/🧭$`; 
arguments = ["text", ["changedCallback","changed-callback"], "id"];
}
window.customElements.define('f-html-gen-string-property', HtmlGen_StringProperty);
class HtmlGen_Text extends HtmlGenMachinery { sourceHtml = `<p>{text}</p>`; arguments = ["text"]; }
window.customElements.define('f-html-gen-text', HtmlGen_Text);
    </script>
</head>

<body>
    <f-html-gen-string-property
    text="Latent functions here work just fine! To confirm this, try clicking on me and typing, then look in the console"
    id=f8d3c6b4c21754686 changed-callback="console.log('changed-callback');"></f-html-gen-string-property>
    <f-html-gen>
    <f-html-gen-box color=gray>
        <f-html-gen-text text="Lorum Ipsum"></f-html-gen-text>
    </f-html-gen-box>
    <f-html-gen-box color=gray>
        <f-html-gen-string-property
        text="However, when nested inside other webcomponents, latent functions stop working. Hence, if you try clicking on me and start tying, you wont see anything printed to the console!"
        id=dzd3b64lc13t56ki4 changed-callback="console.log('changed-callback');">
        </f-html-gen-string-property>
    </f-html-gen-box>
    </f-html-gen>
</body>
</html>


Solution

  • Ray is right, its lightDOM

    You can slim all your code to HTML with Declarative shadow DOM (shadowrootmode attribute)

    <input id="A1" placeholder="a1" onkeyup="console.log(this.id,this.value)" />
    <input-two>
      <input id="A2" placeholder="a2" onkeyup="console.log(this.id,this.value)" />
    </input-two>
    
    <hr>
    
    <input id="B1" placeholder="b1" onkeyup="console.log(this.id,this.value)" />
    <input-two>
      <template shadowrootmode="open">
        <input id="B3" placeholder="b3" onkeyup="console.log(this.id,this.value)" />
      </template>
      <input id="B2" placeholder="b2" onkeyup="console.log(this.id,this.value)" />
    </input-two>

    input b2 is no longer available in the UI,
    because the shadowRoot prevents it from rendering in the UI

    Note! B2 is still in the DOM (as lightDOM) So you can do anything you want with it.

    Adding a <slot> can slot/reflect <input B2> from lightDOM to shadowDOM.

    <input id="B1" placeholder="b1" onkeyup="console.log(this.id,this.value)" />
    <input-two>
      <template shadowrootmode="open">
        <slot></slot>
        <input id="B3" placeholder="b3" onkeyup="console.log(this.id,this.value)" />
      </template>
      <input id="B2" placeholder="b2" onkeyup="console.log(this.id,this.value)" />
    </input-two>

    Side note: Long read on slotted content: ::slotted CSS selector for nested children in shadowDOM slot


    So you get lost in the woods/DOM because your script is working on the invisible b2

    ... which you reference with a document global scope reference

    <script>var el{id}=document.getElementById('{id}');

    so this will never work for nested shadowRoots

    and its not required because the browser will create (equally named) variables for every ID in global scope anyway.
    It has done so since early IE versions,
    and because IE had 80+ percent marketshare back then (25+ years ago) every next browser copied its behavior

    shadowDOM content is NOT in global scope, so will not create those global variables!

    Re: comments

    I refactored your code; see link below

    Here is the problem with your code:

    • You create Custom Elements green and red with a shadowDOM

    • But you use global variables to point to those 2 elements

    • So when your wrapper takes its innerHTML to create the third Custom Element inside shadowDOM (gold)

    • The listener for gold is again attached to red (lightDOM)

    • and since the wrapper now got shadowDOM, its original content (lightDOM) red is no longer visible in the UI

    I refactored your code to the bare essentials,
    StackOverflow can't do formatted console,
    see: https://jsfiddle.net/WebComponents/z125htfo/

    For console output:


    Solution?

    Your logic problem is that you have one BaseClass that handles 2 types of Web Components (input and wrapper) Which is fine when you handle above scenario in your code. It all depends on what you want to achieve.

    The best way out is to not use global variables and this weird <script> injection.

    Use <template> to create new DOM, and script (now having the correct scope!) inside your Web Component methods to attach functionality.

    And yes, this can be done in dozens of ways,
    it all depends on what you want to achieve

    fix your code

    Instead of creating shadowDOM with this.innerHTML
    create a <slot> to prevent a TWO duplicate and reflect the original TWO from lightDOM to shadowDOM (see JSFiddle)

    But that weird script injection will bite you again in the future

    Here is a starting point:

    <input-element id="ONE"></input-element>
    <wrap-element id="WRAP"><input-element id="TWO"></input-element></wrap-element>
    <script>
      const createElement = (tag, props = {}) => Object.assign(document.createElement(tag), props);
      class BaseClass extends HTMLElement {
        connectedCallback() {
          if (this.nodeName == "INPUT-ELEMENT") {
            this.attachShadow({mode: "open" })
                .append(
                    createElement("input", {
                        placeholder: `${this.localName} ${this.id}`,
                        onkeyup: (evt) => console.log(evt.target.value)
                    })
                 )
          } else {
            console.log("do something? for:", this.nodeName)
          }
        }
      }
      customElements.define("wrap-element", class extends BaseClass {})
      customElements.define("input-element", class extends BaseClass {})
    </script>