Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

編集フォームが最新の選択肢一覧に無い選択を自動で間引き、無関係な保存で対象が黙って消えた

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

編集フォームは、v2から取得し直したモニタ一覧に無い選択IDをuseEffectの中で自動的にselectedから間引いて上書きしていたため、価格だけ直すつもりの保存でも一覧側の変動につられて対象モニタが黙って減った。

結論

編集フォームは、配信先システム(v2)から取得し直したモニタ一覧に無い選択IDを検知すると、useEffectの中で自動的に選択状態から間引いて上書きしていた。 価格だけ直すつもりでフォームを開いて保存しても、一覧の側がv2で1台でも変動していれば、対象モニタが本人の意図と無関係に黙って減った状態のまま確定する。

何が起きうる状態だったか

このフォームは「学校を選ぶと、その学校に属するモニタの一覧をv2から取得してチェックボックスとして表示する」という作りになっている。編集時は、契約済みの対象モニタIDがselectedとして既に入った状態で開く。

一覧の取得が成功した直後、修正前のコードはこう書かれていた。

if (r.ok) {
  setLoaded({ schoolId, options: r.monitors, error: null });
  // v2 側で消えた/別校のモニタが選択に残らないようにする
  // (残ると v2 の越境検証で 409 保留になる=配信されない枠を作ってしまう)。
  const valid = new Set(r.monitors.map((m) => m.id));
  const kept = selected.filter((id) => valid.has(id));
  if (kept.length !== selected.length) onChange(kept);
}

意図はコメントの通りで、悪意のあるコードではない。取得し直した一覧に無いIDを選択に残すと、後段のサーバー検証で保存自体が拒否されるため、それを避けるための処理だった。しかし実行されるタイミングは「フォームを開いてモニタ一覧を取得した直後」であり、運営が実際にモニタの選択を変更したかどうかとは無関係だった。編集フォームを開いた瞬間に、選択配列が黙って書き換わる。

原因

取得し直した一覧に無いIDが発生する条件は複数ある。v2側でモニタが削除された、モニタが別の学校に付け替わった、あるいは一時的な取得エラーの後にリトライで一覧が変わった、などだ。このコードは、そうしたケースが起きたときに「選択と一覧のズレをその場で自動的に解消する」ことを選んでいた。

しかし自動的な解消は、実質的にサイレントな書き込みだった。フォームはonChange(kept)を呼ぶだけで、画面上は何も警告を出さない。運営から見れば、価格欄を1つ変えて保存しただけのつもりが、モニタ一覧の取得結果に応じて対象モニタの構成が変わっている。しかも保存自体は成功する。サーバー側には「一覧に実在するモニタIDだけを送る」検証があるため、間引き後のIDはすべて実在するものであり、この検証は正常な保存として素通しする。クライアント側の間引きが、サーバー側の整合性検証が本来検知するはずだった状態のズレを、検証に届く前に消してしまっていた。

直し方

自動的な間引きをやめ、一覧に無い選択IDを検知だけして警告に変えた。

// 一覧を引けたのに選択に含まれない ID=v2 側で消えた/別校のモニタ(保存時に弾かれる)。
const missingSelected =
  fresh && !error ? selected.filter((id) => !options.some((o) => o.id === id)) : [];
{missingSelected.length > 0 && (
  <>
    {/* チェックボックスが描かれない=送信から消える、では「黙って1台減った枠」を保存して
        しまう。hidden で送り続けてサーバーに弾かせる(fail-closed)。 */}
    {missingSelected.map((id) => (
      <input key={id} type="hidden" name="target_monitor_ids" value={id} />
    ))}
    <p className="mt-2 rounded bg-amber-50 p-2 text-[11px] text-amber-800">
      選択中の {missingSelected.length}台が v2 の一覧にありません(v2 側で削除された可能性)。
      このままでは保存できません。選び直してください。
    </p>
  </>
)}

一覧に無いIDはselectedから消さず、hiddenのinputとして送信フォームに残す。送信された先のサーバー側では、モニタIDが一覧に実在するかを確認する検証が既にあり、実在しないIDが1つでも含まれていれば保存全体を拒否する。

const byId = new Map(toMonitorOptions(r.data.monitors).map((m) => [m.id, m]));
const missing = monitorIds.filter((id) => !byId.has(id));
if (missing.length > 0)
  return {
    ok: false,
    error: `選択したモニタのうち ${missing.length}台がこの学校の v2 モニタとして見つかりません(他校のモニタ、または v2 側で削除された可能性)。選び直してください。`,
  };

この検証自体は今回の修正で新しく足したものではない。もとから存在していたが、クライアント側が保存前にズレを取り除いていたため、一度も発火する機会が無かった。クライアント側の親切な自動修正をやめたことで、この検証がようやく本来の役目を果たせるようになった。

再発防止

「一覧に無い選択を見つけたら、消すのではなく警告して止める」というfail-closedな方針に統一した。同じコミットでは、v2への疎通そのものが失敗しているケース(一覧が0件で返るため、既存の選択を維持したまま配信先を変えない保存だけを通す)にも同じ考え方を適用している。

クライアント側で「気を利かせて自動的に直す」処理は、直した結果が正しいことを検証する手段が本人の目にしか無い場合、その気の利かせ方自体が黙ったデータ変更になる。サーバー側に本来の整合性検証がある構成では、クライアント側は検知した差分をユーザーに見せて選択を委ねるところまでに留め、実際に弾くかどうかの判断はサーバー側の検証に任せたほうがいい。

よくある質問

Q1対象モニタはどうやって黙って減っていたのですか?

編集フォームを開くと、選択中の学校のモニタ一覧をv2から取得し直します。取得に成功すると、既存の選択IDのうち取得した一覧に無いものをフィルタで除外し、除外後の配列が元と違えばそのままonChangeで選択状態を上書きしていました。取得結果がv2側で1台でも欠けていれば、画面上は何も警告せずに選択から消えます。

Q2なぜ価格を直すだけの保存でも影響を受けるのですか?

この間引きはモニタ一覧を取得するuseEffectの中で自動的に走るため、フォームを開いた時点で発生します。運営が価格欄しか触っていなくても、開いた時点で選択済みの対象モニタ配列が既に間引かれており、そのまま保存すれば対象モニタが減った状態で確定します。

Q3サーバー側に一覧との整合を確認する処理は無かったのですか?

ありました。保存時にモニタIDがv2側の一覧に実在するかを確認し、無ければ保存自体を拒否する検証が別に存在します。しかしクライアント側が保存前に一覧に無いIDを自動で取り除いていたため、この検証に渡る時点で既に対象が少ない状態になっており、検証は『少なくなった対象』を正常なものとして通してしまっていました。

Q4この間引きはどう直しましたか?

自動的な間引きをやめました。一覧に無い選択IDはselectedから消さず、画面に警告として表示したうえでhiddenフィールドとして送信し続けます。サーバー側の検証がそのIDを一覧に存在しないものとして保存を拒否するため、運営は選び直すまで保存できません。黙って減らすのではなく、気づかせて止める設計にしています。

確認した環境

  • Next.js 16.2.7 / React 19.2.4
  • 別agentレビューの指摘#3として2026-07-24に修正

この記事の根拠

  • TypeScriptファイル 125〜165行目コミット d92b633
  • TypeScriptファイル 172〜197行目コミット d92b633

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。