Stop the Drop-in reloading on every order update - #807
Open
acasazza wants to merge 1 commit into
Open
Conversation
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.
gciotola
approved these changes
Aug 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Root cause
Both parents implement their loader by replacing the subtree rather than overlaying it:
To React those are different trees in the same position, so every flip of
loadingunmounts 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 (freshAdyenCheckout(), newDropin().mount(), translations and analytics again) on each flip.Guarding the individual flips cannot close this:
payment_source.payment_response.statusandorder.payment_statusare 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 becausemount()had resetactivePaymentMethodwhile the patchedref.current.onsubmitsurvived, leaving<PlaceOrderButton>believing it could submit.The fix
PaymentGateway.tsxadyen_paymentsbranch is no longer swapped out for the loader;isPartiallyAuthorizedguards on the loader flips and on recreating the payment sourcePaymentMethod.tsxAdyenPayment.tsxCore.update();checkoutRef+ latch;remove()on unmount;try/catcharoundsubmit(); submit wiring disarmed on refreshThe intentional refresh is kept — refreshing once when the order becomes partially authorized is correct, since the shopper now owes less — but it happens once:
Verified against the shipped
adyen-web@6.41.0: withtrue, Core doessetOptions(amount)→initialize()→update()on each mounted component, andBaseElement.update()isstate = {}plusunmount().mount(this._node)— a real refresh in place, with the payment method list consistent with what is left to pay. It replacesdropinRef.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_centsThat field is "the sum of all the gift_cards applied to the order" — Commerce Layer
gift_cardresources. An Adyen gift card authorized through_authorization_amount_centsis a payment-source authorization and never creates one, so the field stays0andtotal_amount_with_taxes_cents - gift_card_amount_centssilently evaluates to the full total. Adyen's ownpayment_response.order.remainingAmountis preferred, falling back tototal - currentBalance. Reported as{ currency, value }and only when the currency is known:triggerAmountUpdate()gates onisAmountValid, which rejects an empty currency with nothing but aconsole.warn.Behaviour changes to validate
showLoadernow means "while first fetching the payment methods", matching its documented description. It no longer re-enters the loading state after the first render.activePaymentMethod, and it prevents the crash at the source rather than only reporting it.Both touch
PaymentMethod/PaymentGateway, which every gateway shares, so the otherpayments-*.spec.tssuites are worth a run.Testing
specs/payment_source/AdyenPayment.spec.tsx— 13 tests, mocking@adyen/adyen-web/autoand driving the realonSubmithandler the component installs. The important one renders the real chain —PaymentMethod→PaymentSource→PaymentGateway→AdyenGateway→AdyenPayment— and pushes through the order updates a partial authorization actually produces, in order, asserting onemount()and noremove().Each fix was confirmed load-bearing by reverting the source line and watching the test fail:
PaymentMethodlatchexpected 1 times, but got 2 timesPaymentGatewayadyen branchexpected "remove" to not be called, but was called 1 timesexpected 1 times, but got 4 timesAdyenPaymentchangeUnhandled Rejection: Error: No active payment method.vitest run— 75 files, 794 tests greenbiome lint ./src --max-diagnostics=300— 97 warnings, 0 errors, identical to the baseline onv5.0.0tsc --noEmit— no errors in the changed files (35 pre-existing elsewhere)payments-adyen-givex.spec.tspasses locally against a linked buildStill open
The loader-replaces-subtree pattern remains for the other gateways, and
docs/adr/0001-payment-source-effect-invariants.mdshould probably gain an invariant for it — happy to add that here or in a follow-up.🤖 Generated with Claude Code