自作ブラウザの Omoikane で、Web APIやCSSの互換性に関する変更をいくつか進めた。

今回の変更を並べてみると、JavaScriptから見えるAPIを追加するだけでは終わらないものが多かった。ページの状態をホストへ伝えたり、JavaScriptの操作をHTTP通信へ反映したり、CSSで決まったスクロール位置を入力処理の最後まで保ったりする必要がある。

CookieがJavaScriptとHTTPで別々だった

最初に直したのはCookieの扱い。

以前は、サーバーから受け取ったSet-CookieはHTTP側で管理されている一方、document.cookieは別のJavaScript用Mapを参照していた。そのため、レスポンスにCookieが含まれていてもページから読めず、JavaScriptで書いたCookieも次のfetchや画面遷移には送られなかった。

ブラウザ内でCookieを二つの場所に保存していたのが原因なので、document.cookieとHTTPリクエストが同じCookie jarを使うようにつないだ。Cookieを保存するだけでなく、リクエスト先のsiteやトップレベルナビゲーションかどうかも通信経路で受け渡し、SameSiteSecureHttpOnly、Domain、Pathなどの条件を判断する。siteの比較にはschemeful siteを使い、Public Suffix Listを参照してco.ukのような公開suffixをCookieのDomainとして受け入れないようにした。

これで、例えばスクリプトから設定したCookieが同じページのHTTPリクエストに使われるようになった。逆に、HttpOnly Cookieをスクリプトから読んだり上書きしたりできないことや、SameSiteの条件に合わないクロスサイト通信へ送られないこともテストしている。

Cookieの保存場所を統一するだけでは不十分で、リダイレクトやフォーム送信、iframeなど、リクエストを作る側から「どのページが起点か」を渡せる必要があった。リダイレクト後も起点siteとナビゲーション種別を引き継がないと、最初のrequestだけ正しくても次のrequestでSameSite判定が変わってしまう。

テストはローカルHTTP serverを使い、スクリプトで設定したCookieが後続requestに送られること、サーバーから設定されたCookieがページから見えること、HttpOnlyやcross-siteのPOST・Lax GETで送信条件が変わることを確かめている。なお、stylesheetや画像、module scriptなど、個別のHTTPクライアントを持つ一部のサブリソース経路は別の課題として残っている。

変更は Cookie jarを接続したPR #862

ページの表示状態をDOMへ反映する

タブやウィンドウが非表示になったことをページが知るためのPage Visibility APIも追加した。document.visibilityStatedocument.hiddenを公開し、表示状態が変わるとvisibilitychangeを送る。

この状態はDOMだけでは決まらない。タブの切り替えやウィンドウの状態はホスト側にあるので、ホストからブラウザ内部へ通知し、Documentの状態とイベントへ反映する。iframeの破棄やページ遷移時にも状態変化が必要になる。Chrome DevTools ProtocolのPage.setWebLifecycleStateからactive / frozenを指定する経路も追加し、ホスト側の可視性と合成して扱う。

非表示になっただけで保留中のrequestAnimationFrameを捨てるのではなく、再表示時に実行できるよう保持する。凍結状態と画面上のvisibilityは似ているが同じではないため、別々の状態を合成してDocumentから見える値を決めている。

この実装中には、iframeのloadイベントを動かすために、スクリプトを持たないiframeにも子Realmを作ってしまう経路があった。loadハンドラを実行する必要がある場合だけRealmを作るようにし、不要な初期化を避けた。互換性のためにイベントを正しく動かすことと、イベントを使わないページへ余計な仕事を増やさないことを一緒に考える必要があった。

また、addEventListenerのoptionsをDOMの各EventTargetで共通して扱うようにした。signalがabortされたらlistenerと関連するdetach handlerを解除し、passive listener内のpreventDefault()ではイベントをキャンセルしない。options: nullも例外にせず既定値として処理する。

以前はrootのwheel listenerがpassiveとして扱われず、ページ側のlistenerが既定のスクロールを止められる場合があった。イベントターゲットによって別々にoptionを解釈していた部分を共通化し、Node、通常のEventTarget、XHRとその派生ターゲットで同じ解除規則を使うようにした。WPTのonce・passive・signalテストも固定revisionで実行している。

Page Visibilityの変更は Page Visibility APIのPR #864 、listener optionは イベントリスナーのPR #860 に含まれている。

スクロール位置は入力経路の最後まで決める

CSS Scroll Snapでは、scroll-snap-typescroll-snap-alignを指定しても、スクロールの終点がスナップ位置に揃っていなかった。スタイルを計算できるだけでは足りず、実際にスクロールが止まる場所を選ぶ必要がある。

スクロール領域のsnapportと各要素のsnap areaを計算し、mandatoryproximityscroll-paddingscroll-marginなどから終点を選ぶ処理を追加した。候補位置は物理軸・論理軸の両方から評価する。スクリプトによるスクロールだけでなく、wheel、smooth scroll、scrollIntoView()、再レイアウト後の位置にも同じ判断を適用している。RTLや縦書き、入れ子のスクロール領域、スナップ領域がコンテナより大きいケースも回帰テストに加えた。

関連して、scroll-behaviorScrollOptions.behaviorによるsmooth scroll、overscroll-behaviorによるスクロール連鎖の制御も実装した。smooth scrollはscheduler上で補間し、途中の入力による中断やnavigation時のキャンセル、イベント順序も扱う。内側の領域が端に達したとき、残ったwheelやtouchの入力を外側へ渡すかどうかを軸ごとに扱い、containnoneではホスト側のoverscroll動作も区別する。

固定fixtureでの比較では、スナップあり・なしで期待した停止位置が変わり、Firefoxとの描画比較でも追加fixtureの画素差分が0になった。一方、root/windowの一部スクロールテストには、snapとは別にviewportのoverflow処理が原因とみられる既知の失敗が残っている。変更は CSS Scroll SnapのPR #866

HTTPとCSSの細部も更新した

HTTPまわりではURL解釈をWHATWG URL parserへ寄せ、リクエストヘッダーの検証、リダイレクト時のヘッダー方針、HTTP/1の応答フレーミングを見直した。以前の独自解析ではIPv6 authorityやuserinfo、相対URLの解決で誤り得るうえ、リダイレクト先へ資格情報を引き継ぐ危険もあった。JSのURLとネットワーク要求で同じparserを使い、request headerはwireへ書き出す前に検証し、redirectでは機密ヘッダーの扱いを一か所で決める。

HTTP/1ではinformational responseを読み飛ばし、HEADとbodyを持たないstatusを扱う。transfer-codingの並び、chunkのサイズと終端、trailerも検査し、framingが曖昧な接続は再利用しない。HTTP/1とHTTP/2のresponse bufferingには既定64 MiBの上限を設け、OMOIKANE_MAX_HTTP_BODY_BYTESで変更できるようにした。巨大な応答を一度に確保するケースや、壊れたframingを次のresponseとして誤読するケースを避けるための変更。 HTTP処理を堅牢化したPR #859

CSSの@propertyCSS.registerProperty()では、登録名の重複やsyntax、初期値、inheritsを検証し、型付きcustom propertyのcomputed valueとtransition / animationへつないだ。@counter-styleではcyclicextendsなどのsystemとsymbols、prefix、suffixを登録し、同名styleをorigin・cascade layer・source orderで解決する。

印刷時の@pageではscreen用のstyle解決から独立してprint mediaを渡し、selectorと宣言をorigin、layer、specificity、source orderで選ぶ。用紙サイズ、余白、名前付きpageと基本的な改ページを扱うところまでで、margin boxやページごとの完全な再レイアウトは今回の範囲外。

multicolでは、ブロック子要素を分割できず、要素を丸ごと一つのcolumnへ置いていた。fragmentごとのsource/target geometryを保持し、column balancing、getClientRects()、paint、hit testing、scroll overflowで共有するようにした。box-decoration-break: slice | cloneも扱い、半透明背景がfragmentごとに重なって濃くならないよう、clone decorationは一度だけ描画する。固定Firefox fixtureでは新しい3ケースのgeometryとRGBA画素が一致した。 印刷時の@page対応PR #834 multicol断片化のPR #824

Acid3の時間制限に合わせてDOM hot pathを見直した

互換性機能が増えると、その機能を使っていないページのDOM操作まで遅くなることがある。Acid3のtest26とattribute_records_gcが5秒制限に近づいたため、DOMの属性アクセスやmutation処理を計測し直した。

例えばNamedNodeMap.lengthitem()は、要求された個数や一件だけを返せばよいのに、以前は全attribute recordをcloneしてから変換していた。これを必要な値だけ読むようにした。また、custom elementの定義がないときは専用のsubtree bookkeepingを行わず、MutationObserverがいないときはsibling検索を避けるなど、利用されていないhookをhot pathから外した。

同じx86 runner内での比較では、Acid3 test26のDirect modeが4.08秒から3.46秒、Faithful modeが3.71秒から3.19秒へ短縮したrunがあり、attribute_records_gcは4.35秒から1.83秒になった別runがある。runner速度の変動があるため単一の数字を一般化はできないが、同一run内の比較では改善し、両モードともAcid3 100/100を維持した。 DOM hot pathの性能改善PR #835

互換性は状態の受け渡しで決まる

今回の作業では、CookieならJavaScriptとHTTP、Page VisibilityならホストとDocument、スクロールならCSSの計算と実際の入力・描画というように、別々の層にある状態を揃える場面が多かった。WPTは変更した領域ごとに固定revisionで実行し、回帰がないかを確認している。全体テストでも、直近のPage Visibility対応では2,886件のtestが成功し、Scroll Snapでは273件が成功、既知のroot/window関連25 subtestだけが別issueで追跡されている。

API名やCSSプロパティを認識するだけなら比較的早く追加できる。しかし、ページが観測する状態を通信や描画の結果まで一貫させるところで、ブラウザとしての互換性が決まってくる。引き続き、仕様項目を増やすだけでなく、固定したWeb Platform Testsやブラウザ比較で、振る舞いがつながっているか確認しながら進めていく。