Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion platforms/react-native/__mocks__/react-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,56 @@ class ProtocolRelayTest {
assertThat(logs.single().throwable).isSameAs(failure)
}

@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
Expand Down Expand Up @@ -120,6 +170,11 @@ private data class SnakePayload(
@SerialName("line_items") val lineItems: List<String>,
)

private fun checkoutNotificationFixture(method: String) = ecStartNotificationFixture.replace(
"\"method\": \"ec.start\"",
"\"method\": \"$method\"",
)

private val ecStartNotificationFixture = """
{
"jsonrpc": "2.0",
Expand Down Expand Up @@ -160,3 +215,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()
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
```ts

import { Checkout } from '@shopify/checkout-kit-protocol';
import { CheckoutProtocol } from '@shopify/checkout-kit-protocol';
import { CheckoutProtocolPayloads } from '@shopify/checkout-kit-protocol';
import { ErrorResponse } from '@shopify/checkout-kit-protocol';
import type { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';

Expand Down Expand Up @@ -181,16 +184,9 @@ export enum CheckoutNativeErrorType {
UnknownError = "UnknownError"
}

// @public (undocumented)
export const CheckoutProtocol: {
readonly start: "ec.start";
};
export { CheckoutProtocol }

// @public (undocumented)
export interface CheckoutProtocolPayloads {
// (undocumented)
'ec.start': Checkout;
}
export { CheckoutProtocolPayloads }

// @public (undocumented)
export enum ColorScheme {
Expand Down Expand Up @@ -235,6 +231,8 @@ export class DispatchEventParityError extends Error {
constructor(message: string);
}

export { ErrorResponse }

// @public
export interface Features {
handleGeolocationRequests: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ struct DispatchEnvelope<Payload: Encodable>: Encodable {
// event stream. Payloads are emitted in protocol wire casing; JS performs the
// schema-aware conversion to the public camelCase shape with QuickType.
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(
Expand All @@ -27,10 +32,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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,53 @@ struct ProtocolRelayTests {
#expect(paymentHandlers["com.example.loyalty_gold"] != nil)
}

@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?
Expand All @@ -72,6 +119,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",
Expand Down Expand Up @@ -112,3 +166,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"
}
]
}
}
}
"""#
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,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;

Expand Down Expand Up @@ -414,10 +414,11 @@ function routeProtocolDispatchEnvelope(
return;
}

const handler = (events as Record<
string,
((payload: unknown) => void) | undefined
> | undefined)?.[envelope.type];
const handler = (
events as
| Record<string, ((payload: unknown) => void) | undefined>
| undefined
)?.[envelope.type];

if (handler == null) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
export type {
Checkout,
CheckoutProtocolPayloads,
ErrorResponse,
ProtocolHandlers,
} from './protocol';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {CheckoutProtocol} from './protocol';
import type {
Checkout,
CheckoutProtocolPayloads,
ErrorResponse,
ProtocolHandlers,
} from './protocol';

Expand Down Expand Up @@ -388,6 +389,7 @@ export type {
CheckoutException,
CheckoutProtocolPayloads,
Configuration,
ErrorResponse,
Features,
GeolocationRequestEvent,
IosColors,
Expand Down
Loading
Loading