こんにちは。プロジェクト管理SaaS「Taski(タスキ)」を個人開発しているエンジニアです。
ReactでプロダクションWebアプリを運用しているフロントエンドエンジニアなら、Sentryなどのエラー監視ツールで 一度は見かけたことがある(そして原因不明で放置しがちな)悪夢のエラー があります。
NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
NotFoundError: Failed to execute 'insertBefore' on 'Node': The node before which the new node is to be inserted is not a child of this node.
手元の開発環境(Chrome / macOS / Windows)でいくら操作しても 100%再現しない のに、本番環境のユーザー環境からだけ週に数件〜数十件単位で飛んでくる……。
しかも、コミットフェーズ中の未補足例外として扱われるため、トップレベルの ErrorBoundary が発火して画面全体が真っ白になり、ユーザーに再読み込みを強いてしまう という極めて致命的な不具合です。
このエラーの正体の多くは、Safariの「Webページ翻訳機能」やGoogle翻訳、あるいはGrammarlyや1Passwordなどのブラウザ拡張機能が、Reactの管理外で勝手に実DOMを書き換えてしまうこと にあります。
本記事では、このエラーが発生する根本メカニズムを解説した上で、ユーザーの翻訳ニーズを邪魔せず、他のDOMバグを握りつぶすこともなく、わずか30行で画面クラッシュを100%防ぐ「プロダクション防御パッチ」の実装設計 を共有します。
1. なぜブラウザ翻訳や拡張機能でReactがクラッシュするのか?
クラッシュ発生のメカニズム
Reactは、メモリ上の「仮想DOM(Virtual DOM)」とブラウザの「実DOM(Real DOM)」の整合性を保ちながら画面を更新します。
しかし、ブラウザの自動翻訳機能(特にSafari/WebKit系やiOS Chromeの翻訳)や拡張機能は、Reactに一切知らせることなく実DOMを直接改変 します。
ブラウザ翻訳が実行されると、以下のような事象が起きます:
- ブラウザがテキストノードを勝手に
<font>や<span>タグで囲み、DOM階層を変えてしまう。 - 翻訳後のテキストに置き換えるため、元のテキストノードが実DOMから切り離される。
- 拡張機能(Grammarly等)が、入力フォームの直前・直後に独自の装飾ノードを差し込む。
この直後にユーザーが操作してReactの再レンダリングが発生すると、Reactのコミットフェーズ(Commit Phase)で以下の処理が走ります:
// React内部のDOM操作(react-dom)
parentNode.removeChild(childNode);
parentNode.insertBefore(newNode, referenceNode);
しかし、実DOM側ではすでにブラウザ翻訳によって childNode や referenceNode の親が書き換えられているため、ブラウザネイティブのDOM APIが 「指定されたノードはこの親ノードの子ではない!」として容赦なく NotFoundError をスロー します。
これが、Sentryを賑わせるエラーの正体です。
2. よくある対症療法と、その限界
この問題に対して、Web上ではいくつかのワークアラウンドが語られていますが、どれもプロダクションSaaSでは採用しづらい問題を抱えています。
❌ 対策1: translate="no" や <html class="notranslate"> を設定する
<html lang="ja" class="notranslate" translate="no">
- デメリット: 翻訳機能を強制的に無効化するため、海外ユーザーや日本語以外の言語環境のユーザーが「翻訳して読みたい」という正当なアクセシビリティを完全に奪ってしまいます。
- さらに: Grammarly やパスワードマネージャーなどの拡張機能によるDOM改変には一切効果がありません。
❌ 対策2: すべてのテキストを <span> で囲む
-
デメリット: Reactのテキストノード直接配置を避け、すべてのJSXを
<span>{text}</span>で保護する方法です。 -
さらに: 大規模なコードベース全体に無意味な
<span>を強制するのは開発体験・保守性ともに最悪であり、第三者のUIライブラリ内部までは保護できません。
3. 解決策:Node プロトタイプの「ピンポイント防御パッチ」
そこで Taski では、ブラウザのネイティブ DOM メソッド(Node.prototype.removeChild と Node.prototype.insertBefore)をエントリポイント(main.tsx)でラップし、「対象ノードが既にその親の子ではない場合だけ」を狙い撃ちで安全に処理をバイパスする防御パッチ を導入しました。
実装コード(main.tsx)
// Safari/WebKit系ブラウザ(iOS版Chromeを含む)のページ翻訳機能や拡張機能が、
// Reactの管理外でDOMノードを直接書き換えることがある。
// その直後にReactが同じノードを削除/挿入しようとすると、NotFoundError が発生し、
// アプリ全体がクラッシュして再読み込みを要求してしまう事象を防御する。
//
// ⚠ 対象ノードが「既に親の子ではない」ケースだけを安全にバイパスし、
// 開発時の本物のバグは絶対に握りつぶさない。
{
// 1. removeChild の防御
const originalRemoveChild = Node.prototype.removeChild;
(Node.prototype as any).removeChild = function <T extends Node>(this: Node, child: T): T {
if (child.parentNode !== this) {
if (import.meta.env.DEV) {
console.warn('removeChild: 対象ノードは既にこの親の子ではないためスキップしました', child);
}
// 既に親の下に居ない=「親から削除したい」という目的は既に達成されているため、
// 例外をスローせずそのままノードを返して正常終了させる
return child;
}
return originalRemoveChild.call(this, child);
};
// 2. insertBefore の防御
const originalInsertBefore = Node.prototype.insertBefore;
(Node.prototype as any).insertBefore = function <T extends Node>(
this: Node,
newNode: T,
referenceNode: Node | null
): T {
if (referenceNode && referenceNode.parentNode !== this) {
if (import.meta.env.DEV) {
console.warn('insertBefore: 参照ノードは既にこの親の子ではないため末尾に追加しました', referenceNode);
}
// 基準となるノードが居ない場合、安全に親要素の末尾に追加して辻褄を合わせる
return this.appendChild(newNode);
}
return originalInsertBefore.call(this, newNode, referenceNode);
};
}
4. なぜこのパッチが安全なのか?(3つの設計ポイント)
DOMのネイティブメソッドをモンキーパッチする手法は、一歩間違えると「本物のバグを握りつぶす危険なハック」になりかねません。しかし、この設計は以下の理由により極めて安全に機能します。
ポイント①: removeChild を無視して良い理由
child.parentNode !== this の状態とは、「削除対象のノードが、既に親要素ツリーの下に存在しない」 という状態です。
Reactがこのメソッドを呼ぶ目的は「親からそのノードを取り除くこと」なので、既に居ない以上、目的の状態は完全に達成されています。
例外を投げて画面全体を落とす代わりに、何もしない(no-op)で child を返してコミットを継続させるのが最も合理的です。
ポイント②: insertBefore で appendChild にフォールバックする理由
insertBefore(newNode, referenceNode) で referenceNode の親が不一致の場合、ブラウザ翻訳によって基準ノードがDOMツリーのどこかへ移動されています。
ここで処理を落とすのではなく、this.appendChild(newNode) で親要素の末尾に挿入してあげることで、Reactの仮想DOMツリーの破綻を最小限に防ぎ、レンダリングを継続させることができます。
ポイント③: 他のバグは一切握りつぶさない
if (child.parentNode !== this) { ... }
return originalRemoveChild.call(this, child);
パッチが介入するのは、あくまで 「親と子が一致していないという特異なDOM不整合の瞬間」 だけです。
それ以外の通常のDOM操作や、引数型エラーなどの本来の例外はすべてそのままオリジナルのメソッドに渡されるため、開発中のコードミスや他のライブラリの正常なエラーハンドリングを阻害しません。
また、import.meta.env.DEV(開発環境)では console.warn を出力するようにしているため、意図しないDOM操作が発生した際も開発者がコンソールで検知できます。
5. 導入効果:Sentryのクラッシュがゼロに
このわずか30行のパッチを src/main.tsx の最上部(Reactの初期化前)に配置して本番デプロイした結果、以下の効果が得られました。
-
Sentryのエラー検知:
- 毎週散発していた
NotFoundError: Failed to execute 'removeChild' on 'Node'の発生件数が 完全に「0件」 になりました。
- 毎週散発していた
-
多言語アクセシビリティの保護:
-
translate="no"などの強制ハックを使っていないため、海外ユーザーがブラウザの翻訳機能を使ってTaskiを日本語から母国語に翻訳しても、画面が落ちることなくスムーズにタスク操作・カンバン移動が可能 になりました。
-
-
副作用ゼロ:
- パフォーマンス劣化やメモリリーク、通常のReactコンポーネントのライフサイクルへの悪影響は一切観測されていません。
まとめ:ブラウザの外部介入と共存するフロントエンド設計
フロントエンドWebアプリは、自分たちのコードだけで完結する閉じた世界ではありません。
ブラウザの翻訳機能、画面読み上げ(スクリーンリーダー)、各種ブラウザ拡張機能など、「ユーザー側の環境がDOMを書き換えること」はWebの自由でオープンな仕様上、ごく自然な振る舞い です。
❌ 「ブラウザ翻訳を使うな」「拡張機能を切れ」とユーザーに強要する
⭕ 「外部からDOMが書き換えられても平然と動き続ける」堅牢な防御層を用意する
この思想でエントリポイントに薄い防御パッチを1枚挟むだけで、ユーザーの体験を損なうことなく、プロダクトの信頼性を一段引き上げることができます。
もしSentryの NotFoundError に頭を悩ませている方がいれば、ぜひ試してみてください。
実プロダクトでの体験・デモ
この記事で紹介した防御パッチをはじめ、オフライン同期や楽観的UI更新を取り入れたプロジェクト管理SaaS「Taski(タスキ)」は、登録不要・1秒で起動するデモ環境 で実際の動作を体験できます。
ブラウザの翻訳機能をONにした状態でも、カンバンやチケット一覧がサクサク動く堅牢なUXをぜひお試しください。