«zoid destroyed all components» su PrestaShop: il bottone PayPal sparisce nel checkout, e la causa è iubenda

di Luca Cavallini Casi di assistenza
«zoid destroyed all components» su PrestaShop: il bottone PayPal sparisce nel checkout, e la causa è iubenda

Il sintomo: un bottone che sparisce, non che si rompe

Il caso è uno di quelli che fanno perdere giorni perché non sembrano un guasto: il bottone PayPal nel checkout di un negozio PrestaShop 1.7 con un tema su misura spariva, insieme ai campi carta e al banner dei messaggi "paga in 3 rate" sulla scheda prodotto. Non sempre. Circa una volta su due. Ricaricare la pagina a volte lo faceva ricomparire, a volte no.

In console, l'unica traccia era questa:

paypal_js_sdk_v5_unhandled_exception ...
Error: zoid destroyed all components

zoid è la libreria che PayPal usa per montare i suoi componenti (bottone, campi carta, banner) dentro a degli iframe isolati. Quando zoid dice che ha "distrutto tutti i componenti", non sta segnalando un errore del proprio SDK: sta segnalando che qualcun altro, nella pagina, ha toccato un nodo che zoid stava ancora usando.

Cosa sembrava e non era

Prima di arrivare alla causa vera, sono state provate tre strade ragionevoli. Ognuna sembrava sensata per un motivo preciso, e nessuna ha risolto davvero.

  • Cache-bust dello script del checkout. Il primo indizio raccolto era che il ricaricamento forzato (Ctrl+Maiusc+R) faceva comparire il bottone sempre, mentre un caricamento normale ci riusciva solo a intermittenza. L'ipotesi più immediata era che il file JavaScript del modulo di pagamento venisse servito dalla cache del browser in uno stato "vecchio" o parziale. Aggiungere un parametro di cache-bust all'URL dello script (rifetch garantito ad ogni caricamento) ha effettivamente reso il comportamento più vicino a quello dell'hard-refresh, ma non ha eliminato il problema: era un sintomo collegato al timing, non la causa.
  • Whitelist del dominio nel pannello di iubenda. Il sospetto naturale, visto l'errore che compariva proprio quando un banner di cookie era presente, era che lo script di autoblocking del consenso cookie stesse bloccando lo script del pagamento come "non essenziale". Le piattaforme di consenso con blocco preventivo attivo espongono proprio per questo una whitelist di domini che non devono essere toccati prima del consenso. Mettere in whitelist i domini del circuito di pagamento sembrava la soluzione ovvia, ed è anche un passo corretto e necessario. Da solo però non è bastato: il problema tornava.
  • Disattivare temporaneamente alcuni metodi di pagamento accessori (Google Pay, Apple Pay, i banner "paga a rate") per ridurre il numero di componenti che il checkout doveva montare, nella speranza che fosse un problema di carico o di race condition fra troppi iframe insieme. Anche questo ha ridotto la superficie del problema senza eliminarlo.

C'è stato anche un quarto tentativo, tecnicamente più vicino alla soluzione finale ma applicato nel modo sbagliato: riscrivere document.createElement lato tema dopo il caricamento della pagina, per riportarlo alla versione nativa del browser. Il tentativo falliva sempre. Il motivo si capisce solo guardando come iubenda applica la propria sovrascrittura: non lo fa subito, ma dentro a un setTimeout, con un ritardo di circa 150 millisecondi. Un rewrap eseguito troppo presto trovava lo stato che si aspettava di rimpiazzare ancora inesistente, e falliva silenziosamente. Rincorrere il momento giusto per intervenire, invece di togliere all'altro la possibilità di intervenire, era la strada sbagliata.

La causa vera

Lo strumento di consenso coinvolto in questo caso è iubenda, con il blocco preventivo (autoblocking) attivo. La meccanica, verificata a codice aperto sul suo file di autoblocking, è questa:

  1. Con il blocco preventivo attivo, iubenda sovrascrive document.createElement globalmente e installa un MutationObserver su tutto il documento, con childList e subtree attivi: viene notificato di ogni singolo nodo aggiunto alla pagina, da qualunque script.
  2. Per ogni nodo aggiunto, l'observer chiama una propria funzione di controllo che decide se quel nodo va bloccato in base al dominio o al tipo. Fin qui, comportamento atteso: è esattamente ciò per cui uno strumento con blocco preventivo esiste, ed è anche un obbligo che la normativa sul consenso impone a chi tratta dati prima che l'utente abbia scelto.
  3. Il punto che quasi nessuno capisce, e che nessuna whitelist di dominio risolve: quella funzione di controllo, indipendentemente dal fatto che decida di bloccare o no il nodo, gli scrive comunque sopra un attributo diagnostico con setAttribute. La whitelist decide se bloccare un nodo. Non decide se toccarlo. Un nodo whitelistato viene comunque scritto.
  4. Quando il nodo toccato è un iframe che zoid tiene ancora in mano durante il proprio montaggio, quella scrittura equivale a romperglielo sotto le mani. Zoid smonta tutto e alza zoid destroyed all components. Non è un bug di PayPal e non è un bug di iubenda: sono due comportamenti legittimi, uno che deve osservare e marcare ogni nodo della pagina per rispettare l'obbligo di blocco preventivo, l'altro che possiede i propri iframe e non si aspetta che vengano mutati da fuori mentre li sta ancora montando.

Questo spiega anche perché il problema era intermittente, ed è il dettaglio che fa perdere più tempo di tutti: non è un guasto che si presenta "a volte per caso", è un guasto che dipende dai tempi relativi fra due script indipendenti. Con il ricaricamento forzato, i tempi di caricamento si allineavano in un modo che faceva vincere sempre la stessa sequenza, e il bottone appariva sempre. Con un caricamento normale, la sequenza cambiava a ogni visita, e il bottone appariva o spariva a seconda di quale dei due script arrivava prima sul nodo giusto: circa una volta su due. Chi si trova davanti a un problema che sembra non deterministico tende a inseguire la variabile sbagliata (il browser, la connessione, il singolo cliente che "ha sempre il problema lui"), quando la vera variabile è semplicemente l'ordine di esecuzione di due script che non sanno l'uno dell'altro.

La correzione

La soluzione che ha tenuto non combatte sul tempo. Non cerca di eseguire un codice prima o dopo iubenda: toglie a iubenda la possibilità stessa di scrivere sopra document.createElement, qualunque sia il momento in cui prova a farlo.

Nel <head> del tema, prima che lo script dello strumento di consenso venga caricato, si blinda la proprietà con Object.defineProperty: un getter che restituisce sempre la funzione nativa del browser, e un setter che non fa nulla.

<script>
(function () {
  try {
    var d = document;
    var native = d.createElement.bind(d);
    Object.defineProperty(d, 'createElement', {
      configurable: true,
      get: function () {
        return native;
      },
      set: function () {
        // ignora ogni riscrittura
      }
    });
  } catch (e) {}
})();
</script>

Sono cinque righe di logica vera, ed è la parte di questo articolo che vale la pena copiare così com'è. Funziona per un motivo preciso: un normale document.createElement = altraFunzione è un'assegnazione, e le assegnazioni su una proprietà con un setter passano sempre da quel setter. Non importa se lo script che prova a farlo arriva un millisecondo dopo o cinquecento millisecondi dopo, dentro a un setTimeout o su un evento asincrono qualsiasi: il setter no-op la ingoia in silenzio, ogni volta. createElement resta per sempre quello nativo del browser. Di conseguenza, gli iframe che zoid crea per montare bottone e campi carta non vengono mai intercettati, clonati o sostituiti da un wrapper, e l'observer di iubenda non ha più nulla su cui inciampare quando arriva a marcare quei nodi.

È importante essere precisi su cosa questa correzione non tocca: il resto del comportamento di iubenda resta intatto. Il blocco preventivo degli script non essenziali continua a funzionare come deve, la whitelist di dominio continua a valere, il banner e la raccolta del consenso non cambiano. Si sta solo impedendo che una funzione nativa del browser, che il codice di pagamento usa per costruire i propri iframe, venga sostituita da un wrapper. Non è una scelta contro iubenda: è un modo per far coesistere un obbligo legittimo (bloccare prima del consenso) con un altro (un SDK di pagamento che ha bisogno dei propri nodi intatti mentre li sta montando).

Una seconda strada, più invasiva (e i suoi contro)

Esiste anche un'alternativa, usata in un momento diverso della stessa caccia, per coprire un dettaglio che la guardia su createElement da sola non copre fino in fondo: alcuni nodi non iframe (per esempio i banner dei messaggi promozionali del pagamento, che PayPal monta come contenuto normale e non dentro a un iframe) possono comunque essere toccati dall'observer con lo stesso meccanismo, perché lì non c'entra createElement ma la mutazione diretta di un nodo già esistente.

La strada usata in quel caso è stata più invasiva: ospitare in locale, sul tema, una copia del file JavaScript di autoblocking di iubenda, invece di caricarlo dal dominio del fornitore, e patchare direttamente la funzione che decide se marcare un nodo, aggiungendo un controllo esplicito: se il nodo (o un suo genitore) ha una firma riconoscibile come nodo del circuito di pagamento (tag iframe, oppure id/class/name che contengono termini come quelli del provider di pagamento o del suo SDK), la funzione ritorna subito, senza toccarlo.

// in cima alla funzione che
// controlla ogni nodo aggiunto
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; // non toccare
    }
  } catch (e) {}
}
// ... segue il controllo originale

Questa strada funziona, ed è quella che ha coperto anche i nodi non iframe. Ma ha tre contro veri, che vale la pena conoscere prima di sceglierla:

  • Niente aggiornamenti automatici. Il file non viene più scaricato dal fornitore ad ogni visita: è una copia statica. Se il fornitore rilascia una nuova versione dello script (per una correzione, per un requisito normativo aggiornato), quella copia non la riceve.
  • Va ripatchata a mano. Ogni volta che serve aggiornare il file (per una modifica alla configurazione del consenso, o per allinearsi a una nuova versione del fornitore), il file va riscaricato e la patch va riapplicata manualmente, punto per punto.
  • Un salvataggio nel pannello del fornitore può far tornare il guasto in silenzio. Molti moduli di integrazione, quando si salvano le impostazioni nel proprio pannello di amministrazione, rigenerano lo snippet da incorporare nella pagina e lo ripuntano di default al file ospitato dal fornitore. Se questo accade, la copia locale patchata smette di essere quella effettivamente usata, e il problema si ripresenta senza che nulla, in apparenza, sia stato toccato di proposito. È il tipo di regressione più difficile da diagnosticare, perché non c'è nessun errore di deploy da controllare: c'è solo un salvataggio fatto in buona fede in un pannello che sembra non avere niente a che fare con il pagamento.

Per questo, dove è possibile, la guardia su createElement resta la correzione da preferire: non dipende dal fornitore, sopravvive a un aggiornamento del suo script, e non richiede manutenzione. La strada del file self-hostato resta un'opzione per i casi (come i nodi non iframe) che la guardia sola non copre.

Come verificare che tenga

Un guasto intermittente è anche il più facile da dichiarare "risolto" per errore: se la probabilità di comparsa era circa una volta su due, tre caricamenti puliti di fila che vanno bene non sono una prova. Quello che ha funzionato per verificare davvero:

  • Molti caricamenti normali di fila, non hard-refresh: è proprio il caricamento normale quello che prima falliva a intermittenza, quindi è l'unico test che ha senso ripetere più volte.
  • Console aperta ad ogni prova, cercando esplicitamente l'assenza di zoid destroyed all components e di Failed to render CardFields, non solo la presenza visiva del bottone: a volte un componente può apparire comunque riuscendo al secondo tentativo interno, mascherando un errore che è comunque avvenuto.
  • Verifica che createElement sia davvero bloccato, non solo che il sintomo non compaia quella volta: da console, un tentativo di riassegnarlo (document.createElement = function(){}, poi rileggere document.createElement) deve mostrare che il valore non è cambiato.
  • Se si usa la strada del file self-hostato, un controllo in più da mettere in calendario: dopo ogni salvataggio delle impostazioni di consenso nel pannello del fornitore, verificare che lo snippet incorporato nella pagina punti ancora al file locale patchato, e non sia stato ripuntato al file del fornitore. È l'unico modo per accorgersi della regressione silenziosa prima che sia un cliente ad accorgersene per primo.

Il negozio su cui è stato diagnosticato e risolto questo caso resta anonimo di proposito: quello che conta, per chi legge, è il meccanismo, non da chi l'abbiamo visto succedere. Se in un checkout PrestaShop un bottone di pagamento sparisce a intermittenza e in console compare zoid destroyed all components, il sospetto da controllare per primo non è il modulo di pagamento: è cosa, nella pagina, sta scrivendo sopra ai nodi che quel modulo sta ancora montando.

Lascia un commento

I commenti sono moderati prima della pubblicazione.

← Blog

Caricamento in corso ...
Torna all'inizio