🎄Open UI Advent Calendar: Day21 / Customizable Select Element Ep.19

Published on

Customizable Select Elementの関連仕様: `<selectedcontent>` - 子Node変更検知タイミングの仕様決定(現時点で)

Table of Contents

Table of Contents

はじめに

Customizable Select Element Ep.16からは、<selectedcontent>のクローン実装における、技術的背景をお話ししています。

2024/12/9時点でのselectの各パーツの定義 2024/12/9時点でのselectの各パーツの定義

Ep.19では、CEReactions タイミングで Node 変更の検知をする問題から、同期的なタイミングで Node 変更の検知をする方針に切り替えることが主張された経緯と、cloneNode()の制限についてお話ししました。

今回は、一連の議論の結果、現状の<selectedcontent>の仕様がどうなっているのかをみていく、<selectedcontent>の最終エントリです。

「Node の変更検知タイミング」に関しては、元を辿れば、「<option>の子 Node が変更された際、検知するか?しないのか?」という Open UI での問いかけから始まりました。

WHATWG での議論が長引き始めた今、そもそも「Node 変更検知のタイミング」を UA 側でコントロールする必要性自体が、疑問視されます。

Timing of cloning for the `<selectedoption>` element · Issue #10520 · whatwg/html
What is the issue with the HTML Standard? The <selectedoption> element is discussed more generally here, and this issue is for a topic which has been split out: w3c/csswg-drafts#10242 When the auth...
Timing of cloning for the `<selectedoption>` element · Issue #10520 · whatwg/html favicon github.com
Timing of cloning for the `<selectedoption>` element · Issue #10520 · whatwg/html

<option>変更時のクローンに関する整理と提案

上記コメントを投げかけた Jake は、自身のブログで、<option>変更時のクローンタイミングに関する整理と提案を行いました。以降の Option1~4 は、彼のブログを参考に筆者がまとめたものです。

How should <​selectedoption​> work?
It's part of the new customisable `<​select​>`, but there are some tricky details.
How should <​selectedoption​> work? favicon jakearchibald.com
How should <​selectedoption​> work?

Option1: デフォルトではクローンせず、更新をトリガーする方法を提供する(変更検知でのクローンをOpt-in)

この方法では、<option>が明示的に選択されたときであれば、<selectedcontent>にクローンされるようになります。しかし、のちに<option>の子 Node の DOM やスタイルが変更された際には、再クローンせず、”Out-of sync”な状態になります。

デフォルトでは子 Node の変更を検知してクローンしない上に、”Out-of sync”な状態になったからといって、再クローンする手段がないわけではありません。

<selectedcontent>に対して、resetContent()を利用することで、Author 側から再クローンをトリガーすることができます。

つまり、<option>が明示的に選択されたとき、またはresetContent()が呼ばれたときに、<selectedcontent>へのクローンが行われることになります。

Option2: 常に同期的にクローンする(変更検知でのクローンをOpt-out)

この方法では、<option>の子 Node が変更されるたびに、常に<selectedcontent>の子 Node を、<option>最新の子 Node をcloneNode() した結果に置き換えます。

<option>内の全ての DOM をクローンするため、例えば、<option>内部の 1 つの要素の属性を変更しただけでも、<selectedcontent>内の全ての要素がクローンで置き換えられます。

クローンは同期的に行われるため、予想以上に頻繁に行われる恐れがあります。例えば、上記の React の例で、<option>Loading…</option>がアイコン付きのオプションに変更される場合、<selectedcontent>内の変更は3回行われます。

例えば、次のような Node の変更があったとしましょう。

// 選択された<option>を取得
const selectedOption = select.selectedOptions[0];
// 選択された<option>最初の子Nodeを、選択された<option>自体にappend
selectedOption.append(selectedOption.firstChild);

この場合、<option>内の子 Node は 2 回クローンされます。要素を append するには、「削除」&「挿入」という 2 回の Tree 変更が必要だからです。

例えば、<option>内の要素のスタイルを 10 回変更する場合、それぞれの変更はスタイル属性を更新するため、<selectedcontent>の子 Node は 10 回クローンで置き換えられることになります。

また、<option>内の要素で CSS Animations を使用する場合は、フレームごとにelement.styleが変更されます。つまり、<selectedcontent>の子 Node は、フレームごとに再構築されることになります。

過度な変更検知だけでなく、<option>内の子 Node の変更が、<selectedcontent>の子 Node に反映したくない場合も考えられます。

例えば、<option>内の子 Node と、<selectedcontent>の子 Node は独立した要素であるため、それぞれに独立したスタイルを当てることができます。 しかし、例えば、JavaScript を使って、mouseenter時にelement.styleを変更すると、<selectedcontent>に反映されてしまいます。<option>内の子 Node と、<selectedcontent>の子 Node を独立した要素として扱いたい場合、このような挙動は期待しないものとなるでしょう。

加えて、将来的に起こる問題も考えられます。 例えば、<details>は、開いた状態の時<details open>となる仕様になっています。つまり、<details>は自身の属性を変更します。 もし、選択された<option>内にこうした「自身を変更する要素」ある場合、変更が検知され、クローンが走り、<selectedcontent>内の<details>も開くことになります。

加えて、この自動的なクローンは<option>から<selectedcontent>への一方通行です。 <selectedcontent>で子 Node を変更することに意味はなく、<selectedcontent>での手動の変更は、<option>の子 Node が次回クローンされるときに上書きされてしまいます。

Option3: debounceさせて、常に同期的にクローンする(変更検知でのクローンをOpt-out)

上記の方法とほとんど同じですが、<option>の子 Node が変更されると、<selectedcontent>の子 Node は、非同期のマイクロタスクタイミングでcloneNode()した結果に置き換えられるところに違いがあります。

同期ではなく、非同期のマイクロタスクタイミングで変更を検知することより、クローンの回数を大幅に減少させることができます。 Option2 で挙げた例を参考にすると、<option>の子 Node の変更は、マイクロタスクタイミングでまとめることができ、クローンは 1 回のみとすることができます。

しかし、この方法にも問題があります。

// 選択された<option>を取得
const selectedOption = select.selectedOptions[0];
// <selectedoption>を取得
const selectedOptionMirror = select.querySelector('selectedoption');

selectedOption.textContent = 'New text';

// クローンが非同期で遅延されているため、trueにならないかもしれない
console.log(selectedOption.textContent === selectedOptionMirror.textContent);

このほかにも、Option2 で挙げた大半の問題は残存します。次のような問題は、Option3 でも引き続き発生することになります。

Option4: ターゲットのDOM変更を行う

UA が<option>内の各子Nodeとそれに対応するクローンをリンクしておく方法です。元の要素の属性を変更すると、そのクローンの属性も更新され、該当するクローンの属性のみが更新されます。つまり、<option>の子 Node がひとつ変更されるたびに、<selectedcontent>の全ての子 Node がクローンで置き換えられることはなくなります。

挿入も同じで、選択された<option>に新しい要素が挿入されると、その要素はクローンされ、<selectedcontent>内の同等の位置に挿入されます。

ただ、この方法でも次の問題は残ります。

例えば、クローン要素の挿入は、UA ではelement.insertBeforeを使用して実装されます。参照している特定の Node の前に新たなクローンを挿入するにあたって、<selectedcontent>の内容が Author 側で変更されてしまうと、UA 側でクローンの挿入が失敗する恐れがあります。

結論

Open UI での議論の結果、最終的には Option1 が採用されることになり、「デフォルトでは子 Node の更新を検知してクローンせず、resetContent()などの、更新をトリガーする方法を提供する」方針が採用されました。

RESOLVED: dont observe mutations in option elements. only clone into selectedoption during parsing and when a new option becomes selected

The full IRC log of that discussion

つまり、現状の<selectedcontent>は次のような暫定仕様になっていると言えます。


cloneNode()の制限の件など、未だに<selectedcontent>の仕様は策定中です。しかし、「選択された<option>の子 Node の<selectedcontent>へのクローンタイミング」に関しては、長い議論を経てようやく落ち着く結論となりました。

次回は、Ep.1~Ep.19 までで追ってきた CSE の現状をまとめます。

それでは、また明日⛄

See you tomorrow!

Appendix

Standard Positions