Per the custom element specification,
The element must not gain any attributes or children, as this violates the expectations of consumers who use the
createElement
orcreateElementNS
methods.
Both Firefox and Chrome correctly throw an error in this situation. However, when attaching a shadow DOM, no error is present (in either browser).
Firefox:
NotSupportedError: Operation is not supported
Chrome:
Uncaught DOMException: Failed to construct 'CustomElement': The result must not have children
Without shadow DOM
function createElement(tag, ...children) {
let root;
if (typeof tag === 'symbol') {
root = document.createDocumentFragment();
} else {
root = document.createElement(tag);
}
children.forEach(node => root.appendChild(node));
return root;
}
customElements.define(
'x-foo',
class extends HTMLElement {
constructor() {
super();
this.appendChild(
createElement(
Symbol(),
createElement('div'),
),
);
}
},
);
createElement('x-foo');
With shadow DOM
function createElement(tag, ...children) {
let root;
if (typeof tag === 'symbol') {
root = document.createDocumentFragment();
} else {
root = document.createElement(tag);
}
children.forEach(node => root.appendChild(node));
return root;
}
customElements.define(
'x-foo',
class extends HTMLElement {
constructor() {
super();
// it doesn't matter if this is open or closed
this.attachShadow({ mode: 'closed' }).appendChild(
createElement(
Symbol(),
createElement('div'),
),
);
}
},
);
createElement('x-foo');
Please note: in order to view the examples, you need to be using (at least) one of the following: Firefox 63, Chrome 67, Safari 10.1. Edge is not supported.
My question is as follows:
Is the behavior demonstrated correct, per the specification?
Adding a child node to the root would cause a DOM reflow; how can this be avoided without a shadow DOM present?
Every time an element is created it is done through the constructor. But, when the constructor is called there are no children nor any attributes, Those are all added AFTER the component is created.
Even if the element is defined in the HTML page, it is still created by code using the constructor and then the attributes and children are added by the code that is parsing the DOM in the HTML page.
When the constructor is called there are no children and you can not add them since the DOM parser may be adding them as soon as the constructor is finished. The same rule applies for the attributes.
Currently there is no way to specify shadowDOM or shadowDOM children except through JS code. The DOM parser will not add any children to the shadowDOM.
So according to the spec it is illegal to access, change or do anything with attributes or children in the constructor. But, since there is no way for the DOM parser to add anything into a components shadowDOM that is not illegal.
I have gotten around this problem when not using shadowDOM by using an internal template element that is created in the constructor and then placed as a child once the connectedCallback
is called.
// Class for `<test-el>`
class TestEl extends HTMLElement {
constructor() {
super();
console.log('constructor');
const template = document.createElement('template');
template.innerHTML = '<div class="name"></div>';
this.root = template.content;
this.rendered = false;
}
static get observedAttributes() {
return ['name'];
}
attributeChangedCallback(attrName, oldVal, newVal) {
if (oldVal !== newVal) {
console.log('attributeChangedCallback', newVal);
this.root.querySelector('.name').textContent = newVal;
}
}
connectedCallback() {
console.log('connectedCallback');
if (!this.rendered) {
this.rendered = true;
this.appendChild(this.root);
this.root = this;
}
}
// `name` property
get name() {
return this.getAttribute('name');
}
set name(value) {
console.log('set name', value);
if (value == null) { // Check for null or undefined
this.removeAttribute('name');
}
else {
this.setAttribute('name', value)
}
}
}
// Define our web component
customElements.define('test-el', TestEl);
const moreEl = document.getElementById('more');
const testEl = document.getElementById('test');
setTimeout(() => {
testEl.name = "Mummy";
const el = document.createElement('test-el');
el.name = "Frank N Stein";
moreEl.appendChild(el);
}, 1000);
<test-el id="test" name="Dracula"></test-el>
<hr/>
<div id="more"></div>
This code creates a template in the constructor and uses this.root
to reference it.
Once connectedCallback
is called I insert the template into the DOM and change this.root
to point to this
so that all of my references to elements still work.
This is a quick way to allow your component to always be able to keep its children correct without using shadowDOM and only placing the template into the DOM as children once connectedCalback
is called.