症状

Drupal 11.3 のサイトを Safari 26 で開くと、あるページでボタンが1つも動かなくなりました。クリックしても無反応、チェックボックスを入れても送信ボタンが disabled のまま。同じページを Chrome で開くと正常です。

「Safari だけ動かない」は普通、特定のスクリプト1つの互換性問題です。ところがこれは違いました。そのページの Drupal.behaviors が1つも登録されていない。特定の機能ではなく、Drupal の JS 挙動が丸ごと死んでいます。

まず再現環境を疑いから外す

「Chrome は正常、Safari だけ不可」のとき、最初にやるのは実機 Safari の拡張機能やキャッシュを疑うことではありません。クリーンな WebKit で再現するかを見ます。拡張の注入やプロファイル汚染を排除しないと、サイトのバグと環境のバグを取り違えます。

playwright-cli open --browser=webkit

クリーンな WebKit で同じ症状が出ました。これでサイト側のバグと確定します。ここを飛ばして実機 Safari だけで調べると、原因の切り分けが1段増えます。

コンソールに1つだけ uncaught TypeError

WebKit のコンソールを見ると、そのページの読み込み中に uncaught な TypeError が1つ出ていました。Drupal.htmxDrupal.debounce が undefined、という類のものです。

ここが核心でした。この1つの例外が、Drupal.attachBehaviors() の実行を途中で止めています。 attachBehaviors は登録された behavior を順に呼ぶループなので、途中で throw されるとそれ以降が回らない。結果、そのページの behavior が「一切登録されていない」ように見えます。1つの例外が、無関係な全機能を巻き添えにしていました。

なぜ Drupal 11.3 で起きるのか

Drupal 11.3 で BigPipe が htmx ベースに変わりました。BigPipe は、ページの外枠を先に返して、重いプレースホルダを後からストリームで差し込む仕組みです。11.3 ではこのストリーム処理が big_pipe.commands.js になり、本文の置換チャンクを MutationObserver で拾って Drupal.htmx.mergeSettings()addAssets() を呼びます。

問題はロード順です。

  • Drupal.htmx / Drupal.debounce を定義する core のライブラリは、ページフッターで読み込まれます
  • 一方 BigPipe の本文ストリーム処理は、そのフッターよりに走ることがあります

Chrome ではタイミングがずれて顕在化しませんが、Safari 26 ではストリーム処理がフッターの core ロードを追い越します。すると Drupal.htmx がまだ undefined のまま Drupal.htmx.mergeSettings() が呼ばれ、uncaught TypeError。そこから前述の attachBehaviors 中断に連鎖します。

core/misc/form.jsDrupal.debounce を IIFE の引数に取っているのも同じ構図で、フッターの debounce ロードより先に評価されると undefined を掴みます。

対策: 隙間を埋めるガードを head に置く

根本原因は「本物の core が来る前に呼ばれる」ことなので、本物が来るまでの隙間を最小実装で埋めます。依存ゼロのブートストラップを <head> に置き、Drupal.htmxDrupal.debounce を先に定義しておきます。

(function () {
  'use strict';
  var Drupal = (window.Drupal = window.Drupal || {});

  // core/misc/htmx/htmx-utils.js の mergeSettings と同等の再帰ディープマージ。
  function mergeSettings(current) { /* ... 省略 ... */ }

  if (
    !Drupal.htmx ||
    typeof Drupal.htmx.mergeSettings !== 'function' ||
    typeof Drupal.htmx.addAssets !== 'function'
  ) {
    var existingHtmx = Drupal.htmx || {};
    Drupal.htmx = {
      mergeSettings: existingHtmx.mergeSettings || mergeSettings,
      // 隙間でのみ呼ばれる暫定実装。即時 resolve で add_css コマンドの throw を防ぐ。
      addAssets: existingHtmx.addAssets || function () { return Promise.resolve(); },
    };
  }

  if (!Drupal.debounce) {
    // core/misc/debounce.js と同等の最小実装。
    Drupal.debounce = function (func, wait, immediate) { /* ... 省略 ... */ };
  }
})();

設計で効いているのは4点です。

依存ゼロで <head> に置く。 core より前に走るので、core を IIFE 引数に取ると undefined になります。window.Drupal を自前で用意します。ここを間違えると、対策のスクリプト自体が ReferenceError で死にます(実際に一度踏みました)。

実行順序が命。 このガードは drupal.js よりbig_pipe よりに走る必要があります。drupal.jswindow.Drupal を無条件に初期化するので、その前に置くと消されます。big_pipe はフッターなので、head に置けば確実に先行します。この順序を壊すと対策になりません。

冪等であること。 core はフッターで Drupal.htmx を無条件代入で上書きします。つまり定常状態では本物が使われ、ガードは「ストリーム初期の隙間」でだけ働きます。上書きされる前提で、最小実装を置いています。

addAssets は即時 resolve する。 隙間の段階では実アセットを読めませんが、Promise を返して即 resolve すれば add_css コマンドの throw を防げます。プレースホルダの CSS は通常メインの集約に含まれるので、見た目の影響は出ません。

対策しても、critical な操作は behavior に依存させない

ガードで attachBehaviors の中断は塞げますが、それでも取引フローや同意ゲートのような critical な操作は、behavior に頼らない方針にしました。

理由は、attachBehaviors が中断する経路は BigPipe 以外にもあり得るからです。万一また止まっても死なないように、重要な操作は Drupal.behaviors / once / jQuery に依存せず、素の document へのイベント委譲で実装します。

// behavior が全滅しても動く。document への委譲+依存ゼロ。
document.addEventListener('click', function (e) {
  var btn = e.target.closest('[data-critical-action]');
  if (!btn) { return; }
  // ... 処理 ...
});

「フレームワークの初期化に乗る」のが普通ですが、初期化そのものが落ちうる経路では、乗らない実装を併用します。落ちても死なない層を、金銭が動く操作にだけ敷いています。

検証の順序

このクラスのバグは、机上で JS を連投すると泥沼にはまります。順序を固定しました。

  1. クリーンな WebKit で再現する(playwright-cli open --browser=webkit)。環境要因を先に排除
  2. コンソールの uncaught 例外を1つ特定する。behavior 全滅の起点はほぼこれ
  3. ロード順(どのスクリプトが本物より先に評価されているか)を確認する
  4. 最終確認だけ実機 Safari で行う

「Chrome は動くのに Safari が動かない」を、いきなり実機 Safari の勘で直そうとしないことです。

まとめ

  • Drupal 11.3 の BigPipe は htmx 化され、本文ストリーム処理が Drupal.htmx / Drupal.debounce を呼ぶ。 これらはフッターロードのため、Safari 26 ではストリームが追い越して undefined を掴む
  • 1つの uncaught TypeError が attachBehaviors を中断させ、そのページの behavior が全滅する。 症状は「特定機能の不調」でなく「全ボタンが死ぬ」
  • 対策は、依存ゼロのガードを <head>drupal.js の後・big_pipe の前)に置き、隙間を最小実装で埋める。 core が後から本物で上書きするので冪等
  • critical な操作は behavior に依存させず、document へのイベント委譲で実装する。 初期化が落ちても死なない層を用意する
  • 検証はクリーン WebKit での再現から始める。 実機 Safari の勘で JS を連投しない

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

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