From d550169bea939a33e66296e5ddc7efddb027bbba Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Thu, 17 Sep 2026 21:58:42 -0300 Subject: [PATCH 01/13] Fix blocked popup session state --- platforms/web/src/checkout-window.test.ts | 11 +++++++++-- platforms/web/src/checkout.ts | 23 ++++++++++++----------- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 098a4b0c7..62120cb97 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -241,15 +241,23 @@ describe("", () => { }); }); - it("handles popup blocked scenario gracefully", () => { + it("does not open an overlay or session when the popup is blocked", () => { POPUP_TARGETS.forEach((target) => { const telemetrySpy = vi.spyOn(mockTelemetry(), "recordError"); const checkout = renderCheckout({ target }); const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(null); + const dialogShowModalSpy = vi + .spyOn(HTMLDialogElement.prototype, "showModal") + .mockImplementation(() => {}); + const closeEventSpy = vi.fn(); + checkout.addEventListener("ec.close", closeEventSpy); checkout.open(); + checkout.close(); expect(windowOpenSpy).toHaveBeenCalled(); + expect(dialogShowModalSpy).not.toHaveBeenCalled(); + expect(closeEventSpy).not.toHaveBeenCalled(); expect(telemetrySpy).toHaveBeenCalledWith({ category: "navigation", stage: "presentation", @@ -257,7 +265,6 @@ describe("", () => { retryable: false, isRetry: false, }); - // Should not throw error when popup is blocked }); }); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index bf0ddb2cf..855e752e8 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -470,6 +470,17 @@ export class ShopifyCheckout } } + if (!checkoutWindow) { + this.#recorder?.recordError({ + category: "navigation", + stage: "presentation", + code: "blocked", + retryable: false, + isRetry: false, + }); + return; + } + const abortController = new AbortController(); // Opens a dialog element to act as a scrim over the current window while the popup is open. @@ -566,17 +577,7 @@ 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 { From 6fa68ac5d498453a10e02c5b8bb8e5dcf8bc68bc Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Sun, 20 Sep 2026 17:48:26 -0300 Subject: [PATCH 02/13] Warn and document when the checkout window is blocked --- platforms/web/README.md | 6 ++++ platforms/web/src/checkout-window.test.ts | 36 ++++++++++++++++++++++- platforms/web/src/checkout.ts | 4 +++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/platforms/web/README.md b/platforms/web/README.md index 9bdf573ea..c2ef19bca 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -352,6 +352,12 @@ 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), `open()` does nothing: no overlay is +> shown, no session starts, and `ec.close` does not fire. The component logs a +> warning at `log-level="warn"` or more verbose. + ### `appearance` Sets the checkout appearance preference. Defaults to `"storefront"`. diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 62120cb97..8658d65c7 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -244,8 +244,9 @@ describe("", () => { it("does not open an overlay or session when the popup is blocked", () => { POPUP_TARGETS.forEach((target) => { const telemetrySpy = vi.spyOn(mockTelemetry(), "recordError"); - const checkout = renderCheckout({ target }); + const checkout = renderCheckout({ target, "log-level": "warn" }); const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(null); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const dialogShowModalSpy = vi .spyOn(HTMLDialogElement.prototype, "showModal") .mockImplementation(() => {}); @@ -258,6 +259,9 @@ describe("", () => { expect(windowOpenSpy).toHaveBeenCalled(); expect(dialogShowModalSpy).not.toHaveBeenCalled(); expect(closeEventSpy).not.toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledWith( + ": checkout window could not be opened; the browser may have blocked it", + ); expect(telemetrySpy).toHaveBeenCalledWith({ category: "navigation", stage: "presentation", @@ -268,6 +272,36 @@ describe("", () => { }); }); + it("can open successfully after a blocked popup", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + const mockWindow = createMockWindow(); + const windowOpenSpy = vi + .spyOn(window, "open") + .mockReturnValueOnce(null) + .mockReturnValueOnce(mockWindow); + const dialogShowModalSpy = vi + .spyOn(HTMLDialogElement.prototype, "showModal") + .mockImplementation(() => {}); + const closeEventSpy = vi.fn(); + checkout.addEventListener("ec.close", closeEventSpy); + + checkout.open(); + expect(dialogShowModalSpy).not.toHaveBeenCalled(); + + checkout.open(); + expect(windowOpenSpy).toHaveBeenCalledTimes(2); + expect(dialogShowModalSpy).toHaveBeenCalledTimes(1); + + checkout.focus(); + expect(mockWindow.focus).toHaveBeenCalled(); + + checkout.close(); + expect(mockWindow.close).toHaveBeenCalled(); + expect(closeEventSpy).toHaveBeenCalledTimes(1); + }); + }); + it("enforces maximum window size constraints", () => { POPUP_TARGETS.forEach((target) => { const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow()); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 855e752e8..c1b9daaa6 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -470,7 +470,11 @@ export class ShopifyCheckout } } + // The browser refused to open the window (typically a popup blocker, or + // `open()` was called outside a user gesture). There is no window to focus + // and nothing for the buyer to return to, so no overlay or session is created. if (!checkoutWindow) { + this.#logger.warn("checkout window could not be opened; the browser may have blocked it"); this.#recorder?.recordError({ category: "navigation", stage: "presentation", From abdad1af27f0a012af004e45ae88ac3d3f94fcff Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Wed, 23 Sep 2026 22:37:33 -0300 Subject: [PATCH 03/13] Show a retry overlay when the checkout window is blocked --- platforms/web/README.md | 11 +- platforms/web/src/checkout-window.test.ts | 74 +++++--- platforms/web/src/checkout.css | 5 + platforms/web/src/checkout.ts | 215 +++++++++++++++------- 4 files changed, 207 insertions(+), 98 deletions(-) diff --git a/platforms/web/README.md b/platforms/web/README.md index c2ef19bca..18edc8780 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -354,9 +354,10 @@ 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), `open()` does nothing: no overlay is -> shown, no session starts, and `ec.close` does not fire. The component logs a -> warning at `log-level="warn"` or more verbose. +> `open()` called outside a user gesture), the [overlay scrim](#overlay-scrim) +> says so and offers a button to try again. Closing it dispatches `ec.close`. +> If the overlay is hidden, nothing is shown and no events fire. The component +> logs a warning at `log-level="warn"` or more verbose. ### `appearance` @@ -475,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: diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 8658d65c7..a636b7957 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -241,63 +241,79 @@ describe("", () => { }); }); - it("does not open an overlay or session when the popup is blocked", () => { + it("handles popup blocked scenario gracefully", () => { POPUP_TARGETS.forEach((target) => { const telemetrySpy = vi.spyOn(mockTelemetry(), "recordError"); - const checkout = renderCheckout({ target, "log-level": "warn" }); + const checkout = renderCheckout({ target }); const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(null); - const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const dialogShowModalSpy = vi - .spyOn(HTMLDialogElement.prototype, "showModal") - .mockImplementation(() => {}); - const closeEventSpy = vi.fn(); - checkout.addEventListener("ec.close", closeEventSpy); checkout.open(); - checkout.close(); expect(windowOpenSpy).toHaveBeenCalled(); - expect(dialogShowModalSpy).not.toHaveBeenCalled(); - expect(closeEventSpy).not.toHaveBeenCalled(); - expect(consoleWarnSpy).toHaveBeenCalledWith( - ": checkout window could not be opened; the browser may have blocked it", - ); expect(telemetrySpy).toHaveBeenCalledWith({ category: "navigation", stage: "presentation", code: "blocked", - retryable: false, + retryable: true, isRetry: false, }); + // Should not throw error when popup is blocked }); }); - it("can open successfully after a blocked popup", () => { + 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 mockWindow = createMockWindow(); const windowOpenSpy = vi .spyOn(window, "open") .mockReturnValueOnce(null) - .mockReturnValueOnce(mockWindow); - const dialogShowModalSpy = vi - .spyOn(HTMLDialogElement.prototype, "showModal") - .mockImplementation(() => {}); + .mockReturnValueOnce(createMockWindow()); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); const closeEventSpy = vi.fn(); checkout.addEventListener("ec.close", closeEventSpy); checkout.open(); - expect(dialogShowModalSpy).not.toHaveBeenCalled(); + checkout.shadowRoot!.querySelector("#overlay-retry-button")!.click(); - checkout.open(); + const dialog = checkout.shadowRoot!.querySelector("#overlay")!; expect(windowOpenSpy).toHaveBeenCalledTimes(2); - expect(dialogShowModalSpy).toHaveBeenCalledTimes(1); + expect(dialog.dataset.state).toBeUndefined(); + 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("ec.close", closeEventSpy); - checkout.focus(); - expect(mockWindow.focus).toHaveBeenCalled(); + checkout.open(); + checkout + .shadowRoot!.querySelector("#overlay-blocked-close-button")! + .click(); - checkout.close(); - expect(mockWindow.close).toHaveBeenCalled(); expect(closeEventSpy).toHaveBeenCalledTimes(1); }); }); @@ -337,7 +353,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(); 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 c1b9daaa6..79c682eb0 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -153,6 +153,24 @@ const SHADOW_TEMPLATE = createTemplate(html` + +
+
+ Your browser blocked the checkout window.
+ +
+ +
+
@@ -206,6 +224,8 @@ export class ShopifyCheckout // Manages the listeners for the popup window, new tabs, and scrim dialog #currentOpen: { controller: AbortController } | null = null; + // Manages the listeners for 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 +428,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 +464,10 @@ export class ShopifyCheckout return; } + const isRetry = this.#blockedOpen !== null; + // Close any existing sessions before opening a new one - if (this.#currentOpen) { - this.close(); - } + this.close(); this.#checkout = undefined; this.#error = undefined; @@ -470,18 +498,16 @@ export class ShopifyCheckout } } - // The browser refused to open the window (typically a popup blocker, or - // `open()` was called outside a user gesture). There is no window to focus - // and nothing for the buyer to return to, so no overlay or session is created. 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: false, - isRetry: false, + retryable: true, + isRetry, }); + this.#showBlockedOverlay(); return; } @@ -490,65 +516,48 @@ export class ShopifyCheckout // 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()) { + 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", () => { @@ -585,9 +594,85 @@ export class ShopifyCheckout } close(): void { - if (this.#currentOpen) { - this.#currentOpen.controller.abort(); - } + this.#blockedOpen?.controller.abort(); + this.#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 dialog = this.#dialogElement; + if (!dialog || !this.#isDialogVisible()) return; + + const abortController = new AbortController(); + + dialog.dataset.state = "blocked"; + dialog.showModal(); + + let retrying = false; + + this.#dialogRetryButtonElement?.addEventListener( + "click", + () => { + retrying = true; + this.open(); + retrying = false; + }, + { + 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", () => { + delete dialog.dataset.state; + if (dialog.open) dialog.close(); + this.#blockedOpen = null; + if (!retrying) this.dispatchEvent(new ShopifyCheckoutCloseEvent()); + }); + + this.#blockedOpen = { controller: abortController }; } #recordNavigationSuccess(): void { @@ -964,7 +1049,7 @@ export class ShopifyCheckout switch (name) { case "target": { - if (oldValue !== newValue && this.#currentOpen) { + if (oldValue !== newValue && (this.#currentOpen || this.#blockedOpen)) { this.close(); } From ffb6e0015bfd5f5326378cbbe16ffe33702645bb Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Thu, 24 Sep 2026 13:53:09 -0300 Subject: [PATCH 04/13] Treat any open() while the blocked overlay shows as a retry --- platforms/web/src/checkout-window.test.ts | 15 +++++++++++++++ platforms/web/src/checkout.ts | 11 ++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index a636b7957..1e68b73fb 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -302,6 +302,21 @@ describe("", () => { }); }); + 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("ec.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 }); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 79c682eb0..bb37bbd33 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" }], @@ -467,6 +469,7 @@ export class ShopifyCheckout const isRetry = this.#blockedOpen !== null; // Close any existing sessions before opening a new one + this.#blockedOpen?.controller.abort(RETRY_ABORT_REASON); this.close(); this.#checkout = undefined; @@ -629,14 +632,10 @@ export class ShopifyCheckout dialog.dataset.state = "blocked"; dialog.showModal(); - let retrying = false; - this.#dialogRetryButtonElement?.addEventListener( "click", () => { - retrying = true; this.open(); - retrying = false; }, { signal: abortController.signal, @@ -669,7 +668,9 @@ export class ShopifyCheckout delete dialog.dataset.state; if (dialog.open) dialog.close(); this.#blockedOpen = null; - if (!retrying) this.dispatchEvent(new ShopifyCheckoutCloseEvent()); + if (abortController.signal.reason !== RETRY_ABORT_REASON) { + this.dispatchEvent(new ShopifyCheckoutCloseEvent()); + } }); this.#blockedOpen = { controller: abortController }; From 7f31272b6dae065be83162be3351d4e04a59b702 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Fri, 25 Sep 2026 14:42:44 -0300 Subject: [PATCH 05/13] Use the renamed close event in blocked-overlay tests and README --- platforms/web/README.md | 2 +- platforms/web/src/checkout-window.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/platforms/web/README.md b/platforms/web/README.md index 18edc8780..3e4503848 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -355,7 +355,7 @@ 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 `ec.close`. +> says so and offers a button to try again. Closing it dispatches `close`. > If the overlay is hidden, nothing is shown and no events fire. The component > logs a warning at `log-level="warn"` or more verbose. diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 1e68b73fb..e3216f002 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -290,7 +290,7 @@ describe("", () => { .mockReturnValueOnce(createMockWindow()); vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); const closeEventSpy = vi.fn(); - checkout.addEventListener("ec.close", closeEventSpy); + checkout.addEventListener("close", closeEventSpy); checkout.open(); checkout.shadowRoot!.querySelector("#overlay-retry-button")!.click(); @@ -308,7 +308,7 @@ describe("", () => { vi.spyOn(window, "open").mockReturnValue(null); vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); const closeEventSpy = vi.fn(); - checkout.addEventListener("ec.close", closeEventSpy); + checkout.addEventListener("close", closeEventSpy); checkout.open(); checkout.open(); @@ -322,7 +322,7 @@ describe("", () => { const checkout = renderCheckout({ target }); vi.spyOn(window, "open").mockReturnValue(null); const closeEventSpy = vi.fn(); - checkout.addEventListener("ec.close", closeEventSpy); + checkout.addEventListener("close", closeEventSpy); checkout.open(); checkout From 8f58d6aa3c1fc342b6b36d1f034773fa3854e6e8 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Fri, 25 Sep 2026 15:05:48 -0300 Subject: [PATCH 06/13] Keep the blocked-overlay close event out of the custom elements manifest --- platforms/web/src/checkout.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index bb37bbd33..78c4b932e 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -669,6 +669,7 @@ export class ShopifyCheckout if (dialog.open) dialog.close(); this.#blockedOpen = null; if (abortController.signal.reason !== RETRY_ABORT_REASON) { + /** @ignore - Events are documented by the class @event tags. */ this.dispatchEvent(new ShopifyCheckoutCloseEvent()); } }); From 1e038d4918b8edecd53d00b564167a2fb845b271 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Fri, 25 Sep 2026 17:55:30 -0300 Subject: [PATCH 07/13] Document the blocked-window telemetry mapping --- telemetry/contract/metrics.md | 6 ++++++ 1 file changed, 6 insertions(+) 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 From 1e32f7a3ebd7744275a3d733c85faa75cf4f2da4 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Mon, 28 Sep 2026 10:55:02 -0400 Subject: [PATCH 08/13] Keep the blocked message while the overlay fades out --- platforms/web/src/checkout.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 78c4b932e..0c52f5c6c 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -523,6 +523,7 @@ export class ShopifyCheckout const dialogButton = this.#dialogButtonElement; if (dialog && this.#isDialogVisible()) { + delete dialog.dataset.state; dialog.showModal(); dialogCloseButton?.addEventListener( @@ -665,7 +666,6 @@ export class ShopifyCheckout ); abortController.signal.addEventListener("abort", () => { - delete dialog.dataset.state; if (dialog.open) dialog.close(); this.#blockedOpen = null; if (abortController.signal.reason !== RETRY_ABORT_REASON) { From 2b1c971c4593fa20b599c7605bac6130df5c12c8 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Mon, 28 Sep 2026 10:55:02 -0400 Subject: [PATCH 09/13] Keep a session opened from a close listener open --- platforms/web/src/checkout-window.test.ts | 14 ++++++++++++++ platforms/web/src/checkout.ts | 7 +++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index e3216f002..775db5e63 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -658,6 +658,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.ts b/platforms/web/src/checkout.ts index 0c52f5c6c..81fb8568a 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -598,8 +598,11 @@ export class ShopifyCheckout } close(): void { - this.#blockedOpen?.controller.abort(); - 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(); } /** From f74230b5f5afca3367025490655241cfa35be196 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Mon, 28 Sep 2026 11:02:43 -0400 Subject: [PATCH 10/13] Test the blocked overlay's close and retry paths --- platforms/web/src/checkout-window.test.ts | 48 +++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 775db5e63..846416f98 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")!; @@ -302,6 +317,25 @@ describe("", () => { }); }); + 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("does not dispatch close when open() is called while the blocked overlay is showing", () => { POPUP_TARGETS.forEach((target) => { const checkout = renderCheckout({ target }); @@ -634,6 +668,20 @@ 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("closes the checkout scrim dialog", async () => { POPUP_TARGETS.forEach((target) => { const checkout = renderCheckout({ target }); From 2b44d431db5bd4159f939ed6e6baa1c250c08b23 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Mon, 28 Sep 2026 12:13:17 -0400 Subject: [PATCH 11/13] Keep the blocked session when the overlay is hidden --- platforms/web/README.md | 4 +- platforms/web/src/checkout-window.test.ts | 20 +++++++ platforms/web/src/checkout.ts | 73 ++++++++++++----------- 3 files changed, 60 insertions(+), 37 deletions(-) diff --git a/platforms/web/README.md b/platforms/web/README.md index 3e4503848..2a9fffe32 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -356,8 +356,8 @@ Where the checkout is presented. Defaults to `"auto"`. > 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 and no events fire. The component -> logs a warning at `log-level="warn"` or more verbose. +> If the overlay is hidden, nothing is shown. The component logs a warning at +> `log-level="warn"` or more verbose. ### `appearance` diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 846416f98..107016e3a 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -682,6 +682,26 @@ describe("", () => { }); }); + 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 }); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 81fb8568a..c33ac6a19 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -226,7 +226,7 @@ export class ShopifyCheckout // Manages the listeners for the popup window, new tabs, and scrim dialog #currentOpen: { controller: AbortController } | null = null; - // Manages the listeners for the scrim dialog while it shows the blocked-window state + // 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; @@ -628,48 +628,51 @@ export class ShopifyCheckout } #showBlockedOverlay(): void { + const abortController = new AbortController(); const dialog = this.#dialogElement; - if (!dialog || !this.#isDialogVisible()) return; - const abortController = new AbortController(); + if (dialog && this.#isDialogVisible()) { + dialog.dataset.state = "blocked"; + dialog.showModal(); - dialog.dataset.state = "blocked"; - dialog.showModal(); + this.#dialogRetryButtonElement?.addEventListener( + "click", + () => { + this.open(); + }, + { + signal: abortController.signal, + }, + ); - this.#dialogRetryButtonElement?.addEventListener( - "click", - () => { - this.open(); - }, - { - signal: abortController.signal, - }, - ); + this.#dialogBlockedCloseButtonElement?.addEventListener( + "click", + () => { + dialog.close(); + }, + { + 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, + }, + ); - 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", () => { - if (dialog.open) dialog.close(); this.#blockedOpen = null; if (abortController.signal.reason !== RETRY_ABORT_REASON) { /** @ignore - Events are documented by the class @event tags. */ From c4c46fcd3fe05eb9d2345ab4c5d9cb60f7db9ba2 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Mon, 28 Sep 2026 12:13:17 -0400 Subject: [PATCH 12/13] Test the dialog's late close event after a retry --- platforms/web/src/checkout-window.test.ts | 41 +++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 107016e3a..d21908d1e 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -336,6 +336,47 @@ describe("", () => { }); }); + 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 }); From d38fe434cd83f0eacd6ed741fd567907d8a91889 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Mon, 28 Sep 2026 12:13:18 -0400 Subject: [PATCH 13/13] Document close after a blocked window --- platforms/web/README.md | 2 +- platforms/web/src/checkout.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/platforms/web/README.md b/platforms/web/README.md index 2a9fffe32..4b77d9183 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -504,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.ts b/platforms/web/src/checkout.ts index c33ac6a19..9809fce9a 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -194,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