diff --git a/platforms/react-native/README.md b/platforms/react-native/README.md index 0edd8bf7d..50aa80ffd 100644 --- a/platforms/react-native/README.md +++ b/platforms/react-native/README.md @@ -817,6 +817,7 @@ shopify.present(checkoutUrl, { | `onComplete` | `{checkout: Checkout}` | Checkout completes. The confirmation UI can remain visible. | | `onDismiss` | None | Checkout is dismissed, including after completion. | | `onFail` | `{error: CheckoutException}` | Checkout cannot continue. | +| `onLinkClick` | `{url: string}` | Checkout requests opening a link. | | `onGeolocationRequest` | `GeolocationRequestEvent` | Android sheets only. See [geolocation handling](#opting-out-of-the-default-behavior). | Completion keeps callbacks active until dismissal or failure. Delay changes that @@ -825,7 +826,7 @@ until dismissal or failure. Calling `dismiss()` also delivers `onDismiss`. Repeated `present()` calls while a checkout session is active are ignored, including calls from another `ShopifyCheckout` instance. The original checkout -and callbacks remain active. Calls made while the previous sheet is closing are +and callbacks and link policy remain active. Calls made while the previous sheet is closing are also ignored, without firing callbacks for the ignored attempt. `onDismiss` and `onFail` can run before the closing animation finishes, so presenting from those callbacks is not guaranteed to open another checkout. @@ -834,6 +835,24 @@ callbacks is not guaranteed to open another checkout. pending geolocation responses without dismissing the sheet; another checkout can be presented once the native session ends. +### Link handling + +Set `linkAction` before presentation (or on the accelerated buttons): + +- `open` (default): let the native SDK open links. +- `handled`: your app opens or routes links itself. +- `cancel`: prevent links from opening. + +`onLinkClick` is an asynchronous notification. Its return value cannot change the +native decision. For custom routing, pair it with `linkAction: 'handled'`: + +```tsx +shopify.present(checkoutUrl, { + linkAction: 'handled', + onLinkClick: ({url}) => Linking.openURL(url), +}); +``` + ### Migrating from protocol callbacks Replace the third `present()` argument and accelerated `events` prop with the @@ -843,8 +862,8 @@ data from `event.checkout`. Terminal protocol errors now arrive through `onFail` checkout messages remain available in snapshots. Rename sheet `onClose` and accelerated `onCancel` to `onDismiss`. Change -`onFail(error)` to `onFail({error})`. The accelerated `onClickLink` prop is removed; -native SDKs open checkout links by default. `CheckoutProtocol`, +`onFail(error)` to `onFail({error})`, and accelerated `onClickLink(url)` to +`onLinkClick({url})` with the appropriate `linkAction`. `CheckoutProtocol`, `ProtocolHandlers`, and protocol payload exports have been removed. ## Identity & customer accounts @@ -1168,7 +1187,7 @@ The `cornerRadius` prop lets you match the buttons to other calls-to-action in y ### Handle loading, errors, and lifecycle events -Accelerated buttons use the same lifecycle callbacks as sheets. +Accelerated buttons use the same lifecycle callbacks and link policy as sheets. Use a ref to remember completion without unmounting the button's confirmation UI: ```tsx @@ -1192,6 +1211,8 @@ const completed = useRef(false); console.error('Accelerated checkout failed:', error.code); }} onRenderStateChange={(event) => setRenderState(event.state)} + linkAction="handled" + onLinkClick={({url}) => Linking.openURL(url)} /> ``` diff --git a/platforms/react-native/__mocks__/react-native.ts b/platforms/react-native/__mocks__/react-native.ts index 80f3e61df..a3ef81ab8 100644 --- a/platforms/react-native/__mocks__/react-native.ts +++ b/platforms/react-native/__mocks__/react-native.ts @@ -84,6 +84,7 @@ const ShopifyCheckoutKit = { 'complete', 'dismiss', 'fail', + 'linkClick', 'geolocationRequest', ], })), diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java index f3204f7db..253bb3e3d 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/CustomCheckoutListener.java @@ -20,9 +20,12 @@ public class CustomCheckoutListener extends DefaultCheckoutListener { private final ObjectMapper mapper = new ObjectMapper(); private final DispatchHandle dispatch; + private CheckoutLinkAction linkAction = CheckoutLinkAction.Open; private Runnable onTerminal = () -> {}; - public void setOnTerminal(Runnable onTerminal) { + public void configure(String action, Runnable onTerminal) { + this.linkAction = "handled".equals(action) ? CheckoutLinkAction.Handled + : "cancel".equals(action) ? CheckoutLinkAction.Cancel : CheckoutLinkAction.Open; this.onTerminal = onTerminal; } @@ -149,6 +152,19 @@ public void onCheckoutCompleted(CheckoutCompleteEvent event) { emitCheckout(DispatchEventTypes.COMPLETE, event.getCheckout()); } + @Override + public CheckoutLinkAction onCheckoutLinkClicked(CheckoutLink link) { + if (dispatch.isReleased()) return CheckoutLinkAction.Cancel; + try { + Map payload = new HashMap<>(); + payload.put("url", link.getUrl().toString()); + dispatch.invoke(buildEnvelope(DispatchEventTypes.LINK_CLICK, payload)); + } catch (IOException e) { + Log.e(TAG, "Error emitting link click event", e); + } + return linkAction; + } + private void emitCheckout(String type, Checkout checkout) { if (dispatch.isReleased()) return; try { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java index 66c0a5ea7..df408ea4d 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/DispatchEventTypes.java @@ -18,11 +18,12 @@ public final class DispatchEventTypes { public static final String UPDATE = "update"; public static final String COMPLETE = "complete"; public static final String DISMISS = "dismiss"; + public static final String LINK_CLICK = "linkClick"; public static final String FAIL = "fail"; public static final String GEOLOCATION_REQUEST = "geolocationRequest"; public static final List ALL = Collections.unmodifiableList( - Arrays.asList(START, UPDATE, COMPLETE, DISMISS, FAIL, GEOLOCATION_REQUEST)); + Arrays.asList(START, UPDATE, COMPLETE, DISMISS, FAIL, LINK_CLICK, GEOLOCATION_REQUEST)); private DispatchEventTypes() {} } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java index 39dae4f8e..4fe4631d6 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ShopifyCheckoutKitModule.java @@ -81,7 +81,7 @@ public void removeListeners(double count) { } @ReactMethod - public void present(String checkoutURL, Callback onResult) { + public void present(String checkoutURL, String linkAction, Callback onResult) { if (invalidated) { onResult.invoke(false); return; @@ -96,7 +96,7 @@ public void present(String checkoutURL, Callback onResult) { } CustomCheckoutListener listener = new CustomCheckoutListener(this::emitDispatchEvent); checkoutListener = listener; - listener.setOnTerminal(this::finishCheckoutPresentation); + listener.configure(linkAction, this::finishCheckoutPresentation); CheckoutHandle sheet = ShopifyCheckoutKit.present(checkoutURL, (ComponentActivity) currentActivity, listener); // Initialization can fail synchronously and already emit a terminal event. if (checkoutListener != listener) { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerializationTest.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerializationTest.kt index d7577d2f7..c1d9b4d26 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerializationTest.kt +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/CheckoutEventSerializationTest.kt @@ -1,5 +1,6 @@ package com.shopify.reactnative.checkoutkit +import android.net.Uri import com.shopify.checkoutkit.* import kotlinx.serialization.json.* import org.assertj.core.api.Assertions.assertThat @@ -42,4 +43,20 @@ class CheckoutEventSerializationTest { .containsExactly("start", "update", "complete", "dismiss") } + @Test + fun `link policy returns synchronously and also notifies JS`() { + val constructor = CheckoutLink::class.java.getDeclaredConstructor(Uri::class.java) + constructor.isAccessible = true + val link = constructor.newInstance(Uri.parse("https://example.test/policy")) + listOf("open" to CheckoutLinkAction.Open, "handled" to CheckoutLinkAction.Handled, "cancel" to CheckoutLinkAction.Cancel).forEach { (action, expected) -> + val events = mutableListOf() + val listener = CustomCheckoutListener(DispatchCallback { events.add(it) }) + listener.configure(action) {} + assertThat(listener.onCheckoutLinkClicked(link)).isEqualTo(expected) + val envelope = Json.parseToJsonElement(events.single()).jsonObject + assertThat(envelope["type"]?.jsonPrimitive?.content).isEqualTo("linkClick") + assertThat(envelope["payload"]?.jsonObject?.get("url")?.jsonPrimitive?.content).isEqualTo("https://example.test/policy") + } + } + } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md b/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md index 91be542a2..5d5c80d74 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/api/checkout-kit-react-native.api.md @@ -198,11 +198,13 @@ export enum CheckoutErrorCode { // @public export interface CheckoutEventHandlers { + linkAction?: CheckoutLinkAction; onComplete?: (event: CheckoutCompleteEvent) => void; // (undocumented) onDismiss?: () => void; // (undocumented) onFail?: (event: CheckoutFailureEvent) => void; + onLinkClick?: (link: CheckoutLink) => void; // (undocumented) onStart?: (event: CheckoutStartEvent) => void; // (undocumented) @@ -228,6 +230,15 @@ export interface CheckoutFailureEvent { error: CheckoutException; } +// @public (undocumented) +export interface CheckoutLink { + // (undocumented) + url: string; +} + +// @public (undocumented) +export type CheckoutLinkAction = 'open' | 'handled' | 'cancel'; + // @public export type CheckoutNativeError = { code: CheckoutErrorCode; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/AcceleratedCheckoutButtons.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/AcceleratedCheckoutButtons.swift index fff77eb3c..7ba12dfc8 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/AcceleratedCheckoutButtons.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/AcceleratedCheckoutButtons.swift @@ -57,6 +57,7 @@ class RCTAcceleratedCheckoutButtonsView: UIView { private var configuration: ShopifyAcceleratedCheckouts.Configuration? private weak var parentViewController: UIViewController? internal var instance: AcceleratedCheckoutButtons? + private var checkoutIsActive = false @objc var onSizeChange: RCTDirectEventBlock? @@ -99,6 +100,7 @@ class RCTAcceleratedCheckoutButtonsView: UIView { } } + @objc var linkAction: String = "open" @objc var onDismiss: RCTDirectEventBlock? @objc var onRenderStateChange: RCTBubblingEventBlock? @objc var onDispatch: RCTDirectEventBlock? @@ -223,9 +225,17 @@ class RCTAcceleratedCheckoutButtonsView: UIView { private func attachEventListeners(to buttons: AcceleratedCheckoutButtons) -> AcceleratedCheckoutButtons { return buttons - .onStart { [weak self] event in self?.dispatchCheckout(.start, checkout: event.checkout) } + .onStart { [weak self] event in + self?.checkoutIsActive = true + self?.dispatchCheckout(.start, checkout: event.checkout) + } .onUpdate { [weak self] event in self?.dispatchCheckout(.update, checkout: event.checkout) } .onComplete { [weak self] event in self?.dispatchCheckout(.complete, checkout: event.checkout) } + .onLinkClick { [weak self] link in + guard let self, self.checkoutIsActive else { return .cancel } + self.dispatchEvent(.linkClick, payload: ShopifyEventSerialization.serialize(clickEvent: link.url)) + return checkoutLinkAction(self.linkAction) + } .onFail { [weak self] error in self?.handleCheckoutFailed(error) } @@ -324,10 +334,12 @@ class RCTAcceleratedCheckoutButtonsView: UIView { // MARK: - Event Handlers private func handleCheckoutFailed(_ error: CheckoutError) { + checkoutIsActive = false dispatchEvent(.fail, payload: ["error": ShopifyEventSerialization.serialize(checkoutError: error)]) } private func handleCheckoutDismissed() { + checkoutIsActive = false onDismiss?([:]) } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift index 7385ed54e..2ad0a5910 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/CheckoutEventBridge.swift @@ -27,13 +27,24 @@ func checkoutEventJSON(type: DispatchEventType, checkout: Checkout) -> String? { } } +func checkoutLinkAction(_ value: String) -> CheckoutLinkAction { + switch value { + case "handled": return .handled + case "cancel": return .cancel + default: return .open + } +} + /// A presentation retains its own delegate so queued events cannot cross sessions. @MainActor final class CheckoutEventBridge: CheckoutDelegate { private var dispatch: ((String) -> Void)? private let onTerminal: (CheckoutEventBridge) -> Void - init(dispatch: @escaping (String) -> Void, onTerminal: @escaping (CheckoutEventBridge) -> Void) { + let linkAction: CheckoutLinkAction + + init(linkAction: String, dispatch: @escaping (String) -> Void, onTerminal: @escaping (CheckoutEventBridge) -> Void) { + self.linkAction = checkoutLinkAction(linkAction) self.dispatch = dispatch self.onTerminal = onTerminal } @@ -58,6 +69,12 @@ final class CheckoutEventBridge: CheckoutDelegate { finish(.dismiss) } + func checkoutAction(for link: CheckoutLink) -> CheckoutLinkAction { + guard dispatch != nil else { return .cancel } + emit(.linkClick, payload: ShopifyEventSerialization.serialize(clickEvent: link.url)) + return linkAction + } + private func emit(_ type: DispatchEventType, checkout: Checkout) { guard let json = checkoutEventJSON(type: type, checkout: checkout) else { return } dispatch?(json) diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm index e07cb613b..bb51a313e 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.mm @@ -18,6 +18,7 @@ @interface RCT_EXTERN_MODULE (RCTShopifyCheckoutKit, NativeShopifyCheckoutKitSpe RCT_EXTERN_METHOD(setConfig:(NSDictionary *)configuration) RCT_EXTERN_METHOD(present:(NSString *)checkoutURL + linkAction:(NSString *)linkAction onResult:(RCTResponseSenderBlock)onResult) RCT_EXTERN_METHOD(preload:(NSString *)checkoutURL @@ -126,6 +127,8 @@ @interface RCT_EXTERN_MODULE (RCTAcceleratedCheckoutButtonsManager, RCTViewManag */ RCT_EXPORT_VIEW_PROPERTY(applePayStyle, NSString*) +RCT_EXPORT_VIEW_PROPERTY(linkAction, NSString*) + // React Native reserves topDismiss as a direct event. RCT_EXPORT_VIEW_PROPERTY(onDismiss, RCTDirectEventBlock) diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift index 8a70892bb..8beeb6075 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ShopifyCheckoutKit.swift @@ -17,6 +17,7 @@ enum DispatchEventType: String, CaseIterable { case update case complete case dismiss + case linkClick case fail case geolocationRequest } @@ -114,7 +115,7 @@ class RCTShopifyCheckoutKit: NSObject { } } - @objc func present(_ checkoutURL: String, onResult: @escaping RCTResponseSenderBlock) { + @objc func present(_ checkoutURL: String, linkAction: String, onResult: @escaping RCTResponseSenderBlock) { DispatchQueue.main.async { // Preserve the active session and ignore attempts during dismissal. guard self.checkoutEvents == nil else { @@ -128,7 +129,7 @@ class RCTShopifyCheckoutKit: NSObject { return } self.closingCheckoutSheet = nil - let events = CheckoutEventBridge(dispatch: { [weak self] json in + let events = CheckoutEventBridge(linkAction: linkAction, dispatch: { [weak self] json in self?.emitDispatchEvent(json) }, onTerminal: { [weak self] ended in guard let self, self.checkoutEvents === ended else { return } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/checkout.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/checkout.ts index e58c254b7..c19c06f19 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/checkout.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/checkout.ts @@ -52,6 +52,11 @@ export interface CheckoutCompleteEvent { export interface CheckoutFailureEvent { error: CheckoutException; } +export interface CheckoutLink { + url: string; +} +export type CheckoutLinkAction = 'open' | 'handled' | 'cancel'; + /** Lifecycle callbacks shared by checkout sheets and accelerated buttons. */ export interface CheckoutEventHandlers { onStart?: (event: CheckoutStartEvent) => void; @@ -60,6 +65,10 @@ export interface CheckoutEventHandlers { onComplete?: (event: CheckoutCompleteEvent) => void; onFail?: (event: CheckoutFailureEvent) => void; onDismiss?: () => void; + /** Notification only; use linkAction to choose the native response. */ + onLinkClick?: (link: CheckoutLink) => void; + /** Native link policy, chosen before presentation. Defaults to open. */ + linkAction?: CheckoutLinkAction; } export function decodeCheckout(value: unknown): Checkout { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/components/AcceleratedCheckoutButtons.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/components/AcceleratedCheckoutButtons.tsx index a82122fb0..07ac7efa0 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/components/AcceleratedCheckoutButtons.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/components/AcceleratedCheckoutButtons.tsx @@ -139,6 +139,8 @@ export const AcceleratedCheckoutButtons: React.FC< onUpdate, onComplete, onRenderStateChange, + onLinkClick, + linkAction = 'open', ...props }) => { const isCart = isCartProps(props); @@ -170,9 +172,9 @@ export const AcceleratedCheckoutButtons: React.FC< const {dispatcher} = useMemo( () => createPresentDispatcher({ - callbacks: {onStart, onUpdate, onComplete, onFail}, + callbacks: {onStart, onUpdate, onComplete, onFail, onLinkClick}, }), - [onStart, onUpdate, onComplete, onFail], + [onStart, onUpdate, onComplete, onFail, onLinkClick], ); const handleDispatch = useCallback( @@ -239,6 +241,7 @@ export const AcceleratedCheckoutButtons: React.FC< cornerRadius={cornerRadius} wallets={wallets} onDismiss={handleDismiss} + linkAction={linkAction} onRenderStateChange={handleRenderStateChange} onDispatch={handleDispatch} onSizeChange={handleSizeChange} diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/dispatch-events.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/dispatch-events.ts index ca0719c95..9bf56f521 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/dispatch-events.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/dispatch-events.ts @@ -15,6 +15,7 @@ export const SDK_LIFECYCLE_EVENT_TYPES = [ 'update', 'complete', 'dismiss', + 'linkClick', 'fail', 'geolocationRequest', ] as const; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts index 29beab552..797817bc8 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.d.ts @@ -12,6 +12,8 @@ export type { CheckoutUpdateEvent, CheckoutCompleteEvent, CheckoutFailureEvent, + CheckoutLink, + CheckoutLinkAction, CheckoutEventHandlers, } from './checkout'; @@ -184,7 +186,7 @@ export interface GeolocationRequestEvent { respond: (allow: boolean) => void; } -/** Lifecycle callbacks for a checkout presentation. */ +/** Lifecycle callbacks and link policy for a checkout presentation. */ export interface PresentCallbacks extends CheckoutEventHandlers { /** * Fires when the checkout sheet requests geolocation permissions. @@ -312,7 +314,7 @@ export interface ShopifyCheckoutKit { * Present the checkout. * * @param checkoutURL The URL of the checkout to display. - * @param callbacks Lifecycle callbacks. Callbacks remain + * @param callbacks Lifecycle callbacks and native link policy. Callbacks remain * active until dismissal or failure, including after completion. Repeated calls * while checkout is active or closing are ignored, including across instances. * Ignored attempts do not fire callbacks. diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts index 7e887bacb..2f9c1b650 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/index.ts @@ -125,7 +125,7 @@ class ShopifyCheckout implements ShopifyCheckoutKit { return subscription; } - /** Presents checkout with lifecycle callbacks. */ + /** Presents checkout with lifecycle callbacks and a native link policy. */ public present(checkoutUrl: string, callbacks?: PresentCallbacks): void { if (ShopifyCheckout.activePresentation) { // eslint-disable-next-line no-console @@ -165,12 +165,16 @@ class ShopifyCheckout implements ShopifyCheckoutKit { this.dispatchSubscription = subscription; ShopifyCheckout.activePresentation = this; try { - RNShopifyCheckoutKit.present(checkoutUrl, accepted => { - // Native can ignore the attempt while the previous sheet is closing. - // Release only this attempt, without reporting a checkout dismissal. - if (!accepted && subscription) - this.releaseDispatchSubscription(subscription); - }); + RNShopifyCheckoutKit.present( + checkoutUrl, + callbacks?.linkAction ?? 'open', + accepted => { + // Native can ignore the attempt while the previous sheet is closing. + // Release only this attempt, without reporting a checkout dismissal. + if (!accepted && subscription) + this.releaseDispatchSubscription(subscription); + }, + ); } catch (error) { this.releaseDispatchSubscription(subscription); throw error; @@ -457,5 +461,7 @@ export type { CheckoutUpdateEvent, CheckoutCompleteEvent, CheckoutFailureEvent, + CheckoutLink, + CheckoutLinkAction, CheckoutEventHandlers, } from './checkout'; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/present-dispatcher.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/present-dispatcher.ts index ef7943977..fb16cb881 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/present-dispatcher.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/present-dispatcher.ts @@ -96,6 +96,13 @@ function dispatchEnvelope( }); return; } + case 'linkClick': + if (!isPlainObject(payload) || typeof payload.url !== 'string') { + logParseError('`linkClick` envelope payload is malformed'); + return; + } + callbacks?.onLinkClick?.({url: payload.url}); + return; case 'geolocationRequest': if (options.geolocationSignal?.aborted) return; if (!isPlainObject(payload) || typeof payload.origin !== 'string') { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts index 4582f4eff..f221198bb 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/NativeShopifyCheckoutKit.ts @@ -56,7 +56,11 @@ export interface Spec extends TurboModule { readonly onDispatch: CodegenTypes.EventEmitter; readonly onPreloadStateChange: CodegenTypes.EventEmitter; - present(checkoutUrl: string, onResult: (accepted: boolean) => void): void; + present( + checkoutUrl: string, + linkAction: string, + onResult: (accepted: boolean) => void, + ): void; preload(checkoutUrl: string, requestId: string): void; dismiss(): void; invalidateCache(): void; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/RCTAcceleratedCheckoutButtonsNativeComponent.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/RCTAcceleratedCheckoutButtonsNativeComponent.ts index d8b83cdb8..a9941a3df 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/RCTAcceleratedCheckoutButtonsNativeComponent.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/specs/RCTAcceleratedCheckoutButtonsNativeComponent.ts @@ -27,6 +27,7 @@ interface NativeProps extends ViewProps { wallets?: ReadonlyArray; applePayLabel?: string; applePayStyle?: string; + linkAction?: string; onDismiss?: DirectEventHandler; onRenderStateChange?: BubblingEventHandler; onDispatch?: DirectEventHandler; diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/AcceleratedCheckoutButtons.test.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/AcceleratedCheckoutButtons.test.tsx index 74b4dd176..75619e2ff 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/AcceleratedCheckoutButtons.test.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/AcceleratedCheckoutButtons.test.tsx @@ -179,6 +179,21 @@ describe('AcceleratedCheckoutButtons', () => { expect(onStart).not.toHaveBeenCalled(); }); + it.each(['open', 'handled', 'cancel'] as const)( + 'passes the %s link policy to native', + linkAction => { + const {getByTestId} = render( + , + ); + expect( + getByTestId('accelerated-checkout-buttons').props.linkAction, + ).toBe(linkAction); + }, + ); + it('uses default values for cornerRadius', () => { const {getByTestId} = render( { }); }); + it('handles onLinkClick when URL is present and ignores when absent', () => { + const onLinkClick = jest.fn(); + const {getByTestId} = render( + , + ); + const nativeComponent = getByTestId('accelerated-checkout-buttons'); + nativeComponent.props.onDispatch({ + nativeEvent: { + value: JSON.stringify({ + type: 'linkClick', + payload: {url: 'https://example.test/link'}, + }), + }, + }); + expect(onLinkClick).toHaveBeenCalledWith({ + url: 'https://example.test/link', + }); + + onLinkClick.mockClear(); + nativeComponent.props.onDispatch({ + nativeEvent: {value: JSON.stringify({type: 'linkClick', payload: {}})}, + }); + expect(onLinkClick).not.toHaveBeenCalled(); + }); + it('applies dynamic height when onSizeChange is emitted', async () => { const {getByTestId} = render( , @@ -370,6 +413,7 @@ describe('AcceleratedCheckoutButtons', () => { onFail: jest.fn(), onDismiss: jest.fn(), onRenderStateChange: jest.fn(), + onLinkClick: jest.fn(), }; expect(() => { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx index 66cd7ecc3..187dd9725 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/context.test.tsx @@ -175,6 +175,7 @@ describe('useShopifyCheckout', () => { expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, + 'open', expect.any(Function), ); }); @@ -204,6 +205,7 @@ describe('useShopifyCheckout', () => { ); expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( checkoutUrl, + 'open', expect.any(Function), ); }); @@ -292,6 +294,35 @@ describe('useShopifyCheckout', () => { expect(NativeModules.ShopifyCheckoutKit.invalidateCache).toHaveBeenCalled(); }); + it('forwards the native link policy through the provider present function', () => { + let hookValue: any; + const onHookValue = (value: any) => { + hookValue = value; + }; + + render( + + + , + ); + + act(() => { + hookValue.present(checkoutUrl, { + linkAction: 'handled', + onStart: jest.fn(), + }); + }); + + expect(NativeModules.ShopifyCheckoutKit.onDispatch).toHaveBeenCalledWith( + expect.any(Function), + ); + expect(NativeModules.ShopifyCheckoutKit.present).toHaveBeenCalledWith( + checkoutUrl, + 'handled', + expect.any(Function), + ); + }); + it('provides dismiss function', () => { let hookValue: any; const onHookValue = (value: any) => { diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/dispatch-events.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/dispatch-events.test.ts index 2c3b67dba..d8377b2e5 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/dispatch-events.test.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/dispatch-events.test.ts @@ -39,7 +39,7 @@ describe('SDK lifecycle event dispatch contract', () => { 'events missing from js: nativeOnly', ); expect(() => verifyDispatchEventParity(['dismiss', 'nativeOnly'])).toThrow( - 'events missing from native: complete, fail, geolocationRequest, start, update', + 'events missing from native: complete, fail, geolocationRequest, linkClick, start, update', ); }); }); diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts index 0c1cf843e..b30351131 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/index.test.ts @@ -49,6 +49,7 @@ beforeEach(() => { 'complete', 'dismiss', 'fail', + 'linkClick', 'geolocationRequest', ], }); @@ -409,9 +410,22 @@ describe('ShopifyCheckoutKit', () => { totals: [], }; + it.each(['open', 'handled', 'cancel'] as const)( + 'passes the %s link policy to native', + linkAction => { + const instance = new ShopifyCheckout(); + instance.present(checkoutUrl, {linkAction}); + expect(NativeModule.present).toHaveBeenCalledWith( + checkoutUrl, + linkAction, + expect.any(Function), + ); + }, + ); + it('presents checkout without a session ID', () => { new ShopifyCheckout().present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, expect.any(Function)); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, 'open', expect.any(Function)); }); it('keeps a reentrant presentation subscribed when the first present throws', () => { @@ -447,14 +461,14 @@ describe('ShopifyCheckoutKit', () => { const onFail = jest.fn(); instance.present(checkoutUrl, {onDismiss, onFail}); const rejectedDispatch = lastDispatch(); - NativeModule.present.mock.calls[0][1](false); + NativeModule.present.mock.calls[0][2](false); expect(remove).toHaveBeenCalledTimes(1); expect(onDismiss).not.toHaveBeenCalled(); expect(onFail).not.toHaveBeenCalled(); instance.present(checkoutUrl, {onDismiss}); expect(NativeModule.present).toHaveBeenCalledTimes(2); - NativeModule.present.mock.calls[1][1](true); + NativeModule.present.mock.calls[1][2](true); rejectedDispatch(JSON.stringify({type: 'dismiss'})); expect(onDismiss).not.toHaveBeenCalled(); lastDispatch()(JSON.stringify({type: 'dismiss'})); @@ -464,7 +478,7 @@ describe('ShopifyCheckoutKit', () => { it('does not release a newer session when an old result arrives late', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); - const firstResult = NativeModule.present.mock.calls[0][1]; + const firstResult = NativeModule.present.mock.calls[0][2]; lastDispatch()(JSON.stringify({type: 'dismiss'})); const onDismiss = jest.fn(); instance.present(checkoutUrl, {onDismiss}); @@ -535,11 +549,15 @@ describe('ShopifyCheckoutKit', () => { const second = anotherInstance ? new ShopifyCheckout() : first; const onDismiss = jest.fn(); const replacementDismiss = jest.fn(); - first.present(checkoutUrl, {onDismiss}); + first.present(checkoutUrl, {onDismiss, linkAction: 'handled'}); second.present('https://example.test/other', { onDismiss: replacementDismiss, + linkAction: 'cancel', }); expect(NativeModule.present).toHaveBeenCalledTimes(1); + expect(NativeModule.present).toHaveBeenCalledWith( + checkoutUrl, 'handled', expect.any(Function), + ); expect(NativeModule.onDispatch).toHaveBeenCalledTimes(1); lastDispatch()(JSON.stringify({type: 'dismiss'})); expect(onDismiss).toHaveBeenCalledTimes(1); @@ -547,6 +565,23 @@ describe('ShopifyCheckoutKit', () => { }, ); + it('delivers a link notification', () => { + const onLinkClick = jest.fn(); + new ShopifyCheckout().present(checkoutUrl, { + linkAction: 'handled', + onLinkClick, + }); + lastDispatch()( + JSON.stringify({ + type: 'linkClick', + payload: {url: 'https://example.test/policy'}, + }), + ); + expect(onLinkClick).toHaveBeenCalledWith({ + url: 'https://example.test/policy', + }); + }); + it('ignores queued events from a finished presentation after reopening', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); @@ -748,7 +783,7 @@ describe('ShopifyCheckoutKit', () => { it('subscribes to dispatch events when the default handler is enabled, even without callbacks', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, expect.any(Function)); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, 'open', expect.any(Function)); expect(NativeModule.onDispatch).toHaveBeenCalledWith( expect.any(Function), ); @@ -759,7 +794,7 @@ describe('ShopifyCheckoutKit', () => { handleGeolocationRequests: false, }); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, expect.any(Function)); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, 'open', expect.any(Function)); }); it('handles geolocation permission grant correctly', async () => { @@ -946,7 +981,7 @@ describe('ShopifyCheckoutKit', () => { it('presents with the default link policy on iOS', () => { const instance = new ShopifyCheckout(); instance.present(checkoutUrl); - expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, expect.any(Function)); + expect(NativeModule.present).toHaveBeenCalledWith(checkoutUrl, 'open', expect.any(Function)); }); it('does not run the default geolocation handler on iOS even if dispatcher fires', async () => { diff --git a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java index 5da3d78ba..e9066d7c1 100644 --- a/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java +++ b/platforms/react-native/sample/android/app/src/test/java/com/shopify/checkoutkit/reactnativedemo/ShopifyCheckoutKitModuleTest.java @@ -1,5 +1,6 @@ package com.shopify.checkoutkit.reactnativedemo; +import android.net.Uri; import android.os.Looper; import android.webkit.GeolocationPermissions; @@ -17,6 +18,8 @@ import com.shopify.checkoutkit.CheckoutException; import com.shopify.checkoutkit.CheckoutFailureEvent; import com.shopify.checkoutkit.CheckoutHandle; +import com.shopify.checkoutkit.CheckoutLink; +import com.shopify.checkoutkit.CheckoutLinkAction; import com.shopify.checkoutkit.CheckoutPreload; import com.shopify.checkoutkit.LogLevel; import com.shopify.checkoutkit.PreloadState; @@ -160,7 +163,7 @@ public void testCanPresentCheckout() { String checkoutUrl = "https://shopify.com"; mockedShopifyCheckoutKit.when(() -> ShopifyCheckoutKit.present( eq(checkoutUrl), any(), any())).thenReturn(mock(CheckoutHandle.class)); - shopifyCheckoutKitModule.present(checkoutUrl, presentationResult); + shopifyCheckoutKitModule.present(checkoutUrl, "open", presentationResult); verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); runnableCaptor.getValue().run(); @@ -171,7 +174,7 @@ public void testCanPresentCheckout() { } @Test - public void testDuplicatePresentationPreservesOriginalListener() { + public void testDuplicatePresentationPreservesOriginalListener() throws Exception { doAnswer(invocation -> { ((Runnable) invocation.getArgument(0)).run(); return null; @@ -181,12 +184,18 @@ public void testDuplicatePresentationPreservesOriginalListener() { ArgumentCaptor listeners = ArgumentCaptor.forClass(CustomCheckoutListener.class); nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) .thenReturn(sheet); - shopifyCheckoutKitModule.present("https://example.test/first", presentationResult); + shopifyCheckoutKitModule.present("https://example.test/first", "handled", presentationResult); nativeKit.verify(() -> ShopifyCheckoutKit.present(eq("https://example.test/first"), eq(mockComponentActivity), listeners.capture())); - shopifyCheckoutKitModule.present("https://example.test/second", presentationResult); + shopifyCheckoutKitModule.present("https://example.test/second", "cancel", presentationResult); nativeKit.verifyNoMoreInteractions(); + verify(presentationResult).invoke(true); + verify(presentationResult).invoke(false); + java.lang.reflect.Constructor constructor = CheckoutLink.class.getDeclaredConstructor(Uri.class); + constructor.setAccessible(true); + CheckoutLink link = constructor.newInstance(Uri.parse("https://example.test/policy")); + assertThat(listeners.getValue().onCheckoutLinkClicked(link)).isEqualTo(CheckoutLinkAction.Handled); listeners.getValue().onCheckoutDismissed(); - assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(1); + assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(2); } } @@ -214,7 +223,7 @@ private void assertCanPresentAfterProgrammaticDismiss(boolean detachActivity) { nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) .thenReturn(firstSheet, secondSheet); - shopifyCheckoutKitModule.present("https://example.com/first", presentationResult); + shopifyCheckoutKitModule.present("https://example.com/first", "open", presentationResult); nativeKit.verify(() -> ShopifyCheckoutKit.present( eq("https://example.com/first"), eq(mockComponentActivity), listeners.capture())); CustomCheckoutListener firstListener = listeners.getValue(); @@ -232,7 +241,7 @@ private void assertCanPresentAfterProgrammaticDismiss(boolean detachActivity) { shopifyCheckoutKitModule.dismiss(); shadowOf(Looper.getMainLooper()).idle(); when(mockReactContext.getCurrentActivity()).thenReturn(mockComponentActivity); - shopifyCheckoutKitModule.present("https://example.com/second", presentationResult); + shopifyCheckoutKitModule.present("https://example.com/second", "open", presentationResult); nativeKit.verify(() -> ShopifyCheckoutKit.present( eq("https://example.com/second"), eq(mockComponentActivity), listeners.capture())); assertThat(listeners.getValue().isReleased()).isFalse(); @@ -272,13 +281,13 @@ private void assertPresentWhileClosingIsIgnored(String terminal) { Callback ignoredResult = mock(Callback.class); nativeKit.when(() -> ShopifyCheckoutKit.present(anyString(), eq(mockComponentActivity), any())) .thenReturn(firstSheet, firstSheet, firstSheet, secondSheet); - shopifyCheckoutKitModule.present("https://example.com/first", presentationResult); + shopifyCheckoutKitModule.present("https://example.com/first", "open", presentationResult); verify(presentationResult).invoke(true); nativeKit.verify(() -> ShopifyCheckoutKit.present( eq("https://example.com/first"), eq(mockComponentActivity), listeners.capture())); shopifyCheckoutKitModule.onDispatch = event -> { shopifyCheckoutKitModule.onDispatch = null; - shopifyCheckoutKitModule.present("https://example.com/ignored", ignoredResult); + shopifyCheckoutKitModule.present("https://example.com/ignored", "open", ignoredResult); }; if (terminal.equals("fail")) listeners.getValue().onCheckoutFailed(new CheckoutFailureEvent(cartExpired())); @@ -291,14 +300,14 @@ private void assertPresentWhileClosingIsIgnored(String terminal) { verify(ignoredResult).invoke(false); // Repeated explicit attempts still recognize the closing handle. - shopifyCheckoutKitModule.present("https://example.com/still-closing", ignoredResult); + shopifyCheckoutKitModule.present("https://example.com/still-closing", "open", ignoredResult); verify(ignoredResult, times(2)).invoke(false); nativeKit.clearInvocations(); shadowOf(Looper.getMainLooper()).idleFor(Duration.ofSeconds(6)); nativeKit.verifyNoInteractions(); assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(1); - shopifyCheckoutKitModule.present("https://example.com/after-close", presentationResult); + shopifyCheckoutKitModule.present("https://example.com/after-close", "open", presentationResult); verify(presentationResult, times(2)).invoke(true); nativeKit.verify(() -> ShopifyCheckoutKit.present( eq("https://example.com/after-close"), eq(mockComponentActivity), listeners.capture())); @@ -322,7 +331,7 @@ public void testSynchronousInitializationFailureStillEmitsFailure() { listener.onCheckoutFailed(new CheckoutFailureEvent(cartExpired())); return null; }); - shopifyCheckoutKitModule.present("https://example.com/failing", presentationResult); + shopifyCheckoutKitModule.present("https://example.com/failing", "open", presentationResult); verify(presentationResult).invoke(true); assertThat(shopifyCheckoutKitModule.dispatchEvents).hasSize(1); assertThat(shopifyCheckoutKitModule.dispatchEvents.get(0)).contains("\"type\":\"fail\""); @@ -332,12 +341,12 @@ public void testSynchronousInitializationFailureStillEmitsFailure() { @Test public void testInvalidationCancelsQueuedPresentation() { try (MockedStatic nativeKit = Mockito.mockStatic(ShopifyCheckoutKit.class)) { - shopifyCheckoutKitModule.present("https://example.com/checkout", presentationResult); + shopifyCheckoutKitModule.present("https://example.com/checkout", "open", presentationResult); verify(mockComponentActivity).runOnUiThread(runnableCaptor.capture()); shopifyCheckoutKitModule.invalidate(); runnableCaptor.getValue().run(); shadowOf(Looper.getMainLooper()).idle(); - shopifyCheckoutKitModule.present("https://example.com/checkout", presentationResult); + shopifyCheckoutKitModule.present("https://example.com/checkout", "open", presentationResult); nativeKit.verifyNoInteractions(); verify(presentationResult, times(2)).invoke(false); assertThat(shopifyCheckoutKitModule.dispatchEvents).isEmpty(); diff --git a/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts b/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts index 902dd0f0d..3321e58ba 100644 --- a/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts +++ b/platforms/react-native/sample/src/hooks/useCheckoutEventHandlers.ts @@ -40,5 +40,6 @@ export function useShopifyEventHandlers( finishCheckout(); }, onRenderStateChange: event => log('onRenderStateChange', event), + onLinkClick: () => log('onLinkClick'), }; } diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/AcceleratedCheckouts_SupportedTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/AcceleratedCheckouts_SupportedTests.swift index 65d3129c8..94d86743d 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/AcceleratedCheckouts_SupportedTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/AcceleratedCheckouts_SupportedTests.swift @@ -331,6 +331,51 @@ class AcceleratedCheckouts_SupportedTests: XCTestCase { XCTAssertTrue(PKPaymentButtonType.from("unknown", fallback: .buy) == .buy) } + @MainActor + func testLinkHandlingStopsAfterDismissal() throws { + try assertLinkHandlingStops(fail: false) + } + + @MainActor + func testLinkHandlingStopsAfterFailure() throws { + try assertLinkHandlingStops(fail: true) + } + + @MainActor + private func assertLinkHandlingStops(fail: Bool) throws { + configureAcceleratedCheckouts(includeApplePay: false) + let checkout = try JSONDecoder().decode(Checkout.self, from: Data(#"{"id":"checkout-1","currency":"USD","status":"incomplete","line_items":[],"links":[],"totals":[]}"#.utf8)) + let link = try CheckoutLink(url: XCTUnwrap(URL(string: "https://example.test/policy"))) + for action in ["open", "handled", "cancel"] { + let view = RCTAcceleratedCheckoutButtonsView() + view.checkoutIdentifier = ["cartId": "gid://shopify/Cart/1"] + view.linkAction = action + let handlers = try XCTUnwrap(view.instance).eventHandlers + let handleLink = try XCTUnwrap(handlers.checkoutAction) + var links = 0 + view.onDispatch = { payload in + if (payload?["value"] as? String)?.contains("\"type\":\"linkClick\"") == true { links += 1 } + } + XCTAssertEqual(handleLink(link), .cancel) + XCTAssertEqual(links, 0) + handlers.checkoutDidStart?(CheckoutStartEvent(checkout: checkout)) + XCTAssertEqual(handleLink(link), checkoutLinkAction(action)) + handlers.checkoutDidComplete?(CheckoutCompleteEvent(checkout: checkout)) + XCTAssertEqual(handleLink(link), checkoutLinkAction(action)) + XCTAssertEqual(links, 2) + if fail { + handlers.checkoutDidFail?(CheckoutError(code: .sdkError, message: "Failed")) + } else { + handlers.checkoutDidDismiss?() + } + XCTAssertEqual(handleLink(link), .cancel) + XCTAssertEqual(links, 2) + handlers.checkoutDidStart?(CheckoutStartEvent(checkout: checkout)) + XCTAssertEqual(handleLink(link), checkoutLinkAction(action)) + XCTAssertEqual(links, 3) + } + } + func testConfigureAcceleratedCheckoutsReturnsFalseForInvalidApplePayContactField() { let storefrontDomain = "example.myshopify.com" let accessToken = "shpat_test_token" diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/CheckoutEventBridgeTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/CheckoutEventBridgeTests.swift index d25648252..1b10ff534 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/CheckoutEventBridgeTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/CheckoutEventBridgeTests.swift @@ -5,6 +5,18 @@ import XCTest @MainActor final class CheckoutEventBridgeTests: XCTestCase { + func testLinkActionNotifiesAndReturnsSynchronousPolicy() throws { + for action in ["open", "handled", "cancel"] { + var events: [String] = [] + let bridge = CheckoutEventBridge(linkAction: action, dispatch: { events.append($0) }, onTerminal: { _ in }) + let link = try CheckoutLink(url: XCTUnwrap(URL(string: "https://example.test/policy"))) + XCTAssertEqual(bridge.checkoutAction(for: link), checkoutLinkAction(action)) + let envelope = try parse(XCTUnwrap(events.first)) + XCTAssertEqual(envelope["type"] as? String, "linkClick") + XCTAssertEqual((envelope["payload"] as? [String: String])?["url"], "https://example.test/policy") + } + } + private func checkout() throws -> Checkout { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in @@ -32,7 +44,7 @@ final class CheckoutEventBridgeTests: XCTestCase { func testCompletionDoesNotReleaseCallbacks() throws { var events: [String] = [] var terminalCount = 0 - let bridge = CheckoutEventBridge(dispatch: { events.append($0) }, onTerminal: { _ in terminalCount += 1 }) + let bridge = CheckoutEventBridge(linkAction: "open", dispatch: { events.append($0) }, onTerminal: { _ in terminalCount += 1 }) let checkout = try checkout() bridge.checkoutDidStart(CheckoutStartEvent(checkout: checkout)) bridge.checkoutDidUpdate(CheckoutUpdateEvent(checkout: checkout)) @@ -47,7 +59,7 @@ final class CheckoutEventBridgeTests: XCTestCase { func testFailureUsesErrorEventAndReleasesCallbacks() throws { var events: [String] = [] - let bridge = CheckoutEventBridge(dispatch: { events.append($0) }, onTerminal: { _ in }) + let bridge = CheckoutEventBridge(linkAction: "open", dispatch: { events.append($0) }, onTerminal: { _ in }) bridge.checkoutDidFail(CheckoutFailureEvent(error: CheckoutError(code: .sdkError, message: "Failed"))) bridge.checkoutDidDismiss() let envelope = try parse(XCTUnwrap(events.first)) diff --git a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift index b9b7cfbae..b32c6544f 100644 --- a/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift +++ b/platforms/react-native/test/rct-integration-app/RCTIntegrationAppTests/ShopifyCheckoutKitTests.swift @@ -487,7 +487,7 @@ class ShopifyCheckoutKitTests: XCTestCase { func testFailedPresentDoesNotRetainCheckoutSheet() { let presentAttemptCompleted = expectation(description: "present attempt completed") - shopifyCheckoutKit.present("", onResult: { _ in }) + shopifyCheckoutKit.present("", linkAction: "open", onResult: { _ in }) DispatchQueue.main.async { XCTAssertNil(self.shopifyCheckoutKit.checkoutSheet) @@ -594,14 +594,15 @@ extension ShopifyCheckoutKitTests { @MainActor func testPresentWhileActivePreservesOriginalDelegate() async { let module = PresentationTrackingModule() - module.attemptPresentation("https://example.test/first") + module.attemptPresentation("https://example.test/first", linkAction: "handled") await flushPresentationQueue() let originalDelegate = module.delegates[0] - module.attemptPresentation("https://example.test/second") + module.attemptPresentation("https://example.test/second", linkAction: "cancel") await flushPresentationQueue() XCTAssertEqual(module.urls.count, 1) XCTAssertEqual(module.results, [true, false]) XCTAssertTrue(module.delegates[0] === originalDelegate) + XCTAssertEqual(originalDelegate.linkAction, .handled) originalDelegate.checkoutDidDismiss() XCTAssertEqual(module.events.count, 1) } @@ -622,8 +623,8 @@ private final class PresentationTrackingModule: RCTShopifyCheckoutKit { var results: [Bool?] = [] var onEvent: (() -> Void)? - func attemptPresentation(_ url: String) { - present(url, onResult: { self.results.append($0?.first as? Bool) }) + func attemptPresentation(_ url: String, linkAction: String = "open") { + present(url, linkAction: linkAction, onResult: { self.results.append($0?.first as? Bool) }) } override func getCurrentViewController(_: UIViewController? = nil) -> UIViewController? {