diff --git a/platforms/react-native/__mocks__/react-native.ts b/platforms/react-native/__mocks__/react-native.ts index 594df9f4..09a916cf 100644 --- a/platforms/react-native/__mocks__/react-native.ts +++ b/platforms/react-native/__mocks__/react-native.ts @@ -56,7 +56,14 @@ const UIManager = { if (name === 'RCTAcceleratedCheckoutButtons') { return { Constants: { - checkoutProtocolEventTypes: ['ec.start'], + checkoutProtocolEventTypes: [ + 'ec.complete', + 'ec.error', + 'ec.line_items.change', + 'ec.messages.change', + 'ec.start', + 'ec.totals.change', + ], }, }; } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt index 7562bf1e..968fe8dc 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/main/java/com/shopify/reactnative/checkoutkit/ProtocolRelay.kt @@ -38,11 +38,36 @@ object ProtocolRelay { var client = CheckoutProtocol.Client() for (method in subscribedMethods) { when (method) { + CheckoutProtocol.complete.method -> { + client = client.on(CheckoutProtocol.complete) { checkout -> + forwardEnvelope(method, checkout, dispatch) + } + } + CheckoutProtocol.error.method -> { + client = client.on(CheckoutProtocol.error) { error -> + forwardEnvelope(method, error, dispatch) + } + } + CheckoutProtocol.lineItemsChange.method -> { + client = client.on(CheckoutProtocol.lineItemsChange) { checkout -> + forwardEnvelope(method, checkout, dispatch) + } + } + CheckoutProtocol.messagesChange.method -> { + client = client.on(CheckoutProtocol.messagesChange) { checkout -> + forwardEnvelope(method, checkout, dispatch) + } + } CheckoutProtocol.start.method -> { client = client.on(CheckoutProtocol.start) { checkout -> forwardEnvelope(method, checkout, dispatch) } } + CheckoutProtocol.totalsChange.method -> { + client = client.on(CheckoutProtocol.totalsChange) { checkout -> + forwardEnvelope(method, checkout, dispatch) + } + } } } return client diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt index c31e83b2..2a612bfa 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/android/src/test/java/com/shopify/reactnative/checkoutkit/ProtocolRelayTest.kt @@ -81,6 +81,56 @@ class ProtocolRelayTest { assertThat(firstItem["imageUrl"]?.jsonPrimitive?.content).isEqualTo("https://example.com/image.png") } + @Test + fun `relay dispatches envelope for every public checkout state event`() { + val methods = listOf( + "ec.complete", + "ec.line_items.change", + "ec.messages.change", + "ec.start", + "ec.totals.change", + ) + + for (method in methods) { + var captured: String? = null + val client = ProtocolRelay.makeClient( + listOf(method), + DispatchCallback { json -> captured = json }, + ) + + client.process(checkoutNotificationFixture(method)) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val json = captured + assertThat(json).isNotNull() + val parsed = Json.parseToJsonElement(json!!).jsonObject + assertThat(parsed["type"]?.jsonPrimitive?.content).isEqualTo(method) + assertThat(parsed["payload"]!!.jsonObject["id"]?.jsonPrimitive?.content).isEqualTo("checkout-123") + } + } + + @Test + fun `relay dispatches envelope on ec error`() { + var captured: String? = null + val client = ProtocolRelay.makeClient( + listOf("ec.error"), + DispatchCallback { json -> captured = json }, + ) + + client.process(ecErrorNotificationFixture) + shadowOf(Looper.getMainLooper()).runToEndOfTasks() + + val json = captured + assertThat(json).isNotNull() + val parsed = Json.parseToJsonElement(json!!).jsonObject + assertThat(parsed["type"]?.jsonPrimitive?.content).isEqualTo("ec.error") + + val payload = parsed["payload"]!!.jsonObject + assertThat(payload["messages"]!!.jsonArray[0].jsonObject["content"]?.jsonPrimitive?.content) + .isEqualTo("Something went wrong") + assertThat(payload["ucp"]!!.jsonObject["status"]?.jsonPrimitive?.content).isEqualTo("error") + } + @Test fun `relay ignores methods not in subscribed list`() { var captured: String? = null @@ -118,6 +168,11 @@ private data class SnakePayload( @SerialName("line_items") val lineItems: List, ) +private fun checkoutNotificationFixture(method: String) = ecStartNotificationFixture.replace( + "\"method\": \"ec.start\"", + "\"method\": \"$method\"", +) + private val ecStartNotificationFixture = """ { "jsonrpc": "2.0", @@ -156,3 +211,25 @@ private val ecStartNotificationFixture = """ } } """.trimIndent() + +private val ecErrorNotificationFixture = """ +{ + "jsonrpc": "2.0", + "method": "ec.error", + "params": { + "error": { + "ucp": { + "version": "2026-04-08", + "status": "error" + }, + "messages": [ + { + "type": "error", + "content": "Something went wrong", + "severity": "recoverable" + } + ] + } + } +} +""".trimIndent() diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift index 084cfe25..848c5554 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/ProtocolRelay.swift @@ -29,7 +29,12 @@ import Foundation #endif let supportedProtocolRelayMethods = [ - CheckoutProtocol.start.method + CheckoutProtocol.complete.method, + CheckoutProtocol.error.method, + CheckoutProtocol.lineItemsChange.method, + CheckoutProtocol.messagesChange.method, + CheckoutProtocol.start.method, + CheckoutProtocol.totalsChange.method ] func makeRelayClient( @@ -40,10 +45,30 @@ func makeRelayClient( for method in subscribedMethods { switch method { + case CheckoutProtocol.complete.method: + client = client.on(CheckoutProtocol.complete) { checkout in + forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) + } + case CheckoutProtocol.error.method: + client = client.on(CheckoutProtocol.error) { error in + forwardEnvelope(type: method, payload: error, dispatch: dispatch) + } + case CheckoutProtocol.lineItemsChange.method: + client = client.on(CheckoutProtocol.lineItemsChange) { checkout in + forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) + } + case CheckoutProtocol.messagesChange.method: + client = client.on(CheckoutProtocol.messagesChange) { checkout in + forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) + } case CheckoutProtocol.start.method: client = client.on(CheckoutProtocol.start) { checkout in forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) } + case CheckoutProtocol.totalsChange.method: + client = client.on(CheckoutProtocol.totalsChange) { checkout in + forwardEnvelope(type: method, payload: checkout, dispatch: dispatch) + } default: continue } diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift index eec25bde..09a25d88 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/ios/Tests/ProtocolRelayTests.swift @@ -67,6 +67,53 @@ struct ProtocolRelayTests { #expect(firstItem["imageUrl"] as? String == "https://example.com/image.png") } + @MainActor + @Test func relayDispatchesEnvelopeForEveryPublicCheckoutStateEvent() async throws { + let methods = [ + "ec.complete", + "ec.line_items.change", + "ec.messages.change", + "ec.start", + "ec.totals.change" + ] + + for method in methods { + var captured: String? + let client = makeRelayClient( + subscribedMethods: [method], + dispatch: { json in captured = json } + ) + + _ = await client.process(checkoutNotificationFixture(method: method)) + + let json = try #require(captured) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + #expect(parsed["type"] as? String == method) + let payload = try #require(parsed["payload"] as? [String: Any]) + #expect(payload["id"] as? String == "checkout-123") + } + } + + @MainActor + @Test func relayDispatchesEnvelopeOnEcError() async throws { + var captured: String? + let client = makeRelayClient( + subscribedMethods: ["ec.error"], + dispatch: { json in captured = json } + ) + + _ = await client.process(ecErrorNotificationFixture) + + let json = try #require(captured) + let parsed = try #require(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any]) + #expect(parsed["type"] as? String == "ec.error") + let payload = try #require(parsed["payload"] as? [String: Any]) + let messages = try #require(payload["messages"] as? [[String: Any]]) + #expect(messages.first?["content"] as? String == "Something went wrong") + let ucp = try #require(payload["ucp"] as? [String: Any]) + #expect(ucp["status"] as? String == "error") + } + @MainActor @Test func relayIgnoresMethodsNotInSubscribedList() async throws { var captured: String? @@ -91,6 +138,13 @@ private struct SnakePayload: Codable { } } +private func checkoutNotificationFixture(method: String) -> String { + ecStartNotificationFixture.replacingOccurrences( + of: "\"method\": \"ec.start\"", + with: "\"method\": \"\(method)\"" + ) +} + private let ecStartNotificationFixture = #""" { "jsonrpc": "2.0", @@ -129,3 +183,25 @@ private let ecStartNotificationFixture = #""" } } """# + +private let ecErrorNotificationFixture = #""" +{ + "jsonrpc": "2.0", + "method": "ec.error", + "params": { + "error": { + "ucp": { + "version": "2026-04-08", + "status": "error" + }, + "messages": [ + { + "type": "error", + "content": "Something went wrong", + "severity": "recoverable" + } + ] + } + } +} +"""# 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 5fb44caf..b62a560a 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 @@ -115,7 +115,7 @@ interface CommonAcceleratedCheckoutButtonsProps { /** * Checkout Protocol event handlers scoped to this button instance. * - * Currently supports CheckoutProtocol.start. + * Supports all public Checkout Protocol notification events. */ events?: ProtocolHandlers; @@ -437,10 +437,11 @@ function routeProtocolDispatchEnvelope( return; } - const handler = (events as Record< - string, - ((payload: unknown) => void) | undefined - > | undefined)?.[envelope.type]; + const handler = ( + events as + | Record void) | undefined> + | undefined + )?.[envelope.type]; if (handler == null) { return; 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 a3fb8a12..2129f346 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 @@ -23,7 +23,12 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SO import type {CheckoutException} from './errors'; import type {ProtocolHandlers} from './protocol'; -export type {Checkout, CheckoutProtocolPayloads, ProtocolHandlers} from './protocol'; +export type { + Checkout, + CheckoutProtocolPayloads, + ErrorResponse, + ProtocolHandlers, +} from './protocol'; export type Maybe = T | undefined; 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 2473be19..5e974c24 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 @@ -67,6 +67,7 @@ import {CheckoutProtocol} from './protocol'; import type { Checkout, CheckoutProtocolPayloads, + ErrorResponse, ProtocolHandlers, } from './protocol'; @@ -155,12 +156,14 @@ class ShopifyCheckout implements ShopifyCheckoutKit { .map(([method]) => method); if (dispatcher) { - this.dispatchSubscription = RNShopifyCheckoutKit.onDispatch(envelopeJson => { - dispatcher(envelopeJson); - if (isTerminalDispatchEnvelope(envelopeJson)) { - this.releaseDispatchSubscription(); - } - }); + this.dispatchSubscription = RNShopifyCheckoutKit.onDispatch( + envelopeJson => { + dispatcher(envelopeJson); + if (isTerminalDispatchEnvelope(envelopeJson)) { + this.releaseDispatchSubscription(); + } + }, + ); } RNShopifyCheckoutKit.present(checkoutUrl, subscribedMethods); @@ -396,10 +399,12 @@ class ShopifyCheckout implements ShopifyCheckoutKit { const protocolHandler = protocol == null ? undefined - : (protocol as Record< - string, - ((payload: unknown) => void) | undefined - >)[type]; + : ( + protocol as Record< + string, + ((payload: unknown) => void) | undefined + > + )[type]; if (protocolHandler) { if (!isPlainObject(payload)) { @@ -673,6 +678,7 @@ export type { CheckoutException, CheckoutProtocolPayloads, Configuration, + ErrorResponse, Features, GeolocationRequestEvent, IosColors, diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts index 9f5d3c1b..710075bd 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/src/protocol.ts @@ -21,16 +21,26 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type {Checkout} from '@shopify/checkout-kit-protocol'; +import type {Checkout, ErrorResponse} from '@shopify/checkout-kit-protocol'; -export type {Checkout} from '@shopify/checkout-kit-protocol'; +export type {Checkout, ErrorResponse} from '@shopify/checkout-kit-protocol'; export const CheckoutProtocol = { + complete: 'ec.complete', + error: 'ec.error', + lineItemsChange: 'ec.line_items.change', + messagesChange: 'ec.messages.change', start: 'ec.start', + totalsChange: 'ec.totals.change', } as const; export interface CheckoutProtocolPayloads { + 'ec.complete': Checkout; + 'ec.error': ErrorResponse; + 'ec.line_items.change': Checkout; + 'ec.messages.change': Checkout; 'ec.start': Checkout; + 'ec.totals.change': Checkout; } export type ProtocolHandlers = Partial<{ diff --git a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts index e30528ff..136b41d9 100644 --- a/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts +++ b/platforms/react-native/modules/@shopify/checkout-kit-react-native/tests/protocol.test.ts @@ -1,33 +1,93 @@ import { CheckoutProtocol, type Checkout, + type ErrorResponse, type ProtocolHandlers, } from '../src'; +const checkoutPayloadMethods = [ + CheckoutProtocol.complete, + CheckoutProtocol.lineItemsChange, + CheckoutProtocol.messagesChange, + CheckoutProtocol.start, + CheckoutProtocol.totalsChange, +] as const; + describe('CheckoutProtocol', () => { describe('runtime values', () => { - it('exposes ec.start as the literal method string', () => { - expect(CheckoutProtocol.start).toBe('ec.start'); + it('exposes all public checkout protocol notification method strings', () => { + expect(CheckoutProtocol).toEqual({ + complete: 'ec.complete', + error: 'ec.error', + lineItemsChange: 'ec.line_items.change', + messagesChange: 'ec.messages.change', + start: 'ec.start', + totalsChange: 'ec.totals.change', + }); + expect(CheckoutProtocol).not.toHaveProperty('buyerChange'); }); }); describe('ProtocolHandlers typing', () => { - it('accepts a handler keyed by CheckoutProtocol.start', () => { + it('accepts handlers keyed by every public CheckoutProtocol event', () => { const handlers: ProtocolHandlers = { - [CheckoutProtocol.start]: chk => { - expect(typeof chk.id).toBe('string'); + [CheckoutProtocol.complete]: checkout => { + expect(typeof checkout.id).toBe('string'); + }, + [CheckoutProtocol.error]: error => { + expect(error.messages).toBeDefined(); + }, + [CheckoutProtocol.lineItemsChange]: checkout => { + expect(typeof checkout.id).toBe('string'); + }, + [CheckoutProtocol.messagesChange]: checkout => { + expect(typeof checkout.id).toBe('string'); + }, + [CheckoutProtocol.start]: checkout => { + expect(typeof checkout.id).toBe('string'); + }, + [CheckoutProtocol.totalsChange]: checkout => { + expect(typeof checkout.id).toBe('string'); }, }; + expect(typeof handlers[CheckoutProtocol.complete]).toBe('function'); + expect(typeof handlers[CheckoutProtocol.error]).toBe('function'); + expect(typeof handlers[CheckoutProtocol.lineItemsChange]).toBe( + 'function', + ); + expect(typeof handlers[CheckoutProtocol.messagesChange]).toBe('function'); expect(typeof handlers[CheckoutProtocol.start]).toBe('function'); + expect(typeof handlers[CheckoutProtocol.totalsChange]).toBe('function'); + }); + + it('infers Checkout as the payload type for checkout-state events', () => { + type HandlerMap = ProtocolHandlers; + type CheckoutPayloadMethod = (typeof checkoutPayloadMethods)[number]; + type CheckoutPayloadParam = Parameters< + NonNullable + >[0]; + + type AllCheckoutPayloads = { + [K in CheckoutPayloadMethod]: Checkout extends CheckoutPayloadParam + ? CheckoutPayloadParam extends Checkout + ? true + : false + : false; + }[CheckoutPayloadMethod]; + + const _typeCheck: AllCheckoutPayloads = true; + + expect(_typeCheck).toBe(true); }); - it('infers Checkout as the start handler payload type', () => { - type StartHandler = NonNullable; - type StartParam = Parameters[0]; + it('infers ErrorResponse as the error handler payload type', () => { + type ErrorHandler = NonNullable; + type ErrorParam = Parameters[0]; - const _typeCheck: Checkout extends StartParam ? true : false = true; - const _reverseCheck: StartParam extends Checkout ? true : false = true; + const _typeCheck: ErrorResponse extends ErrorParam ? true : false = true; + const _reverseCheck: ErrorParam extends ErrorResponse ? true : false = + true; expect(_typeCheck).toBe(true); expect(_reverseCheck).toBe(true);