症状
「成功したのに遷移しない」。サーバーは 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 で構築・運用しています。

