Failing to access setter method of a web-component

I building a web-component and within its constructor using template to initialize its substructure. A part of this substructure is another web-component whose setter method I wish to call. I get the sub-component by querying DOM tree that was created via the template, but through this only standard Element properties are accessible.

It is a bit of a complex problem and it might be I am missing something fundamental. It seems to be related with the fact that one web component uses another web component via the template clone. It was suggested in this question that the problem might be due to sub-component not being loaded/defined. I don’t understand this, especially since I can not get the proposed solution to work. I would also assume that whatever JS engine browser is running is smart enough to resolve import dependencies and does not run the code if its imports are not ready. Am I over simplistic with this?

A simple reproducible example that fails deterministically is indispensable. So I have managed to created a simple replica that demonstrates the problem. For the purpose of consistency I have used the same multi-file structure of the design as in the original:

  • component_a.js
    class CompA extends HTMLElement
    {
        constructor()
        {
            super();
            this.attachShadow({mode: "open"});
          this.shadowRoot.append(CompA.template.content.cloneNode(true));
    
                this._value = 0;
          this.shadowRoot.getElementById('top').innerHTML = "A=" + this._value;
        }
    
        set value(x)
      {
        this._value = 2*x;
        this.shadowRoot.getElementById('top').innerHTML = "A=" + this._value;
        console.log('Value set on CompA');
      }
    }
    
    CompA.template = document.createElement("template");
    CompA.template.innerHTML = `<div id='top'></div>`;
    
    customElements.define("comp-a", CompA);
    
    export { CompA };

  • component_b.js
    import { CompA } from "./component_a.js"
    
    class CompB extends HTMLElement
    {
        constructor()
        {
            super();
            this.attachShadow({mode: "open"});
          this.shadowRoot.append(CompB.template.content.cloneNode(true));
    
          let s = this.shadowRoot.getElementById('subcomponent');
          console.log(s.constructor.name);
          console.log(s.matches(':defined'));
          s.value = 1;
        }
    
        set value(x)
      {
        this.shadowRoot.getElementById('subcomponent').value = x;
        console.log('Value set on CompB');
      }
    }
    
    CompB.template = document.createElement("template");
    CompB.template.innerHTML = `<div>
      <span>Component B:</span>
      <comp-a id='subcomponent'></comp-a>
    </div>`;
    
    customElements.define("comp-b", CompB);
    
    export { CompB };

  • question.js
    import { CompB } from "./component_b.js"
    
    window.onload = (event) =>
    {
      let x = document.createElement('comp-b');
      document.body.append(x);
      x.value = 10;
    }

  • question.html
    <!doctype html>
    <html lang=en>
    <head>
        <meta charset=utf-8>
        <title>question</title>
      <script type='module' src='./question.js'></script>
    </head>
    <body>
    </body>
    </html>

Expected behavior: On question.html load <comp-b> is created and inserted onto the page. Its setter method is called with argument of 10. During this creation of <comp-b> in its constructor <comp-a> is appended via the template provided. Once instantiated, it is referenced by variable s and its setter method should be called – producing A=20 innerHTML. So the page should be:

Component B:
A=20

with the expected console output:

CompB
true
Value set on CompA
Value set on CompA
Value set on CompB

Observed behavior: Variable s is indeed pointing to the correct element, however s.value = 1; does not call the setter of CompA but simply assigns property with value of 1 to the element, thus living its default value in place. The page is:

Component B:
A=0

with the console output:

HTMLElement
false
Value set on CompB

Question: Can someone explain me why this fails and how to force JS to associate entire specified CompA with the s, not only the Element? Please feel free to suggest further possible diagnosing of the problem?

Answer

Forget the import and whenDefined mumbo jumbo.

There are 2 root causes for your problem:

  1. template is upgraded a-sync
    when you append it to the shadowDOM the constructor code continues,
    and the Template HTML (now in shadowDOM) is upgraded once the Event Loop is done.
  2. .getElementById finds HTMLUnknownElements
    HTMLUnknownElements are HTMLElements, thus your constructor.name says HTMLElement, and you can do anything you want with them.
    They are just not upgraded Web Components yet,
    as you found with your matches(":defined") code

And, yes, alas nearly every blog shows a createElement("template") pattern.
You don’t need this mumbo jumbo either:

CompB.template = document.createElement("template");
CompB.template.innerHTML = `<div>
  <span>Component B:</span>
  <comp-a id='subcomponent'></comp-a>
</div>`;

Solution

Make your constructor do:

super()
  .attachShadow({mode: "open"})
  .innerHTML = `<div>
                   <span>Component B:</span>
                   <comp-a id='subcomponent'></comp-a>
                </div>`;

And your HTML will be parsed/upgraded synchronous (render blocking)

Don’t want to use innerHTML? Build your HTML with .createElement("div")

.createElement("template") and <template> are upgraded A-sync.
Then understand how to delay your code (in the connectedCallback)

and in general

Do not do DOM (I am not saying shadowDOM!!) updates in the constructor, that work should be done in the connectedCallback. There are cases where there is NO DOM in the constructor (think SSR and .createElement("my-component")


PS. I am not a fan of this pattern:

export { CompA } from './comp-a/comp-a.js';
export { CompB } from './comp-b/comp-b.js';

const componentsToRegister = { 
    CompA, 
    CompB,
}

for (const clazz of Object.values(componentsToRegister)) {
   customElements.define(clazz.TAG_NAME, clazz);
} 

You are creating dependencies.
When or Where a Web Component is defined should not matter, just like Lego bricks are Lego bricks.

“exporting” a class is highly overrated.

customElements.define("my-element", class extends HTMLElement{

})

does the job (nearly) always. Use export when you start to use BaseClasses for your own Elements.

You don’t (always) need am exported class, you can steal someone else Components

<script>
customElements.define( "poker-card", 
  class extends customElements.get("card-t") {})
</script>