綴Photo の勉強ノートに戻る
開発記録2026.07.12

「また今度」に戻せない時がある — 巻き戻し先を覚えるフラグが、途中の付け替えで消えていた

FlutterDart状態管理デバッグ綴Photo

挙動を変えないリファクタの検証中に踏んだ詰みバグと、それを直した実装がさらに別のバグを抱えていた話。判断の流れを勉強のためにまとめた制作メモです。

内部構造を整理するだけの「挙動は変えない」リファクタを一区切りして、綴る画面(写真を1枚ずつ見て「また今度/リアクション/言葉を綴る」を決める画面)を実機で一周していた。自動テストは全部緑(=失敗ゼロ)だし、見た目も同じ。なのに、ある操作の並びで画面が先に進めなくなった。

踏んだ手順はこう。写真を「また今度」にする → その写真まで1枚戻る → mood(リアクション)を付ける → mood を外す → 「次へ」。ここで、次へ進むボタンが効かなくなる。

詰みの正体は frontier を「枚数」で数えていたこと

問題は、frontier を決めた写真の枚数で数えていたことだった。

dart
// 決定済み = 「また今度」の枚数 + 「綴った または リアクションを付けた」枚数
// union = 2つの入れ物を重ねて1つにする。
// 両方に入っている写真(綴った上にリアクションも付けた)は 1枚として数える
int get frontier =>
    _passedKeys.length + _writtenKeys.union(_reactedKeys).length;
 
// index = いま見ている写真が何枚目か。
// 「今いる位置」が「決定済みの右端」より手前のときだけ、次へ進める
bool get canAdvance => index < frontier;

前から順に決めていく限り、これで合う。決めるたびに枚数が1つ増え、右端が1つ前へ動く。でも今回の手順は「一度決めた写真に戻って、その決定を崩す」。また今度にした写真に mood を付けて、その mood を外すと、その写真は「未決定」に逆戻りして、枚数が1つ減る。すると、決定済みが先頭から途切れずに並んでいる前提が崩れて、いま見ている写真のところに「未決定の穴」が空く。

写真1枚だけで追うとこうなる。

ルールは index < frontier ── いる位置が決定済みの右端より手前なら、次へ進める。① また今度にした決定済みの右端 = 1写真1また今度次の写真index = 0(ここにいる)0 < 1 は成り立つ進める② mood を付けた右端 = 1(変わらず)写真1リアクション次の写真0 < 1 は成り立つ進める③ mood を外した右端 = 0 ── 1つ左へ戻った写真1未決定次の写真0 < 0 は成り立たない進めない(詰み)写真は1枚も動いていない(index は 0 のまま)。動いたのは右端だけ。frontier が「位置」でなく「決めた枚数」だったから、取り消すと数が減って右端が自分より左へ来る。

この数え方は、書かれた時点では正しかった。frontier を入れた頃、決定は「また今度」と「綴った」の2つしかなくて、どちらも決定済みに足すだけだった。決定済みから抜ける操作が1つも無いなら、決定済みは前から順に積み上がる一方で、枚数がそのまま右端に一致する。崩れたのは、あとからリアクションを足したとき。mood はもう一度押すと外せる=決定済みから抜ける経路が初めてできた。数え方のコードは1文字も変えていないのに、周りに取り消せる操作が1つ増えただけで、前提のほうが静かに成り立たなくなっていた。

そのリアクションを足したときの設計は 写真1枚に何を残したか — 状態を「集合」で持って union で数える に書いた。また今度/リアクション/綴ったの3集合に組み直して、共存する2つを union で1回だけ数える、というところまで。そこでは決定を取り消して未決定へ戻る経路のことは見ていない。

このリファクタで作り込んだバグじゃない。リアクションを足した時点から潜っていて、挙動を変えないはずの検証が、たまたまその穴を踏み抜いた形だ。

「mood を外したら また今度 に戻す」で直すことにした

直し方はいくつかあったけれど、frontier の数え方そのものをいじると波及が大きい。この値は「次へ進めるか」だけじゃなく、「全部決め終わったか」の判定と、進捗表示(何枚中の何枚か)にも使い回している。数え方を変えると、その3つが一緒に動く。選んだのは、mood を外したときに『また今度』に戻すという案(案A と呼んでいた)。写真を未決定に落とさなければ、決定済みの列に穴が空かない=詰まない。

そのために、cursor(綴る画面の状態を持つ不変オブジェクト)に「この写真の mood は、元がまた今度だった」を覚えるフラグを持たせた。フラグと言っても真偽値ひとつではなく、写真ごとに旗を立てるので、_reactedFromPassed という集合(旗を立てた写真の key を放り込んでおく袋)を足してもらい、また今度→リアクションの遷移のときに記録し、mood を外すときにそれを見て、また今度へ戻す。画面の状態だけ戻しても DB に「また今度」の行が無いままなので、DB 側も mood の削除と また今度 の復元をひとまとめ(トランザクション)で戻すようにしてある。この記事で追うのは前者、画面側の記憶のほうだ。

dart
// また今度→リアクションのとき「元がまた今度だった」を覚える
TsuzuruSessionCursor markReacted(String key) {
  final wasPassed = _passedKeys.contains(key); // この写真、元は「また今度」だった?
  return _copyWith(
    // 波かっこ+ドット3つ = 元の袋を壊さずコピー。そこから 1枚 抜く / 1枚 足す
    passedKeys: {..._passedKeys}..remove(key), // また今度の袋から外す
    reactedKeys: {..._reactedKeys, key},       // リアクションの袋へ入れる
    reactedFromPassed: wasPassed
        ? {..._reactedFromPassed, key} // ← また今度由来なら覚える
        : ({..._reactedFromPassed}..remove(key)),
  );
}
 
// mood を外すとき、覚えていたら また今度 へ戻す
TsuzuruSessionCursor unmarkReacted(String key) {
  // 「元がまた今度」と覚えていて、かつ まだ言葉を綴っていない写真だけ戻す
  // (綴ってあるなら決定済みのままでいい=また今度に戻す必要がない)
  final restorePassed =
      _reactedFromPassed.contains(key) && !_writtenKeys.contains(key);
 
  // 以下省略: リアクションの袋から外し、restorePassed なら また今度の袋へ戻す
}

テストを足して緑にして、実機でも「また今度→mood付け→外す→また今度に戻る」を確認した。詰みは消えた。ここで一度、直ったつもりでいた。

直したはずが、mood を「付け替える」と戻らない

もう一周していて、また詰みが再発した。ただし毎回じゃない。「出ない時がある」という、いちばん厄介な出方だった。

再現条件を絞ると、mood を付け替えたときだった。また今度にした写真に mood A を付けて、気が変わって mood B に変えて、それから外す。この並びだと、また今度に戻らず未決定に落ちて、また詰む。

犯人は、さっきの markReacted の**「そうでなかったとき」側**だった。

dart
    reactedFromPassed: wasPassed
        ? {..._reactedFromPassed, key}
        : ({..._reactedFromPassed}..remove(key)), // ← 外れたとき。ここで記憶を消していたのが罠
同じ「mood を外す」でも、途中に1手あるかどうかで結末が変わる。A のまま外す ── 戻る① また今度また今度② mood を付けるリアクション③ mood を外すまた今度へ戻る進める ✓旗: なし旗が立つ ✓旗を見て戻す途中で別の mood に付け替えてから外す ── 戻らない① また今度また今度② mood を付けるリアクション③ 別の mood へリアクションのまま④ mood を外す未決定へ落ちる詰み ✕旗: なし旗が立つ ✓旗が消える ✕旗が無い=戻せない③ の時点で写真は「リアクション」のまま=画面上は何ひとつ変わらない。動いたのは旗だけ。markReacted の2回目は、もう「また今度」の袋に居ないので wasPassed = false → 外れたとき側で旗を消す。旗を消していいのは3つだけ ── mood を外した / また今度にした / 言葉を綴った。「付け替え」はどれでもない。立てる条件は自然に書けるが、消す条件は出口を全部数えないと漏れる。

「もう passed じゃないんだから、また今度由来の記憶も要らないよね」と消しにいく実装になっていた。だから付け替えたあとに外すと、戻り先の記憶が無くなっていて、未決定に落ちる。

なぜ外れたとき側に「消す」を書いてしまうのか。cursor は不変オブジェクトで、更新は「今の値をコピーして一部だけ差し替える」形で書く。差し替えないフィールドは渡さなければ据え置きなので、触らないことは書ける。ただし条件分岐を三項演算子で式の中に持ち込んだ瞬間、外れたとき側にも必ず何かを書かされる。そこで「今の値をそのまま書き戻す」が思いつかないと、空欄を埋める気持ちで「それっぽい後始末」を書いてしまう。

ただし「if で書いていれば避けられた」という話ではない。else を書かなければ黙って据え置きになるが、それは据え置きが正解だっただけで、もし正解が「消す」のほうだったら、今度は何も書かされないまま旗が永久に残る ── しかも誰にも催促されないぶん、気づく機会はもっと少ない。三項演算子は問いがあることだけは見せてくれる。if は問いごと隠す。どちらも、問いに答える代わりにはならない。

直し方は、外れたとき側でフラグを消さず保持するだけだった。今の値をそのまま書き戻す=「何もしない」の明示形になっている。

dart
    reactedFromPassed: wasPassed
        ? {..._reactedFromPassed, key}
        : _reactedFromPassed, // 消さず保持。クリアは「外す/また今度/綴る」のときだけ

フラグを消していい瞬間は3つに絞れる。mood を外したとき、明示的にまた今度にしたとき、言葉を綴ったとき。この3つ以外では触らない。「mood を別のに付け替える」はそのどれでもないから、記憶は残すのが正しかった。付け替え→外すのテストを足して、今度こそ緑にした。

セットより「消さない条件」を詰め損ねていた

書いていて腑に落ちたのは、フラグを足すとき「どういう時に立てるか」ばかり考えていて、「どういう時に消すか(消してはいけないか)」を詰めていなかったこと。立てる条件は、また今度→リアクションという分かりやすい入口があるから自然に書ける。消す条件は、その写真が「リアクション付き」から抜けていく出口を全部数え上げないと漏れる。今回は「同じ reacted のまま mood だけ変わる」という、状態が実質変わらない遷移を見落としていた。コードは書いてもらえても、こういう遷移の穴は操作の並びを手で試さないと出てこない。

手順に落とすなら、状態を1つ足すときに3つ揃える、になる。

旗のような状態を1つ足すときに、決めておく3つ。③ 据え置き ── 上のどれでもない全部例: mood を別のに付け替える⚠ 数えるものが無いので意識に上がらない = 今回抜けていたのはここ① 立つ条件入口は1つまた今度 → リアクション旗が立っている= 戻り先を覚えている② 消える条件 ── 出口を全部数えるmood を外すまた今度にする言葉を綴る① は入口が1つだから自然に書ける。② は出口を数えれば書ける。③ は「残り全部」で、数える対象が無い。だから抜けるのはいつも ③。書き方を if にしても三項にしても、この3つを決めそびれたことは変わらない。

2つの層は、別々の話に見えて形は同じだった。frontier は「決定は増える一方」を前提に枚数で数えていて、あとから mood の取り消しが入って崩れた。フラグのほうは「立てる」ばかり見ていて、あとから mood の付け替えが入って崩れた。どちらも、あとから「やり直せる操作」を足したときに、やり直しを想定していない古い前提が残っていた、という同じ壊れ方だ。

もう一つは、挙動を変えないリファクタの検証が、リファクタとは無関係の既存バグをあぶり出して、その修正がさらに次のバグを連れてきた、という入れ子だったこと。1枚めくると下からもう1枚出てくる感覚で、テストが緑でも実機で操作の並びを変えると別の層が見える。詰みが「毎回」じゃなく「時々」だったのも、この最後の層まで降りて初めて再現条件(付け替え)が言葉になった。

この記事は2026年7月時点の自分の理解で書いています。仕様や認識が変わってる可能性があります。