結論。「html文字列が同じでもDOMは再構築されうる」という前提を、修正の依存配列すべてに反映し切れていなかったのが原因。 1度目はtheme依存を追加して直したが、colorModeという別のprop変化が同じ穴を通ることを見落とした。2度目の修正でようやくテストを追加し、その後の実装変更(dangerouslySetInnerHTML→morphdom移行)でこの不具合の前提条件自体が変わったことまで確認している。
何が起きたか
MermaidはコードフェンスをJavaScriptで非同期に描画するため、描画後の<pre>要素には
data-processed属性が付き、再度同じ要素を処理しないようにしている。
1度目の報告は「Mermaidがあるmdをgit風プレビューからnote風に変えて、また git風に戻すと図が消える」というものだった。調査の結果、原因は2つ組み合わさっていた。
- Mermaidだけの文書でも、無関係な
highlight.jsの読み込みが誤って走っていた (```mermaidのフェンスをコードハイライト対象と誤判定していたため)。 この読み込み完了時の再レンダリングで、dangerouslySetInnerHTML`の中身の文字列が 前回と一致していてもReactが要素を丸ごと作り直すことが、要素に目印を付けて 生存確認する実験で判明した。 - 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点だと考えている。
- 「同じ症状が消えたか」ではなく「特定した根本原因を引き起こす経路を全て 洗い出したか」で直った判定をする。 今回は「html文字列を変えずにDOMだけを 変化させるprop」が本質的な条件で、theme・colorMode以外にも同じ性質を持つ propが将来追加されれば同じ穴が開く。修正時に「この原因はどの入力でも 起こりうるか」を一段抽象化して洗い出す手間を、症状の再現テストで済ませて しまわないこと。
- 実機確認だけで終えず、原因そのものを固定するテストを先に書く。 1度目の修正は実機で10回連続の切り替えを確認しており、確認作業自体は 丁寧だった。それでも「何を確認したか」がテストコードとして残らなければ、 次に似た変更(今回で言えばcolorModeという新しいprop)が入ったときに 同じ穴が開いていないかを機械的に再検証する手段が無い。2度目でようやく 追加したテストが、後の大規模な実装変更(morphdom移行)の際に 「この不具合が再発していないか」を自動で確認する役目も果たした。
この記録が言っていないこと
- この記録は自作アプリ1件の開発ログ(PLAN.md 17節・24節)とそのコミット
(
b72d029・eb176f5)、現在のソース・テストを一次資料としている。 他のMarkdownエディタやReactアプリ一般でも同じ順序でバグが起きる、という 主張ではない。 dangerouslySetInnerHTMLが常にDOMを再構築するという性質自体は、この記録の 対象コードで実験的に確認した挙動であり、React全バージョン・全条件での 仕様保証を検証したものではない。- 「テストを先に書けば再発しない」という一般論の実証ではない。今回は 2度目の修正で書いたテストが後の実装変更にも有効だった、という1件の事例。