◆ 登録済みタグが HTML パース中に来た場合 connectedCallback で子要素が存在しない
  ◆ 閉じタグまで来てから呼び出されるのではなく開始タグの時点で呼び出されてるみたい
  ◆ 子要素を見るなら HTML パース後に登録するように defer や module にしておいたほうが良い
  ◆ innerHTML なら問題なし
◆ 親要素からアップグレードされるので connectedCallback 時点で子要素のメソッドが使えない
  ◆ HTML に書かれているなら子要素からアップグレードすれば対処できる
  ◆ 両方登録後に innerHTML なら親要素からになる
  ◆ whenDefined で待てるけど要素単位じゃなくタグが登録されてるかだけのチェック
    ◆ Promise が非同期だから setTimeout(fn, 0) みたいなものでうまくいってる
  ◆ 非同期にすると他も非同期にしないといけなくて面倒
  ◆ innerHTML のセットで始まる非同期処理で初期化されると親が完了を知るのがつらい
◆ アップグレードされるタイミングはカスタム要素が登録された window の document に属したとき
  ◆ template.content の cloneNode なら append するまでアップグレードされない
  ◆ importNode だとその場でアップグレードされる

x-parent と x-child の 2 つのカスタム要素を作ります

先に define すると

<!doctype html>

<script>
customElements.define("x-parent", class extends HTMLElement {
connectedCallback(){
console.log(this.children)
this.firstElementChild.method()
}
})

console.log(1)

customElements.define("x-child", class extends HTMLElement {
method(){console.log("method called")}
})
</script>

<x-parent><x-child></x-child></x-parent>

x-parent は初期化処理として子要素である x-child のメソッドを呼び出します
これ自体だと使いみちがなさそうですが tab を切り替える要素や URL に応じて表示を変える router 要素など 特定の子要素が来る前提の要素はときどきあります

これを実行すると

HTMLCollection []

Uncaught TypeError: Cannot read property 'method' of null
at HTMLElement.connectedCallback (s.html:7)

x-parent の connectedCallback では children が空です
なのでメソッドは呼び出せずエラーが起きています

HTML が順番にパースされるとき x-parent が作られるタイミングではまだ x-child が存在しないようです
x-parent の閉じタグまでパースされてからよびだされるわけではないようですね

あとで define すると

今度は先に HTML タグを書いておきます

<!doctype html>

<x-parent><x-child></x-child></x-parent>

<script>
customElements.define("x-parent", class extends HTMLElement {
connectedCallback(){
console.log(this.children)
this.firstElementChild.method()
}
})

console.log(1)

customElements.define("x-child", class extends HTMLElement {
method(){console.log("method called")}
})
</script>

これだと x-parent の connectedCallback の時点で x-child が存在します
ただ 別のエラーが起きます
これについては後で書きます

HTMLCollection [x-child]

Uncaught TypeError: this.firstElementChild.method is not a function
at HTMLElement.connectedCallback (s.html:9)
at s.html:6

innerHTML だと

先に HTML を書いておけばどうにかなりましたが innerHTML で動的に作る場合はすでに定義済みの状態です
この場合も x-child がないのでしょうか

<div id="d"></div>

を追加しておいて

d.innerHTML = `<x-parent><x-child></x-child></x-parent>`

を実行します
すると

HTMLCollection [x-child]

Uncaught TypeError: this.firstElementChild.method is not a function
at HTMLElement.connectedCallback (s.html:7)
at <anonymous>:1:13

x-child が存在します
子要素がない可能性があるのは直接 HTML ファイルに書かれたときだけの問題みたいです
まぁ HTML ファイルの場合はパースと受信が並列だったりもして どこまで子要素が続くかわからないですからね
その点 innerHTML なら文字列として全体が渡されているので閉じタグまである状態で開始できます

customElements.define を書く JavaScript ファイルは最後に実行するように module か defer でロードするようにしておくのが良さそうです

メソッドがない

innerHTML や 先に HTML を書いていた場合は x-parent の connectedCallback 時点で x-child が存在することがわかりました
ですが さっきのエラー

this.firstElementChild.method is not a function

がでます

firstElementChild が x-child なのは確認していますし あとからコンソールで x-child の method を実行するとちゃんと動きます

CustomElement はスクリプトで動的に定義されるものなので アップグレードという処理があり それが行われるまではカスタムされないただの HTMLElement です
アップグレードされるとカスタム要素となり コンストラクタが実行され ユーザ定義クラスで追加したメソッドが利用できるようになります

ここまで触れてませんでしたが script タグの中には console.log(1) がありました

customElements.define("x-parent", class extends HTMLElement {
connectedCallback(){
console.log(this.children)
this.firstElementChild.method()
}
})

console.log(1)

customElements.define("x-child", class extends HTMLElement {
method(){console.log("method called")}
})

コンソールの表示順を見ると エラーは 1 が表示されるより前です
ということは x-parent を define したら同期的にすでに DOM に存在する x-parent がアップグレードされ connectedCallback が呼び出されています
その時点ではまだ x-child が define されていないので x-child に method というメソッドは存在しません

順番を変えてみると

<!doctype html>

<x-parent><x-child></x-child></x-parent>

<script>
customElements.define("x-child", class extends HTMLElement {
method(){console.log("method called")}
})

console.log(1)

customElements.define("x-parent", class extends HTMLElement {
connectedCallback(){
console.log(this.children)
this.firstElementChild.method()
}
})

</script>
1
HTMLCollection [x-child]
method called

問題なく動きました

今回は HTML 1 ファイルに全部書いてますが ちゃんとわけるならコンポーネントごとに 1 ファイルになると思います
x-parent の中で

import "./x-child.mjs"

のようにしておけば先に x-child を初期化できるので依存するカスタム要素の定義を import しておけば大丈夫そうです

innerHTML だと

これで大丈夫だと思ったのですが innerHTML を使うと

d.innerHTML = `<x-parent><x-child></x-child></x-parent>`
HTMLCollection [x-child]
Uncaught TypeError: this.firstElementChild.method is not a function
at HTMLElement.connectedCallback (s.html:15)
at <anonymous>:1:13

さっきの場合は x-child のアップグレードが終わってから x-parent のアップグレードなので大丈夫でしたが
両方が登録済みなら HTML の構造上親である x-parent からアップグレードされるようで同じエラーになりました

whenDefined

さっきの x-child と x-parent 登録順ですが 別の要素が登録されるのを待つ機能に whenDefined というのがあります

<!doctype html>

<x-parent><x-child></x-child></x-parent>

<script>
customElements.define("x-parent", class extends HTMLElement {
connectedCallback(){
customElements.whenDefined("x-child").then(() => {
console.log(this.children)
this.firstElementChild.method()
})
}
})

console.log(1)

customElements.define("x-child", class extends HTMLElement {
method(){console.log("method called")}
})
</script>

x-parent の connectedCallback の中を whenDefined で x-child を指定して実行するようにすると x-child が define されてから実行されます
whenDefined が返すのは Promise なので await でもできます

結果は

1
HTMLCollection [x-child]
method called

これを使えば x-child と x-parent の順番を問わずに書けます

しかし この方法はあくまで CustomElementRegistry に登録されたかだけを見ています
個々の要素がアップグレード済みかは見ていません
指定した要素名がすでに登録されていたなら 子要素にアップグレードされていない要素があっても即 resolve されます

ただ Promise の仕様上 即 resolve されても非同期で実行されます
そのため then の部分が実行されるまでにアップグレードが完了しています
同期的に x-child も define しているならこれでも動きます

<!doctype html>

<x-parent><x-child></x-child></x-parent>

<script>
customElements.define("x-parent", class extends HTMLElement {
connectedCallback(){
Promise.resolve().then(() => {
console.log(this.children)
this.firstElementChild.method()
})
}
})

console.log(1)

customElements.define("x-child", class extends HTMLElement {
method(){console.log("method called")}
})
</script>

Promise.resolve().then() 形式にしました

両方登録済みで innerHTML の場合も x-parent の connectedCallback の処理が非同期で後回しになるので 先に x-child のアップグレードが行われるのでちゃんと動きます
ただ ちゃんとアップグレードを待ってるわけじゃなくて Promise で非同期にしてるだけという解決策なので setTimeout で 0 秒の方法使ってるときみたいな気持ち悪さが残ります
もうちょっといい方法ないのでしょうか

非同期のデメリット

whenDefined などで非同期にすれば子要素のアップグレードを先にしてもらえますが問題点もあります

<!doctype html>

<x-parent><x-child></x-child></x-parent>

<script>
const set = new Set()

customElements.define("x-parent", class extends HTMLElement {
connectedCallback(){
customElements.whenDefined("x-child").then(() => {
console.log(this.children)
this.firstElementChild.method()
set.add(this)
})
}
disconnectedCallback(){
set.delete(this)
}
})

console.log(1)

customElements.define("x-child", class extends HTMLElement {
method(){console.log("method called")}
})
</script>

connectedCallback で set にインスタンスを保存して disconnectedCallback で set から除去します
大抵の場合はこの処理で問題ないのですが

d.innerHTML = "<x-parent><x-child></x-child></x-parent>";d.innerHTML = "<div></div>"

こういう同期的に追加削除が行われると connectedCallback の then の中は disconnectedCallback より後に呼び出されます
その結果 set には disconnect 済みのインスタンスが残ります

こういう set に入れるケースは少ないかと思いますが window.addEventListener をしてグローバルな部分にリスナをつけたりはずしたりというケースならよりありがちかと思います
リスナの場合残り続けると問題になることは多々ありますからね

今回みたいなものなら set に入れたりリスナ登録する処理は 非同期にする必要はないと思います
ですが その処理が子要素に依存していて 子要素のプロパティやメソッドの結果が必要となったら非同期にするしかありません

disconnectedCallback の方も whenDefined の中で処理すれば先に登録した connectedCallback の方から実行されるので対処できますが あちこちを非同期化していかないといけなくなって辛いです
他のメソッドも非同期で行われる connectedCallback の初期化処理が済んでいることを前提としているなら非同期化しないといけないです



また これらのカスタム要素を使う親要素で初期化済みかを判断するのも難しくなります

<!doctype html>

<x-app></x-app>

<script>
customElements.define("x-parent", class extends HTMLElement {
connectedCallback(){
customElements.whenDefined("x-child").then(() => {
console.log(this.children)
this.firstElementChild.method()
})
}
})

customElements.define("x-child", class extends HTMLElement {
method(){console.log("method called")}
})

customElements.define("x-app", class extends HTMLElement {
init(){
this.innerHTML = `
<div>
<x-parent>
<x-child></x-child>
</x-parent>
</div>
`
this.querySelector("x-child").method()
console.log("init end")
}
})
</script>

これで x-app の init メソッドを呼び出すと

method called
init end
HTMLCollection [x-child]
method called

という結果です

innerHTML を書き換え直後は x-child がアップグレード済みで即使えています
ただ x-child のアップグレードを待つために非同期化した x-parent の非同期処理は終わっておらず init メソッドが終わってから実行されます

x-parent のプロパティに promise を用意しておいて init メソッドの中で

this.querySelector("x-parent").ready.then(e => { /* 子要素初期化後の処理 */ })

のようにすればできますがあまり良い方法には思えません
x-app の中に x-parent のような要素が複数あればそれらすべての promise 取り出して Promise.all で待つ必要があります

cloneNode と importNode

これまで対して気にすることもなかった cloneNode と importNode ですが CustomElement では違いが出てきます

<!doctype html>

<template>
<x-elem></x-elem>
</template>

<script>
customElements.define("x-elem", class extends HTMLElement {
constructor(){
super()
console.log("constructor")
}
connectedCallback(){
console.log("connected")
}
})
</script>

というページを作ります
開くだけでは何も起きません

template からクローンしてみます

const template = document.querySelector("template")
const clone = template.content.cloneNode(true)

クローンをつくっただけでは何も起きません
アップグレードはされていません

body に append します

document.body.append(clone)
// constructor
// connected

append されたタイミングでアップグレードされるので constructor と connectedCallback が呼び出されます

次は import してみます

const clone2 = document.importNode(template.content, true)
// constructor

こっちだと import した時点でアップグレードされて constructor が呼び出されました
body に append すると

document.body.append(clone2)
// connected

connectedCallback が呼び出されます

カスタム要素がアップグレードされるのは document に adopt されたタイミングです
(こっちの記事参照)
template の内の DocumentFragment は tempate タグが存在する document とは異なる document に属しています

template.content.ownerDocument === document
// false

importNode の場合は所属する document も変わるのでそのタイミングでアップグレードされます

cloneNode を使えばアップグレード前の要素を操作できますが アップグレード前に操作する必要は基本的にないと思います
しかし cloneNode のほうが見慣れているしメソッドで実行できて扱いやすいです

DocumentFragment の状態で中身を操作するならアップグレードが必要かどうかに応じて使い分けて 特に中身を操作しないならどっちでもいいと思います