Orcantus

Postmortem

Mermaidが消えるバグを、同じ原因で2度直した話

自作のMarkdownエディタで、Mermaid図が「テーマを切り替えると消える」報告を直し、 2日後に「ダーク/ライトを切り替えても消える」という別の報告を受けました。 原因は同じでした。1度目の修正は、原因の片側にしか効いていませんでした。

結論。「html文字列が同じでもDOMは再構築されうる」という前提を、修正の依存配列すべてに反映し切れていなかったのが原因。 1度目はtheme依存を追加して直したが、colorModeという別のprop変化が同じ穴を通ることを見落とした。2度目の修正でようやくテストを追加し、その後の実装変更(dangerouslySetInnerHTML→morphdom移行)でこの不具合の前提条件自体が変わったことまで確認している。

何が起きたか

MermaidはコードフェンスをJavaScriptで非同期に描画するため、描画後の<pre>要素には data-processed属性が付き、再度同じ要素を処理しないようにしている。

1度目の報告は「Mermaidがあるmdをgit風プレビューからnote風に変えて、また git風に戻すと図が消える」というものだった。調査の結果、原因は2つ組み合わさっていた。

  1. Mermaidだけの文書でも、無関係なhighlight.jsの読み込みが誤って走っていた (```mermaidのフェンスをコードハイライト対象と誤判定していたため)。 この読み込み完了時の再レンダリングで、dangerouslySetInnerHTML`の中身の文字列が 前回と一致していてもReactが要素を丸ごと作り直すことが、要素に目印を付けて 生存確認する実験で判明した。
  2. Mermaid再描画用のuseEffectが[html, source]にしか依存していなかった。 テーマを切り替えても(Mermaid自体はテーマで見た目を変えないため)html文字列は 前後で完全一致する。1のせいでDOMは再構築されているのにeffectは再実行されず、 処理済みのはずの<pre>参照が古くなり、二度と処理されないまま残っていた。

1つ目はhighlight.js誤読み込みの原因そのものを断ち、2つ目は依存配列にthemeを 追加して塞いだ。実機でgithub→note→github・zenn・qiitaを含む10回連続のテーマ切替を 確認し、この時点では再現しなくなっていた。が、ユニットテストは書かなかった。

2日後、同じ症状が別の経路で再発した

「ダーク/ライトを切り替えるとMermaidが表示されなくなる」という報告が来た。 症状が同じだったため、まず「同じ原因が再発していないか」を疑って調べたところ、 根本原因は1度目と完全に同一で、直した範囲が狭すぎたことが分かった。

colorMode(ダーク/ライト)はJSX側でclassNameにしか使っておらず、Mermaid自体は カラーモードで描画を変えないため、これもhtml文字列を変化させない。 1度目の修正でuseEffectの依存配列は[html, source, theme]になっていたが、 colorModeは含めていなかった。そのためthemeを切り替えたときと全く同じ機序で、 colorModeだけを切り替えたときに図が消えたまま残っていた。

「テーマの切り替えでReactがDOMを再構築する」という原因は正しく特定していたのに、 「その原因を引き起こすprop変化はtheme以外にもある」という一段抽象化した見方が 1度目の修正時に抜けていた。同じ仕組みに依存する値を全て洗い出す代わりに、 報告された症状(テーマ往復)を再現しなくなった時点で止めていた。

2度目は、直す前にテストを赤くした

2度目の修正では、1度目に無かったユニットテストを先に書いた。 「theme切替」と「colorMode切替」それぞれについて、Mermaidの再描画関数 (mermaid.run())が2回目も呼ばれることを検証するテストを追加し、 修正前に実行してcolorMode切替のテストだけが失敗することを確認してから直した (theme切替のテストは1度目の修正が効いているため、この時点で既に合格していた)。 このRED→GREENの手順を踏んだこと自体が、PLAN.mdの開発ログに明記されている。

その後、この不具合の前提が消えた

さらに後の変更(プレビューのちらつき解消)で、DOM更新の方式が dangerouslySetInnerHTMLから差分適用ライブラリmorphdomに置き換わった。 morphdomはhtml文字列の中身が変わらない限りDOMを書き換えないため、 この不具合の根本原因だった「文字列が同じでもDOMが再構築される」という前提自体が 無くなった。

このタイミングで、2度目の修正で追加した回帰テストは書き直されている。 現在のテストは「カラーモード/テーマを切り替えても、既に描画済みのMermaid図には 一切触れず、mermaid.run()を再実行する必要がない」ことを検証する内容になっており、 2度目の修正時とは逆方向の期待値になっている。theme・colorModeを useEffectの依存配列に残すコード自体は削除せず、コメントで 「今は不要だが、変化のないtheme/colorMode切替時に無駄な再探索が入る以外の 副作用が無いため防御的に残している」と明記する形を選んでいる。 このテストとコメントの現状は、リポジトリのPreview.test.tsxとPreview.tsxを 実際に読んで確認したもので、テスト34件が現時点でも通ることも実行して確かめた。

他の開発者が再発を防ぐには

この記録から一般化できるのは、ReactのuseEffect依存配列の不具合に限らない、 次の2点だと考えている。

この記録が言っていないこと