« zoid destroyed all components » sur PrestaShop : le bouton PayPal disparaît du checkout, et la cause est iubenda

par Luca Cavallini Cas d'assistance
« zoid destroyed all components » sur PrestaShop : le bouton PayPal disparaît du checkout, et la cause est iubenda

Le symptôme : un bouton qui disparaît, pas un bouton qui casse

Ce cas fait partie de ceux qui font perdre des jours parce qu'ils ne ressemblent pas à une panne : le bouton PayPal du checkout d'une boutique PrestaShop 1.7 avec un thème sur mesure disparaissait, en même temps que les champs de carte et le bandeau de messages « payez en 3 fois » sur la fiche produit. Pas toujours. Environ une fois sur deux. Recharger la page le faisait parfois réapparaître, parfois non.

Dans la console, la seule trace était celle-ci :

paypal_js_sdk_v5_unhandled_exception ...
Error: zoid destroyed all components

zoid est la bibliothèque que PayPal utilise pour monter ses composants (bouton, champs de carte, bandeau) dans des iframes isolées. Quand zoid dit qu'il a « détruit tous les composants », il ne signale pas une erreur de son propre SDK : il signale que quelqu'un d'autre, dans la page, a touché un nœud que zoid était encore en train d'utiliser.

Ce que cela semblait être, et n'était pas

Avant d'arriver à la vraie cause, trois pistes raisonnables ont été essayées. Chacune semblait sensée pour une raison précise, et aucune n'a vraiment réglé le problème.

  • Cache-bust du script du checkout. Le premier indice recueilli était que le rechargement forcé (Ctrl+Maj+R) faisait apparaître le bouton à chaque fois, alors qu'un chargement normal n'y parvenait que par intermittence. L'hypothèse la plus immédiate était que le fichier JavaScript du module de paiement était servi depuis le cache du navigateur dans un état « ancien » ou partiel. Ajouter un paramètre de cache-bust à l'URL du script (rechargement garanti à chaque visite) a effectivement rapproché le comportement de celui du rechargement forcé, mais n'a pas éliminé le problème : c'était un symptôme lié au timing, pas la cause.
  • Liste blanche du domaine dans le panneau d'iubenda. Le soupçon naturel, vu que l'erreur apparaissait justement quand un bandeau cookies était présent, était que le script de blocage automatique du consentement bloquait le script de paiement comme « non essentiel ». Les plateformes de consentement avec blocage préventif actif proposent justement pour cela une liste blanche de domaines à ne pas toucher avant le consentement. Mettre en liste blanche les domaines du circuit de paiement semblait la solution évidente, et c'est aussi une étape correcte et nécessaire. À elle seule, elle n'a pourtant pas suffi : le problème revenait.
  • Désactiver temporairement certains moyens de paiement secondaires (Google Pay, Apple Pay, les bandeaux de paiement en plusieurs fois) pour réduire le nombre de composants que le checkout devait monter, dans l'espoir qu'il s'agisse d'un problème de charge ou d'une situation de concurrence entre trop d'iframes à la fois. Cela aussi a réduit la surface du problème sans l'éliminer.

Il y a eu aussi une quatrième tentative, techniquement plus proche de la solution finale mais appliquée de la mauvaise façon : réécrire document.createElement côté thème après le chargement de la page, pour le ramener à la version native du navigateur. La tentative échouait à chaque fois. On ne comprend pourquoi qu'en regardant comment iubenda applique sa propre surcharge : il ne le fait pas immédiatement, mais dans un setTimeout, avec un délai d'environ 150 millisecondes. Une réécriture exécutée trop tôt trouvait l'état qu'elle voulait remplacer encore inexistant, et échouait en silence. Courir après le bon moment pour intervenir, au lieu d'enlever à l'autre la possibilité d'intervenir, était la mauvaise piste.

La vraie cause

L'outil de consentement impliqué dans ce cas est iubenda, avec le blocage préventif (autoblocking) actif. La mécanique, vérifiée à code ouvert dans son fichier d'autoblocking, est la suivante :

  1. Avec le blocage préventif actif, iubenda surcharge document.createElement globalement et installe un MutationObserver sur tout le document, avec childList et subtree actifs : il est notifié de chaque nœud ajouté à la page, par n'importe quel script.
  2. Pour chaque nœud ajouté, l'observer appelle sa propre fonction de contrôle qui décide si ce nœud doit être bloqué selon son domaine ou son type. Jusque-là, comportement attendu : c'est exactement la raison d'être d'un outil de blocage préventif, et c'est aussi une obligation que la réglementation sur le consentement impose à quiconque traite des données avant que l'utilisateur ait fait son choix.
  3. Le point que presque personne ne comprend, et qu'aucune liste blanche de domaines ne résout : cette fonction de contrôle, qu'elle décide ou non de bloquer le nœud, écrit de toute façon dessus un attribut de diagnostic avec setAttribute. La liste blanche décide s'il faut bloquer un nœud. Elle ne décide pas s'il faut le toucher. Un nœud en liste blanche est quand même modifié.
  4. Quand le nœud touché est une iframe que zoid tient encore pendant son propre montage, cette écriture revient à la lui casser entre les mains. Zoid démonte tout et lève zoid destroyed all components. Ce n'est pas un bug de PayPal et ce n'est pas un bug d'iubenda : ce sont deux comportements légitimes, l'un qui doit observer et marquer chaque nœud de la page pour respecter l'obligation de blocage préventif, l'autre qui possède ses propres iframes et ne s'attend pas à ce qu'elles soient modifiées de l'extérieur pendant qu'il les monte encore.

Cela explique aussi pourquoi le problème était intermittent, et c'est le détail qui fait perdre le plus de temps : ce n'est pas une panne qui survient « parfois, par hasard », c'est une panne qui dépend du timing relatif entre deux scripts indépendants. Avec le rechargement forcé, les temps de chargement s'alignaient d'une façon qui faisait toujours gagner la même séquence, et le bouton apparaissait toujours. Avec un chargement normal, la séquence changeait à chaque visite, et le bouton apparaissait ou disparaissait selon lequel des deux scripts arrivait en premier sur le bon nœud : environ une fois sur deux. Face à un problème qui semble non déterministe, on a tendance à poursuivre la mauvaise variable (le navigateur, la connexion, le client qui « a toujours le problème, lui »), alors que la vraie variable est simplement l'ordre d'exécution de deux scripts qui ne savent rien l'un de l'autre.

Le correctif

La solution qui a tenu ne se bat pas sur le timing. Elle ne cherche pas à exécuter du code avant ou après iubenda : elle enlève à iubenda la possibilité même d'écrire par-dessus document.createElement, quel que soit le moment où il essaie de le faire.

Dans le <head> du thème, avant que le script de l'outil de consentement ne soit chargé, on verrouille la propriété avec Object.defineProperty : un getter qui renvoie toujours la fonction native du navigateur, et un setter qui ne fait rien.

<script>
(function () {
  try {
    var d = document;
    var native = d.createElement.bind(d);
    Object.defineProperty(d, 'createElement', {
      configurable: true,
      get: function () {
        return native;
      },
      set: function () {
        // ignore toute réécriture
      }
    });
  } catch (e) {}
})();
</script>

Ce sont cinq lignes de vraie logique, et c'est la partie de cet article qui vaut la peine d'être copiée telle quelle. Cela fonctionne pour une raison précise : un simple document.createElement = autreFonction est une affectation, et les affectations sur une propriété dotée d'un setter passent toujours par ce setter. Peu importe que le script qui essaie de le faire arrive une milliseconde ou cinq cents millisecondes plus tard, dans un setTimeout ou sur un événement asynchrone quelconque : le setter sans effet l'avale en silence, à chaque fois. createElement reste pour toujours celui du navigateur. Par conséquent, les iframes que zoid crée pour monter le bouton et les champs de carte ne sont jamais interceptées, clonées ou remplacées par un wrapper, et l'observer d'iubenda n'a plus rien sur quoi trébucher quand il vient marquer ces nœuds.

Il est important d'être précis sur ce que ce correctif ne touche pas : le reste du comportement d'iubenda reste intact. Le blocage préventif des scripts non essentiels continue de fonctionner comme il le doit, la liste blanche de domaines reste valable, le bandeau et le recueil du consentement ne changent pas. On empêche seulement qu'une fonction native du navigateur, que le code de paiement utilise pour construire ses propres iframes, soit remplacée par un wrapper. Ce n'est pas un choix contre iubenda : c'est une façon de faire coexister une obligation légitime (bloquer avant le consentement) avec une autre (un SDK de paiement qui a besoin de ses propres nœuds intacts pendant qu'il les monte).

Une seconde piste, plus invasive (et ses inconvénients)

Il existe aussi une alternative, utilisée à un autre moment de la même enquête, pour couvrir un détail que la protection sur createElement ne couvre pas entièrement à elle seule : certains nœuds qui ne sont pas des iframes (par exemple les bandeaux de messages promotionnels du paiement, que PayPal monte comme du contenu normal et non dans une iframe) peuvent quand même être touchés par l'observer selon le même mécanisme, parce que là, ce n'est pas createElement qui est en cause mais la modification directe d'un nœud déjà existant.

La piste utilisée dans ce cas a été plus invasive : héberger localement, dans le thème, une copie du fichier JavaScript d'autoblocking d'iubenda au lieu de le charger depuis le domaine du fournisseur, et patcher directement la fonction qui décide de marquer un nœud, en y ajoutant un contrôle explicite : si le nœud (ou l'un de ses parents) a une signature reconnaissable comme nœud du circuit de paiement (balise iframe, ou id/class/name contenant des termes comme ceux du prestataire de paiement ou de son SDK), la fonction retourne immédiatement, sans le toucher.

// en haut de la fonction qui
// contrôle chaque nœud ajouté
if (node && node.nodeType === 1) {
  try {
    var tag = (node.nodeName || '')
      .toLowerCase();
    var id = node.id || '';
    var cls = node.getAttribute &&
      node.getAttribute('class') || '';
    var sig = id + ' ' + cls;
    var isPay = tag === 'iframe' ||
      /paypal|checkout/i.test(sig) ||
      (node.closest && node.closest(
        '[id*="paypal" i],' +
        '[class*="paypal" i],' +
        '[data-pp-message]'
      ));
    if (isPay) {
      return; // ne pas toucher
    }
  } catch (e) {}
}
// ... suite du contrôle d'origine

Cette piste fonctionne, et c'est elle qui a aussi couvert les nœuds qui ne sont pas des iframes. Mais elle a trois vrais inconvénients, qu'il vaut mieux connaître avant de la choisir :

  • Plus de mises à jour automatiques. Le fichier n'est plus téléchargé depuis le fournisseur à chaque visite : c'est une copie statique. Si le fournisseur publie une nouvelle version du script (pour une correction, pour une exigence réglementaire mise à jour), cette copie ne la reçoit pas.
  • Il faut la repatcher à la main. Chaque fois qu'il faut mettre le fichier à jour (pour une modification de la configuration du consentement, ou pour s'aligner sur une nouvelle version du fournisseur), le fichier doit être téléchargé à nouveau et le patch réappliqué manuellement, point par point.
  • Un enregistrement dans le panneau du fournisseur peut faire revenir la panne en silence. Beaucoup de modules d'intégration, quand on enregistre les réglages dans leur propre panneau d'administration, régénèrent le code à intégrer dans la page et le refont pointer par défaut vers le fichier hébergé par le fournisseur. Si cela arrive, la copie locale patchée cesse d'être celle réellement utilisée, et le problème réapparaît sans que rien, en apparence, n'ait été modifié volontairement. C'est le type de régression le plus difficile à diagnostiquer, parce qu'il n'y a aucune erreur de déploiement à vérifier : il n'y a qu'un enregistrement fait de bonne foi dans un panneau qui semble n'avoir rien à voir avec le paiement.

C'est pourquoi, quand c'est possible, la protection sur createElement reste le correctif à privilégier : elle ne dépend pas du fournisseur, survit à une mise à jour de son script et ne demande aucune maintenance. La piste du fichier auto-hébergé reste une option pour les cas (comme les nœuds qui ne sont pas des iframes) que la protection seule ne couvre pas.

Comment vérifier que le correctif tient

Une panne intermittente est aussi la plus facile à déclarer « résolue » par erreur : si la probabilité d'apparition était d'environ une fois sur deux, trois chargements propres d'affilée qui se passent bien ne sont pas une preuve. Voici ce qui a fonctionné pour vérifier réellement :

  • De nombreux chargements normaux d'affilée, pas des rechargements forcés : c'est justement le chargement normal qui échouait par intermittence, c'est donc le seul test qu'il est utile de répéter plusieurs fois.
  • La console ouverte à chaque essai, en cherchant explicitement l'absence de zoid destroyed all components et de Failed to render CardFields, pas seulement la présence visuelle du bouton : un composant peut parfois apparaître quand même en réussissant à sa deuxième tentative interne, ce qui masque une erreur qui s'est bel et bien produite.
  • Vérifier que createElement est vraiment verrouillé, et pas seulement que le symptôme n'apparaît pas cette fois-là : depuis la console, une tentative de réaffectation (document.createElement = function(){}, puis relire document.createElement) doit montrer que la valeur n'a pas changé.
  • Si vous utilisez la piste du fichier auto-hébergé, un contrôle supplémentaire à inscrire au calendrier : après chaque enregistrement des réglages de consentement dans le panneau du fournisseur, vérifier que le code intégré dans la page pointe toujours vers le fichier local patché, et n'a pas été redirigé vers le fichier du fournisseur. C'est le seul moyen de repérer la régression silencieuse avant qu'un client ne la remarque en premier.

La boutique sur laquelle ce cas a été diagnostiqué et résolu reste volontairement anonyme : ce qui compte pour le lecteur, c'est le mécanisme, pas l'endroit où nous l'avons vu se produire. Si, dans un checkout PrestaShop, un bouton de paiement disparaît par intermittence et que la console affiche zoid destroyed all components, le premier suspect à vérifier n'est pas le module de paiement : c'est ce qui, dans la page, écrit par-dessus les nœuds que ce module est encore en train de monter.

Laisser un commentaire

Les commentaires sont modérés avant publication.

← Blog

Chargement...
Retour en haut