Corrigir 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 despejar um CLS de 0.91 em uma página quando um visitante o abre ou fecha. O lançador vive dentro do iframe do HubSpot, então o toque do visitante é registrado no documento do iframe e nunca chega à página pai. O pai é onde o layout shift é registrado e, como o pai nunca viu um clique, o Chrome sinaliza a mudança como não iniciada pelo usuário. A animação lenta de abrir e fechar piora a situação, mas o limite do iframe é a causa raiz. Abaixo está a solução alternativa que uso em sites de clientes. É uma gambiarra (hack). Funciona. A correção real tem que vir do HubSpot.
Table of Contents!
O que 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 lançador, o container anima do tamanho do lançador (cerca de 60 por 60 pixels) para o tamanho do painel completo (cerca de 380 por 600 pixels). Quando o visitante o fecha, o container anima de volta.
A Layout Instability API marca cada entrada de mudança com uma flag hadRecentInput. A flag só é verdadeira se um evento de entrada discreto (clique, toque, pressionamento de tecla) foi disparado no mesmo documento da mudança nos 500 milissegundos anteriores. A mudança aqui atinge o documento pai, porque é lá que o elemento container do iframe redimensiona. O clique que o disparou atinge o documento do iframe. Dois documentos diferentes. O hadRecentInput do pai permanece falso. Todo o movimento de abertura ou fechamento é contado para o CLS.
A animação lenta é um problema secundário em cima disso. Mesmo se o clique passasse para o pai, a animação de fechamento do HubSpot dura várias centenas de milissegundos, então os quadros após a janela de exclusão de 500ms ainda contariam. Dois problemas empilhados: um clique entre frames que quebra a atribuição de entrada, e uma animação longa o suficiente para ultrapassar a janela de exclusão 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 de CLS. Isso é o suficiente para empurrar uma página de uma pontuação de aprovação para o balde de ruim por conta própria.
Por que o HubSpot simplesmente não corrige isso
Dois motivos.
O primeiro é estrutural. O lançador está dentro de um iframe, então o evento de clique vive no documento do iframe. A Layout Instability API vincula hadRecentInput ao documento onde a entrada foi disparada, não ao local visual do clique na tela. O HubSpot não pode fazer um clique in-iframe contar no pai. A única correção limpa do lado deles é renderizar o lançador como um elemento do documento pai, com o iframe do painel de chat permanecendo separado. Essa não é uma reescrita trivial de como o widget é montado.
O segundo é a própria animação, que é útil. Ela atrai o olho, sinaliza que o painel está abrindo e parece menos abrupta do que um ajuste de tamanho instantâneo (snap-to-size). Remover a animação tornaria a experiência pior para todos, não apenas para os sites que se importam com o CLS. E encurtá-la para menos de 500ms não ajudaria a menos que o clique cross-frame também seja resolvido.
Então você tem um script de terceiros causando CLS no seu domínio que você não pode corrigir por dentro do iframe e não pode corrigir diretamente de fora.
A solução alternativa
A correção tem duas partes, uma para cada problema.
O truque um resolve o clique cross-frame. Nós montamos um botão invisível sobre o lançador do HubSpot na página pai. Quando o visitante toca, nosso botão recebe o evento no documento pai, em seguida, chama HubSpotConversations.widget.open(). O pai agora tem uma entrada recente. O widget anima aberto, o container do iframe muda e o Chrome sinaliza as entradas de mudança do pai com hadRecentInput=true. A abertura é excluída do CLS.
O truque dois lida com a animação lenta de fechamento. A animação de fechamento dura mais de 500ms, então mesmo com o clique do lado do pai no lugar, os quadros após a janela de exclusão de entrada ainda contam. O Chrome 89 mudou o CLS para ignorar mudanças de retângulo (rect changes) em qualquer elemento (ou ancestral) cuja opacidade calculada é 0 tanto no quadro atual quanto no anterior. Então nós definimos opacity: 0 no container do iframe de forma síncrona dentro do handler de clique, em seguida, chamamos HubSpotConversations.widget.close(). A animação de fechamento do HubSpot roda de forma invisível. Assim que ela termina, nós restauramos a opacidade. O container já voltou ao tamanho do lançador a essa altura, então restaurar a opacidade é uma mudança de pintura (paint change), não uma mudança de layout. Zero entradas de mudança registradas.
Essa é a ideia toda. O código completo está abaixo.
O código
Coloque este script no seu site. Ele roda uma vez, monta um botão transparente sobre o lançador do HubSpot e lida ele mesmo com o abrir e fechar. Sem etapa de build, sem framework, sem dependência. Vanilla JS.
(function () {
if (window.__cwvHsClsFix) return;
window.__cwvHsClsFix = true;
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());
}
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() {
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;
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);
})(); O que cada parte faz
A chamada clearOpenCookie no topo destrói o cookie hs-messages-is-open do HubSpot antes que o HubSpot o leia. O HubSpot usa esse cookie para lembrar dentro de uma janela de 30 minutos se o widget estava aberto e reabri-lo automaticamente no próximo carregamento da página. Uma abertura automática no carregamento da página dispara sem nenhuma entrada recente, então conta integralmente para o CLS. Destruir o cookie interrompe esse caminho.
O botão tap é posicionado sobre onde o iframe do HubSpot estiver no momento. Quando o iframe está no tamanho do lançador (fechado), o botão cobre todo o lançador. Quando o iframe está no tamanho do painel (aberto), o botão cobre apenas o X de fechar no canto superior direito para que não bloqueie os cliques dentro do chat aberto.
O ResizeObserver e o MutationObserver reposicionam o botão quando o HubSpot redimensiona o iframe ou o remonta. O HubSpot remonta o iframe ocasionalmente, especialmente após a navegação em single page apps. Os listeners widgetOpened e widgetClosed na parte inferior são defensivos; eles disparam de forma inconsistente na prática, então o rAF polling e o ResizeObserver fazem o trabalho real.
A função showChat lida com o caso em que o widget do HubSpot ainda não foi carregado. Se HS.widget.status().loaded for falso, ela chama HS.widget.load({ widgetOpen: true }), o que carrega o widget em um estado já aberto. Se estiver carregado, ela chama HS.widget.open(). Ambos os caminhos rodam em resposta ao clique, então a janela de exclusão de entrada se aplica.
A função hideChat é o truque. A ordem das operações importa:
- Defina
opacity: 0no container de forma síncrona. - Em seguida, chame
HS.widget.close(). - Aguarde a animação de fechamento terminar (1200ms é seguro).
- Restaure a opacidade.
Se você inverter as etapas 1 e 2, a animação de fechamento começa antes que a opacidade vá para 0, e os primeiros quadros da mudança são registrados. Mudança síncrona de opacidade primeiro.
O resultado
No site do cliente onde eu implementei isso pela primeira vez, o CLS p75 caiu de 0.91 para 0.02 em dois dias de acúmulo de dados de campo. O widget ainda abre e fecha da mesma maneira visualmente. Os visitantes não notam nada. O CrUX capta os novos dados de campo em seu ciclo mensal.
Isto é uma gambiarra. Avise o HubSpot.
Quero ser claro sobre o que é isso: É uma gambiarra (hack), não é uma correção. É uma solução alternativa que corrige o layout shift com dois comportamentos específicos da API de layout shift:
- A janela de exclusão de entrada de 500ms para cliques discretos.
- A exclusão de opacidade 0 que chegou no Chrome 89.
Ambos podem mudar. A exclusão de opacidade 0 já foi debatida discretamente no WICG. Se o Chrome decidir começar a contar as mudanças em elementos de opacidade 0 (porque alguém abusa do truque para esconder anúncios ou outro conteúdo manipulador), isso para de funcionar da noite para o dia.
A correção certa tem que vir do HubSpot. O widget deles está em dezenas de milhares de sites. Cada um desses sites está pagando um imposto de CLS que o proprietário do site não introduziu e não pode corrigir na fonte. O HubSpot poderia:
- Renderizar o lançador de chat como um elemento do documento pai. O iframe do painel de chat pode ficar; apenas o lançador tem que viver no pai para que o clique seja registrado no documento pai.
- Encurtar as animações de abrir e fechar para menos de 500ms, ou fornecer uma opção de configuração para desativar a animação inteiramente. Combinado com a correção do lançador, isso tornaria a contribuição de CLS efetivamente zero. Alguns proprietários de sites ficariam felizes em aceitar um widget de ajuste de tamanho instantâneo (snap-to-size) ao invés de uma contribuição de 0.9 de CLS de qualquer maneira.
- Documentar o problema claramente nos developer docs para que os proprietários de sites saibam que ele existe antes de lançar um dashboard CrUX com o widget em todas as páginas.
Se você encontrar isso no seu site, implemente o script. Então, abra um ticket de suporte do HubSpot e coloque o link para esta página. Quanto mais proprietários de sites relatarem isso, mais provável será que seja priorizado. Eu mesmo enviei feedback através dos canais deles e, até agora, a única resposta foi apontar para os developer docs, que não mencionam CLS em nenhum lugar.
FAQ de CLS do HubSpot Chat
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 é alterado. Nós estamos apenas interceptando o clique que o abre e o clique que o fecha. Histórico de conversa, roteamento de agente, identificação de contato, nada disso é afetado.
Isso funciona no mobile?
Sim. O botão transparente é posicionado com position: fixed e segue o retângulo delimitador (bounding rectangle) do iframe. Ele funciona da mesma forma no mobile e no desktop.
Isso afeta o INP?
O handler de clique 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.
Navegadores e medição de CLS
E quanto ao Safari e Firefox?
Safari e Firefox não implementam a Layout Instability API, então o CLS só é medido em navegadores Chromium. O script ainda funciona no Safari e Firefox em termos de abrir e fechar o widget. Os visitantes deles não estão contribuindo para os seus dados de CLS do CrUX de qualquer maneira.
Ajuste 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 de segurança. Se você definir muito curto, a opacidade é restaurada enquanto o iframe ainda está no meio da animação; os quadros restantes são contados.
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 redesign, o seletor precisará de atualização. 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
