diff --git a/platforms/web/README.md b/platforms/web/README.md index 94398f32f..60c8b6bf1 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -17,8 +17,7 @@ store customizations: Checkout UI extensions, Functions, branding, and more. It also provides web idiomatic defaults such as opening checkout in a popup or new tab, a transient overlay scrim while the popup is open, and convenient developer APIs to embed, customize, and follow the lifecycle of the checkout -experience via the -[Embedded Checkout Protocol](https://ucp.dev/2026-04-08/specification/embedded-checkout/). +experience through typed Checkout Kit events. Check out our blog to [learn how and why we built the Shopify Checkout Kit](https://www.shopify.com/partners/blog/mobile-checkout-sdks-for-ios-and-android). @@ -134,7 +133,7 @@ checkout.src = 'https://your-store.myshopify.com/checkouts/cn/abc123'; checkout.target = 'popup'; document.body.append(checkout); -checkout.addEventListener('ec.complete', (event) => { +checkout.addEventListener('complete', (event) => { console.log('Order complete', event.detail.checkout.order?.id); }); @@ -163,7 +162,7 @@ React 19+ has first-class support for custom elements — it renders `` and forwards props to it as properties with no extra configuration. Reach for a `ref` for the two things that aren't expressible as JSX props: calling imperative methods (`open()`, `close()`, `focus()`) and -subscribing to the `ec.*` events. +subscribing to Checkout Kit events. ```tsx import {useEffect, useRef} from 'react'; @@ -182,11 +181,11 @@ export function BuyNowButton({checkoutUrl}: {checkoutUrl: string}) { const {signal} = controller; checkout.addEventListener( - 'ec.complete', + 'complete', (event) => console.log('Order complete', event.detail.checkout.order?.id), {signal}, ); - checkout.addEventListener('ec.close', () => console.log('Dismissed'), { + checkout.addEventListener('close', () => console.log('Dismissed'), { signal, }); @@ -203,7 +202,7 @@ export function BuyNowButton({checkoutUrl}: {checkoutUrl: string}) { ``` `event` is fully typed inside each listener. For example, order data for -`ec.complete` is available at `event.detail.checkout.order`. The element's +`complete` is available at `event.detail.checkout.order`. The element's overloaded `addEventListener` signatures provide these types. See [Checkout lifecycle](#checkout-lifecycle) for the full event list. @@ -483,49 +482,66 @@ shopify-checkout::part(overlay) { ## Checkout lifecycle -The element dispatches `ec.*` `CustomEvent`s at every meaningful moment -of the checkout session. All events bubble, so you can listen anywhere in your -DOM — including a single delegated listener at `document` if you have many -elements on the page. Each event carries a typed `event.detail` payload with -exactly the fields relevant to that moment. - -| Event | `event.detail` | When it fires | -| ---------------------- | -------------- | -------------------------------------------------------------------------- | -| `ec.start` | `{checkout}` | Checkout has loaded and is interactive. | -| `ec.complete` | `{checkout}` | The buyer completed the order successfully. | -| `ec.close` | _(none)_ | The open session ended through `close()`, overlay dismissal, or detection of a popup the buyer closed. | -| `ec.error` | `{error}` | Checkout reported an error. The component closes automatically only when a message has `unrecoverable` severity. | -| `ec.fulfillment.change` | `{checkout}` | The checkout's fulfillment details changed. | -| `ec.line_items.change` | `{checkout}` | The cart's line items changed (item added/removed/quantity updated). | -| `ec.totals.change` | `{checkout}` | The cart totals changed (subtotal, tax, shipping, discounts, total). | -| `ec.messages.change` | `{checkout}` | Checkout-level warnings/errors/info shown inside the checkout changed. | - -`ec.start`, `ec.complete`, and the change events carry the full UCP `Checkout` -snapshot in `event.detail.checkout` for handlers that need broader context. +The element dispatches typed `CustomEvent`s at every meaningful moment of the +checkout session. The `start`, `update`, `complete`, and `close` events bubble, +so you can listen anywhere in your DOM, including a single delegated listener +at `document` if you have many elements on the page. The `error` event does not +bubble; attach its listener directly to the checkout element. Event payloads +are available in `event.detail`. + +| Event | `event.detail` | When it fires | +| ---------- | -------------- | ------------- | +| `start` | `{checkout}` | Checkout has loaded and is interactive. | +| `update` | `{checkout}` | A change to line items, fulfillment, totals, or checkout messages produces a different checkout snapshot. | +| `complete` | `{checkout}` | The buyer completed the order successfully. | +| `error` | `{error}` | Checkout reported a terminal error, exposed as `{code, message}`. The component closes automatically after this event. | +| `close` | _(none)_ | The open session ended through `close()`, overlay dismissal, or detection of a popup the buyer closed. | + +`start`, `update`, and `complete` carry a Checkout Kit `Checkout` snapshot in +`event.detail.checkout`. It preserves checkout data, including unknown +extension fields, and omits the protocol's top-level `ucp` metadata. Known +fields use camelCase names such as `lineItems` and `fulfillment`. +Unknown extension properties remain inline and keep their original names. + +All four supported change notifications feed the same `update` event. Repeated +identical snapshots are deduplicated, including when separate notifications +describe the same checkout state. Read the fields you need from the full +snapshot; there is no list of changed fields. Buyer and payment updates are +not currently supported. +Start and complete events are always delivered. Opening checkout starts a +fresh snapshot history and clears the previous checkout and error properties. ```ts -checkout.addEventListener('ec.complete', (event) => { +checkout.addEventListener('complete', (event) => { const {order} = event.detail.checkout; if (order) { analytics.track('checkout_complete', {orderId: order.id}); } }); -checkout.addEventListener('ec.totals.change', (event) => { +checkout.addEventListener('update', (event) => { miniCart.updateTotals(event.detail.checkout.totals); }); -checkout.addEventListener('ec.close', () => { +checkout.addEventListener('error', (event) => { + const {code, message} = event.detail.error; + console.error('Checkout error', code, message); +}); + +checkout.addEventListener('close', () => { router.back(); }); ``` +Protocol errors are terminal for the checkout session regardless of message +severity. The component emits `error` before closing and emitting `close`. + Because these events carry the full snapshot, one handler can combine fields. -For example, rendering an inline cart summary on `ec.start` requires line -items, totals, and currency together: +For example, rendering an inline cart summary on `start` requires line items, +totals, and currency together: ```ts -checkout.addEventListener('ec.start', (event) => { +checkout.addEventListener('start', (event) => { const {checkout: snapshot} = event.detail; loadingSpinner.hide(); cartSummary.render({ @@ -536,18 +552,11 @@ checkout.addEventListener('ec.start', (event) => { }); ``` -The latest full UCP `Checkout` snapshot is also mirrored to `element.checkout` -whenever an event with `{checkout}` arrives. The latest error is mirrored to -`element.error` when `ec.error` fires. These properties are useful for handlers -that don't have a reference to the originating event. TypeScript users get -fully typed events through overloaded `addEventListener` signatures with no -additional setup. - -> [!NOTE] -> Most public `ec.*` DOM event names mirror the underlying -> [Embedded Checkout Protocol](https://ucp.dev/2026-04-08/specification/embedded-checkout/) -> JSON-RPC method names. `ec.close` is component-only and synthetic; it is not -> part of the ECP wire protocol. +The latest snapshot is also mirrored to `element.checkout`. The latest +`{code, message}` error is mirrored to `element.error` when `error` fires. +These properties are useful for handlers that don't have a reference to the +originating event. TypeScript users get fully typed events through overloaded +`addEventListener` signatures with no additional setup. ## Explore the sample app diff --git a/platforms/web/sample/README.md b/platforms/web/sample/README.md index 03a8dce1f..ce330db4e 100644 --- a/platforms/web/sample/README.md +++ b/platforms/web/sample/README.md @@ -1,6 +1,6 @@ # Web Component Playground -A development harness for the `` web component. It imports the same entry as published consumers (`@shopify/checkout-kit`, aliased to `../src/index.ts` in dev), registers the custom element, and logs `ec.*` events. +A development harness for the `` web component. It imports the same entry as published consumers (`@shopify/checkout-kit`, aliased to `../src/index.ts` in dev), registers the custom element, and logs Checkout Kit lifecycle events. ## Run locally @@ -34,10 +34,17 @@ You can also choose **Use existing checkout source** in Settings. In that mode, - **Settings** — persisted storefront domain, flow, target (`popup` | `auto`), appearance (default `storefront` | `app:light` | `app:dark` | `app:automatic` | `storefront`), and log-level (`debug` | `warn` | `error` | `none`) settings. The storefront domain appears first because the cart builder cannot load products without it. - **Center workspace** — build mode shows a storefront-style product grid plus sticky cart banner; manual mode shows a focused checkout URL/cart permalink input. -- **Runtime** — shows component state above the `ec.*` event log, with a JSON snapshot of component state at fire time. +- **Runtime** — shows component state above the `start`, `update`, `complete`, `error`, and `close` event log. Each entry includes the event detail and a JSON snapshot of component state at fire time. The element is mounted on ``. For `popup` / `auto`, the visible UI is mostly the overlay scrim while checkout is open in a separate window or tab. +The `start`, `update`, and `complete` events expose the latest Checkout Kit +snapshot at `event.detail.checkout`, without protocol metadata. Changes to line +items, fulfillment, totals, or messages feed one `update` event; repeated identical +snapshots do not produce another update. The `error` event exposes a +`{code, message}` error. Checkout stays open for recoverable errors and closes +automatically only for errors with `unrecoverable` severity. + ## Troubleshooting product loading The demo relies on the public `/products.json` endpoint. If product loading fails: diff --git a/platforms/web/sample/index.html b/platforms/web/sample/index.html index d2349cd31..f73c8fad2 100644 --- a/platforms/web/sample/index.html +++ b/platforms/web/sample/index.html @@ -235,7 +235,7 @@

Events

    - Open checkout and interact; ec.* events from the component appear here. + Open checkout and interact; lifecycle events appear here.

    diff --git a/platforms/web/sample/main.ts b/platforms/web/sample/main.ts index 910e7d8a5..86f78e258 100644 --- a/platforms/web/sample/main.ts +++ b/platforms/web/sample/main.ts @@ -21,16 +21,7 @@ import { } from "./storage"; import "./styles.css"; -const EVENT_TYPES = [ - "ec.start", - "ec.complete", - "ec.close", - "ec.error", - "ec.fulfillment.change", - "ec.line_items.change", - "ec.totals.change", - "ec.messages.change", -] as const; +const EVENT_TYPES = ["start", "update", "complete", "close", "error"] as const; const refs = queryRefs(); @@ -129,10 +120,11 @@ function openCheckout(): void { checkout.open(); } -function recordEvent(type: string): void { +function recordEvent(event: Event): void { const snapshot: ComponentSnapshot = { checkout: checkout.checkout, error: checkout.error }; const json = JSON.stringify( { + detail: event instanceof CustomEvent ? (event.detail as unknown) : undefined, checkout: checkout.checkout, error: checkout.error, target: checkout.target, @@ -144,7 +136,7 @@ function recordEvent(type: string): void { ); store.setState({ component: snapshot, - log: [{ type, time: timestamp(), snapshot: json }, ...store.getState().log], + log: [{ type: event.type, time: timestamp(), snapshot: json }, ...store.getState().log], }); } @@ -281,10 +273,7 @@ function attachListeners(): void { store.setState({ log: [] }); }); - const checkoutEl: HTMLElement = checkout; for (const type of EVENT_TYPES) { - checkoutEl.addEventListener(type, () => { - recordEvent(type); - }); + checkout.addEventListener(type, recordEvent); } } diff --git a/platforms/web/sample/render.test.ts b/platforms/web/sample/render.test.ts index af74a0722..7a0b80dbb 100644 --- a/platforms/web/sample/render.test.ts +++ b/platforms/web/sample/render.test.ts @@ -193,15 +193,15 @@ describe("renderLog", () => { refs, state({ log: [ - { type: "ec.close", time: "00:00:02.000", snapshot: "{}" }, - { type: "ec.start", time: "00:00:01.000", snapshot: "{}" }, + { type: "close", time: "00:00:02.000", snapshot: "{}" }, + { type: "start", time: "00:00:01.000", snapshot: "{}" }, ], }), ); const names = [...refs.eventLog.querySelectorAll(".event-entry-name")].map( (el) => el.textContent, ); - expect(names).toEqual(["ec.close", "ec.start"]); + expect(names).toEqual(["close", "start"]); }); it("collapses the events panel", () => { diff --git a/platforms/web/src/checkout-events.ts b/platforms/web/src/checkout-events.ts new file mode 100644 index 000000000..ec498159f --- /dev/null +++ b/platforms/web/src/checkout-events.ts @@ -0,0 +1,67 @@ +import type { Checkout } from "./models/checkout"; +import type { CheckoutError } from "./models/error"; + +export interface ShopifyCheckoutStartEventDetail { + checkout: Checkout; +} + +export interface ShopifyCheckoutUpdateEventDetail { + checkout: Checkout; +} + +export interface ShopifyCheckoutCompleteEventDetail { + checkout: Checkout; +} + +export interface ShopifyCheckoutErrorEventDetail { + error: CheckoutError; +} + +export class ShopifyCheckoutStartEvent extends CustomEvent { + declare type: "start"; + + constructor(detail: ShopifyCheckoutStartEventDetail) { + super("start", { detail, bubbles: true }); + } +} + +export class ShopifyCheckoutUpdateEvent extends CustomEvent { + declare type: "update"; + + constructor(detail: ShopifyCheckoutUpdateEventDetail) { + super("update", { detail, bubbles: true }); + } +} + +export class ShopifyCheckoutCompleteEvent extends CustomEvent { + declare type: "complete"; + + constructor(detail: ShopifyCheckoutCompleteEventDetail) { + super("complete", { detail, bubbles: true }); + } +} + +export class ShopifyCheckoutCloseEvent extends CustomEvent { + declare type: "close"; + + constructor() { + super("close", { bubbles: true }); + } +} + +export class ShopifyCheckoutErrorEvent extends CustomEvent { + declare type: "error"; + + constructor(detail: ShopifyCheckoutErrorEventDetail) { + // Keep checkout failures out of window.onerror. + super("error", { detail, bubbles: false }); + } +} + +export interface ShopifyCheckoutEventMap { + start: ShopifyCheckoutStartEvent; + update: ShopifyCheckoutUpdateEvent; + complete: ShopifyCheckoutCompleteEvent; + error: ShopifyCheckoutErrorEvent; + close: ShopifyCheckoutCloseEvent; +} diff --git a/platforms/web/src/checkout-protocol.test.ts b/platforms/web/src/checkout-protocol.test.ts index de9a76fea..5da94edee 100644 --- a/platforms/web/src/checkout-protocol.test.ts +++ b/platforms/web/src/checkout-protocol.test.ts @@ -1,12 +1,22 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { EmbeddedCheckoutProtocol } from "@shopify/checkout-kit-protocol"; +import { + EmbeddedCheckoutProtocol, + type ErrorResponse, + type Message, +} from "@shopify/checkout-kit-protocol"; -import type { CheckoutProtocolMessageMap, ErrorResponse, Message } from "./checkout.types"; +import type { CheckoutProtocolMessageMap } from "./checkout.types"; import "./checkout-web-component"; import type { ShopifyCheckout } from "./checkout"; import { mockTelemetry } from "./telemetry.test-helpers"; const EMBED_PROTOCOL_VERSION = EmbeddedCheckoutProtocol.specVersion; +const CHECKOUT_CHANGE_METHODS = [ + "ec.line_items.change", + "ec.fulfillment.change", + "ec.totals.change", + "ec.messages.change", +] as const; describe("", () => { beforeEach(() => { @@ -154,22 +164,28 @@ describe("", () => { expect(mockCheckoutWindow.postMessage).not.toHaveBeenCalled(); }); - it.each(["customMethod", "ec.buyer.change"])( + it.each(["customMethod", "ec.buyer.change", "ec.payment.change"])( "ignores unsupported notification %s", - (method) => { + async (method) => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const updateSpy = vi.fn(); + checkout.addEventListener("update", updateSpy); simulateRawMessageEvent( checkout, { jsonrpc: "2.0", method, - params: {}, + params: makeCheckoutPayload(), }, { source: mockCheckoutWindow }, ); + await flushProtocolDispatch(); + expect(mockCheckoutWindow.postMessage).not.toHaveBeenCalled(); + expect(updateSpy).not.toHaveBeenCalled(); + expect(checkout.checkout).toBeUndefined(); }, ); @@ -197,10 +213,10 @@ describe("", () => { }); describe("ec.start", () => { - it("updates the checkout property and dispatches an ec.start event", async () => { + it("updates the checkout property and dispatches a start event", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onStartSpy = vi.fn(); - const listenForEvent = waitForEvent(checkout, "ec.start", onStartSpy); + const listenForEvent = waitForEvent(checkout, "start", onStartSpy); const payload = makeCheckoutPayload(); simulateProtocolMessageEvent(checkout, "ec.start", payload, { @@ -241,10 +257,10 @@ describe("", () => { }); describe("ec.complete", () => { - it("updates the checkout property and dispatches an ec.complete event", async () => { + it("updates the checkout property and dispatches a complete event", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onCompleteSpy = vi.fn(); - const listenForEvent = waitForEvent(checkout, "ec.complete", onCompleteSpy); + const listenForEvent = waitForEvent(checkout, "complete", onCompleteSpy); const payload = makeCheckoutPayload(); simulateProtocolMessageEvent(checkout, "ec.complete", payload, { @@ -258,13 +274,13 @@ describe("", () => { }); describe("ec.error", () => { - it("updates the error property and dispatches an ec.error event", async () => { + it("updates the error property and dispatches an error event", async () => { const telemetry = mockTelemetry(); const telemetrySpy = vi.spyOn(telemetry, "recordError"); const durationSpy = vi.spyOn(telemetry, "recordNavigationDuration"); const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onErrorSpy = vi.fn(); - const listenForEvent = waitForEvent(checkout, "ec.error", onErrorSpy); + const listenForEvent = waitForEvent(checkout, "error", onErrorSpy); const errorParams = makeErrorParams({ severity: "recoverable" }); simulateProtocolMessageEvent(checkout, "ec.error", errorParams, { @@ -272,7 +288,7 @@ describe("", () => { }); await listenForEvent; - expect(checkout.error).toEqual(decodeError(errorParams)); + expect(checkout.error).toEqual({ code: "unknown", message: "Session failed" }); expect(onErrorSpy).toHaveBeenCalledOnce(); expect(telemetrySpy).toHaveBeenCalledWith({ category: "protocol", @@ -288,10 +304,41 @@ describe("", () => { }); }); + it("delivers checkout errors to the element without triggering global error handlers", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const onError = vi.fn(); + const onDocumentError = vi.fn(); + const onWindowError = vi.fn(); + const onGlobalError = vi.fn(); + const originalOnError = window.onerror; + checkout.addEventListener("error", onError); + document.addEventListener("error", onDocumentError); + window.addEventListener("error", onWindowError); + // oxlint-disable-next-line unicorn/prefer-add-event-listener -- Exercise the hook used by error-reporting SDKs. + window.onerror = onGlobalError; + + try { + simulateProtocolMessageEvent(checkout, "ec.error", makeErrorParams(), { + source: mockCheckoutWindow, + }); + await flushProtocolDispatch(); + + expect(onError).toHaveBeenCalledOnce(); + expect(onDocumentError).not.toHaveBeenCalled(); + expect(onWindowError).not.toHaveBeenCalled(); + expect(onGlobalError).not.toHaveBeenCalled(); + } finally { + document.removeEventListener("error", onDocumentError); + window.removeEventListener("error", onWindowError); + // oxlint-disable-next-line unicorn/prefer-add-event-listener -- Restore the previous global error hook. + window.onerror = originalOnError; + } + }); + it("ignores the old ec.error shape with ucp and messages directly in params", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onErrorSpy = vi.fn(); - checkout.addEventListener("ec.error", onErrorSpy); + checkout.addEventListener("error", onErrorSpy); const errorPayload = makeErrorPayload(); window.dispatchEvent( @@ -311,6 +358,31 @@ describe("", () => { expect(onErrorSpy).not.toHaveBeenCalled(); }); + it("auto-closes for mixed message severities", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const errorOrder: string[] = []; + checkout.addEventListener("error", () => errorOrder.push("error")); + checkout.addEventListener("close", () => errorOrder.push("close")); + + simulateProtocolMessageEvent( + checkout, + "ec.error", + { + error: { + ...makeErrorPayload(), + messages: [ + ...makeErrorPayload({ severity: "recoverable" }).messages, + ...makeErrorPayload({ severity: "unrecoverable" }).messages, + ], + }, + }, + { source: mockCheckoutWindow }, + ); + await flushProtocolDispatch(); + + expect(errorOrder).toStrictEqual(["error", "close"]); + }); + const ERROR_SEVERITIES: ReadonlyArray = [ "unrecoverable", "recoverable", @@ -323,8 +395,8 @@ describe("", () => { const durationSpy = vi.spyOn(mockTelemetry(), "recordNavigationDuration"); const { checkout, mockCheckoutWindow } = openPopupCheckout(); const errorOrder: string[] = []; - checkout.addEventListener("ec.error", () => errorOrder.push("error")); - checkout.addEventListener("ec.close", () => errorOrder.push("close")); + checkout.addEventListener("error", () => errorOrder.push("error")); + checkout.addEventListener("close", () => errorOrder.push("close")); simulateProtocolMessageEvent(checkout, "ec.error", makeErrorParams({ severity }), { source: mockCheckoutWindow, @@ -345,8 +417,8 @@ describe("", () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onErrorSpy = vi.fn(); const closeSpy = vi.fn(); - checkout.addEventListener("ec.error", onErrorSpy); - checkout.addEventListener("ec.close", closeSpy); + checkout.addEventListener("error", onErrorSpy); + checkout.addEventListener("close", closeSpy); const nodeProcess = ( globalThis as unknown as { @@ -389,83 +461,151 @@ describe("", () => { }); }); - describe("ec.line_items.change", () => { - it("updates the checkout property and dispatches an ec.line_items.change event", async () => { - const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const onLineItemsChangeSpy = vi.fn(); - const listenForEvent = waitForEvent(checkout, "ec.line_items.change", onLineItemsChangeSpy); + describe("checkout updates", () => { + it.each(CHECKOUT_CHANGE_METHODS)( + "%s updates the checkout property and emits one update event", + async (method) => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const updateSpy = vi.fn(); + checkout.addEventListener("update", updateSpy); + const payload = makeCheckoutPayload(); - const payload = makeCheckoutPayload(); - simulateProtocolMessageEvent(checkout, "ec.line_items.change", payload, { - source: mockCheckoutWindow, + simulateProtocolMessageEvent(checkout, method, payload, { + source: mockCheckoutWindow, + }); + await flushProtocolDispatch(); + + expect(updateSpy).toHaveBeenCalledOnce(); + expect(checkout.checkout).toEqual(decodeCheckout(payload)); + expect(updateSpy.mock.calls[0]![0].detail.checkout).toBe(checkout.checkout); + }, + ); + + it("deduplicates structurally equal snapshots across change methods", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const updateSpy = vi.fn(); + checkout.addEventListener("update", updateSpy); + const extension = { enabled: true, nested: { first: 1, second: 2 } }; + const payload = makeCheckoutPayload({ "com.example.extension": extension }); + const reordered = makeCheckoutPayload({ + "com.example.extension": { nested: { second: 2, first: 1 }, enabled: true }, }); - await listenForEvent; + reordered.checkout = { + "com.example.extension": reordered.checkout["com.example.extension"], + ...reordered.checkout, + }; + for (const [index, method] of CHECKOUT_CHANGE_METHODS.entries()) { + simulateProtocolMessageEvent(checkout, method, index === 0 ? payload : reordered, { + source: mockCheckoutWindow, + }); + await flushProtocolDispatch(); + } + + expect(updateSpy).toHaveBeenCalledOnce(); expect(checkout.checkout).toEqual(decodeCheckout(payload)); - expect(onLineItemsChangeSpy).toHaveBeenCalledOnce(); }); - }); - describe("ec.fulfillment.change", () => { - it("updates the checkout property and dispatches an ec.fulfillment.change event", async () => { + it("emits each changed snapshot even when it returns to an earlier value", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const onFulfillmentChangeSpy = vi.fn(); - const listenForEvent = waitForEvent( - checkout, - "ec.fulfillment.change", - onFulfillmentChangeSpy, - ); + const updateSpy = vi.fn(); + checkout.addEventListener("update", updateSpy); - const payload = makeCheckoutPayload(); - simulateProtocolMessageEvent(checkout, "ec.fulfillment.change", payload, { - source: mockCheckoutWindow, - }); - await listenForEvent; + for (const amount of [1000, 1200, 1000]) { + simulateProtocolMessageEvent( + checkout, + "ec.totals.change", + makeCheckoutPayload({ totals: [{ type: "total", amount }] }), + { source: mockCheckoutWindow }, + ); + await flushProtocolDispatch(); + } - expect(checkout.checkout).toEqual(decodeCheckout(payload)); - expect(onFulfillmentChangeSpy).toHaveBeenCalledOnce(); + expect(updateSpy).toHaveBeenCalledTimes(3); + expect(checkout.checkout?.totals[0]?.amount).toBe(1000); }); - }); - describe("ec.totals.change", () => { - it("updates the checkout property and dispatches an ec.totals.change event", async () => { + it("uses start and complete snapshots as the update baseline without deduplicating lifecycle events", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const onTotalsChangeSpy = vi.fn(); - const listenForEvent = waitForEvent(checkout, "ec.totals.change", onTotalsChangeSpy); + const startSpy = vi.fn(); + const updateSpy = vi.fn(); + const completeSpy = vi.fn(); + checkout.addEventListener("start", startSpy); + checkout.addEventListener("update", updateSpy); + checkout.addEventListener("complete", completeSpy); + + for (const method of [ + "ec.start", + "ec.start", + "ec.totals.change", + "ec.complete", + "ec.complete", + "ec.messages.change", + ] as const) { + simulateProtocolMessageEvent(checkout, method, makeCheckoutPayload(), { + source: mockCheckoutWindow, + }); + await flushProtocolDispatch(); + } + expect(startSpy).toHaveBeenCalledTimes(2); + expect(completeSpy).toHaveBeenCalledTimes(2); + expect(updateSpy).not.toHaveBeenCalled(); + }); + + it("resets update deduplication when a new checkout session opens", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const updateSpy = vi.fn(); + checkout.addEventListener("update", updateSpy); const payload = makeCheckoutPayload(); + simulateProtocolMessageEvent(checkout, "ec.totals.change", payload, { source: mockCheckoutWindow, }); - await listenForEvent; + await flushProtocolDispatch(); + checkout.open(); + simulateProtocolMessageEvent(checkout, "ec.totals.change", payload, { + source: mockCheckoutWindow, + }); + await flushProtocolDispatch(); - expect(checkout.checkout).toEqual(decodeCheckout(payload)); - expect(onTotalsChangeSpy).toHaveBeenCalledOnce(); + expect(updateSpy).toHaveBeenCalledTimes(2); }); - }); - describe("ec.messages.change", () => { - it("updates the checkout property and dispatches an ec.messages.change event", async () => { + it("does not dispatch raw protocol names as public DOM events", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const onMessagesChangeSpy = vi.fn(); - const listenForEvent = waitForEvent(checkout, "ec.messages.change", onMessagesChangeSpy); + const rawEventSpy = vi.fn(); + const snapshotMethods = ["ec.start", ...CHECKOUT_CHANGE_METHODS, "ec.complete"] as const; + for (const method of [...snapshotMethods, "ec.error", "ec.close"]) { + (checkout as HTMLElement).addEventListener(method, rawEventSpy); + } - const payload = makeCheckoutPayload(); - simulateProtocolMessageEvent(checkout, "ec.messages.change", payload, { - source: mockCheckoutWindow, - }); - await listenForEvent; + for (const method of snapshotMethods) { + simulateProtocolMessageEvent(checkout, method, makeCheckoutPayload(), { + source: mockCheckoutWindow, + }); + await flushProtocolDispatch(); + } + simulateProtocolMessageEvent( + checkout, + "ec.error", + makeErrorParams({ severity: "recoverable" }), + { + source: mockCheckoutWindow, + }, + ); + await flushProtocolDispatch(); + checkout.close(); - expect(checkout.checkout).toEqual(decodeCheckout(payload)); - expect(onMessagesChangeSpy).toHaveBeenCalledOnce(); + expect(rawEventSpy).not.toHaveBeenCalled(); }); }); describe("event.detail payloads", () => { - it("ec.start carries {checkout}", async () => { + it("start carries {checkout}", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const spy = vi.fn(); - const wait = waitForEvent(checkout, "ec.start", spy); + const wait = waitForEvent(checkout, "start", spy); const payload = makeCheckoutPayload(); simulateProtocolMessageEvent(checkout, "ec.start", payload, { @@ -477,10 +617,10 @@ describe("", () => { expect(event.detail).toStrictEqual({ checkout: decodeCheckout(payload) }); }); - it("ec.complete carries {checkout} with order nested in checkout", async () => { + it("complete carries {checkout} with order nested in checkout", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const spy = vi.fn(); - const wait = waitForEvent(checkout, "ec.complete", spy); + const wait = waitForEvent(checkout, "complete", spy); const order = { id: "order-1", @@ -498,10 +638,10 @@ describe("", () => { expect(event.detail.checkout.order).toEqual(decoded.order); }); - it("ec.complete keeps an absent order nested in checkout", async () => { + it("complete keeps an absent order nested in checkout", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const spy = vi.fn(); - const wait = waitForEvent(checkout, "ec.complete", spy); + const wait = waitForEvent(checkout, "complete", spy); const payload = makeCheckoutPayload(); simulateProtocolMessageEvent(checkout, "ec.complete", payload, { @@ -515,10 +655,10 @@ describe("", () => { expect(event.detail.checkout.order).toBeUndefined(); }); - it("ec.error carries {error}", async () => { + it("error carries {error}", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const spy = vi.fn(); - const wait = waitForEvent(checkout, "ec.error", spy); + const wait = waitForEvent(checkout, "error", spy); const errorParams = makeErrorParams(); simulateProtocolMessageEvent(checkout, "ec.error", errorParams, { @@ -527,13 +667,16 @@ describe("", () => { await wait; const event = spy.mock.calls[0]![0] as CustomEvent; - expect(event.detail).toStrictEqual({ error: decodeError(errorParams) }); + expect(event.detail).toStrictEqual({ + error: { code: "unknown", message: "Session failed" }, + }); + expect(event.detail.error).toBe(checkout.error); }); - it("ec.line_items.change carries {checkout} with lineItems nested in checkout", async () => { + it("update from ec.line_items.change carries {checkout} with lineItems nested in checkout", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const spy = vi.fn(); - const wait = waitForEvent(checkout, "ec.line_items.change", spy); + const wait = waitForEvent(checkout, "update", spy); const payload = makeCheckoutPayload(); simulateProtocolMessageEvent(checkout, "ec.line_items.change", payload, { @@ -547,10 +690,10 @@ describe("", () => { expect(event.detail.checkout.lineItems).toEqual(decoded.lineItems); }); - it("ec.fulfillment.change carries {checkout} with fulfillment nested in checkout", async () => { + it("update from ec.fulfillment.change carries {checkout} with fulfillment nested in checkout", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const spy = vi.fn(); - const wait = waitForEvent(checkout, "ec.fulfillment.change", spy); + const wait = waitForEvent(checkout, "update", spy); const fulfillment = { methods: [ @@ -581,10 +724,10 @@ describe("", () => { expect(event.detail.checkout.fulfillment).toEqual(decoded.fulfillment); }); - it("ec.totals.change carries {checkout} with totals nested in checkout", async () => { + it("update from ec.totals.change carries {checkout} with totals nested in checkout", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const spy = vi.fn(); - const wait = waitForEvent(checkout, "ec.totals.change", spy); + const wait = waitForEvent(checkout, "update", spy); const payload = makeCheckoutPayload(); simulateProtocolMessageEvent(checkout, "ec.totals.change", payload, { @@ -598,10 +741,10 @@ describe("", () => { expect(event.detail.checkout.totals).toEqual(decoded.totals); }); - it("ec.messages.change carries {checkout} with messages nested in checkout", async () => { + it("update from ec.messages.change carries {checkout} with messages nested in checkout", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const spy = vi.fn(); - const wait = waitForEvent(checkout, "ec.messages.change", spy); + const wait = waitForEvent(checkout, "update", spy); const payload = makeCheckoutPayload(); simulateProtocolMessageEvent(checkout, "ec.messages.change", payload, { @@ -615,10 +758,10 @@ describe("", () => { expect(event.detail.checkout.messages).toEqual(decoded.messages); }); - it("ec.close carries no detail", () => { + it("close carries no detail", () => { const { checkout } = openPopupCheckout(); const spy = vi.fn(); - checkout.addEventListener("ec.close", spy); + checkout.addEventListener("close", spy); checkout.close(); @@ -846,7 +989,7 @@ describe("", () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onStartSpy = vi.fn(); const payload = makeCheckoutPayload(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", payload, { source: mockCheckoutWindow, @@ -862,7 +1005,7 @@ describe("", () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onStartSpy = vi.fn(); const payload = makeCheckoutPayload(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", payload, { source: mockCheckoutWindow, @@ -877,7 +1020,7 @@ describe("", () => { it("drops protocol messages from an untrusted HTTPS origin by default", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { source: mockCheckoutWindow, @@ -895,7 +1038,7 @@ describe("", () => { }); const onStartSpy = vi.fn(); const payload = makeCheckoutPayload(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", payload, { source: mockCheckoutWindow, @@ -912,7 +1055,7 @@ describe("", () => { "allowed-origins": "https://other.example.com/", }); const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { source: mockCheckoutWindow, @@ -933,7 +1076,7 @@ describe("", () => { "allowed-origins": pattern, }); const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { source: mockCheckoutWindow, @@ -948,7 +1091,7 @@ describe("", () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onStartSpy = vi.fn(); const payload = makeCheckoutPayload(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", payload, { source: mockCheckoutWindow, @@ -966,7 +1109,7 @@ describe("", () => { }); const onStartSpy = vi.fn(); const payload = makeCheckoutPayload(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", payload, { source: mockCheckoutWindow, @@ -983,7 +1126,7 @@ describe("", () => { "allowed-origins": "https://*.example.com:443", }); const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { source: mockCheckoutWindow, @@ -999,7 +1142,7 @@ describe("", () => { "allowed-origins": "https://other.example.com:443", }); const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { source: mockCheckoutWindow, @@ -1019,7 +1162,7 @@ describe("", () => { "allowed-origins": "https://other.example.com", }); const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { source: mockCheckoutWindow, @@ -1038,7 +1181,7 @@ describe("", () => { "allowed-origins": "https://*.example.com", }); const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { source: mockCheckoutWindow, @@ -1056,7 +1199,7 @@ describe("", () => { }); const onStartSpy = vi.fn(); const payload = makeCheckoutPayload(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", payload, { source: mockCheckoutWindow, @@ -1072,7 +1215,7 @@ describe("", () => { const { checkout } = openPopupCheckout(); const otherWindow = createMockWindow(); const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent( checkout, @@ -1099,7 +1242,7 @@ describe("", () => { checkout.removeAttribute("src"); const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); const event = new MessageEvent("message", { data: { @@ -1120,7 +1263,7 @@ describe("", () => { it("drops protocol messages when the event origin is not HTTPS", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { source: mockCheckoutWindow, @@ -1135,7 +1278,7 @@ describe("", () => { it("drops protocol messages when the event origin is opaque", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { source: mockCheckoutWindow, @@ -1150,7 +1293,7 @@ describe("", () => { it("ignores window 'message' events that aren't JSON-RPC checkout protocol messages", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); window.dispatchEvent( @@ -1173,7 +1316,7 @@ describe("", () => { const onMessageRejected = vi.fn(); checkout.onMessageRejected = onMessageRejected; const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { source: mockCheckoutWindow, @@ -1224,7 +1367,7 @@ describe("", () => { it("is a no-op when called with a null listener", () => { const checkout = renderCheckout(); expect(() => { - checkout.addEventListener("ec.start", null as unknown as EventListener); + checkout.addEventListener("start", null as unknown as EventListener); }).not.toThrow(); }); }); @@ -1312,7 +1455,7 @@ describe("", () => { it("drops protocol messages while the element is disconnected", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); checkout.remove(); @@ -1327,7 +1470,7 @@ describe("", () => { it("re-attaches the message listener on reconnect without duplicating it", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onStartSpy = vi.fn(); - checkout.addEventListener("ec.start", onStartSpy); + checkout.addEventListener("start", onStartSpy); simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { source: mockCheckoutWindow, @@ -1354,8 +1497,8 @@ describe("", () => { const firstSpy = vi.fn(); const secondSpy = vi.fn(); - first.checkout.addEventListener("ec.start", firstSpy); - second.checkout.addEventListener("ec.start", secondSpy); + first.checkout.addEventListener("start", firstSpy); + second.checkout.addEventListener("start", secondSpy); const firstPayload = makeCheckoutPayload(); simulateProtocolMessageEvent(first.checkout, "ec.start", firstPayload, { @@ -1539,12 +1682,8 @@ function openPopupCheckout(attributes: Record = {}): * equality with the fixture). */ function decodeCheckout(payload: { checkout: unknown }) { - return EmbeddedCheckoutProtocol.Event.start.decode(payload).checkout; -} - -/** Wire → decoded `ErrorResponse`, mirroring the client's `ec.error` handling. */ -function decodeError(params: { error: unknown }) { - return EmbeddedCheckoutProtocol.Event.error.decode(params).error; + const { ucp: _ucp, ...checkout } = EmbeddedCheckoutProtocol.Event.start.decode(payload).checkout; + return checkout; } /** diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 2c52495cc..098a4b0c7 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -59,7 +59,7 @@ describe("", () => { vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); const closeEventSpy = vi.fn(); - checkout.addEventListener("ec.close", closeEventSpy); + checkout.addEventListener("close", closeEventSpy); checkout.open(); expect(closeEventSpy).not.toHaveBeenCalled(); @@ -291,7 +291,7 @@ describe("", () => { const closeEventSpy = vi.fn(); const durationSpy = vi.spyOn(mockTelemetry(), "recordNavigationDuration"); - checkout.addEventListener("ec.close", closeEventSpy); + checkout.addEventListener("close", closeEventSpy); checkout.open(); @@ -381,7 +381,7 @@ describe("", () => { vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); const closeEventSpy = vi.fn(); - checkout.addEventListener("ec.close", closeEventSpy); + checkout.addEventListener("close", closeEventSpy); checkout.open(); checkout.open(); @@ -446,7 +446,7 @@ describe("", () => { vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); const closeEventSpy = vi.fn(); - checkout.addEventListener("ec.close", closeEventSpy); + checkout.addEventListener("close", closeEventSpy); checkout.open(); @@ -471,7 +471,7 @@ describe("", () => { vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); const closeEventSpy = vi.fn(); - checkout.addEventListener("ec.close", closeEventSpy); + checkout.addEventListener("close", closeEventSpy); checkout.open(); window.dispatchEvent(new FocusEvent("focus")); @@ -498,7 +498,7 @@ describe("", () => { vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); const closeEventSpy = vi.fn(); - checkout.addEventListener("ec.close", closeEventSpy); + checkout.addEventListener("close", closeEventSpy); // Session A opens. checkout.open(); @@ -554,7 +554,7 @@ describe("", () => { vi.spyOn(window, "open").mockReturnValue(mockWindow); - checkout.addEventListener("ec.close", closeEventSpy); + checkout.addEventListener("close", closeEventSpy); checkout.open(); checkout.close(); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 42be18c56..f962cadc3 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -6,8 +6,19 @@ import { INVALID_PARAMS_CODE, type WindowOpenRequest, type WindowOpenResult, + type Checkout as ProtocolCheckout, } from "@shopify/checkout-kit-protocol"; +import { toCheckout, checkoutComparisonKey } from "./models/checkout"; +import { toCheckoutError } from "./models/error"; +import { + ShopifyCheckoutStartEvent, + ShopifyCheckoutUpdateEvent, + ShopifyCheckoutCompleteEvent, + ShopifyCheckoutErrorEvent, + ShopifyCheckoutCloseEvent, + type ShopifyCheckoutEventMap, +} from "./checkout-events"; import stylesText from "./checkout.css?inline"; import { Logger, coerceLogLevel } from "./logger"; import { createTelemetry, telemetryProtocolMethod, type CheckoutKitTelemetry } from "./telemetry"; @@ -21,7 +32,7 @@ import type { TypedEventListener, Checkout, CheckoutAppearance, - ErrorResponse, + CheckoutError, LogLevel, MessageRejectedDetail, } from "./checkout.types"; @@ -159,14 +170,11 @@ const SHADOW_TEMPLATE = createTemplate(html` * @attribute log-level - Console logging verbosity (debug, warn, error, or none). * @attribute allowed-origins - Extra trusted message origins, separated by spaces or commas. * - * @event ec.start - Dispatched when the checkout has started - * @event ec.complete - Dispatched when the checkout was successfully completed - * @event ec.error - Dispatched on a session-level fatal error - * @event ec.fulfillment.change - Dispatched when fulfillment details change - * @event ec.line_items.change - Dispatched when cart line items change - * @event ec.totals.change - Dispatched when totals change - * @event ec.messages.change - Dispatched when checkout messages change - * @event ec.close - Dispatched when the checkout overlay is closed (synthetic, not part of ECP) + * @event {ShopifyCheckoutStartEvent} start - Checkout has started. + * @event {ShopifyCheckoutUpdateEvent} update - The checkout snapshot changed. + * @event {ShopifyCheckoutCompleteEvent} complete - Checkout completed successfully. + * @event {ShopifyCheckoutErrorEvent} error - Checkout reported a terminal error; the session closes after this event. + * @event {ShopifyCheckoutCloseEvent} close - The checkout session closed. * * @example * ```js @@ -191,7 +199,8 @@ export class ShopifyCheckout } #checkout?: Checkout; - #error?: ErrorResponse; + #error?: CheckoutError; + #checkoutComparisonKey?: string; #checkoutWindow: WindowProxy | null = null; @@ -357,13 +366,12 @@ export class ShopifyCheckout */ /** - * The latest UCP `Checkout` object received from the embedded checkout. - * Populated and updated whenever a notification carrying a `checkout` field - * is received (e.g., `ec.start`, `ec.complete`, every `ec.*.change`). + * The latest checkout snapshot, excluding protocol metadata. + * Updated before start, update, and complete events are dispatched. * * @returns The current Checkout, or undefined before the first notification. * @example - * checkout.addEventListener('ec.start', (event) => { + * checkout.addEventListener('start', (event) => { * const {lineItems, totals, buyer} = event.detail.checkout; * }); */ @@ -372,16 +380,15 @@ export class ShopifyCheckout } /** - * Session-level fatal error received via `ec.error`. + * The latest checkout error, with a stable recovery code and diagnostic message. * - * @returns The UCP error response, or undefined. + * @returns The checkout error, or undefined. * @example - * checkout.addEventListener('ec.error', (event) => { - * const {messages} = event.detail.error; - * console.error(messages[0]?.code, messages[0]?.content); + * checkout.addEventListener('error', (event) => { + * console.error(event.detail.error.code, event.detail.error.message); * }); */ - get error(): ErrorResponse | undefined { + get error(): CheckoutError | undefined { return this.#error; } @@ -434,6 +441,10 @@ export class ShopifyCheckout this.close(); } + this.#checkout = undefined; + this.#error = undefined; + this.#checkoutComparisonKey = undefined; + let checkoutWindow: WindowProxy | null = null; const navigationStartedAt = performance.now(); @@ -530,6 +541,7 @@ export class ShopifyCheckout checkoutWindow?.close(); this.#checkoutWindow = null; this.#currentOpen = null; + /** @ignore - Events are documented by the class @event tags. */ this.dispatchEvent(new ShopifyCheckoutCloseEvent()); }); @@ -809,12 +821,14 @@ export class ShopifyCheckout // Web cannot reliably observe cross-origin popup page-finish, so the // success duration ends at `ec.start`: checkout is loaded and interactive. this.#recordNavigationSuccess(); - this.#checkout = checkout; - this.dispatchEvent(new ShopifyCheckoutStartEvent({ checkout })); + const snapshot = this.#recordCheckout(checkout); + /** @ignore - Events are documented by the class @event tags. */ + this.dispatchEvent(new ShopifyCheckoutStartEvent({ checkout: snapshot })); }) .on(Event.complete, ({ params: { checkout } }) => { - this.#checkout = checkout; - this.dispatchEvent(new ShopifyCheckoutCompleteEvent({ checkout })); + const snapshot = this.#recordCheckout(checkout); + /** @ignore - Events are documented by the class @event tags. */ + this.dispatchEvent(new ShopifyCheckoutCompleteEvent({ checkout: snapshot })); }) .on(Event.error, ({ params: { error } }) => { this.#recorder?.recordError({ @@ -824,32 +838,45 @@ export class ShopifyCheckout retryable: false, isRetry: false, }); - this.#error = error; - this.dispatchEvent(new ShopifyCheckoutErrorEvent({ error })); + this.#error = toCheckoutError(error); + /** @ignore - Events are documented by the class @event tags. */ + this.dispatchEvent(new ShopifyCheckoutErrorEvent({ error: this.#error })); // `ec.error` is terminal for the embedded session. Message severity is // payload detail for the checkout error, not a host-side recovery signal. this.#recordNavigationFailure(); this.close(); }) .on(Event.fulfillmentChange, ({ params: { checkout } }) => { - this.#checkout = checkout; - this.dispatchEvent(new ShopifyCheckoutFulfillmentChangeEvent({ checkout })); + this.#updateCheckout(checkout); }) .on(Event.lineItemsChange, ({ params: { checkout } }) => { - this.#checkout = checkout; - this.dispatchEvent(new ShopifyCheckoutLineItemsChangeEvent({ checkout })); + this.#updateCheckout(checkout); }) .on(Event.totalsChange, ({ params: { checkout } }) => { - this.#checkout = checkout; - this.dispatchEvent(new ShopifyCheckoutTotalsChangeEvent({ checkout })); + this.#updateCheckout(checkout); }) .on(Event.messagesChange, ({ params: { checkout } }) => { - this.#checkout = checkout; - this.dispatchEvent(new ShopifyCheckoutMessagesChangeEvent({ checkout })); + this.#updateCheckout(checkout); }) .on(Event.windowOpen, ({ params }) => this.#handleWindowOpen(params)); } + #recordCheckout(checkout: ProtocolCheckout): Checkout { + const snapshot = toCheckout(checkout); + this.#checkout = snapshot; + // Keep the comparison independent of mutations made by event listeners. + this.#checkoutComparisonKey = checkoutComparisonKey(snapshot); + return snapshot; + } + + #updateCheckout(checkout: ProtocolCheckout): void { + const previous = this.#checkoutComparisonKey; + const snapshot = this.#recordCheckout(checkout); + if (this.#checkoutComparisonKey === previous) return; + /** @ignore - Events are documented by the class @event tags. */ + this.dispatchEvent(new ShopifyCheckoutUpdateEvent({ checkout: snapshot })); + } + /** * Feeds a serialized JSON-RPC message through the protocol client and posts * any response back to the checkout window. Responses only exist for @@ -959,52 +986,22 @@ export class ShopifyCheckout * Custom Events * ------------------------------------------------------------ */ - // we overload these so that the consumer of the component can autocomplete the correct events - override addEventListener( - type: "ec.start", - listener: TypedEventListener | null, + // Typed overloads provide event-specific payloads and preserve native listeners. + override addEventListener( + type: K, + listener: TypedEventListener | null, options?: boolean | AddEventListenerOptions, ): void; - override addEventListener( - type: "ec.close", - listener: TypedEventListener | null, + override addEventListener( + type: K, + listener: TypedEventListener | null, options?: boolean | AddEventListenerOptions, ): void; override addEventListener( - type: "ec.complete", - listener: TypedEventListener | null, - options?: boolean | AddEventListenerOptions, - ): void; - - override addEventListener( - type: "ec.error", - listener: TypedEventListener | null, - options?: boolean | AddEventListenerOptions, - ): void; - - override addEventListener( - type: "ec.fulfillment.change", - listener: TypedEventListener | null, - options?: boolean | AddEventListenerOptions, - ): void; - - override addEventListener( - type: "ec.line_items.change", - listener: TypedEventListener | null, - options?: boolean | AddEventListenerOptions, - ): void; - - override addEventListener( - type: "ec.totals.change", - listener: TypedEventListener | null, - options?: boolean | AddEventListenerOptions, - ): void; - - override addEventListener( - type: "ec.messages.change", - listener: TypedEventListener | null, + type: string, + listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions, ): void; @@ -1017,112 +1014,3 @@ export class ShopifyCheckout super.addEventListener(type, listener, options); } } - -/* ------------------------------------------------------------ - * Event detail shapes — what each event carries on `event.detail`. - * ------------------------------------------------------------ - */ - -export interface ShopifyCheckoutStartEventDetail { - /** Initial checkout snapshot from the ECP `ec.start` notification. */ - checkout: Checkout; -} - -export interface ShopifyCheckoutCompleteEventDetail { - /** Final checkout snapshot from the ECP `ec.complete` notification. */ - checkout: Checkout; -} - -export interface ShopifyCheckoutErrorEventDetail { - /** Error payload from the ECP `ec.error` notification. */ - error: ErrorResponse; -} - -export interface ShopifyCheckoutFulfillmentChangeEventDetail { - /** Checkout snapshot with updated fulfillment details. */ - checkout: Checkout; -} - -export interface ShopifyCheckoutLineItemsChangeEventDetail { - /** Checkout snapshot with updated cart line items. */ - checkout: Checkout; -} - -export interface ShopifyCheckoutTotalsChangeEventDetail { - /** Checkout snapshot with updated totals. */ - checkout: Checkout; -} - -export interface ShopifyCheckoutMessagesChangeEventDetail { - /** Checkout snapshot with updated warnings, errors, and informational messages. */ - checkout: Checkout; -} - -/* ------------------------------------------------------------ - * Event classes — `CustomEvent` subclasses carrying typed details. - * ------------------------------------------------------------ - */ - -export class ShopifyCheckoutStartEvent extends CustomEvent { - declare type: "ec.start"; - - constructor(detail: ShopifyCheckoutStartEventDetail) { - super("ec.start", { detail, bubbles: true }); - } -} - -export class ShopifyCheckoutCompleteEvent extends CustomEvent { - declare type: "ec.complete"; - - constructor(detail: ShopifyCheckoutCompleteEventDetail) { - super("ec.complete", { detail, bubbles: true }); - } -} - -export class ShopifyCheckoutCloseEvent extends CustomEvent { - declare type: "ec.close"; - - constructor() { - super("ec.close", { bubbles: true }); - } -} - -export class ShopifyCheckoutErrorEvent extends CustomEvent { - declare type: "ec.error"; - - constructor(detail: ShopifyCheckoutErrorEventDetail) { - super("ec.error", { detail, bubbles: true }); - } -} - -export class ShopifyCheckoutFulfillmentChangeEvent extends CustomEvent { - declare type: "ec.fulfillment.change"; - - constructor(detail: ShopifyCheckoutFulfillmentChangeEventDetail) { - super("ec.fulfillment.change", { detail, bubbles: true }); - } -} - -export class ShopifyCheckoutLineItemsChangeEvent extends CustomEvent { - declare type: "ec.line_items.change"; - - constructor(detail: ShopifyCheckoutLineItemsChangeEventDetail) { - super("ec.line_items.change", { detail, bubbles: true }); - } -} - -export class ShopifyCheckoutTotalsChangeEvent extends CustomEvent { - declare type: "ec.totals.change"; - - constructor(detail: ShopifyCheckoutTotalsChangeEventDetail) { - super("ec.totals.change", { detail, bubbles: true }); - } -} - -export class ShopifyCheckoutMessagesChangeEvent extends CustomEvent { - declare type: "ec.messages.change"; - - constructor(detail: ShopifyCheckoutMessagesChangeEventDetail) { - super("ec.messages.change", { detail, bubbles: true }); - } -} diff --git a/platforms/web/src/checkout.types.ts b/platforms/web/src/checkout.types.ts index 38dd05e5f..a1f4b36d1 100644 --- a/platforms/web/src/checkout.types.ts +++ b/platforms/web/src/checkout.types.ts @@ -1,12 +1,18 @@ -// Types for this component are derived from the 2026-04-08 UCP embedded -// checkout protocol. Payload shapes come from the shared -// `@shopify/checkout-kit-protocol` package (decoded to camelCase). +// Public component types and the internal protocol message map. Checkout Kit +// owns the top-level snapshots and events; nested checkout domain models reuse +// the shared protocol package's camelCase types. -import type { Checkout, ReadyRequest, ErrorResponse } from "@shopify/checkout-kit-protocol"; +import type { + Checkout as ProtocolCheckout, + ReadyRequest, + ErrorResponse, +} from "@shopify/checkout-kit-protocol"; import type { LogLevel } from "./logger"; export type { LogLevel }; +export type { Checkout } from "./models/checkout"; +export type { CheckoutError, CheckoutErrorCode } from "./models/error"; // This component should follow the custom element conventions set out here: // https://github.com/Shopify/ui-api-design/tree/main/codex. In particular, @@ -163,19 +169,18 @@ export type TypedEventListener = */ export interface CheckoutProtocolMessageMap { "ec.ready": ReadyRequest; - "ec.start": { checkout: Checkout }; - "ec.complete": { checkout: Checkout }; + "ec.start": { checkout: ProtocolCheckout }; + "ec.complete": { checkout: ProtocolCheckout }; "ec.error": { error: ErrorResponse }; - "ec.fulfillment.change": { checkout: Checkout }; - "ec.line_items.change": { checkout: Checkout }; - "ec.totals.change": { checkout: Checkout }; - "ec.messages.change": { checkout: Checkout }; + "ec.fulfillment.change": { checkout: ProtocolCheckout }; + "ec.line_items.change": { checkout: ProtocolCheckout }; + "ec.totals.change": { checkout: ProtocolCheckout }; + "ec.messages.change": { checkout: ProtocolCheckout }; "ec.window.open_request": { url: string }; } export type { Buyer, - Checkout, LineItem, Message, ReadyRequest, diff --git a/platforms/web/src/index.test.ts b/platforms/web/src/index.test.ts index d58ae8c47..12fce84fc 100644 --- a/platforms/web/src/index.test.ts +++ b/platforms/web/src/index.test.ts @@ -14,10 +14,7 @@ describe("@shopify/checkout-kit public entry", () => { pkg.ShopifyCheckoutCompleteEvent, pkg.ShopifyCheckoutCloseEvent, pkg.ShopifyCheckoutErrorEvent, - pkg.ShopifyCheckoutFulfillmentChangeEvent, - pkg.ShopifyCheckoutLineItemsChangeEvent, - pkg.ShopifyCheckoutTotalsChangeEvent, - pkg.ShopifyCheckoutMessagesChangeEvent, + pkg.ShopifyCheckoutUpdateEvent, ]; for (const ctor of eventCtors) { expect(typeof ctor).toBe("function"); diff --git a/platforms/web/src/index.ts b/platforms/web/src/index.ts index b37364a2a..cc4b433d7 100644 --- a/platforms/web/src/index.ts +++ b/platforms/web/src/index.ts @@ -1,48 +1,33 @@ // Registers `` (side effect). import "./checkout-web-component"; -// The custom element class. export { ShopifyCheckout } from "./checkout"; -// Event classes — useful for `instanceof` checks and as type aliases for handlers. export { ShopifyCheckoutStartEvent, + ShopifyCheckoutUpdateEvent, ShopifyCheckoutCompleteEvent, - ShopifyCheckoutCloseEvent, ShopifyCheckoutErrorEvent, - ShopifyCheckoutFulfillmentChangeEvent, - ShopifyCheckoutLineItemsChangeEvent, - ShopifyCheckoutTotalsChangeEvent, - ShopifyCheckoutMessagesChangeEvent, -} from "./checkout"; + ShopifyCheckoutCloseEvent, +} from "./checkout-events"; -// Event detail payload types — useful for typing handler parameter shapes. export type { ShopifyCheckoutStartEventDetail, + ShopifyCheckoutUpdateEventDetail, ShopifyCheckoutCompleteEventDetail, ShopifyCheckoutErrorEventDetail, - ShopifyCheckoutFulfillmentChangeEventDetail, - ShopifyCheckoutLineItemsChangeEventDetail, - ShopifyCheckoutTotalsChangeEventDetail, - ShopifyCheckoutMessagesChangeEventDetail, -} from "./checkout"; + ShopifyCheckoutEventMap, +} from "./checkout-events"; -// Public configuration types. export type { CheckoutAppearance, CheckoutTarget, + Checkout, + CheckoutError, + CheckoutErrorCode, LogLevel, MessageRejectedDetail, } from "./checkout.types"; -// UCP domain types — surfaced because they appear on event details and the -// `element.checkout` / `element.error` mirrors. -export type { - Buyer, - Checkout, - LineItem, - Message, - OrderConfirmation, - CheckoutTotal, - ErrorResponse, -} from "./checkout.types"; +// Shared domain types used by the Kit-owned checkout snapshot. +export type { Buyer, LineItem, Message, OrderConfirmation, CheckoutTotal } from "./checkout.types"; diff --git a/platforms/web/src/models/checkout.test.ts b/platforms/web/src/models/checkout.test.ts new file mode 100644 index 000000000..bc27a157f --- /dev/null +++ b/platforms/web/src/models/checkout.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { + decodeProtocolPayload, + type Buyer, + type Checkout as ProtocolCheckout, + type CheckoutStatus, + type LineItem, +} from "@shopify/checkout-kit-protocol"; + +import { checkoutComparisonKey, toCheckout, type Checkout } from "./checkout"; + +function protocolCheckout(overrides: Partial = {}): ProtocolCheckout { + return { + id: "checkout-123", + currency: "USD", + lineItems: [], + links: [], + status: "incomplete", + totals: [], + ucp: { version: "2026-04-08", paymentHandlers: {} }, + ...overrides, + }; +} + +describe("toCheckout", () => { + it("removes only top-level protocol metadata without modifying the protocol checkout", () => { + const original = protocolCheckout({ + attribution: { campaign: "sample-campaign" }, + buyer: { firstName: "Sample", email: "buyer@example.com" }, + context: { addressCountry: "US" }, + continueUrl: "https://example.com/checkout", + discounts: { codes: ["SAMPLE"] }, + expiresAt: "2026-09-14T12:00:00Z", + fulfillment: { methods: [], availableMethods: [] }, + links: [{ type: "privacy_policy", url: "https://example.com/privacy" }], + messages: [{ type: "info", content: "Sample message" }], + order: { id: "order-123", permalinkUrl: "https://example.com/orders/sample" }, + payment: { instruments: [] }, + signals: { "com.example.device": { nested_key: "value" } }, + totals: [{ type: "total", amount: 2500 }], + "com.example.extension": { ucp: { preserved: true }, nested_key: null }, + }); + + const snapshot = toCheckout(original); + + expect(snapshot).not.toBe(original); + expect(snapshot).not.toHaveProperty("ucp"); + expect({ ...snapshot, ucp: original.ucp }).toEqual(original); + expect(original.ucp).toEqual({ version: "2026-04-08", paymentHandlers: {} }); + expect(snapshot["com.example.extension"]).toEqual({ + ucp: { preserved: true }, + nested_key: null, + }); + }); + + it("preserves decoded camelCase properties and unknown extension spelling", () => { + const decoded = decodeProtocolPayload("ec.start", { + id: "checkout-123", + currency: "USD", + status: "incomplete", + links: [], + totals: [], + line_items: [ + { + id: "line-1", + parent_id: "parent-1", + item: { + id: "item-1", + title: "Sample item", + price: 2500, + image_url: "https://example.com/item.png", + custom_item_data: { original_key: true }, + }, + quantity: 1, + totals: [], + custom_line_data: { original_key: 7 }, + }, + ], + buyer: { first_name: "Sample", custom_buyer_data: { original_key: false } }, + ucp: { version: "2026-04-08" }, + custom_checkout_data: { original_key: [1, null, "sample"] }, + }); + + const snapshot = toCheckout(decoded); + + expect(snapshot.lineItems[0]?.parentId).toBe("parent-1"); + expect(snapshot.lineItems[0]?.item.imageUrl).toBe("https://example.com/item.png"); + expect(snapshot.buyer?.firstName).toBe("Sample"); + expect(snapshot.lineItems[0]?.custom_line_data).toEqual({ original_key: 7 }); + expect(snapshot.lineItems[0]?.item.custom_item_data).toEqual({ original_key: true }); + expect(snapshot.buyer?.custom_buyer_data).toEqual({ original_key: false }); + expect(snapshot.custom_checkout_data).toEqual({ original_key: [1, null, "sample"] }); + expect(snapshot).not.toHaveProperty("line_items"); + expect(snapshot).not.toHaveProperty("customCheckoutData"); + }); + + it("does not introduce optional fields absent from the protocol snapshot", () => { + const snapshot = toCheckout(protocolCheckout()); + + expect(snapshot).toEqual({ + id: "checkout-123", + currency: "USD", + lineItems: [], + links: [], + status: "incomplete", + totals: [], + }); + }); + + it("retains explicit known-field types while keeping extensions unknown", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toBeUnknown(); + expectTypeOf().toBeUnknown(); + expectTypeOf().not.toExtend(); + }); +}); + +describe("checkoutComparisonKey", () => { + it("ignores object key ordering at every depth, including extension objects inside arrays", () => { + const first = toCheckout( + protocolCheckout({ + buyer: { firstName: "Sample", lastName: "Buyer" }, + custom_extension: { first: 1, nested: [{ left: true, right: null }] }, + }), + ); + const second = toCheckout( + protocolCheckout({ + custom_extension: { nested: [{ right: null, left: true }], first: 1 }, + buyer: { lastName: "Buyer", firstName: "Sample" }, + }), + ); + + expect(checkoutComparisonKey(first)).toBe(checkoutComparisonKey(second)); + }); + + it("ignores protocol metadata changes after adaptation", () => { + const first = toCheckout(protocolCheckout()); + const second = toCheckout( + protocolCheckout({ + ucp: { version: "2099-01-01", paymentHandlers: {}, custom_metadata: true }, + }), + ); + + expect(checkoutComparisonKey(first)).toBe(checkoutComparisonKey(second)); + }); + + it("detects changed extension values and preserves array order", () => { + const snapshot = toCheckout(protocolCheckout({ custom_extension: { values: [1, 2] } })); + const changedValue = { ...snapshot, custom_extension: { values: [1, 3] } }; + const changedOrder = { ...snapshot, custom_extension: { values: [2, 1] } }; + + expect(checkoutComparisonKey(snapshot)).not.toBe(checkoutComparisonKey(changedValue)); + expect(checkoutComparisonKey(snapshot)).not.toBe(checkoutComparisonKey(changedOrder)); + }); + + it("uses JSON semantics for omitted optional fields while preserving null and primitive types", () => { + const snapshot = toCheckout(protocolCheckout()); + + expect(checkoutComparisonKey(snapshot)).toBe( + checkoutComparisonKey({ ...snapshot, buyer: undefined }), + ); + expect(checkoutComparisonKey(snapshot)).not.toBe( + checkoutComparisonKey({ ...snapshot, custom_extension: null }), + ); + expect(checkoutComparisonKey({ ...snapshot, custom_extension: 1 })).not.toBe( + checkoutComparisonKey({ ...snapshot, custom_extension: "1" }), + ); + }); +}); diff --git a/platforms/web/src/models/checkout.ts b/platforms/web/src/models/checkout.ts new file mode 100644 index 000000000..574f40270 --- /dev/null +++ b/platforms/web/src/models/checkout.ts @@ -0,0 +1,65 @@ +import type { + Buyer, + Checkout as ProtocolCheckout, + CheckoutDiscounts, + CheckoutFulfillment, + CheckoutStatus, + CheckoutTotal, + Context, + LineItem, + Link, + Message, + OrderConfirmation, + Payment, +} from "@shopify/checkout-kit-protocol"; + +/** A checkout snapshot containing checkout data without protocol metadata. */ +export interface Checkout { + attribution?: Record; + buyer?: Buyer; + context?: Context; + continueUrl?: string; + currency: string; + discounts?: CheckoutDiscounts; + expiresAt?: string; + fulfillment?: CheckoutFulfillment; + id: string; + lineItems: LineItem[]; + links: Link[]; + messages?: Message[]; + order?: OrderConfirmation; + payment?: Payment; + signals?: Record; + status: CheckoutStatus; + totals: CheckoutTotal[]; + /** Extension fields keep their original names and values. */ + [key: string]: unknown; +} + +/** Adapts an already-decoded checkout while preserving its extension fields. */ +export function toCheckout(protocolCheckout: ProtocolCheckout): Checkout { + const checkout: Checkout = { ...protocolCheckout }; + delete checkout.ucp; + return checkout; +} + +/** + * Compares snapshot contents independently of object-key order. Compute this + * before dispatching events so changes made by listeners cannot affect the + * stored comparison with the next snapshot. + */ +export function checkoutComparisonKey(checkout: Checkout): string { + return JSON.stringify(sortObjectKeys(checkout)); +} + +function sortObjectKeys(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortObjectKeys); + } + if (value !== null && typeof value === "object") { + const entries = Object.entries(value); + entries.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)); + return Object.fromEntries(entries.map(([key, item]) => [key, sortObjectKeys(item)])); + } + return value; +} diff --git a/platforms/web/src/models/error.test.ts b/platforms/web/src/models/error.test.ts new file mode 100644 index 000000000..e7d17303f --- /dev/null +++ b/platforms/web/src/models/error.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { ErrorResponse } from "@shopify/checkout-kit-protocol"; + +import { toCheckoutError, type CheckoutError, type CheckoutErrorCode } from "./error"; + +function protocolError(messages: unknown): ErrorResponse { + // The shared decoder does not validate every member of the messages field. + return { messages, ucp: { version: "2026-04-08", status: "error" } } as ErrorResponse; +} + +describe("toCheckoutError", () => { + it.each([ + "storefront_password_required", + "customer_account_required", + "cart_expired", + "cart_completed", + "invalid_cart", + ] as const)("maps the checkout recovery reason %s", (code) => { + expect( + toCheckoutError(protocolError([{ type: "error", code, content: "Sample failure" }])), + ).toEqual({ code, message: "Sample failure" }); + }); + + it("prefers an unrecoverable error over earlier errors and other message types", () => { + const error = toCheckoutError( + protocolError([ + { type: "warning", severity: "unrecoverable", code: "invalid_cart", content: "Warning" }, + { type: "error", severity: "recoverable", code: "invalid_cart", content: "Earlier error" }, + { + type: "error", + severity: "unrecoverable", + code: "cart_expired", + content: "Expired checkout", + }, + { + type: "error", + severity: "unrecoverable", + code: "cart_completed", + content: "Later terminal error", + }, + ]), + ); + + expect(error).toEqual({ code: "cart_expired", message: "Expired checkout" }); + }); + + it("falls back to the first error when no unrecoverable error is present", () => { + const error = toCheckoutError( + protocolError([ + { type: "info", content: "Sample information" }, + { type: "error", code: "INVALID_CART", content: "First error" }, + { type: "error", code: "cart_expired", content: "Later error" }, + ]), + ); + + expect(error).toEqual({ code: "invalid_cart", message: "First error" }); + }); + + it.each(["new_checkout_reason", "network_error", "http_error", "sdk_error", 123, null])( + "maps unsupported or malformed code %s to unknown without discarding the diagnostic", + (code) => { + const error = toCheckoutError( + protocolError([{ type: "error", code, content: "Diagnostic description" }]), + ); + + expect(error).toEqual({ code: "unknown", message: "Diagnostic description" }); + }, + ); + + it.each( + [undefined, null, "invalid", {}, [], [null, 123, "invalid", {}, []]].map((messages) => ({ + messages, + })), + )("handles malformed or empty message collections: $messages", ({ messages }) => { + expect(toCheckoutError(protocolError(messages))).toEqual({ + code: "unknown", + message: "Embedded checkout reported an error.", + }); + }); + + it("ignores malformed entries around an error message", () => { + expect( + toCheckoutError( + protocolError([ + null, + false, + [], + { type: "error", code: "cart_expired", content: "Expired" }, + ]), + ), + ).toEqual({ code: "cart_expired", message: "Expired" }); + }); + + it.each([undefined, null, 123, {}, "", " "])( + "provides a diagnostic fallback for invalid message content: %j", + (content) => { + expect( + toCheckoutError(protocolError([{ type: "error", code: "invalid_cart", content }])), + ).toEqual({ code: "invalid_cart", message: "Embedded checkout reported an error." }); + }, + ); + + it("does not select warning or informational messages as checkout failure reasons", () => { + expect( + toCheckoutError( + protocolError([ + { type: "warning", severity: "unrecoverable", code: "invalid_cart", content: "Warning" }, + { type: "info", code: "cart_expired", content: "Information" }, + ]), + ), + ).toEqual({ code: "unknown", message: "Embedded checkout reported an error." }); + }); + + it("exposes only a stable code and diagnostic message", () => { + expectTypeOf().toEqualTypeOf<"code" | "message">(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + + const error = toCheckoutError( + protocolError([{ type: "error", code: "invalid_cart", content: "Invalid checkout" }]), + ); + expect(error).not.toHaveProperty("ucp"); + expect(error).not.toHaveProperty("messages"); + }); +}); diff --git a/platforms/web/src/models/error.ts b/platforms/web/src/models/error.ts new file mode 100644 index 000000000..fe428f541 --- /dev/null +++ b/platforms/web/src/models/error.ts @@ -0,0 +1,68 @@ +import type { ErrorResponse } from "@shopify/checkout-kit-protocol"; + +/** Stable checkout error reasons applications can use to choose recovery. */ +export type CheckoutErrorCode = + | "storefront_password_required" + | "customer_account_required" + | "cart_expired" + | "cart_completed" + | "invalid_cart" + | "unknown"; + +/** A checkout error with a stable reason and a diagnostic description. */ +export interface CheckoutError { + code: CheckoutErrorCode; + /** Diagnostic text; use `code` for application behavior. */ + message: string; +} + +interface ErrorMessage { + type: "error"; + severity?: unknown; + code?: unknown; + content?: unknown; +} + +const UNKNOWN_ERROR_MESSAGE = "Embedded checkout reported an error."; + +/** Maps a protocol error without exposing its envelope or arbitrary codes. */ +export function toCheckoutError(protocolError: ErrorResponse): CheckoutError { + // The protocol decoder preserves message contents, including malformed ones. + const messages: unknown[] = Array.isArray(protocolError.messages) ? protocolError.messages : []; + const errors = messages.filter(isErrorMessage); + const representative = + errors.find((message) => message.severity === "unrecoverable") ?? errors[0]; + const content = representative?.content; + + return { + code: toCheckoutErrorCode(representative?.code), + message: typeof content === "string" && content.trim() !== "" ? content : UNKNOWN_ERROR_MESSAGE, + }; +} + +function isErrorMessage(value: unknown): value is ErrorMessage { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + "type" in value && + value.type === "error" + ); +} + +function toCheckoutErrorCode(value: unknown): CheckoutErrorCode { + if (typeof value !== "string") return "unknown"; + + // Only checkout-origin recovery reasons are accepted from protocol messages. + const code = value.toLowerCase(); + switch (code) { + case "storefront_password_required": + case "customer_account_required": + case "cart_expired": + case "cart_completed": + case "invalid_cart": + return code; + default: + return "unknown"; + } +}