Solucionar el cambio de diseño del widget de chat de HubSpot
Una solución alternativa que reduce un CLS de 0.91 a 0.02 aprovechando dos exclusiones de cambio de diseño del navegador

Cómo solucionar el cambio de diseño causado por el chat de HubSpot
El widget de chat de HubSpot puede causar un enorme CLS de 0.91 en una página. Este es un problema muy conocido. Así que lo he solucionado para ti. Puedes usar mi script a continuación que lleva el p75 del CLS de 0.91 a 0.00. En un mundo perfecto, HubSpot lo solucionaría por sí mismo. Pero no lo harán, y yo no puedo cambiar el código de HubSpot. Así que es un hack, lo sé, pero funciona.
Table of Contents!
El CLS del chat de HubSpot es una queja común
La gente se ha estado quejando durante años. Hilos de la comunidad de HubSpot aquí, aquí y aquí, un hilo de ayuda de Google Search Console, y un artículo de Cronyx Digital. HubSpot no lo ha solucionado.
Por qué ocurre el CLS
El widget de chat de HubSpot se renderiza dentro de un iframe con el id hubspot-messages-iframe-container. Cuando un visitante toca el lanzador, el contenedor se anima desde el tamaño del lanzador (alrededor de 60 por 60 píxeles) hasta el tamaño del panel completo (alrededor de 380 por 600 píxeles). Cuando el visitante lo cierra, el contenedor se anima de vuelta.
Aquí está la trampa. La Layout Instability API vincula hadRecentInput al documento donde se disparó el evento de entrada. El clic en el lanzador de HubSpot se dispara dentro del documento del iframe. El cambio de diseño aterriza en el documento padre, porque ahí es donde el contenedor del iframe cambia de tamaño. Dos documentos diferentes. El hadRecentInput del padre permanece en falso. El cambio cuenta.
En realidad, tienes tres momentos de cambio con los que lidiar. Al cargar, HubSpot monta el lanzador pequeño (~100x96), luego lo hace crecer a ~242x245 uno o dos segundos después para mostrar la burbuja de bienvenida. Ninguna entrada del usuario en absoluto. Cuenta en su totalidad. Al abrir, el clic se dispara dentro del iframe, por lo que el padre nunca lo ve. Cuenta en su totalidad. Al cerrar, el mismo problema entre marcos, además de que la animación de cierre dura más de 500ms, por lo que los fotogramas posteriores a la ventana de exclusión cuentan incluso cuando la atribución es correcta.
He medido esto en los sitios de varios clientes. Un solo ciclo de apertura y cierre puede producir una contribución de 0.91 al CLS. Eso por sí solo es suficiente para empujar a una página de una puntuación aprobatoria a la categoría de pobre.

El código
Aquí está el script. Ponlo en tu sitio. Se ejecuta una vez, sin paso de compilación, sin framework, sin dependencias. Vanilla JS. Monta un botón transparente sobre el lanzador de HubSpot, maneja la apertura y el cierre, y oculta el crecimiento en el tiempo de carga.
(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);
})();
La solución alternativa
Mi script hace tres cosas, una para cada momento de cambio, más una pieza defensiva para la ruta de auto-apertura.
Primer truco: ocultar el crecimiento del lanzador al cargar. cloakOnLoad establece opacity: 0 en el contenedor del iframe en el momento en que se monta, lo vigila con un ResizeObserver, y lo revela una vez que las dimensiones se mantienen estables durante 2 segundos. Hay un límite estricto de 7 segundos como red de seguridad para redes lentas. Chrome 89 ignora los cambios de rectángulo en cualquier elemento cuya opacidad calculada sea 0 tanto en el fotograma actual como en el anterior, por lo que el crecimiento del lanzador a la burbuja de bienvenida ocurre de forma invisible. Cero entradas de cambio. Si el visitante toca antes de que se levante la ocultación, showChat lo revela inmediatamente porque la entrada en sí excluye el cambio.
Segundo truco: atrapar el clic de apertura en el padre. Monto un botón invisible sobre el lanzador de HubSpot y lo mantengo fijado al rectángulo delimitador del iframe con un ResizeObserver, un MutationObserver y una llamada recurrente de requestAnimationFrame de 1.5 segundos. HubSpot vuelve a montar el iframe ocasionalmente, especialmente en aplicaciones de una sola página, por lo que los observadores se vuelven a adjuntar cuando eso sucede. Cuando el visitante toca, el botón recibe el evento en el documento padre y llama a HubSpotConversations.widget.open(). El padre ahora tiene una entrada reciente. Chrome marca el cambio con hadRecentInput=true. Excluido. Cuando el chat está abierto, el botón cubre solo la X de cierre para que no bloquee los clics dentro del chat.
Tercer truco: ocultar la animación de cierre detrás de la opacidad. El mismo truco de opacidad 0 que el Primer truco. El script establece opacity: 0 de forma síncrona dentro del manejador de clics, luego llama a widget.close(). El orden importa: opacidad 0 primero, luego cerrar. Inviértelos y la animación de cierre comienza antes de que la opacidad llegue a cero, y los primeros fotogramas del cambio quedan registrados.
La eliminación de la cookie en la parte superior es la pieza defensiva. La cookie hs-messages-is-open recuerda que el widget estaba abierto en una sesión anterior y lo reabre automáticamente en la siguiente carga de página, sin necesidad de entrada del usuario. Matar la cookie detiene esa ruta por completo.
El resultado
En el sitio del cliente donde implementé esto por primera vez, el p75 del CLS cayó de 0.91 a 0.00 en dos días de acumulación de datos de campo. El widget aún se abre y se cierra de la misma manera visualmente. Los visitantes no notan nada. CrUX recoge los nuevos datos de campo en su ciclo mensual.

Preguntas frecuentes sobre el CLS del chat de HubSpot
La solución en sí
¿Esto romperá el seguimiento o las características del chat de HubSpot?
No. El widget en sí no cambia. Solo estamos interceptando el clic que lo abre y el clic que lo cierra. El historial de conversaciones, el enrutamiento de agentes, la identificación de contactos, nada de eso se ve afectado.
¿Funciona esto en móviles?
Sí. El botón transparente se posiciona con position: fixed y sigue el rectángulo delimitador del iframe. Funciona igual en móviles que en escritorio.
¿Afecta esto al INP?
El manejador de clics hace muy poco trabajo síncrono: una escritura de opacidad, una escritura de cookie, una lectura de DOM síncrona para getBoundingClientRect. El impacto en el INP es insignificante. El propio widget de HubSpot es el mayor riesgo para el INP en la mayoría de los sitios, y ese es un problema separado.
Ajuste y estabilidad
¿Por qué un tiempo de espera de 1200ms para restaurar la opacidad?
La animación de cierre de HubSpot dura varios cientos de milisegundos. 1200ms da mucho margen. Si lo configuras demasiado corto, la opacidad se restaura mientras el iframe todavía está en plena animación; los fotogramas restantes se contabilizan.
¿Qué pasa si HubSpot cambia el ID del iframe?
Entonces este script se rompe. El id hubspot-messages-iframe-container ha sido estable durante años, pero es un detalle de implementación de un tercero. Si HubSpot lanza un rediseño, el selector necesita actualización. Este es el costo de construir sobre el widget de otra persona.
Tiempo real. No medias de 28 días.
CoreDash segmenta cada métrica por ruta, dispositivo, browser y tipo de conexión.
Echa un vistazo a CoreDash
