◆ 親要素と子要素が同時に DOM にアタッチされると親からアップグレードされる
◆ 子要素を待とうと非同期にするとアタッチした処理の直後に初期化は完了してない
◆ innerHTML や append だと Promise を返すことも出来ない
◆ 子要素がアップグレード時にイベント発生させて全てアップグレードされたら初期化を行う

問題点

以前カスタム要素のアップグレードがやっかいという記事を書きました

簡単にまとめると 親要素の初期化処理をするときにまだ子要素がアップグレードされておらずメソッドやプロパティがない状態のことがあります
ちゃんと依存する子要素の定義ファイルをインポートしていれば定義済みではあるので非同期にするだけでおっけいです
動的なものでインポートされてない場合があるなら whenDefined を使って待つ必要があります
初期化処理が非同期になると色々不便なところがあって 引きづられて非同期が必要ないはずなのに非同期にしないと順番がおかしくなるケースがあったり 初期化完了したかが分からないデメリットがあります

この問題が起きるのは親子でカスタム要素のものが同時に作られた場合です

document.body.innerHTML = `<x-parent><x-child></x-child></x-parent>`

のように一度に作ると x-parent から初期化されて

parent constructor
parent connected
child constructor
child connected

という順番で処理されます

JavaScript でそれぞれの要素を作る場合は

const p = document.createElement("x-parent")
const c = document.createElement("x-child")
p.append(c)
document.body.append(p)
parent constructor
child constructor
parent connected
child connected

順にコンストラクタが実行されて DOM にアタッチされたときに connectedCallback は親から順に呼び出されます
ですがコンストラクタが呼び出されるタイミングでアップグレードは完了しています
親要素の connectedCallback の時点で子要素のメソッドは使えます


また 親要素が初期化処理で子要素を作る場合は innerHTML を書き換えたり append したときに同期的にコンストラクタと connectedCallback が呼び出されます
append などの直後に子要素にアクセスすればそのときにはメソッドは使用可能です

なので DOM にまとめて要素が親子揃って作られた時のみの問題です

同期的に処理する

子要素のアップグレードを待つために非同期にすると 親子の要素を DOM にアタッチした側でアタッチ直後にまだ初期化が終わってません
アップグレードは終わっていますが通常は connectedCallback もどちらも終わっているはずなのに子要素を待つせいでこれらを使う側まで不便になります

document.body.innerHTML = `<x-parent><x-child></x-child></x-parent>`
console.log("非同期にしてるとこのタイミングでは connectedCallback の処理が完了してない")

これが嫌なので同期的に扱えるようにします

class BaseElement extends HTMLElement {
constructor() {
super()
this._upgraded = true
this.dispatchEvent(new CustomEvent("element-upgraded", { detail: this }))
}
}

customElements.define(
"x-parent",
class extends BaseElement {
connectedCallback() {
console.log("parent-connected")
let count = 0
const listener = eve => {
eve.currentTarget.removeEventListener("element-upgraded", listener)
--count || this.onReadyChildren()
}
for (const elem of this.children) {
if (!elem._upgraded && elem.localName.includes("-")) {
count++
elem.addEventListener("element-upgraded", listener)
}
}
}
onReadyChildren() {
console.log("parent-children-ready")
this.firstElementChild.method()
}
}
)

customElements.define(
"x-child",
class extends BaseElement {
connectedCallback() {
console.log("child-connected")
}
method() {
console.log("child-method")
}
}
)

document.body.innerHTML = `
<x-parent>
<x-child>a</x-child>
<x-child>b</x-child>
</x-parent>
`
console.log("innerHTML set")
parent-connected
child-connected
parent-children-ready
child-method
child-connected
innerHTML set

「innerHTML set」 が表示される前に 「parent-children-ready」 や 「child-method」 が表示されています

やっていることはイベントを listen して最後の要素の処理が終わったら関数を呼び出すという昔からある方法です
共通のベースクラスを用意してそこでコンストラクタが実行されたら element-upgraded イベントを起こすようにしています
またアップグレード済みを表すフラグもセットします
親要素では初期処理をする時に未アップグレードの要素にリスナを付けてその数をカウントします
アップグレードされるたびにリスナが呼び出されてカウントが減り最後の要素がアップグレードされると 親要素の初期化処理が行われます
イベントリスナは同期的なので 「innerHTML set」 が出るより先に行われます

タイミングはコンストラクタなので 2 つ目の connectedCallback より先に親の初期化処理が実行されます
子要素の connectedCallback を待ちたいなら connectedCallback で initialized イベントを起こすようにしてそっちを listen すれば connectedCallback の後に親要素の処理を実行できます