From 046c10a95374a3f5d3efb462c799691d37e3ffd7 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 24 Aug 2026 16:26:38 +0100 Subject: [PATCH 1/5] [Web] Handle browser-managed overlay navigation --- platforms/web/src/checkout-window.test.ts | 29 +++++++++++++++++++ platforms/web/src/checkout.test.ts | 8 ++++-- platforms/web/src/checkout.ts | 34 +++++++++++++++++++++-- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 684254953..790626f8a 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -409,6 +409,35 @@ describe("", () => { expect(event.defaultPrevented).toBe(true); expect(mockWindow.focus).toHaveBeenCalled(); }); + + it("opens and tracks an embedded checkout for a modified overlay link click", () => { + const checkout = renderCheckout({ target: "popup" }); + const initialWindow = createMockWindow(); + const openedWindow = createMockWindow(); + const windowOpenSpy = vi + .spyOn(window, "open") + .mockReturnValueOnce(initialWindow) + .mockReturnValueOnce(openedWindow); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + + checkout.open(); + + const link = checkout.shadowRoot!.querySelector("#overlay-link")!; + const event = new MouseEvent("click", { + bubbles: true, + cancelable: true, + metaKey: true, + }); + link.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(true); + expect(windowOpenSpy).toHaveBeenNthCalledWith(2, expect.any(String), "_blank"); + const openedUrl = new URL(windowOpenSpy.mock.calls[1]![0] as string); + expect(openedUrl.searchParams.get("ec_version")).toBe(EMBED_PROTOCOL_VERSION); + + checkout.focus(); + expect(openedWindow.focus).toHaveBeenCalled(); + }); }); describe("when the popup is dismissed externally", () => { diff --git a/platforms/web/src/checkout.test.ts b/platforms/web/src/checkout.test.ts index 3064ac080..bd470bfa0 100644 --- a/platforms/web/src/checkout.test.ts +++ b/platforms/web/src/checkout.test.ts @@ -352,15 +352,17 @@ describe("", () => { expect(link!.getAttribute("target")).toBe("_blank"); }); - it("points to the parametrised checkout URL (matching what the popup would open)", () => { + it("points to a standalone checkout URL while preserving unrelated URL data", () => { const checkout = renderCheckout({ - src: "https://shop.example.com/checkout", + src: "https://shop.example.com/checkout?existing=1&ec_version=stale&ec_delegate=custom&ec_auth=secret#payment", }); const link = checkout.shadowRoot!.querySelector("#overlay-link"); const href = link!.getAttribute("href") ?? ""; const url = new URL(href); expect(url.origin).toBe("https://shop.example.com"); - expect(url.searchParams.get("ec_version")).toBe(EMBED_PROTOCOL_VERSION); + expect(url.searchParams.get("existing")).toBe("1"); + expect([...url.searchParams.keys()].filter((key) => key.startsWith("ec_"))).toEqual([]); + expect(url.hash).toBe("#payment"); }); }); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 7f53295a0..e2dd0fc7a 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -248,6 +248,24 @@ export class ShopifyCheckout return finalUrl; } + /** + * Returns the validated checkout URL without embedded checkout protocol + * parameters, for browser-managed navigation where no transport is available. + */ + #srcAsStandaloneURL() { + const url = this.#srcAsURL(); + if (!url) return undefined; + + const queryKeys = Array.from(url.searchParams.keys()); + for (const key of queryKeys) { + if (key.startsWith("ec_")) { + url.searchParams.delete(key); + } + } + + return url; + } + /** * Console logging verbosity. Ordered as a threshold — `debug` is the most * verbose and `none` silences everything. Defaults to `'warn'`. @@ -478,6 +496,16 @@ export class ShopifyCheckout "click", (event: MouseEvent) => { event.preventDefault(); + + if (event.metaKey || event.ctrlKey) { + const openedWindow = window.open(src, "_blank"); + if (openedWindow) { + checkoutWindow = openedWindow; + this.#checkoutWindow = openedWindow; + } + return; + } + this.#checkoutWindow?.focus(); }, { @@ -532,13 +560,13 @@ export class ShopifyCheckout } /** - * Sets the overlay link href to the validated, parametrised checkout - * URL (matching what the popup would open) + * Sets the overlay link href to a standalone checkout URL. Browser-managed + * navigation, such as "Open link in new tab", has no embedded transport. */ #updateOverlayLink() { const link = this.#dialogLinkElement; if (!link) return; - const url = this.#srcAsURL(); + const url = this.#srcAsStandaloneURL(); if (url) { link.setAttribute("href", url.href); } else { From 609e7076fa0cd90e97789aab6742a14ce0c24489 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 24 Aug 2026 16:44:43 +0100 Subject: [PATCH 2/5] [Web] Define package version in sample app --- platforms/web/sample/vite.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/platforms/web/sample/vite.config.ts b/platforms/web/sample/vite.config.ts index 7343d6c2e..f37dd5d58 100644 --- a/platforms/web/sample/vite.config.ts +++ b/platforms/web/sample/vite.config.ts @@ -3,9 +3,14 @@ import { fileURLToPath } from "node:url"; import { defineConfig } from "vite"; +import packageJson from "../package.json"; + const here = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ + define: { + CHECKOUT_KIT_PACKAGE_VERSION: JSON.stringify(packageJson.version), + }, // Treat `sample/` as the project root so vite serves `index.html` from here. root: here, resolve: { From 8c6471c8af11640c83845f46466a7294529c9dd3 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 24 Aug 2026 16:53:19 +0100 Subject: [PATCH 3/5] [Web] Preserve overlay focus behavior --- platforms/web/src/checkout-window.test.ts | 29 ----------------------- platforms/web/src/checkout.ts | 10 -------- 2 files changed, 39 deletions(-) diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 790626f8a..684254953 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -409,35 +409,6 @@ describe("", () => { expect(event.defaultPrevented).toBe(true); expect(mockWindow.focus).toHaveBeenCalled(); }); - - it("opens and tracks an embedded checkout for a modified overlay link click", () => { - const checkout = renderCheckout({ target: "popup" }); - const initialWindow = createMockWindow(); - const openedWindow = createMockWindow(); - const windowOpenSpy = vi - .spyOn(window, "open") - .mockReturnValueOnce(initialWindow) - .mockReturnValueOnce(openedWindow); - vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); - - checkout.open(); - - const link = checkout.shadowRoot!.querySelector("#overlay-link")!; - const event = new MouseEvent("click", { - bubbles: true, - cancelable: true, - metaKey: true, - }); - link.dispatchEvent(event); - - expect(event.defaultPrevented).toBe(true); - expect(windowOpenSpy).toHaveBeenNthCalledWith(2, expect.any(String), "_blank"); - const openedUrl = new URL(windowOpenSpy.mock.calls[1]![0] as string); - expect(openedUrl.searchParams.get("ec_version")).toBe(EMBED_PROTOCOL_VERSION); - - checkout.focus(); - expect(openedWindow.focus).toHaveBeenCalled(); - }); }); describe("when the popup is dismissed externally", () => { diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index e2dd0fc7a..1acd0b98b 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -496,16 +496,6 @@ export class ShopifyCheckout "click", (event: MouseEvent) => { event.preventDefault(); - - if (event.metaKey || event.ctrlKey) { - const openedWindow = window.open(src, "_blank"); - if (openedWindow) { - checkoutWindow = openedWindow; - this.#checkoutWindow = openedWindow; - } - return; - } - this.#checkoutWindow?.focus(); }, { From efa895a4f600c5ecf48395583d7a7467c3939e1f Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Mon, 24 Aug 2026 17:14:34 +0100 Subject: [PATCH 4/5] [Web] Use button for overlay focus control --- platforms/web/src/checkout-window.test.ts | 6 +-- platforms/web/src/checkout.css | 11 +++++ platforms/web/src/checkout.test.ts | 49 +++------------------- platforms/web/src/checkout.ts | 51 +++-------------------- 4 files changed, 26 insertions(+), 91 deletions(-) diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 684254953..36142a1fe 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -396,15 +396,15 @@ describe("", () => { expect(dialog).toBeTruthy(); }); - it("focuses the popup when the overlay link is clicked", () => { + it("focuses the popup when the overlay button is clicked", () => { const { checkout, mockWindow } = openWithRealOverlay(); - const link = checkout.shadowRoot!.querySelector("#overlay-link")!; + const button = checkout.shadowRoot!.querySelector("#overlay-link")!; const event = new MouseEvent("click", { bubbles: true, cancelable: true, }); - link.dispatchEvent(event); + button.dispatchEvent(event); expect(event.defaultPrevented).toBe(true); expect(mockWindow.focus).toHaveBeenCalled(); diff --git a/platforms/web/src/checkout.css b/platforms/web/src/checkout.css index 9430d2d3d..336b8b770 100644 --- a/platforms/web/src/checkout.css +++ b/platforms/web/src/checkout.css @@ -94,6 +94,17 @@ max-inline-size: 21em; } +.overlay-focus-button { + appearance: none; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + font: inherit; + color: LinkText; + text-decoration: underline; +} + .overlay-close-button { display: inline-flex; align-items: center; diff --git a/platforms/web/src/checkout.test.ts b/platforms/web/src/checkout.test.ts index bd470bfa0..27e61c2cb 100644 --- a/platforms/web/src/checkout.test.ts +++ b/platforms/web/src/checkout.test.ts @@ -264,22 +264,6 @@ describe("", () => { expect(windowOpenSpy).not.toHaveBeenCalled(); }); - it.each([ - "javascript:alert(1)", - "data:text/html,", - "http://shop.example.com/checkout", - "blob:https://shop.example.com/abc", - "file:///etc/passwd", - "not a url", - ])("leaves overlay link without href when src is %s", (badSrc) => { - const checkout = renderCheckout({ src: badSrc }); - - expect(checkout.shadowRoot!.querySelector("iframe")).toBeNull(); - - const overlayLink = checkout.shadowRoot!.querySelector("#overlay-link"); - expect(overlayLink!.hasAttribute("href")).toBe(false); - }); - it("does not interpret HTML metacharacters in src as markup", () => { const src = 'https://shop.example.com/">'; // The URL constructor accepts this (the `"` becomes part of the @@ -321,15 +305,6 @@ describe("", () => { expect(CK_VERSION).toBe(version); }); - it("includes ck_version on the overlay link", () => { - const checkout = renderCheckout({ - src: "https://shop.example.com/checkout", - }); - const link = checkout.shadowRoot!.querySelector("#overlay-link"); - const url = new URL(link!.getAttribute("href") ?? ""); - expect(url.searchParams.get("ck_version")).toBe(CK_VERSION); - }); - it("preserves caller-provided ck_version rather than appending a duplicate", () => { const checkout = renderCheckout({ src: "https://shop.example.com/checkout?ck_version=old", @@ -344,25 +319,13 @@ describe("", () => { }); }); - describe("overlay link", () => { - it('has rel="noopener noreferrer" and target="_blank"', () => { + describe("overlay focus control", () => { + it("is a button without navigation attributes", () => { const checkout = renderCheckout(); - const link = checkout.shadowRoot!.querySelector("#overlay-link"); - expect(link!.getAttribute("rel")).toBe("noopener noreferrer"); - expect(link!.getAttribute("target")).toBe("_blank"); - }); - - it("points to a standalone checkout URL while preserving unrelated URL data", () => { - const checkout = renderCheckout({ - src: "https://shop.example.com/checkout?existing=1&ec_version=stale&ec_delegate=custom&ec_auth=secret#payment", - }); - const link = checkout.shadowRoot!.querySelector("#overlay-link"); - const href = link!.getAttribute("href") ?? ""; - const url = new URL(href); - expect(url.origin).toBe("https://shop.example.com"); - expect(url.searchParams.get("existing")).toBe("1"); - expect([...url.searchParams.keys()].filter((key) => key.startsWith("ec_"))).toEqual([]); - expect(url.hash).toBe("#payment"); + const button = checkout.shadowRoot!.querySelector("#overlay-link"); + expect(button!.type).toBe("button"); + expect(button!.hasAttribute("href")).toBe(false); + expect(button!.hasAttribute("target")).toBe(false); }); }); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 1acd0b98b..b811711c2 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -128,7 +128,9 @@ const SHADOW_TEMPLATE = createTemplate(html`
Continue your purchase in the
- checkout window +