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型で辿るほうが堅牢です。