iOS 11.3 SafariとmacOS Safari 11.1の空<input type="file">をAjaxで送信できない問題への対応

「特定のiOS端末だけ投稿時に400 Bad Requestが返ってくる」・・・突然のバグ報告をもらい、同僚の @elim さんと一緒に四苦八苦しながら調査した結果、最新のSafariのバグであることを突き止めた話です。

## SafariのXHRバグの概要

iOS 11.3とmacOS向けのSafari 11.1が2018/03/29にリリースされました。
https://jp.techcrunch.com/2018/03/30/2018-03-29-apple-releases-ios-11-3-with-new-animojis/

これらのバージョンでは、ファイルが選択されていない`<input type="file">`が含まれているform要素をFormDataに渡してXmlHttpRequestに使うと、リクエスト実行時にしばらく沈黙したりしなかったりしてから、`400 Bad Request`やProtocol Error等で失敗します。

例えば画像ファイルが添付可能なAjax投稿欄の実装で`<input type="file">`が設置されている場合、このバグによって該当バージョンのSafariを使用しているiOSやmacでのみ、投稿時に謎のエラーが起きて失敗する形になります。

https://stackoverflow.com/questions/49614091/safari-11-1-ajax-xhr-form-submission-fails-when-inputtype-file-is-empty

macOSのSafariはすぐ修正されてもおかしくないと思うのですが、**iOSのほうは次のOSリリースを待つ**こととなり、いつ修正されるのか不安が残ります。その間ユーザーが一部機能を一切使えなくなってしまう恐れがあるので、何らかの対策コードを入れるのが得策だと考えています。

## Railsにおける問題と対応策

Railsは、rails-ujsやjquery_ujsに通常の`<form>`をXHRを使ってAjaxで送信する機能を持っています（通称`remote: true`）。特にRails 5.1以降においてform_withでは`remote: true`の挙動がデフォルト（無効化するには`local: true`を指定）になったため、form_withを使用しているだけでなんの前触れもなくこの問題にハマることになります。

https://github.com/rails/rails/issues/32440


**追記：
FirefoxでFormDataの中身を取得すると、空ファイルinputが、な、なんと、Fileオブジェクトじゃなくてただの空文字列になっていることが判明しました。ファイルであるかを判別する他の方法がないため、FormDataを書き換える系のスニペットを正しく実装することは実質不可能となりました・・。（そのまま使うと空欄のテキスト入力欄として送信されてしまいます）**

そのため、下記のようにFormDataを作成する前後で、DOMをいじって当該inputをdisabledにする（＝formの送信対象から外す）のが最も安全な対策となります。
（※`ajax:beforeSend`では、FormDataオブジェクトがイベントの中に含まれて渡ってきますが、`ajax:before`ではそれがまだありません。）

```js
// iOS 11.3 Safari / macOS Safari 11.1 empty <input type="file"> XHR bug workaround.
// This should work with every modern browser which supports ES5 (including IE9).
// https://stackoverflow.com/questions/49614091/safari-11-1-ajax-xhr-form-submission-fails-when-inputtype-file-is-empty
// https://github.com/rails/rails/issues/32440
// https://qiita.com/yuya_presto/items/65be91b0255af49f0396

document.addEventListener('ajax:before', function(e) {
  var inputs = e.target.querySelectorAll('input[type="file"]:not([disabled])')
  inputs.forEach(function(input) {
    if (input.files.length > 0) return
    input.setAttribute('data-safari-temp-disabled', 'true')
    input.setAttribute('disabled', '')
  })
})

// You should call this by yourself when you aborted an ajax request by stopping a event in ajax:before hook.
document.addEventListener('ajax:beforeSend', function(e) {
  var inputs = e.target.querySelectorAll('input[type="file"][data-safari-temp-disabled]')
  inputs.forEach(function(input) {
    input.removeAttribute('data-safari-temp-disabled')
    input.removeAttribute('disabled')
  })
})
```

### 古いバージョン

~~下記のスニペットをapplication.js等の適当な場所に貼り付けると、rails-ujsおよびjquery_ujsがAjaxでデータを送信する直前に、FormDataオブジェクトの中から問題となる空のFileオブジェクトを空のBlobオブジェクトに置き換えることで解決します。~~

```js
// iOS 11.3 Safari / macOS Safari 11.1 empty <input type="file"> XHR bug workaround.
// Replace empty File object with equivalent Blob in FormData, keeping its order, before sending it to server.
// Should work with IE10 and all other modern browsers.
// Because useragent value can be customized by WebView or etc., applying workaround code for all browsers.
// https://stackoverflow.com/questions/49614091/safari-11-1-ajax-xhr-form-submission-fails-when-inputtype-file-is-empty
// https://github.com/rails/rails/issues/32440
document.addEventListener('ajax:beforeSend', function(e) {
  var formData = e.detail[1].data
  if (!(formData instanceof window.FormData)) return
  if (!formData.keys) return // unsupported browser
  var newFormData = new window.FormData()
  Array.from(formData.entries()).forEach(function(entry) {
    var value = entry[1]
    if (value instanceof window.File && value.name === '' && value.size === 0) {
      newFormData.append(entry[0], new window.Blob([]), '')
    } else {
      newFormData.append(entry[0], value)
    }
  })
  e.detail[1].data = newFormData
})
```

https://github.com/rails/rails/issues/32440#issuecomment-381185380

## jQueryの$.ajax()等、FormDataを自前で作っている場合

**追記：こちらも上述の通り、FormDataを後からフィルタがFirefoxで正しく機能しないので、FormDataを作る箇所に下記のスニペットを設置することで代替してください。**

```js
// iOS 11.3 Safari / macOS Safari 11.1 empty <input type="file"> XHR bug workaround.
// https://qiita.com/yuya_presto/items/65be91b0255af49f0396
var $form = $('form')
var $inputs = $('input[type="file"]:not([disabled])', $form)
$inputs.each(function(_, input) {
  if (input.files.length > 0) return
  $(input).prop('disabled', true)
})
var formData = new FormData($form[0])
$inputs.prop('disabled', false)
```

Live Demo: https://jsfiddle.net/ypresto/05Lc45eL/

jQueryを使っていない場合は下記

```js
// iOS 11.3 Safari / macOS Safari 11.1 empty <input type="file"> XHR bug workaround.
// https://qiita.com/yuya_presto/items/65be91b0255af49f0396
var form = document.querySelector('form')
var inputs = form.querySelectorAll('input[type="file"]:not([disabled])')
inputs.forEach(function(input) {
  if (input.files.length > 0) return
  input.setAttribute('disabled', '')
})
var formData = new FormData(form)
inputs.forEach(function(input) {
  input.removeAttribute('disabled')
})
```


### 古いバージョン

~~`new FormData()`に`<form>`要素を渡すことでFormDataを作っている場合、上のsnippetにある空FileをBlobに置き換えるコードを作成後に呼ぶことで対応できます。~~

```js
var formDataFilter = function(formData) {
  if (!(window.FormData && formData instanceof window.FormData)) return
  if (!formData.keys) return // unsupported browser
  var newFormData = new window.FormData()
  Array.from(formData.entries()).forEach(function(entry) {
    var value = entry[1]
    if (value instanceof window.File && value.name === '' && value.size === 0) {
      newFormData.append(entry[0], new window.Blob([]), '')
    } else {
      newFormData.append(entry[0], value)
    }
  })
  return newFormData
}
```

https://stackoverflow.com/a/49827426/1474113

Live Demo: https://jsfiddle.net/ypresto/y6v333bq/

### ~~もうちょっと透過的にやるには~~

~~`XMLHttpRequest.prototype.send()`を置き換えて、アプリケーションコードを一切書き換えずに上記のfilterコードを挟むこともできそうです。
https://stackoverflow.com/questions/5202296/add-a-hook-to-all-ajax-requests-on-a-page
（ただし、影響範囲が未知数なのであまりおすすめとは言えません。）~~

## 影響範囲が限られていてサクッと対応したい場合

ファイルを扱っているformが一箇所だけとかの場合は、submit時に `if (!inputElement.files.length) inputElement.setAttribute('disabled', '')`して後で戻すという作戦とかでも回避できます。

## なぜ問題のあるブラウザ以外にも適用するのか

FirefoxのFormData問題があったので、Safariにだけ適用しようと考えて試行錯誤したのですが、[以前からあった方法がiframeの中だと動かなくなった](https://stackoverflow.com/questions/9847580/how-to-detect-safari-chrome-ie-firefox-and-opera-browser#comment79417294_9851769)り、User-AgentはWebViewの場合やセキュリティ対策ソフト等が入っていると自由に書き換えられてしまうだろうので、Safariを確実安全に判定できる方法がないと判断しましたorz