◆ lit-html では非プリミティブ値なら前回との差分確認を無視して毎回プロパティ更新できる
◆ form パーツなど毎回更新してほしいものはオブジェクトにしておくと毎回更新できる
  ◆ valueOf と toString を実装しておけば変換される(valueOf は使う機会ないかも?)
  ◆ checked の場合はオブジェクトなら常に true にされるので使えない

問題点

これまでも何度か記事に書いてますが lit-html (hyperhtml も) では前回の値をキャッシュするために form パーツの input と相性が悪いです

簡単な例

<script type="module">
import { html, render } from "https://unpkg.com/lit-html?module"

let value = "1"

const onChange = eve => {
const new_value = eve.target.value
if(new_value === "3") {
alert("MESSAGE")
value = "1"
} else {
value = new_value
}
rerender()
}

const rerender = () => {
render(html`
<select @change=${onChange} .value=${value}>
<option value="1">A</option>
<option value="2">B</option>
<option value="3">C</option>
</select>
<span>${value}</span>
`, document.body)
}

rerender()
</script>

select があって A, B, C が選択できます
デフォルトは A です
C は画面では選べますが 選べなくしていて C を選ぶとアラートが出て A に戻されます
onChange 関数のところの処理です
select で選択中の内部の値を横に span で表示しています

B → C の順に選ぶと特に問題もなく アラートが出て A が選択状態に戻ります
A → C の順に選ぶとアラートは出ますが C が選択されています
span で表示されている値は 1 になっていて A が選択されてるはずです

これは lit-html が内部で前回の値をキャッシュして差分がある場合しか DOM のプロパティを書き換えないからです
A → C の選択のとき C は選択できず A に戻していますが そのときの lit-html の処理は A から A への変更です
変更はないので DOM には何もしません
しかし 画面上では C が選択されたので C になっています

これまでの解決策

これまでは変更を行う前に DOM と内部値の状態を合わせるために一旦 lit-html の render を行っていました
DOM の更新イベントが起きればそこでする処理を問わずに必ず最初に内部値に反映して render を実行します
その後に 必要ならば内部値を更新して再度 render します

今回の例だと onChange を次のようにします

	const onChange = eve => {
value = eve.target.value
rerender()

if(value === "3") {
alert("MESSAGE")
value = "1"
rerender()
}
}

あまりこれが好きになれなくて form パーツに関しては常に DOM プロパティを更新してほしいと思っていました
特に lit-element になると プロパティ更新で自動で render が実行され しかもプロパティを何度更新してもまとめて 1 回だけという最適化も入ってるので 2 回 render はちょっと面倒になります

今回見つけた方法

ここまでに書いたように lit-html で埋め込む値が前回と変わらない場合はプロパティ代入を行わないはずです
しかし lit-element を使ってるときに プロパティが変わらないのに setter が呼び出されているケースがありました

調べてみると配列も含むオブジェクトの場合は常に代入処理が行われるようです
配列に要素追加やプロパティ更新では値は変わるものの そのオブジェクト自身の参照は同じなので検出できないのでありがたいものです

ソース

ソース的にはこういう感じです
TypeScript になってます

setValue(value: unknown): void {
if (value !== noChange && (!isPrimitive(value) || value !== this.value)) {
this.value = value;
// If the value is a not a directive, dirty the committer so that it'll
// call setAttribute. If the value is a directive, it'll dirty the
// committer if it calls setValue().
if (!isDirective(value)) {
this.committer.dirty = true;
}
}
}

これが PropertyPart の setValue メソッドです
noChange は directive で変更を行わない場合に使うものなので 今回は無視します
isPrimitive が false か value と this.value が異なれば if を実行します
value と this.value が前回と一緒ならスキップするためのものです
isPrimitive が false なのでオブジェクトなら値が異なっても実行されるわけです

対処方法

プリミティブじゃなければいいので単純に配列でラップします

<script type="module">
import { html, render } from "https://unpkg.com/lit-html?module"

let value = ["1"]

const onChange = eve => {
const new_value = eve.target.value
if(new_value === "3") {
alert("MESSAGE")
value[0] = "1"
} else {
value[0] = new_value
}
rerender()
}

const rerender = () => {
render(html`
<select @change=${onChange} .value=${value}>
<option value="1">A</option>
<option value="2">B</option>
<option value="3">C</option>
</select>
<span>${value}</span>
`, document.body)
}

rerender()
</script>

value が配列になって 更新するときには value[0] を更新するようになりました
これで変更がなくても毎回更新されるので C を選んだときに A が表示できるようになりました

見やすく

とは言っても 配列である必要がないのに配列にするとわかりづらく扱いづらいです
new String などプリミティブ値のオブジェクト版もありますが これを使うのは避けたいです

ということで 自分でオブジェクト型でそういう型をつくりました

<script type="module">
import { html, render } from "https://unpkg.com/lit-html?module"

class LitFormValue {
constructor(value) {
this.value = value
}
get() {
return this.value
}
set(value) {
this.value = value
}
valueOf() {
return this.value
}
toString() {
return this.value != null ? this.value.toString() : ""
}
}

let value = new LitFormValue(1)

const onChange = eve => {
const new_value = eve.target.value
if(new_value === "3") {
alert("MESSAGE")
value.set("1")
} else {
value.set(new_value)
}
rerender()
}

const rerender = () => {
render(html`
<select @change=${onChange} .value=${value}>
<option value="1">A</option>
<option value="2">B</option>
<option value="3">C</option>
</select>
<span>${value}</span>
`, document.body)
}

rerender()
</script>

LitFormValue のコンストラクタ引数に初期値を入れます
値を取得するときは get メソッドで更新するときは set メソッドです
valueOf と toString メソッドを用意してるので 埋め込み時にはちゃんと内部の値として動作します

DOM プロパティ以外の CustomElements のプロパティなどに使うとプリミティブ値や文字列化せずそのまま LitFormValue 型として渡されます

文字列のところは大丈夫なのですが checked の boolean 値変換のところではオブジェクトなら常に true になるので使えませんでした



楽せず ちゃんとした方法で毎回更新させる方法