diff --git a/packages/react-components/specs/payment_source/adyen-payment.spec.tsx b/packages/react-components/specs/payment_source/adyen-payment.spec.tsx new file mode 100644 index 00000000..0acd5ae4 --- /dev/null +++ b/packages/react-components/specs/payment_source/adyen-payment.spec.tsx @@ -0,0 +1,111 @@ +import type { Order } from "@commercelayer/sdk" +import { render, waitFor } from "@testing-library/react" +import type { ReactNode } from "react" +import { afterEach, describe, expect, it, vi } from "vitest" +import AdyenPayment from "#components/payment_source/AdyenPayment" +import CommerceLayerContext from "#context/CommerceLayerContext" +import CustomerContext from "#context/CustomerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import PaymentMethodContext, { + defaultPaymentMethodContext, +} from "#context/PaymentMethodContext" +import PlaceOrderContext, { + defaultPlaceOrderContext, +} from "#context/PlaceOrderContext" +import type { PaymentMethodState } from "#reducers/PaymentMethodReducer" + +const removeMock = vi.fn() +const mountMock = vi.fn() +const dropinConstructor = vi.fn() + +vi.mock("@adyen/adyen-web/auto", () => { + return { + AdyenCheckout: vi.fn(async () => ({})), + Dropin: class { + mount = mountMock.mockReturnThis() + remove = removeMock + submit = vi.fn() + handleAction = vi.fn() + constructor(...args: unknown[]) { + dropinConstructor(...args) + } + }, + } +}) + +const order = { id: "order_1" } as unknown as Order + +function Providers({ + status, + children, +}: { + status: "standby" | "placing" + children: ReactNode +}): ReactNode { + return ( + + + + + + {children} + + + + + + ) +} + +describe("AdyenPayment", () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it("destroys the Drop-in on unmount, but not on a status-only re-render", async () => { + const { rerender, unmount } = render( + + + , + ) + + await waitFor(() => expect(dropinConstructor).toHaveBeenCalledTimes(1)) + + // A declined-payment retry flips status standby -> placing -> standby + // while AdyenPayment stays mounted; that must not tear down the Drop-in. + rerender( + + + , + ) + rerender( + + + , + ) + + expect(removeMock).not.toHaveBeenCalled() + expect(dropinConstructor).toHaveBeenCalledTimes(1) + + unmount() + + expect(removeMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/react-components/src/components/payment_source/AdyenPayment.tsx b/packages/react-components/src/components/payment_source/AdyenPayment.tsx index 55473ce5..7d42be1e 100644 --- a/packages/react-components/src/components/payment_source/AdyenPayment.tsx +++ b/packages/react-components/src/components/payment_source/AdyenPayment.tsx @@ -702,6 +702,24 @@ export function AdyenPayment({ setLoadAdyen(false) } }, [clientKey, ref != null, status, setPaymentMethodErrors != null]) + + /** + * Destroys the Adyen Drop-in only on true unmount (empty deps), independent + * of the effect above: that effect's cleanup also runs on every `status` + * flip (e.g. standby -> placing -> standby on a declined retry) while the + * component stays mounted, and calling remove() there would tear down and + * rebuild the Drop-in mid-flow. + */ + useEffect(() => { + return () => { + try { + dropinRef.current?.remove() + } catch (error) { + console.error("Adyen drop-in teardown error:", error) + } + dropinRef.current = null + } + }, []) return !clientKey && !loadAdyen && !checkout ? null : (