症状

「成功したのに遷移しない」。サーバーは 200 を返し、ログを見ると処理も正しく走っている。なのにブラウザ側は何も受け取っていないかのように振る舞う。お気に入りトグルを押しても表示が変わらない。フォームを送っても、レスポンスに入れたはずのリダイレクト先へ飛ばない。

Chrome では正常。Safari 26 だけで起きます。原因は2つあり、どちらも fetch のレスポンスボディにまつわるものでした。

原因1: res.json() が正常な JSON で例外を投げる

これがいちばん意外でした。

const res = await fetch(url, { method: 'POST', body: ... });
const data = await res.json();   // Safari 26 で正常な JSON でも throw することがある

サーバーが返しているのは妥当な JSON です。Chrome では問題なくパースできます。ところが Safari 26 は、その同じレスポンスに対して res.json() が例外を投げることがある。

タチが悪いのは、多くのコードがこの例外を握りつぶしている点です。

try {
  const data = await res.json();
  if (data.redirect) location.href = data.redirect;
} catch (e) {
  // 握りつぶす → data ごと消え、redirect も失われる
}

例外を catch して無視すると、data が丸ごと消えます。その中に入っていた redirect も一緒に消える。結果が「成功したのに遷移しない」です。サーバーは正しく応答しているのに、クライアントがボディを捨てている。

原因2: POST のボディが非決定的に空になる

もう1つ、fetch の POST でレスポンスボディが非決定的に空になることがあります。毎回ではなく、時々。空のときに res.json() を呼べば当然パースできず、上と同じく握りつぶされて body が欠落します。

「時々」なので、手元で数回試して再現しないと「直った」と誤認しやすい。これも Safari 特有の厄介さです。

対策1: res.json() を使わない

res.json() は使用禁止にしました。代わりに res.text() でテキストとして受けてから JSON.parse します。空・非 JSON のときは例外を投げずに null を返す。

function readJson(res) {
  return res.text().then(function (text) {
    if (!text) return null;          // 空ボディは null(throw しない)
    try { return JSON.parse(text); }
    catch (e) { return null; }        // 非 JSON も null
  });
}

res.text() はボディをそのまま文字列で返すので、res.json() のような内部パースの気まぐれに巻き込まれません。パースは自分で JSON.parse する。空なら null を返して、呼び出し側がステータスから判断できるようにします。

対策2: POST は XHR で送る

fetch の POST ボディ欠落を避けるため、POST は XMLHttpRequest に切り替えました。レスポンスは responseText を JSON.parse します。

function postJson(url, data, token) {
  return new Promise(function (resolve, reject) {
    var xhr = new XMLHttpRequest();
    xhr.open('POST', url, true);
    xhr.withCredentials = true;
    xhr.setRequestHeader('Content-Type', 'application/json');
    xhr.setRequestHeader('X-CSRF-Token', token);
    xhr.onreadystatechange = function () {
      if (xhr.readyState === 4) {
        var raw = xhr.responseText;
        var parsed = null;
        try { if (raw) parsed = JSON.parse(raw); } catch (e) {}
        resolve({ status: xhr.status, ok: xhr.status >= 200 && xhr.status < 300, data: parsed, raw: raw });
      }
    };
    xhr.onerror = reject;
    xhr.send(data == null ? null : JSON.stringify(data));
  });
}

返すのは { status, ok, data, raw }。data が null(ボディが空)でも、status と ok は取れます。だからボディが空でも、ステータスから楽観的に確定できる。トグル系 UI なら「クリック前の状態を反転させる」で十分です。

const r = await postJson('/favorite/toggle', { nid });
// data が空でも、200 なら成功とみなしてクリック前状態から反転
const added = r.data ? r.data.added : !wasFavorited;

落とし穴: CSRF トークンの取得も空になる

XHR に切り替えても、CSRF トークンの取得でまた詰まりました。Drupal の /session/token を GET で取る定番も、Safari 26 では空を返すことがあります。トークンが空だと POST が弾かれる。

対策は、トークンを GET で取りに行かず、サーバーがページに埋め込んだ値を読むことです。

<meta name="csrf-token" content="(サーバーが出力したトークン)">

「クライアントから取りに行く」のをやめて「サーバーが最初から渡しておく」に変えると、空になる経路そのものが消えます。フォールバックとして /session/token も残しますが、優先は <meta> です。

仕組みを1箇所に閉じ込める

これらは個々のスクリプトで対処すると漏れます。依存ゼロの小さなブートストラップを <head> の先頭で読み、window.rikka.readJson / postJson / csrfToken として全 JS から使えるようにしました。順序レースに強く、どのスクリプトからも同じ安全な経路を通れます。

そして再発防止として、カスタム JS 全体を走査して res.json() の使用を検出する自動テストを入れています。禁止を人間の記憶に頼らず、機械で守る。

まとめ

  • Safari 26 は正常な JSON でも res.json() が例外を投げることがある。 握りつぶすと body ごと欠落し「成功したのに遷移しない」になる
  • res.json() を使わず res.text() + JSON.parse。 空・非 JSON は null を返して throw しない
  • POST のボディが非決定的に空になる。 POST は XHR にし、ボディが空でも status から楽観的に確定する
  • /session/token の GET も空を返す。 CSRF は <meta> 埋め込みを優先する
  • 禁止事項は自動テストで機械検出する。 人間の記憶に頼らない

この記事は、事業・Webサイトの売買プラットフォーム RIKKA M&A を開発する中で得た知見です。RIKKA M&A は GitHub リポジトリを解析する技術デューデリジェンスを備え、Drupal 11 / PHP / LLM で構築・運用しています。

サイト・アプリ・AIプロダクトM&Aのプラットフォーム RIKKA M&A