HubSpotチャットウィジェットのレイアウトシフトを修正する
ブラウザのレイアウトシフト除外ルールを2つ活用し、0.91のCLSを0.02に下げる回避策

HubSpotチャットによって引き起こされるレイアウトシフトの修正方法
HubSpotのチャットウィジェットは、ページ上で0.91という大規模なCLSを引き起こす可能性があります。これはよく知られた問題です。そこで、皆さんのためにこれを修正しました。以下のドロップインスクリプトを使用すれば、CLSのp75を0.91から0.00に抑えることができます。理想を言えばHubSpot自身が修正すべきですが、彼らは修正しませんし、私がHubSpotのコードを変更することもできません。ですので、これはハックだと承知していますが、機能します。
Table of Contents!
HubSpotチャットのCLSは一般的な不満です
人々は何年も前から不満の声を上げています。HubSpot Communityのスレッドがこちらやこちら、こちらにあり、Google Search Consoleのヘルプスレッド、そしてCronyx Digitalの記事もあります。それでもHubSpotは修正していません。
なぜCLSが発生するのか
HubSpotのチャットウィジェットは、idがhubspot-messages-iframe-containerのiframe内にレンダリングされます。訪問者がランチャーをタップすると、コンテナはランチャーサイズ(約60×60ピクセル)からフルパネルサイズ(約380×600ピクセル)へとアニメーションしながら拡大します。訪問者が閉じた時は、コンテナが元に戻るアニメーションが行われます。
ここに落とし穴があります。Layout Instability APIは、hadRecentInputを入力イベントが発生したドキュメントに結び付けます。HubSpotランチャーのクリックはiframeドキュメント内で発生します。しかし、iframeコンテナがリサイズされる場所であるため、レイアウトシフトは親ドキュメントに影響します。2つの異なるドキュメントが存在するわけです。親ドキュメントのhadRecentInputはfalseのままであるため、このシフトはカウントされてしまいます。
実際には対処すべきシフトのタイミングが3つあります。ロード時、HubSpotはランチャーを小さく(約100x96)マウントし、1、2秒後に約242x245に拡大してウェルカムバブルを表示します。ここではユーザー入力は一切ありません。そのため完全にカウントされます。開く時、クリックはiframe内で発生するため、親ドキュメントはそれを検知しません。これも完全にカウントされます。閉じる時、クロスフレームの同じ問題が発生することに加え、閉じるアニメーションが500ミリ秒以上続くため、仮に正しい起因付けが行われていたとしても、除外ウィンドウを過ぎた後のフレームがカウントされてしまいます。
私は複数のクライアントサイトでこれを測定しました。1回の開閉サイクルだけで、0.91のCLS寄与が生じる可能性があります。これだけで、ページが合格スコアから「不良(poor)」のバケツへと押し下げられるのに十分な数値です。

コード
これがそのスクリプトです。あなたのサイトにドロップしてください。1回だけ実行され、ビルドステップ、フレームワーク、依存関係は一切不要です。Vanilla JSです。HubSpotランチャーの上に透明なボタンをマウントし、開閉を処理し、ロード時の拡大を隠蔽(クローク)します。
(function () {
if (window.__cwvHsClsFix) return;
window.__cwvHsClsFix = true;
// 戦略:
// LOAD: iframe-containerが表示された瞬間にopacity:0で隠蔽し、
// HubSpotがランチャーからウェルカムバブルへの展開を終えた後に表示する。
// Chromeのレイアウトシフトアルゴリズムは、現在と前のフレームの両方で
// 計算されたopacityが0である要素の矩形変更をスキップするため、
// この拡大によるシフトエントリはゼロになる。
//
// OPEN: 不可視の同一オリジンのオーバーレイがタップを捕捉し、
// widget.open()を呼び出す。シフトは500ミリ秒の入力猶予期間内に発生するため、
// hadRecentInput=trueとなり、シフトはCLSから除外される。
//
// CLOSE: (クリックハンドラー内で) 同期的にコンテナにopacity:0を設定し、
// その後にwidget.close()を呼び出す。同じopacity:0の除外ルールにより、
// HubSpotの遅い閉じるアニメーションがマスクされる。HubSpotが
// (ランチャーサイズに戻って)完了したら、次のクリックが機能するようにopacityを復元する。
//
// AUTO-OPEN: HubSpotが読み取る前にhs-messages-is-open Cookieを削除し、
// 前回の「開いた」セッションによってページ読み込み時に入力なしのシフトが
// 発生しないようにする。
function clearOpenCookie() {
var paths = ['/', location.pathname];
var domains = ['', location.hostname, '.' + location.hostname];
paths.forEach(function (p) {
domains.forEach(function (d) {
document.cookie = 'hs-messages-is-open=false; path=' + p +
(d ? '; domain=' + d : '') + '; max-age=86400; SameSite=Lax';
});
});
}
clearOpenCookie();
var Z = '2147483647';
var BASE = 'position:fixed;background:transparent;border:0;padding:0;' +
'margin:0;cursor:pointer;pointer-events:auto;' +
'z-index:' + Z + ';';
var tap = document.createElement('button');
tap.type = 'button';
tap.id = '__cwv_tap';
tap.setAttribute('aria-label', 'Open chat');
tap.style.cssText = BASE + 'right:0;bottom:0;width:100px;height:100px;';
document.body.appendChild(tap);
function getContainer() {
return document.getElementById('hubspot-messages-iframe-container');
}
function isOpen() {
var c = getContainer();
if (!c) return false;
var r = c.getBoundingClientRect();
return r.width >= 280 && r.height >= 280;
}
function place(rect, asClose) {
if (!rect) {
tap.style.cssText = BASE + 'right:0;bottom:0;width:100px;height:100px;';
tap.setAttribute('aria-label', 'Open chat');
return;
}
if (asClose) {
tap.style.cssText = BASE +
'left:' + (rect.right - 60) + 'px;top:' + rect.top + 'px;' +
'width:60px;height:60px;';
tap.setAttribute('aria-label', 'Close chat');
} else {
tap.style.cssText = BASE +
'left:' + rect.left + 'px;top:' + rect.top + 'px;' +
'width:' + rect.width + 'px;height:' + rect.height + 'px;';
tap.setAttribute('aria-label', 'Open chat');
}
}
function reposition() {
var c = getContainer();
if (!c) return place(null, false);
place(c.getBoundingClientRect(), isOpen());
}
// ロード時の隠蔽: HubSpotはランチャーを約100x96でマウントし、
// 約1〜2秒待機した後、ウェルカムバブルを表示するために約242x245に拡大する。
// この隙間、ランチャーは「安定」しているように見えるため、短い安定性しきい値では
// 早く表示されすぎてしまう。その隙間よりも長いつきい値が必要となる。
// 余裕を持たせて2000ミリ秒でカバーする。遅いネットワークに備えて7秒をハードキャップとする。
function cloakOnLoad() {
var c = getContainer();
if (!c) return setTimeout(cloakOnLoad, 50);
if (c.dataset.cwvCloaked) return;
c.dataset.cwvCloaked = '1';
c.style.opacity = '0';
var lastSig = '';
var stableTimer = null;
var revealed = false;
var revealRO = null;
var reveal = function () {
if (revealed) return;
revealed = true;
if (revealRO) try { revealRO.disconnect(); } catch (e) {}
var cur = getContainer();
if (cur) cur.style.opacity = '1';
reposition();
};
var check = function () {
if (revealed) return;
var cur = getContainer();
if (!cur) return;
var r = cur.getBoundingClientRect();
var sig = r.width + 'x' + r.height;
if (sig !== lastSig) {
lastSig = sig;
if (stableTimer) clearTimeout(stableTimer);
stableTimer = setTimeout(reveal, 2000);
}
};
if (window.ResizeObserver) {
revealRO = new ResizeObserver(check);
revealRO.observe(c);
}
check();
setTimeout(reveal, 7000);
}
cloakOnLoad();
var ro;
function watch() {
if (ro) try { ro.disconnect(); } catch (e) {}
var c = getContainer();
if (!c) return setTimeout(watch, 100);
if (window.ResizeObserver) {
ro = new ResizeObserver(reposition);
ro.observe(c);
}
reposition();
var t0 = Date.now();
var tick = function () {
reposition();
if (Date.now() - t0 < 1500) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
watch();
if (window.MutationObserver) {
new MutationObserver(function () {
var c = getContainer();
if (c && (!ro || (ro._target && ro._target !== c))) watch();
}).observe(document.body, { childList: true });
}
function whenReady(fn) {
if (window.HubSpotConversations) return fn(window.HubSpotConversations);
(window.hsConversationsOnReady = window.hsConversationsOnReady || [])
.push(function () { fn(window.HubSpotConversations); });
}
function showChat() {
// 隠蔽がまだアクティブな場合は、今すぐ表示する。入力主導のシフトは
// すでにhadRecentInputによって除外されており、ユーザーはチャットを見る必要があるため。
var c = getContainer();
if (c) c.style.opacity = '1';
whenReady(function (HS) {
var status = HS.widget.status ? HS.widget.status() : null;
if (status && status.loaded === false) {
HS.widget.load({ widgetOpen: true });
} else {
HS.widget.open();
}
});
requestAnimationFrame(reposition);
}
function hideChat() {
var c = getContainer();
if (!c) return;
// 重要: レイアウトシフトAPIが閉じるアニメーションの矩形変更をスキップするように、
// widget.close()の前にopacity:0を設定する。
c.style.opacity = '0';
c.style.pointerEvents = 'none';
whenReady(function (HS) { HS.widget.close(); });
clearOpenCookie();
reposition();
setTimeout(function () {
var c2 = getContainer();
if (c2) {
c2.style.opacity = '1';
c2.style.pointerEvents = 'auto';
}
reposition();
}, 1200);
}
tap.addEventListener('click', function () {
if (isOpen()) hideChat();
else showChat();
});
whenReady(function (HS) {
if (HS.on) {
HS.on('widgetOpened', reposition);
HS.on('widgetClosed', function () { clearOpenCookie(); reposition(); });
}
window.addEventListener('resize', reposition);
window.addEventListener('scroll', reposition, { passive: true });
});
window.addEventListener('pagehide', clearOpenCookie);
window.addEventListener('beforeunload', clearOpenCookie);
})(); 回避策
私のスクリプトは、それぞれのシフトのタイミングに対する3つの処理と、自動オープンのパスに対する1つの防御策を実行します。
トリック1: ロード時のランチャーの拡大を隠蔽する。cloakOnLoadは、iframeコンテナがマウントされた瞬間にopacity: 0を設定し、ResizeObserverでそれを監視し、寸法が2秒間安定した段階で表示します。遅いネットワークのためのセーフティネットとして、7秒のハードキャップを設けています。Chrome 89は、現在と前のフレームの両方で計算されたopacityが0である要素の矩形変更を無視するため、ランチャーからウェルカムバブルへの拡大は目に見えない形で行われます。シフトエントリはゼロです。隠蔽が解除される前に訪問者がタップした場合、入力自体がシフトを除外するため、showChatが即座に表示されます。
トリック2: 親でのオープンクリックをキャッチする。HubSpotランチャーの上に不可視のボタンをマウントし、ResizeObserver、MutationObserver、および1.5秒のrequestAnimationFrameポーリングを使用して、iframeの境界矩形に固定したままにします。HubSpotは時折、特にシングルページアプリにおいてiframeを再マウントするため、その際にオブザーバーが再アタッチされます。訪問者がタップすると、ボタンは親ドキュメントでイベントを受け取り、HubSpotConversations.widget.open()を呼び出します。これで親には最近の入力(recent input)が存在することになります。ChromeはこのシフトにhadRecentInput=trueのフラグを立てます。これで除外されます。チャットが開いている時、ボタンは閉じる用の「X」のみを覆うため、チャット内のクリックを妨げることはありません。
トリック3: 閉じるアニメーションをopacityの背後に隠す。トリック1と同じopacity 0のトリックです。スクリプトはクリックハンドラー内で同期的にopacity: 0を設定し、その後widget.close()を呼び出します。順序が重要です。最初にopacityを0にし、次に閉じます。これを逆にすると、opacityがゼロになる前に閉じるアニメーションが開始され、シフトの最初の数フレームが記録されてしまいます。
上部にあるCookieの削除は防御策です。hs-messages-is-open Cookieは、前のセッションでウィジェットが開いていたことを記憶しており、次のページ読み込み時に自動的に再オープンします(ユーザー入力は不要です)。このCookieを削除することで、そのパスを完全に遮断します。
結果
私がこれを最初に導入したクライアントのサイトでは、フィールドデータが蓄積される2日間のうちにCLSのp75が0.91から0.00に低下しました。ウィジェットは視覚的には今までと同じように開閉します。訪問者が何かに気づくことはありません。CrUXは月次サイクルで新しいフィールドデータをピックアップします。

HubSpotチャットのCLSに関するFAQ
修正自体について
これによりHubSpotのトラッキングやチャット機能は壊れませんか?
壊れません。ウィジェット自体は変更されていません。開くクリックと閉じるクリックを傍受しているだけです。会話履歴、エージェントのルーティング、連絡先の特定など、どれも影響を受けません。
これはモバイルでも機能しますか?
はい。透明なボタンはposition: fixedで配置され、iframeの境界矩形に追従します。デスクトップと同じようにモバイルでも機能します。
これはINPに影響しますか?
クリックハンドラーは同期的な処理をほとんど行いません(opacityの書き込み、Cookieの書き込み、getBoundingClientRectの同期的なDOM読み取り程度です)。INPへの影響はごくわずかです。ほとんどのサイトにおいて、HubSpotウィジェット自体がより大きなINPリスクであり、それは別の問題となります。
ブラウザとCLSの測定
SafariとFirefoxについてはどうですか?
SafariとFirefoxはLayout Instability APIを実装していないため、CLSはChromiumブラウザでのみ測定されます。ウィジェットの開閉に関して言えば、スクリプトはSafariやFirefoxでも引き続き機能します。いずれにせよ、それらの訪問者はあなたのCrUXのCLSデータには寄与していません。
チューニングと安定性
opacityを復元するためのタイムアウトが1200msなのはなぜですか?
HubSpotの閉じるアニメーションは数百ミリ秒にわたって実行されます。1200msあれば十分な余裕が生まれます。短すぎると、iframeがまだアニメーションの途中である間にopacityが復元され、残りのフレームがカウントされてしまいます。
HubSpotがiframeのIDを変更した場合はどうなりますか?
その場合、このスクリプトは壊れます。hubspot-messages-iframe-containerというIDは何年にもわたり安定していますが、それはサードパーティの実装の詳細です。HubSpotが再設計版をリリースした場合は、セレクタを更新する必要があります。これが他社のウィジェットの上に構築する際のコストです。

