Solucionar el Layout Shift del widget de chat de HubSpot
Una solución alternativa que reduce un CLS de 0.91 a 0.02 aprovechando dos exclusiones de layout-shift del navegador

Cómo solucionar el Layout Shift causado por el chat de HubSpot
El widget de chat de HubSpot puede volcar un CLS de 0.91 en una página cuando un visitante lo abre o lo cierra. El widget se anima desde un pequeño lanzador hasta un panel de 380 por 600, la animación dura más que la ventana de gracia de entrada de 500ms, y HubSpot no hace nada para marcar el cambio como iniciado por el usuario. A continuación se muestra la solución alternativa que uso en los sitios de clientes. Es un hack. Funciona. La solución real tiene que venir de HubSpot.
Table of Contents!
Qué está pasando
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) al tamaño del panel completo (alrededor de 380 por 600 píxeles). Cuando el visitante lo cierra, el contenedor se anima de vuelta.
Ambas transiciones tardan varios cientos de milisegundos. Eso importa por cómo Chrome calcula el CLS.
El navegador excluye un Layout Shift de tu puntuación CLS si ocurre dentro de los 500 milisegundos de una entrada de usuario discreta como un clic, un toque o una pulsación de tecla. A esto se le llama la ventana de exclusión de entrada. Todo lo que cambie de diseño después de ese límite de 500ms cuenta. La animación de HubSpot termina mucho después del límite, por lo que todo el movimiento de apertura y cierre se registra como un Layout Shift inesperado para el usuario.
He medido esto en múltiples sitios de clientes. Un solo ciclo de apertura y cierre puede producir una contribución al CLS de 0.91. Eso es suficiente para empujar a una página desde una puntuación de aprobado hacia la categoría de pobre por sí sola.
Por qué HubSpot no simplemente arregla esto
Dos razones.
La primera es que la animación del widget es útil. Atrae la mirada, señala que el panel se está abriendo y se siente menos brusco que un cambio de tamaño instantáneo. Eliminar la animación empeoraría la experiencia para todos, no solo para los sitios que se preocupan por el CLS.
La segunda es más difícil. La animación se ejecuta dentro de un iframe. Incluso si HubSpot la acortara a menos de 500ms, todavía necesitarías que el evento de clic en la página principal tuviera una marca de tiempo para que el navegador pueda aplicar la ventana de exclusión de entrada. La página principal es tu sitio. HubSpot no tiene una forma limpia de hacer que eso suceda desde dentro de su iframe.
Así que tienes un script de terceros causando CLS en tu dominio que no puedes solucionar desde dentro del iframe y que no puedes solucionar directamente desde fuera.
La solución alternativa
El truco se basa en dos cosas que el navegador ya hace. Vamos a usar ambas a la vez.
Truco uno: atrapar el clic de apertura en la página principal. En lugar de dejar que el visitante toque el lanzador de HubSpot dentro del iframe, ponemos un botón invisible sobre el lanzador en la página principal. Cuando el visitante toca, nuestro botón dispara HubSpotConversations.widget.open(). El evento de clic vive en el documento principal, por lo que el navegador inicia la ventana de exclusión de entrada de 500ms. El widget se anima al abrirse, el iframe cambia de posición y Chrome marca el cambio con hadRecentInput=true. Excluido del CLS.
Truco dos: ocultar la animación de cierre detrás de la opacidad. La animación de cierre tarda más de 500ms, por lo que el truco de exclusión de entrada no funcionará. Pero Chrome 89 cambió el CLS para ignorar los cambios de rect en cualquier elemento (o ancestro) cuya opacidad computada sea 0 tanto en el frame actual como en el anterior. Así que establecemos opacity: 0 en el contenedor del iframe de forma síncrona dentro del manejador del clic, y luego llamamos a HubSpotConversations.widget.close(). La animación de cierre de HubSpot se ejecuta de forma invisible. Una vez que termina, restauramos la opacidad. Para entonces, el contenedor ha vuelto al tamaño del lanzador, por lo que restaurar la opacidad es un cambio de pintura, no un cambio de diseño. Cero entradas de shift registradas.
Esa es toda la idea. El código completo está a continuación.
El código
Deja caer este script en tu sitio. Se ejecuta una vez, monta un botón transparente sobre el lanzador de HubSpot y maneja la apertura y el cierre por sí mismo. Sin paso de compilación, sin framework, sin dependencias. Vanilla JS.
Qué hace cada parte
La llamada a clearOpenCookie en la parte superior mata la cookie hs-messages-is-open de HubSpot antes de que HubSpot la lea. HubSpot usa esa cookie para recordar dentro de una ventana de 30 minutos si el widget estaba abierto y volver a abrirlo automáticamente en la siguiente carga de página. Una apertura automática al cargar la página se dispara sin entrada reciente, por lo que cuenta para el CLS en su totalidad. Matar la cookie detiene ese camino.
El botón tap se posiciona sobre donde sea que esté el iframe de HubSpot actualmente. Cuando el iframe está en el tamaño del lanzador (cerrado), el botón cubre todo el lanzador. Cuando el iframe está en el tamaño del panel (abierto), el botón solo cubre la X de cierre en la esquina superior derecha para que no bloquee los clics dentro del chat abierto.
El ResizeObserver y el MutationObserver reposicionan el botón cuando HubSpot redimensiona el iframe o lo vuelve a montar. HubSpot vuelve a montar el iframe ocasionalmente, especialmente después de la navegación en single page apps. Los listeners widgetOpened y widgetClosed en la parte inferior son defensivos; se disparan de manera inconsistente en la práctica, por lo que el polling con rAF y el ResizeObserver hacen el trabajo real.
La función showChat maneja el caso donde el widget de HubSpot aún no ha cargado. Si HS.widget.status().loaded es falso, llama a HS.widget.load({ widgetOpen: true }) lo cual carga el widget en un estado ya abierto. Si está cargado, llama a HS.widget.open(). Ambas rutas se ejecutan en respuesta al clic, por lo que se aplica la ventana de exclusión de entrada.
La función hideChat es el truco. El orden de las operaciones importa:
- Establecer
opacity: 0en el contenedor de forma síncrona. - Luego llamar a
HS.widget.close(). - Esperar a que termine la animación de cierre (1200ms es seguro).
- Restaurar la opacidad.
Si inviertes los pasos 1 y 2, la animación de cierre comienza antes de que la opacidad llegue a 0, y los primeros frames del shift quedan registrados. Cambio síncrono de opacidad primero.
El resultado
En el sitio del cliente donde desplegué esto por primera vez, el p75 del CLS cayó de 0.91 a 0.02 dentro de los dos días de acumulación de datos de campo. El widget todavía se abre y cierra de la misma manera visualmente. Los visitantes no notan nada. CrUX recoge los nuevos datos de campo en su ciclo mensual.
Esto es un hack. Díselo a HubSpot.
Quiero ser honesto sobre lo que es esto. No es una solución. Es una solución alternativa que explota dos comportamientos específicos de la API de Layout Shift:
- La ventana de exclusión de entrada de 500ms para clics discretos.
- La exclusión de opacidad 0 que aterrizó en Chrome 89.
Ambas pueden cambiar. La exclusión de opacidad 0 ya se ha debatido silenciosamente en el WICG. Si Chrome decide comenzar a contar los shifts en elementos con opacidad 0 (porque alguien abusa del truco para ocultar anuncios u otro contenido manipulador), esto deja de funcionar de la noche a la mañana.
La solución real tiene que venir de HubSpot. Su widget está en decenas de miles de sitios. Cada uno de esos sitios está pagando un impuesto de CLS que el propietario del sitio no introdujo y no puede solucionar en la fuente. HubSpot podría:
- Acortar las animaciones de apertura y cierre a menos de 500ms para que la ventana de exclusión de entrada existente las cubra.
- Proporcionar una opción de configuración para desactivar la animación de apertura y cierre por completo. Algunos propietarios de sitios tomarían con gusto un widget con cambio de tamaño instantáneo en lugar de una contribución de 0.9 al CLS.
- Documentar este problema claramente en su documentación para desarrolladores para que los propietarios de los sitios sepan que existe antes de lanzar un panel de control de CrUX con su widget en cada página.
Si te encuentras con esto en tu sitio, despliega el script. Luego abre un ticket de soporte de HubSpot y enlaza a esta página. Cuantos más propietarios de sitios lo reporten, más probable es que se priorice. Yo mismo he enviado comentarios a través de sus canales y hasta ahora la única respuesta ha sido señalar la documentación para desarrolladores, que no menciona el CLS en absoluto.
FAQ
¿Esto romperá el seguimiento de HubSpot o las funciones de chat?
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.
¿Esto funciona 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óvil que en escritorio.
¿Qué pasa con Safari y Firefox?
Safari y Firefox no implementan la API de inestabilidad del diseño (Layout Instability API), por lo que el CLS solo se mide en navegadores Chromium. El script sigue funcionando en Safari y Firefox en términos de abrir y cerrar el widget. Sus visitantes no están contribuyendo a tus datos de CLS de CrUX de todos modos.
¿Por qué un tiempo de espera de 1200ms para restaurar la opacidad?
La animación de cierre de HubSpot se ejecuta durante varios cientos de milisegundos. 1200ms da un margen de sobra. Si lo configuras demasiado corto y la opacidad se restaura mientras el iframe todavía está a mitad de la animación; los frames restantes se cuentan.
¿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 terceros. Si HubSpot lanza un rediseño, el selector necesita ser actualizado. Este es el costo de construir encima del widget de otra persona.
¿Esto afecta al INP?
El manejador del clic hace muy poco trabajo síncrono: una escritura de opacidad, una escritura de cookie, una lectura síncrona del DOM para getBoundingClientRect. El impacto en el INP es insignificante. El widget de HubSpot en sí es el mayor riesgo para el INP en la mayoría de los sitios, y ese es un problema distinto.
Entérate de qué va lento de verdad.
Trazo tu critical rendering path con datos reales. Te paso una lista de fixes priorizada. No otro informe de Lighthouse.
Quiero la auditoría
