From b83b70d6a3120d9478992f86174220df5ae8ed6b Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Fri, 25 Sep 2026 17:01:54 -0300 Subject: [PATCH] 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 4b77d9183..7eab0659b 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -354,10 +354,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` @@ -492,11 +494,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 | | ---------- | -------------- | ------------- | @@ -505,6 +507,7 @@ are available in `event.detail`. | `complete` | `{checkout}` | The buyer completed the order successfully. | | `error` | `{error}` | Checkout reported a terminal error, exposed as `{code, message}`. The component closes automatically after this event. | | `close` | _(none)_ | The open session ended through `close()`, overlay dismissal, or detection of a popup the buyer closed. 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 9809fce9a..f4dcb73ad 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -17,6 +17,7 @@ import { ShopifyCheckoutCompleteEvent, ShopifyCheckoutErrorEvent, ShopifyCheckoutCloseEvent, + ShopifyCheckoutBlockedEvent, type ShopifyCheckoutEventMap, } from "./checkout-events"; import stylesText from "./checkout.css?inline"; @@ -195,6 +196,7 @@ const SHADOW_TEMPLATE = createTemplate(html` * @event {ShopifyCheckoutCompleteEvent} complete - Checkout completed successfully. * @event {ShopifyCheckoutErrorEvent} error - Checkout reported a terminal error; the session closes after this event. * @event {ShopifyCheckoutCloseEvent} close - The checkout session closed, including after a blocked window. + * @event {ShopifyCheckoutBlockedEvent} blocked - The browser blocked the checkout window. * * @example * ```js @@ -228,6 +230,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 @@ -451,6 +454,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 { target } = this; const src = this.#srcAsURL({ warnInvalidAppearance: true })?.href; @@ -511,6 +521,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 {