VS CodeでMarkdownを書いていると、エディターから文章を削除したのに、Markdownプレビューには古い見出しが残り続ける現象が発生しました。
文書内の文章をすべて消すとプレビューからも消えます。しかし、一部を削除しただけでは表示が残ります。さらに同じ内容を追加すると、古い表示の上へ新しい表示が積み重なりました。
最初は再現条件が分かりませんでしたが、手書きしたHTMLだけで比較したところ、文書内で重複したidが原因だと分かりました。
HTMLをMarkdownに直接貼り付ける人ならば、遭遇する可能性があります。
発生した現象
Markdown文書へ、同じHTMLタグを複数回貼り付けました。

<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個になりました。

最初の5個
↓
すべて削除しても4個残る
↓
5個追加すると9個になるソースとプレビューの内容が一致していません。古いDOM要素が、プレビューの差分更新で正しく削除されていないように見えます。
調査
id属性が2個あることが原因なのか
最初に疑ったのは、1つの要素へid属性が2個書かれていることです。
<h2 id="カスタム" id="テスト-カスタム-14">テスト</h2>HTMLの1要素が複数のid属性を持つのは不正です。しかし、それだけがプレビュー更新を壊す直接の条件なのかは分かりません。
そこで、Markdown拡張機能やHTML変換処理を使わず、5種類のHTMLを手書きして比較しました。
5種類のサンプルで比較する
- A:IDなし
- B:すべて異なるID
- C:単一だが全要素で同じID
- D:各要素にIDが2個あり、先頭のIDが同じ
- E:各要素にIDが2個あるが、先頭のIDは異なる
<!-- # A:IDなし -->
<h2>A1</h2>
<h2>A2</h2>
<h2>A3</h2>
<h2>A4</h2>
<h2>A5</h2><!-- 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><!-- 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><!-- 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><!-- 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です。
<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を指定できます。
## 見出し {#ID}では、同じIDを複数回指定するとどうなるのでしょうか。
## 見出し {#ID}
## 見出し {#ID}
## 見出し {#ID}
## 見出し {#ID}
## 見出し {#ID}このMarkdownは構文エラーになりません。各見出しには、指定どおり同じIDが設定されます。
Markdownの解析段階では、{#ID}という属性指定自体は正しい記法です。パーサーは文書全体を通したIDの一意性までは保証しません。
つまり、次の二つは別の話です。
Markdownとして属性指定を解析できる
≠
生成されたHTMLのIDが文書内で一意になる明示IDを使う場合、同じ文書で重複しない値を指定する必要があります。
対策: 明示IDの重複を避ける
今回の問題を避ける基本的な対策は、HTML文書内のIDを一意にすることです。
<!-- 問題が起きる例 -->
<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を指定する場合も同様です。
## 見出し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がないか確認すると原因を見つけられる可能性があります。
