From dc4884d85a2551fa72d0b6dd111fabeb72f607a3 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 14 Sep 2026 12:59:13 +0100 Subject: [PATCH 1/8] Add protocol-agnostic checkout events for web --- platforms/web/README.md | 150 +++-- platforms/web/sample/README.md | 17 +- platforms/web/sample/index.html | 2 +- platforms/web/sample/main.ts | 28 +- platforms/web/sample/render.test.ts | 6 +- platforms/web/src/checkout-error.test.ts | 125 ++++ platforms/web/src/checkout-error.ts | 68 +++ platforms/web/src/checkout-events.ts | 133 ++++ platforms/web/src/checkout-model.test.ts | 173 ++++++ platforms/web/src/checkout-model.ts | 65 ++ platforms/web/src/checkout-protocol.test.ts | 637 ++++++++++++++++---- platforms/web/src/checkout-window.test.ts | 14 +- platforms/web/src/checkout.ts | 279 +++------ platforms/web/src/checkout.types.ts | 35 +- platforms/web/src/index.test.ts | 6 +- platforms/web/src/index.ts | 41 +- 16 files changed, 1368 insertions(+), 411 deletions(-) create mode 100644 platforms/web/src/checkout-error.test.ts create mode 100644 platforms/web/src/checkout-error.ts create mode 100644 platforms/web/src/checkout-events.ts create mode 100644 platforms/web/src/checkout-model.test.ts create mode 100644 platforms/web/src/checkout-model.ts diff --git a/platforms/web/README.md b/platforms/web/README.md index 94398f32f..7d5f97073 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). @@ -40,6 +39,8 @@ Check out our blog to - [Popup dimensions](#popup-dimensions) - [Overlay scrim](#overlay-scrim) - [Checkout lifecycle](#checkout-lifecycle) +- [Handling links](#handling-links) +- [Migrating from `ec.*` events](#migrating-from-ec-events) - [Explore the sample app](#explore-the-sample-app) - [Contributing](#contributing) - [License](#license) @@ -134,7 +135,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 +164,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 +183,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 +204,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 +484,67 @@ 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. 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 an `event.detail` payload with the fields +relevant to that moment. + +| 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 an error, exposed as `{code, message}`. The component closes automatically only when a message has `unrecoverable` severity. | +| `close` | _(none)_ | The open session ended through `close()`, overlay dismissal, or detection of a popup the buyer closed. | +| `linkclick` | `{link}` | Checkout requests that the host open a link. See [Handling links](#handling-links). | + +`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(); }); ``` +Errors with other severities emit `error` while leaving checkout open. For an +unrecoverable error, 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 +555,63 @@ 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. +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. -> [!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. +## Handling links + +The `linkclick` event exposes a validated HTTPS `URL` at +`event.detail.link.url`. Call `event.respondWith()` to select a link policy: + +| Policy | Behavior | +| ------ | -------- | +| `'open'` | Checkout Kit opens the URL in a new tab with `noopener`. This is the default when no handler responds. | +| `'handled'` | Your application handles the link. Checkout Kit does not open another tab. | +| `'cancel'` | Cancel the link request. | + +```ts +checkout.addEventListener('linkclick', (event) => { + const {url} = event.detail.link; + + if (url.origin === location.origin && url.pathname === '/help') { + event.respondWith('handled'); + router.navigate(url.pathname); + return; + } + + event.respondWith('open'); +}); +``` + +Call `respondWith` synchronously while the listener is running. It accepts +either a policy or a promise that resolves to a policy, so asynchronous +handlers should pass their promise immediately rather than awaiting it first. +Only one listener can respond to a link request. A rejected promise or a +checkout session ending cancels the pending request. Calling `preventDefault()` +also cancels it when no response was supplied. +Links with invalid or non-HTTPS URLs are rejected before this event fires. + +## Migrating from `ec.*` events + +Checkout Kit's public events replace the protocol-named DOM events from +earlier alpha releases: + +| Previous event | Replacement | +| -------------- | ----------- | +| `ec.start` | `start` | +| `ec.complete` | `complete` | +| `ec.error` | `error` | +| `ec.close` | `close` | +| `ec.fulfillment.change`, `ec.line_items.change`, `ec.totals.change`, `ec.messages.change` | `update` | + +Subscribe to `update` once when replacing several change listeners, since a +single snapshot may include changes to several fields. Checkout snapshots no +longer expose `checkout.ucp`. Error handlers read `event.detail.error.code` +and `.message` instead of a protocol `ErrorResponse`. Use `linkclick` for +application-owned link handling; it has no previous public event equivalent. ## Explore the sample app diff --git a/platforms/web/sample/README.md b/platforms/web/sample/README.md index 03a8dce1f..b7adab0eb 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 and link clicks. ## Run locally @@ -34,10 +34,23 @@ 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`, `close`, and `linkclick` 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. + +The sample's `linkclick` listener calls `event.respondWith('open')` to let the +component open `event.detail.link.url` in a new tab. To try application-owned +navigation, change the listener in `main.ts` to respond with `'handled'` after +handling the link, or `'cancel'` to block it. Call `respondWith` during the +listener; it also accepts a promise for an asynchronous decision. + ## 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..a01211b0d 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 and link clicks appear here.

    diff --git a/platforms/web/sample/main.ts b/platforms/web/sample/main.ts index 910e7d8a5..a104f11b7 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,14 @@ 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); } + + checkout.addEventListener("linkclick", (event) => { + // Choose the policy while the event is being dispatched. An application can + // respond with "handled" after taking over navigation, or "cancel" to block it. + event.respondWith("open"); + recordEvent(event); + }); } 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-error.test.ts b/platforms/web/src/checkout-error.test.ts new file mode 100644 index 000000000..ebb158fe4 --- /dev/null +++ b/platforms/web/src/checkout-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 "./checkout-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/checkout-error.ts b/platforms/web/src/checkout-error.ts new file mode 100644 index 000000000..fe428f541 --- /dev/null +++ b/platforms/web/src/checkout-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"; + } +} diff --git a/platforms/web/src/checkout-events.ts b/platforms/web/src/checkout-events.ts new file mode 100644 index 000000000..260a64593 --- /dev/null +++ b/platforms/web/src/checkout-events.ts @@ -0,0 +1,133 @@ +import type { Checkout } from "./checkout-model"; +import type { CheckoutError } from "./checkout-error"; +import type { CheckoutLink, CheckoutLinkAction } from "./checkout.types"; + +export interface ShopifyCheckoutStartEventDetail { + checkout: Checkout; +} + +export interface ShopifyCheckoutUpdateEventDetail { + checkout: Checkout; +} + +export interface ShopifyCheckoutCompleteEventDetail { + checkout: Checkout; +} + +export interface ShopifyCheckoutErrorEventDetail { + error: CheckoutError; +} + +export interface ShopifyCheckoutLinkClickEventDetail { + link: CheckoutLink; +} + +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) { + super("error", { detail, bubbles: true }); + } +} + +const linkResponses = new WeakMap>(); + +/** A link request whose default action opens the URL in a new tab. */ +export class ShopifyCheckoutLinkClickEvent extends CustomEvent { + declare type: "linkclick"; + + constructor(detail: ShopifyCheckoutLinkClickEventDetail) { + super("linkclick", { detail, bubbles: true, cancelable: true }); + } + + /** + * Choose how to handle this link. Call once, during event dispatch; pass a + * promise if the decision requires asynchronous work. A rejected promise + * rejects the link request. Calling preventDefault() cancels the request + * when no response was supplied. + */ + respondWith(action: CheckoutLinkAction | Promise): void { + if (this.eventPhase === Event.NONE || linkResponses.has(this)) { + throw new DOMException( + "respondWith must be called once during event dispatch", + "InvalidStateError", + ); + } + const response = Promise.resolve(action); + // A later listener may throw before the bridge consumes this response. + void response.catch(() => {}); + linkResponses.set(this, response); + } +} + +export interface ShopifyCheckoutEventMap { + start: ShopifyCheckoutStartEvent; + update: ShopifyCheckoutUpdateEvent; + complete: ShopifyCheckoutCompleteEvent; + error: ShopifyCheckoutErrorEvent; + close: ShopifyCheckoutCloseEvent; + linkclick: ShopifyCheckoutLinkClickEvent; +} + +/** Dispatches the public event and consumes its response inside the bridge. */ +export async function dispatchCheckoutLinkClick( + target: EventTarget, + link: CheckoutLink, + signal?: AbortSignal, +): Promise { + const event = new ShopifyCheckoutLinkClickEvent({ link }); + target.dispatchEvent(event); + const response: Promise = + linkResponses.get(event) ?? Promise.resolve(event.defaultPrevented ? "cancel" : "open"); + const action = await new Promise((resolve, reject) => { + const abort = () => reject(new DOMException("Checkout session ended", "AbortError")); + if (signal?.aborted) abort(); + else signal?.addEventListener("abort", abort, { once: true }); + response.then( + (value) => { + signal?.removeEventListener("abort", abort); + return resolve(value); + }, + (error: unknown) => { + signal?.removeEventListener("abort", abort); + return reject(error); + }, + ); + }); + if (action !== "open" && action !== "handled" && action !== "cancel") { + throw new TypeError("Invalid checkout link action"); + } + return action; +} diff --git a/platforms/web/src/checkout-model.test.ts b/platforms/web/src/checkout-model.test.ts new file mode 100644 index 000000000..43dbfb6a8 --- /dev/null +++ b/platforms/web/src/checkout-model.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-model"; + +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/checkout-model.ts b/platforms/web/src/checkout-model.ts new file mode 100644 index 000000000..574f40270 --- /dev/null +++ b/platforms/web/src/checkout-model.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/checkout-protocol.test.ts b/platforms/web/src/checkout-protocol.test.ts index de9a76fea..4689a551d 100644 --- a/platforms/web/src/checkout-protocol.test.ts +++ b/platforms/web/src/checkout-protocol.test.ts @@ -1,12 +1,23 @@ 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"; +import { ShopifyCheckoutLinkClickEvent } from "./checkout-events"; 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 +165,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 +214,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 +258,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 +275,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 +289,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", @@ -291,7 +308,7 @@ describe("", () => { 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 +328,31 @@ describe("", () => { expect(onErrorSpy).not.toHaveBeenCalled(); }); + it("auto-closes when any message has severity 'unrecoverable'", 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 +365,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 +387,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 +431,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 +587,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 +608,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 +625,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 +637,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 +660,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 +694,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 +711,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 +728,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(); @@ -628,6 +741,286 @@ describe("", () => { }); describe("ec.window.open_request", () => { + it("dispatches linkclick with a parsed URL before applying the default open action", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const windowOpenSpy = vi.spyOn(window, "open").mockClear(); + const linkSpy = vi.fn((event: ShopifyCheckoutLinkClickEvent) => { + expect(event).toBeInstanceOf(ShopifyCheckoutLinkClickEvent); + expect(event.detail.link.url).toBeInstanceOf(URL); + expect(event.detail.link.url.href).toBe("https://example.com/policy"); + expect(windowOpenSpy).not.toHaveBeenCalled(); + }); + checkout.addEventListener("linkclick", linkSpy); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + { url: "https://example.com/policy" }, + { id: "link-default", source: mockCheckoutWindow }, + ); + await flushProtocolDispatch(); + + expect(linkSpy).toHaveBeenCalledOnce(); + expect(windowOpenSpy).toHaveBeenCalledWith( + "https://example.com/policy", + "_blank", + "noopener", + ); + expectLinkResponse(checkout, mockCheckoutWindow, "link-default", "success"); + }); + + it.each(["javascript:alert(1)", "https://other.example.com/replaced"])( + "opens the original validated URL when a listener changes its URL to %s", + async (replacement) => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const windowOpenSpy = vi.spyOn(window, "open").mockClear(); + checkout.addEventListener("linkclick", (event) => { + event.detail.link.url.href = replacement; + event.respondWith("open"); + }); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + { url: "https://example.com/policy" }, + { id: "link-mutated", source: mockCheckoutWindow }, + ); + await flushProtocolDispatch(); + + expect(windowOpenSpy).toHaveBeenCalledExactlyOnceWith( + "https://example.com/policy", + "_blank", + "noopener", + ); + expectLinkResponse(checkout, mockCheckoutWindow, "link-mutated", "success"); + }, + ); + + it.each(["open", "handled", "cancel"] as const)( + "honors the consumer's synchronous %s action", + async (action) => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const windowOpenSpy = vi.spyOn(window, "open").mockClear(); + const errorSpy = vi.fn(); + const closeSpy = vi.fn(); + checkout.addEventListener("error", errorSpy); + checkout.addEventListener("close", closeSpy); + checkout.addEventListener("linkclick", (event) => event.respondWith(action)); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + { url: "https://example.com/policy" }, + { id: "link-action", source: mockCheckoutWindow }, + ); + await flushProtocolDispatch(); + + expect(windowOpenSpy).toHaveBeenCalledTimes(action === "open" ? 1 : 0); + expectLinkResponse( + checkout, + mockCheckoutWindow, + "link-action", + action === "cancel" ? "error" : "success", + ); + expect(errorSpy).not.toHaveBeenCalled(); + expect(closeSpy).not.toHaveBeenCalled(); + expect(checkout.error).toBeUndefined(); + }, + ); + + it.each(["open", "handled", "cancel"] as const)( + "awaits a registered promise before applying its %s action", + async (action) => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const windowOpenSpy = vi.spyOn(window, "open").mockClear(); + let resolveAction!: (action: "open" | "handled" | "cancel") => void; + const response = new Promise<"open" | "handled" | "cancel">((resolve) => { + resolveAction = resolve; + }); + checkout.addEventListener("linkclick", (event) => event.respondWith(response)); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + { url: "https://example.com/policy" }, + { id: "link-async", source: mockCheckoutWindow }, + ); + await flushProtocolDispatch(); + + expect(windowOpenSpy).not.toHaveBeenCalled(); + expect(mockCheckoutWindow.postMessage).not.toHaveBeenCalled(); + resolveAction(action); + await flushProtocolDispatch(); + + expect(windowOpenSpy).toHaveBeenCalledTimes(action === "open" ? 1 : 0); + expectLinkResponse( + checkout, + mockCheckoutWindow, + "link-async", + action === "cancel" ? "error" : "success", + ); + }, + ); + + it.each(["close", "reopen", "disconnect"] as const)( + "rejects a pending link when the checkout session ends through %s", + async (action) => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const windowOpenSpy = vi.spyOn(window, "open").mockClear(); + let resolveAction!: (action: "open") => void; + const response = new Promise<"open">((resolve) => { + resolveAction = resolve; + }); + checkout.addEventListener("linkclick", (event) => event.respondWith(response)); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + { url: "https://example.com/obsolete" }, + { id: "link-ended-session", source: mockCheckoutWindow }, + ); + await flushProtocolDispatch(); + expect(mockCheckoutWindow.postMessage).not.toHaveBeenCalled(); + + if (action === "reopen") { + windowOpenSpy.mockReturnValueOnce(createMockWindow()); + checkout.open(); + windowOpenSpy.mockClear(); + } else if (action === "disconnect") { + checkout.remove(); + } else { + checkout.close(); + } + await flushProtocolDispatch(); + + expectLinkResponse(checkout, mockCheckoutWindow, "link-ended-session", "error"); + resolveAction("open"); + await flushProtocolDispatch(); + + expect(windowOpenSpy).not.toHaveBeenCalled(); + expect(mockCheckoutWindow.postMessage).toHaveBeenCalledOnce(); + }, + ); + + it("rejects a resolved link action if the session closes before the action is applied", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const windowOpenSpy = vi.spyOn(window, "open").mockClear(); + let resolveAction!: (action: "open") => void; + const response = new Promise<"open">((resolve) => { + resolveAction = resolve; + }); + checkout.addEventListener("linkclick", (event) => event.respondWith(response)); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + { url: "https://example.com/obsolete" }, + { id: "link-close-race", source: mockCheckoutWindow }, + ); + await flushProtocolDispatch(); + + resolveAction("open"); + queueMicrotask(() => checkout.close()); + await flushProtocolDispatch(); + + expectLinkResponse(checkout, mockCheckoutWindow, "link-close-race", "error"); + expect(windowOpenSpy).not.toHaveBeenCalled(); + }); + + it("cancels a link when the consumer prevents the default action", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const windowOpenSpy = vi.spyOn(window, "open").mockClear(); + const errorSpy = vi.fn(); + const closeSpy = vi.fn(); + checkout.addEventListener("error", errorSpy); + checkout.addEventListener("close", closeSpy); + checkout.addEventListener("linkclick", (event) => event.preventDefault()); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + { url: "https://example.com/policy" }, + { id: "link-prevented", source: mockCheckoutWindow }, + ); + await flushProtocolDispatch(); + + expectLinkResponse(checkout, mockCheckoutWindow, "link-prevented", "error"); + expect(windowOpenSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(closeSpy).not.toHaveBeenCalled(); + expect(checkout.error).toBeUndefined(); + }); + + it("rejects the protocol request when the consumer's response promise rejects", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const windowOpenSpy = vi.spyOn(window, "open").mockClear(); + const errorSpy = vi.fn(); + const closeSpy = vi.fn(); + checkout.addEventListener("error", errorSpy); + checkout.addEventListener("close", closeSpy); + checkout.addEventListener("linkclick", (event) => { + event.respondWith(Promise.reject(new Error("Consumer could not handle link"))); + }); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + { url: "https://example.com/policy" }, + { id: "link-rejected", source: mockCheckoutWindow }, + ); + await flushProtocolDispatch(); + + expectLinkResponse(checkout, mockCheckoutWindow, "link-rejected", "error"); + expect(windowOpenSpy).not.toHaveBeenCalled(); + expect(errorSpy).not.toHaveBeenCalled(); + expect(closeSpy).not.toHaveBeenCalled(); + expect(checkout.error).toBeUndefined(); + }); + + it("allows only one response registration across all link listeners", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const windowOpenSpy = vi.spyOn(window, "open").mockClear(); + checkout.addEventListener("linkclick", (event) => event.respondWith("handled")); + const secondListener = vi.fn((event: ShopifyCheckoutLinkClickEvent) => { + expect(() => event.respondWith("open")).toThrow(DOMException); + }); + checkout.addEventListener("linkclick", secondListener); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + { url: "https://example.com/policy" }, + { id: "link-once", source: mockCheckoutWindow }, + ); + await flushProtocolDispatch(); + + expect(secondListener).toHaveBeenCalledOnce(); + expect(windowOpenSpy).not.toHaveBeenCalled(); + expectLinkResponse(checkout, mockCheckoutWindow, "link-once", "success"); + }); + + it("requires respondWith to be called during synchronous event dispatch", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout(); + const windowOpenSpy = vi.spyOn(window, "open").mockClear(); + let linkEvent!: ShopifyCheckoutLinkClickEvent; + checkout.addEventListener("linkclick", (event) => { + linkEvent = event; + }); + + simulateProtocolMessageEvent( + checkout, + "ec.window.open_request", + { url: "https://example.com/policy" }, + { id: "link-late", source: mockCheckoutWindow }, + ); + await flushProtocolDispatch(); + + expect(linkEvent).toBeDefined(); + expect(() => linkEvent.respondWith("cancel")).toThrow(DOMException); + expect(windowOpenSpy).toHaveBeenCalledOnce(); + expectLinkResponse(checkout, mockCheckoutWindow, "link-late", "success"); + }); + it("opens the requested url in a new tab with noopener when an id is present", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const windowOpenSpy = vi.spyOn(window, "open"); @@ -750,6 +1143,8 @@ describe("", () => { it("rejects the request when the url string cannot be parsed", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout({ "log-level": "warn" }); const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const linkSpy = vi.fn(); + checkout.addEventListener("linkclick", linkSpy); simulateProtocolMessageEvent( checkout, @@ -774,12 +1169,15 @@ describe("", () => { }), new URL(checkout.src).origin, ); + expect(linkSpy).not.toHaveBeenCalled(); }); it("rejects the request when the url uses a non-https scheme", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout({ "log-level": "warn" }); const windowOpenSpy = vi.spyOn(window, "open"); const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const linkSpy = vi.fn(); + checkout.addEventListener("linkclick", linkSpy); simulateProtocolMessageEvent( checkout, @@ -809,6 +1207,7 @@ describe("", () => { "_blank", "noopener", ); + expect(linkSpy).not.toHaveBeenCalled(); }); it("does not warn about an invalid url when the handler throws internally", async () => { @@ -846,7 +1245,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 +1261,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 +1276,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 +1294,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 +1311,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 +1332,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 +1347,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 +1365,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 +1382,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 +1398,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 +1418,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 +1437,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 +1455,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 +1471,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 +1498,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 +1519,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 +1534,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 +1549,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 +1572,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 +1623,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 +1711,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 +1726,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 +1753,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, { @@ -1437,6 +1836,24 @@ function simulateProtocolMessageEvent( window.dispatchEvent(event); } +function expectLinkResponse( + checkout: ShopifyCheckout, + checkoutWindow: Window, + id: string, + status: "success" | "error", +) { + expect(checkoutWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + jsonrpc: "2.0", + id, + result: expect.objectContaining({ + ucp: { status, version: EMBED_PROTOCOL_VERSION }, + }), + }), + new URL(checkout.src).origin, + ); +} + function flushProtocolDispatch(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } @@ -1539,12 +1956,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..2b2a60845 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -6,8 +6,20 @@ import { INVALID_PARAMS_CODE, type WindowOpenRequest, type WindowOpenResult, + type Checkout as ProtocolCheckout, } from "@shopify/checkout-kit-protocol"; +import { toCheckout, checkoutComparisonKey } from "./checkout-model"; +import { toCheckoutError } from "./checkout-error"; +import { + ShopifyCheckoutStartEvent, + ShopifyCheckoutUpdateEvent, + ShopifyCheckoutCompleteEvent, + ShopifyCheckoutErrorEvent, + ShopifyCheckoutCloseEvent, + type ShopifyCheckoutEventMap, + dispatchCheckoutLinkClick, +} from "./checkout-events"; import stylesText from "./checkout.css?inline"; import { Logger, coerceLogLevel } from "./logger"; import { createTelemetry, telemetryProtocolMethod, type CheckoutKitTelemetry } from "./telemetry"; @@ -21,7 +33,7 @@ import type { TypedEventListener, Checkout, CheckoutAppearance, - ErrorResponse, + CheckoutError, LogLevel, MessageRejectedDetail, } from "./checkout.types"; @@ -159,14 +171,12 @@ 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 an error; unrecoverable errors close the session. + * @event {ShopifyCheckoutCloseEvent} close - The checkout session closed. + * @event {ShopifyCheckoutLinkClickEvent} linkclick - Checkout requested a link; respondWith selects its handling. * * @example * ```js @@ -191,7 +201,8 @@ export class ShopifyCheckout } #checkout?: Checkout; - #error?: ErrorResponse; + #error?: CheckoutError; + #checkoutComparisonKey?: string; #checkoutWindow: WindowProxy | null = null; @@ -357,13 +368,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 +382,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 +443,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 +543,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 +823,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 +840,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 @@ -880,11 +909,12 @@ export class ShopifyCheckout } /** - * Handles an `ec.window.open_request` delegation: opens a validated `https:` - * URL in a new tab and returns a UCP result. Invalid or non-`https:` URLs - * are rejected (and warned about) rather than opened. + * Handles a link delegation after HTTPS validation. The public linkclick + * event controls whether the URL opens, was handled by the app, or is rejected. */ - #handleWindowOpen(request: WindowOpenRequest): WindowOpenResult { + async #handleWindowOpen(request: WindowOpenRequest): Promise { + const session = this.#currentOpen; + if (!session) return windowOpenRejected("checkout session ended"); let targetUrl: URL; try { targetUrl = new URL(request.url); @@ -898,6 +928,22 @@ export class ShopifyCheckout return windowOpenRejected("url must use https scheme"); } + try { + // Keep the validated default URL separate from the consumer's mutable URL. + const action = await dispatchCheckoutLinkClick( + this, + { url: new URL(targetUrl.href) }, + session.controller.signal, + ); + if (session.controller.signal.aborted || this.#currentOpen !== session) { + return windowOpenRejected("checkout session ended"); + } + if (action === "handled") return windowOpenSuccess(); + if (action === "cancel") return windowOpenRejected("link opening canceled"); + } catch { + return windowOpenRejected("link handler failed"); + } + window.open(targetUrl.href, "_blank", "noopener"); return windowOpenSuccess(); } @@ -959,52 +1005,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, - options?: boolean | AddEventListenerOptions, - ): void; - - override addEventListener( - type: "ec.close", - 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, + // 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.totals.change", - listener: TypedEventListener | null, + override addEventListener( + type: K, + 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 +1033,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..41d4f8d14 100644 --- a/platforms/web/src/checkout.types.ts +++ b/platforms/web/src/checkout.types.ts @@ -1,12 +1,26 @@ -// 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 "./checkout-model"; +export type { CheckoutError, CheckoutErrorCode } from "./checkout-error"; + +/** A validated HTTPS link that checkout asked the host page to open. */ +export interface CheckoutLink { + url: URL; +} + +/** Open the link normally, report that the app handled it, or reject the request. */ +export type CheckoutLinkAction = "open" | "handled" | "cancel"; // 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 +177,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..0688e21df 100644 --- a/platforms/web/src/index.test.ts +++ b/platforms/web/src/index.test.ts @@ -14,10 +14,8 @@ describe("@shopify/checkout-kit public entry", () => { pkg.ShopifyCheckoutCompleteEvent, pkg.ShopifyCheckoutCloseEvent, pkg.ShopifyCheckoutErrorEvent, - pkg.ShopifyCheckoutFulfillmentChangeEvent, - pkg.ShopifyCheckoutLineItemsChangeEvent, - pkg.ShopifyCheckoutTotalsChangeEvent, - pkg.ShopifyCheckoutMessagesChangeEvent, + pkg.ShopifyCheckoutUpdateEvent, + pkg.ShopifyCheckoutLinkClickEvent, ]; 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..3f7efe50c 100644 --- a/platforms/web/src/index.ts +++ b/platforms/web/src/index.ts @@ -1,48 +1,37 @@ // 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, + ShopifyCheckoutLinkClickEvent, +} 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"; + ShopifyCheckoutLinkClickEventDetail, + ShopifyCheckoutEventMap, +} from "./checkout-events"; -// Public configuration types. export type { CheckoutAppearance, CheckoutTarget, + CheckoutLink, + CheckoutLinkAction, + 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"; From 2d968346f32c238f1702f646fde204481e88fe0d Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 14 Sep 2026 13:05:21 +0100 Subject: [PATCH 2/8] Keep delegated web link handling internal --- platforms/web/README.md | 38 +-- platforms/web/sample/README.md | 10 +- platforms/web/sample/index.html | 2 +- platforms/web/sample/main.ts | 7 - platforms/web/src/checkout-events.ts | 67 ----- platforms/web/src/checkout-protocol.test.ts | 308 +------------------- platforms/web/src/checkout.ts | 27 +- platforms/web/src/checkout.types.ts | 8 - platforms/web/src/index.test.ts | 1 - platforms/web/src/index.ts | 4 - 10 files changed, 11 insertions(+), 461 deletions(-) diff --git a/platforms/web/README.md b/platforms/web/README.md index 7d5f97073..8b26839a4 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -39,7 +39,6 @@ Check out our blog to - [Popup dimensions](#popup-dimensions) - [Overlay scrim](#overlay-scrim) - [Checkout lifecycle](#checkout-lifecycle) -- [Handling links](#handling-links) - [Migrating from `ec.*` events](#migrating-from-ec-events) - [Explore the sample app](#explore-the-sample-app) - [Contributing](#contributing) @@ -497,7 +496,6 @@ relevant to that moment. | `complete` | `{checkout}` | The buyer completed the order successfully. | | `error` | `{error}` | Checkout reported an error, exposed as `{code, message}`. The component closes automatically only when a message has `unrecoverable` severity. | | `close` | _(none)_ | The open session ended through `close()`, overlay dismissal, or detection of a popup the buyer closed. | -| `linkclick` | `{link}` | Checkout requests that the host open a link. See [Handling links](#handling-links). | `start`, `update`, and `complete` carry a Checkout Kit `Checkout` snapshot in `event.detail.checkout`. It preserves checkout data, including unknown @@ -561,39 +559,6 @@ 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. -## Handling links - -The `linkclick` event exposes a validated HTTPS `URL` at -`event.detail.link.url`. Call `event.respondWith()` to select a link policy: - -| Policy | Behavior | -| ------ | -------- | -| `'open'` | Checkout Kit opens the URL in a new tab with `noopener`. This is the default when no handler responds. | -| `'handled'` | Your application handles the link. Checkout Kit does not open another tab. | -| `'cancel'` | Cancel the link request. | - -```ts -checkout.addEventListener('linkclick', (event) => { - const {url} = event.detail.link; - - if (url.origin === location.origin && url.pathname === '/help') { - event.respondWith('handled'); - router.navigate(url.pathname); - return; - } - - event.respondWith('open'); -}); -``` - -Call `respondWith` synchronously while the listener is running. It accepts -either a policy or a promise that resolves to a policy, so asynchronous -handlers should pass their promise immediately rather than awaiting it first. -Only one listener can respond to a link request. A rejected promise or a -checkout session ending cancels the pending request. Calling `preventDefault()` -also cancels it when no response was supplied. -Links with invalid or non-HTTPS URLs are rejected before this event fires. - ## Migrating from `ec.*` events Checkout Kit's public events replace the protocol-named DOM events from @@ -610,8 +575,7 @@ earlier alpha releases: Subscribe to `update` once when replacing several change listeners, since a single snapshot may include changes to several fields. Checkout snapshots no longer expose `checkout.ucp`. Error handlers read `event.detail.error.code` -and `.message` instead of a protocol `ErrorResponse`. Use `linkclick` for -application-owned link handling; it has no previous public event equivalent. +and `.message` instead of a protocol `ErrorResponse`. ## Explore the sample app diff --git a/platforms/web/sample/README.md b/platforms/web/sample/README.md index b7adab0eb..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 Checkout Kit lifecycle events and link clicks. +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,7 +34,7 @@ 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 `start`, `update`, `complete`, `error`, `close`, and `linkclick` event log. Each entry includes the event detail and 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. @@ -45,12 +45,6 @@ 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. -The sample's `linkclick` listener calls `event.respondWith('open')` to let the -component open `event.detail.link.url` in a new tab. To try application-owned -navigation, change the listener in `main.ts` to respond with `'handled'` after -handling the link, or `'cancel'` to block it. Call `respondWith` during the -listener; it also accepts a promise for an asynchronous decision. - ## 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 a01211b0d..f73c8fad2 100644 --- a/platforms/web/sample/index.html +++ b/platforms/web/sample/index.html @@ -235,7 +235,7 @@

    Events

      - Open checkout and interact; lifecycle events and link clicks 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 a104f11b7..86f78e258 100644 --- a/platforms/web/sample/main.ts +++ b/platforms/web/sample/main.ts @@ -276,11 +276,4 @@ function attachListeners(): void { for (const type of EVENT_TYPES) { checkout.addEventListener(type, recordEvent); } - - checkout.addEventListener("linkclick", (event) => { - // Choose the policy while the event is being dispatched. An application can - // respond with "handled" after taking over navigation, or "cancel" to block it. - event.respondWith("open"); - recordEvent(event); - }); } diff --git a/platforms/web/src/checkout-events.ts b/platforms/web/src/checkout-events.ts index 260a64593..2e4712147 100644 --- a/platforms/web/src/checkout-events.ts +++ b/platforms/web/src/checkout-events.ts @@ -1,6 +1,5 @@ import type { Checkout } from "./checkout-model"; import type { CheckoutError } from "./checkout-error"; -import type { CheckoutLink, CheckoutLinkAction } from "./checkout.types"; export interface ShopifyCheckoutStartEventDetail { checkout: Checkout; @@ -18,10 +17,6 @@ export interface ShopifyCheckoutErrorEventDetail { error: CheckoutError; } -export interface ShopifyCheckoutLinkClickEventDetail { - link: CheckoutLink; -} - export class ShopifyCheckoutStartEvent extends CustomEvent { declare type: "start"; @@ -62,72 +57,10 @@ export class ShopifyCheckoutErrorEvent extends CustomEvent>(); - -/** A link request whose default action opens the URL in a new tab. */ -export class ShopifyCheckoutLinkClickEvent extends CustomEvent { - declare type: "linkclick"; - - constructor(detail: ShopifyCheckoutLinkClickEventDetail) { - super("linkclick", { detail, bubbles: true, cancelable: true }); - } - - /** - * Choose how to handle this link. Call once, during event dispatch; pass a - * promise if the decision requires asynchronous work. A rejected promise - * rejects the link request. Calling preventDefault() cancels the request - * when no response was supplied. - */ - respondWith(action: CheckoutLinkAction | Promise): void { - if (this.eventPhase === Event.NONE || linkResponses.has(this)) { - throw new DOMException( - "respondWith must be called once during event dispatch", - "InvalidStateError", - ); - } - const response = Promise.resolve(action); - // A later listener may throw before the bridge consumes this response. - void response.catch(() => {}); - linkResponses.set(this, response); - } -} - export interface ShopifyCheckoutEventMap { start: ShopifyCheckoutStartEvent; update: ShopifyCheckoutUpdateEvent; complete: ShopifyCheckoutCompleteEvent; error: ShopifyCheckoutErrorEvent; close: ShopifyCheckoutCloseEvent; - linkclick: ShopifyCheckoutLinkClickEvent; -} - -/** Dispatches the public event and consumes its response inside the bridge. */ -export async function dispatchCheckoutLinkClick( - target: EventTarget, - link: CheckoutLink, - signal?: AbortSignal, -): Promise { - const event = new ShopifyCheckoutLinkClickEvent({ link }); - target.dispatchEvent(event); - const response: Promise = - linkResponses.get(event) ?? Promise.resolve(event.defaultPrevented ? "cancel" : "open"); - const action = await new Promise((resolve, reject) => { - const abort = () => reject(new DOMException("Checkout session ended", "AbortError")); - if (signal?.aborted) abort(); - else signal?.addEventListener("abort", abort, { once: true }); - response.then( - (value) => { - signal?.removeEventListener("abort", abort); - return resolve(value); - }, - (error: unknown) => { - signal?.removeEventListener("abort", abort); - return reject(error); - }, - ); - }); - if (action !== "open" && action !== "handled" && action !== "cancel") { - throw new TypeError("Invalid checkout link action"); - } - return action; } diff --git a/platforms/web/src/checkout-protocol.test.ts b/platforms/web/src/checkout-protocol.test.ts index 4689a551d..540b99a68 100644 --- a/platforms/web/src/checkout-protocol.test.ts +++ b/platforms/web/src/checkout-protocol.test.ts @@ -9,7 +9,6 @@ import type { CheckoutProtocolMessageMap } from "./checkout.types"; import "./checkout-web-component"; import type { ShopifyCheckout } from "./checkout"; import { mockTelemetry } from "./telemetry.test-helpers"; -import { ShopifyCheckoutLinkClickEvent } from "./checkout-events"; const EMBED_PROTOCOL_VERSION = EmbeddedCheckoutProtocol.specVersion; const CHECKOUT_CHANGE_METHODS = [ @@ -741,289 +740,11 @@ describe("", () => { }); describe("ec.window.open_request", () => { - it("dispatches linkclick with a parsed URL before applying the default open action", async () => { - const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const windowOpenSpy = vi.spyOn(window, "open").mockClear(); - const linkSpy = vi.fn((event: ShopifyCheckoutLinkClickEvent) => { - expect(event).toBeInstanceOf(ShopifyCheckoutLinkClickEvent); - expect(event.detail.link.url).toBeInstanceOf(URL); - expect(event.detail.link.url.href).toBe("https://example.com/policy"); - expect(windowOpenSpy).not.toHaveBeenCalled(); - }); - checkout.addEventListener("linkclick", linkSpy); - - simulateProtocolMessageEvent( - checkout, - "ec.window.open_request", - { url: "https://example.com/policy" }, - { id: "link-default", source: mockCheckoutWindow }, - ); - await flushProtocolDispatch(); - - expect(linkSpy).toHaveBeenCalledOnce(); - expect(windowOpenSpy).toHaveBeenCalledWith( - "https://example.com/policy", - "_blank", - "noopener", - ); - expectLinkResponse(checkout, mockCheckoutWindow, "link-default", "success"); - }); - - it.each(["javascript:alert(1)", "https://other.example.com/replaced"])( - "opens the original validated URL when a listener changes its URL to %s", - async (replacement) => { - const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const windowOpenSpy = vi.spyOn(window, "open").mockClear(); - checkout.addEventListener("linkclick", (event) => { - event.detail.link.url.href = replacement; - event.respondWith("open"); - }); - - simulateProtocolMessageEvent( - checkout, - "ec.window.open_request", - { url: "https://example.com/policy" }, - { id: "link-mutated", source: mockCheckoutWindow }, - ); - await flushProtocolDispatch(); - - expect(windowOpenSpy).toHaveBeenCalledExactlyOnceWith( - "https://example.com/policy", - "_blank", - "noopener", - ); - expectLinkResponse(checkout, mockCheckoutWindow, "link-mutated", "success"); - }, - ); - - it.each(["open", "handled", "cancel"] as const)( - "honors the consumer's synchronous %s action", - async (action) => { - const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const windowOpenSpy = vi.spyOn(window, "open").mockClear(); - const errorSpy = vi.fn(); - const closeSpy = vi.fn(); - checkout.addEventListener("error", errorSpy); - checkout.addEventListener("close", closeSpy); - checkout.addEventListener("linkclick", (event) => event.respondWith(action)); - - simulateProtocolMessageEvent( - checkout, - "ec.window.open_request", - { url: "https://example.com/policy" }, - { id: "link-action", source: mockCheckoutWindow }, - ); - await flushProtocolDispatch(); - - expect(windowOpenSpy).toHaveBeenCalledTimes(action === "open" ? 1 : 0); - expectLinkResponse( - checkout, - mockCheckoutWindow, - "link-action", - action === "cancel" ? "error" : "success", - ); - expect(errorSpy).not.toHaveBeenCalled(); - expect(closeSpy).not.toHaveBeenCalled(); - expect(checkout.error).toBeUndefined(); - }, - ); - - it.each(["open", "handled", "cancel"] as const)( - "awaits a registered promise before applying its %s action", - async (action) => { - const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const windowOpenSpy = vi.spyOn(window, "open").mockClear(); - let resolveAction!: (action: "open" | "handled" | "cancel") => void; - const response = new Promise<"open" | "handled" | "cancel">((resolve) => { - resolveAction = resolve; - }); - checkout.addEventListener("linkclick", (event) => event.respondWith(response)); - - simulateProtocolMessageEvent( - checkout, - "ec.window.open_request", - { url: "https://example.com/policy" }, - { id: "link-async", source: mockCheckoutWindow }, - ); - await flushProtocolDispatch(); - - expect(windowOpenSpy).not.toHaveBeenCalled(); - expect(mockCheckoutWindow.postMessage).not.toHaveBeenCalled(); - resolveAction(action); - await flushProtocolDispatch(); - - expect(windowOpenSpy).toHaveBeenCalledTimes(action === "open" ? 1 : 0); - expectLinkResponse( - checkout, - mockCheckoutWindow, - "link-async", - action === "cancel" ? "error" : "success", - ); - }, - ); - - it.each(["close", "reopen", "disconnect"] as const)( - "rejects a pending link when the checkout session ends through %s", - async (action) => { - const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const windowOpenSpy = vi.spyOn(window, "open").mockClear(); - let resolveAction!: (action: "open") => void; - const response = new Promise<"open">((resolve) => { - resolveAction = resolve; - }); - checkout.addEventListener("linkclick", (event) => event.respondWith(response)); - - simulateProtocolMessageEvent( - checkout, - "ec.window.open_request", - { url: "https://example.com/obsolete" }, - { id: "link-ended-session", source: mockCheckoutWindow }, - ); - await flushProtocolDispatch(); - expect(mockCheckoutWindow.postMessage).not.toHaveBeenCalled(); - - if (action === "reopen") { - windowOpenSpy.mockReturnValueOnce(createMockWindow()); - checkout.open(); - windowOpenSpy.mockClear(); - } else if (action === "disconnect") { - checkout.remove(); - } else { - checkout.close(); - } - await flushProtocolDispatch(); - - expectLinkResponse(checkout, mockCheckoutWindow, "link-ended-session", "error"); - resolveAction("open"); - await flushProtocolDispatch(); - - expect(windowOpenSpy).not.toHaveBeenCalled(); - expect(mockCheckoutWindow.postMessage).toHaveBeenCalledOnce(); - }, - ); - - it("rejects a resolved link action if the session closes before the action is applied", async () => { - const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const windowOpenSpy = vi.spyOn(window, "open").mockClear(); - let resolveAction!: (action: "open") => void; - const response = new Promise<"open">((resolve) => { - resolveAction = resolve; - }); - checkout.addEventListener("linkclick", (event) => event.respondWith(response)); - - simulateProtocolMessageEvent( - checkout, - "ec.window.open_request", - { url: "https://example.com/obsolete" }, - { id: "link-close-race", source: mockCheckoutWindow }, - ); - await flushProtocolDispatch(); - - resolveAction("open"); - queueMicrotask(() => checkout.close()); - await flushProtocolDispatch(); - - expectLinkResponse(checkout, mockCheckoutWindow, "link-close-race", "error"); - expect(windowOpenSpy).not.toHaveBeenCalled(); - }); - - it("cancels a link when the consumer prevents the default action", async () => { - const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const windowOpenSpy = vi.spyOn(window, "open").mockClear(); - const errorSpy = vi.fn(); - const closeSpy = vi.fn(); - checkout.addEventListener("error", errorSpy); - checkout.addEventListener("close", closeSpy); - checkout.addEventListener("linkclick", (event) => event.preventDefault()); - - simulateProtocolMessageEvent( - checkout, - "ec.window.open_request", - { url: "https://example.com/policy" }, - { id: "link-prevented", source: mockCheckoutWindow }, - ); - await flushProtocolDispatch(); - - expectLinkResponse(checkout, mockCheckoutWindow, "link-prevented", "error"); - expect(windowOpenSpy).not.toHaveBeenCalled(); - expect(errorSpy).not.toHaveBeenCalled(); - expect(closeSpy).not.toHaveBeenCalled(); - expect(checkout.error).toBeUndefined(); - }); - - it("rejects the protocol request when the consumer's response promise rejects", async () => { - const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const windowOpenSpy = vi.spyOn(window, "open").mockClear(); - const errorSpy = vi.fn(); - const closeSpy = vi.fn(); - checkout.addEventListener("error", errorSpy); - checkout.addEventListener("close", closeSpy); - checkout.addEventListener("linkclick", (event) => { - event.respondWith(Promise.reject(new Error("Consumer could not handle link"))); - }); - - simulateProtocolMessageEvent( - checkout, - "ec.window.open_request", - { url: "https://example.com/policy" }, - { id: "link-rejected", source: mockCheckoutWindow }, - ); - await flushProtocolDispatch(); - - expectLinkResponse(checkout, mockCheckoutWindow, "link-rejected", "error"); - expect(windowOpenSpy).not.toHaveBeenCalled(); - expect(errorSpy).not.toHaveBeenCalled(); - expect(closeSpy).not.toHaveBeenCalled(); - expect(checkout.error).toBeUndefined(); - }); - - it("allows only one response registration across all link listeners", async () => { - const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const windowOpenSpy = vi.spyOn(window, "open").mockClear(); - checkout.addEventListener("linkclick", (event) => event.respondWith("handled")); - const secondListener = vi.fn((event: ShopifyCheckoutLinkClickEvent) => { - expect(() => event.respondWith("open")).toThrow(DOMException); - }); - checkout.addEventListener("linkclick", secondListener); - - simulateProtocolMessageEvent( - checkout, - "ec.window.open_request", - { url: "https://example.com/policy" }, - { id: "link-once", source: mockCheckoutWindow }, - ); - await flushProtocolDispatch(); - - expect(secondListener).toHaveBeenCalledOnce(); - expect(windowOpenSpy).not.toHaveBeenCalled(); - expectLinkResponse(checkout, mockCheckoutWindow, "link-once", "success"); - }); - - it("requires respondWith to be called during synchronous event dispatch", async () => { - const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const windowOpenSpy = vi.spyOn(window, "open").mockClear(); - let linkEvent!: ShopifyCheckoutLinkClickEvent; - checkout.addEventListener("linkclick", (event) => { - linkEvent = event; - }); - - simulateProtocolMessageEvent( - checkout, - "ec.window.open_request", - { url: "https://example.com/policy" }, - { id: "link-late", source: mockCheckoutWindow }, - ); - await flushProtocolDispatch(); - - expect(linkEvent).toBeDefined(); - expect(() => linkEvent.respondWith("cancel")).toThrow(DOMException); - expect(windowOpenSpy).toHaveBeenCalledOnce(); - expectLinkResponse(checkout, mockCheckoutWindow, "link-late", "success"); - }); - it("opens the requested url in a new tab with noopener when an id is present", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const windowOpenSpy = vi.spyOn(window, "open"); + const linkClickSpy = vi.fn(); + checkout.addEventListener("linkclick", linkClickSpy); simulateProtocolMessageEvent( checkout, @@ -1038,6 +759,7 @@ describe("", () => { "_blank", "noopener", ); + expect(linkClickSpy).not.toHaveBeenCalled(); }); it("posts a JSON-RPC response back to the source", async () => { @@ -1143,8 +865,6 @@ describe("", () => { it("rejects the request when the url string cannot be parsed", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout({ "log-level": "warn" }); const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const linkSpy = vi.fn(); - checkout.addEventListener("linkclick", linkSpy); simulateProtocolMessageEvent( checkout, @@ -1169,15 +889,12 @@ describe("", () => { }), new URL(checkout.src).origin, ); - expect(linkSpy).not.toHaveBeenCalled(); }); it("rejects the request when the url uses a non-https scheme", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout({ "log-level": "warn" }); const windowOpenSpy = vi.spyOn(window, "open"); const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const linkSpy = vi.fn(); - checkout.addEventListener("linkclick", linkSpy); simulateProtocolMessageEvent( checkout, @@ -1207,7 +924,6 @@ describe("", () => { "_blank", "noopener", ); - expect(linkSpy).not.toHaveBeenCalled(); }); it("does not warn about an invalid url when the handler throws internally", async () => { @@ -1836,24 +1552,6 @@ function simulateProtocolMessageEvent( window.dispatchEvent(event); } -function expectLinkResponse( - checkout: ShopifyCheckout, - checkoutWindow: Window, - id: string, - status: "success" | "error", -) { - expect(checkoutWindow.postMessage).toHaveBeenCalledWith( - expect.objectContaining({ - jsonrpc: "2.0", - id, - result: expect.objectContaining({ - ucp: { status, version: EMBED_PROTOCOL_VERSION }, - }), - }), - new URL(checkout.src).origin, - ); -} - function flushProtocolDispatch(): Promise { return new Promise((resolve) => setTimeout(resolve, 0)); } diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 2b2a60845..475dbbec9 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -18,7 +18,6 @@ import { ShopifyCheckoutErrorEvent, ShopifyCheckoutCloseEvent, type ShopifyCheckoutEventMap, - dispatchCheckoutLinkClick, } from "./checkout-events"; import stylesText from "./checkout.css?inline"; import { Logger, coerceLogLevel } from "./logger"; @@ -176,7 +175,6 @@ const SHADOW_TEMPLATE = createTemplate(html` * @event {ShopifyCheckoutCompleteEvent} complete - Checkout completed successfully. * @event {ShopifyCheckoutErrorEvent} error - Checkout reported an error; unrecoverable errors close the session. * @event {ShopifyCheckoutCloseEvent} close - The checkout session closed. - * @event {ShopifyCheckoutLinkClickEvent} linkclick - Checkout requested a link; respondWith selects its handling. * * @example * ```js @@ -909,12 +907,11 @@ export class ShopifyCheckout } /** - * Handles a link delegation after HTTPS validation. The public linkclick - * event controls whether the URL opens, was handled by the app, or is rejected. + * Handles an `ec.window.open_request` delegation: opens a validated `https:` + * URL in a new tab and returns a UCP result. Invalid or non-`https:` URLs + * are rejected (and warned about) rather than opened. */ - async #handleWindowOpen(request: WindowOpenRequest): Promise { - const session = this.#currentOpen; - if (!session) return windowOpenRejected("checkout session ended"); + #handleWindowOpen(request: WindowOpenRequest): WindowOpenResult { let targetUrl: URL; try { targetUrl = new URL(request.url); @@ -928,22 +925,6 @@ export class ShopifyCheckout return windowOpenRejected("url must use https scheme"); } - try { - // Keep the validated default URL separate from the consumer's mutable URL. - const action = await dispatchCheckoutLinkClick( - this, - { url: new URL(targetUrl.href) }, - session.controller.signal, - ); - if (session.controller.signal.aborted || this.#currentOpen !== session) { - return windowOpenRejected("checkout session ended"); - } - if (action === "handled") return windowOpenSuccess(); - if (action === "cancel") return windowOpenRejected("link opening canceled"); - } catch { - return windowOpenRejected("link handler failed"); - } - window.open(targetUrl.href, "_blank", "noopener"); return windowOpenSuccess(); } diff --git a/platforms/web/src/checkout.types.ts b/platforms/web/src/checkout.types.ts index 41d4f8d14..3cef42996 100644 --- a/platforms/web/src/checkout.types.ts +++ b/platforms/web/src/checkout.types.ts @@ -14,14 +14,6 @@ export type { LogLevel }; export type { Checkout } from "./checkout-model"; export type { CheckoutError, CheckoutErrorCode } from "./checkout-error"; -/** A validated HTTPS link that checkout asked the host page to open. */ -export interface CheckoutLink { - url: URL; -} - -/** Open the link normally, report that the app handled it, or reject the request. */ -export type CheckoutLinkAction = "open" | "handled" | "cancel"; - // This component should follow the custom element conventions set out here: // https://github.com/Shopify/ui-api-design/tree/main/codex. In particular, // take note of the following: diff --git a/platforms/web/src/index.test.ts b/platforms/web/src/index.test.ts index 0688e21df..12fce84fc 100644 --- a/platforms/web/src/index.test.ts +++ b/platforms/web/src/index.test.ts @@ -15,7 +15,6 @@ describe("@shopify/checkout-kit public entry", () => { pkg.ShopifyCheckoutCloseEvent, pkg.ShopifyCheckoutErrorEvent, pkg.ShopifyCheckoutUpdateEvent, - pkg.ShopifyCheckoutLinkClickEvent, ]; 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 3f7efe50c..cc4b433d7 100644 --- a/platforms/web/src/index.ts +++ b/platforms/web/src/index.ts @@ -9,7 +9,6 @@ export { ShopifyCheckoutCompleteEvent, ShopifyCheckoutErrorEvent, ShopifyCheckoutCloseEvent, - ShopifyCheckoutLinkClickEvent, } from "./checkout-events"; export type { @@ -17,15 +16,12 @@ export type { ShopifyCheckoutUpdateEventDetail, ShopifyCheckoutCompleteEventDetail, ShopifyCheckoutErrorEventDetail, - ShopifyCheckoutLinkClickEventDetail, ShopifyCheckoutEventMap, } from "./checkout-events"; export type { CheckoutAppearance, CheckoutTarget, - CheckoutLink, - CheckoutLinkAction, Checkout, CheckoutError, CheckoutErrorCode, From 6e008f657a5fd609f130e85892397af9a69f3023 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 14 Sep 2026 13:08:49 +0100 Subject: [PATCH 3/8] Move web checkout model into models directory --- platforms/web/src/checkout-events.ts | 2 +- platforms/web/src/checkout-model.test.ts | 2 +- platforms/web/src/checkout.ts | 2 +- platforms/web/src/checkout.types.ts | 2 +- platforms/web/src/{checkout-model.ts => models/checkout.ts} | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename platforms/web/src/{checkout-model.ts => models/checkout.ts} (100%) diff --git a/platforms/web/src/checkout-events.ts b/platforms/web/src/checkout-events.ts index 2e4712147..de75c37a1 100644 --- a/platforms/web/src/checkout-events.ts +++ b/platforms/web/src/checkout-events.ts @@ -1,4 +1,4 @@ -import type { Checkout } from "./checkout-model"; +import type { Checkout } from "./models/checkout"; import type { CheckoutError } from "./checkout-error"; export interface ShopifyCheckoutStartEventDetail { diff --git a/platforms/web/src/checkout-model.test.ts b/platforms/web/src/checkout-model.test.ts index 43dbfb6a8..60fcf0795 100644 --- a/platforms/web/src/checkout-model.test.ts +++ b/platforms/web/src/checkout-model.test.ts @@ -7,7 +7,7 @@ import { type LineItem, } from "@shopify/checkout-kit-protocol"; -import { checkoutComparisonKey, toCheckout, type Checkout } from "./checkout-model"; +import { checkoutComparisonKey, toCheckout, type Checkout } from "./models/checkout"; function protocolCheckout(overrides: Partial = {}): ProtocolCheckout { return { diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 475dbbec9..cade728cb 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -9,7 +9,7 @@ import { type Checkout as ProtocolCheckout, } from "@shopify/checkout-kit-protocol"; -import { toCheckout, checkoutComparisonKey } from "./checkout-model"; +import { toCheckout, checkoutComparisonKey } from "./models/checkout"; import { toCheckoutError } from "./checkout-error"; import { ShopifyCheckoutStartEvent, diff --git a/platforms/web/src/checkout.types.ts b/platforms/web/src/checkout.types.ts index 3cef42996..d8934acac 100644 --- a/platforms/web/src/checkout.types.ts +++ b/platforms/web/src/checkout.types.ts @@ -11,7 +11,7 @@ import type { import type { LogLevel } from "./logger"; export type { LogLevel }; -export type { Checkout } from "./checkout-model"; +export type { Checkout } from "./models/checkout"; export type { CheckoutError, CheckoutErrorCode } from "./checkout-error"; // This component should follow the custom element conventions set out here: diff --git a/platforms/web/src/checkout-model.ts b/platforms/web/src/models/checkout.ts similarity index 100% rename from platforms/web/src/checkout-model.ts rename to platforms/web/src/models/checkout.ts From 0cfafda3aed77e4a7f36f63d538aba1b71ffb634 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 14 Sep 2026 13:10:26 +0100 Subject: [PATCH 4/8] Document web events without migration guidance --- platforms/web/README.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/platforms/web/README.md b/platforms/web/README.md index 8b26839a4..6bef5a3c8 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -39,7 +39,6 @@ Check out our blog to - [Popup dimensions](#popup-dimensions) - [Overlay scrim](#overlay-scrim) - [Checkout lifecycle](#checkout-lifecycle) -- [Migrating from `ec.*` events](#migrating-from-ec-events) - [Explore the sample app](#explore-the-sample-app) - [Contributing](#contributing) - [License](#license) @@ -559,24 +558,6 @@ 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. -## Migrating from `ec.*` events - -Checkout Kit's public events replace the protocol-named DOM events from -earlier alpha releases: - -| Previous event | Replacement | -| -------------- | ----------- | -| `ec.start` | `start` | -| `ec.complete` | `complete` | -| `ec.error` | `error` | -| `ec.close` | `close` | -| `ec.fulfillment.change`, `ec.line_items.change`, `ec.totals.change`, `ec.messages.change` | `update` | - -Subscribe to `update` once when replacing several change listeners, since a -single snapshot may include changes to several fields. Checkout snapshots no -longer expose `checkout.ucp`. Error handlers read `event.detail.error.code` -and `.message` instead of a protocol `ErrorResponse`. - ## Explore the sample app See the [`sample/`](./sample) directory for a small Vite playground that mounts From ad577ed6ff5f60eb00af4ac9dab904ddf4d6dc83 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 14 Sep 2026 13:12:28 +0100 Subject: [PATCH 5/8] Move web error model into models directory --- platforms/web/src/checkout-error.test.ts | 2 +- platforms/web/src/checkout-events.ts | 2 +- platforms/web/src/checkout.ts | 2 +- platforms/web/src/checkout.types.ts | 2 +- platforms/web/src/{checkout-error.ts => models/error.ts} | 0 5 files changed, 4 insertions(+), 4 deletions(-) rename platforms/web/src/{checkout-error.ts => models/error.ts} (100%) diff --git a/platforms/web/src/checkout-error.test.ts b/platforms/web/src/checkout-error.test.ts index ebb158fe4..26fbeba0a 100644 --- a/platforms/web/src/checkout-error.test.ts +++ b/platforms/web/src/checkout-error.test.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import type { ErrorResponse } from "@shopify/checkout-kit-protocol"; -import { toCheckoutError, type CheckoutError, type CheckoutErrorCode } from "./checkout-error"; +import { toCheckoutError, type CheckoutError, type CheckoutErrorCode } from "./models/error"; function protocolError(messages: unknown): ErrorResponse { // The shared decoder does not validate every member of the messages field. diff --git a/platforms/web/src/checkout-events.ts b/platforms/web/src/checkout-events.ts index de75c37a1..0171f9ec0 100644 --- a/platforms/web/src/checkout-events.ts +++ b/platforms/web/src/checkout-events.ts @@ -1,5 +1,5 @@ import type { Checkout } from "./models/checkout"; -import type { CheckoutError } from "./checkout-error"; +import type { CheckoutError } from "./models/error"; export interface ShopifyCheckoutStartEventDetail { checkout: Checkout; diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index cade728cb..f3f880d5f 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -10,7 +10,7 @@ import { } from "@shopify/checkout-kit-protocol"; import { toCheckout, checkoutComparisonKey } from "./models/checkout"; -import { toCheckoutError } from "./checkout-error"; +import { toCheckoutError } from "./models/error"; import { ShopifyCheckoutStartEvent, ShopifyCheckoutUpdateEvent, diff --git a/platforms/web/src/checkout.types.ts b/platforms/web/src/checkout.types.ts index d8934acac..a1f4b36d1 100644 --- a/platforms/web/src/checkout.types.ts +++ b/platforms/web/src/checkout.types.ts @@ -12,7 +12,7 @@ import type { LogLevel } from "./logger"; export type { LogLevel }; export type { Checkout } from "./models/checkout"; -export type { CheckoutError, CheckoutErrorCode } from "./checkout-error"; +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, diff --git a/platforms/web/src/checkout-error.ts b/platforms/web/src/models/error.ts similarity index 100% rename from platforms/web/src/checkout-error.ts rename to platforms/web/src/models/error.ts From 49e331c9bbdd47490c511c4a0630dfd5a2970ac5 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 14 Sep 2026 13:15:19 +0100 Subject: [PATCH 6/8] Colocate web model tests with their implementations --- .../web/src/{checkout-model.test.ts => models/checkout.test.ts} | 2 +- .../web/src/{checkout-error.test.ts => models/error.test.ts} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename platforms/web/src/{checkout-model.test.ts => models/checkout.test.ts} (99%) rename platforms/web/src/{checkout-error.test.ts => models/error.test.ts} (99%) diff --git a/platforms/web/src/checkout-model.test.ts b/platforms/web/src/models/checkout.test.ts similarity index 99% rename from platforms/web/src/checkout-model.test.ts rename to platforms/web/src/models/checkout.test.ts index 60fcf0795..bc27a157f 100644 --- a/platforms/web/src/checkout-model.test.ts +++ b/platforms/web/src/models/checkout.test.ts @@ -7,7 +7,7 @@ import { type LineItem, } from "@shopify/checkout-kit-protocol"; -import { checkoutComparisonKey, toCheckout, type Checkout } from "./models/checkout"; +import { checkoutComparisonKey, toCheckout, type Checkout } from "./checkout"; function protocolCheckout(overrides: Partial = {}): ProtocolCheckout { return { diff --git a/platforms/web/src/checkout-error.test.ts b/platforms/web/src/models/error.test.ts similarity index 99% rename from platforms/web/src/checkout-error.test.ts rename to platforms/web/src/models/error.test.ts index 26fbeba0a..e7d17303f 100644 --- a/platforms/web/src/checkout-error.test.ts +++ b/platforms/web/src/models/error.test.ts @@ -1,7 +1,7 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import type { ErrorResponse } from "@shopify/checkout-kit-protocol"; -import { toCheckoutError, type CheckoutError, type CheckoutErrorCode } from "./models/error"; +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. From 8c55f102c794a739dd2851ad2f41842b8eef23d4 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 16 Sep 2026 13:11:15 +0100 Subject: [PATCH 7/8] Align web event docs with terminal protocol errors --- platforms/web/README.md | 7 +++---- platforms/web/src/checkout-protocol.test.ts | 2 +- platforms/web/src/checkout.ts | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/platforms/web/README.md b/platforms/web/README.md index 6bef5a3c8..cf720181e 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -493,7 +493,7 @@ relevant to that moment. | `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 an error, exposed as `{code, message}`. The component closes automatically only when a message has `unrecoverable` severity. | +| `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 @@ -532,9 +532,8 @@ checkout.addEventListener('close', () => { }); ``` -Errors with other severities emit `error` while leaving checkout open. For an -unrecoverable error, the component emits `error` before closing and emitting -`close`. +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 `start` requires line items, diff --git a/platforms/web/src/checkout-protocol.test.ts b/platforms/web/src/checkout-protocol.test.ts index 540b99a68..762bf8bf0 100644 --- a/platforms/web/src/checkout-protocol.test.ts +++ b/platforms/web/src/checkout-protocol.test.ts @@ -327,7 +327,7 @@ describe("", () => { expect(onErrorSpy).not.toHaveBeenCalled(); }); - it("auto-closes when any message has severity 'unrecoverable'", async () => { + it("auto-closes for mixed message severities", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const errorOrder: string[] = []; checkout.addEventListener("error", () => errorOrder.push("error")); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index f3f880d5f..f962cadc3 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -173,7 +173,7 @@ const SHADOW_TEMPLATE = createTemplate(html` * @event {ShopifyCheckoutStartEvent} start - Checkout has started. * @event {ShopifyCheckoutUpdateEvent} update - The checkout snapshot changed. * @event {ShopifyCheckoutCompleteEvent} complete - Checkout completed successfully. - * @event {ShopifyCheckoutErrorEvent} error - Checkout reported an error; unrecoverable errors close the session. + * @event {ShopifyCheckoutErrorEvent} error - Checkout reported a terminal error; the session closes after this event. * @event {ShopifyCheckoutCloseEvent} close - The checkout session closed. * * @example From 1f7a523d26568ed1899edf8e45cfa13489922a26 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Fri, 18 Sep 2026 09:54:13 +0100 Subject: [PATCH 8/8] Prevent checkout errors from bubbling to global handlers --- platforms/web/README.md | 9 +++--- platforms/web/src/checkout-events.ts | 3 +- platforms/web/src/checkout-protocol.test.ts | 34 +++++++++++++++++++-- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/platforms/web/README.md b/platforms/web/README.md index cf720181e..60c8b6bf1 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -483,10 +483,11 @@ shopify-checkout::part(overlay) { ## Checkout lifecycle The element dispatches typed `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 an `event.detail` payload with the fields -relevant to that moment. +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 | | ---------- | -------------- | ------------- | diff --git a/platforms/web/src/checkout-events.ts b/platforms/web/src/checkout-events.ts index 0171f9ec0..ec498159f 100644 --- a/platforms/web/src/checkout-events.ts +++ b/platforms/web/src/checkout-events.ts @@ -53,7 +53,8 @@ export class ShopifyCheckoutErrorEvent extends CustomEvent", () => { }); }); + 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(); @@ -743,8 +774,6 @@ describe("", () => { it("opens the requested url in a new tab with noopener when an id is present", async () => { const { checkout, mockCheckoutWindow } = openPopupCheckout(); const windowOpenSpy = vi.spyOn(window, "open"); - const linkClickSpy = vi.fn(); - checkout.addEventListener("linkclick", linkClickSpy); simulateProtocolMessageEvent( checkout, @@ -759,7 +788,6 @@ describe("", () => { "_blank", "noopener", ); - expect(linkClickSpy).not.toHaveBeenCalled(); }); it("posts a JSON-RPC response back to the source", async () => {