diff --git a/platforms/web/README.md b/platforms/web/README.md index b70fb9b8f..2f1d47433 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -354,6 +354,15 @@ Where the checkout is presented. Defaults to `"auto"`. > the host page away. The component falls back to `"auto"` if you set one, > and logs a warning at `log-level="warn"` or more verbose. +> [!NOTE] +> If the browser refuses to open the window (for example, a popup blocker, or +> `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` Sets the checkout appearance preference. Defaults to `"storefront"`. @@ -472,7 +481,9 @@ shopify-checkout { While a popup is open the component renders a `` scrim over the host page, with a "Continue your purchase in the checkout window" link and a close -button. Hide it by either: +button. If the browser blocks the window, the scrim instead says "Your browser +blocked the checkout window." with an "Open checkout" button that tries again. +Hide it by either: - Setting `display: none` on the element itself, or - Targeting the `overlay` shadow part: @@ -486,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 | | ---------- | -------------- | ------------- | @@ -498,7 +509,8 @@ are available in `event.detail`. | `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 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. | +| `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 098a4b0c7..eb2aab213 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -69,6 +69,21 @@ describe("", () => { expect(closeEventSpy).toHaveBeenCalledTimes(1); }); + it("closes the blocked overlay when the target attribute changes", () => { + const checkout = renderCheckout({ target: "popup" }); + vi.spyOn(window, "open").mockReturnValue(null); + + const closeEventSpy = vi.fn(); + checkout.addEventListener("close", closeEventSpy); + + checkout.open(); + expect(closeEventSpy).not.toHaveBeenCalled(); + + checkout.setAttribute("target", "auto"); + + expect(closeEventSpy).toHaveBeenCalledTimes(1); + }); + it("is a no-op when the target attribute is set to the same value", () => { const checkout = renderCheckout({ target: "popup" }); const wrapper = checkout.shadowRoot!.querySelector(".Shopify-target")!; @@ -254,13 +269,219 @@ describe("", () => { category: "navigation", stage: "presentation", code: "blocked", - retryable: false, + retryable: true, isRetry: false, }); // Should not throw error when popup is blocked }); }); + it("shows the blocked overlay when the popup is blocked", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target, "log-level": "warn" }); + vi.spyOn(window, "open").mockReturnValue(null); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const dialogShowModalSpy = vi + .spyOn(HTMLDialogElement.prototype, "showModal") + .mockImplementation(() => {}); + + checkout.open(); + + const dialog = checkout.shadowRoot!.querySelector("#overlay")!; + expect(dialogShowModalSpy).toHaveBeenCalledTimes(1); + expect(dialog.dataset.state).toBe("blocked"); + expect(consoleWarnSpy).toHaveBeenCalledWith( + ": checkout window could not be opened; the browser may have blocked it", + ); + }); + }); + + it("opens checkout when the blocked overlay's retry button is clicked", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + const windowOpenSpy = vi + .spyOn(window, "open") + .mockReturnValueOnce(null) + .mockReturnValueOnce(createMockWindow()); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + const closeEventSpy = vi.fn(); + checkout.addEventListener("close", closeEventSpy); + + checkout.open(); + checkout.shadowRoot!.querySelector("#overlay-retry-button")!.click(); + + const dialog = checkout.shadowRoot!.querySelector("#overlay")!; + expect(windowOpenSpy).toHaveBeenCalledTimes(2); + expect(dialog.dataset.state).toBeUndefined(); + expect(closeEventSpy).not.toHaveBeenCalled(); + }); + }); + + it("records a retry when the popup is blocked again from the blocked overlay", () => { + POPUP_TARGETS.forEach((target) => { + const telemetrySpy = vi.spyOn(mockTelemetry(), "recordError"); + const checkout = renderCheckout({ target }); + vi.spyOn(window, "open").mockReturnValue(null); + + checkout.open(); + checkout.open(); + + expect(telemetrySpy).toHaveBeenLastCalledWith({ + category: "navigation", + stage: "presentation", + code: "blocked", + retryable: true, + isRetry: true, + }); + }); + }); + + it("ignores the previous dialog's late close event after a retry opens checkout", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + const mockWindow = createMockWindow(); + vi.spyOn(window, "open").mockReturnValueOnce(null).mockReturnValueOnce(mockWindow); + const closeEventSpy = vi.fn(); + checkout.addEventListener("close", closeEventSpy); + + checkout.open(); + checkout.shadowRoot!.querySelector("#overlay-retry-button")!.click(); + + // Browsers queue the dialog's `close` event, so it lands after the dialog is re-shown + const dialog = checkout.shadowRoot!.querySelector("#overlay")!; + expect(dialog.open).toBe(true); + dialog.dispatchEvent(new Event("close")); + + expect(mockWindow.close).not.toHaveBeenCalled(); + expect(closeEventSpy).not.toHaveBeenCalled(); + }); + }); + + it("ignores the previous dialog's late close event when a retry is blocked again", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + vi.spyOn(window, "open").mockReturnValue(null); + const closeEventSpy = vi.fn(); + checkout.addEventListener("close", closeEventSpy); + + checkout.open(); + checkout.shadowRoot!.querySelector("#overlay-retry-button")!.click(); + + // Browsers queue the dialog's `close` event, so it lands after the dialog is re-shown + const dialog = checkout.shadowRoot!.querySelector("#overlay")!; + expect(dialog.open).toBe(true); + dialog.dispatchEvent(new Event("close")); + + expect(dialog.dataset.state).toBe("blocked"); + expect(closeEventSpy).not.toHaveBeenCalled(); + }); + }); + + it("does not dispatch close when open() is called while the blocked overlay is showing", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + vi.spyOn(window, "open").mockReturnValue(null); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + const closeEventSpy = vi.fn(); + checkout.addEventListener("close", closeEventSpy); + + checkout.open(); + checkout.open(); + + expect(closeEventSpy).not.toHaveBeenCalled(); + }); + }); + + 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 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() 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 }); + vi.spyOn(window, "open").mockReturnValue(null); + const closeEventSpy = vi.fn(); + checkout.addEventListener("close", closeEventSpy); + + checkout.open(); + checkout + .shadowRoot!.querySelector("#overlay-blocked-close-button")! + .click(); + + expect(closeEventSpy).toHaveBeenCalledTimes(1); + }); + }); + it("enforces maximum window size constraints", () => { POPUP_TARGETS.forEach((target) => { const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); @@ -296,7 +517,7 @@ describe("", () => { checkout.open(); const dialog = checkout.shadowRoot!.querySelector("dialog") as HTMLDialogElement; - dialog.dispatchEvent(new Event("close")); + dialog.close(); expect(mockPopup.close).toHaveBeenCalled(); expect(closeEventSpy).toHaveBeenCalled(); @@ -562,6 +783,40 @@ describe("", () => { }); }); + it("dispatches close event when the blocked overlay is showing", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + vi.spyOn(window, "open").mockReturnValue(null); + + const closeEventSpy = vi.fn(); + checkout.addEventListener("close", closeEventSpy); + checkout.open(); + checkout.close(); + + expect(closeEventSpy).toHaveBeenCalledTimes(1); + }); + }); + + it("dispatches close event when the popup was blocked and the overlay is hidden", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + vi.spyOn(window, "open").mockReturnValue(null); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + getPropertyValue: (prop: string) => { + if (prop === "display") return "none"; + return ""; + }, + } as CSSStyleDeclaration); + + const closeEventSpy = vi.fn(); + checkout.addEventListener("close", closeEventSpy); + checkout.open(); + checkout.close(); + + expect(closeEventSpy).toHaveBeenCalledTimes(1); + }); + }); + it("closes the checkout scrim dialog", async () => { POPUP_TARGETS.forEach((target) => { const checkout = renderCheckout({ target }); @@ -586,6 +841,20 @@ describe("", () => { expect(dialogCloseSpy).toHaveBeenCalled(); }); }); + + it("does not close a session opened from a close listener", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + const mockWindow = createMockWindow(); + vi.spyOn(window, "open").mockReturnValueOnce(null).mockReturnValue(mockWindow); + + checkout.addEventListener("close", () => checkout.open(), { once: true }); + checkout.open(); + checkout.close(); + + expect(mockWindow.close).not.toHaveBeenCalled(); + }); + }); }); }); }); diff --git a/platforms/web/src/checkout.css b/platforms/web/src/checkout.css index 24bc4475f..f2d915fa5 100644 --- a/platforms/web/src/checkout.css +++ b/platforms/web/src/checkout.css @@ -81,6 +81,11 @@ } } +.overlay[data-state="blocked"] slot[name="overlay"], +.overlay:not([data-state="blocked"]) slot[name="overlay-blocked"] { + display: none; +} + .overlay-content-wrapper { display: grid; grid-template-rows: 1fr 20%; diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index c53c1d2a3..91aa5cf8e 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"; @@ -117,6 +118,8 @@ function originMatchesPattern(pattern: string, origin: URL): boolean { const WINDOW_OPEN_INVALID_URL_WARNING = "ec.window.open_request received without a valid url"; +const RETRY_ABORT_REASON = "retry"; + const EMBED_DELEGATIONS = [EmbeddedCheckoutProtocol.Delegations.windowOpen] as const; const CHECKOUT_APPEARANCES = new Map([ ["app:light", { colorScheme: "light", branding: "app" }], @@ -152,6 +155,24 @@ const SHADOW_TEMPLATE = createTemplate(html` + + + + Your browser blocked the checkout window. + + Open checkout + + + + Close + + + + + + @@ -173,7 +194,8 @@ const SHADOW_TEMPLATE = createTemplate(html` * @event {ShopifyCheckoutUpdateEvent} update - The checkout snapshot changed. * @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. + * @event {ShopifyCheckoutCloseEvent} close - The checkout session closed, including after a blocked window. + * @event {ShopifyCheckoutBlockedEvent} blocked - The browser blocked the checkout window. * * @example * ```js @@ -205,6 +227,9 @@ export class ShopifyCheckout // Manages the listeners for the popup window, new tabs, and scrim dialog #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 @@ -409,6 +434,14 @@ export class ShopifyCheckout return this.shadowRoot?.querySelector("#overlay-link") ?? undefined; } + get #dialogRetryButtonElement(): HTMLButtonElement | undefined { + return this.shadowRoot?.querySelector("#overlay-retry-button") ?? undefined; + } + + get #dialogBlockedCloseButtonElement(): HTMLButtonElement | undefined { + return this.shadowRoot?.querySelector("#overlay-blocked-close-button") ?? undefined; + } + get #targetElement(): HTMLDivElement | undefined { return this.shadowRoot?.querySelector(".Shopify-target") ?? undefined; } @@ -422,6 +455,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; @@ -437,10 +477,11 @@ export class ShopifyCheckout return; } + const isRetry = this.#blockedOpen !== null; + // Close any existing sessions before opening a new one - if (this.#currentOpen) { - this.close(); - } + this.#blockedOpen?.controller.abort(RETRY_ABORT_REASON); + this.close(); this.#checkout = undefined; this.#error = undefined; @@ -471,70 +512,71 @@ export class ShopifyCheckout } } + if (!checkoutWindow) { + this.#logger.warn("checkout window could not be opened; the browser may have blocked it"); + this.#recorder?.recordError({ + category: "navigation", + stage: "presentation", + code: "blocked", + retryable: true, + isRetry, + }); + this.#showBlockedOverlay(); + this.#dispatchingBlocked = true; + /** @ignore - Events are documented by the class @event tags. */ + this.dispatchEvent(new ShopifyCheckoutBlockedEvent()); + this.#dispatchingBlocked = false; + return; + } + const abortController = new AbortController(); // Opens a dialog element to act as a scrim over the current window while the popup is open. // The dialog can be closed by the user, or will close itself when the popup is closed. const dialog = this.#dialogElement; - const dialogBackground = this.#dialogBackgroundElement; const dialogCloseButton = this.#dialogCloseButtonElement; const dialogButton = this.#dialogButtonElement; - if (dialog && dialogBackground) { - // By default we show the scrim. - // If a consumer wants to hide it, they can either: - // 1. Set `display: none` on the `` element itself - // 2. Set `display: none` on the overlay using CSS parts, e.g., - // ``` - // shopify-checkout::part(overlay) { - // display: none; - // } - // ``` - // It's important not to call `dialog.showModal()` if the dialog is not visible because it traps focus and - // hides the rest of the page from the accessibility tree. - const isElementHidden = window.getComputedStyle(this).getPropertyValue("display") === "none"; - const isOverlayHidden = - window.getComputedStyle(dialogBackground).getPropertyValue("display") === "none"; - const showDialog = !isElementHidden && !isOverlayHidden; - - if (showDialog) { - dialog.showModal(); - - dialogCloseButton?.addEventListener( - "click", - () => { - dialog.close(); - }, - { - signal: abortController.signal, - }, - ); + if (dialog && this.#isDialogVisible()) { + delete dialog.dataset.state; + dialog.showModal(); - dialog.addEventListener( - "close", - () => { - abortController.abort(); - }, - { - signal: abortController.signal, - }, - ); + dialogCloseButton?.addEventListener( + "click", + () => { + dialog.close(); + }, + { + signal: abortController.signal, + }, + ); - dialogButton?.addEventListener( - "click", - (event: MouseEvent) => { - event.preventDefault(); - this.#checkoutWindow?.focus(); - }, - { - signal: abortController.signal, - }, - ); + dialog.addEventListener( + "close", + () => { + // `close` fires asynchronously; ignore it if the dialog has since been re-shown + if (dialog.open) return; + abortController.abort(); + }, + { + signal: abortController.signal, + }, + ); - abortController.signal.addEventListener("abort", () => { - dialog.close(); - }); - } + dialogButton?.addEventListener( + "click", + (event: MouseEvent) => { + event.preventDefault(); + this.#checkoutWindow?.focus(); + }, + { + signal: abortController.signal, + }, + ); + + abortController.signal.addEventListener("abort", () => { + dialog.close(); + }); } abortController.signal.addEventListener("abort", () => { @@ -567,23 +609,93 @@ export class ShopifyCheckout this.#currentOpen = { controller: abortController }; this.#checkoutWindow = checkoutWindow; - this.#navigationStartedAt = checkoutWindow && this.telemetry ? navigationStartedAt : undefined; - - if (!checkoutWindow) { - this.#recorder?.recordError({ - category: "navigation", - stage: "presentation", - code: "blocked", - retryable: false, - isRetry: false, - }); - } + this.#navigationStartedAt = this.telemetry ? navigationStartedAt : undefined; } close(): void { - if (this.#currentOpen) { - this.#currentOpen.controller.abort(); + // Read both first: a `close` listener may open a new session while these abort + const blockedOpen = this.#blockedOpen; + const currentOpen = this.#currentOpen; + blockedOpen?.controller.abort(); + currentOpen?.controller.abort(); + } + + /** + * By default we show the scrim. If a consumer wants to hide it, they can either: + * 1. Set `display: none` on the `` element itself + * 2. Set `display: none` on the overlay using CSS parts, e.g., + * ``` + * shopify-checkout::part(overlay) { + * display: none; + * } + * ``` + * It's important not to call `dialog.showModal()` if the dialog is not visible because it traps + * focus and hides the rest of the page from the accessibility tree. + */ + #isDialogVisible(): boolean { + const dialogBackground = this.#dialogBackgroundElement; + if (!dialogBackground) return false; + + const isElementHidden = window.getComputedStyle(this).getPropertyValue("display") === "none"; + const isOverlayHidden = + window.getComputedStyle(dialogBackground).getPropertyValue("display") === "none"; + return !isElementHidden && !isOverlayHidden; + } + + #showBlockedOverlay(): void { + const abortController = new AbortController(); + const dialog = this.#dialogElement; + + if (dialog && this.#isDialogVisible()) { + dialog.dataset.state = "blocked"; + dialog.showModal(); + + this.#dialogRetryButtonElement?.addEventListener( + "click", + () => { + this.open(); + }, + { + signal: abortController.signal, + }, + ); + + this.#dialogBlockedCloseButtonElement?.addEventListener( + "click", + () => { + dialog.close(); + }, + { + signal: abortController.signal, + }, + ); + + dialog.addEventListener( + "close", + () => { + // `close` fires asynchronously; ignore it if the dialog has since been re-shown + if (dialog.open) return; + abortController.abort(); + }, + { + signal: abortController.signal, + }, + ); + + abortController.signal.addEventListener("abort", () => { + if (dialog.open) dialog.close(); + }); } + + abortController.signal.addEventListener("abort", () => { + this.#blockedOpen = null; + if (abortController.signal.reason !== RETRY_ABORT_REASON) { + /** @ignore - Events are documented by the class @event tags. */ + this.dispatchEvent(new ShopifyCheckoutCloseEvent()); + } + }); + + this.#blockedOpen = { controller: abortController }; } #recordNavigationSuccess(): void { @@ -961,7 +1073,7 @@ export class ShopifyCheckout switch (name) { case "target": { - if (oldValue !== newValue && this.#currentOpen) { + if (oldValue !== newValue && (this.#currentOpen || this.#blockedOpen)) { this.close(); } 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 { diff --git a/platforms/web/test/e2e/fixtures/host.html b/platforms/web/test/e2e/fixtures/host.html index edd98ef67..47e83f9ab 100644 --- a/platforms/web/test/e2e/fixtures/host.html +++ b/platforms/web/test/e2e/fixtures/host.html @@ -12,7 +12,7 @@ const checkout = document.getElementById("checkout"); window.checkoutEvents = []; - for (const type of ["start", "update", "complete", "error", "close"]) { + for (const type of ["start", "update", "complete", "error", "close", "blocked"]) { checkout.addEventListener(type, (event) => { window.checkoutEvents.push({ type, detail: event.detail ?? null }); }); diff --git a/platforms/web/test/e2e/tests/synthetic/presentation.spec.ts b/platforms/web/test/e2e/tests/synthetic/presentation.spec.ts index 04463ad67..c5edf3479 100644 --- a/platforms/web/test/e2e/tests/synthetic/presentation.spec.ts +++ b/platforms/web/test/e2e/tests/synthetic/presentation.spec.ts @@ -1,3 +1,4 @@ +import type { ShopifyCheckout } from "@shopify/checkout-kit"; import packageJson from "@shopify/checkout-kit/package.json" with { type: "json" }; import { checkoutOrigin, checkoutSrc, expect, test } from "../../support"; @@ -72,6 +73,83 @@ test.describe("open()", () => { }); }); +test.describe("blocked overlay", () => { + test("switches from repeated blocking to an open popup without stale close listeners", async ({ + host, + page, + }) => { + await page.addInitScript(() => { + const open = window.open.bind(window); + let attempts = 0; + window.open = (...args) => (attempts++ < 2 ? null : open(...args)); + }); + await host.goto(); + await host.configure({ src: checkoutSrc() }); + await host.clickBuy(); + + const retry = host.component.getByRole("button", { name: "Open checkout", exact: true }); + await expect(host.overlay).toHaveAttribute("data-state", "blocked"); + await expect(retry).toBeVisible(); + await expect(host.overlayCloseButton).toBeVisible(); + await expect(host.overlayFocusButton).not.toBeVisible(); + + await retry.click(); + await expect.poll(() => host.eventTypes()).toEqual(["blocked", "blocked"]); + await expect(host.overlay).toHaveAttribute("open", ""); + + const [popup] = await Promise.all([page.waitForEvent("popup"), retry.click()]); + await expect(host.overlayFocusButton).toBeVisible(); + await expect(retry).not.toBeVisible(); + await expect.poll(() => host.eventTypes()).toEqual(["blocked", "blocked"]); + + await host.overlayCloseButton.click(); + await expect.poll(() => popup.isClosed()).toBe(true); + await expect(host.overlay).not.toBeVisible(); + await expect.poll(() => host.eventTypes()).toEqual(["blocked", "blocked", "close"]); + }); + + test("preserves custom content in both slots across a retry", async ({ host, page }) => { + await page.addInitScript(() => { + const open = window.open.bind(window); + let blocked = true; + window.open = (...args) => { + if (!blocked) return open(...args); + blocked = false; + return null; + }; + }); + await host.goto(); + await host.configure({ src: checkoutSrc() }); + await host.component.evaluate((element) => { + const normal = document.createElement("p"); + normal.slot = "overlay"; + normal.textContent = "Custom checkout open"; + const retry = document.createElement("button"); + retry.slot = "overlay-blocked"; + retry.textContent = "Try again"; + retry.addEventListener("click", () => (element as ShopifyCheckout).open()); + element.append(normal, retry); + }); + await host.clickBuy(); + + const retry = host.component.getByRole("button", { name: "Try again", exact: true }); + const normal = host.component.getByText("Custom checkout open", { exact: true }); + await expect(retry).toBeVisible(); + await expect(normal).not.toBeVisible(); + await expect(host.overlayCloseButton).not.toBeVisible(); + + const [popup] = await Promise.all([page.waitForEvent("popup"), retry.click()]); + await expect(normal).toBeVisible(); + await expect(retry).not.toBeVisible(); + await expect(host.overlayCloseButton).not.toBeVisible(); + await expect.poll(() => host.eventTypes()).toEqual(["blocked"]); + + await host.close(); + await expect.poll(() => popup.isClosed()).toBe(true); + await expect.poll(() => host.eventTypes()).toEqual(["blocked", "close"]); + }); +}); + test.describe("closing checkout", () => { test("close() dismisses the popup and dispatches close", async ({ host }) => { const popup = await host.startCheckout(); diff --git a/telemetry/contract/metrics.md b/telemetry/contract/metrics.md index de4418183..5f9b0a208 100644 --- a/telemetry/contract/metrics.md +++ b/telemetry/contract/metrics.md @@ -91,6 +91,12 @@ A terminal `ec.error` protocol message is recorded as `category=protocol`, payload additionally records `checkout_kit_protocol_decode_error` with `method=ec.error`. +A checkout window the browser blocks on web is recorded as +`category=navigation`, `stage=presentation`, `code=blocked`, +`retryable=true`, and `is_retry=false`. A block that happens while the blocked +overlay is already showing, such as a retry from its Open checkout button, is +recorded with `is_retry=true`. + ## Prohibited data - Checkout, cart, order, shop, customer, or payment identifiers