Markdownの重複IDでVS Codeプレビューから見出しが消えなくなった原因

VS CodeでMarkdownを書いていると、エディターから文章を削除したのに、Markdownプレビューには古い見出しが残り続ける現象が発生しました。

文書内の文章をすべて消すとプレビューからも消えます。しかし、一部を削除しただけでは表示が残ります。さらに同じ内容を追加すると、古い表示の上へ新しい表示が積み重なりました。

最初は再現条件が分かりませんでしたが、手書きしたHTMLだけで比較したところ、文書内で重複したidが原因だと分かりました。

HTMLをMarkdownに直接貼り付ける人ならば、遭遇する可能性があります。

目次

発生した現象

Markdown文書へ、同じHTMLタグを複数回貼り付けました。

Markdown
<h2 id="カスタム" id="テスト-カスタム">テスト</h2>
<h2 id="カスタム" id="テスト-カスタム">テスト</h2>
<h2 id="カスタム" id="テスト-カスタム">テスト</h2>
<h2 id="カスタム" id="テスト-カスタム">テスト</h2>
<h2 id="カスタム" id="テスト-カスタム">テスト</h2>

プレビューには5個の見出しが表示されます。

ところが、エディターから5行すべてを削除しても、プレビューでは1個しか消えず、4個が残りました。その状態でもう一度5行を貼り付けると、プレビュー上の見出しは9個になりました。

Markdown
最初の5個

すべて削除しても4個残る

5個追加すると9個になる

ソースとプレビューの内容が一致していません。古いDOM要素が、プレビューの差分更新で正しく削除されていないように見えます。

調査

id属性が2個あることが原因なのか

最初に疑ったのは、1つの要素へid属性が2個書かれていることです。

Markdown
<h2 id="カスタム" id="テスト-カスタム-14">テスト</h2>

HTMLの1要素が複数のid属性を持つのは不正です。しかし、それだけがプレビュー更新を壊す直接の条件なのかは分かりません。

そこで、Markdown拡張機能やHTML変換処理を使わず、5種類のHTMLを手書きして比較しました。

5種類のサンプルで比較する

5種類のサンプル
  • A:IDなし
  • B:すべて異なるID
  • C:単一だが全要素で同じID
  • D:各要素にIDが2個あり、先頭のIDが同じ
  • E:各要素にIDが2個あるが、先頭のIDは異なる
Markdown
<!-- # A:IDなし -->

<h2>A1</h2>
<h2>A2</h2>
<h2>A3</h2>
<h2>A4</h2>
<h2>A5</h2>
Markdown
<!-- B:すべて異なるID -->

<h2 id="unique-1">B1</h2>
<h2 id="unique-2">B2</h2>
<h2 id="unique-3">B3</h2>
<h2 id="unique-4">B4</h2>
<h2 id="unique-5">B5</h2>
Markdown
<!-- C:単一だが全要素で同じID -->

<h2 id="same-id">C1</h2>
<h2 id="same-id">C2</h2>
<h2 id="same-id">C3</h2>
<h2 id="same-id">C4</h2>
<h2 id="same-id">C5</h2>
Markdown
<!-- D:各要素にIDが2個あり、先頭のIDが同じ -->

<h2 id="same-first" id="second-1">D1</h2>
<h2 id="same-first" id="second-2">D2</h2>
<h2 id="same-first" id="second-3">D3</h2>
<h2 id="same-first" id="second-4">D4</h2>
<h2 id="same-first" id="second-5">D5</h2>
Markdown
<!-- E:各要素にIDが2個あるが、先頭のIDは異なる -->

<h2 id="first-1" id="same-second">E1</h2>
<h2 id="first-2" id="same-second">E2</h2>
<h2 id="first-3" id="same-second">E3</h2>
<h2 id="first-4" id="same-second">E4</h2>
<h2 id="first-5" id="same-second">E5</h2>

各サンプルについて、貼り付け、全行削除、再度貼り付けという操作を行いました。

結果は次のとおりです。

サンプル有効なID結果
Aなし正常
Bすべて異なる正常
Cすべて同じ問題発生
Dすべて同じ問題発生
Eすべて異なる正常

CとDだけで、削除した見出しがプレビューへ残りました。

この結果から、直接の原因は「1要素にid属性が2個あること」ではなく、「複数要素が同じ有効IDを持つこと」だと分かります。

考察

ID属性が2個ある場合、どちらが使われるのか

HTMLパーサーは、同じ開始タグに同名の属性が複数ある場合、後ろの属性を別のIDとして保持しません。今回の環境では、先頭のidが要素のIDとして使われました。

例えば、Dの各要素で有効なのはsecond-1からsecond-5ではなく、先頭にあるsame-firstです。

Markdown
<h2 id="same-first" id="second-1">D1</h2>

そのためDは、実質的にCと同じ状態になります。

一方、Eも各要素にid属性が2個ある不正なHTMLですが、有効になる先頭IDはfirst-1からfirst-5まで一意です。プレビューの差分更新は正常に動きました。

もちろん、Eが正常に更新されたからといって、1要素へid属性を複数書いてよいわけではありません。ここで分かったのは、HTMLとしての誤りと、今回のプレビュー異常を引き起こす直接条件が別だったということです。

VS CodeプレビューはIDを照合キーにしている

VS CodeのMarkdownプレビューは、Markdownが編集されるたびにWebviewを完全に作り直すのではなく、現在のDOMと新しく生成したHTMLの差分を反映します。

VS Codeに同梱されているMarkdownプレビューの処理を確認すると、DOM要素のidをノードの照合キーとして取得し、IDから既存要素を引ける対応表を作っていました。

IDが一意なら、新旧の要素を正しく対応付けられます。しかし同じIDを持つ要素が複数あると、対応表の同じキーが後の要素で上書きされます。その結果、どの要素を再利用し、どの要素を削除すべきか正しく判断できなくなります。

今回の「5個を削除しても4個残り、そこへ5個追加すると9個になる」という現象は、このIDをキーにした差分更新の動作と一致します。

Markdownでは同じ明示IDを書ける

markdown-it-attrsなどを使うと、Markdownの見出しへ明示的なIDを指定できます。

Markdown
## 見出し {#ID}

では、同じIDを複数回指定するとどうなるのでしょうか。

Markdown
## 見出し {#ID}
## 見出し {#ID}
## 見出し {#ID}
## 見出し {#ID}
## 見出し {#ID}

このMarkdownは構文エラーになりません。各見出しには、指定どおり同じIDが設定されます。

Markdownの解析段階では、{#ID}という属性指定自体は正しい記法です。パーサーは文書全体を通したIDの一意性までは保証しません。

つまり、次の二つは別の話です。

Markdown
Markdownとして属性指定を解析できる

生成されたHTMLのIDが文書内で一意になる

明示IDを使う場合、同じ文書で重複しない値を指定する必要があります。

対策: 明示IDの重複を避ける

今回の問題を避ける基本的な対策は、HTML文書内のIDを一意にすることです。

Markdown
<!-- 問題が起きる例 -->
<h2 id="same-id">見出し1</h2>
<h2 id="same-id">見出し2</h2>

<!-- 一意にした例 -->
<h2 id="heading-1">見出し1</h2>
<h2 id="heading-2">見出し2</h2>

Markdownで明示IDを指定する場合も同様です。

Markdown
## 見出し1 {#heading-1}
## 見出し2 {#heading-2}

また、1つの要素へ複数のid属性を出力しないことも必要です。複数のMarkdown拡張機能がIDを追加する場合は、明示IDを優先するなど、最終的に1個へ正規化する設計が必要になります。

プレビューへ古い要素が残った状態では、原因となる重複IDを修正しても、その時点のDOMが正常に差分更新されないことがあります。今回の検証では、文書の内容をすべて消すと残った表示も消えました。

まとめ

VS CodeのMarkdownプレビューで、削除した見出しが消えず、編集のたびに表示が増える現象を調査しました。

手書きHTMLによる比較で分かったのは、次の点です。

調査でわかったこと
  • IDがない要素や、一意のIDを持つ要素では問題が起きない
  • 同じ有効IDを持つ要素が複数あると問題が起きる
  • 1要素にID属性が2個あっても、有効な先頭IDが一意なら今回の現象は起きない
  • VS Codeプレビューは、DOMの差分更新でIDをノードの照合キーにしている
  • Markdownの属性記法が正しくても、生成後のHTMLでIDが一意とは限らない

HTMLはブラウザに表示できるだけでは十分ではありません。idはページ内リンクだけでなく、DOM更新時の要素識別にも使われます。

Markdownプレビューで削除した内容が残る、または同じ表示が増え続ける場合は、生成されたHTMLに重複IDがないか確認すると原因を見つけられる可能性があります。

この記事が参考になりましたら、
上記の「いいね」を押していただけると嬉しいです😄
今後の記事作りの励みになります!
目次