Corrija o Layout Shift do Widget de Chat do HubSpot
Uma solução alternativa que reduz um CLS de 0,91 para 0,02 explorando duas exclusões de layout-shift do navegador

Como corrigir o layout shift causado pelo chat do HubSpot
O widget de chat do HubSpot pode causar um CLS massivo de 0,91 em uma página. Este é um problema bem conhecido. Então eu o corrigi para você. Você pode usar o meu script drop-in abaixo que leva o p75 do CLS de 0,91 para 0,00. Em um mundo perfeito, o próprio HubSpot resolveria isso. Mas eles não vão, e eu não posso mudar o código do HubSpot. Então é uma gambiarra, eu sei, mas funciona.
Table of Contents!
O CLS do Chat do HubSpot é uma reclamação comum
As pessoas têm reclamado por anos. Tópicos da Comunidade do HubSpot aqui, aqui e aqui, uma thread de ajuda do Google Search Console e um artigo da Cronyx Digital. O HubSpot não corrigiu o problema.
Por que o CLS está acontecendo
O widget de chat do HubSpot renderiza dentro de um iframe com o id hubspot-messages-iframe-container. Quando um visitante toca no launcher, o contêiner é animado do tamanho do launcher (cerca de 60 por 60 pixels) para o tamanho do painel completo (cerca de 380 por 600 pixels). Quando o visitante o fecha, o contêiner é animado de volta.
Aqui está a armadilha. A Layout Instability API vincula hadRecentInput ao documento onde o evento de input foi disparado. O clique no launcher do HubSpot é disparado dentro do documento do iframe. O layout shift ocorre no documento pai, porque é lá que o contêiner do iframe redimensiona. Dois documentos diferentes. O hadRecentInput do pai permanece falso. O shift conta.
Na verdade, você tem três momentos de shift para lidar. No load, o HubSpot monta o launcher pequeno (~100x96), depois o aumenta para ~242x245 um ou dois segundos depois para mostrar o welcome bubble. Sem nenhum user input. Conta integralmente. Ao abrir, o clique dispara dentro do iframe, então o pai nunca o vê. Conta integralmente. Ao fechar, o mesmo problema cross-frame, além de a animação de fechamento durar mais do que 500ms, então os frames após a janela de exclusão contam mesmo quando a atribuição está correta.
Eu medi isso em vários sites de clientes. Um único ciclo de abrir e fechar pode produzir uma contribuição de 0,91 no CLS. Isso por si só é suficiente para empurrar uma página de uma pontuação de aprovação para o balde de ruim.

O código
Aqui está o script. Coloque no seu site. Roda uma vez, sem build step, sem framework, sem dependência. Vanilla JS. Ele monta um botão transparente sobre o launcher do HubSpot, lida com a abertura e o fechamento, e oculta o crescimento no momento do load.
(function () {
if (window.__cwvHsClsFix) return;
window.__cwvHsClsFix = true;
// Strategy:
// LOAD: cloak the iframe-container with opacity:0 the moment it appears,
// then reveal once HubSpot has finished its launcher to welcome-bubble
// expansion. Chrome's layout-shift algorithm skips rect changes on
// elements whose computed opacity is 0 in both frames, so the growth
// generates ZERO shift entries.
//
// OPEN: an invisible same-origin overlay catches the tap and calls
// widget.open(). The shift fires within the 500ms input grace window,
// so hadRecentInput=true and the shift is excluded from CLS.
//
// CLOSE: we set opacity:0 on the container SYNCHRONOUSLY (in the click
// handler), THEN call widget.close(). Same opacity-0 exclusion masks
// HubSpot's slow close animation. Once HubSpot finishes (back at
// launcher size), we restore opacity so the next click works.
//
// AUTO-OPEN: kill the hs-messages-is-open cookie before HubSpot reads
// it, so a previous "open" session never fires a no-input shift on
// page load.
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());
}
// CLOAK ON LOAD: HubSpot mounts the launcher at ~100x96, sits there for
// ~1-2s, then grows to ~242x245 to show the welcome bubble. The launcher
// looks "stable" during that gap, so a short stability threshold reveals
// too early. We need a threshold longer than that gap. 2000ms covers it
// with margin; hard cap 7s in case of slow networks.
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() {
// If the cloak is still active, reveal now. Input-driven shifts are
// already excluded by hadRecentInput, and the user needs to see the 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;
// CRITICAL: opacity:0 BEFORE widget.close() so the layout-shift API
// skips the close animation's rect change.
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);
})(); A solução alternativa
Meu script faz três coisas, uma para cada momento do shift, mais uma parte defensiva para o caminho de auto-open.
Truque um: ocultar o crescimento do launcher no load. cloakOnLoad define opacity: 0 no contêiner do iframe no momento em que ele é montado, o observa com um ResizeObserver e o revela assim que as dimensões permanecem estáveis por 2 segundos. Existe um limite rígido de 7 segundos como uma rede de segurança para redes lentas. O Chrome 89 ignora mudanças de rect em qualquer elemento cuja opacidade computada seja 0 tanto no frame atual quanto no anterior, então o crescimento do launcher para o welcome-bubble acontece de forma invisível. Zero shift entries. Se o visitante tocar antes de a ocultação ser removida, showChat revela imediatamente porque o próprio input exclui o shift.
Truque dois: capturar o clique de abertura no pai. Eu monto um botão invisível sobre o launcher do HubSpot e o mantenho fixo no retângulo delimitador do iframe com um ResizeObserver, um MutationObserver e um poll de requestAnimationFrame de 1,5 segundo. O HubSpot remonta o iframe ocasionalmente, especialmente em single-page apps, então os observers se anexam novamente quando isso acontece. Quando o visitante toca, o botão recebe o evento no documento pai e chama HubSpotConversations.widget.open(). O pai agora tem um input recente. O Chrome marca o shift com hadRecentInput=true. Excluído. Quando o chat está aberto, o botão cobre apenas o X de fechar para que não bloqueie os cliques dentro do chat.
Truque três: esconder a animação de fechamento atrás da opacidade. O mesmo truque de opacidade 0 do Truque um. O script define opacity: 0 sincronicamente dentro do click handler, e então chama widget.close(). A ordem importa: opacidade 0 primeiro, depois fechar. Inverta isso e a animação de fechamento começará antes que a opacidade atinja zero, e os primeiros frames do shift serão gravados.
A eliminação do cookie no topo é a parte defensiva. O cookie hs-messages-is-open lembra que o widget estava aberto em uma sessão anterior e o reabre automaticamente no próximo load da página, sem a necessidade de user input. Matar o cookie interrompe totalmente esse caminho.
O resultado
No site do cliente onde eu implantei isso pela primeira vez, o CLS p75 caiu de 0,91 para 0,00 dentro de dois dias de acúmulo de field data. O widget ainda abre e fecha da mesma forma visualmente. Os visitantes não notam nada. O CrUX capta o novo field data em seu ciclo mensal.

FAQ do CLS do Chat do HubSpot
A correção em si
Isso vai quebrar o rastreamento do HubSpot ou as funcionalidades de chat?
Não. O widget em si não sofreu alterações. Nós estamos apenas interceptando o clique que o abre e o clique que o fecha. O histórico de conversas, o roteamento de agentes, a identificação do contato, nada disso é afetado.
Isso funciona em mobile?
Sim. O botão transparente é posicionado com position: fixed e acompanha o retângulo delimitador do iframe. Funciona da mesma maneira no mobile e no desktop.
Isso afeta o INP?
O click handler faz muito pouco trabalho síncrono: uma escrita de opacidade, uma escrita de cookie, uma leitura síncrona do DOM para getBoundingClientRect. O impacto no INP é insignificante. O widget do HubSpot em si é o maior risco de INP na maioria dos sites, e esse é um problema separado.
Ajustes e estabilidade
Por que um timeout de 1200ms para restaurar a opacidade?
A animação de fechamento do HubSpot dura várias centenas de milissegundos. 1200ms dá bastante margem. Defina como muito curto e a opacidade será restaurada enquanto o iframe ainda estiver no meio da animação; os frames restantes serão contados.
E se o HubSpot alterar o ID do iframe?
Então este script quebra. O id hubspot-messages-iframe-container tem sido estável por anos, mas é um detalhe de implementação de terceiros. Se o HubSpot lançar um redesign, o seletor precisará ser atualizado. Este é o custo de se construir em cima do widget de outra pessoa.
Escrevo código, não relatório.
Entro no seu time por 1 ou 2 sprints. Monto o monitoramento e garanto que o time mantém as métricas no verde depois que eu saio.
Entra em contato
