diff --git a/platforms/web/README.md b/platforms/web/README.md
index e164ae242..a7f4b4443 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` | `{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/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..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,6 +55,14 @@ export class ShopifyCheckoutCloseEvent extends CustomEvent {
}
}
+export class ShopifyCheckoutBlockedEvent extends CustomEvent {
+ declare type: "blocked";
+
+ constructor(detail: ShopifyCheckoutBlockedEventDetail) {
+ super("blocked", { detail, bubbles: true });
+ }
+}
+
export class ShopifyCheckoutErrorEvent extends CustomEvent {
declare type: "error";
@@ -64,4 +78,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..4f7c0125b 100644
--- a/platforms/web/src/checkout-window.test.ts
+++ b/platforms/web/src/checkout-window.test.ts
@@ -392,6 +392,83 @@ 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);
+ expect(blockedEventSpy.mock.calls[0]![0].detail).toStrictEqual({
+ code: "popup_blocked",
+ });
+ });
+ });
+
+ 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 });
+ 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() is ignored in a blocked listener; use a user action",
+ );
+ });
+ });
+
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..4fd43e4f5 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,11 @@ export class ShopifyCheckout
* Reveals checkout in the target.
*/
open(): void {
+ if (this.#dispatchingBlocked) {
+ this.#logger.warn("open() is ignored in a blocked listener; use a user action");
+ return;
+ }
+
const unsupportedCapabilities = getUnsupportedBrowserCapabilities();
if (unsupportedCapabilities.length > 0) {
this.#checkout = undefined;
@@ -504,6 +512,10 @@ export class ShopifyCheckout
isRetry,
});
this.#showBlockedOverlay();
+ this.#dispatchingBlocked = true;
+ /** @ignore - Events are documented by the class @event tags. */
+ this.dispatchEvent(new ShopifyCheckoutBlockedEvent({ code: "popup_blocked" }));
+ 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..1a95c2b3d 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 {
@@ -16,6 +17,8 @@ export type {
ShopifyCheckoutUpdateEventDetail,
ShopifyCheckoutCompleteEventDetail,
ShopifyCheckoutErrorEventDetail,
+ ShopifyCheckoutBlockedEventDetail,
+ CheckoutBlockedCode,
ShopifyCheckoutEventMap,
} from "./checkout-events";