Skip to content

Refactor payment methods: standalone components without deprecated containers#796

Open
acasazza wants to merge 36 commits into
v5.0.0from
refactor/payment-standalone-components
Open

Refactor payment methods: standalone components without deprecated containers#796
acasazza wants to merge 36 commits into
v5.0.0from
refactor/payment-standalone-components

Conversation

@acasazza

@acasazza acasazza commented Jun 9, 2026

Copy link
Copy Markdown
Member

Closes #795

Changes

Phase 1 — PaymentMethod standalone component

  • PaymentMethod now works without PaymentMethodsContainer by using the new usePaymentMethod hook internally
  • Detects standalone vs container mode via _isProvided sentinel on PaymentMethodContext
  • Replaced module-level loadingResource variable with useRef to properly scope state per instance

PaymentMethodsContainer deprecated

  • Kept for backward compatibility, marked as deprecated
  • Sets _isProvided: true on context to signal container mode to PaymentMethod

New hook: usePaymentMethod

  • Encapsulates all payment method state management for standalone mode
  • Handles order fetching, payment method selection, and payment source setup

Tests (Phase 1)

  • PaymentMethod.tsx: 100% statements, 99.21% branches (1 unreachable null branch)
  • PaymentMethodsContainer.tsx: 100% statements, 100% branches

Phase 2 — PlaceOrderButton, PlaceOrderContainer, PrivacyAndTermsCheckbox standalone

PlaceOrderButton standalone

  • Works without PlaceOrderContainer wrapper — detects standalone mode via _isProvided sentinel
  • Accepts options prop directly (previously owned by PlaceOrderContainer)
  • Calls usePlaceOrder hook to manage state in standalone mode

PrivacyAndTermsCheckbox standalone

  • Works without PlaceOrderContainer wrapper
  • In standalone mode, dispatches cl:placeorder:recheck DOM event so sibling PlaceOrderButton can re-run permission checks without a common parent

PlaceOrderContainer deprecated

  • Marked with @deprecated JSDoc, kept for backward compatibility
  • Sets _isProvided: true on context to signal container mode

New hook: usePlaceOrder

  • Encapsulates place-order state management for standalone mode
  • Registers includes for shipments, billing and shipping addresses
  • Listens for cl:placeorder:recheck events from PrivacyAndTermsCheckbox
  • Exports PLACE_ORDER_RECHECK_EVENT constant for sibling communication

Tests (Phase 2 — 100% coverage)

  • PlaceOrderContainer.tsx: 100% statements, 100% branches
  • PrivacyAndTermsCheckbox.tsx: 100% statements, 100% branches
  • usePlaceOrder.ts: 100% statements, 100% branches
  • PlaceOrderButton.tsx: ~60% (payment-gateway redirect flows require full integration mocks — covered in a future pass)
  • 55 tests total in specs/orders/place-order.spec.tsx

CI fixes

  • pgk-pr-new.yaml: fixed pnpm build → per-package filter command
  • Removed react-doctor.yml workflow

Alessandro Casazza and others added 25 commits June 5, 2026 16:43
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add overrides for fast-uri, @babel/plugin-transform-modules-systemjs,
  js-cookie, postcss, qs, uuid, axios, vitest, handlebars, lodash
- Fix msgpackr-extract allowBuilds placeholder
- Result: 0 high/critical vulnerabilities (5 moderate devDeps only)
- Extract getFormElement + shared types to addressFormUtils.ts
- Extract standalone address reducer/context to useStandaloneAddress hook
- Extract form field effects (validation, reset, checkbox, includes) to useAddressFormFields hook
- Slim BillingAddressForm: 416 → 113 lines (73% reduction)
- Slim ShippingAddressForm: 423 → 115 lines (73% reduction)
- Add unit tests: saveOrderAddresses (15), useAddressForm (12), standalone form modes (+19)
…te loop

BillingAddressForm and ShippingAddressForm were wrapping the memoized
setValue from useAddressFormFields in a new arrow function on every
render. This created a new function reference each cycle, which
AddressInput detected as a dependency change in its useEffect, triggering
setState, which caused the 'Maximum update depth exceeded' loop.

Pass the memoized setValue directly to the context provider instead.
Add stability regression test to BillingAddressForm spec.
…r edit

When editing an existing address, AddressInput calls setValue for each
field individually. Previously, each call dispatched setAddress with only
that single field, replacing the entire billing_address/shipping_address
in the reducer state (each dispatch lost all previously set fields).

Two fixes in useAddressFormFields:
- setValue now reads ALL form inputs before dispatching so each call
  accumulates the full address instead of overwriting it.
- The main validation effect now supplements rapid-form tracked values
  (required fields only) with non-required fields read from the DOM,
  so optional fields like phone/line_2 are preserved when the user edits
  any required field.
…ptures programmatic updates

rapid-form attaches 'input' event listeners (not 'change') to required form
elements. setValue and resetField were dispatching 'change', which meant
rapid-form never updated its internal formValues when a value was set
programmatically (e.g., pre-filling from an existing address).

This caused AddressStateSelector to read an empty country code from
billingAddress.values and fall back to a plain text input instead of
showing the state/province select.

Fix: dispatch 'input' in both setValue and resetField.

Also adds an integration test proving that ctx.values is updated after
setValue('billing_address_country_code', 'US').
…ropdown

The BaseSelect (rendered when a country with states is selected) had no
onChange handler, so picking a state from the dropdown never updated val
or called billingAddress.setValue / shippingAddress.setValue. The state_code
was therefore never written into the address context.

Added onChange handler mirroring the existing BaseInput handler.
Added regression test covering this path.
…dressStateSelector

When editing an existing address, the country code is pre-filled via setValue,
which triggers changeBillingCountry=true (countryCode was '' → 'US'). The state
written into the form context.

Fix: distinguish initial country detection (countryCode was empty) from a
user-initiated country change. On initial detection, pre-fill the state from
the value prop. On user-initiated change, reset the state only if the current
value is invalid for the new country (existing behavior, now guarded by

Added regression test.
…d in AddressStateSelector

BaseSelect uses defaultValue (uncontrolled), so val must be correct at the
exact moment the select first mounts. If no value prop is passed to
AddressStateSelector but the text input was pre-filled externally via a
billingAddress.setValue call (e.g. from AddressInput), val remained '' and
the select showed empty.

Fix: add a ref to the BaseInput so its current DOM value can be read when
the country is first detected (isFirstCountryDetection). The state value
is resolved in priority order: value prop > DOM text input value > ''.

This handles two pre-fill scenarios:
1. Explicit value prop (e.g. value={existingAddress.state_code})
2. External setValue call setting the DOM value before country arrives

Added regression tests for both scenarios.
…ingAddressFormContext so AddressStateSelector can detect country regardless of required attribute

AddressStateSelector watches billingAddress.values[country_key] to detect
the country and load state options. billingAddress.values was only
rapid-form's formValues, which only tracks required fields.

If the country input was not required (or used without rapid-form
capturing the input event), formValues never got billing_address_country_code,
countryCode stayed '' and the Italian (or other) state options never loaded.

Fix: BillingAddressForm and ShippingAddressForm now merge the address reducer
values (which are always updated by setValue via setAddress, regardless of
the required attribute) into the form context values alongside rapid-form's
tracked values. Rapid-form values (Value objects) take precedence.

AddressStateSelector already handles both formats:
  typeof v === 'string' ? v : v?.value
- Add errorMode='inline'|'submit' prop to both address form components
- 'inline' (default): errors shown as user types (existing behavior)
- 'submit': errors suppressed until SaveAddressesButton is clicked;
  after first validation, errors update live as user corrects fields
- Add validate() function exposed via form context for manual triggering
- SaveAddressesButton calls validate() before saving when errorMode='submit'
- Export ErrorMode type from BillingAddressFormContext
- Add 10 tests covering new behavior
- Add test for line 117 false branch: billing context with no setValue,
  value prop changes while internal val differs (typed input)
- Refactor lines 126 and 160 to split the unreachable ?? "" fallback
  onto a separate line with v8 ignore, making branch coverage trackable
- Both rawStateValue expressions (billing and shipping) are covered by
  existing pre-fill and external-setValue tests
When useShipments returns a new shipments array reference on every render
(e.g. due to unstable hook args), the useEffect would re-run on every
render and call setErrors() which triggered another re-render, causing
'Maximum update depth exceeded'.

Two root causes fixed:
1. Cleanup function setErrors([]) ran before every effect re-execution,
   scheduling an extra state update that compounded the loop.
2. setErrors(nextErrors) always scheduled a state update even when the
   computed errors were identical to the previous value.

Fix: remove the cleanup (errors are always recomputed from current deps),
and use a functional setErrors updater that returns prev unchanged when
error codes have not changed, breaking the re-render cycle.
Shipment.tsx listed setShippingMethod in its useEffect deps, but
setShippingMethod was an inline async function recreated on every render
of Shipments.tsx. Each re-render of Shipments provided a new function
reference via ShipmentContext, causing Shipment's useEffect to re-run,
which triggered the cleanup setLoading(true) + setLoading(false),
re-rendering Shipment and consuming the changed context again — infinite
'Maximum update depth exceeded' loop.

Two root causes fixed:

Shipments.tsx:
- Wrap setShippingMethod with useCallback so its reference is stable
  across re-renders (only changes when order/orderId/getOrder change)
- Wrap setShipmentErrors with useCallback for the same reason
- Memoize contextValue with useMemo to prevent all ShipmentContext
  consumers from re-rendering on every Shipments render

Shipment.tsx:
- Remove cleanup setLoading(true): same spurious-state-update pattern
  as the previous Shipments.tsx fix; the new effect always sets the
  correct loading state without needing a reset on cleanup
- Remove redundant shipments?.length dep (shipments already covers it)
After calling setShippingMethod, the order updates in OrderContext
which recreates the setShippingMethod callback (it depends on order).
The new reference triggered the autoSelect useEffect to re-run before
SWR refetches shipments, so shipping_method still appeared null and
setShippingMethod was called again — infinite loop.

- Shipment: access setShippingMethod via a ref so it is not a dep of
  the autoSelect effect; effect now re-runs only when shipments or
  autoSelectSingleShippingMethod changes
- Shipment: extract ShipmentItem component that memoises the
  ShipmentChildrenContext value, stabilising deliveryLeadTimes for
  ShippingMethod and preventing unnecessary consumer re-renders
- ShippingMethod: remove setItems([]) cleanup; with stable context
  deps the cleanup is never needed and caused extra renders
The second useEffect had 'loading' in its deps and called setLoading
inside the body, while the cleanup always reset setLoading(true).
This created an infinite toggle loop:
  setLoading(false) → loading dep changes → cleanup setLoading(true)
  → loading dep changes → setLoading(false) → repeat.

- Remove 'loading' from second effect deps (React bails out on same
  primitive value, so the && loading guards were unnecessary)
- Remove 'order' from second effect deps in favour of order?.status
- Remove both cleanup functions (same pattern fixed in Shipments,
  Shipment and ShippingMethod)
getPayMethods was defined as an inline async function inside the
component body, making it a new reference on every render. Because it
was listed as a useEffect dependency, the effect re-ran on every render.
While state.paymentMethods was still unset (between async dispatches
true and getPaymentMethods was called again — infinite loop.

- Remove the getPayMethods wrapper function entirely
- Call getPaymentMethods directly inside the effect
- Remove getPayMethods from the dependency array
fetchOrder(state.order) was called inside useMemo, which runs during
the render phase. When fetchOrder updated external state (e.g. AppProvider),
React logged: 'Cannot update a component while rendering a different
component'.

- Move the fetchOrder call into a useEffect with [fetchOrder, state.order] deps
- Remove fetchOrder from useMemo deps (it is no longer referenced inside it)
Introduces usePaymentMethod hook that encapsulates the reducer, includes
setup, and payment method fetching currently owned by PaymentMethodsContainer.

PaymentMethod now works in two modes:
- Standalone (no container parent): calls usePaymentMethod internally and
  wraps its children with PaymentMethodContext.Provider — no container needed.
  Pass gateway config via the new config prop on PaymentMethod.
- Container mode (existing): detects the parent PaymentMethodsContainer via
  the new _isProvided marker on PaymentMethodContext and uses it as before.

PaymentMethodsContainer is kept for backwards compatibility with a
@deprecated JSDoc notice; it will be removed in the next major version.

Closes #795 (partial — payment_gateways and payment_source still pending)
PaymentMethodsContainer (backward compat):
- renders children, provides _isProvided in context
- populates paymentMethods from order, calls addResourceToInclude

PaymentMethod standalone mode:
- detects standalone via _isProvided absence, provides context to children
- populates paymentMethods, calls addResourceToInclude on mount
- skips addResourceToInclude when includes already loaded
- renders one div per payment method after effects settle

PaymentMethod container mode:
- reads paymentMethods from parent container context
- standalone hook does not call addResourceToInclude when isStandalone=false
- _isProvided preserved through the tree

Regression: does not trigger 'Maximum update depth exceeded'
…ner and usePaymentMethod

- Refactor PaymentMethod.tsx: replace module-level loadingResource variable
  with useRef to properly scope per component instance
- Add comprehensive tests covering all uncovered branches and statements:
  expressPayments flow, autoSelect paths, clickableContainer, hide/sortBy
  props, showLoader effects, config prop, getOrder fallback call
- Mock @commercelayer/core with importOriginal to preserve existing exports
- Add MockPaymentMethodProvider helper for isolated effect testing
- PaymentMethod.tsx: 100% statements, 99.21% branches
- PaymentMethodsContainer.tsx: 100% statements, 100% branches
- usePaymentMethod.ts: 100% statements, 100% branches
@acasazza acasazza self-assigned this Jun 9, 2026
- Remove react-doctor.yml CI workflow
- Fix pgk-pr-new.yaml: replace 'pnpm build' (no root script) with
  explicit filter for the three published packages
@pkg-pr-new

pkg-pr-new Bot commented Jun 9, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/commercelayer/commercelayer-react-components/@commercelayer/core@796
npm i https://pkg.pr.new/commercelayer/commercelayer-react-components/@commercelayer/hooks@796
npm i https://pkg.pr.new/commercelayer/commercelayer-react-components/@commercelayer/react-components@796

commit: 4e1e1de

@commercelayer commercelayer deleted a comment from github-actions Bot Jun 9, 2026
Alessandro Casazza added 10 commits June 10, 2026 10:19
…rms not accepted

The second useEffect in PlaceOrderButton called setNotPermitted(false) unconditionally
when paymentMethodErrors/errors cleared, ignoring isPermitted. This meant filling in
payment data (which clears prior errors) would enable the button even if the
PrivacyAndTermsCheckbox was not checked.

Fix: guard the re-enable path with isPermitted so clearing errors only enables the button
when all other conditions (privacy/terms accepted, billing/shipping address, etc.) are
also satisfied.
…rs clear

The previous fix introduced a regression: the error-clearing useEffect called
setNotPermitted(false) whenever isPermitted=true, but isPermitted only checks if
payment_method.id is set — it does not check whether the payment source (card details)
is actually filled. This caused the button to enable as soon as errors cleared, even
without card details entered.

Fix: introduce a hasBlockingErrors state. The error effect only manages this flag
(and loading/forceDisable side-effects). The first payment effect now reacts to
hasBlockingErrors changes — so when errors clear, it re-runs its full check including
the card.brand and onsubmit conditions before enabling the button.
…tainer to prevent infinite re-render loop

Inline functions inside useMemo get a new reference on every recompute.
Payment forms (StripePaymentForm, BraintreePayment, KlarnaPayment, etc.)
include setPaymentRef in their useEffect deps — when this changes on every
render the effect re-runs, calling setPaymentRef again, which mutates state,
which triggers useMemo to recompute, causing an infinite loop.

Fix: extract setLoading, setPaymentRef and setPaymentMethodErrors as stable
useCallback(() => {}, []) callbacks. dispatch from useReducer is guaranteed
stable so empty deps are correct. This matches the pattern already used in
usePaymentMethod (standalone mode), where setPaymentRefCallback is useCallback.

ExternalPayment.tsx had an explicit biome-ignore to work around this bug;
StripePaymentForm, BraintreePayment, KlarnaPayment and CheckoutComPayment
did not and were all susceptible to the infinite loop in container mode.
The status useEffect had a default case calling setNotPermitted(false) on
every mount, running after the payment check effect and unconditionally
enabling the button regardless of payment state.

Root cause: dep was [status != null] — always true, so the effect only
ran once on mount. With status='standby' (initial state) it hit the
default case, overrode the payment check, and left the button enabled.
For orders without a payment method, isPermitted stayed false and the
payment check effect never re-ran to correct this.

Fix: remove the default case — the payment check effect is the sole
authority for enabling the button. Change dep to [status] so the effect
re-runs on actual status changes (e.g. 'standby' -> 'disabled').

Also update the handleClick tests: they previously relied on the buggy
status effect to enable the button for clicking. Now they correctly mock
getCardDetails to return a brand (matching real user state), add
paymentType to contexts that were missing it, and align
currentPaymentMethodType in Providers with the paymentType under test.
BaseSelect used an uncontrolled <select> (defaultValue) which only
applied on the initial mount. When the value prop changed externally
(e.g. from a country to empty), React had no mechanism to update the
DOM element and the old selection persisted.

Switch BaseSelect to a controlled component: maintain localValue state
synced via useEffect([value]), and expose onChange to callers. This
ensures the select always reflects the current value prop.

Also remove the truthy guard in AddressCountrySelector's useEffect so
setValue is called even when value is falsy, resetting the form element
to the placeholder in the browser/form scenario.
Calling setValue(name, '') unconditionally on mount would cause rapid-form
to process the empty required field immediately, potentially triggering
premature validation errors. The controlled BaseSelect already handles
the visual reset via localValue state — no manual setValue needed for
the placeholder case.
When order.billing_address?.country_code is null (SDK returns null for
unset fields), the destructuring default '' does not apply (it only
applies for undefined). This caused localValue=null which renders
<select value={null}>, potentially showing the first option instead of
the placeholder.

Apply nullish coalescing (value ?? '') on both useState initializer and
useEffect sync so null is treated identically to undefined: both result
in the placeholder being selected.
…ween null/undefined

The useEffect approach had two problems:
1. When value prop changed from null to undefined (or vice versa), the
   effect fired and reset the user's selection to '' — both are the same
   semantic 'no value' but have different JS identities.
2. The effect ran after the commit phase, causing a one-frame flicker
   where the wrong option was briefly visible.

Switch to the React derived-state pattern: update localValue synchronously
during render when safeValue (value ?? '') differs from the last synced
value. This ensures:
- null/undefined oscillation does NOT reset the user's own selection
- value=null -> value='IT' (order pre-fill) correctly shows Italy
- value='IT' -> value='' (reset) correctly shows placeholder
- User selecting a country while value stays null is preserved
Switch from controlled select (value + useState derived-state) back to
an uncontrolled approach (defaultValue) combined with useLayoutEffect to
imperatively update the DOM when the external value prop genuinely changes.

The controlled approach caused the selected label to not update when the
user changed the option while the parent held a stable pre-filled value
prop — React would revert the DOM back to localValue on every render
cycle before the user's pick could be reflected, or browser-native
autofill/form behaviour conflicted with the React-managed value.

With the uncontrolled approach:
- defaultValue sets the correct initial option (placeholder or pre-fill).
- useLayoutEffect fires before paint and sets select.value imperatively
  only when the external safeValue actually changes, so user-driven
  changes are never silently reset.
- null/undefined are normalised to "" so the placeholder option is
  always visible when no value is provided by the SDK.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant