nsIWebProgressListener.onStateChange で DOMContentLoaded のタイミングを受信する話

Secure Login Rebooted の、ログイン情報探索を呼び出す回数を減らそうって話です。
結論から言うと、「イベントハンドラ使わずに state flags だけで DOMContentLoad 取れた!」みたいな都合のいい話はなかった。

nsIWebProgressListener

nsIWebProgressListener とは、リソースの読み込みに応じて登録したリスナを呼び出し、任意の処理を実行可能にする仕組み。これを使うことで、「ページのロード時に○○を実行する」みたいなことが可能になる。詳細は MDC とか参照のこと。
このリスナの登録先は複数あって、nsIWebProgress に登録するとか、gBrowser.addProgressListener() を使って、現在のタブのイベントだけを受け取るとか、gBrowser.addTabsProgressListener() を使って、開いたタブ( xul:browser )のイベントをすべて受け取るとか、色々バリエーションがある。

nsIWebProgressListener の活用例

この仕組みは Secure Login(本家、fork 版)や Secure Login Rebooted でも使っている。本体でも色々な個所で使っている。nsILoginManager あたりでも使ってますね。

本題

nsIWebProgressListener でページの遷移状態を取得する場合、onStateChange() を使うのが一般的だと思うのだけれども、onStateChange() を使用した場合、状態フラグを適切に判別しないと無駄な処理が増えることになる。
Secure Login Rebooted の先日までの実装では以下のようなコードを用いて、ページの読み込みが終了次第、ページの読み込みが完了してから、ページ中のログイン情報を探索するようにしていた。

onStateChange: function (aWebProgress, aRequest, aStateFlags, aStatus) {
	// ページの読み込みが止まっているかどうか
	if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
		//ページ中のログイン情報の探索
	}
},

しかし、この実装には以下のような問題もあった。

  • ページの読み込みが完了するまで( STATE_STOP になるまで)探索ができないぽい
    • load イベントが発火するまで探索できないのとほぼ同義
  • ページ中の iframe の読み込みが完了する度に探索を呼び出す( STATE_STOP になる度に呼び出す)
  • ページ中で通信が発生する度に探索を呼び出す(onStateChange()の罠ですね)

Facebook のトップページや Google のログインページなどであればそこまで問題は無いのだけれども、ログインフォームのあるトップページに iframe 埋め込んでて非同期で通信までしてる pixiv とかだと、ページを開いて放置しているだけで探索が5回も10回も実行されるみたいなひどいことになっているわけ。
で、しょうがないのでどうにかこの探索回数を減らすことにチャレンジしてみた

結局 DOMContentLoad を使う

最初は onStateChange() フラグだけで頑張ってみようとしたのだけれども、どうしても通信が発生する度に onStateChange() が呼び出されて上手くいかない。
しかたがないので DOMContentLoad のタイミングでイベントリスナ呼び出すように登録させることにした。

onStateChange: function (aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) {
	// STATE_STOP だと読み込み完了してるのでDOMContentLoaded使う意味ない
	if (!(aStateFlags & Ci.nsIWebProgressListener.STATE_STOP)) {
		// タブの復元時はDOMContentLoaded発火しない
		if (aStateFlags & Ci.nsIWebProgressListener.STATE_RESTORING) {
			// 探索処理に直行
		}
		else {
			// DOMContentLoaded のタイミングで探索処理を行わせる
			aBrowser.addEventListener("DOMContentLoaded", this, true, true);
		}
	}
},

ページが DOMContentLoad を発火させたタイミングにのみ探索処理を行えることに成功。これで探索回数をガクッと減らせるようになった。

この実装の問題点

上手くいくかなと思ったのも束の間、別の問題発生。https://twitter.com/login を、初めて訪問した場合に、ログイン情報を探索しても見つからなくなる事態が発生する。
リダイレクト先の https://twitter.com/#!/login を直接訪れた場合は、問題なく動いているので、

  1. リダイレクトする
  2. アンカーとか History API でアドレスバー更新する
  3. 非同期でページ内要素構築する

みたいな条件が揃った場合だと、

  • DOMContentLoad 発火後にページが書き換わる
    • 通信の度にチェックしていないので、非同期でリソースを読み込んでページを更新する種類のテクニックに対応できない
  • アドレスバー更新してから少し経った後の変更は onLocationChange() で捕まえられない

ので、ダメなんじゃないかなーと推察。振り出しに戻りかける。
(※挙動から推察してるんで、厳密な発生条件は不明)

こうした問題があったけれども、結局は DOMContentLoad を採用することにした。理由は以下の二点。

  • 問題が発生するページはレアケースである
    • 上記の Twitter の例も、十分に回避可能な状況である
  • 正規のフォームであれ、動的に構築されたログインフォームは行儀が悪いので、無視すべきであるように感じる

とくに下の点が主な理由。今後、History API を使うなどした所謂pjaxが普及するとしても、ログインフォームの動的構築はあまり行儀が良くないように感じる。History API での動的ページ遷移を行っている Github ですら、ログインページは専用の固定ページを使用している。
仮に今後、そうしたログインフォームが一般的になり、無視できなくなった場合は、今回の実装をバックアウトするなりなんなりで改めて問題に対処することにすればよい。

onLocationChange() の新しい引数

Gecko 10、すなわち Firefox 10 からは、nsIWebProgressListner.onLocationChange() に新しくロケーションの状態を示すフラグが加わった
このフラグを用いることで、onLocationChange() が呼び出された時が、History API やアンカーによるロケーションの変更なのか、一般的なページ遷移によるものなのかがわかる。便利ですね。

とはいえ、上述の理由により、onLocationChange() のタイミングで表示されたログインフォームって信頼して良いのか結構疑問なところはある。
その一方で、アンカーなどを利用してページ中の「既に構築されたログインフォーム」の位置に移動するような場合を考慮すると onLocationChange() は必要にも思える。https://twitter.com/#!/login のようなページを訪れた場合、上のディレクトリに移動した場合は、onStateChange()ではなくonLocationChange()が呼ばれる。利用するケースの頻度はともかく、あるに越したことはない。

だが、ページの読み込み時に構築されて、そのまま非表示か何かになっているフォームは DOMContentLoad のタイミングで発見できるだろうし、そうでないものは結局動的生成しているものだし、「 history.pushState() → フォームを構築して DOM ツリーに追加」みたいな場合では、onLocationChange()による探索はまず失敗するので、onLocationChange() は Secure Login Rebooted からはいったん外しても問題ないと考えている。いざとなったら MutationEvent でイベントリスナ追加してどうにかすればいいだろうとは考えてる。

ちなみに nsILoginManager の、フォームへのログイン情報の自動補完は、onStateChange()DOMContentLoad イベントを利用したもの( onLocationChange() は使用していない )なので、そっちの実装に合わせる方針もありかもしれない。

onLocationChange()いったん削除してみた。今後の状況を見て、問題があるようならバックアウトかな。

色々弄った結果

Secure Login のページ遷移時のログイン情報探索回数が、

  • DOMContentLoad イベントの発生時(タブの復元時)
  • History API によるロケーションバーの変更時

に限定されるようになった。
「非同期通信が多い」かつ「ページ中のフォームの要素数が多い」ようなページ、たとえば Twitter とか Google のサービスでのレスポンスは結構良くなったはず。

新しく増えた問題点

この実装では、Firefox で画像・ビデオ・音声ファイルを単体で開いた場合でも DOMContentLoad のイベントリスナは追加される。しかし、これらのファイルを単体で読み込んだ場合、FirefoxDOMContentLoad イベントを発火させないため、イベントリスナの解放処理ができず、メモリリークを抱えてしまうのではないかという微々たる懸念が無いわけではない。