Summary
AdyenPayment mounts an Adyen Web Drop-in instance but never calls the SDK's destroy API on unmount. Its cleanup only resets local React refs/state; it never calls remove()/unmount() on the Drop-in, so the previous instance's Secured-Field iframes (PAN/expiry/CVC), wallet SDK injections (Apple Pay / Google Pay), any 3-D Secure challenge frames, and its global message/resize listeners are orphaned but kept reachable, and are never garbage-collected.
This is not a rare "force remount" edge case: AdyenGateway.tsx unmounts <AdyenPayment> (return null) every time the shopper selects a different payment method (payment?.id !== currentPaymentMethodId). So switching away from and back to Adyen during normal multi-payment-method checkout browsing repeatedly leaks Drop-in instances — this is the standard flow, not a contrived scenario.
On severity: the leak mechanism itself is confirmed by code inspection. The specific claim that this crashes iOS Safari tabs is not backed by a production incident (no crash report / Sentry issue / support ticket found) — it's a reasonable inference from WebKit's known stricter per-tab memory ceiling combined with the confirmed leak, but should be treated as expected/theorized impact rather than an observed fact until a field incident confirms it.
Environment
- Package:
@commercelayer/react-components
- Underlying SDK:
@adyen/adyen-web (^6.28.0 in package.json, 6.31.0 installed)
- File:
packages/react-components/src/components/payment_source/AdyenPayment.tsx
Reproduction
- Load a checkout with Adyen configured alongside at least one other payment method; reach the payment step (Drop-in mounts, injecting Secured-Field iframes).
- Select a different payment method, then switch back to Adyen —
AdyenGateway unmounts and remounts AdyenPayment on every switch.
- Observe in DevTools → Memory / Elements across repeated cycles:
- Detached iframe nodes (Adyen Secured Fields) accumulate.
- Retained heap grows each cycle and is not reclaimed after a manual GC.
getEventListeners(window)/document shows growing message listener counts.
- On a long-lived SPA checkout session (or repeated cycling), this is expected to push iOS Safari toward its per-tab memory ceiling sooner than Chromium/Firefox, though this hasn't been confirmed against a real crash/incident.
Root cause
The mount effect instantiates and mounts the Drop-in:
const checkout = await AdyenCheckout(options)
const dropin = new Dropin(checkout, { /* ... */ }).mount("#adyen-dropin")
if (dropin && checkout) {
dropinRef.current = dropin
setCheckout(dropin)
setLoadAdyen(true)
}
But the effect's cleanup never destroys that instance:
return () => {
setPaymentRef({ ref: { current: null } })
setLoadAdyen(false)
}
}, [clientKey, ref != null, status, setPaymentMethodErrors != null])
It never calls dropinRef.current.remove() / .unmount(), never nulls dropinRef.current, and never resets the checkout state.
@adyen/adyen-web's own type definitions (confirmed against the installed 6.31.0) describe exactly this distinction:
/**
* Unmounts a payment element from the DOM
*/
unmount(): this;
/**
* Unmounts an element and removes it from the parent instance
* For "destroy" type cleanup - when you don't intend to use the component again
*/
remove(): void;
Important nuance that shapes the fix: this cleanup fires on every dependency change, not only on true unmount. status (from PlaceOrderContext) genuinely flips standby → placing → standby on a declined/retried payment (see PlaceOrderButton.tsx) while AdyenPayment stays mounted. Today that's harmless only because the existing cleanup never resets checkout state — !checkout stays false on the next effect run, so initializeAdyen() never re-fires on that flip. This means the fix cannot simply add dropinRef.current.remove() (and reset checkout) inside this shared cleanup: doing so would tear down and rebuild the live Drop-in on every ordinary declined-card retry, mid-flow.
Fix
Add a second, unmount-only effect, decoupled from the main effect (which is already flagged with a biome-ignore ... Infinite loop comment warning about its dependency sensitivity):
useEffect(() => {
return () => {
try {
dropinRef.current?.remove()
} catch (error) {
console.error("Adyen drop-in teardown error:", error)
}
dropinRef.current = null
}
}, [])
An empty dependency array means this cleanup only runs on real unmount, regardless of status/clientKey churn — it never touches checkout/loadAdyen state and leaves the main effect entirely untouched.
The component.mount("#adyen-dropin") call sites elsewhere in the file (inside onSubmit/onAdditionalDetails callbacks) reuse the same live instance the SDK itself hands back — not new instances — so no change is needed there.
Scope
The identical missing-teardown pattern (cleanup resets only local refs/state, no SDK-level destroy call) exists in StripePayment, KlarnaPayment, BraintreePayment, and PaypalPayment. This fix is deliberately scoped to Adyen only, since its Drop-in has the heaviest per-instance footprint (multiple Secured-Field iframes, wallet SDKs, 3DS challenge frames) of any gateway integration in this codebase.
Testing
Added specs/payment_source/adyen-payment.spec.tsx: mocks Dropin/AdyenCheckout and asserts:
remove() is called exactly once on real unmount.
remove() is not called across a status-only re-render (standby → placing → standby) while the component stays mounted.
Verified this test fails against both the unfixed code (no remove() call at all) and against a naive fix that merges remove() + setCheckout(undefined) into the existing shared cleanup (which calls remove() on the status flip and rebuilds the Drop-in). Real detached-iframe/heap confirmation stays a manual DevTools check — jsdom has no real GC/detached-node instrumentation to assert on in CI.
Related
A distinct latent race — the component unmounting while AdyenCheckout() is still in flight, which can still orphan a Drop-in instance created after unmount — was found during this review and is filed separately rather than bundled into this fix.
Summary
AdyenPaymentmounts an Adyen Web Drop-in instance but never calls the SDK's destroy API on unmount. Its cleanup only resets local React refs/state; it never callsremove()/unmount()on the Drop-in, so the previous instance's Secured-Field iframes (PAN/expiry/CVC), wallet SDK injections (Apple Pay / Google Pay), any 3-D Secure challenge frames, and its global message/resize listeners are orphaned but kept reachable, and are never garbage-collected.This is not a rare "force remount" edge case:
AdyenGateway.tsxunmounts<AdyenPayment>(return null) every time the shopper selects a different payment method (payment?.id !== currentPaymentMethodId). So switching away from and back to Adyen during normal multi-payment-method checkout browsing repeatedly leaks Drop-in instances — this is the standard flow, not a contrived scenario.On severity: the leak mechanism itself is confirmed by code inspection. The specific claim that this crashes iOS Safari tabs is not backed by a production incident (no crash report / Sentry issue / support ticket found) — it's a reasonable inference from WebKit's known stricter per-tab memory ceiling combined with the confirmed leak, but should be treated as expected/theorized impact rather than an observed fact until a field incident confirms it.
Environment
@commercelayer/react-components@adyen/adyen-web(^6.28.0inpackage.json,6.31.0installed)packages/react-components/src/components/payment_source/AdyenPayment.tsxReproduction
AdyenGatewayunmounts and remountsAdyenPaymenton every switch.getEventListeners(window)/documentshows growing message listener counts.Root cause
The mount effect instantiates and mounts the Drop-in:
But the effect's cleanup never destroys that instance:
It never calls
dropinRef.current.remove()/.unmount(), never nullsdropinRef.current, and never resets thecheckoutstate.@adyen/adyen-web's own type definitions (confirmed against the installed6.31.0) describe exactly this distinction:Important nuance that shapes the fix: this cleanup fires on every dependency change, not only on true unmount.
status(fromPlaceOrderContext) genuinely flipsstandby → placing → standbyon a declined/retried payment (seePlaceOrderButton.tsx) whileAdyenPaymentstays mounted. Today that's harmless only because the existing cleanup never resetscheckoutstate —!checkoutstaysfalseon the next effect run, soinitializeAdyen()never re-fires on that flip. This means the fix cannot simply adddropinRef.current.remove()(and resetcheckout) inside this shared cleanup: doing so would tear down and rebuild the live Drop-in on every ordinary declined-card retry, mid-flow.Fix
Add a second, unmount-only effect, decoupled from the main effect (which is already flagged with a
biome-ignore ... Infinite loopcomment warning about its dependency sensitivity):An empty dependency array means this cleanup only runs on real unmount, regardless of
status/clientKeychurn — it never touchescheckout/loadAdyenstate and leaves the main effect entirely untouched.The
component.mount("#adyen-dropin")call sites elsewhere in the file (insideonSubmit/onAdditionalDetailscallbacks) reuse the same live instance the SDK itself hands back — not new instances — so no change is needed there.Scope
The identical missing-teardown pattern (cleanup resets only local refs/state, no SDK-level destroy call) exists in
StripePayment,KlarnaPayment,BraintreePayment, andPaypalPayment. This fix is deliberately scoped to Adyen only, since its Drop-in has the heaviest per-instance footprint (multiple Secured-Field iframes, wallet SDKs, 3DS challenge frames) of any gateway integration in this codebase.Testing
Added
specs/payment_source/adyen-payment.spec.tsx: mocksDropin/AdyenCheckoutand asserts:remove()is called exactly once on real unmount.remove()is not called across astatus-only re-render (standby → placing → standby) while the component stays mounted.Verified this test fails against both the unfixed code (no
remove()call at all) and against a naive fix that mergesremove()+setCheckout(undefined)into the existing shared cleanup (which callsremove()on the status flip and rebuilds the Drop-in). Real detached-iframe/heap confirmation stays a manual DevTools check — jsdom has no real GC/detached-node instrumentation to assert on in CI.Related
A distinct latent race — the component unmounting while
AdyenCheckout()is still in flight, which can still orphan a Drop-in instance created after unmount — was found during this review and is filed separately rather than bundled into this fix.