ページ内の見出しへ飛ぶリンクは単純に見える。

<a href="#details">詳細へ</a>
<section id="details">...</section>

このリンクを押すとURLの末尾が#detailsになり、該当する要素がページ内の移動先になる。CSSで:targetを使えば、その要素だけを強調することもできる。

:target {
  outline: 2px solid red;
}

普段は「URLの#以降とidが一致する」という理解で困らない。ただ、自作ブラウザのOmoikaneへ:targetを入れようとすると、それだけでは足りなかった。URLを読む処理、対象を選ぶHTMLの規則、Documentが持つ状態、CSSのセレクタ照合、location.hashを変えた後の更新がそれぞれ別の場所にあるからだ。

特に面白かったのは、:targetの条件をCSSの関数内で毎回URLとidから計算するのではない、という点だった。HTMLの側でDocumentの対象要素を決め、その状態をCSSが参照する。今回は、その間にある処理を対象要素の追加と同一Document内のフラグメント遷移への対応から追ってみる。

まず、何が変わるのか

次のHTMLを考える。

<style>
  section:target { color: rgb(13, 42, 71); }
</style>

<section id="first">最初の節</section>
<section id="second">次の節</section>

URLがhttps://example.test/page#firstなら、firstの要素が:targetに一致する。#secondへ変われば一致先も移る。getComputedStyle()の色だけでなく、document.querySelector(':target')やelement.matches(':target')の結果も変わる。これらを別々の規則で実装すると、画面では色が変わったのにDOM検索は古い要素を返す、といった不整合が起きる。

ここには二種類の仕事がある。一つはURLから「このDocumentの対象はどの要素か」を決めること。もう一つは、決まった対象をCSSやDOMのセレクタ照合に反映することだ。URLが変わったときは、前者をやり直し、後者が古い結果を使わないようにする必要がある。

flowchart TB
    URL["DocumentのURL<br/>#second"] --> HTML["HTMLの規則で対象を選ぶ"]
    HTML --> STATE["Documentのtarget element<br/>secondの要素"]
    STATE --> MATCH["セレクタ照合<br/>:target"]
    MATCH --> STYLE["CSSのスタイル・描画"]
    MATCH --> DOM["querySelector / matches"]

上の図でCSSに入る前の箱が重要になる。URLそのものをCSSの各呼び出しへ渡すのではなく、Documentの状態を更新してから照合する。

:targetはCSSだけの規則ではない

Selectors Level 4の:targetは、文書のURLが指す対象要素に一致する擬似クラスとして定義されている。#detailsというフラグメントがあれば何でも一致する、という意味ではない。対象要素が選ばれなければ、:targetに一致する要素もない。

では、その対象要素を誰が選ぶのか。HTML文書では、HTML Standardのフラグメントに関する手順が決める。仕様には「Documentのindicated part」と「target element」が登場する。前者はURLのフラグメントが示す部分で、通常は要素だが、文書の先頭を示す特別な値になる場合もある。後者は:targetの判定に使われるDocumentの対象要素だ。

この区別はhttps://example.test/page#を考えると分かりやすい。空のフラグメントは文書の先頭を指す扱いだが、「文書の先頭」という特別な値はHTML要素ではない。したがって、空のフラグメントを付けたからといって、htmlやbodyが自動的に:targetへ一致するわけではない。ページ内の移動先と、CSSで一致する要素は似ていても同じ概念ではない。

また、:targetは:hoverや:focusとも違う。マウスが上にあるか、キーボード入力先か、という状態ではなく、そのDocumentのURLに結び付いた状態である。別のURLを持つiframeのDocumentまで、親ページの#detailsで同じ要素を対象にしてはいけない。

フラグメントとidを単純に比較できない

対象の選び方を「location.hash.slice(1)と各要素のidを比較する」と書くと、普通の例は通る。しかしHTMLの規則には照合する順序がある。

まず、フラグメントをそのまま使って候補を探す。同じ値のidを持つ要素があれば文書順で最初のものを選ぶ。該当するidがなければ、古い形式の<a name="...">も候補になる。それでも見つからなければ、フラグメントをパーセントデコードし、UTF-8として解釈してから同じ探索をもう一度行う。仕様にはその後のtopの特例もある。HTML Standardの選択手順は、こうした順序まで指定している。

flowchart TB
    F["URLのフラグメント"] --> RAW["元の文字列で探す"]
    RAW --> ID1{"一致するidはあるか"}
    ID1 -->|はい| HIT1["文書順で最初の要素"]
    ID1 -->|いいえ| NAME1{"一致するa[name]はあるか"}
    NAME1 -->|はい| HIT2["文書順で最初のa要素"]
    NAME1 -->|いいえ| DEC["パーセントデコードして再探索"]
    DEC --> ID2["idを優先し、次にa[name]"]
    ID2 --> RESULT["要素、または対象なし"]

例えば、URLが#encoded%20valueで、ページ内に次の二つがあるとする。

<div id="encoded%20value">生のフラグメント</div>
<div id="encoded value">デコード後のフラグメント</div>

最初の要素が選ばれる。%20を先に空白へ変換してしまうと、二番目を選んでしまう。Omoikaneの回帰テストには、この生の文字列が優先される例と、生の文字列に候補がない場合だけデコード後のidへ進む例が入っている。

idとa[name]にも順序がある。

<a name="legacy">古い形式のアンカー</a>
<div id="legacy">IDを持つ要素</div>

URLが#legacyなら、先に現れるaではなくidを持つdivが選ばれる。ここ、文書を前から歩いて最初の文字列一致を返すだけだと間違う。重複したid同士なら文書順で最初を使うが、idと古いnameでは属性の種類が優先順位を持つ。

Omoikaneのfind_fragment_targetは、生のフラグメントで候補を探し、なければパーセントデコード後にもう一度探す。探索中は一致するa[name]を覚えておき、idが見つかったらそちらを返す。上記のテストには、空のフラグメント、一致しないフラグメント、UTF-8のパーセントエンコード、重複したidも含まれる。

対象はDocumentごとに持つ

URLから要素が決まっても、CSSの照合ごとにDOMを全走査する構成にはしなかった。OmoikaneではDocumentの識別子ごとに、現在選ばれている要素への弱い参照をHostStateへ持たせている。元のURLを読む処理と、:targetの照合をここで切り離せる。

「弱い参照」を使う理由もある。対象として選ばれた要素が後で文書ツリーから外れても、フラグメント用の記録だけがそのNodeを保持し続けるのは避けたい。対象を更新する際は以前の参照を調べ、前の要素からtarget状態を外し、次の要素へ付け替える。対象がなくなったらDocumentの記録も消す。ランタイムの終了時にも残っている状態を片付ける。

この処理はOmoikaneのupdate_document_targetにある。初期Documentだけでなく、子iframeのDocumentや別の閲覧コンテキストを作る経路でも呼んでいる。#detailsという同じ文字列が親と子の両方に現れても、対象はそれぞれのDocumentのURLから別々に決まる。親のURLが変わっただけで子の:targetを更新しない、という境界にもなる。

対象を持つ場所を選ぶとき、最初はNodeがidを持っていることから「Nodeの属性を見ればよい」と考えがちだった。しかしidは候補を探すための情報であり、現在その要素が選ばれているかはURLとDocumentの状態に依存する。id="details"の要素が存在しても、URLが#otherなら:targetではない。URL側の状態をNodeに付ける場合でも、選択を管理する主体はDocumentに置く必要がある。

CSSとDOM検索で同じ答えを返す

Omoikaneは選ばれたNodeにtarget状態を付け、共通のセレクタ照合でその状態を読む。src/css/matcher.rsでは:targetがNodeのtarget状態に一致するかを調べる。CSSのスタイル適用だけでなく、querySelector()、querySelectorAll()、matches()もこの照合を通る。

初期URLが#firstのテストでは、次を合わせて確認している。

const target = document.querySelector(':target');
const matches = target?.matches(':target');
const color = target && getComputedStyle(target).color;

ここでtargetだけが正しくても、colorが元のままならCSS側の適用ができていない。反対に色だけ変わってもquerySelector(':target')がnullなら、DOMのセレクタ検索とCSSの照合が別々の状態を見ている。回帰テストは一つの入力URLについて、選ばれたNode、セレクタ一致、DOM検索、計算後の色を同時に調べている。

その前提として、パーサが:targetを有効な擬似クラスとして受け入れなければならない。location系擬似クラスのパース修正では:link、:visited、:any-link、:targetを認識するようにした。名前を受け付けることと、実際に一致する要素を正しく決めることは別の段階だ。a:target(x)のように引数を付けた無効な形は拒否する。querySelector()に渡した場合も、不正なセレクタを黙って「一致なし」にするのではなく、構文エラーとして扱う。

CSSから見れば:targetはセレクタの一部だが、その真偽を決めるまでにはHTMLの対象選択とDocumentの状態が必要になる。パーサだけ直しても、URLを更新する経路だけ直しても、ページから見える挙動は揃わない。

location.hashを変えた後が大変

最初のURLだけで:targetが合うなら、実装は比較的簡単だ。難しいのは、同じDocumentを保ったままフラグメントだけが変わる場合だった。たとえば#firstを表示しているページで、スクリプトが次を実行する。

location.hash = '#second';

Documentは新しくならない。CSSの対象だけでなく、URL、履歴、hashchangeなども同じDocumentの遷移として扱う必要がある。Omoikaneのlocation.hash代入は、新しいURLを組み立ててナビゲーションを要求する。単なるJavaScriptオブジェクトのプロパティ更新として終わらせない。

同一Document遷移の修正では、CDP側でURLの変更を受け入れた後に、JavaScriptランタイムへ確定したURLを渡す経路が入った。ランタイムは現在のDocumentのURLを更新し、そのURLで対象を選び直す。実装上の順序としては、「location.hashへの代入を受ける」と「URLの遷移を確定する」を分けている。代入を見た瞬間にCSS状態だけ先行して変えると、URLや履歴と:targetが違う時点を示しかねない。

flowchart TB
    JS["location.hash = '#second'"] --> NAV["同一Documentのナビゲーション"]
    NAV --> COMMIT["新しいURLを確定"]
    COMMIT --> SELECT["対象を選び直す"]
    SELECT --> SWAP["firstを解除<br/>secondを対象に"]
    SWAP --> CACHE["Documentのスタイルキャッシュを無効化"]
    CACHE --> OBS["CSSとDOM検索に新しい結果"]

対象を付け替えれば終わり、と思いきや、すでに計算したスタイルが古いまま残る。Omoikaneのupdate_document_targetは対象が変わったとき、Documentのスタイルキャッシュを無効化する。これにより、次のgetComputedStyle()や描画が新しい:targetの一致結果を使える。対象が同じなら不要な無効化は行わない。

CDP経由の回帰テストでは、最初に#sectionへ移動し、次にlocation.hash = '#next'を実行し、最後にフラグメントを空にしている。それぞれでlocation.hash、hashchangeの回数、querySelector(':target')、古い対象と新しい対象のmatches(':target')、計算後の色を確認する。子iframeには同じid="section"の要素も置くが、親の遷移によって子の:targetにはならない。

このテストの面白さは、個々のAPIが単独で動くことではなく、同じ遷移を見ているかどうかにある。URLが#nextなのにquerySelector(':target')がsectionを返す、あるいはDOM検索はnextなのに色だけsectionへ残るなら、どこかの状態更新か無効化が抜けている。

:targetとスクロールは同じ仕事ではない

フラグメントリンクの話では、対象へスクロールする動作も一緒に思い浮かべる。しかしHTML Standardでは、対象を選ぶことに加え、対象を表示するためのスクロールやフォーカスに関する手順も定めている。CSSの:targetが一致することだけで、スクロール位置まで正しくなるわけではない。

今回追ったOmoikaneの修正とテストは、対象の選択、同一Document内のURL変更、CSS・DOMの結果の一致が中心だ。この範囲をもって「フラグメントナビゲーションの全仕様を実装した」とは言わない。仕様には空のフラグメントやtopの特別扱いもあるし、スクロール、フォーカス、履歴との相互作用には別の確認が必要になる。

実装を読むときも、「#detailsが付いたら該当要素を探す」という一文だけで済ませない方がよかった。表示のための移動先とCSSの対象要素、Documentの状態とNodeの属性、初期URLと後から変わるURLを分けると、どのテストが何を保証しているか見える。

何を確認できたか

今回の追加テストは、少なくとも次の境界を押さえている。

入力や操作確認していること
#firstを持つ初期URLCSS、DOM検索、matches()が同じ要素を選ぶ
#encoded%20valueデコード前のidを先に探す
#only%20decoded生の値に候補がなければデコード後を探す
idとa[name]が同じ値文書内で後に現れてもidを優先する
重複したid文書順で最初の要素を選ぶ
#sectionから#nextへの変更古い対象を外し、DOM検索とスタイルを更新する
フラグメントを空にする対象がなくなり、以前のスタイルも外れる
親と子iframeに同じid親のURL変更を子Documentへ波及させない

一つずつは小さなケースだが、URL、HTML、CSS、JavaScriptの接点を通るように置かれている。画像の見た目だけを比較するテストでは、querySelector(':target')の不一致は分からない。DOMの値だけを比べるテストでは、スタイルキャッシュに残った古い色を見逃す。両方を確認する理由がある。

まとめ

:targetはCSSの短い擬似クラスだが、その答えはCSSだけでは作れない。HTMLがURLのフラグメントからDocumentの対象要素を選び、Omoikaneはその状態をDocumentごとに保持する。CSSの照合とDOMの検索は同じ状態を読み、URLが変われば対象を付け替えて古いスタイルを無効化する。

実装中に印象に残ったのは、生のフラグメントとデコード後のフラグメントの順序、idとa[name]の優先順位、そして同じDocumentのままURLだけが変わる場合だった。どれも普通の#detailsでは見えないが、ブラウザの振る舞いとしては決めておかなければならない。

小さなCSS機能に見えても、その背後ではHTMLの対象選択とナビゲーションの状態が動いている。Omoikaneでは今回、そのうち:targetがCSSとDOMから同じように観測できるところまでを実装し、テストで確認した。