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:
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 };
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 };
import { CompB } from "./component_b.js"
window.onload = (event) =>
{
let x = document.createElement('comp-b');
document.body.append(x);
x.value = 10;
}
<!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?
Forget the import
and whenDefined
mumbo jumbo.
There are 2 root causes for your problem:
template
is upgraded a-syncconstructor
code continues,.getElementById
finds HTMLUnknownElements
HTMLUnknownElements
are HTMLElements
, thus your constructor.name
says HTMLElement
, and you can do anything you want with them.matches(":defined")
codeAnd, 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>`;
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
)
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>