アプリは10分間は正常に動作します。しかし、その後スクロールが重くなります。30分後にはタブのメモリ使用量が1GBに達します。最終的に、ページはOut-of-memoryエラーでクラッシュしますが、その原因を示すスタックトレースは何も残りません。

これはレンダリングパフォーマンスの問題ではありません。React DevTools Profilerは静かに見えるでしょう。なぜなら、問題はコンポーネントがどれくらいの頻度で再描画されるかではなく、アンマウント後に何が生き残っているかにあるからです。JavaScriptのヒープ内のどこかに迷い込んだ参照が、DOMノード、クロージャ、ステートのツリー全体を固定してしまいます。ブラウザはそれらを解放できないため、プロセスが崩壊するまでメモリが増え続けます。

ソースコードを読んでもリークは見つかりません。バグは、「アンマウントしたはずのもの」と「ガベージコレクタが実際に認識しているもの」の間の隙間に潜んでいます。V8は、保持パス(retaining paths)がゼロのオブジェクトのみを解放します。迷い込んだイベントリスナー、解除されていないオブザーバー、あるいは生存期間の長いクロージャが、fiberやDOMノードへのポインタを一つでも保持していると、コンポーネントのサブツリー全体が生き残ってしまいます。モーダルをアンマウントしても、window上のリスナーがそのモーダル内で定義されたハンドラーを依然として指しているため、切り離されたノードがメモリ内に残り続けます。

リークがどこにあるかを証明するには、エディタではなくヒープを見る必要があります。

なぜヒープが真実を語るのか

Chrome DevToolsを使えば、ガベージコレクタが見ているものを直接覗き見ることができます。Memoryタブでは、ヒープスナップショットを記録できます。これは、現在JavaScriptメモリ内に保持されているすべてのオブジェクト、DOMノード、クロージャの完全な目録です。リークが疑われる前と後の2つのスナップショットを比較することで、どのオブジェクトが消え損ねたのかを正確に特定できます。

これは抽象的な理論ではありません。たった一つのリークしたReactコンポーネントが、数千もの切り離された HTMLElement オブジェクトを保持することがあります。それらのオブジェクトは、もはや表示されているドキュメントには接続されていませんが、JavaScriptの参照が原因で回収が妨げられています。これらは比較ビューにおいて、コンストラクタ名 Detached HTMLElement として表示されます。これが増殖しているのを見つけたら、それがリークの正体です。

Chrome DevToolsのワークフロー

まずはクリーンな状態から始めます。無関係なブラウザタブを閉じ、無関係な拡張機能を無効にし、アプリケーションを安定した状態にします。Chrome DevToolsを開き、Memoryタブに切り替えて、Heap snapshotを選択します。「Take snapshot」をクリックします。このベースラインによって、開始時のメモリ使用量がキャプチャされます。

次に、リークが疑われるユーザー操作を正確に実行します。重いモーダルを開いて閉じます。ウィジェットをマウントしてアンマウントします。ルートを移動して戻ります。UIが元の視覚的な状態に戻ったら、Memoryタブのゴミ箱アイコンをクリックします。これにより、グローバルなガベージコレクションが強制的に実行されます。レンダリングサイクルによる一時的なオブジェクトは消えるはずです。残ったものこそが、リークの真の候補です。

再度「Take snapshot」をクリックします。これでメモリの「写真」が2枚揃いました。表示をSummaryからComparisonに変更します。比較対象(comparison scope)を最初のスナップショットに設定します。ツールは、ランタイムのノイズを取り除き、2つのキャプチャ間で変化した部分のみを表示します。

Deltaでソートします。オブジェクト数が増加しているものを探します。Detached HTMLElementArrayFunction、あるいは自身のコードベースにある名前付きクラスのインスタンスなどのコンストラクタに特に注意してください。Deltaが増加しているということは、操作中にオブジェクトが作成され、その後回収されなかったことを意味します。

保持パスの追跡

リークした要素を見つけたら、それを選択します。下のパネルに保持パス(retaining path)が表示されます。これは、なぜそのオブジェクトがまだ生きているのかを説明する参照の連鎖です。この連鎖は、切り離された div からReactの内部プロパティを通り、クロージャへと進み、最終的にコンポーネント内で登録されたイベントリスナーに到達するかもしれません。この連鎖の最後のリンクが、あなたのコードの行番号になります。

ここから、診断から根本原因の特定へと進みます。保持パスが window.addEventListener で終わっている場合、グローバルなリスナーがコンポーネントを「人質」に取っていることがわかります。もし IntersectionObserver のインスタンスで終わっているなら、オブザーバーが、本来ガベージコレクションされるべきノードをまだ監視していることがわかります。

Reactにおけるよくある原因

Reactにおけるメモリリークは、通常3つのパターンに分類されます。

孤立したグローバルリスナー。 useEffectwindowdocument にフックして、スクロール位置、キー入力、またはリサイズイベントを追跡します。もし、そのエフェクトが removeEventListener を呼び出すクリーンアップ関数を返していない場合、リスナーはページの生存期間中ずっと生き残り続けます。リスナーはクロージャであるため、Reactがコンポーネントをアンマウントした後も、コンポーネントのスコープ全体を維持してしまいます。

解除されていないオブザーバー。 IntersectionObserverResizeObserver は強力ですが、React の制御外でネイティブのリファレンスを作成します。コンポーネント内でオブザーバーをインスタンス化し、クリーンアップフェーズで disconnect() を呼び出すのを忘れると、オブザーバーがターゲットの DOM ノードを保持し、その DOM ノードが React の fiber、props、および state を保持し続けてしまいます。

クロージャの罠。 コンポーネント内で関数を定義し、それをサードパーティライブラリやグローバルキャッシュ、あるいは setTimeout に渡すと、その関数はレキシカルスコープ内のすべての変数をクロージャとして保持します。外部の所有者がその関数を保持し続けると、コンポーネントのスコープ全体も一緒に保持されてしまいます。

実際に機能するクリーンアップパターン

メモリリークを修正するということは、スナップショットで見つかったすべての保持パス(retaining path)を断ち切ることを意味します。

useEffect からは常にクリーンアップ関数を返してください。エフェクト内でリスナーを追加した場合は、そこで削除を行います。

DOM や window にアタッチするハンドラーには useCallback を使用してください。これがないと、レンダリングのたびに新しい関数リファレンスが作成されます。あるリファレンスで addEventListener を呼び出し、後で別のリファレンスで removeEventListener を呼び出すと、削除は静かに失敗します。元のリスナーは window に永遠に残り続けます。useCallback を使ってリファレンスを安定させることで、追加と削除を正確に一致させることができます。

オブザーバーも同様の規律を持って扱ってください。オブザーバーのインスタンスは、ref またはエフェクト内のローカル変数に保存します。クリーンアップ関数内で observer.disconnect() を呼び出してください。コンポーネントのアンマウントによってオブザーバーが破棄されると思い込まないでください。実際にはそうはなりません。

コンポーネントがグローバルな名前空間やシングルトンサービスに何かを公開している場合は、アンマウント時にそれらのリファレンスを削除してください。V8 エンジンがメモリを回収できるのは、オブジェクトが真に到達不能(unreachable)になったときだけです。window にフックを残したり、モジュールレベルの Map にエントリを残したりすると、見えないブリッジが形成され、ヒープが成長し続けてしまいます。

真の教訓

メモリリークは、すぐにアプリをクラッシュさせるわけではありません。長いユーザーセッション中に、切り離されたノードが一つずつ蓄積されていくのです。解決策は、ライブラリのアップグレードやコンパイラフラグではありません。ヒープスナップショットを用いて、クリーンアップロジックが正しく機能していることを証明する習慣を持つことです。

ベースラインを取得し、疑わしいフローを実行し、ガベージコレクションを強制的に実行して比較します。差分(delta)が増加している場合は、保持パスを調査し、存在すべきでないリスナーやオブザーバーを見つけて、そのリファレンスを断ち切ります。テストを再度実行してください。差分が横ばいになれば、実際に解決できたことになります。アプリケーションの応答性は維持され、ユーザーがブラウザのタブのフリーズによって作業内容を失うこともなくなります。