開発Tips

Riverpod の autoDispose provider が await 中に破棄されてクラッシュした話

「ぽんっ!」(旧称:でいりんぐ)にパスワードのリセット・変更 UI を足したとき、実機でだけクラッシュする不具合に当たりました。原因は Riverpod の autoDispose な provider を ref.read だけで操作していたこと。ウィジェットテストは緑なのに実機で落ちる、という厄介なパターンだったので残します。

背景(当時の状況)

パスワードのリセット・変更 UIを実装していた 2026-07 のことです。ウィジェットテストはすべて緑なのに、実機の E2E でだけクラッシュする——という、なかなかやっかいなパターンに当たりました。「テストが通っている=安全」という油断を突かれた形で、原因が Riverpod の autoDispose だと分かるまで少し追いかけました。

症状: 実機の await 中にだけ落ちる

パスワード変更は「再認証 → updatePassword」と非同期処理が続く操作です。この処理中に、担当の Notifier が UnmountedRefException でクラッシュしました。ローカルのウィジェットテストでは再現せず、E2E(実機)でのみ発生します。

根本原因: read だけだと await の途中で破棄される

Riverpod の autoDispose な provider は、誰も watch していないと破棄されます。

  • UI からその Notifier を ref.read で取得して呼ぶだけだと、UI 側にリスナー(watch)が無い。
  • 実機では await(再認証やネットワーク)に実時間がかかるため、その最中に「誰も見ていない」provider が autoDispose で破棄される。
  • 破棄後に Notifier が state を書こうとして UnmountedRefException

ウィジェットテストで再現しなかったのは、fake が同期的に即完了していて「await 中に破棄される隙」が生まれなかったからです。実時間の待ちがある実機で初めて表面化しました。

対処: watch で生かし、書き込み前に mounted を確認する

class PasswordChangeScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // read だけにせず watch して provider を生かしておく
    final state = ref.watch(passwordChangeProvider);
    // ...
  }
}

// Notifier 側: state 書き込み前に mounted を確認
Future<void> change(...) async {
  await _reauthenticate();
  await _updatePassword();
  if (!ref.mounted) return; // 破棄後の書き込みを防ぐ
  state = const PasswordChangeState.success();
}
  • 操作系の Notifier を使うシート/画面は、build で必ず ref.watch(provider) して生存させる。
  • Notifier 側も、state を書く前に ref.mounted を確認する二重の守り。

回帰テストは「遅延 fake + リスナー不在」で書ける

この不具合は「同期 fake」では再現しないので、遅延を入れた fake + リスナーのいないコンテナで再現させました。実機の「await 中に破棄」を、テストで意図的に作り出す形です。これで回帰として固定できます。

おまけ: E2E の pageBack は日本語ロケールで滑る

関連して E2E 側の罠も一つ。patrol の pageBack() は戻るボタンの tooltip("Back")に依存するため、日本語ロケール(「戻る」)では失敗します。find.byType(BackButton) を使うと安定します。

また、パスワードリセットは user-not-found を成功と同じ表示にしています(メアドが登録済みか漏らさない=列挙攻撃対策)。

まとめ

  • autoDispose provider を read だけで操作すると、実機の await 中に破棄されて落ちる。UI で watch して生かし、Notifier は ref.mounted を確認する。
  • 同期完了する fake のウィジェットテストは、この手の非同期破棄を捕まえられない。遅延 fake で「await 中の破棄」を再現して回帰化する。
  • E2E の pageBack() はロケール依存。BackButton 型で辿るほうが堅牢です。