feat: added support for separate card fields - #18
Merged
Merged
Conversation
aritro2002
reviewed
Sep 3, 2026
aritro2002
approved these changes
Sep 3, 2026
sakksham7
approved these changes
Sep 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.
Type of Change
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:
<HyperElements>(existing)confirmPayment()<HyperPaymentMethodsSession>(new)tokenize()A single
<CardForm>serves both, reading its surface from the provider it sits under. This mirrors the SDK, where the accessor iscardForm()on bothhyper.widgets(...)andhyper.paymentMethodsSession(...)and only the settle method differs.Naming
The package already exports
CardNumberElement/CardNumberWidgetand friends for the older bundled element. The new separate fields are thereforeCardNumberField,CardExpiryFieldandCardCvcField, withCardCVCFieldaliased toCardCvcFieldto match the existingCardCVCElementcasing — the same aliasing the package already does forElement/Widgetpairs.Component tree
Everything below the provider is surface-agnostic: one
<CardForm>and one set of field components serve payments, vault, and saved-card CVC recollect.Files
Added —
src/components/:CardForm.res,HyperPaymentMethodsSession.res,CardFieldWrapper.res,CardNumberField.res,CardExpiryField.res,CardCvcField.res(plus generated.bs.js).Modified —
src/OrcaJs.res(bindings forfieldHandle/cardForm/vaultCardForm/paymentMethodsSession, imperative-handle and props records;cardFormadded toelement,paymentMethodsSessionadded toswitchInstance),src/Context.res(cardFormContext,paymentMethodsSessionContext;cardFormandisReadyadded toelementsType),src/components/Elements.resandHyperElements.res(two added record fields each),src/Index.res/src/Index.resi,README.md.CardFieldWrapper.resfollows the existingPaymentElementsWrapper.respattern, andHyperPaymentMethodsSession.resfollowsHyperElements.res, including itsPromise.all2/Promise.catchinitialisation 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>publishesisPresent: trueon 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 anunsupported_on_surfaceerror rather than doing nothing. This also makes the ref handle and theuseCardForm()hook the same shape.2. Fields create their handle once per component instance and cycle with
mount()/unmount(), nevercreate()/destroy().The SDK's
fieldHandle.destroy()tears down the iframe but does not remove the field's entry from the group's internal registry — onlydeinit()clears it — andfindFieldOfTypereturns the first match. A create/destroy cycle would therefore leave a deadcardNumberentry shadowing the live one at confirm time. React StrictMode double-invokes effects in development, so this would fire on every dev mount.CardFieldWrapperholds the handle in auseRefthat 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 acardFormRefinside 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 commononChange={(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.resandHyperElements.reseach gain two record fields; nothing existing was removed or renamed.Context.elementsTypegainscardFormandisReady, souseWidgets()returns two extra keys.isReadyis load-bearing rather than cosmetic: the elements context is delivered twice — once as the default placeholder, then again once thehyperpromise 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 ahyper.jsthat ships separate card fields (juspay/hyperswitch-web#1742). Against an older SDK it throws when rendered, becauseelements.cardFormis 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()acceptedsdkAuthorizationbut the card form dropped it.clientSecretworks on either revision.New APIs
Payments
Vault — the same
<CardForm>and the same fields; only the provider and the settle method change.Saved-card CVC recollect — mount only the CVC field:
brandis case- and separator-insensitive (CardUtils.normalizeCardBrandlowercases and strips-_\sbefore matching), so"visa","VISA"and"Visa"are equivalent, as are"amex"and"american express".Updating a mounted form
Three routes, deliberately not equivalent:
optionspropupdate()update()The group-level
update()differs by surface: on payments it refusesclientSecretandconfirmParams(immutable after mount) and warns; on vault it only warns, since session options are fixed at creation. Per-fieldupdate()works on both.Exports added
HyperPaymentMethodsSessionhyper,options,childrenCardFormforwardRefCardNumberField·CardExpiryField·CardCvcField·CardCVCFieldforwardRefuseCardForm()createFieldandisReadyusePaymentMethodsSession(){ session, isPresent }Props
All field props are optional, so
<CardNumberField />is valid.idoptionscreate(). Changing it callsupdate()on the mounted field — see Updating a mounted form.classNamediv.onChange{ elementType, empty, complete, valid, brand?, error? }onReady·onFocus·onBlur{ elementType, iframeId }onErroronCardFieldStatusInfosubscriptionEvents: ["cardFieldStatusInfo"]in that field'soptions, the same gatingsurchargeInfoandappliedOffersInfouse. Sugar forfield.on("cardFieldStatusInfo", cb).<CardForm>takesonReady,onUnready,onErrorandonConfirmDispatched. The last two are emitted by the payments surface only; registering them on a vault form is harmless.Ref handles
<CardForm>confirmPayment·tokenize·update·deinit·on·getFieldsmount·unmount·destroy·update·focus·blur·clear·onuseCardForm()returns the same group methods pluscreateFieldandisReady, 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,Changeis a negative predicate (!focus && !blur && !ready && !confirmTriggered && !oneClickConfirmTriggered), so subscription payloads such assurchargeInfofall through intoonChangeand no dedicated listener is needed. Card fields dispatch differently —PaymentsGroupuses an explicitif/else ifchain in whichcardFieldStatusInfois handled ahead of, and exclusive with,change(which itself only fires on acardStateUpdate). That keeps a field'schangepayload to one fixed shape instead of making it polymorphic oneventName, but it does meancardFieldStatusInfoneeds its own subscription.Errors
confirmPayment()andtokenize()resolve rather than reject, matchinghyper.confirmPayment()— branch onresult.error. Two envelopes originate in this wrapper rather than the SDK, both in the SDK's own shape:codesdk_not_readyhyperpromise resolvedunsupported_on_surfacetokenize()on a payments form, orconfirmPayment()on a vault formVersion Update
package.jsonfollowing semantic versioning:x.x.xfor major changes (breaking).x.x.xfor minor changes (new feature, no breaking changes).x.x.xfor patch changes (bug fixes, minor improvements).Additive feature, no breaking changes — a minor bump,
2.6.0→2.7.0. Not applied in this branch; left to whoever cuts the release.Checklist
npm run re:buildand verified the build artifacts.