Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions platforms/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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 |
| ---------- | -------------- | ------------- |
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion platforms/web/sample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<body>`. For `popup` / `auto`, the visible UI is mostly the overlay scrim while checkout is open in a separate window or tab.

Expand Down
2 changes: 1 addition & 1 deletion platforms/web/sample/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
9 changes: 9 additions & 0 deletions platforms/web/src/checkout-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ export class ShopifyCheckoutCloseEvent extends CustomEvent<undefined> {
}
}

export class ShopifyCheckoutBlockedEvent extends CustomEvent<undefined> {
declare type: "blocked";

constructor() {
super("blocked", { bubbles: true });
}
}

export class ShopifyCheckoutErrorEvent extends CustomEvent<ShopifyCheckoutErrorEventDetail> {
declare type: "error";

Expand All @@ -64,4 +72,5 @@ export interface ShopifyCheckoutEventMap {
complete: ShopifyCheckoutCompleteEvent;
error: ShopifyCheckoutErrorEvent;
close: ShopifyCheckoutCloseEvent;
blocked: ShopifyCheckoutBlockedEvent;
}
56 changes: 56 additions & 0 deletions platforms/web/src/checkout-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,62 @@ describe("<shopify-checkout>", () => {
});
});

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(
"<shopify-checkout>: 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 });
Expand Down
14 changes: 14 additions & 0 deletions platforms/web/src/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
ShopifyCheckoutCompleteEvent,
ShopifyCheckoutErrorEvent,
ShopifyCheckoutCloseEvent,
ShopifyCheckoutBlockedEvent,
type ShopifyCheckoutEventMap,
} from "./checkout-events";
import stylesText from "./checkout.css?inline";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}

Expand Down
1 change: 1 addition & 0 deletions platforms/web/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
1 change: 1 addition & 0 deletions platforms/web/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export {
ShopifyCheckoutCompleteEvent,
ShopifyCheckoutErrorEvent,
ShopifyCheckoutCloseEvent,
ShopifyCheckoutBlockedEvent,
} from "./checkout-events";

export type {
Expand Down
Loading