From 4b434ba82fcfc6866456fa521122d4d772282f55 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Thu, 17 Sep 2026 21:58:42 -0300 Subject: [PATCH 01/16] 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 c53c1d2a3..9c0f479ba 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -471,6 +471,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. @@ -567,17 +578,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 a75f034e479c3bf35d6f1b9d1c43a71585d3bb88 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Sun, 20 Sep 2026 17:48:26 -0300 Subject: [PATCH 02/16] 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 b70fb9b8f..f343d4a81 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -354,6 +354,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 9c0f479ba..f0689640f 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -471,7 +471,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 25fc96e2b1363518162f78c6a94319bb818f5055 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Wed, 23 Sep 2026 22:37:33 -0300 Subject: [PATCH 03/16] 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 f343d4a81..3a038f775 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -356,9 +356,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` @@ -478,7 +479,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 f0689640f..dc4fa0e36 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -152,6 +152,24 @@ const SHADOW_TEMPLATE = createTemplate(html` + +
+
+ Your browser blocked the checkout window.
+ +
+ +
+
@@ -205,6 +223,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 @@ -409,6 +429,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; } @@ -437,10 +465,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; @@ -471,18 +499,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; } @@ -491,65 +517,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", () => { @@ -586,9 +595,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 { @@ -966,7 +1051,7 @@ export class ShopifyCheckout switch (name) { case "target": { - if (oldValue !== newValue && this.#currentOpen) { + if (oldValue !== newValue && (this.#currentOpen || this.#blockedOpen)) { this.close(); } From 7f5dfe9f3bb63eb7cd23285b37fea798d3cd721f Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Thu, 24 Sep 2026 13:53:09 -0300 Subject: [PATCH 04/16] 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 dc4fa0e36..da257b6d1 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -117,6 +117,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" }], @@ -468,6 +470,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; @@ -630,14 +633,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, @@ -670,7 +669,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 b102a6e6c68bd5d71f8a904874baafe29656697c Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Fri, 25 Sep 2026 14:42:44 -0300 Subject: [PATCH 05/16] 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 3a038f775..1e99cb8ca 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -357,7 +357,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 a25f8f137755fbf802d27bdc8eb673c1b9a81bfe Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Fri, 25 Sep 2026 15:05:48 -0300 Subject: [PATCH 06/16] 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 da257b6d1..ea490fe82 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -670,6 +670,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 2a57f3b579f7b1139ac5a82e7ef766a942f5b4d8 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Fri, 25 Sep 2026 17:55:30 -0300 Subject: [PATCH 07/16] 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 09d7f85380a15d3012e158aa484abbcb1068a197 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Mon, 28 Sep 2026 10:55:02 -0400 Subject: [PATCH 08/16] 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 ea490fe82..e4d08d6ae 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -524,6 +524,7 @@ export class ShopifyCheckout const dialogButton = this.#dialogButtonElement; if (dialog && this.#isDialogVisible()) { + delete dialog.dataset.state; dialog.showModal(); dialogCloseButton?.addEventListener( @@ -666,7 +667,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 1a9364bffa6f009ad969a7496fccf698c2712eeb Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Mon, 28 Sep 2026 10:55:02 -0400 Subject: [PATCH 09/16] 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 e4d08d6ae..e8988f5ec 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -599,8 +599,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 0792fa1af05e2fc0cc89f923fbefb6accaa94897 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Mon, 28 Sep 2026 11:02:43 -0400 Subject: [PATCH 10/16] 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 fceb0ae04179aacc908e9c4bb859cfada71902ff Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Mon, 28 Sep 2026 12:13:17 -0400 Subject: [PATCH 11/16] 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 1e99cb8ca..55ce8e434 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -358,8 +358,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 e8988f5ec..83a01b8fb 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -225,7 +225,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; @@ -629,48 +629,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 6de01a96ee0cbbaffb3040614cbb2973fb9b3809 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Mon, 28 Sep 2026 12:13:17 -0400 Subject: [PATCH 12/16] 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 0001baec6579df660665e76d2d6301b213102fff Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Mon, 28 Sep 2026 12:13:18 -0400 Subject: [PATCH 13/16] 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 55ce8e434..2d028e75f 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -507,7 +507,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 83a01b8fb..57b5d5141 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -193,7 +193,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 From d3fd6a584c0ce748ab1b21069daaa1e260bb7ded Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Fri, 25 Sep 2026 17:01:54 -0300 Subject: [PATCH 14/16] Add a blocked event when the checkout window is blocked --- platforms/web/README.md | 21 +++++---- platforms/web/sample/README.md | 2 +- platforms/web/sample/main.ts | 2 +- platforms/web/src/checkout-events.ts | 9 ++++ platforms/web/src/checkout-window.test.ts | 56 +++++++++++++++++++++++ platforms/web/src/checkout.ts | 14 ++++++ platforms/web/src/index.test.ts | 1 + platforms/web/src/index.ts | 1 + 8 files changed, 95 insertions(+), 11 deletions(-) diff --git a/platforms/web/README.md b/platforms/web/README.md index 2d028e75f..2f1d47433 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -356,10 +356,12 @@ Where the checkout is presented. Defaults to `"auto"`. > [!NOTE] > If the browser refuses to open the window (for example, a popup blocker, or -> `open()` called outside a user gesture), the [overlay scrim](#overlay-scrim) -> says so and offers a button to try again. Closing it dispatches `close`. -> If the overlay is hidden, nothing is shown. The component logs a warning at -> `log-level="warn"` or more verbose. +> `open()` called outside a user gesture), the component dispatches `blocked` +> and the [overlay scrim](#overlay-scrim) says so and offers a button to try +> again. Closing it dispatches `close`. If you hide the overlay, listen for +> `blocked` to show your own message, and call `open()` again from a user +> action such as a click. A call made directly from the listener is ignored. +> The component logs a warning at `log-level="warn"` or more verbose. ### `appearance` @@ -495,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 | | ---------- | -------------- | ------------- | @@ -508,6 +510,7 @@ are available in `event.detail`. | `complete` | `{checkout}` | The buyer completed the order successfully. | | `error` | `{error}` | Checkout reported a terminal error, exposed as `{code, message}`. The component closes automatically after this event. | | `close` | _(none)_ | The open session ended through `close()`, overlay dismissal, or detection of a popup the buyer closed. If the browser blocked the window, no `start` precedes it. | +| `blocked` | _(none)_ | The browser blocked the checkout window. Fires on every blocked attempt, whether or not the overlay is shown. | `start`, `update`, and `complete` carry a Checkout Kit `Checkout` snapshot in `event.detail.checkout`. It preserves checkout data, including unknown diff --git a/platforms/web/sample/README.md b/platforms/web/sample/README.md index ce330db4e..a2e4474d0 100644 --- a/platforms/web/sample/README.md +++ b/platforms/web/sample/README.md @@ -34,7 +34,7 @@ You can also choose **Use existing checkout source** in Settings. In that mode, - **Settings** — persisted storefront domain, flow, target (`popup` | `auto`), appearance (default `storefront` | `app:light` | `app:dark` | `app:automatic` | `storefront`), and log-level (`debug` | `warn` | `error` | `none`) settings. The storefront domain appears first because the cart builder cannot load products without it. - **Center workspace** — build mode shows a storefront-style product grid plus sticky cart banner; manual mode shows a focused checkout URL/cart permalink input. -- **Runtime** — shows component state above the `start`, `update`, `complete`, `error`, and `close` event log. Each entry includes the event detail and a JSON snapshot of component state at fire time. +- **Runtime** — shows component state above the `start`, `update`, `complete`, `error`, `close`, and `blocked` event log. Each entry includes the event detail and a JSON snapshot of component state at fire time. The element is mounted on ``. For `popup` / `auto`, the visible UI is mostly the overlay scrim while checkout is open in a separate window or tab. diff --git a/platforms/web/sample/main.ts b/platforms/web/sample/main.ts index 86f78e258..f3e7f027c 100644 --- a/platforms/web/sample/main.ts +++ b/platforms/web/sample/main.ts @@ -21,7 +21,7 @@ import { } from "./storage"; import "./styles.css"; -const EVENT_TYPES = ["start", "update", "complete", "close", "error"] as const; +const EVENT_TYPES = ["start", "update", "complete", "close", "error", "blocked"] as const; const refs = queryRefs(); diff --git a/platforms/web/src/checkout-events.ts b/platforms/web/src/checkout-events.ts index ec498159f..87c3d6d05 100644 --- a/platforms/web/src/checkout-events.ts +++ b/platforms/web/src/checkout-events.ts @@ -49,6 +49,14 @@ export class ShopifyCheckoutCloseEvent extends CustomEvent { } } +export class ShopifyCheckoutBlockedEvent extends CustomEvent { + declare type: "blocked"; + + constructor() { + super("blocked", { bubbles: true }); + } +} + export class ShopifyCheckoutErrorEvent extends CustomEvent { declare type: "error"; @@ -64,4 +72,5 @@ export interface ShopifyCheckoutEventMap { complete: ShopifyCheckoutCompleteEvent; error: ShopifyCheckoutErrorEvent; close: ShopifyCheckoutCloseEvent; + blocked: ShopifyCheckoutBlockedEvent; } diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index d21908d1e..0751e3691 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -392,6 +392,62 @@ describe("", () => { }); }); + it("dispatches blocked when the popup is blocked", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + vi.spyOn(window, "open").mockReturnValue(null); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + const blockedEventSpy = vi.fn(); + checkout.addEventListener("blocked", blockedEventSpy); + + checkout.open(); + + expect(blockedEventSpy).toHaveBeenCalledTimes(1); + }); + }); + + it("dispatches blocked when the popup is blocked and the overlay is hidden", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target }); + vi.spyOn(window, "open").mockReturnValue(null); + const dialogShowModalSpy = vi + .spyOn(HTMLDialogElement.prototype, "showModal") + .mockImplementation(() => {}); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + getPropertyValue: (prop: string) => { + if (prop === "display") return "none"; + return ""; + }, + } as CSSStyleDeclaration); + const blockedEventSpy = vi.fn(); + checkout.addEventListener("blocked", blockedEventSpy); + + checkout.open(); + + expect(dialogShowModalSpy).not.toHaveBeenCalled(); + expect(blockedEventSpy).toHaveBeenCalledTimes(1); + }); + }); + + it("ignores open() called from a blocked listener", () => { + POPUP_TARGETS.forEach((target) => { + const checkout = renderCheckout({ target, "log-level": "warn" }); + const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(null); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const blockedEventSpy = vi.fn(() => checkout.open()); + checkout.addEventListener("blocked", blockedEventSpy); + + checkout.open(); + + expect(windowOpenSpy).toHaveBeenCalledTimes(1); + expect(blockedEventSpy).toHaveBeenCalledTimes(1); + expect(consoleWarnSpy).toHaveBeenCalledWith( + ": open() called from a blocked listener will be ignored; call it from a user action such as a click", + ); + }); + }); + it("dispatches close when the blocked overlay is dismissed", () => { POPUP_TARGETS.forEach((target) => { const checkout = renderCheckout({ target }); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 57b5d5141..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"; @@ -194,6 +195,7 @@ const SHADOW_TEMPLATE = createTemplate(html` * @event {ShopifyCheckoutCompleteEvent} complete - Checkout completed successfully. * @event {ShopifyCheckoutErrorEvent} error - Checkout reported a terminal error; the session closes after this event. * @event {ShopifyCheckoutCloseEvent} close - The checkout session closed, including after a blocked window. + * @event {ShopifyCheckoutBlockedEvent} blocked - The browser blocked the checkout window. * * @example * ```js @@ -227,6 +229,7 @@ export class ShopifyCheckout #currentOpen: { controller: AbortController } | null = null; // Manages a blocked open, and the scrim dialog while it shows the blocked-window state #blockedOpen: { controller: AbortController } | null = null; + #dispatchingBlocked = false; // Manages the global message event listener for checkout protocol communication #checkoutProtocolController: { controller: AbortController } | null = null; // Shared protocol client that decodes messages and dispatches to handlers @@ -452,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; @@ -512,6 +522,10 @@ export class ShopifyCheckout isRetry, }); this.#showBlockedOverlay(); + this.#dispatchingBlocked = true; + /** @ignore - Events are documented by the class @event tags. */ + this.dispatchEvent(new ShopifyCheckoutBlockedEvent()); + this.#dispatchingBlocked = false; return; } diff --git a/platforms/web/src/index.test.ts b/platforms/web/src/index.test.ts index 12fce84fc..fba4ba40a 100644 --- a/platforms/web/src/index.test.ts +++ b/platforms/web/src/index.test.ts @@ -15,6 +15,7 @@ describe("@shopify/checkout-kit public entry", () => { pkg.ShopifyCheckoutCloseEvent, pkg.ShopifyCheckoutErrorEvent, pkg.ShopifyCheckoutUpdateEvent, + pkg.ShopifyCheckoutBlockedEvent, ]; for (const ctor of eventCtors) { expect(typeof ctor).toBe("function"); diff --git a/platforms/web/src/index.ts b/platforms/web/src/index.ts index cc4b433d7..e639dd222 100644 --- a/platforms/web/src/index.ts +++ b/platforms/web/src/index.ts @@ -9,6 +9,7 @@ export { ShopifyCheckoutCompleteEvent, ShopifyCheckoutErrorEvent, ShopifyCheckoutCloseEvent, + ShopifyCheckoutBlockedEvent, } from "./checkout-events"; export type { From f4f93f47080f3c17e2964dcc7c0e116c748d7746 Mon Sep 17 00:00:00 2001 From: Kyle Schellen Date: Wed, 30 Sep 2026 11:04:25 -0400 Subject: [PATCH 15/16] Test that blocked reaches document listeners --- platforms/web/src/checkout-window.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 0751e3691..eb2aab213 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -406,6 +406,24 @@ describe("", () => { }); }); + 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 }); From 59b2b58c9bc0240283a5ae893248dc7fb1b0372b Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Tue, 6 Oct 2026 12:31:11 +0100 Subject: [PATCH 16/16] Cover blocked checkout retries and custom overlay slots in Chromium --- platforms/web/test/e2e/fixtures/host.html | 2 +- .../e2e/tests/synthetic/presentation.spec.ts | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) 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();