Reactの保存フックは、行削除後にrouter.refresh()が失敗すると保存が黙って失敗する
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
行を削除した直後の保存フックは、保存中かどうかと再取得待ちかどうかを同じ return false にまとめていたため、再取得が終わらない間はエラーも出さずに保存だけを黙って捨てていた。
結論
Reactの保存フックuseOriginRowSaveは、保存中(saving.current)であることと、行の項目を削除した直後で再取得待ち(staleRows)であることを、同じif文でreturn falseにまとめていました。 呼び出し側は保存が失敗した理由を区別できず、再取得(router.refresh())の描画が何らかの理由で終わらない間は、エラーも表示されずに保存だけが黙って失敗し続ける経路になっていました。2026-10-01 15:55の実装コミットに対し、同日15:59のレビュー(Reviewer #1411)で発見され、4分後に修正されています。本番でこの経路が実行された記録はありません。
症状
前の日から続けて表示されている連絡・提出物を、その日のエディタでまとめて直せる機能がある。この画面で、ある行の項目を1件取り消し(削除)した直後に、同じ行に残っている別の項目を書き直して保存しようとすると、画面には何も起きない。入力欄のblurイベントは発生しているのに、サーバーへの書き込み(write)自体が呼ばれておらず、エラーメッセージも表示されない。
原因
このフックは、1件の削除のあとその行をどう扱うかを、あらかじめこう設計していました。
/**
* 「前の日から続けて表示中」の連絡・提出物で共通の保存(2026-10-01・#1408)。直す=**入力日の行の置換保存**。
*
* - 保存中と再取得中は `busy`=呼び出し側は全操作を止める(ref で二重起動も止める)。続けて 2 件直しても、
* 成功ごとに手元の行(latestRows)を更新するので古い行で上書きして 1 件目を戻さない。
* - 成功したら `router.refresh()` を transition で走らせ、再取得の描画が終わるまで busy を保つ(親が entries の
* 中身で key を振って作り直すので、それまで入力させない=書きかけが消えない・Reviewer #1408)。
* - `next === null` は当該項目の削除。行内 index がずれるので手元の行は書き換えず(同じ行のほかの項目が別の項目を
* 指してしまう)、その行は再取得で作り直されるまで保存を受け付けない(呼び出し側は削除した項目を隠す)。
*/
項目を削除すると行内のindexがずれるため、再取得でその行が作り直されるまでは保存を受け付けない、という設計自体は妥当です。問題は、この「受け付けない」を実装した箇所でした。
async function save(entry: OriginRowEntry<T>, next: T | null): Promise<boolean> {
if (saving.current || staleRows.current.has(entry.originDate)) {
return false;
}
saving.current = true;
// ...
saving.current(いままさに保存処理が実行中)とstaleRows.current.has(entry.originDate)(削除直後で再取得待ち)は、原因としては別物です。前者は一瞬で解消する「連打の間引き」、後者はrouter.refresh()の再取得が完了するまで続く「意図的な保留」です。この2つを同じif文でreturn falseに潰していたため、呼び出し側のコードは保存されなかった理由を一切区別できませんでした。
router.refresh()はstartTransitionの中で呼ばれているだけで、その完了を保証する仕組みはありません。ネットワークが不安定だったり、サーバー側の再取得が何らかの理由で描画まで進まなかったりすれば、staleRowsに入ったままの行は、ユーザーに何も知らされずに保存を拒否され続けます。
直す
2つの条件を分け、staleRows側だけ理由をsetErrorで明示するようにしました。
async function save(entry: OriginRowEntry<T>, next: T | null): Promise<boolean> {
if (saving.current) {
return false;
}
if (staleRows.current.has(entry.originDate)) {
// 通常は再取得まで操作を止めているので来ない。再取得が失敗して作り直されなかった時だけ、黙って捨てずに知らせる。
setError("読み込み直しています。少し待ってからもう一度お試しください。");
return false;
}
saving.current = true;
// ...
この挙動を固定する回帰テストも追加されています。
it("同じ行の 1 件を取り除いたあとは、作り直されるまでその行を保存しない(index のずれで別の項目を書かない)", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
renderList();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "前の日からの提出物 2 件目の取り消し" }));
});
expect(h.setAssignmentsAction).toHaveBeenCalledTimes(1);
// 残った 1 件(数学)を直しても、再取得で作り直されるまでは保存せず、知らせる。
const task = screen.getByLabelText("前の日からの提出物 1 件目の提出物");
fireEvent.change(task, { target: { value: "プリント No.8" } });
await act(async () => {
fireEvent.blur(task);
});
expect(h.setAssignmentsAction).toHaveBeenCalledTimes(1);
expect(screen.getByRole("alert").textContent).toContain("読み込み直しています");
});
削除直後に保存を保留する設計自体は変えず、「保留中であること」をsaving.currentの一瞬の間引きと取り違えないように分岐を分け、保留が長引いた場合にだけユーザーに知らせる形にしています。
再発防止
根拠にあるのは、この1箇所のif文を2つに分けたことと、その分岐を固定する回帰テストを追加したことだけです。router.refresh()の再取得が実際に失敗したときにstaleRowsの印を自動で外す仕組みが追加された、という記述は根拠にはありません。つまりこの修正は「保存が拒否され続けている理由をユーザーに見えるようにした」だけで、「拒否され続ける時間そのもの」を短くする変更ではありません。
構造として見えるのは、{ data, error }型のAPIでerrorを握りつぶすのと同じ形の問題が、Reactのローカル状態(refで持つフラグ)同士の合流でも起きるということです。今回は「一時的な間引き」と「完了待ちの保留」という性質の違う2つのfalseを、呼び出し側から見分けられない1つのif文に合流させていました。性質が異なる複数の条件を1つの早期returnにまとめるときは、それぞれの条件が「どれだけの時間、何を理由に」偽を返すのかを先に書き分けておかないと、片方が長引いた瞬間に無言のまま固まる経路が生まれます。
よくある質問
Q1このバグは本番で実際に発生したのですか?
いいえ。2026-10-01 15:55 の実装コミットに対して、同日 15:59(レビュー Reviewer #1411)で発覚し、4分後に修正されています。本番でこの経路が実行された記録はありません。
Q2具体的に何と何を区別せずfalseを返していたのですか?
保存処理が実行中であること(saving.current)と、直前に行の項目を削除してその行がまだ再取得で作り直されていないこと(staleRows)の2つを、同じif文でまとめてreturn falseしていました。呼び出し側はどちらが理由で保存されなかったのか区別できませんでした。
Q3なぜstaleRowsは再取得が終わるまで保存をブロックするのですか?
項目を削除すると行内のindexがずれるため、再取得で作り直されるまで手元の行を書き換えると別の項目を指してしまう危険があるためです。削除直後にその行への保存を止める設計そのものは今回のバグではありません。
Q4直し方はエラーを出すようにしただけですか?
はい。根拠にあるのは、2つの条件を分けてstaleRowsの場合だけ「読み込み直しています。少し待ってからもう一度お試しください。」とsetErrorするよう変更したことと、その挙動を固定する回帰テストを追加したことだけです。再取得が失敗したときにstaleRowsの印を自動で外す仕組みが追加されたという記述は根拠にありません。
確認した環境
- Next.js ^16.3.7 / React ^19.0.0 — キミテラス-v2 apps/web
- 2026-10-01 15:55 実装 → 同日 15:59 レビュー(Reviewer #1411)で発覚・4分後に修正(本番未実行)
この記事の根拠
- TypeScriptファイル 10〜19行目コミット 2aae210
- TypeScriptファイル 38〜41行目コミット 2aae210
- TypeScriptファイル 38〜46行目コミット 35ae6c5
- TypeScriptファイル 164〜179行目コミット 35ae6c5
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。