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 @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -118,6 +168,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 @@ -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()
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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",
Expand Down Expand Up @@ -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"
}
]
}
}
}
"""#
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<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 @@ -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> = T | undefined;

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

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -673,6 +678,7 @@ export type {
CheckoutException,
CheckoutProtocolPayloads,
Configuration,
ErrorResponse,
Features,
GeolocationRequestEvent,
IosColors,
Expand Down
Loading
Loading