Corrigir a Mudança de Layout 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 mudança de layout do navegador

Como corrigir a mudança de layout causada pelo chat do HubSpot
O widget de chat do HubSpot pode causar um enorme CLS de 0,91 em uma página. Este é um problema bem conhecido. Então, eu o corrigi para você. Você pode usar meu script pronto abaixo, que reduz o p75 do CLS de 0,91 para 0,00. Em um mundo perfeito, o próprio HubSpot corrigiria 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 vêm reclamando há anos. Tópicos da Comunidade HubSpot aqui, aqui e aqui, um tópico de ajuda do Google Search Console e um artigo da Cronyx Digital. O HubSpot não corrigiu isso.
Por que o CLS está acontecendo
O widget de chat do HubSpot é renderizado dentro de um iframe com o id hubspot-messages-iframe-container. Quando um visitante toca no ativador, o contêiner é animado do tamanho do ativador (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 API de Layout Instability vincula hadRecentInput ao documento onde o evento de entrada (input) foi disparado. O clique no ativador do HubSpot é disparado dentro do documento do iframe. A mudança de layout ocorre no documento pai, porque é lá que o contêiner do iframe é redimensionado. Dois documentos diferentes. O hadRecentInput do pai permanece falso. A mudança é contabilizada.
Na verdade, você tem três momentos de mudança de layout para lidar. No carregamento, o HubSpot monta o ativador pequeno (~100x96), depois o aumenta para ~242x245 um ou dois segundos depois para mostrar o balão de boas-vindas. Nenhuma entrada do usuário. Conta integralmente. Ao abrir, o clique é disparado dentro do iframe, então o pai nunca o vê. Conta integralmente. Ao fechar, o mesmo problema entre frames, além de que a animação de fechamento dura mais de 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 para o CLS. Isso por si só é suficiente para empurrar uma página de uma pontuação de aprovação para o grupo dos ruins.

O código
Aqui está o script. Adicione-o no seu site. Executa uma vez, sem etapa de build, sem framework, sem dependência. Vanilla JavaScript. Ele monta um botão transparente sobre o ativador do HubSpot, lida com o abrir e fechar, e oculta o crescimento no momento do carregamento.
(function () {
if (window.__cwvHsClsFix) return;
window.__cwvHsClsFix = true;
// Estratégia:
// LOAD: ocultar o iframe-container com opacity:0 no momento em que ele aparece,
// em seguida revelar assim que o HubSpot terminar sua expansão de ativador para
// balão de boas-vindas. O algoritmo de layout-shift do Chrome ignora mudanças de rect em
// elementos cuja opacidade computada é 0 em ambos os frames, então o crescimento
// gera ZERO entradas de shift.
//
// OPEN: uma sobreposição (overlay) invisível de mesma origem captura o toque e chama
// widget.open(). O shift dispara dentro da janela de tolerância de entrada de 500ms,
// então hadRecentInput=true e o shift é excluído do CLS.
//
// CLOSE: nós definimos opacity:0 no contêiner SINCRONAMENTE (no manipulador de clique),
// DEPOIS chamamos widget.close(). A mesma exclusão de opacity-0 mascara
// a animação lenta de fechamento do HubSpot. Quando o HubSpot termina (de volta ao
// tamanho do ativador), restauramos a opacidade para que o próximo clique funcione.
//
// AUTO-OPEN: eliminar o cookie hs-messages-is-open antes que o HubSpot o leia,
// para que uma sessão anterior "aberta" nunca dispare um shift sem entrada no
// carregamento da página.
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: O HubSpot monta o ativador em ~100x96, fica lá por
// ~1-2s, depois cresce para ~242x245 para mostrar o balão de boas-vindas. O ativador
// parece "estável" durante essa lacuna, então um limite curto de estabilidade revela
// muito cedo. Precisamos de um limite mais longo que essa lacuna. 2000ms cobre isso
// com margem; limite rígido de 7s em caso de redes lentas.
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 o cloak ainda estiver ativo, revelar agora. Shifts causados por entrada
// já são excluídos por hadRecentInput, e o usuário precisa ver o 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;
// CRÍTICO: opacity:0 ANTES de widget.close() para que a API de layout-shift
// ignore a mudança de rect da animação de fechamento.
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 de mudança de layout, mais uma peça defensiva para o caminho de abertura automática (auto-open).
Truque um: ocultar o crescimento do ativador no carregamento. cloakOnLoad define opacity: 0 no contêiner do iframe no momento em que ele é montado, observa-o com um ResizeObserver e o revela assim que as dimensões permanecem estáveis por 2 segundos. Há um limite rígido de 7 segundos como 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 ativador para o balão de boas-vindas acontece de forma invisível. Zero entradas de mudança de layout. Se o visitante tocar antes de a ocultação ser removida, showChat revela imediatamente porque a própria entrada exclui a mudança.
Truque dois: capturar o clique de abertura no pai. Eu monto um botão invisível sobre o ativador do HubSpot e o mantenho fixado no retângulo delimitador do iframe com um ResizeObserver, um MutationObserver e uma pesquisa (poll) de requestAnimationFrame de 1,5 segundos. O HubSpot remonta o iframe ocasionalmente, especialmente em aplicativos de página única (single-page apps), então os observadores 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 uma entrada recente. O Chrome sinaliza a mudança de layout com hadRecentInput=true. Excluído. Quando o chat está aberto, o botão cobre apenas o X de fechar, para não bloquear 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 de forma síncrona dentro do manipulador de clique e, em seguida, chama widget.close(). A ordem é importante: opacidade 0 primeiro, depois fechar. Inverta-os e a animação de fechamento começa antes que a opacidade chegue a zero, e os primeiros frames da mudança são registrados.
A eliminação do cookie no topo é a peça 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 carregamento da página, sem necessidade de entrada do usuário. Eliminar o cookie interrompe esse caminho inteiramente.
O resultado
No site do cliente onde implementei isso pela primeira vez, o p75 do CLS caiu de 0,91 para 0,00 em dois dias de acúmulo de dados de campo. O widget ainda abre e fecha visualmente da mesma forma. Os visitantes não percebem nada. O CrUX capta os novos dados de campo em seu ciclo mensal.

FAQ sobre CLS do Chat do HubSpot
A correção em si
Isso vai quebrar o rastreamento ou os recursos de chat do HubSpot?
Não. O widget em si não é modificado. Estamos apenas interceptando o clique que o abre e o clique que o fecha. Histórico de conversas, roteamento de agentes, identificação de contatos, nada disso é afetado.
Isso funciona em dispositivos móveis?
Sim. O botão transparente é posicionado com position: fixed e segue o retângulo delimitador do iframe. Ele funciona da mesma forma em dispositivos móveis e computadores desktop.
Isso afeta o INP?
O manipulador de clique faz muito pouco trabalho síncrono: uma gravação de opacidade, uma gravação de cookie, uma leitura síncrona do DOM para getBoundingClientRect. O impacto no INP é insignificante. O próprio widget do HubSpot é o maior risco de INP na maioria dos sites, e isso é um problema separado.
Ajustes e estabilidade
Por que um timeout de 1200ms para restaurar a opacidade?
A animação de fechamento do HubSpot é executada por várias centenas de milissegundos. 1200ms dá bastante margem. Se definido muito curto, a opacidade é restaurada enquanto o iframe ainda está no meio da animação; os frames restantes são contabilizados.
E se o HubSpot mudar 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 redesenho, o seletor precisará ser atualizado. Este é o custo de construir em cima do widget de outra pessoa.
Descobre o que é mesmo lento.
Mapeio o critical rendering path com dados RUM. Recebes uma lista de fixes por prioridade, não um relatório do Lighthouse.
Quero a auditoria
