Skip to content

AdyenPayment never destroys the Adyen Web Drop-in on unmount (memory leak) #811

Description

@Randagio13

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

  1. Load a checkout with Adyen configured alongside at least one other payment method; reach the payment step (Drop-in mounts, injecting Secured-Field iframes).
  2. Select a different payment method, then switch back to Adyen — AdyenGateway unmounts and remounts AdyenPayment on every switch.
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions