触っていないのに React の要素が揺れ続ける原因は Google 翻訳拡張だった
まとめ
- 揺れの正体は、Google 翻訳拡張が本文の DOM を書き換え、React が描き直して戻す往復だった
- 高さで折りたたみを決める UI では、1 行の差が「続きを表示」リンクの出入りに増え、下に積んだ要素まで動かす
- 切り分けは、ブラウザ上で値を毎フレーム記録し、幅、高さ、中身の順に絞ると早い。最後にシークレットウィンドウで拡張の影響を確かめる
- ヘッドレスブラウザで再現しないときは、本人の環境にしかないもの(拡張機能など)から先に確かめる
起きたこと
自作の Markdown レビューツールは、本文の横にコメントのカードを並べる。 コメントが近い行に集まると、何も操作していないのにカードが上下に動き続けた。 同じページをヘッドレスの Chromium で開くと、ウィンドウ幅や拡大率を変えても一度も揺れなかった。
揺れの仕組み
カードは、本文がある高さを超えると折りたたまれ、その下に「続きを表示」リンクが出る。 カードは上から順に積んであり、あるカードの高さが変わると、その下のカードの位置もすべて変わる。
Google 翻訳拡張が本文の DOM を書き換える(2 行 → 1 行)
→ 本文の高さが 40px → 20px
→ 「あふれているか」の判定が false になり、「続きを表示」(20px)が消える
→ カードが縮み、下のカードが上がる
React が本文を元の HTML に戻す(1 行 → 2 行)
→ 判定が true に戻り、「続きを表示」が出る
→ カードが伸び、下のカードが下がる
(最初に戻る)本文は dangerouslySetInnerHTML で描いていた。 React は再描画のたびに自分の HTML を書き戻す。 拡張の書き換えと競り合い、止まらない。 カードの高さは ResizeObserver で測り直しているため、この往復が配置に毎回反映された。
別の表示(状態ラベル)がカードに加わると揺れが止まった。 高さが変わって折りたたみ判定の境界から外れたためと見ているが、確かめてはいない。
切り分けの順序
表1: 立てた仮説と確かめた値
| 立てた仮説 | 確かめた値 | 結果 |
|---|---|---|
| ウィンドウ幅と拡大率の条件 | ヘッドレスで幅 1000〜2000px、devicePixelRatio 1 / 1.25 / 1.5 / 2 を総当たり | 揺れない |
| マウスのホバー | 全カードと全ハイライトの上にカーソルを置いて計測 | 揺れない |
| カード内のスクロールバーで幅が縮む | 枠の offsetWidth - clientWidth | 常に 0 |
| サーバーが返すデータが揺れる | API を 6 回取得して比較 | 毎回同じ |
| 本文の中身が変わる | 本文の scrollHeight、textContent、innerHTML.length | 1 行になったときだけ HTML が 35 → 132 文字に増えていた |
| ブラウザ拡張 | シークレットウィンドウで開く | 揺れない |
ヘッドレスでの総当たりに時間を使ったが、決め手は普段使いのブラウザで取った数字だった。 本文の幅は 274px で変わらないのに行数だけが変わっていたので中身が書き換わっていると絞れ、アプリのコードには本文の DOM を書き換える処理が無かったので、外から手を入れている何かが残った。 犯人は拡張だった。
毎フレーム記録するスニペット
DevTools のコンソールに貼り、2 秒ぶん(120 フレーム)の値のうち、変化したものだけを出す。 測る値を差し替えれば、幅、高さ、中身のどこが動いているかを順に確かめられる。
(async () => {
const seen = new Set();
for (let i = 0; i < 120; i++) {
await new Promise((r) => requestAnimationFrame(r));
const body = document.querySelector('[data-comment-id="c-013"] [data-testid="comment-body"]');
seen.add(JSON.stringify({
width: Math.round(body.getBoundingClientRect().width),
scrollHeight: body.scrollHeight,
text: body.textContent.slice(0, 15),
htmlLength: body.innerHTML.length,
}));
}
console.log([...seen].join("\n"));
})();DevTools の copy() は環境によって定義されていないので、結果は console.log で出しておく。
対処
Google 翻訳拡張の設定で、このツールのサイトでは動かないようにした。 アプリ側で防ぐなら、翻訳させたくない要素に translate="no" を付ける方法がある。 今回はアプリを変えず、拡張の側で除外した。