From 06c31577900b5174154c83a66018dbac53827b73 Mon Sep 17 00:00:00 2001 From: Alessandro Casazza Date: Fri, 31 Jul 2026 21:41:52 +0200 Subject: [PATCH] fix(adyen): stop the Drop-in reloading on every order update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Adyen gift card that covers only part of the order left the Drop-in reloading repeatedly, and a Place Order click could crash the page with `unhandledRejection: Error: No active payment method.` Both `` and `` implement their loader by *replacing* the subtree (`content = !loading ? ... : loader` and `if (loading) return loaderComponent`), so any flip of `loading` unmounts every gateway below. For a stateless gateway that costs nothing; the Adyen Drop-in owns imperative state — the selected method and typed-in details — so it was destroyed and fully re-initialized each time. Guarding the individual flips cannot close this: `payment_response.status` and `payment_status` are populated by two different API calls, so there is a window where a flip looks legitimate. The gateway is therefore kept mounted for `adyen_payments`, and the payment methods are never swapped back out for the loader once rendered. `showLoader` now means "while first fetching the payment methods", as its documentation says. The intentional refresh is kept, but happens once: `Core.update({ amount }, { shouldReinitializeCheckout: true })` with the remaining amount. This replaces `dropinRef.current.mount("#adyen-dropin")`, which re-rendered the Drop-in with the *old* amount — losing the selection for no benefit. The remaining amount is deliberately not derived from `gift_card_amount_cents`: that field sums the Commerce Layer `gift_card` resources, and an Adyen gift card authorized through `_authorization_amount_cents` never creates one, so it stays 0 and the subtraction would hand back the full total. Adyen's own `remainingAmount` is preferred, falling back to `total - authorized balance`. Also: - `Dropin.remove()` on unmount, clearing `dropinRef`/`checkoutRef`, so a remounted component initializes a fresh instance instead of staying wired to a destroyed one. Kept in its own mount-scoped effect: the main effect re-runs on `status` changes and must not tear the Drop-in down. - `Dropin.submit()` wrapped in try/catch, routing the failure to `setPaymentMethodErrors` instead of an unhandled rejection, and the submit wiring disarmed on refresh so `` cannot submit an empty Drop-in. - Recreating the payment source is skipped while the order is partially authorized: `mismatched_amounts` is true by design in that window, and "healing" it would discard the authorization just obtained. Covered by 13 tests in specs/payment_source/AdyenPayment.spec.tsx, including one that drives the real PaymentMethod -> PaymentSource -> PaymentGateway -> AdyenGateway -> AdyenPayment chain through the order updates a partial authorization produces. --- .../payment_source/AdyenPayment.spec.tsx | 734 ++++++++++++++++++ .../payment_gateways/PaymentGateway.tsx | 44 +- .../payment_methods/PaymentMethod.tsx | 37 +- .../payment_source/AdyenPayment.tsx | 112 ++- 4 files changed, 915 insertions(+), 12 deletions(-) create mode 100644 packages/react-components/specs/payment_source/AdyenPayment.spec.tsx diff --git a/packages/react-components/specs/payment_source/AdyenPayment.spec.tsx b/packages/react-components/specs/payment_source/AdyenPayment.spec.tsx new file mode 100644 index 00000000..9a993f4f --- /dev/null +++ b/packages/react-components/specs/payment_source/AdyenPayment.spec.tsx @@ -0,0 +1,734 @@ +// Regression coverage for the partial gift-card authorization flow. When an Adyen gift card +// covers only part of the order, the Drop-in is refreshed once for the remaining amount via +// Core's `update()`. It used to be `mount()`ed again instead, which re-rendered it with the +// *old* amount — losing the shopper's selection for no benefit — and did so repeatedly. +import { act, fireEvent, render, screen } from "@testing-library/react" +import type { ReactNode } from "react" +import PaymentGateway from "#components/payment_gateways/PaymentGateway" +import { PaymentMethod } from "#components/payment_methods/PaymentMethod" +import { AdyenPayment } from "#components/payment_source/AdyenPayment" +import { PaymentSource } from "#components/payment_source/PaymentSource" +import CommerceLayerContext from "#context/CommerceLayerContext" +import CustomerContext from "#context/CustomerContext" +import OrderContext, { defaultOrderContext } from "#context/OrderContext" +import PaymentMethodChildrenContext from "#context/PaymentMethodChildrenContext" +import PaymentMethodContext, { defaultPaymentMethodContext } from "#context/PaymentMethodContext" +import PlaceOrderContext, { defaultPlaceOrderContext } from "#context/PlaceOrderContext" + +const TEST_TOKEN = "test-token" + +const adyen = vi.hoisted(() => ({ + coreUpdate: vi.fn(), + dropinMount: vi.fn(), + dropinRemove: vi.fn(), + dropinSubmit: vi.fn(), + // The Core configuration the component builds, so tests can invoke the real + // `onSubmit` handler it installs. + // biome-ignore lint/suspicious/noExplicitAny: test cast + captured: { options: null as any }, +})) + +vi.mock("@adyen/adyen-web/auto", () => ({ + // biome-ignore lint/suspicious/noExplicitAny: test cast + AdyenCheckout: vi.fn(async (options: any) => { + adyen.captured.options = options + return { update: adyen.coreUpdate } + }), + Dropin: class FakeDropin { + mount(selector: string): this { + adyen.dropinMount(selector) + return this + } + submit(): void { + adyen.dropinSubmit() + } + remove(): void { + adyen.dropinRemove() + } + unmount(): this { + return this + } + handleAction(): void {} + }, +})) + +// derives the Adyen environment from the access token; the test token is not +// a real JWT. +vi.mock("#utils/jwt", () => ({ + jwt: () => ({ test: true }), +})) + +vi.mock("#utils/getPublicIp", () => ({ + getPublicIP: vi.fn(async () => "127.0.0.1"), +})) + +// biome-ignore lint/suspicious/noExplicitAny: test cast +const ORDER: any = { + id: "order-1", + currency_code: "EUR", + country_code: "IT", + language_code: "en-US", + status: "pending", + payment_status: "unpaid", + total_amount_with_taxes_cents: 1000, + line_items: [], +} + +/** Adyen's payment_methods payload, so the component does not log a config error. */ +const PAYMENT_SOURCE = { + id: "ps-1", + type: "adyen_payments", + payment_methods: { + paymentMethods: [{ type: "giftcard" }, { type: "scheme" }], + }, +} + +function Providers({ + children, + order = ORDER, + paymentSource = PAYMENT_SOURCE, + placeOrderStatus = "standby", + updateOrder, + getOrderByFields = vi.fn().mockResolvedValue({ status: "pending", payment_status: "unpaid" }), + setPaymentSource, + setPaymentMethodErrors = vi.fn(), + setPaymentRef = vi.fn(), +}: { + children: ReactNode + // biome-ignore lint/suspicious/noExplicitAny: test cast + order?: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + paymentSource?: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + placeOrderStatus?: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + updateOrder?: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + getOrderByFields?: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + setPaymentSource?: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + setPaymentMethodErrors?: any + // biome-ignore lint/suspicious/noExplicitAny: test cast + setPaymentRef?: any +}) { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const paymentMethodCtx: any = { + ...defaultPaymentMethodContext, + _isProvided: true as const, + paymentSource, + currentPaymentMethodType: "giftcard", + setPaymentSource, + setPaymentMethodErrors, + setPaymentRef, + errors: [], + } + return ( + + + + + + {children} + + + + + + ) +} + +/** Resolves every pending promise chain kicked off by the component. */ +async function flush(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) +} + +/** + * Mounts the Drop-in, then submits a gift card. `balance` is what the gift card is worth + * and `orderAfterAuthorization` is the order the API returns from the authorize call. + */ +async function submitGiftCard({ + balance, + // biome-ignore lint/suspicious/noExplicitAny: test cast + orderAfterAuthorization, +}: { + balance: number + // biome-ignore lint/suspicious/noExplicitAny: test cast + orderAfterAuthorization: any +}): Promise<{ resolve: ReturnType; reject: ReturnType }> { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const setPaymentSource = vi.fn(async ({ attributes }: any) => { + if (attributes?._balance) return { ...PAYMENT_SOURCE, balance } + return { ...PAYMENT_SOURCE, payment_response: {} } + }) + const updateOrder = vi.fn().mockResolvedValue({ order: orderAfterAuthorization }) + + await act(async () => { + render( + + {message}, + }} + /> + + ) + }) + await flush() + + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + adyen.captured.options.onSubmit( + { data: { paymentMethod: { type: "giftcard" } }, isValid: true }, + { mount: vi.fn() }, + actions + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + return actions +} + +describe("AdyenPayment gift card partial authorization", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("updates the mounted Drop-in with the remaining amount instead of remounting it", async () => { + const actions = await submitGiftCard({ + balance: 400, + orderAfterAuthorization: { + ...ORDER, + payment_status: "partially_authorized", + // The Commerce Layer gift-card total stays 0 — an Adyen gift card authorized via + // `_authorization_amount_cents` is not a `gift_card` resource. + gift_card_amount_cents: 0, + payment_source: { payment_response: { resultCode: "Authorised" } }, + }, + }) + + expect(actions.resolve).toHaveBeenCalledWith({ resultCode: "Authorised" }) + // 1000 total - 400 authorized by the gift card + expect(adyen.coreUpdate).toHaveBeenCalledWith( + { amount: { currency: "EUR", value: 600 } }, + { shouldReinitializeCheckout: true } + ) + // Mounted once at initialization and never again + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + expect(adyen.dropinMount).toHaveBeenCalledWith("#adyen-dropin") + }) + + it("prefers Adyen's own remainingAmount when the response carries one", async () => { + await submitGiftCard({ + balance: 400, + orderAfterAuthorization: { + ...ORDER, + payment_status: "partially_authorized", + payment_source: { + payment_response: { + resultCode: "Authorised", + order: { remainingAmount: { currency: "EUR", value: 550 } }, + }, + }, + }, + }) + + expect(adyen.coreUpdate).toHaveBeenCalledWith( + { amount: { currency: "EUR", value: 550 } }, + { shouldReinitializeCheckout: true } + ) + }) + + it("does not touch the amount when the gift card covers the whole order", async () => { + const actions = await submitGiftCard({ + balance: 1000, + orderAfterAuthorization: { + ...ORDER, + payment_status: "authorized", + payment_source: { payment_response: { resultCode: "Authorised" } }, + }, + }) + + expect(actions.resolve).toHaveBeenCalledWith({ resultCode: "Authorised" }) + expect(adyen.coreUpdate).not.toHaveBeenCalled() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + }) + + it("removes the Adyen instance on unmount so a remount can re-initialize", async () => { + const setPaymentSource = vi.fn(async () => PAYMENT_SOURCE) + const { unmount } = render( + + + + ) + await flush() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + + // Without this the ref kept pointing at a destroyed Drop-in and the `!dropinRef.current` + // init guard left the component wired to it forever. + unmount() + expect(adyen.dropinRemove).toHaveBeenCalledTimes(1) + }) + + it("rejects and surfaces an error for a gift card with no balance", async () => { + const actions = await submitGiftCard({ + balance: 0, + orderAfterAuthorization: ORDER, + }) + + expect(actions.reject).toHaveBeenCalled() + expect(actions.resolve).not.toHaveBeenCalled() + expect(adyen.coreUpdate).not.toHaveBeenCalled() + expect(screen.getByTestId("gc-error").textContent).toContain("no balance") + }) +}) + +// `Dropin.submit()` throws synchronously when it has no `activePaymentMethod`. Because +// `handleSubmit` is async that became a rejected promise which awaited +// without a catch, so it surfaced as `unhandledRejection: Error: No active payment method.` +// and took the page (and the Playwright run) down instead of telling the shopper anything. +describe("AdyenPayment submit with no active payment method", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + adyen.dropinSubmit.mockReset() + }) + + it("surfaces the Adyen error instead of rejecting", async () => { + adyen.dropinSubmit.mockImplementation(() => { + throw new Error("No active payment method.") + }) + const setPaymentMethodErrors = vi.fn() + + const { container } = render( + PAYMENT_SOURCE)} + updateOrder={vi.fn()} + setPaymentMethodErrors={setPaymentMethodErrors} + > + + + ) + await flush() + + const form = container.querySelector("form") + expect(form).not.toBeNull() + + // Must not throw and must not leave a rejected promise behind. + await act(async () => { + fireEvent.submit(form as HTMLFormElement) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(setPaymentMethodErrors).toHaveBeenCalledWith([ + expect.objectContaining({ + resource: "payment_methods", + message: "No active payment method.", + }), + ]) + }) + + it("does not report an error when submit succeeds", async () => { + const setPaymentMethodErrors = vi.fn() + + const { container } = render( + PAYMENT_SOURCE)} + updateOrder={vi.fn()} + setPaymentMethodErrors={setPaymentMethodErrors} + > + + + ) + await flush() + + await act(async () => { + fireEvent.submit(container.querySelector("form") as HTMLFormElement) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(adyen.dropinSubmit).toHaveBeenCalledTimes(1) + expect(setPaymentMethodErrors).not.toHaveBeenCalled() + }) +}) + +// The reported glitch: "when the order updates the Adyen component keeps reloading". +// Re-initializing means a fresh AdyenCheckout() + new Dropin().mount(), which throws the +// shopper's selection away and re-fetches translations/analytics. An order update must not +// cause it. +describe("AdyenPayment stability across order updates", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("does not re-initialize when the payment source is recreated by an order update", async () => { + const setPaymentSource = vi.fn(async () => PAYMENT_SOURCE) + const tree = ( + // biome-ignore lint/suspicious/noExplicitAny: test cast + paymentSource: any + ) => ( + + + + ) + + const { rerender } = render(tree(PAYMENT_SOURCE)) + await flush() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + + // What does on a mismatched amount: create a brand new payment source. + // New id and new object identity, same Adyen account so the same `public_key`. + await act(async () => { + rerender(tree({ ...PAYMENT_SOURCE, id: "ps-2", mismatched_amounts: false })) + }) + await flush() + + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + expect(adyen.dropinRemove).not.toHaveBeenCalled() + }) + + it("does not re-initialize across a place-order status round trip", async () => { + const setPaymentSource = vi.fn(async () => PAYMENT_SOURCE) + // biome-ignore lint/suspicious/noExplicitAny: test cast + const tree = (placeOrderStatus: any) => ( + + + + ) + + const { rerender } = render(tree("standby")) + await flush() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + + // `status` is a dependency of the main effect, so this fires its cleanup and body again. + for (const status of ["placing", "standby"]) { + await act(async () => { + rerender(tree(status)) + }) + await flush() + } + + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + expect(adyen.dropinRemove).not.toHaveBeenCalled() + }) +}) + +// Refreshing the Drop-in once, when the order becomes partially authorized, is intended: the +// shopper now owes a smaller amount. Refreshing it repeatedly for the same authorization is +// the glitch — it discards the selection and re-fetches translations/analytics each time. +describe("AdyenPayment partial-authorization refresh happens once", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("refreshes once even if the gift card is submitted again for the same source", async () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const setPaymentSource = vi.fn(async ({ attributes }: any) => { + if (attributes?._balance) return { ...PAYMENT_SOURCE, balance: 400 } + return { ...PAYMENT_SOURCE, payment_response: {} } + }) + const updateOrder = vi.fn().mockResolvedValue({ + order: { + ...ORDER, + payment_status: "partially_authorized", + payment_source: { payment_response: { resultCode: "Authorised" } }, + }, + }) + + await act(async () => { + render( + + + + ) + }) + await flush() + + const submitOnce = async (): Promise => { + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + adyen.captured.options.onSubmit( + { data: { paymentMethod: { type: "giftcard" } }, isValid: true }, + { mount: vi.fn() }, + actions + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + } + + await submitOnce() + expect(adyen.coreUpdate).toHaveBeenCalledTimes(1) + + // Three more passes over the same authorization — repeated effect passes, a retry, an + // order refetch that lands on the same partially-authorized state. + await submitOnce() + await submitOnce() + await submitOnce() + + // Still exactly one refresh, and the Drop-in was never remounted. + expect(adyen.coreUpdate).toHaveBeenCalledTimes(1) + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + expect(adyen.dropinRemove).not.toHaveBeenCalled() + }) +}) + +// The refresh resets the Drop-in's `activePaymentMethod`, so the form genuinely is not +// submittable until the shopper picks a method again. Leaving `ref.current.onsubmit` patched +// is what let call `Dropin.submit()` on an empty Drop-in. +describe("AdyenPayment disarms the submit wiring on refresh", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("clears the payment ref when the Drop-in is refreshed", async () => { + const setPaymentRef = vi.fn() + // biome-ignore lint/suspicious/noExplicitAny: test cast + const setPaymentSource = vi.fn(async ({ attributes }: any) => { + if (attributes?._balance) return { ...PAYMENT_SOURCE, balance: 400 } + return { ...PAYMENT_SOURCE, payment_response: {} } + }) + const updateOrder = vi.fn().mockResolvedValue({ + order: { + ...ORDER, + payment_status: "partially_authorized", + payment_source: { payment_response: { resultCode: "Authorised" } }, + }, + }) + + await act(async () => { + render( + + + + ) + }) + await flush() + + const actions = { resolve: vi.fn(), reject: vi.fn() } + await act(async () => { + adyen.captured.options.onSubmit( + { data: { paymentMethod: { type: "giftcard" } }, isValid: true }, + { mount: vi.fn() }, + actions + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(adyen.coreUpdate).toHaveBeenCalledTimes(1) + expect(setPaymentRef).toHaveBeenCalledWith({ ref: { current: null } }) + }) +}) + +// The real cost of the loader swap, measured through the real : it used to +// return `loaderComponent` instead of the gateway, unmounting the Adyen Drop-in and forcing a +// full re-initialization on the way back. `status: "placing"` is one of the flips that does it. +describe("PaymentGateway keeps the Adyen Drop-in mounted across loading flips", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("does not unmount the Drop-in when the place-order status flips", async () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const order: any = { + ...ORDER, + payment_method: { id: "pm-1", payment_source_type: "adyen_payments" }, + payment_source: { id: "ps-1", mismatched_amounts: false }, + } + // biome-ignore lint/suspicious/noExplicitAny: test cast + const source: any = { ...PAYMENT_SOURCE, public_key: "test_CLIENTKEY" } + + // biome-ignore lint/suspicious/noExplicitAny: test cast + const tree = (placeOrderStatus: any) => ( + // biome-ignore lint/suspicious/noExplicitAny: test cast + + {/* biome-ignore lint/suspicious/noExplicitAny: test cast */} + + {/* biome-ignore lint/suspicious/noExplicitAny: test cast */} + + {/* biome-ignore lint/suspicious/noExplicitAny: test cast */} + + + source), + paymentSource: source, + paymentMethods: [{ id: "pm-1" }], + errors: [], + setPaymentMethodErrors: vi.fn(), + setPaymentRef: vi.fn(), + // biome-ignore lint/suspicious/noExplicitAny: test cast + } as any + } + > + {}} /> + + + + + + + ) + + const { rerender } = render(tree("standby")) + await flush() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + + // `status === "placing"` sets loading true inside PaymentGateway. + await act(async () => { + rerender(tree("placing")) + }) + await flush() + await act(async () => { + rerender(tree("standby")) + }) + await flush() + + // Never torn down, never re-initialized. + expect(adyen.dropinRemove).not.toHaveBeenCalled() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + }) +}) + +// End-to-end through the real chain: → +// . Both and implement their +// loader by replacing the subtree, so either one flipping `loading` used to tear the Drop-in +// down and re-initialize it. This is the "keeps reloading" glitch, measured where it happens. +describe("the real payment chain keeps the Adyen Drop-in mounted", () => { + beforeEach(() => { + vi.clearAllMocks() + adyen.captured.options = null + }) + + it("survives the order updates of a partial gift-card authorization", async () => { + // biome-ignore lint/suspicious/noExplicitAny: test cast + const source: any = { ...PAYMENT_SOURCE, public_key: "test_CLIENTKEY" } + const baseOrder = { + ...ORDER, + payment_method: { id: "pm-1", payment_source_type: "adyen_payments" }, + available_payment_methods: [ + { id: "pm-1", payment_source_type: "adyen_payments", name: "Adyen" }, + ], + payment_source: { id: "ps-1", mismatched_amounts: false }, + } + + // biome-ignore lint/suspicious/noExplicitAny: test cast + const tree = (order: any) => ( + // biome-ignore lint/suspicious/noExplicitAny: test cast + + {/* biome-ignore lint/suspicious/noExplicitAny: test cast */} + + {/* biome-ignore lint/suspicious/noExplicitAny: test cast */} + + {/* biome-ignore lint/suspicious/noExplicitAny: test cast */} + + source), + paymentSource: source, + paymentMethods: baseOrder.available_payment_methods, + errors: [], + setPaymentMethodErrors: vi.fn(), + setPaymentRef: vi.fn(), + setPaymentMethod: vi.fn(), + setLoading: vi.fn(), + // biome-ignore lint/suspicious/noExplicitAny: test cast + } as any + } + > + Loading}> + + + + + + + + + + ) + + const { rerender } = render(tree(baseOrder)) + await flush() + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + + // The order updates a partial gift-card authorization produces, in order: the payment + // response lands first (the balance check refetches the order), then the authorization + // flips payment_status, then the amounts read as mismatched. + const updates = [ + { ...baseOrder, payment_source: { id: "ps-1", payment_response: { status: "authorized" } } }, + { + ...baseOrder, + payment_status: "partially_authorized", + payment_source: { id: "ps-1", payment_response: { status: "authorized" } }, + }, + { + ...baseOrder, + payment_status: "partially_authorized", + payment_source: { + id: "ps-1", + mismatched_amounts: true, + payment_response: { status: "authorized" }, + }, + }, + ] + for (const order of updates) { + await act(async () => { + rerender(tree(order)) + }) + await flush() + } + + // One mount, never torn down — no matter how the order churned underneath. + expect(adyen.dropinMount).toHaveBeenCalledTimes(1) + expect(adyen.dropinRemove).not.toHaveBeenCalled() + }) +}) diff --git a/packages/react-components/src/components/payment_gateways/PaymentGateway.tsx b/packages/react-components/src/components/payment_gateways/PaymentGateway.tsx index 3eb56117..e5606529 100644 --- a/packages/react-components/src/components/payment_gateways/PaymentGateway.tsx +++ b/packages/react-components/src/components/payment_gateways/PaymentGateway.tsx @@ -70,6 +70,16 @@ export function PaymentGateway({ const paymentResource = readonly ? currentPaymentMethodType : (payment?.payment_source_type as PaymentResource) + // Returning the loader instead of the gateway (see the render below) swaps the gateway out + // rather than overlaying the loader, so every flip of `loading` unmounts it — and with it a + // mounted Adyen Drop-in, which loses the shopper's selection and re-initializes on the way + // back. + // + // While the order is partially authorized the shopper is mid-payment in that very gateway, + // so it must stay mounted. `mismatched_amounts` is also expected in that window (we + // authorized only the gift-card balance against the full total), and "healing" it by + // recreating the payment source would throw the authorization away. + const isPartiallyAuthorized = order?.payment_status === "partially_authorized" // Non-reactive reconcile pass. It reads the *latest* `order`, `config`, `paymentSource`, // etc. on every invocation, so those objects are deliberately absent from the driving @@ -148,13 +158,13 @@ export function PaymentGateway({ setPaymentSources() } // @ts-expect-error no type - if (paymentSource?.mismatched_amounts && show) { + if (paymentSource?.mismatched_amounts && show && !isPartiallyAuthorized) { setPaymentSources() } if (order?.payment_source?.id != null) { setLoading(false) } - if (!paymentSource) { + if (!paymentSource && !isPartiallyAuthorized) { setLoading(true) } } @@ -182,6 +192,9 @@ export function PaymentGateway({ order?.payment_method?.id, order?.payment_method?.payment_source_type, order?.status, + // Read by the recreate guard and the loader branch, so it has to re-fire the effect — + // otherwise the pass keeps deciding from a stale partial-authorization state. + order?.payment_status, order?.payment_source?.id, // @ts-expect-error no type order?.payment_source?.mismatched_amounts, @@ -199,12 +212,15 @@ export function PaymentGateway({ ]) useEffect(() => { - if (status === "placing") setLoading(true) + // Not while partially authorized: a place-order attempt on such an order is refused + // anyway (see ), so raising the loader here only unmounts the gateway + // the shopper still needs in order to pay the remainder. + if (status === "placing" && !isPartiallyAuthorized) setLoading(true) if (status === "standby") setLoading(false) if (order?.status === "placed") setLoading(false) // No cleanup: setLoading(true) in cleanup + loading in deps caused an infinite // toggle loop (setLoading(false) → dep change → cleanup setLoading(true) → repeat). - }, [status, order?.status]) + }, [status, order?.status, isPartiallyAuthorized]) const gatewayConfig = { readonly, @@ -220,7 +236,25 @@ export function PaymentGateway({ ...p, } if (currentPaymentMethodType !== paymentResource) return null - if (loading) return loaderComponent + // Swapping the gateway out for the loader unmounts it. For a stateless gateway that costs + // nothing; the Adyen Drop-in, though, owns imperative state — the shopper's selected method + // and typed-in details — and unmounting it destroys that and forces a full re-initialization + // (fresh AdyenCheckout(), new Dropin().mount(), translations and analytics again). + // + // Guarding individual `loading` flips is not enough: `payment_response.status` and + // `payment_status` are populated by two different API calls, so there is a window where a + // flip looks legitimate. Keeping the Drop-in mounted removes the whole class of problem — + // and it manages its own loading UI anyway. + // The Adyen Drop-in owns imperative state — the shopper's selected method and typed-in + // details — so unmounting it destroys that and forces a full re-initialization (fresh + // AdyenCheckout(), new Dropin().mount(), translations and analytics again). For a stateless + // gateway the swap costs nothing, so it is kept. + // + // Guarding individual `loading` flips is not enough here: `payment_response.status` and + // `payment_status` are populated by two different API calls, so there is a window where a + // flip looks legitimate. Keeping the Drop-in mounted removes the whole class of problem, and + // it manages its own loading UI anyway. + if (loading && paymentResource !== "adyen_payments") return loaderComponent switch (paymentResource) { case "adyen_payments": return {children} diff --git a/packages/react-components/src/components/payment_methods/PaymentMethod.tsx b/packages/react-components/src/components/payment_methods/PaymentMethod.tsx index 78fd6dca..2bba11f7 100644 --- a/packages/react-components/src/components/payment_methods/PaymentMethod.tsx +++ b/packages/react-components/src/components/payment_methods/PaymentMethod.tsx @@ -92,6 +92,8 @@ export function PaymentMethod({ const [paymentSelected, setPaymentSelected] = useState("") const [paymentSourceCreated, setPaymentSourceCreated] = useState(false) const loadingResourceRef = useRef(false) + /** Latches once the methods have rendered, so the loader can never unmount them again. */ + const hasRenderedMethodsRef = useRef(false) // Detect standalone mode: no parent has set _isProvided. const parentCtx = useContext(PaymentMethodContext) @@ -114,6 +116,12 @@ export function PaymentMethod({ const { order } = useContext(OrderContext) const { getCustomerPaymentSources } = useContext(CustomerContext) const { status } = useContext(PlaceOrderContext) + /** + * A partially-authorized order is mid-payment: part of the total is covered (an Adyen gift + * card, say) and the shopper still has to pay the remainder with another method, in the + * gateway that is already on screen. Raising the loader in that window unmounts it. + */ + const isPartiallyAuthorized = order?.payment_status === "partially_authorized" useEffect(() => { if (paymentMethods != null && !isEmpty(paymentMethods) && expressPayments) { const [paymentMethod] = getAvailableExpressPayments(paymentMethods) @@ -286,7 +294,16 @@ export function PaymentMethod({ // @ts-expect-error no type order?.payment_source?.payment_response?.status // If showLoader is undefined, we don't change the loading - if (showLoader && status) { + // + // `content` swaps the whole subtree for the loader rather than overlaying it, so raising + // `loading` here unmounts and with it any mounted Adyen Drop-in. A gift + // card authorization is exactly what populates `payment_response.status`, so without the + // partial-authorization guard this fires on the very update the shopper is mid-way + // through and reloads the Drop-in — repeatedly, as the order settles. + // + // A partially-authorized order is still mid-payment: the shopper has to cover the + // remainder in that same Drop-in, so the subtree has to stay mounted. + if (showLoader && status && !isPartiallyAuthorized) { if (status.toLowerCase() === "declined") { setLoading(false) } else { @@ -296,7 +313,7 @@ export function PaymentMethod({ setLoading(false) } // @ts-expect-error no type - }, [showLoader, order?.payment_source?.payment_response?.status]) + }, [showLoader, order?.payment_source?.payment_response?.status, isPartiallyAuthorized]) const sortedPaymentMethods = paymentMethods != null && sortBy != null ? sortPaymentMethods(paymentMethods, sortBy) @@ -359,7 +376,21 @@ export function PaymentMethod({ ) }) - const content = !loading ? <>{components} : getLoaderComponent(loader) + // Once the payment methods have rendered, never swap them back out for the loader. + // + // `content` replaces the whole subtree rather than overlaying the loader, so any later flip + // of `loading` unmounts every gateway below — including a mounted Adyen Drop-in, which owns + // the shopper's selected method and typed-in details and has to fully re-initialize on the + // way back. Guarding the individual flips cannot close this: `payment_response.status` and + // `payment_status` are populated by two different API calls, so there is a window where a + // flip looks legitimate. + // + // This makes `showLoader` mean "while first fetching the payment methods", which is what it + // documents ("Show loader while fetching payment methods"). Re-entering the loading state + // after that is the glitch, not a feature. + if (!loading) hasRenderedMethodsRef.current = true + const content = + !loading || hasRenderedMethodsRef.current ? <>{components} : getLoaderComponent(loader) // In standalone mode provide the context so that child components // (PaymentSource, PaymentGateway, etc.) can read payment state without diff --git a/packages/react-components/src/components/payment_source/AdyenPayment.tsx b/packages/react-components/src/components/payment_source/AdyenPayment.tsx index 9163ad8c..27eff579 100644 --- a/packages/react-components/src/components/payment_source/AdyenPayment.tsx +++ b/packages/react-components/src/components/payment_source/AdyenPayment.tsx @@ -8,6 +8,7 @@ import { type CoreConfiguration, Dropin, type DropinConfiguration, + type ICore, type OnChangeData, type PayPalConfiguration, type SubmitData, @@ -143,6 +144,31 @@ export function AdyenPayment({ const { customers } = useContext(CustomerContext) const ref = useRef(null) const dropinRef = useRef(null) + // The Core instance, kept alongside the Drop-in: refreshing the amount after a partial + // authorization goes through Core, not the Drop-in. See the `onSubmit` handler below. + const checkoutRef = useRef(null) + // Latches the partial-authorization refresh: refreshing the Drop-in once, when the order + // becomes partially authorized, is intended — doing it again for the same authorization is + // the glitch. Keyed by payment source id so a genuinely new source can refresh again. + const refreshedForSourceRef = useRef(null) + + // Tear the Adyen instance down on real unmount only, and clear the refs so a remounted + // component can initialize a fresh one (the init guard below is `!dropinRef.current`, so a + // leftover reference would leave the component wired to a destroyed Drop-in forever). + // + // Deliberately its own mount-scoped effect: the main effect below re-runs whenever the + // place-order `status` changes, and destroying the Drop-in on those passes would throw the + // shopper's selection away mid-checkout. + useEffect(() => { + return () => { + // `remove()` rather than `unmount()`: Adyen documents it as the "destroy" cleanup — it + // unmounts the element *and* drops it from `core.components`, so Core stops holding a + // reference to a dead element (which `triggerAmountUpdate()` would otherwise iterate). + dropinRef.current?.remove() + dropinRef.current = null + checkoutRef.current = null + } + }, []) const handleSubmit = async (e: FormEvent): Promise => { const savePaymentSourceToCustomerWallet: string = // @ts-expect-error no type @@ -153,7 +179,25 @@ export function AdyenPayment({ savePaymentSourceToCustomerWallet ) if (dropinRef.current) { - dropinRef.current.submit() + // `Dropin.submit()` throws synchronously when it has no `activePaymentMethod` — e.g. + // the shopper has not picked a method, or the Drop-in was re-rendered and lost the + // selection while `ref.current.onsubmit` stayed patched from an earlier `onChange`. + // Because this function is async the throw became a rejected promise that + // awaited without a catch, surfacing as an unhandledRejection that + // takes the page (and the e2e run) down instead of telling the shopper anything. + try { + dropinRef.current.submit() + } catch (error) { + setPaymentMethodErrors([ + { + code: "VALIDATION_ERROR", + resource: "payment_methods", + field: currentPaymentMethodType, + message: error instanceof Error ? error.message : String(error), + }, + ]) + return false + } } return false } @@ -233,6 +277,8 @@ export function AdyenPayment({ paymentMethodType?: string message?: string paymentStatus?: Order["payment_status"] + /** Still to be covered by another payment method, in Adyen's `{ currency, value }` shape. */ + remainingAmount?: { currency: string; value: number } } > => { const url = cleanUrlBy() @@ -360,11 +406,36 @@ export function AdyenPayment({ message, } } + // What the shopper still has to cover with another method, in Adyen's + // `{ currency, value }` shape. + // + // Do NOT derive this from `gift_card_amount_cents`: that field is the sum of the + // Commerce Layer `gift_card` resources applied to the order, and an Adyen gift card + // authorized through `_authorization_amount_cents` never creates one — it stays 0, + // so the subtraction would hand the Drop-in back the full total. + // + // Adyen's own `remainingAmount` is authoritative when the account uses the + // partial-payments order flow (it already nets off every card authorized so far); + // otherwise fall back to what we just authorized ourselves: `currentBalance`, the + // amount sent as `_authorization_amount_cents` above. + const adyenRemainingAmount = + // @ts-expect-error no type + orderUpdated?.payment_source?.payment_response?.order?.remainingAmount + const currency = orderUpdated?.currency_code ?? order?.currency_code + const remainingValue = + typeof adyenRemainingAmount?.value === "number" + ? adyenRemainingAmount.value + : Math.max(totalAmount - currentBalance, 0) return { resultCode: "Authorised", paymentMethodType: currentPaymentMethodType, action, paymentStatus, + // Adyen validates the amount and silently cancels the update on an empty + // currency, so only report one when the currency is actually known. + ...(currency != null && remainingValue > 0 + ? { remainingAmount: { currency, value: remainingValue } } + : {}), } } const res = await setPaymentSource({ @@ -532,7 +603,10 @@ export function AdyenPayment({ }, onSubmit: (state, element, actions) => { const handleSubmit = async (): Promise => { - const { resultCode, action, message, paymentStatus } = await onSubmit(state, element) + const { resultCode, action, message, paymentStatus, remainingAmount } = await onSubmit( + state, + element + ) if (["Cancelled", "Refused"].includes(resultCode)) { actions.reject() if (message) { @@ -544,8 +618,37 @@ export function AdyenPayment({ actions.resolve({ resultCode, }) - if (paymentStatus === "partially_authorized") { - dropinRef.current?.mount("#adyen-dropin") + const refreshKey = paymentSource?.id ?? "unknown" + if ( + paymentStatus === "partially_authorized" && + remainingAmount != null && + refreshedForSourceRef.current !== refreshKey + ) { + refreshedForSourceRef.current = refreshKey + // Refresh the Drop-in for the reduced amount. `shouldReinitializeCheckout: true` + // makes Core `setOptions(amount)`, re-`initialize()`, then `update()` every + // mounted component — and `BaseElement.update()` is `state = {}` plus + // `unmount().mount(this._node)`, i.e. a real refresh in place, with the payment + // method list consistent with what is left to pay. + // + // This replaces `dropinRef.current?.mount("#adyen-dropin")`, which re-rendered + // the Drop-in with the *old* amount: same lost selection, none of the benefit. + // + // Latched above, so it happens once per authorization and not on every pass. + checkoutRef.current?.update( + { amount: remainingAmount }, + { shouldReinitializeCheckout: true } + ) + // The refresh resets `activePaymentMethod`, so the form is genuinely not + // submittable until the shopper picks a method again — at which point + // `handleChange` re-patches `ref.current.onsubmit` and re-arms the ref. Dropping + // it here keeps honest; leaving it patched is what let it + // call `Dropin.submit()` on an empty Drop-in and throw "No active payment + // method.". + if (ref.current != null) { + ref.current.onsubmit = null + } + setPaymentRef({ ref: { current: null } }) } setGiftcardError(null) } @@ -557,6 +660,7 @@ export function AdyenPayment({ if (clientKey && !loadAdyen && window && !checkout) { const initializeAdyen = async (): Promise => { const checkout = await AdyenCheckout(options) + checkoutRef.current = checkout const dropin = new Dropin(checkout, { disableFinalAnimation: true, showRemovePaymentMethodButton: showStoredPaymentMethods,