diff --git a/platforms/web/README.md b/platforms/web/README.md index 9bdf573ea..4b77d9183 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -352,6 +352,13 @@ 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 [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. + ### `appearance` Sets the checkout appearance preference. Defaults to `"storefront"`. @@ -469,7 +476,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: @@ -495,7 +504,7 @@ 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. | `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-window.test.ts b/platforms/web/src/checkout-window.test.ts index 098a4b0c7..d21908d1e 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,145 @@ 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 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 +443,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 +709,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 +767,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 bf0ddb2cf..9809fce9a 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -118,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" }], @@ -153,6 +155,24 @@ const SHADOW_TEMPLATE = createTemplate(html` + + + + Your browser blocked the checkout window. + + Open checkout + + + + Close + + + + + + @@ -174,7 +194,7 @@ 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. * * @example * ```js @@ -206,6 +226,8 @@ 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; // 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 @@ -408,6 +430,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; } @@ -436,10 +466,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; @@ -470,70 +501,67 @@ 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(); + 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", () => { @@ -566,23 +594,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 { @@ -959,7 +1057,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/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