From 6dbcb389cb1fd9d0baf645ced45b0d318260e479 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Fri, 25 Sep 2026 17:01:54 -0300 Subject: [PATCH 1/4] Add a blocked event when the checkout window is blocked --- platforms/web/README.md | 21 +++++---- platforms/web/sample/README.md | 2 +- platforms/web/sample/main.ts | 2 +- platforms/web/src/checkout-events.ts | 9 ++++ platforms/web/src/checkout-window.test.ts | 56 +++++++++++++++++++++++ platforms/web/src/checkout.ts | 14 ++++++ platforms/web/src/index.test.ts | 1 + platforms/web/src/index.ts | 1 + 8 files changed, 95 insertions(+), 11 deletions(-) diff --git a/platforms/web/README.md b/platforms/web/README.md index e164ae242..7430b8704 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -356,10 +356,12 @@ Where the checkout is presented. Defaults to `"auto"`. > [!NOTE] > If the browser refuses to open the window (for example, a popup blocker, or -> `open()` called outside a user gesture), the [overlay scrim](#overlay-scrim) -> says so and offers a button to try again. Closing it dispatches `close`. -> If the overlay is hidden, nothing is shown. The component logs a warning at -> `log-level="warn"` or more verbose. +> `open()` called outside a user gesture), the component dispatches `blocked` +> and the [overlay scrim](#overlay-scrim) says so and offers a button to try +> again. Closing it dispatches `close`. If you hide the overlay, listen for +> `blocked` to show your own message, and call `open()` again from a user +> action such as a click. A call made directly from the listener is ignored. +> The component logs a warning at `log-level="warn"` or more verbose. ### `appearance` @@ -495,11 +497,11 @@ shopify-checkout::part(overlay) { ## Checkout lifecycle The element dispatches typed `CustomEvent`s at every meaningful moment of the -checkout session. The `start`, `update`, `complete`, and `close` events bubble, -so you can listen anywhere in your DOM, including a single delegated listener -at `document` if you have many elements on the page. The `error` event does not -bubble; attach its listener directly to the checkout element. Event payloads -are available in `event.detail`. +checkout session. The `start`, `update`, `complete`, `close`, and `blocked` +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 | | ---------- | -------------- | ------------- | @@ -508,6 +510,7 @@ are available in `event.detail`. | `complete` | `{checkout}` | The buyer completed the order successfully. | | `error` | `{error}` | Checkout could not open or reported a terminal error, exposed as `{code, message}`. An open session closes automatically after this event. | | `close` | _(none)_ | The open session ended through `close()`, overlay dismissal, or detection of a popup the buyer closed. If the browser blocked the window, no `start` precedes it. | +| `blocked` | _(none)_ | The browser blocked the checkout window. Fires on every blocked attempt, whether or not the overlay is shown. | `start`, `update`, and `complete` carry a Checkout Kit `Checkout` snapshot in `event.detail.checkout`. It preserves checkout data, including unknown diff --git a/platforms/web/sample/README.md b/platforms/web/sample/README.md index ce330db4e..a2e4474d0 100644 --- a/platforms/web/sample/README.md +++ b/platforms/web/sample/README.md @@ -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`, and `close` 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`, `close`, and `blocked` 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. diff --git a/platforms/web/sample/main.ts b/platforms/web/sample/main.ts index 86f78e258..f3e7f027c 100644 --- a/platforms/web/sample/main.ts +++ b/platforms/web/sample/main.ts @@ -21,7 +21,7 @@ import { } from "./storage"; import "./styles.css"; -const EVENT_TYPES = ["start", "update", "complete", "close", "error"] as const; +const EVENT_TYPES = ["start", "update", "complete", "close", "error", "blocked"] as const; const refs = queryRefs(); diff --git a/platforms/web/src/checkout-events.ts b/platforms/web/src/checkout-events.ts index ec498159f..87c3d6d05 100644 --- a/platforms/web/src/checkout-events.ts +++ b/platforms/web/src/checkout-events.ts @@ -49,6 +49,14 @@ export class ShopifyCheckoutCloseEvent extends CustomEvent { } } +export class ShopifyCheckoutBlockedEvent extends CustomEvent { + declare type: "blocked"; + + constructor() { + super("blocked", { bubbles: true }); + } +} + export class ShopifyCheckoutErrorEvent extends CustomEvent { declare type: "error"; @@ -64,4 +72,5 @@ export interface ShopifyCheckoutEventMap { complete: ShopifyCheckoutCompleteEvent; error: ShopifyCheckoutErrorEvent; close: ShopifyCheckoutCloseEvent; + blocked: ShopifyCheckoutBlockedEvent; } diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index d21908d1e..0751e3691 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -392,6 +392,62 @@ describe("", () => { }); }); + it("dispatches blocked when the popup is blocked", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + vi.spyOn(window, "open").mockReturnValue(null); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + const blockedEventSpy = vi.fn(); + checkout.addEventListener("blocked", blockedEventSpy); + + checkout.open(); + + expect(blockedEventSpy).toHaveBeenCalledTimes(1); + }); + }); + + it("dispatches blocked when the popup is blocked and the overlay is hidden", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + vi.spyOn(window, "open").mockReturnValue(null); + const dialogShowModalSpy = vi + .spyOn(HTMLDialogElement.prototype, "showModal") + .mockImplementation(() => {}); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + getPropertyValue: (prop: string) => { + if (prop === "display") return "none"; + return ""; + }, + } as CSSStyleDeclaration); + const blockedEventSpy = vi.fn(); + checkout.addEventListener("blocked", blockedEventSpy); + + checkout.open(); + + expect(dialogShowModalSpy).not.toHaveBeenCalled(); + expect(blockedEventSpy).toHaveBeenCalledTimes(1); + }); + }); + + it("ignores open() called from a blocked listener", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target, "log-level": "warn" }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(null); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const blockedEventSpy = vi.fn(() => checkout.open()); + checkout.addEventListener("blocked", blockedEventSpy); + + checkout.open(); + + expect(windowOpenSpy).toHaveBeenCalledTimes(1); + expect(blockedEventSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith( + ": open() called from a blocked listener will be ignored; call it from a user action such as a click", + ); + }); + }); + it("dispatches close when the blocked overlay is dismissed", () => { POPUP_TARGETS.forEach((target) => { const checkout = renderCheckout({ target }); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index bfe076d6a..48f970ee3 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -18,6 +18,7 @@ import { ShopifyCheckoutCompleteEvent, ShopifyCheckoutErrorEvent, ShopifyCheckoutCloseEvent, + ShopifyCheckoutBlockedEvent, type ShopifyCheckoutEventMap, } from "./checkout-events"; import stylesText from "./checkout.css?inline"; @@ -187,6 +188,7 @@ const SHADOW_TEMPLATE = createTemplate(html` * @event {ShopifyCheckoutCompleteEvent} complete - Checkout completed successfully. * @event {ShopifyCheckoutErrorEvent} error - Checkout could not open or reported a terminal error; an open session closes after this event. * @event {ShopifyCheckoutCloseEvent} close - The checkout session closed, including after a blocked window. + * @event {ShopifyCheckoutBlockedEvent} blocked - The browser blocked the checkout window. * * @example * ```js @@ -224,6 +226,7 @@ export class ShopifyCheckout #currentOpen: { controller: AbortController } | null = null; // Manages a blocked open, and the scrim dialog while it shows the blocked-window state #blockedOpen: { controller: AbortController } | null = null; + #dispatchingBlocked = false; // Manages the global message event listener for checkout protocol communication #checkoutProtocolController: { controller: AbortController } | null = null; // Shared protocol client that decodes messages and dispatches to handlers @@ -433,6 +436,13 @@ export class ShopifyCheckout * Reveals checkout in the target. */ open(): void { + if (this.#dispatchingBlocked) { + this.#logger.warn( + "open() called from a blocked listener will be ignored; call it from a user action such as a click", + ); + return; + } + const unsupportedCapabilities = getUnsupportedBrowserCapabilities(); if (unsupportedCapabilities.length > 0) { this.#checkout = undefined; @@ -504,6 +514,10 @@ export class ShopifyCheckout isRetry, }); this.#showBlockedOverlay(); + this.#dispatchingBlocked = true; + /** @ignore - Events are documented by the class @event tags. */ + this.dispatchEvent(new ShopifyCheckoutBlockedEvent()); + this.#dispatchingBlocked = false; return; } diff --git a/platforms/web/src/index.test.ts b/platforms/web/src/index.test.ts index 12fce84fc..fba4ba40a 100644 --- a/platforms/web/src/index.test.ts +++ b/platforms/web/src/index.test.ts @@ -15,6 +15,7 @@ describe("@shopify/checkout-kit public entry", () => { pkg.ShopifyCheckoutCloseEvent, pkg.ShopifyCheckoutErrorEvent, pkg.ShopifyCheckoutUpdateEvent, + pkg.ShopifyCheckoutBlockedEvent, ]; 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 cc4b433d7..e639dd222 100644 --- a/platforms/web/src/index.ts +++ b/platforms/web/src/index.ts @@ -9,6 +9,7 @@ export { ShopifyCheckoutCompleteEvent, ShopifyCheckoutErrorEvent, ShopifyCheckoutCloseEvent, + ShopifyCheckoutBlockedEvent, } from "./checkout-events"; export type { From 085b53b33d4d7cceb133d949aee76b2e2e527e2a Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Wed, 30 Sep 2026 11:04:25 -0400 Subject: [PATCH 2/4] Test that blocked reaches document listeners --- platforms/web/src/checkout-window.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 0751e3691..eb2aab213 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -406,6 +406,24 @@ describe("", () => { }); }); + it("dispatches blocked to document listeners", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + vi.spyOn(window, "open").mockReturnValue(null); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + const documentBlockedSpy = vi.fn(); + document.addEventListener("blocked", documentBlockedSpy); + + try { + checkout.open(); + + expect(documentBlockedSpy).toHaveBeenCalledTimes(1); + } finally { + document.removeEventListener("blocked", documentBlockedSpy); + } + }); + }); + it("dispatches blocked when the popup is blocked and the overlay is hidden", () => { POPUP_TARGETS.forEach((target) => { const checkout = renderCheckout({ target }); From 924b591ca994db22545a07457a7437191921108d Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Tue, 6 Oct 2026 17:47:05 -0400 Subject: [PATCH 3/4] Add a code to the blocked event --- platforms/web/README.md | 2 +- platforms/web/src/checkout-events.ts | 12 +++++++++--- platforms/web/src/checkout-window.test.ts | 3 +++ platforms/web/src/checkout.ts | 2 +- platforms/web/src/index.ts | 2 ++ 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/platforms/web/README.md b/platforms/web/README.md index 7430b8704..a7f4b4443 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -510,7 +510,7 @@ element. Event payloads are available in `event.detail`. | `complete` | `{checkout}` | The buyer completed the order successfully. | | `error` | `{error}` | Checkout could not open or reported a terminal error, exposed as `{code, message}`. An open session closes automatically after this event. | | `close` | _(none)_ | The open session ended through `close()`, overlay dismissal, or detection of a popup the buyer closed. If the browser blocked the window, no `start` precedes it. | -| `blocked` | _(none)_ | The browser blocked the checkout window. Fires on every blocked attempt, whether or not the overlay is shown. | +| `blocked` | `{code}` | The browser blocked the checkout window, with `code` set to `"popup_blocked"`. Fires on every blocked attempt, whether or not the overlay is shown. | `start`, `update`, and `complete` carry a Checkout Kit `Checkout` snapshot in `event.detail.checkout`. It preserves checkout data, including unknown diff --git a/platforms/web/src/checkout-events.ts b/platforms/web/src/checkout-events.ts index 87c3d6d05..da8404aa9 100644 --- a/platforms/web/src/checkout-events.ts +++ b/platforms/web/src/checkout-events.ts @@ -17,6 +17,12 @@ export interface ShopifyCheckoutErrorEventDetail { error: CheckoutError; } +export type CheckoutBlockedCode = "popup_blocked"; + +export interface ShopifyCheckoutBlockedEventDetail { + code: CheckoutBlockedCode; +} + export class ShopifyCheckoutStartEvent extends CustomEvent { declare type: "start"; @@ -49,11 +55,11 @@ export class ShopifyCheckoutCloseEvent extends CustomEvent { } } -export class ShopifyCheckoutBlockedEvent extends CustomEvent { +export class ShopifyCheckoutBlockedEvent extends CustomEvent { declare type: "blocked"; - constructor() { - super("blocked", { bubbles: true }); + constructor(detail: ShopifyCheckoutBlockedEventDetail) { + super("blocked", { detail, bubbles: true }); } } diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index eb2aab213..8529421a2 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -403,6 +403,9 @@ describe("", () => { checkout.open(); expect(blockedEventSpy).toHaveBeenCalledTimes(1); + expect(blockedEventSpy.mock.calls[0]![0].detail).toStrictEqual({ + code: "popup_blocked", + }); }); }); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 48f970ee3..d17b0d49c 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -516,7 +516,7 @@ export class ShopifyCheckout this.#showBlockedOverlay(); this.#dispatchingBlocked = true; /** @ignore - Events are documented by the class @event tags. */ - this.dispatchEvent(new ShopifyCheckoutBlockedEvent()); + this.dispatchEvent(new ShopifyCheckoutBlockedEvent({ code: "popup_blocked" })); this.#dispatchingBlocked = false; return; } diff --git a/platforms/web/src/index.ts b/platforms/web/src/index.ts index e639dd222..1a95c2b3d 100644 --- a/platforms/web/src/index.ts +++ b/platforms/web/src/index.ts @@ -17,6 +17,8 @@ export type { ShopifyCheckoutUpdateEventDetail, ShopifyCheckoutCompleteEventDetail, ShopifyCheckoutErrorEventDetail, + ShopifyCheckoutBlockedEventDetail, + CheckoutBlockedCode, ShopifyCheckoutEventMap, } from "./checkout-events"; From 8efaead5beaa0904137c9318e068515885355118 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Wed, 7 Oct 2026 10:37:40 -0400 Subject: [PATCH 4/4] Shorten the blocked listener warning --- platforms/web/src/checkout-window.test.ts | 2 +- platforms/web/src/checkout.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 8529421a2..4f7c0125b 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -464,7 +464,7 @@ describe("", () => { expect(windowOpenSpy).toHaveBeenCalledTimes(1); expect(blockedEventSpy).toHaveBeenCalledTimes(1); expect(consoleWarnSpy).toHaveBeenCalledWith( - ": open() called from a blocked listener will be ignored; call it from a user action such as a click", + ": open() is ignored in a blocked listener; use a user action", ); }); }); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index d17b0d49c..4fd43e4f5 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -437,9 +437,7 @@ export class ShopifyCheckout */ open(): void { if (this.#dispatchingBlocked) { - this.#logger.warn( - "open() called from a blocked listener will be ignored; call it from a user action such as a click", - ); + this.#logger.warn("open() is ignored in a blocked listener; use a user action"); return; }