disable-with
HTMLでフォームを実装する際に、送信ボタンをダブルクリックしてしまい二重送信されるトラブルは誰でも一度は踏んだ道かと思います。
Railsではこのような場合に便利な data-disable-with
属性があり、簡易的に対策が可能です。
<form>
<input type="submit" data-disable-width="送信中..." value="送信">
</form>
このように属性を指定するだけで、1回クリックするとボタンが「送信中...」に変わりdisable状態になるので、ダブルクリックできなくなります。
もちろん、重要な操作や冪等でない操作は、必要に応じてサーバ側でチェックすべきですが、簡易的な検証で足りるケース・ユーザビリティ向上目的では、クライアント側のチェックも大いに活用できます。
今回踏んだ罠
data-disable-with
を付けているフォームで、二重POSTをしてきたユーザがいました。
JSを無効化するなどで容易に突破ができるのは当然としても、そんなスキルがあるとも思えないごく一般ユーザからのリクエストのようで、なぜ発生したのか疑問に思っていました。
この処理は実際にはrails-ujsの機能です。Turboを使っている環境では事情が違うと思いますが、僕はTurboのことは良く知りません。
ローカルで少し試したところ、悪意のない一般ユーザの一般的な操作でも、以下のようなケースでは二重POSTになりそうなことに気づきました。
JSの読み込みが間に合っていない
<script>
タグをbodyの一番下に置いたり、defer
属性を付けたりすると、スクリプトの読み込みは後回しにされます。rails-ujsの読み込みが完了して実行される前に送信ボタンを押したら、クリック時にdisableされる処理は当然実行されません。
ベストプラクティスと言われて、深く考えずに全部deferとかやっていると、回線速度が遅い環境でうっかり踏んでしまうこともありそうなので、注意が必要ですね。どちらかというと data-confirm
が反映されないほうがより被害が大きそう。
超高速に連打する
本題です。
rails-ujsが正しく読み込み・実行された環境でも、送信ボタンを超高速に連打すると、二重POSTされます。具体的には13ミリ秒後の setTimeout
が発動するより前に2連打。本当に13msで連打するのは大変(僕はクリック連打テストは秒間10回位が限度)ですが、 setTimeout
はすぐに実行されるわけではなくキューに積まれるので、PCスペックが低い環境だと猶予期間が広がります。
ということで、devtoolsでCPU速度を制限して超連打したら、たまに再現できるようになりました。
どうしてこうなった
ソースを見る限り https://github.com/rails/rails/blob/v7.1.3.4/actionview/app/assets/javascripts/rails-ujs.js#L600 の setTimeout
が悪そうです。ここを増やすと誰でも簡単に再現できるようになりますし、同期実行にすれば再現しなくなります。
この行は 900d714 で導入され、その後CoffeeScript化やリポジトリ移動などの荒波に耐えながら残っているコードのようです。コメントを見る限り、 submit
イベントハンドラ内で無効化してしまうと <input type="submit">
の情報が送信データに含まれないという問題を解決したかったようですね。たしかに、複数のinputを設置して name
属性でどのボタンが押されたか判別するケースなどで困りそうです。
ただ、それなら内部でフラグを持っておいて、2回目の submit
イベントを preventDefault
するとかやりようはあった気がします。面倒くさかったのかな。
どうする?
自分のユースケースで解決するのは簡単そうですが、あらゆるケースでの副作用を考慮して本家にPRするのは少し大変そうです。rails-ujs自体が非推奨化されつつあり、おそらくマージされないことを考えると...
どのみち簡易的な対策だし、submitボタンのnameとか再有効化とかいらないんだよ!と割り切って自前スクリプトでごまかしたり、
document.querySelector('form').addEventListener('submit', (e) => {
e.target.querySelector('input[type="submit"]').disabled = true;
});
とりあえず data-confirm
を付けておいて踏みにくくする、などで逃げるのも一案になりそうです。
教訓
ネットワークやCPUをthrottolingした状態でのテストはとても有用。