Skip to content

feat: added support for separate card fields - #18

Merged
ArushKapoorJuspay merged 4 commits into
mainfrom
feat/separate-card-fields
Sep 3, 2026
Merged

ArushKapoorJuspay merged 4 commits into
mainfrom
feat/separate-card-fields

Conversation

@ArushKapoorJuspay

@ArushKapoorJuspay ArushKapoorJuspay commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Type of Change

  • Bugfix
  • New feature
  • Enhancement
  • Refactoring
  • Dependency updates
  • Documentation
  • CI/CD

Description

Adds React support for the Web SDK's separate card fields — card number, expiry and CVC as three independently positioned elements instead of one bundled card element, with card data staying inside Hyperswitch-controlled iframes.

Depends on the Web SDK feature landing in juspay/hyperswitch-web#1742. This PR is the React wrapper for it and adds no behaviour of its own beyond React lifecycle management.

Both SDK surfaces are covered by one set of components:

Surface Provider Settles with
Payments <HyperElements> (existing) confirmPayment()
Vault <HyperPaymentMethodsSession> (new) tokenize()

A single <CardForm> serves both, reading its surface from the provider it sits under. This mirrors the SDK, where the accessor is cardForm() on both hyper.widgets(...) and hyper.paymentMethodsSession(...) and only the settle method differs.

Naming

The package already exports CardNumberElement / CardNumberWidget and friends for the older bundled element. The new separate fields are therefore CardNumberField, CardExpiryField and CardCvcField, with CardCVCField aliased to CardCvcField to match the existing CardCVCElement casing — the same aliasing the package already does for Element / Widget pairs.

Component tree

<HyperElements hyper options>          <HyperPaymentMethodsSession hyper options>
          │                                          │
          └────────────────┬─────────────────────────┘
                           ▼
                    <CardForm ref>
      ref → confirmPayment() · tokenize() · update · deinit · on · getFields
                           │
                           │  Context.cardFormContext
                           ▼
        <CardNumberField> · <CardExpiryField> · <CardCvcField>
                ref → mount/unmount/destroy/update/focus/blur/clear

Everything below the provider is surface-agnostic: one <CardForm> and one set of field components serve payments, vault, and saved-card CVC recollect.

Files

Addedsrc/components/: CardForm.res, HyperPaymentMethodsSession.res, CardFieldWrapper.res, CardNumberField.res, CardExpiryField.res, CardCvcField.res (plus generated .bs.js).

Modifiedsrc/OrcaJs.res (bindings for fieldHandle / cardForm / vaultCardForm / paymentMethodsSession, imperative-handle and props records; cardForm added to element, paymentMethodsSession added to switchInstance), src/Context.res (cardFormContext, paymentMethodsSessionContext; cardForm and isReady added to elementsType), src/components/Elements.res and HyperElements.res (two added record fields each), src/Index.res / src/Index.resi, README.md.

CardFieldWrapper.res follows the existing PaymentElementsWrapper.res pattern, and HyperPaymentMethodsSession.res follows HyperElements.res, including its Promise.all2 / Promise.catch initialisation and error logging.

Four implementation notes worth reviewing

1. One <CardForm> rather than a payments variant and a vault variant.

The provider already declares the surface, so declaring it a second time on the group would be redundant — and it would add a failure mode, since a payments group under a vault provider would silently never mount its fields. Instead the group resolves its surface from context: <HyperPaymentMethodsSession> publishes isPresent: true on its very first render, synchronously, so the choice never depends on which provider's promise happens to resolve first. The ref carries both settle methods; the one that does not apply to the surface resolves an unsupported_on_surface error rather than doing nothing. This also makes the ref handle and the useCardForm() hook the same shape.

2. Fields create their handle once per component instance and cycle with mount() / unmount(), never create() / destroy().

The SDK's fieldHandle.destroy() tears down the iframe but does not remove the field's entry from the group's internal registry — only deinit() clears it — and findFieldOfType returns the first match. A create/destroy cycle would therefore leave a dead cardNumber entry shadowing the live one at confirm time. React StrictMode double-invokes effects in development, so this would fire on every dev mount. CardFieldWrapper holds the handle in a useRef that survives StrictMode's simulated remount, so exactly one field is ever registered per component instance.

3. The card form group is not deinitialised when <CardForm> unmounts.

elements.cardForm() is memoised behind a cardFormRef inside the SDK, so deiniting on unmount would hand a dead group to any later remount. deinit() is exposed on the ref for merchants who want the iframes gone.

4. Event callbacks rebind on every render.

The SDK's on(event, cb) is a dictionary overwrite keyed by event name, so rebinding is idempotent and costs a handful of assignments. Binding once on mount instead would freeze the first render's closure, which breaks the common onChange={(e) => setErrors({...errors, number: e.error})} shape.


How did you test it?

Same as juspay/hyperswitch-web#1742


Usage Notes

Backward compatibility

No breaking changes. Every existing export keeps its name, props and behaviour. Elements.res and HyperElements.res each gain two record fields; nothing existing was removed or renamed.

Context.elementsType gains cardForm and isReady, so useWidgets() returns two extra keys. isReady is load-bearing rather than cosmetic: the elements context is delivered twice — once as the default placeholder, then again once the hyper promise resolves — and without a way to tell them apart <CardForm> would latch onto the placeholder's stub group and never build the real one.

The one thing to know: <CardForm> requires a hyper.js that ships separate card fields (juspay/hyperswitch-web#1742). Against an older SDK it throws when rendered, because elements.cardForm is absent. Existing consumers who never render it are unaffected.

The payments example below passes sdkAuthorization. That also depends on #1742, which threads the credential through to the card form — before it, widgets() accepted sdkAuthorization but the card form dropped it. clientSecret works on either revision.

New APIs

Payments

import { useRef } from "react";
import {
  HyperElements, CardForm,
  CardNumberField, CardExpiryField, CardCvcField,
} from "@juspay-tech/react-hyper-js";

function Checkout({ hyperPromise, sdkAuthorization, appearance }) {
  const cardFormRef = useRef(null);

  const pay = async () => {
    const result = await cardFormRef.current.confirmPayment();
    if (result.error) showError(result.error.message);
    else onPaid(result);
  };

  return (
    <HyperElements hyper={hyperPromise} options={{ sdkAuthorization, appearance, locale: "en" }}>
      <CardForm ref={cardFormRef} onError={(e) => showError(e.message)}>
        <CardNumberField
          options={{ placeholder: "Card number" }}
          onChange={(e) => setComplete(e.complete)}
        />
        <CardExpiryField options={{ placeholder: "MM / YY" }} />
        <CardCvcField options={{ placeholder: "CVC" }} />
        <button onClick={pay}>Pay</button>
      </CardForm>
    </HyperElements>
  );
}

Vault — the same <CardForm> and the same fields; only the provider and the settle method change.

<HyperPaymentMethodsSession hyper={hyperPromise} options={{ sdkAuthorization, appearance }}>
  <CardForm ref={vaultRef}>
    <CardNumberField />
    <CardExpiryField />
    <CardCvcField />
  </CardForm>
</HyperPaymentMethodsSession>
const result = await vaultRef.current.tokenize();
if (result.error) showError(result.error.message);

Saved-card CVC recollect — mount only the CVC field:

<CardCvcField options={{ savedCard: { token: paymentToken, brand: "Visa" } }} />

brand is case- and separator-insensitive (CardUtils.normalizeCardBrand lowercases and strips -_\s before matching), so "visa", "VISA" and "Visa" are equivalent, as are "amex" and "american express".

Updating a mounted form

Three routes, deliberately not equivalent:

Route Scope Behaviour
options prop one field Declarative. Compared by serialised value, not object identity, so an inline literal does not fire an update per render.
field ref update() one field A patch — a key left out is left alone, not reset.
card form ref update() every mounted field Forwards the options to all of them at once.

The group-level update() differs by surface: on payments it refuses clientSecret and confirmParams (immutable after mount) and warns; on vault it only warns, since session options are fixed at creation. Per-field update() works on both.

Exports added

Export Kind
HyperPaymentMethodsSession Provider — hyper, options, children
CardForm Card form group for either surface, forwardRef
CardNumberField · CardExpiryField · CardCvcField · CardCVCField Fields, forwardRef
useCardForm() Hook — the group's methods plus createField and isReady
usePaymentMethodsSession() Hook — { session, isPresent }

Props

All field props are optional, so <CardNumberField /> is valid.

Prop Type Notes
id string Names the container element; needed when two forms share a page. Defaults to the field type.
options object Forwarded to the SDK's create(). Changing it calls update() on the mounted field — see Updating a mounted form.
className string Applied to the container div.
onChange function { elementType, empty, complete, valid, brand?, error? }
onReady · onFocus · onBlur function { elementType, iframeId }
onError function Vault surface only
onCardFieldStatusInfo function Opt-in subscription event — requires subscriptionEvents: ["cardFieldStatusInfo"] in that field's options, the same gating surchargeInfo and appliedOffersInfo use. Sugar for field.on("cardFieldStatusInfo", cb).

<CardForm> takes onReady, onUnready, onError and onConfirmDispatched. The last two are emitted by the payments surface only; registering them on a vault form is harmless.

Ref handles

Component Ref exposes
<CardForm> confirmPayment · tokenize · update · deinit · on · getFields
Field components mount · unmount · destroy · update · focus · blur · clear · on

useCardForm() returns the same group methods plus createField and isReady, for hook-style integrations.

The field ref carries on() rather than relying on a prop per event, so any event the SDK emits is reachable without adding a binding first. The event props are convenience wrappers over it.

Worth flagging for reviewers: on the bundled PaymentElement, Change is a negative predicate (!focus && !blur && !ready && !confirmTriggered && !oneClickConfirmTriggered), so subscription payloads such as surchargeInfo fall through into onChange and no dedicated listener is needed. Card fields dispatch differently — PaymentsGroup uses an explicit if/else if chain in which cardFieldStatusInfo is handled ahead of, and exclusive with, change (which itself only fires on a cardStateUpdate). That keeps a field's change payload to one fixed shape instead of making it polymorphic on eventName, but it does mean cardFieldStatusInfo needs its own subscription.

Errors

confirmPayment() and tokenize() resolve rather than reject, matching hyper.confirmPayment() — branch on result.error. Two envelopes originate in this wrapper rather than the SDK, both in the SDK's own shape:

code When
sdk_not_ready Called before the hyper promise resolved
unsupported_on_surface tokenize() on a payments form, or confirmPayment() on a vault form

Version Update

  • I have bumped the package version in package.json following semantic versioning:
    • x.x.x for major changes (breaking).
    • x.x.x for minor changes (new feature, no breaking changes).
    • x.x.x for patch changes (bug fixes, minor improvements).

Additive feature, no breaking changes — a minor bump, 2.6.02.7.0. Not applied in this branch; left to whoever cuts the release.


Checklist

  • I ran npm run re:build and verified the build artifacts.
  • I reviewed the code for style, readability, and consistency.
  • I verified the changes are backward compatible (if applicable).
  • I tested this change in a real or simulated consuming project.
  • I updated documentation, README, or usage examples if necessary.

Comment thread src/Index.res
@ArushKapoorJuspay
ArushKapoorJuspay merged commit 31f4218 into main Sep 3, 2026
8 checks passed
@ArushKapoorJuspay
ArushKapoorJuspay deleted the feat/separate-card-fields branch September 3, 2026 16:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants