Skip to content

Stop the Drop-in reloading on every order update - #807

Open
acasazza wants to merge 1 commit into
v5.0.0from
fix/adyen-dropin-reload-partial-authorization
Open

Stop the Drop-in reloading on every order update#807
acasazza wants to merge 1 commit into
v5.0.0from
fix/adyen-dropin-reload-partial-authorization

Conversation

@acasazza

Copy link
Copy Markdown
Member

The bug

An Adyen gift card covering only part of the order left the Drop-in reloading repeatedly ("as if the Adyen component kept reloading"), and a Place Order click could take the page — and the Playwright run — down with:

unhandledRejection: Error: No active payment method.
  at handleSubmit (AdyenPayment.tsx)
  at ref.current.onsubmit
  at handleClick (PlaceOrderButton.tsx)

Root cause

Both parents implement their loader by replacing the subtree rather than overlaying it:

// PaymentMethod.tsx
const content = !loading ? <>{components}</> : getLoaderComponent(loader)
// PaymentGateway.tsx
if (loading) return loaderComponent

To React those are different trees in the same position, so every flip of loading unmounts every gateway below. For a stateless gateway that costs nothing. The Adyen Drop-in, though, owns imperative state — the shopper's selected method and typed-in card details — so it was destroyed and fully re-initialized (fresh AdyenCheckout(), new Dropin().mount(), translations and analytics again) on each flip.

Guarding the individual flips cannot close this: payment_source.payment_response.status and order.payment_status are populated by two different API calls (the gift-card balance check refetches the order; the authorization flips the payment status), so there is a window where a flip looks legitimate.

Dropin.submit() then threw because mount() had reset activePaymentMethod while the patched ref.current.onsubmit survived, leaving <PlaceOrderButton> believing it could submit.

The fix

File Change
PaymentGateway.tsx the adyen_payments branch is no longer swapped out for the loader; isPartiallyAuthorized guards on the loader flips and on recreating the payment source
PaymentMethod.tsx once the methods have rendered they are never swapped back out for the loader
AdyenPayment.tsx one intentional refresh via Core.update(); checkoutRef + latch; remove() on unmount; try/catch around submit(); submit wiring disarmed on refresh

The intentional refresh is kept — refreshing once when the order becomes partially authorized is correct, since the shopper now owes less — but it happens once:

checkoutRef.current?.update({ amount: remainingAmount }, { shouldReinitializeCheckout: true })

Verified against the shipped adyen-web@6.41.0: with true, Core does setOptions(amount)initialize()update() on each mounted component, and BaseElement.update() is state = {} plus unmount().mount(this._node) — a real refresh in place, with the payment method list consistent with what is left to pay. It replaces dropinRef.current.mount("#adyen-dropin"), which re-rendered the Drop-in with the old amount: same lost selection, none of the benefit.

The remaining amount is not gift_card_amount_cents

That field is "the sum of all the gift_cards applied to the order" — Commerce Layer gift_card resources. An Adyen gift card authorized through _authorization_amount_cents is a payment-source authorization and never creates one, so the field stays 0 and total_amount_with_taxes_cents - gift_card_amount_cents silently evaluates to the full total. Adyen's own payment_response.order.remainingAmount is preferred, falling back to total - currentBalance. Reported as { currency, value } and only when the currency is known: triggerAmountUpdate() gates on isAmountValid, which rejects an empty currency with nothing but a console.warn.

Behaviour changes to validate

  • showLoader now means "while first fetching the payment methods", matching its documented description. It no longer re-enters the loading state after the first render.
  • Place Order goes back to disabled right after the gift card is applied, until the shopper picks a method again. This is the logical consequence of a refresh resetting activePaymentMethod, and it prevents the crash at the source rather than only reporting it.

Both touch PaymentMethod/PaymentGateway, which every gateway shares, so the other payments-*.spec.ts suites are worth a run.

Testing

specs/payment_source/AdyenPayment.spec.tsx — 13 tests, mocking @adyen/adyen-web/auto and driving the real onSubmit handler the component installs. The important one renders the real chainPaymentMethodPaymentSourcePaymentGatewayAdyenGatewayAdyenPayment — and pushes through the order updates a partial authorization actually produces, in order, asserting one mount() and no remove().

Each fix was confirmed load-bearing by reverting the source line and watching the test fail:

Reverted Failure
PaymentMethod latch expected 1 times, but got 2 times
PaymentGateway adyen branch expected "remove" to not be called, but was called 1 times
refresh latch expected 1 times, but got 4 times
whole AdyenPayment change reproduces Unhandled Rejection: Error: No active payment method.
  • vitest run — 75 files, 794 tests green
  • biome lint ./src --max-diagnostics=300 — 97 warnings, 0 errors, identical to the baseline on v5.0.0
  • tsc --noEmit — no errors in the changed files (35 pre-existing elsewhere)
  • husky pre-commit hook green (workspace build + lint + suite)
  • payments-adyen-givex.spec.ts passes locally against a linked build

Still open

The loader-replaces-subtree pattern remains for the other gateways, and docs/adr/0001-payment-source-effect-invariants.md should probably gain an invariant for it — happy to add that here or in a follow-up.

🤖 Generated with Claude Code

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 `<PaymentMethod>` and `<PaymentGateway>` 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 `<PlaceOrderButton>` 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.
@acasazza acasazza self-assigned this Jul 31, 2026
@acasazza acasazza added the bug Something isn't working label Jul 31, 2026
@acasazza
acasazza requested review from Copilot, gciotola and malessani and removed request for Copilot July 31, 2026 19:54
@malessani malessani changed the title fix(adyen): stop the Drop-in reloading on every order update Stop the Drop-in reloading on every order update Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants