StrictMode の dev double-mount で concurrency ガードの active フラグが壊れる
TL;DR
- 「React ツリーが生きているか」を表す
active: booleanフラグで async 結果を破棄する concurrency ガードは、StrictMode の dev double-mount(mount → cleanup → remount)で壊れる。 - cleanup で
active = falseにすると、同じ ref を共有する値が死んだまま remount され、以降の command が全部破棄される。 activeフラグを廃止して 世代番号(current/openRequest)に一本化する。unmount cleanup は世代を進めるだけにすれば、double-mount でも壊れない。
1. 問題
ProjectVersion に active: boolean フラグがあり、Provider の unmount 時に active = false にして進行中の async 結果を破棄する仕組みだった。
React StrictMode は dev で mount → cleanup → remount をシミュレートする。この cleanup で deactivateProject(active = false)が走ると、同じ ref の version が死んだまま remountされ、その後の全 command が「もう active じゃない」と判定されて破棄されてしまう。
mount → version.active = true
cleanup → version.active = false // StrictMode の擬似アンマウント
remount → 同じ ref を使い回すが active は false のまま
→ 以降の command がすべて破棄される
2. 原因
active フラグは「React ツリーが生きているか」を表す concurrency ガードだが、本番ではほとんど出番のない防御機構だった。
- boolean は「今生きているか死んでいるか」の 1 ビットしか持たず、mount の世代を区別できない。
- StrictMode の cleanup → remount は「別インスタンスの開始」ではなく「同じ ref の再利用」なので、false に落とした 1 ビットがそのまま持ち越されて復帰できない。
つまり StrictMode との相性問題が、この防御機構の設計の弱さを露見させた。
3. 解決
active フラグを廃止し、世代番号(current / openRequest)への一本化に置き換える。
- 各 async command は「自分が発行された世代」を掴んでおき、完了時に
currentと一致するかで採否を判定する。 - unmount / cleanup は
active = falseにするのではなく、世代を 1 つ進めるだけ。 - StrictMode の double-mount で cleanup → remount しても、remount 側が新しい世代で動き出すので、「死んだまま復帰できない」状態が発生しない。
mount → 世代 = 1
cleanup → 世代 = 2(進めるだけ、無効化フラグは持たない)
remount → 世代 = 3 で通常どおり動作
4. 判断のポイント
- 「生きている / 死んでいる」の boolean フラグは、同じインスタンスが cleanup→remount で復活するケース(StrictMode / 高速な再マウント)を表現できない。
- concurrency ガードは boolean ではなく単調増加する世代番号で持つと、「どの mount のリクエストか」を区別でき、cleanup が破壊的にならない。
- StrictMode の double-mount は「本番で稀にしか出ない防御機構」のバグを開発中に炙り出す装置として機能する。dev で壊れたら本番の稀ケースでも壊れると考える。