Risolvere il Layout Shift del widget di chat HubSpot
Un workaround che riduce un CLS da 0.91 a 0.02 sfruttando due esclusioni dei layout-shift nel browser

Come risolvere il layout shift causato dalla chat HubSpot
Il widget della chat HubSpot può causare un enorme CLS di 0.91 su una pagina. È un problema noto. Quindi l'ho risolto per te. Puoi utilizzare il mio script drop-in qui sotto che porta il p75 del CLS da 0.91 a 0.00. In un mondo perfetto, HubSpot lo risolverebbe da solo. Ma non lo faranno e io non posso modificare il codice di HubSpot. Quindi è un hack, lo so, ma funziona.
Table of Contents!
Il CLS della chat HubSpot è una lamentela comune
Le persone se ne lamentano da anni. Thread della Community HubSpot qui, qui e qui, un thread di assistenza di Google Search Console e un articolo di Cronyx Digital. HubSpot non lo ha risolto.
Perché si verifica il CLS
Il widget della chat HubSpot viene renderizzato all'interno di un iframe con l'id hubspot-messages-iframe-container. Quando un visitatore tocca il launcher, il contenitore si anima passando dalle dimensioni del launcher (circa 60 per 60 pixel) a quelle dell'intero pannello (circa 380 per 600 pixel). Quando il visitatore lo chiude, il contenitore si anima all'indietro.
Ecco la trappola. La Layout Instability API lega hadRecentInput al documento in cui è stato attivato l'evento di input. Il clic sul launcher HubSpot viene attivato all'interno del documento iframe. Il layout shift atterra sul documento genitore, perché è lì che il contenitore iframe viene ridimensionato. Due documenti diversi. hadRecentInput del genitore rimane falso. Il salto viene contato.
In realtà ci sono tre momenti di shift da affrontare. Al caricamento, HubSpot monta il launcher in piccolo (~100x96), per poi ingrandirlo a ~242x245 uno o due secondi dopo per mostrare la bolla di benvenuto. Nessun input da parte dell'utente. Conta per intero. All'apertura, il clic si attiva all'interno dell'iframe, quindi il genitore non lo vede mai. Conta per intero. Alla chiusura, c'è lo stesso problema cross-frame, inoltre l'animazione di chiusura dura più di 500ms, quindi i frame successivi alla finestra di esclusione contano anche quando l'attribuzione è corretta.
L'ho misurato su diversi siti di clienti. Un singolo ciclo di apertura e chiusura può produrre un contributo CLS di 0.91. Questo da solo è sufficiente per spingere una pagina da un punteggio sufficiente alla categoria scarsa ("poor").

Il codice
Ecco lo script. Inseriscilo nel tuo sito. Viene eseguito una volta sola, nessun processo di build, nessun framework, nessuna dipendenza. Vanilla JS. Monta un pulsante trasparente sopra il launcher HubSpot, gestisce l'apertura e la chiusura e maschera l'ingrandimento in fase di caricamento.
(function () {
if (window.__cwvHsClsFix) return;
window.__cwvHsClsFix = true;
// Strategia:
// LOAD: maschera l'iframe-container con opacity:0 nel momento in cui appare,
// per poi rivelarlo una volta che HubSpot ha terminato l'espansione dal launcher
// alla bolla di benvenuto. L'algoritmo di layout-shift di Chrome ignora le modifiche
// al rect sugli elementi la cui opacity calcolata è 0 in entrambi i frame, quindi
// l'ingrandimento genera ZERO voci di shift.
//
// OPEN: un overlay invisibile della stessa origine cattura il tocco e chiama
// widget.open(). Lo shift si attiva all'interno della finestra di grazia dell'input di 500ms,
// quindi hadRecentInput=true e lo shift viene escluso dal CLS.
//
// CLOSE: impostiamo opacity:0 sul contenitore in modo SINCRONO (nel gestore del click),
// POI chiamiamo widget.close(). La stessa esclusione di opacity-0 maschera
// la lenta animazione di chiusura di HubSpot. Una volta che HubSpot ha finito (tornando alle
// dimensioni del launcher), ripristiniamo l'opacity in modo che il clic successivo funzioni.
//
// AUTO-OPEN: elimina il cookie hs-messages-is-open prima che HubSpot lo legga,
// in modo che una precedente sessione "aperta" non provochi mai uno shift senza input
// al caricamento della pagina.
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', 'Apri 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', 'Apri 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', 'Chiudi 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', 'Apri chat');
}
}
function reposition() {
var c = getContainer();
if (!c) return place(null, false);
place(c.getBoundingClientRect(), isOpen());
}
// CLOAK ON LOAD: HubSpot monta il launcher a ~100x96, rimane lì per
// ~1-2s, poi cresce a ~242x245 per mostrare la bolla di benvenuto. Il launcher
// sembra "stabile" durante quel lasso di tempo, quindi una soglia di stabilità breve
// lo rivelerebbe troppo presto. Abbiamo bisogno di una soglia più lunga di questo divario.
// 2000ms lo coprono con margine; limite massimo di 7s in caso di reti lente.
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() {
// Se la mascheratura è ancora attiva, rivelalo ora. Gli shift guidati dall'input
// sono già esclusi da hadRecentInput e l'utente ha bisogno di vedere la chat.
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;
// CRITICO: opacity:0 PRIMA di widget.close() in modo che l'API layout-shift
// ignori la modifica del rect dell'animazione di chiusura.
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);
})(); Il workaround
Il mio script fa tre cose, una per ogni momento di shift, più una componente difensiva per il percorso di apertura automatica.
Primo trucco: mascherare l'ingrandimento del launcher al caricamento. cloakOnLoad imposta opacity: 0 sul contenitore dell'iframe non appena viene montato, lo osserva con un ResizeObserver e lo rivela una volta che le dimensioni rimangono stabili per 2 secondi. Esiste un limite massimo di 7 secondi come rete di sicurezza per le reti lente. Chrome 89 ignora le modifiche del rect su qualsiasi elemento la cui opacità calcolata (opacity) è 0 sia nel frame corrente che in quello precedente, quindi l'ingrandimento dal launcher alla bolla di benvenuto avviene in modo invisibile. Zero voci di shift. Se il visitatore tocca prima che la maschera venga rimossa, showChat lo rivela immediatamente perché l'input stesso esclude lo shift.
Secondo trucco: intercettare il clic di apertura sul genitore. Monto un pulsante invisibile sopra il launcher HubSpot e lo tengo ancorato al rettangolo di delimitazione dell'iframe con un ResizeObserver, un MutationObserver e un polling requestAnimationFrame di 1,5 secondi. A volte HubSpot rimonta l'iframe, specialmente nelle single-page app, quindi gli observer si ricollegano quando ciò accade. Quando il visitatore tocca, il pulsante riceve l'evento sul documento genitore e chiama HubSpotConversations.widget.open(). Il genitore ora ha un input recente. Chrome contrassegna lo shift con hadRecentInput=true. Escluso. Quando la chat è aperta, il pulsante copre solo la X di chiusura, in modo da non bloccare i clic all'interno della chat.
Terzo trucco: nascondere l'animazione di chiusura dietro l'opacity. Stesso trucco dell'opacity a 0 del Primo trucco. Lo script imposta opacity: 0 in modo sincrono all'interno del gestore del clic, poi chiama widget.close(). L'ordine è importante: prima l'opacity a 0, poi la chiusura. Invertendoli, l'animazione di chiusura inizia prima che l'opacity raggiunga lo zero, e i primi frame dello shift vengono registrati.
L'eliminazione del cookie all'inizio è la parte difensiva. Il cookie hs-messages-is-open ricorda che il widget era aperto in una sessione precedente e lo riapre automaticamente al caricamento della pagina successiva, senza alcun input richiesto da parte dell'utente. Eliminando il cookie, questo percorso viene bloccato del tutto.
Il risultato
Sul sito del cliente in cui ho implementato questo codice per la prima volta, il p75 del CLS è sceso da 0.91 a 0.00 nel giro di due giorni di accumulo di dati sul campo (field data). Il widget si apre e si chiude ancora allo stesso modo visivamente. I visitatori non notano nulla. CrUX rileva i nuovi dati sul campo nel suo ciclo mensile.

FAQ sul CLS della Chat HubSpot
La soluzione stessa
Questo comprometterà il tracciamento di HubSpot o le funzionalità della chat?
No. Il widget in sé rimane invariato. Stiamo solo intercettando il clic che lo apre e il clic che lo chiude. La cronologia delle conversazioni, l'instradamento degli agenti, l'identificazione dei contatti, nulla di tutto ciò viene influenzato.
Funziona su mobile?
Sì. Il pulsante trasparente è posizionato con position: fixed e segue il rettangolo di delimitazione dell'iframe. Funziona allo stesso modo sia su mobile che su desktop.
Influisce sull'INP?
Il gestore del clic esegue pochissimo lavoro sincrono: una scrittura dell'opacity, una scrittura del cookie, una lettura sincrona del DOM per getBoundingClientRect. L'impatto sull'INP è trascurabile. Il widget HubSpot stesso rappresenta il rischio INP maggiore sulla maggior parte dei siti, ma questo è un problema separato.
Ottimizzazione e stabilità
Perché un timeout di 1200ms per ripristinare l'opacity?
L'animazione di chiusura di HubSpot dura diverse centinaia di millisecondi. 1200ms offrono un margine di sicurezza abbondante. Impostandolo in modo troppo breve, l'opacity si ripristina mentre l'iframe è ancora a metà dell'animazione; i frame rimanenti verrebbero conteggiati.
Cosa succede se HubSpot cambia l'ID dell'iframe?
In tal caso questo script si rompe. L'id hubspot-messages-iframe-container è rimasto stabile per anni, ma è un dettaglio di implementazione di terze parti. Se HubSpot rilascia una riprogettazione, il selettore necessiterà di un aggiornamento. Questo è il costo di costruire sopra il widget di qualcun altro.
Codice, non report.
Entro nel tuo team per 1 o 2 sprint. Setto il monitoring e lascio il team in grado di tenere le metriche verdi da solo.
Scrivimi
