2018年12月7日
InputEvent.inputType
がbeforeinput
イベントの実装時に非常に重要なので、これ(とbeforeinput
)を実装するための準備として、Geckoのエディタを大きく書き換えていました。これをブロックしているバグを見ると、なんと5月ぐらいに取りかかってるようなので、実に半年間もやっていたわけです。
Geckoのエディタのコンクリートクラスは、TextEditor
とHTMLEditor
という名前通りのふたつのクラスがあります。前者は当然、<input>
要素や、<textarea>
要素、後者は、document.designMode
やcontenteditable
属性の指定された要素があるドキュメントでそれぞれインスタンス化されています。これらのエディタは、確実にinput
イベントを何らかの編集後に発火させるために編集が見込まれるメソッドの各所にスタック上にインスタンス化されるクラスを設置し、それの最後のインスタンスが破棄される際にinput
イベントが発火されるようにしています。この設計は普段のメンテナンス性を高める点では優秀ですが、InputEvent.inputType
の実装を行う今回のように、編集内容をイベント発火時に知ることが困難です(複数の編集がネストすることがあり、それぞれに対して別のinput
イベントを発火する必要があるため、なおさらです)。
そこで私が考えたアプローチは、これらの基底クラスも含め、ユーザの入力で編集が始まったとき、もしくは、Webアプリがエディタを通して何らかの編集を開始した時に、スタックに新しいクラスをインスタンス化し、その中にユーザ、もしくはWebアプリが行おうとした編集内容を記録しておき、input
イベントの発火時にはその情報を参照しようというものです。そうすれば、どんなに複雑な編集が行われたとしても、編集内容を示すInputEvent.inputType
は確実に、なおかつ、的確に設定できることが保証されます。しかし、これを行うには、エディタ外部からの呼び出し時にのみそのインスタンスを生成するという必要があります。
ここでまず問題になったのが、外部から呼び出されるメソッドがそのままpublicメソッドでは無いという点です。なぜなら、publicメソッドはクラス内部からも呼び出されるためです。もし、Webアプリによって、ユーザの編集中に他の編集がネストできないならこれは問題になりません。しかし、Web標準仕様はそれを許しています。つまり、純粋に外部から呼び出される場合にのみpublicメソッドを使用し、エディタ内部で編集中にはprotected以下のスコープのメソッドを呼び出すようにエディタ全体を書き変えるしかありません。
ところがここで問題になったのはこれら全てのクラスのpublicメソッドの多さです。具体的には、これらのクラスはnsIEditor
、nsIPlaintextEditor
、nsIHTMLEditor
、nsIEditorMailSupport
、nsIEditorStyleSheets
、nsIHTMLAbsPosEditor
、nsIHTMLInlineTableEditor
、nsIHTMLObjectResizer
、nsITableEditor
といった多くのインターフェースを実装し、さらにQuantum Flowプロジェクトで高速化のために削除した、もしくは複製されたpublicメソッドを多々もつクラスです。
この絶望的な物量を前にしても、内部からも呼び出されるpublicメソッドはprotectedメソッドとして実装し、publicメソッドは外部からの呼び出し時にも用い、対応するprotectedメソッドを呼ぶだけにしていくしかありませんでした。より、C++として安全な実装(ビルド時に内部からpublicメソッドが呼ばれないようにする)があるかもしれませんが、ひとまず、こういった形で半年かけて大きく書き換えたのがこのバグ修正となっています。
他のバグの修正中に気付いたのですが、<input>
要素や<textarea>
要素の数だけインスタンス化されるTextEditor
クラスが64bit版では544バイトも使っていました。さらに悪いことに、Geckoで採用しているjemallocは、メモリのフラグメンテーションを抑制するために、特定のサイズ内のメモリ確保時には、それが収まる容量用に確保されたエリアに確保されます。このボーダーラインが512バイトということもあり、今まではTextEditor
クラスのインスタンスはひとつあたり1024バイトも使うということになってしまっていました。
TextEditor
やその基底クラスであるEditorBase
のメンバを確認してみると、そのうちの多くが実際にユーザのアクションに反応して、もしくは、Webアプリからの指示で編集中にのみ必要なものが非常に多いことが分かりました。
ここで、Bug-org 1465702の修正が思わぬ形で使えることが分かりました。このバグの修正により、エディタは編集中にのみ、一時的にスタック上にクラスをインスタンス化し、それを簡単に参照できるようにしています。そのため、上述のメンバは全てこの中に移動できるのです。
さらに、メモリフラグメンテーションの抑制と、メモリ確保時のオーバーヘッド削減のために、確実に登録されていたエディタの様々なリスナを登録するための配列に確保していた容量も、Quantum Flowプロジェクトでそのリスナが減った結果、予約しなくてよくなっていることも発見しました。
これらを削減することにより、バグの登録時には512バイトのラインを少し割れば良いかなと思っていたものが、なんと、400バイトにまで削減することに成功しました。つまり、<input>
要素や<textarea>
要素ひとつひとつで消費されるメモリ量がエディタ部分に関しては半減します。スタック内に一時的に確保すれば十分なものをヒープに確保してはいけませんね、というのを実感したバグでした。
ちなみに、macOSネイティブアプリのアロケータもjemallocらしいので、macOSを使っていると、この辺に明るくないアプリ開発者が作ったアプリに無駄にメモリを使われてる事が多いんではないかなという気はします。
2018年12月11日
非常に長い長い道のりでしたが、ついにこのバグ修正、そして続けてポストされる記事にある、keypress
イベントの挙動を他のブラウザにあわせた修正が完了しました。
UI Events (DOM Level 3 Events)のWGにInvited Expertとして参加していた時に、WGのメンバーと議論を重ね、IMEの未確定文字列がある間(compositionstart
からcompositionend
の間)、キーボードイベントはどのように発火されるべきか、されないべきかというのを決めて、現在の仕様案のように結論付けられています。
私の主張は、Geckoがkeydown
もkeyup
も未確定文字列がある場合に発火していないこともあり、IMEを知らない開発者に不用意にこれらのイベント中にSelection
を変更されたりして未確定文字列を強制確定しないといけないケース、つまり、IMEユーザがIMEを意識せずに作られたWebアプリで困らないように、全く発火させないという形でした。
一方でWG側の主張は、keydown
とkeyup
はキーボードのキーの物理状態を示すイベントであり、keypress
は文字入力を示す、論理状態を示すイベントであるので、keydown
とkeyup
は未確定文字列があっても発火すべき、というものでした。
そして色々とあって、WG側の案が標準仕様として残ることになりました。
このバグの修正により、keydown
イベントと、keyup
イベントが未確定文字列がある場合にも発火するように修正されています。
これらのkey
値は、IMEが処理していた場合にはProcess
、そうではない場合はIME無しで入力される文字、もしくは本来のキー名(Enter
等)となります。この定義が非常に曖昧なのと、プラットフォーム、もしくはアクティブなIMEによって、どのキーイベントを処理したと主張するかはそれぞれに依存することになりますので、KeyboardEvent.isComposing
を参照して、未確定文字列が存在するかどうかを確認する方がシンプルなコードになるでしょう。
ちなみにGeckoでは未確定文字列がある場合、各OS用のキーイベントハンドラが、クロスプラットフォーム部分に対してGecko内のイベントを発火しないようにしていました。このため、最初に全ての内部キーボードイベント、内部IMEイベントをTextEventDispatcher
という新しいクラスを介して発火するように書き換え、ここで集中管理することで、長期間、NightlyとEarly-Betaでのみテスタ向けに新しい動作を有効化していました。
長年、Geckoでは、モディファイアキーを除くキーの入力時に、keypress
イベントを発火していました。しかし、UI Events (旧DOM Level 3 Events)ではkeypress
は文字の入力時にのみ発火するという定義が(後から)行われました(例えば、aの入力では発火するが、Ctrl+aでは発火しない、また、ArrowDownのように、端から文字を入力しないキーの場合にも発火しない)。このバグ修正は、Geckoの動作をUI Evnetsの仕様通りの動作にあわせたものです。
Geckoではほぼ全てのショートカットキーがkeypress
イベントを処理することで実装されています。そこで、まず考えられるのは、全ての内部のkeypress
ハンドラをkeydown
イベントハンドラで書き換えることでした。しかし、ひとつ問題があります。keypress
イベントは、keydown
のpreventDefault()
が呼び出されない限りは発火しなくてはいけません。ですので、先行するkeydown
イベントでのショートカットキーの実装は無理であると分かりました。
仕方ないので、keypress
イベントのリスナを、Gecko内部システムグループに全て書き換えることにしました。Geckoはイベントの発火を毎回2回ずつ行っており、最初の発火でデフォルトグループと呼ばれるリスナ(通常のWebアプリのリスナはすべてこちら)向けに発火し、その後、システムグループのリスナ向けに再発火しています。そう、本来は全てのGeckoもしくはFirefoxのデフォルトアクションは、システムグループのリスナで実装されていなければ、Webアプリのリスナとどちらが先に処理するかというレースが発生してしまうので、これは潜在的だったバグを全て修正していく作業になりました。そしてもちろん、この大きな修正は、大量の自動テストの修正が余儀なくされたり、多くのregressionを出しつつ、潰しつつ、という作業に終始しました。
そして最終的に、上述のデフォルトグループでの発火のみを停止し、Webアプリからはkeypress
イベントが、文字の入力がある場合にのみ発火するかのように見えるようになっています。
ちなみに、Enterキーの扱いのみが特殊です。そもそもEnterが押された場合、改行が行われますが、文字が入る訳ではありません。しかし、どのブラウザでもEnterキーが押された場合にはkeypress
イベントが発火するので、Geckoもそれにあわせています。しかし、モディファイアキーと組み合わせた場合にkeypress
イベントが発火するかどうかはブラウザ依存だったので、GeckoではShift+Enter、Ctrl+Enterはkeypress
イベントを発火するようにしています。特にCtrl+EnterはGeckoでは何も起きないのですが。
ちなみに、この修正はGoogleの検索チームから、これを修正しないとFirefox for AndroidのサポートをTier1から外したままにするよっていう宣告が来たのでかなりがんばって修正したんですが、この修正によって一番広範囲に壊れたのはGoogle Closure Libraryと、これをベースにしてるGoogle Docs等、Googleの他のチームのプロダクトだったというのがオチですかね……(Closureのチームと、Google Docsのチームは本当に大変な修正に尽力してくださったことに、あらためてお礼を言っておきます。ありがとうございました。)
多くのデキの悪いサイトがGeckoで動かない原因のひとつに、イベントリスナにevent
という引数を渡していないのに、event.preventDefault()
等と書き、実質的にwindow.event
を参照しているというケースが多々あったそうです。これに対する互換性としてwindow.event
はWHATWGによって標準仕様に取り込まれることになったので、Geckoでもサポートするようにした模様です。
ところが、いざ実際にこれを実装し、有効化してみると、多くのサイトがwindow.event
をIEと同じkeypress
イベントが来るかどうかを判定するfeature detectionとしてこれを参照していました。当然、これらに関連性は皆無なので、そのように書かれていると動きません。まさにそのようなアプリのコードはfeature detectionの悪い例です。
そこで急遽、keypress
イベントのみ、KeyboardEvent.keyCode
とKeyboardEvent.charCode
の値をどちらか非ゼロの値をもう一方にセットしなくてはいけなくなりました。
Geckoでは、keypress
イベントのKeyboardEvent.keyCode
の値をまずは先行するkeydown
イベントと同じものと仮セットします。次に、KeyboardEvent.charCode
をそのキー入力、もしくは、そのキー入力からCtrl、Alt、またはCommandの状態を取り除いた際に入力される文字のUnicodeコードポイントにセットします。最後に、KeyboardEvent.charCode
値が非ゼロ場合、KeyboardEvent.keyCode
をゼロにしていました。
しかし、他のブラウザでは最後の段階でKeyboardEvent.keyCode
にKeyboardEvent.charCode
の値をセットし、逆に、KeyboardEvent.charCode
の値がゼロの場合(Enterキーの場合)にKeyboardEvent.keyCode
の値をKeyboardEvent.charCode
にセットしていました。
幸いにもnon-printableキーのkeypress
イベントの発火を止める修正が進行中だったので、少ないリスクでこの修正を行うことができました。
それにしても、本当に、論理的に考えずにトライアンドエラーだけで実装しちゃう人が多いんですね。一見関連性のあるもの同士を対にしていたために壊れるサイトが多かったことに非常に驚かされました。