Corriger le Layout Shift du widget de chat HubSpot
Une solution de contournement qui réduit un CLS de 0.91 à 0.02 en exploitant deux exclusions de layout-shift du navigateur

Comment corriger le layout shift causé par le chat HubSpot
Le widget de chat HubSpot peut causer un CLS massif de 0.91 sur une page. C'est un problème bien connu. Je l'ai donc corrigé pour vous. Vous pouvez utiliser mon script prêt à l'emploi ci-dessous qui fait passer le CLS p75 de 0.91 à 0.00. Dans un monde idéal, HubSpot le corrigerait lui-même. Mais ils ne le feront pas, et je ne peux pas modifier le code de HubSpot. C'est donc un hack, je sais, mais ça fonctionne.
Table of Contents!
Le CLS du chat HubSpot est une plainte fréquente
Les gens s'en plaignent depuis des années. Les fils de discussion de la communauté HubSpot ici, ici et ici, un fil d'aide Google Search Console, et un article de Cronyx Digital. HubSpot ne l'a pas corrigé.
Pourquoi le CLS se produit-il
Le widget de chat HubSpot s'affiche dans une iframe avec l'id hubspot-messages-iframe-container. Lorsqu'un visiteur appuie sur le lanceur, le conteneur s'anime de la taille du lanceur (environ 60 par 60 pixels) à la taille complète du panneau (environ 380 par 600 pixels). Lorsque le visiteur le ferme, le conteneur s'anime en sens inverse.
Voici le piège. L'API Layout Instability lie hadRecentInput au document où l'événement d'entrée s'est déclenché. Le clic sur le lanceur HubSpot se déclenche à l'intérieur du document de l'iframe. Le layout shift se produit sur le document parent, car c'est là que le conteneur de l'iframe est redimensionné. Deux documents différents. Le hadRecentInput du parent reste false. Le décalage est comptabilisé.
Vous avez en fait trois moments de décalage à gérer. Au chargement, HubSpot monte le lanceur en petit (~100x96), puis l'agrandit à ~242x245 une seconde ou deux plus tard pour afficher la bulle de bienvenue. Aucune interaction de l'utilisateur. Comptabilisé en totalité. À l'ouverture, le clic se déclenche à l'intérieur de l'iframe, le parent ne le voit donc jamais. Comptabilisé en totalité. À la fermeture, même problème de frame croisée, de plus l'animation de fermeture dure plus de 500 ms, de sorte que les frames après la fenêtre d'exclusion comptent même lorsque l'attribution est correcte.
J'ai mesuré cela sur de multiples sites clients. Un seul cycle d'ouverture et de fermeture peut produire une contribution de 0.91 au CLS. Cela seul suffit à faire passer une page d'un score satisfaisant à la catégorie médiocre.

Le code
Voici le script. Déposez-le sur votre site. Il s'exécute une fois, sans étape de build, sans framework, sans dépendance. Du JavaScript Vanilla. Il monte un bouton transparent sur le lanceur HubSpot, gère l'ouverture et la fermeture, et masque l'agrandissement lors du chargement.
(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 solution de contournement
Mon script fait trois choses, une pour chaque moment de décalage, plus une mesure défensive pour le chemin d'ouverture automatique.
Première astuce : masquer l'agrandissement du lanceur au chargement. cloakOnLoad définit opacity: 0 sur le conteneur de l'iframe au moment où il est monté, le surveille avec un ResizeObserver, et le révèle une fois que les dimensions restent stables pendant 2 secondes. Il y a une limite stricte de 7 secondes comme filet de sécurité pour les réseaux lents. Chrome 89 ignore les changements de rect sur tout élément dont l'opacité calculée est de 0 à la fois dans la frame actuelle et précédente, de sorte que l'agrandissement du lanceur vers la bulle de bienvenue se produit de manière invisible. Zéro entrée de décalage. Si le visiteur tape avant que le masque ne se lève, showChat révèle immédiatement car l'entrée elle-même exclut le décalage.
Deuxième astuce : capturer le clic d'ouverture sur le parent. Je monte un bouton invisible sur le lanceur HubSpot et le garde épinglé au rectangle englobant de l'iframe avec un ResizeObserver, un MutationObserver et une interrogation requestAnimationFrame de 1,5 seconde. HubSpot remonte l'iframe occasionnellement, en particulier dans les applications monopages, de sorte que les observateurs se rattachent lorsque cela se produit. Lorsque le visiteur appuie, le bouton reçoit l'événement sur le document parent et appelle HubSpotConversations.widget.open(). Le parent a maintenant une entrée récente. Chrome marque le décalage avec hadRecentInput=true. Exclu. Lorsque le chat est ouvert, le bouton ne couvre que le X de fermeture afin de ne pas bloquer les clics à l'intérieur du chat.
Troisième astuce : cacher l'animation de fermeture derrière l'opacité. Même astuce d'opacité 0 que la première. Le script définit opacity: 0 de manière synchrone à l'intérieur du gestionnaire de clic, puis appelle widget.close(). L'ordre compte : opacité 0 d'abord, puis fermeture. Inversez-les et l'animation de fermeture commence avant que l'opacité n'atteigne zéro, et les premières frames du décalage sont enregistrées.
La suppression du cookie en haut est la partie défensive. Le cookie hs-messages-is-open se souvient que le widget était ouvert lors d'une session précédente et le rouvre automatiquement au prochain chargement de page, aucune entrée utilisateur n'est requise. Tuer le cookie arrête entièrement ce chemin.
Le résultat
Sur le site client où j'ai déployé cela pour la première fois, le CLS p75 a chuté de 0.91 à 0.00 en deux jours d'accumulation de données de terrain. Le widget s'ouvre et se ferme toujours de la même manière visuellement. Les visiteurs ne remarquent rien. CrUX récupère les nouvelles données de terrain sur son cycle mensuel.

FAQ sur le CLS du chat HubSpot
Le correctif lui-même
Cela va-t-il casser le suivi ou les fonctionnalités de chat HubSpot ?
Non. Le widget lui-même reste inchangé. Nous interceptons simplement le clic qui l'ouvre et le clic qui le ferme. L'historique des conversations, le routage des agents, l'identification des contacts, rien de tout cela n'est affecté.
Est-ce que cela fonctionne sur mobile ?
Oui. Le bouton transparent est positionné avec position: fixed et suit le rectangle englobant de l'iframe. Cela fonctionne de la même manière sur mobile que sur bureau.
Est-ce que cela affecte l'INP ?
Le gestionnaire de clic effectue très peu de travail synchrone : une écriture d'opacité, une écriture de cookie, une lecture de DOM synchrone pour getBoundingClientRect. L'impact sur l'INP est négligeable. Le widget HubSpot lui-même représente le risque INP le plus important sur la plupart des sites, et c'est un problème distinct.
Réglage et stabilité
Pourquoi un timeout de 1200 ms pour restaurer l'opacité ?
L'animation de fermeture de HubSpot dure plusieurs centaines de millisecondes. 1200 ms offrent une grande marge de manœuvre. Réglez-le trop court et l'opacité se restaure alors que l'iframe est encore en pleine animation ; les frames restantes sont comptabilisées.
Et si HubSpot change l'ID de l'iframe ?
Ce script cessera alors de fonctionner. L'id hubspot-messages-iframe-container est stable depuis des années, mais c'est un détail d'implémentation tiers. Si HubSpot déploie une refonte, le sélecteur doit être mis à jour. C'est le prix à payer pour construire par-dessus le widget de quelqu'un d'autre.
Trouvez ce qui est vraiment lent.
Je remonte votre critical rendering path avec de vraies données terrain. Vous repartez avec une liste de fix priorisés, pas un rapport Lighthouse.
Demander l'audit
