"zoid destroyed all components" on PrestaShop: the PayPal button vanishes at checkout, and the cause is iubenda
The symptom: a button that vanishes, not one that breaks
This is one of those cases that cost days because they don't look like a fault: the PayPal button in the checkout of a PrestaShop 1.7 store with a custom theme kept vanishing, along with the card fields and the "pay in 3 installments" message banner on the product page. Not every time. About one time in two. Reloading the page sometimes brought it back, sometimes not.
In the console, the only trace was this:
paypal_js_sdk_v5_unhandled_exception ...
Error: zoid destroyed all components
zoid is the library PayPal uses to mount its components (button, card fields, banner) inside isolated iframes. When zoid says it "destroyed all components", it is not reporting an error in its own SDK: it is reporting that someone else on the page touched a node zoid was still using.
What it looked like, and wasn't
Before reaching the real cause, three reasonable avenues were tried. Each made sense for a specific reason, and none of them actually solved it.
- Cache-busting the checkout script. The first clue gathered was that a forced reload (Ctrl+Shift+R) made the button appear every time, while a normal load only managed it intermittently. The most immediate hypothesis was that the payment module's JavaScript file was being served from the browser cache in an "old" or partial state. Adding a cache-bust parameter to the script URL (guaranteeing a refetch on every load) did bring the behavior closer to that of a hard refresh, but it did not eliminate the problem: it was a symptom tied to timing, not the cause.
- Whitelisting the domain in the iubenda panel. The natural suspicion, given that the error showed up precisely when a cookie banner was present, was that the cookie consent autoblocking script was blocking the payment script as "non-essential". Consent platforms with prior blocking enabled expose a whitelist of domains that must not be touched before consent for exactly this reason. Whitelisting the payment network's domains looked like the obvious fix, and it is also a correct and necessary step. On its own, though, it wasn't enough: the problem came back.
- Temporarily disabling some secondary payment methods (Google Pay, Apple Pay, the "pay in installments" banners) to reduce the number of components the checkout had to mount, in the hope that it was a load problem or a race condition among too many iframes at once. This too shrank the surface of the problem without eliminating it.
There was also a fourth attempt, technically closer to the final solution but applied the wrong way: rewriting document.createElement on the theme side after the page had loaded, to restore the browser's native version. The attempt always failed. The reason only becomes clear by looking at how iubenda applies its own override: it does not do it right away, but inside a setTimeout, with a delay of about 150 milliseconds. A rewrap run too early found that the state it expected to replace did not exist yet, and failed silently. Chasing the right moment to step in, instead of taking away the other party's ability to step in at all, was the wrong road.
The real cause
The consent tool involved in this case is iubenda, with prior blocking (autoblocking) enabled. The mechanics, checked by reading the source of its autoblocking file, are these:
- With prior blocking enabled, iubenda overrides
document.createElementglobally and installs aMutationObserveron the whole document, withchildListandsubtreeon: it is notified of every single node added to the page, by any script. - For every node added, the observer calls its own check function, which decides whether that node must be blocked based on domain or type. So far, expected behavior: it is exactly what a tool with prior blocking exists to do, and it is also an obligation that consent regulations impose on anyone processing data before the user has made a choice.
- The point almost nobody grasps, and that no domain whitelist solves: that check function, whether or not it decides to block the node, still writes a diagnostic attribute onto it with
setAttribute. The whitelist decides whether to block a node. It does not decide whether to touch it. A whitelisted node still gets written to. - When the touched node is an iframe that zoid is still holding while it mounts, that write is the equivalent of breaking it under its hands. Zoid tears everything down and raises
zoid destroyed all components. It is not a PayPal bug and it is not an iubenda bug: they are two legitimate behaviors, one that has to observe and mark every node on the page to meet the prior-blocking obligation, the other that owns its iframes and does not expect them to be mutated from outside while it is still mounting them.
This also explains why the problem was intermittent, which is the detail that wastes the most time of all: it is not a fault that shows up "sometimes, by chance", it is a fault that depends on the relative timing of two independent scripts. With a forced reload, loading times lined up in a way that made the same sequence win every time, and the button always appeared. With a normal load, the sequence changed on every visit, and the button appeared or vanished depending on which of the two scripts reached the right node first: about one time in two. Anyone facing a problem that looks non-deterministic tends to chase the wrong variable (the browser, the connection, the one customer who "always has the problem"), when the real variable is simply the execution order of two scripts that know nothing about each other.
The fix
The solution that held does not fight over timing. It does not try to run code before or after iubenda: it takes away iubenda's very ability to write over document.createElement, whatever the moment it tries to do so.
In the theme's <head>, before the consent tool's script is loaded, the property is locked down with Object.defineProperty: a getter that always returns the browser's native function, and a setter that does nothing.
<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 any rewrite
}
});
} catch (e) {}
})();
</script>
That is five lines of real logic, and it is the part of this article worth copying as is. It works for a precise reason: a plain document.createElement = otherFunction is an assignment, and assignments to a property with a setter always go through that setter. It does not matter whether the script trying it arrives one millisecond later or five hundred milliseconds later, inside a setTimeout or on any asynchronous event: the no-op setter swallows it silently, every time. createElement stays the browser's native one forever. As a result, the iframes zoid creates to mount the button and card fields are never intercepted, cloned or replaced by a wrapper, and iubenda's observer has nothing left to trip over when it gets around to marking those nodes.
It is important to be precise about what this fix does not touch: the rest of iubenda's behavior stays intact. Prior blocking of non-essential scripts keeps working as it should, the domain whitelist still applies, and the banner and consent collection do not change. All that is being prevented is a native browser function, which the payment code uses to build its own iframes, from being replaced by a wrapper. It is not a choice against iubenda: it is a way to make one legitimate obligation (blocking before consent) coexist with another (a payment SDK that needs its own nodes intact while it is mounting them).
A second approach, more invasive (and its drawbacks)
There is also an alternative, used at a different point in the same hunt, to cover a detail that the createElement guard alone does not fully cover: some non-iframe nodes (for example the payment promotional message banners, which PayPal mounts as normal content and not inside an iframe) can still be touched by the observer through the same mechanism, because here createElement is not involved, but the direct mutation of an already existing node.
The approach used in that case was more invasive: hosting a copy of iubenda's autoblocking JavaScript file locally, on the theme, instead of loading it from the vendor's domain, and patching directly the function that decides whether to mark a node, adding an explicit check: if the node (or one of its parents) has a signature recognizable as a payment-network node (an iframe tag, or an id/class/name containing terms from the payment provider or its SDK), the function returns immediately, without touching it.
// at the top of the function that
// checks every added node
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; // do not touch
}
} catch (e) {}
}
// ... the original check follows
This approach works, and it is the one that also covered the non-iframe nodes. But it has three real drawbacks, worth knowing before choosing it:
- No automatic updates. The file is no longer fetched from the vendor on each visit: it is a static copy. If the vendor releases a new version of the script (for a fix, for an updated regulatory requirement), that copy does not receive it.
- It has to be re-patched by hand. Every time the file needs updating (for a change to the consent configuration, or to align with a new vendor version), the file has to be downloaded again and the patch reapplied manually, point by point.
- Saving in the vendor's panel can silently bring the fault back. Many integration modules, when you save settings in their own admin panel, regenerate the snippet to embed in the page and point it back by default to the vendor-hosted file. When that happens, the patched local copy stops being the one actually in use, and the problem reappears without anything, apparently, having been touched on purpose. It is the kind of regression that is hardest to diagnose, because there is no deploy error to check: there is only a save made in good faith in a panel that seems to have nothing to do with payment.
For this reason, where possible, the createElement guard remains the preferred fix: it does not depend on the vendor, it survives an update of its script, and it requires no maintenance. The self-hosted file approach remains an option for the cases (such as non-iframe nodes) that the guard alone does not cover.
How to verify that it holds
An intermittent fault is also the easiest to declare "solved" by mistake: if it appeared about one time in two, three clean loads in a row that go well are not proof. What worked to really verify it:
- Many normal loads in a row, not hard refreshes: it is the normal load that used to fail intermittently, so it is the only test worth repeating many times.
- Console open on every attempt, explicitly looking for the absence of
zoid destroyed all componentsand ofFailed to render CardFields, not just the visible presence of the button: sometimes a component can still appear by succeeding on an internal second attempt, masking an error that did in fact happen. - Check that
createElementis really locked, not just that the symptom did not show up that time: from the console, an attempt to reassign it (document.createElement = function(){}, then readingdocument.createElementback) must show that the value has not changed. - If the self-hosted file approach is used, one more check to put on the calendar: after every save of the consent settings in the vendor's panel, verify that the snippet embedded in the page still points to the patched local file, and has not been pointed back to the vendor's file. It is the only way to notice the silent regression before a customer is the first to notice it.
The store on which this case was diagnosed and solved stays anonymous on purpose: what matters, for the reader, is the mechanism, not who we saw it happen to. If a payment button in a PrestaShop checkout vanishes intermittently and zoid destroyed all components shows up in the console, the first suspect to check is not the payment module: it is whatever, on the page, is writing over the nodes that module is still mounting.
