From 9aea8f7cc1bfe17e343962013a89cbbe41097101 Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Mon, 28 Sep 2026 16:59:36 +0200 Subject: [PATCH 1/2] Extract shared Web checkout presentation --- platforms/web/src/checkout-protocol.test.ts | 97 +++- platforms/web/src/checkout-window.test.ts | 123 +++++ platforms/web/src/checkout.ts | 517 +++--------------- .../web/src/internal/checkout-element.ts | 390 +++++++++++++ 4 files changed, 685 insertions(+), 442 deletions(-) create mode 100644 platforms/web/src/internal/checkout-element.ts diff --git a/platforms/web/src/checkout-protocol.test.ts b/platforms/web/src/checkout-protocol.test.ts index 0870114a1..bda72ac8e 100644 --- a/platforms/web/src/checkout-protocol.test.ts +++ b/platforms/web/src/checkout-protocol.test.ts @@ -70,6 +70,76 @@ describe("", () => { expect(mockCheckoutWindow.postMessage).not.toHaveBeenCalled(); }); + + it("does not reply to a sender after its presentation is replaced", async () => { + const { checkout, mockCheckoutWindow: firstWindow } = openPopupCheckout(); + const secondWindow = createMockWindow(); + + simulateProtocolMessageEvent( + checkout, + "ec.ready", + { delegate: [] }, + { + id: "stale-ready", + source: firstWindow, + }, + ); + vi.mocked(window.open).mockReturnValue(secondWindow); + checkout.open(); + await flushProtocolDispatch(); + + expect(firstWindow.postMessage).not.toHaveBeenCalled(); + expect(secondWindow.postMessage).not.toHaveBeenCalled(); + + simulateProtocolMessageEvent( + checkout, + "ec.ready", + { delegate: [] }, + { + id: "current-ready", + source: secondWindow, + }, + ); + await flushProtocolDispatch(); + + expect(secondWindow.postMessage).toHaveBeenCalledWith( + { + jsonrpc: "2.0", + id: "current-ready", + result: { ucp: { status: "success", version: EMBED_PROTOCOL_VERSION } }, + }, + new URL(checkout.src).origin, + ); + }); + + it("keeps sender and response routing independent across elements", async () => { + const first = renderCheckout({ target: "popup", src: "https://first.example/checkout" }); + const second = renderCheckout({ target: "popup", src: "https://second.example/checkout" }); + const firstWindow = createMockWindow(); + const secondWindow = createMockWindow(); + vi.spyOn(window, "open").mockReturnValueOnce(firstWindow).mockReturnValueOnce(secondWindow); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); + first.open(); + second.open(); + + simulateProtocolMessageEvent( + first, + "ec.ready", + { delegate: [] }, + { + id: "first-ready", + source: firstWindow, + }, + ); + await flushProtocolDispatch(); + + expect(firstWindow.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ id: "first-ready" }), + "https://first.example", + ); + expect(secondWindow.postMessage).not.toHaveBeenCalled(); + }); }); describe("unsupported protocol methods", () => { @@ -909,11 +979,9 @@ describe("", () => { const targetOrigin = new URL(checkout.src).origin; // The shared client can't decode a request without a valid `url`, so - // the handler never runs. The host preserves the diagnostic warning, - // logging the raw message data that failed to decode. + // the handler never runs. The host preserves a safe diagnostic warning. expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining("ec.window.open_request received without a valid url"), - expect.objectContaining({ method: "ec.window.open_request" }), ); expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( { @@ -954,8 +1022,8 @@ describe("", () => { expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining("ec.window.open_request received without a valid url"), - expect.objectContaining({ url: "not a real url" }), ); + expect(consoleWarnSpy.mock.calls.flat().join(" ")).not.toContain("not a real url"); expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( expect.objectContaining({ jsonrpc: "2.0", @@ -984,8 +1052,8 @@ describe("", () => { expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining("ec.window.open_request received without a valid url"), - expect.objectContaining({ url: "http://example.com/insecure" }), ); + expect(consoleWarnSpy.mock.calls.flat().join(" ")).not.toContain("example.com/insecure"); expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( expect.objectContaining({ jsonrpc: "2.0", @@ -1020,7 +1088,6 @@ describe("", () => { expect(consoleWarnSpy).not.toHaveBeenCalledWith( expect.stringContaining("ec.window.open_request received without a valid url"), - expect.anything(), ); expect(mockCheckoutWindow.postMessage).toHaveBeenCalledWith( expect.objectContaining({ @@ -1279,6 +1346,24 @@ describe("", () => { expect(checkout.checkout).toBeUndefined(); }); + it("does not trust a null sender when the popup was blocked", async () => { + const checkout = renderCheckout({ target: "popup" }); + vi.spyOn(window, "open").mockReturnValue(null); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); + checkout.open(); + const onStart = vi.fn(); + checkout.addEventListener("start", onStart); + + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: null, + }); + await flushProtocolDispatch(); + + expect(onStart).not.toHaveBeenCalled(); + expect(checkout.checkout).toBeUndefined(); + }); + it("drops protocol messages when src is unset even if the event origin is HTTPS", async () => { const checkout = document.createElement("shopify-checkout"); document.body.appendChild(checkout); diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 098a4b0c7..0fdf3bdfd 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -80,6 +80,32 @@ describe("", () => { }); }); + describe("src", () => { + it("closes the active presentation when the URL changes", () => { + const checkout = renderCheckout({ target: "popup" }); + const firstWindow = createMockWindow(); + const secondWindow = createMockWindow(); + const windowOpenSpy = vi + .spyOn(window, "open") + .mockReturnValueOnce(firstWindow) + .mockReturnValueOnce(secondWindow); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); + const onClose = vi.fn(); + checkout.addEventListener("close", onClose); + + checkout.open(); + checkout.src = "https://new.example/checkout"; + + expect(firstWindow.close).toHaveBeenCalledOnce(); + expect(onClose).toHaveBeenCalledOnce(); + + checkout.open(); + expect(new URL(windowOpenSpy.mock.calls[1]![0]!).origin).toBe("https://new.example"); + expect(secondWindow.close).not.toHaveBeenCalled(); + }); + }); + describe("methods", () => { describe("open", () => { describe("when target is not specified", () => { @@ -296,6 +322,7 @@ describe("", () => { checkout.open(); const dialog = checkout.shadowRoot!.querySelector("dialog") as HTMLDialogElement; + dialog.removeAttribute("open"); dialog.dispatchEvent(new Event("close")); expect(mockPopup.close).toHaveBeenCalled(); @@ -389,6 +416,54 @@ describe("", () => { expect(closeEventSpy).toHaveBeenCalledTimes(1); expect(openSpy).toHaveBeenCalledTimes(2); }); + + it("ignores a queued dialog close event after opening the next session", () => { + vi.useFakeTimers(); + try { + const checkout = renderCheckout({ target: "popup" }); + const firstWindow = createMockWindow(); + const secondWindow = createMockWindow(); + vi.spyOn(window, "open") + .mockReturnValueOnce(firstWindow) + .mockReturnValueOnce(secondWindow); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation( + function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }, + ); + vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation( + function (this: HTMLDialogElement) { + if (!this.open) return; + this.removeAttribute("open"); + setTimeout(() => this.dispatchEvent(new Event("close")), 0); + }, + ); + + const closeEventSpy = vi.fn(); + checkout.addEventListener("close", closeEventSpy); + const dialog = checkout.shadowRoot!.querySelector("#overlay")!; + + checkout.open(); + checkout.open(); + + expect(dialog.open).toBe(true); + expect(firstWindow.close).toHaveBeenCalledOnce(); + expect(secondWindow.close).not.toHaveBeenCalled(); + + // Native dialog.close() queues the event after a new showModal() can run. + vi.advanceTimersByTime(0); + expect(dialog.open).toBe(true); + expect(secondWindow.close).not.toHaveBeenCalled(); + expect(closeEventSpy).toHaveBeenCalledOnce(); + + dialog.close(); + vi.advanceTimersByTime(0); + expect(secondWindow.close).toHaveBeenCalledOnce(); + expect(closeEventSpy).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); }); describe("overlay scrim", () => { @@ -419,6 +494,30 @@ describe("", () => { expect(dialog).toBeTruthy(); }); + it("keeps the overlay slot usable for custom focus and close controls", () => { + const checkout = renderCheckout({ target: "popup" }); + const customOverlay = document.createElement("div"); + customOverlay.slot = "overlay"; + const focusButton = document.createElement("button"); + const closeButton = document.createElement("button"); + focusButton.addEventListener("click", () => checkout.focus()); + closeButton.addEventListener("click", () => checkout.close()); + customOverlay.append(focusButton, closeButton); + checkout.append(customOverlay); + + const mockWindow = createMockWindow(); + vi.spyOn(window, "open").mockReturnValue(mockWindow); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); + checkout.open(); + + expect(checkout.shadowRoot!.querySelector('slot[name="overlay"]')).not.toBeNull(); + focusButton.click(); + closeButton.click(); + expect(mockWindow.focus).toHaveBeenCalledOnce(); + expect(mockWindow.close).toHaveBeenCalledOnce(); + }); + it("focuses the popup when the overlay button is clicked", () => { const { checkout, mockWindow } = openWithRealOverlay(); const button = checkout.shadowRoot!.querySelector("#overlay-link")!; @@ -543,6 +642,30 @@ describe("", () => { }); describe("close", () => { + it("dispatches once per presentation across repeated close and disconnect", () => { + const checkout = renderCheckout({ target: "popup" }); + const firstWindow = createMockWindow(); + const secondWindow = createMockWindow(); + vi.spyOn(window, "open").mockReturnValueOnce(firstWindow).mockReturnValueOnce(secondWindow); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); + const onClose = vi.fn(); + checkout.addEventListener("close", onClose); + + checkout.open(); + checkout.close(); + checkout.close(); + checkout.remove(); + expect(onClose).toHaveBeenCalledOnce(); + expect(firstWindow.close).toHaveBeenCalledOnce(); + + document.body.append(checkout); + checkout.open(); + checkout.remove(); + expect(onClose).toHaveBeenCalledTimes(2); + expect(secondWindow.close).toHaveBeenCalledOnce(); + }); + describe('when target="popup", "auto", or undefined', () => { it("dispatches close event when popup is closed", () => { POPUP_TARGETS.forEach((target) => { diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index bf0ddb2cf..0064c6fc2 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -1,11 +1,7 @@ import { EmbeddedCheckoutProtocol, decodeProtocolMessage, - windowOpenSuccess, - windowOpenRejected, INVALID_PARAMS_CODE, - type WindowOpenRequest, - type WindowOpenResult, type Checkout as ProtocolCheckout, } from "@shopify/checkout-kit-protocol"; @@ -19,10 +15,25 @@ import { ShopifyCheckoutCloseEvent, type ShopifyCheckoutEventMap, } from "./checkout-events"; -import stylesText from "./checkout.css?inline"; + +import { + applyCheckoutTargetClass, + attachCheckoutShadow, + checkoutAllowedOrigins, + checkoutSourceURL, + handleWindowOpenRequest, + isCheckoutMessageFromPresentation, + openCheckoutPresentation, + rejectCheckoutMessage, + removeCheckoutTargetClass, + setCheckoutAttribute, + validateCheckoutMessageOrigin, + WINDOW_OPEN_INVALID_URL_WARNING, + type CheckoutPresentation, +} from "./internal/checkout-element"; + import { Logger, coerceLogLevel } from "./logger"; import { createTelemetry, telemetryProtocolMethod, type CheckoutKitTelemetry } from "./telemetry"; -import { createTemplate, html, safe } from "./utils"; import { CK_VERSION } from "./version"; import type { CheckoutAttributes, @@ -37,87 +48,13 @@ import type { MessageRejectedDetail, } from "./checkout.types"; -export const DEFAULT_POPUP_WIDTH = 600; -export const DEFAULT_POPUP_HEIGHT = 600; +export { + DEFAULT_POPUP_WIDTH, + DEFAULT_POPUP_HEIGHT, + SHOP_APP_ORIGIN, +} from "./internal/checkout-element"; export { CK_VERSION } from "./version"; -/** - * Trusted origin always allowed to post messages, alongside the cart URL - * origin derived from `src`. Both are included whether or not the integrator - * configures an explicit `allowedOrigins` list. - */ -export const SHOP_APP_ORIGIN = "https://shop.app"; - -/** - * Default trusted origin patterns for `shop.app`: the apex origin plus a - * wildcard covering its subdomains (e.g. regional or checkout subdomains). - */ -const SHOP_APP_ORIGIN_PATTERNS = [SHOP_APP_ORIGIN, "https://*.shop.app"]; - -/** Matches a wildcard-subdomain origin pattern, e.g. `https://*.example.com[:8443]`. */ -const WILDCARD_ORIGIN_PATTERN = /^(https?):\/\/\*\.([^/:]+)(?::(\d+))?\/?$/i; - -/** Returns whether `pattern` is a usable origin pattern (`*`, wildcard, or exact origin). */ -function isValidOriginPattern(pattern: string): boolean { - if (pattern === "*") return true; - if (pattern.includes("*")) return WILDCARD_ORIGIN_PATTERN.test(pattern); - try { - const url = new URL(pattern); - return ( - (url.protocol === "https:" || url.protocol === "http:") && - url.username === "" && - url.password === "" && - url.pathname === "/" && - url.search === "" && - url.hash === "" - ); - } catch { - return false; - } -} - -/** Returns the serialized port browsers use for an origin. */ -function normalizedOriginPort(protocol: string, port: string | undefined): string { - if (port === undefined) return ""; - if ((protocol === "http" && port === "80") || (protocol === "https" && port === "443")) { - return ""; - } - return port; -} - -/** - * Tests whether `origin` satisfies an allowlist `pattern`: - * - `"*"` matches every origin. - * - `https://*.example.com` matches proper subdomains of `example.com` (not the - * apex), requiring the scheme and port to match too. - * - Anything else is treated as an exact origin (normalized via `URL`). - */ -function originMatchesPattern(pattern: string, origin: URL): boolean { - if (pattern === "*") return true; - - if (!pattern.includes("*")) { - try { - return isValidOriginPattern(pattern) && new URL(pattern).origin === origin.origin; - } catch { - return false; - } - } - - const match = WILDCARD_ORIGIN_PATTERN.exec(pattern); - if (!match) return false; - const [, scheme, suffix, port] = match; - if (scheme === undefined || suffix === undefined) return false; - - if (`${scheme.toLowerCase()}:` !== origin.protocol) return false; - if (normalizedOriginPort(scheme.toLowerCase(), port) !== origin.port) return false; - - const host = origin.hostname.toLowerCase(); - const suffixHost = suffix.toLowerCase(); - return host !== suffixHost && host.endsWith(`.${suffixHost}`); -} - -const WINDOW_OPEN_INVALID_URL_WARNING = "ec.window.open_request received without a valid url"; - const EMBED_DELEGATIONS = [EmbeddedCheckoutProtocol.Delegations.windowOpen] as const; const CHECKOUT_APPEARANCES = new Map([ ["app:light", { colorScheme: "light", branding: "app" }], @@ -126,39 +63,6 @@ const CHECKOUT_APPEARANCES = new Map - - -
- -
- -
-
- Continue your purchase in the
- -
- -
-
-
-
-
- -`); - /** * An element that renders a Shopify Checkout. Checkout opens in a popup or browser tab/window * (see `target`). To use, create a `shopify-checkout` element, set the `src` attribute to the @@ -195,17 +99,14 @@ export class ShopifyCheckout constructor() { super(); - this.attachShadow({ mode: "open" }).appendChild(SHADOW_TEMPLATE.content.cloneNode(true)); + attachCheckoutShadow(this); } #checkout?: Checkout; #error?: CheckoutError; #checkoutComparisonKey?: string; - #checkoutWindow: WindowProxy | null = null; - - // Manages the listeners for the popup window, new tabs, and scrim dialog - #currentOpen: { controller: AbortController } | null = null; + #presentation?: CheckoutPresentation; // 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 @@ -233,13 +134,8 @@ export class ShopifyCheckout * `https:` scheme. */ #srcAsURL({ warnInvalidAppearance = false } = {}) { - let url: URL; - try { - url = new URL(this.src); - } catch { - return undefined; - } - if (url.protocol !== "https:") return undefined; + const url = checkoutSourceURL(this.src); + if (!url) return undefined; url.searchParams.delete("ck_branding"); @@ -327,9 +223,7 @@ export class ShopifyCheckout * attribute and property can be used interchangeably. */ get allowedOrigins(): string[] { - const attr = this.getAttribute("allowed-origins"); - if (!attr) return []; - return attr.split(/[\s,]+/).filter(Boolean); + return checkoutAllowedOrigins(this); } set allowedOrigins(value: string[] | string | undefined) { @@ -351,13 +245,7 @@ export class ShopifyCheckout onMessageRejected?: (detail: MessageRejectedDetail) => void; #setAttribute(name: string, value: string | boolean | undefined) { - if (value === true) { - this.setAttribute(name, ""); - } else if (value != null && value !== false) { - this.setAttribute(name, value); - } else { - this.removeAttribute(name); - } + setCheckoutAttribute(this, name, value); } /* ------------------------------------------------------------ @@ -392,26 +280,6 @@ export class ShopifyCheckout return this.#error; } - get #dialogElement(): HTMLDialogElement | undefined { - return this.shadowRoot?.querySelector("#overlay") ?? undefined; - } - - get #dialogBackgroundElement(): HTMLDivElement | undefined { - return this.shadowRoot?.querySelector("#overlay-background") ?? undefined; - } - - get #dialogCloseButtonElement(): HTMLButtonElement | undefined { - return this.shadowRoot?.querySelector("#overlay-close-button") ?? undefined; - } - - get #dialogButtonElement(): HTMLButtonElement | undefined { - return this.shadowRoot?.querySelector("#overlay-link") ?? undefined; - } - - get #targetElement(): HTMLDivElement | undefined { - return this.shadowRoot?.querySelector(".Shopify-target") ?? undefined; - } - /* ------------------------------------------------------------ * Methods * ------------------------------------------------------------ @@ -436,136 +304,30 @@ export class ShopifyCheckout return; } - // Close any existing sessions before opening a new one - if (this.#currentOpen) { - this.close(); - } - + // Close any existing session before opening another one. + this.close(); this.#checkout = undefined; this.#error = undefined; this.#checkoutComparisonKey = undefined; - - let checkoutWindow: WindowProxy | null = null; const navigationStartedAt = performance.now(); - - switch (target) { - case "popup": { - const features = this.#getPopupFeatures(); - checkoutWindow = window.open(src, "", features); - break; - } - - case "auto": - case "_blank": - default: { - if (target === "_self" || target === "_parent" || target === "_top") { - this.#logger.warn( - `target="${target}" would navigate the current page; falling back to "auto"`, - ); - checkoutWindow = window.open(src, "auto"); - } else { - checkoutWindow = window.open(src, target); - } - break; - } - } - - const abortController = new AbortController(); - - // Opens a dialog element to act as a scrim over the current window while the popup is open. - // The dialog can be closed by the user, or will close itself when the popup is closed. - const dialog = this.#dialogElement; - const dialogBackground = this.#dialogBackgroundElement; - const dialogCloseButton = this.#dialogCloseButtonElement; - const dialogButton = this.#dialogButtonElement; - - if (dialog && dialogBackground) { - // By default we show the scrim. - // If a consumer wants to hide it, they can either: - // 1. Set `display: none` on the `` element itself - // 2. Set `display: none` on the overlay using CSS parts, e.g., - // ``` - // shopify-checkout::part(overlay) { - // display: none; - // } - // ``` - // It's important not to call `dialog.showModal()` if the dialog is not visible because it traps focus and - // hides the rest of the page from the accessibility tree. - const isElementHidden = window.getComputedStyle(this).getPropertyValue("display") === "none"; - const isOverlayHidden = - window.getComputedStyle(dialogBackground).getPropertyValue("display") === "none"; - const showDialog = !isElementHidden && !isOverlayHidden; - - if (showDialog) { - dialog.showModal(); - - dialogCloseButton?.addEventListener( - "click", - () => { - dialog.close(); - }, - { - signal: abortController.signal, - }, - ); - - dialog.addEventListener( - "close", - () => { - abortController.abort(); - }, - { - signal: abortController.signal, - }, + const presentation = openCheckoutPresentation({ + element: this, + src, + target, + onUnsafeTarget: (unsafeTarget) => { + this.#logger.warn( + `target="${unsafeTarget}" would navigate the current page; falling back to "auto"`, ); - - dialogButton?.addEventListener( - "click", - (event: MouseEvent) => { - event.preventDefault(); - this.#checkoutWindow?.focus(); - }, - { - signal: abortController.signal, - }, - ); - - abortController.signal.addEventListener("abort", () => { - dialog.close(); - }); - } - } - - abortController.signal.addEventListener("abort", () => { - this.#navigationStartedAt = undefined; - checkoutWindow?.close(); - this.#checkoutWindow = null; - this.#currentOpen = null; - /** @ignore - Events are documented by the class @event tags. */ - this.dispatchEvent(new ShopifyCheckoutCloseEvent()); - }); - - // Handles cases where the user closed the window and returned to the page. - window.addEventListener( - "focus", - () => { - // Small delay to allow browser to update the closed property - const timer = setTimeout(() => { - if (checkoutWindow?.closed) { - abortController.abort(); - } - }, 50); - abortController.signal.addEventListener("abort", () => { - clearTimeout(timer); - }); }, - { - signal: abortController.signal, + onClose: () => { + this.#navigationStartedAt = undefined; + this.#presentation = undefined; + this.dispatchEvent(new ShopifyCheckoutCloseEvent()); }, - ); + }); + this.#presentation = presentation; - this.#currentOpen = { controller: abortController }; - this.#checkoutWindow = checkoutWindow; + const { checkoutWindow } = presentation; this.#navigationStartedAt = checkoutWindow && this.telemetry ? navigationStartedAt : undefined; if (!checkoutWindow) { @@ -580,9 +342,7 @@ export class ShopifyCheckout } close(): void { - if (this.#currentOpen) { - this.#currentOpen.controller.abort(); - } + this.#presentation?.close(); } #recordNavigationSuccess(): void { @@ -605,66 +365,15 @@ export class ShopifyCheckout } override focus(): void { - this.#checkoutWindow?.focus(); + this.#presentation?.focus(); } - /** - * Adds the `Shopify-target--` modifier class to the rendered - * target element - */ - #applyTargetClass() { - const value = this.target; - if (!value || /\s/.test(value)) return; - this.#targetElement?.classList.add(`Shopify-target--${value}`); + #applyTargetClass(): void { + applyCheckoutTargetClass(this, this.target); } - /** Mirror of `#applyTargetClass` for removing a previous modifier. */ - #removeTargetClass(value: string | null) { - if (!value || /\s/.test(value)) return; - this.#targetElement?.classList.remove(`Shopify-target--${value}`); - } - - #getPopupFeatures() { - const computedStyle = window.getComputedStyle(this); - const widthFromCustomProperty = computedStyle.getPropertyValue( - "--shopify-checkout-dialog-width", - ); - const desiredWidth = widthFromCustomProperty - ? Number.parseInt(widthFromCustomProperty, 10) - : DEFAULT_POPUP_WIDTH; - const screenLeft = window.screenLeft ?? window.screenX; - const windowWidth = window.outerWidth ?? document.documentElement.clientWidth ?? screen.width; - const maxWidth = Math.floor(windowWidth * 0.9); - const width = Math.min(desiredWidth, maxWidth); - - const heightFromCustomProperty = computedStyle.getPropertyValue( - "--shopify-checkout-dialog-height", - ); - const desiredHeight = heightFromCustomProperty - ? Number.parseInt(heightFromCustomProperty, 10) - : DEFAULT_POPUP_HEIGHT; - - const screenTop = window.screenTop ?? window.screenY; - const windowHeight = - window.outerHeight ?? document.documentElement.clientHeight ?? screen.height; - const maxHeight = Math.floor(windowHeight * 0.9); - const height = Math.min(desiredHeight, maxHeight); - - const left = Math.floor((windowWidth - width) / 2) + screenLeft; - const top = Math.floor((windowHeight - height) / 2) + screenTop; - - const features = [ - `width=${width}`, - `height=${height}`, - `left=${left}`, - `top=${top}`, - `scrollbars=yes`, - `status=no`, - `toolbar=no`, - `resizable=yes`, - ].join(","); - - return features; + #removeTargetClass(value: string | null): void { + removeCheckoutTargetClass(this, value); } /* ------------------------------------------------------------ @@ -672,69 +381,14 @@ export class ShopifyCheckout * ------------------------------------------------------------ */ - #validateMessageOrigin(event: MessageEvent) { - const src = this.#srcAsURL(); - if (!src) { - throw new Error("Dropped message because src is invalid or unset"); - } - - let origin: URL; - try { - origin = new URL(event.origin); - } catch { - throw new Error(`Dropped message from non-HTTPS origin "${event.origin}"`); - } - - if (origin.protocol !== "https:") { - throw new Error(`Dropped message from non-HTTPS origin "${event.origin}"`); - } - - const patterns = this.#allowedOriginPatterns(src); - if (patterns !== null && !patterns.some((pattern) => originMatchesPattern(pattern, origin))) { - throw new Error(`Dropped message from origin "${origin.origin}" not in allowlist`); - } - } - - /** - * Computes the effective set of trusted origins for incoming messages, or - * `null` when validation is disabled via the `"*"` escape hatch. - * - * Web is closed by default: the cart URL origin (from `src`) and `shop.app` - * are always trusted, and any configured `allowedOrigins` are added on top. - */ - #allowedOriginPatterns(src: URL): string[] | null { - const configured = this.allowedOrigins; - if (configured.includes("*")) return null; - - const patterns = [src.origin, ...SHOP_APP_ORIGIN_PATTERNS]; - for (const entry of configured) { - if (isValidOriginPattern(entry)) { - patterns.push(entry); - } else { - this.#logger.warn(`Ignoring invalid allowed origin "${entry}"`); - } - } - return patterns; + #validateMessageOrigin(event: MessageEvent): void { + validateCheckoutMessageOrigin(event, this.#srcAsURL(), this.allowedOrigins, (message) => { + this.#logger.warn(message); + }); } - /** - * Routes a dropped message to the {@link onMessageRejected} callback, falling - * back to a logged warning when no callback is set. - */ - #rejectMessage(event: MessageEvent, error: unknown) { - const reason = error instanceof Error ? error.message : String(error); - if (this.onMessageRejected) { - try { - this.onMessageRejected({ origin: event.origin, data: event.data, reason }); - } catch (callbackError) { - this.#logger.error( - "onMessageRejected callback threw", - callbackError instanceof Error ? callbackError.message : String(callbackError), - ); - } - return; - } - this.#logger.warn(reason); + #rejectMessage(event: MessageEvent, error: unknown): void { + rejectCheckoutMessage(this, event, error, this.#logger); } #initCheckoutProtocol() { @@ -762,7 +416,8 @@ export class ShopifyCheckout // Source check: messages must come from the embedded checkout window // we opened. Unrelated postMessage traffic on the host page (other // SDKs, browser extensions, etc.) is dropped silently. - if (event.source !== this.#checkoutWindow) return; + const presentation = this.#presentation; + if (!isCheckoutMessageFromPresentation(event, presentation)) return; try { this.#validateMessageOrigin(event); @@ -787,7 +442,7 @@ export class ShopifyCheckout return; } - void this.#dispatchProtocolMessage(serialized, event); + void this.#dispatchProtocolMessage(serialized, event, presentation); }; /** @@ -858,7 +513,9 @@ export class ShopifyCheckout .on(Event.messagesChange, ({ params: { checkout } }) => { this.#updateCheckout(checkout); }) - .on(Event.windowOpen, ({ params }) => this.#handleWindowOpen(params)); + .on(Event.windowOpen, ({ params }) => + handleWindowOpenRequest(params, EmbeddedCheckoutProtocol.specVersion, this.#logger), + ); } #recordCheckout(checkout: ProtocolCheckout): Checkout { @@ -883,9 +540,19 @@ export class ShopifyCheckout * requests (`ec.ready`, `ec.window.open_request`, unknown methods); * notifications resolve to `undefined` and post nothing. */ - async #dispatchProtocolMessage(serialized: string, event: MessageEvent): Promise { + async #dispatchProtocolMessage( + serialized: string, + event: MessageEvent, + presentation: CheckoutPresentation, + ): Promise { + // The source and origin were authenticated before the async protocol handler. + // A later presentation must never receive this response. + const source = event.source as WindowProxy; + const origin = event.origin; const response = await this.#client.process(serialized); - if (response === undefined) return; + if (response === undefined || this.#presentation !== presentation || !presentation.isActive()) { + return; + } const parsed = JSON.parse(response) as { error?: { code?: number }; @@ -897,36 +564,10 @@ export class ShopifyCheckout parsed.error?.code === INVALID_PARAMS_CODE && decodeProtocolMessage(serialized)?.method === EmbeddedCheckoutProtocol.Event.windowOpen.method ) { - this.#logger.warn(WINDOW_OPEN_INVALID_URL_WARNING, event.data); + this.#logger.warn(WINDOW_OPEN_INVALID_URL_WARNING); } - const { source } = event; - if (source) { - (source as WindowProxy).postMessage(parsed, event.origin); - } - } - - /** - * Handles an `ec.window.open_request` delegation: opens a validated `https:` - * URL in a new tab and returns a UCP result. Invalid or non-`https:` URLs - * are rejected (and warned about) rather than opened. - */ - #handleWindowOpen(request: WindowOpenRequest): WindowOpenResult { - let targetUrl: URL; - try { - targetUrl = new URL(request.url); - } catch { - this.#logger.warn(WINDOW_OPEN_INVALID_URL_WARNING, request); - return windowOpenRejected("url is not a valid URL"); - } - - if (targetUrl.protocol !== "https:") { - this.#logger.warn(WINDOW_OPEN_INVALID_URL_WARNING, request); - return windowOpenRejected("url must use https scheme"); - } - - window.open(targetUrl.href, "_blank", "noopener"); - return windowOpenSuccess(); + source.postMessage(parsed, origin); } /* ------------------------------------------------------------ @@ -958,8 +599,12 @@ export class ShopifyCheckout if (oldValue === newValue) return; switch (name) { + case "src": { + if (this.#presentation) this.close(); + break; + } case "target": { - if (oldValue !== newValue && this.#currentOpen) { + if (this.#presentation) { this.close(); } diff --git a/platforms/web/src/internal/checkout-element.ts b/platforms/web/src/internal/checkout-element.ts new file mode 100644 index 000000000..92e501c1f --- /dev/null +++ b/platforms/web/src/internal/checkout-element.ts @@ -0,0 +1,390 @@ +/* eslint ssr-friendly/no-dom-globals-in-module-scope: off */ + +import { + windowOpenRejected, + windowOpenSuccess, + type WindowOpenRequest, + type WindowOpenResult, +} from "@shopify/checkout-kit-protocol"; + +import stylesText from "../checkout.css?inline"; +import type { CheckoutTarget, MessageRejectedDetail } from "../checkout.types"; +import type { Logger } from "../logger"; +import { createTemplate, html, safe } from "../utils"; + +export const DEFAULT_POPUP_WIDTH = 600; +export const DEFAULT_POPUP_HEIGHT = 600; +export const SHOP_APP_ORIGIN = "https://shop.app"; +export const WINDOW_OPEN_INVALID_URL_WARNING = + "ec.window.open_request received without a valid url"; + +const SHOP_APP_ORIGIN_PATTERNS = [SHOP_APP_ORIGIN, "https://*.shop.app"]; +const WILDCARD_ORIGIN_PATTERN = /^(https?):\/\/\*\.([^/:]+)(?::(\d+))?\/?$/i; + +const SHADOW_TEMPLATE = createTemplate(html` +
+ + +
+ +
+ +
+
+ Continue your purchase in the
+ +
+ +
+
+
+
+
+
+`); + +export interface CheckoutPresentation { + readonly checkoutWindow: WindowProxy | null; + /** True only while this particular presentation is still open. */ + isActive(): boolean; + close(): void; + focus(): void; +} + +interface OpenCheckoutPresentationOptions { + readonly element: HTMLElement; + readonly src: string; + readonly target: CheckoutTarget | string; + readonly onUnsafeTarget: (target: string) => void; + readonly onClose: () => void; +} + +export function attachCheckoutShadow(element: HTMLElement): void { + element.attachShadow({ mode: "open" }).appendChild(SHADOW_TEMPLATE.content.cloneNode(true)); +} + +export function checkoutSourceURL(source: string): URL | undefined { + try { + const url = new URL(source); + return url.protocol === "https:" ? url : undefined; + } catch { + return undefined; + } +} + +export function setCheckoutAttribute( + element: HTMLElement, + name: string, + value: string | boolean | undefined, +): void { + if (value === true) { + element.setAttribute(name, ""); + } else if (value != null && value !== false) { + element.setAttribute(name, value); + } else { + element.removeAttribute(name); + } +} + +export function checkoutAllowedOrigins(element: HTMLElement): string[] { + const attribute = element.getAttribute("allowed-origins"); + return attribute ? attribute.split(/[\s,]+/).filter(Boolean) : []; +} + +export function openCheckoutPresentation({ + element, + src, + target, + onUnsafeTarget, + onClose, +}: OpenCheckoutPresentationOptions): CheckoutPresentation { + let checkoutWindow: WindowProxy | null; + + switch (target) { + case "popup": + checkoutWindow = window.open(src, "", getPopupFeatures(element)); + break; + case "auto": + case "_blank": + default: + if (target === "_self" || target === "_parent" || target === "_top") { + onUnsafeTarget(target); + checkoutWindow = window.open(src, "auto"); + } else { + checkoutWindow = window.open(src, target); + } + break; + } + + const abortController = new AbortController(); + const dialog = element.shadowRoot?.querySelector("#overlay"); + const dialogBackground = element.shadowRoot?.querySelector("#overlay-background"); + const dialogCloseButton = + element.shadowRoot?.querySelector("#overlay-close-button"); + const dialogButton = element.shadowRoot?.querySelector("#overlay-link"); + + if (dialog && dialogBackground) { + const isElementHidden = window.getComputedStyle(element).getPropertyValue("display") === "none"; + const isOverlayHidden = + window.getComputedStyle(dialogBackground).getPropertyValue("display") === "none"; + + if (!isElementHidden && !isOverlayHidden) { + dialog.showModal(); + + dialogCloseButton?.addEventListener("click", () => dialog.close(), { + signal: abortController.signal, + }); + dialog.addEventListener( + "close", + () => { + // close events are queued; a previous presentation can close this + // dialog and then reopen it before its close event is dispatched. + if (!dialog.open) abortController.abort(); + }, + { signal: abortController.signal }, + ); + dialogButton?.addEventListener( + "click", + (event: MouseEvent) => { + event.preventDefault(); + checkoutWindow?.focus(); + }, + { signal: abortController.signal }, + ); + abortController.signal.addEventListener("abort", () => dialog.close()); + } + } + + abortController.signal.addEventListener("abort", () => { + checkoutWindow?.close(); + onClose(); + }); + + window.addEventListener( + "focus", + () => { + const timer = setTimeout(() => { + if (checkoutWindow?.closed) { + abortController.abort(); + } + }, 50); + abortController.signal.addEventListener("abort", () => clearTimeout(timer)); + }, + { signal: abortController.signal }, + ); + + return { + checkoutWindow, + isActive: () => !abortController.signal.aborted, + close: () => abortController.abort(), + focus: () => checkoutWindow?.focus(), + }; +} + +export function applyCheckoutTargetClass(element: HTMLElement, target: string): void { + if (!target || /\s/.test(target)) return; + element.shadowRoot + ?.querySelector(".Shopify-target") + ?.classList.add(`Shopify-target--${target}`); +} + +export function removeCheckoutTargetClass(element: HTMLElement, target: string | null): void { + if (!target || /\s/.test(target)) return; + element.shadowRoot + ?.querySelector(".Shopify-target") + ?.classList.remove(`Shopify-target--${target}`); +} + +/** A blocked popup has no sender and must never authenticate a null source. */ +export function isCheckoutMessageFromPresentation( + event: MessageEvent, + presentation: CheckoutPresentation | undefined, +): presentation is CheckoutPresentation { + return ( + presentation !== undefined && + presentation.isActive() && + presentation.checkoutWindow !== null && + event.source === presentation.checkoutWindow + ); +} + +export function validateCheckoutMessageOrigin( + event: MessageEvent, + src: URL | undefined, + configuredOrigins: readonly string[], + warn: (message: string) => void, +): void { + if (!src) { + throw new Error("Dropped message because src is invalid or unset"); + } + + let origin: URL; + try { + origin = new URL(event.origin); + } catch { + throw new Error(`Dropped message from non-HTTPS origin "${event.origin}"`); + } + + if (origin.protocol !== "https:") { + throw new Error(`Dropped message from non-HTTPS origin "${event.origin}"`); + } + + const configured = configuredOrigins.includes("*") ? null : configuredOrigins; + if (configured === null) return; + + const patterns = [src.origin, ...SHOP_APP_ORIGIN_PATTERNS]; + for (const entry of configured) { + if (isValidOriginPattern(entry)) { + patterns.push(entry); + } else { + warn(`Ignoring invalid allowed origin "${entry}"`); + } + } + + if (!patterns.some((pattern) => originMatchesPattern(pattern, origin))) { + throw new Error(`Dropped message from origin "${origin.origin}" not in allowlist`); + } +} + +export function rejectCheckoutMessage( + element: { onMessageRejected?: (detail: MessageRejectedDetail) => void }, + event: MessageEvent, + error: unknown, + logger: Logger, +): void { + const reason = error instanceof Error ? error.message : String(error); + if (element.onMessageRejected) { + try { + element.onMessageRejected({ origin: event.origin, data: event.data, reason }); + } catch (callbackError) { + logger.error( + "onMessageRejected callback threw", + callbackError instanceof Error ? callbackError.message : String(callbackError), + ); + } + return; + } + logger.warn(reason); +} + +/** + * Handles an `ec.window.open_request` delegation: opens a validated `https:` + * URL in a new tab and returns a UCP result for the negotiated `version`. + * Invalid or non-`https:` URLs are rejected (and warned about) rather than opened. + */ +export function handleWindowOpenRequest( + request: WindowOpenRequest, + version: string, + logger: Logger, +): WindowOpenResult { + let targetUrl: URL; + try { + targetUrl = new URL(request.url); + } catch { + logger.warn(WINDOW_OPEN_INVALID_URL_WARNING); + return windowOpenRejected("url is not a valid URL", version); + } + + if (targetUrl.protocol !== "https:") { + logger.warn(WINDOW_OPEN_INVALID_URL_WARNING); + return windowOpenRejected("url must use https scheme", version); + } + + window.open(targetUrl.href, "_blank", "noopener"); + return windowOpenSuccess(version); +} + +function getPopupFeatures(element: HTMLElement): string { + const computedStyle = window.getComputedStyle(element); + const widthFromCustomProperty = computedStyle.getPropertyValue("--shopify-checkout-dialog-width"); + const desiredWidth = widthFromCustomProperty + ? Number.parseInt(widthFromCustomProperty, 10) + : DEFAULT_POPUP_WIDTH; + const screenLeft = window.screenLeft ?? window.screenX; + const windowWidth = window.outerWidth ?? document.documentElement.clientWidth ?? screen.width; + const width = Math.min(desiredWidth, Math.floor(windowWidth * 0.9)); + + const heightFromCustomProperty = computedStyle.getPropertyValue( + "--shopify-checkout-dialog-height", + ); + const desiredHeight = heightFromCustomProperty + ? Number.parseInt(heightFromCustomProperty, 10) + : DEFAULT_POPUP_HEIGHT; + const screenTop = window.screenTop ?? window.screenY; + const windowHeight = window.outerHeight ?? document.documentElement.clientHeight ?? screen.height; + const height = Math.min(desiredHeight, Math.floor(windowHeight * 0.9)); + const left = Math.floor((windowWidth - width) / 2) + screenLeft; + const top = Math.floor((windowHeight - height) / 2) + screenTop; + + return [ + `width=${width}`, + `height=${height}`, + `left=${left}`, + `top=${top}`, + "scrollbars=yes", + "status=no", + "toolbar=no", + "resizable=yes", + ].join(","); +} + +function isValidOriginPattern(pattern: string): boolean { + if (pattern === "*") return true; + if (pattern.includes("*")) return WILDCARD_ORIGIN_PATTERN.test(pattern); + try { + const url = new URL(pattern); + return ( + (url.protocol === "https:" || url.protocol === "http:") && + url.username === "" && + url.password === "" && + url.pathname === "/" && + url.search === "" && + url.hash === "" + ); + } catch { + return false; + } +} + +function originMatchesPattern(pattern: string, origin: URL): boolean { + if (pattern === "*") return true; + + if (!pattern.includes("*")) { + try { + return isValidOriginPattern(pattern) && new URL(pattern).origin === origin.origin; + } catch { + return false; + } + } + + const match = WILDCARD_ORIGIN_PATTERN.exec(pattern); + if (!match) return false; + const [, scheme, suffix, port] = match; + if (scheme === undefined || suffix === undefined) return false; + + if (`${scheme.toLowerCase()}:` !== origin.protocol) return false; + if (normalizedOriginPort(scheme.toLowerCase(), port) !== origin.port) return false; + + const host = origin.hostname.toLowerCase(); + const suffixHost = suffix.toLowerCase(); + return host !== suffixHost && host.endsWith(`.${suffixHost}`); +} + +function normalizedOriginPort(protocol: string, port: string | undefined): string { + if (port === undefined) return ""; + if ((protocol === "http" && port === "80") || (protocol === "https" && port === "443")) { + return ""; + } + return port; +} From 886117e3b694e680f1424b2a575d1e2fd38e8d8e Mon Sep 17 00:00:00 2001 From: tiagocandido <2342727+tiagocandido@users.noreply.github.com> Date: Wed, 30 Sep 2026 23:35:48 +0200 Subject: [PATCH 2/2] Address shared Web presentation review feedback --- platforms/web/src/checkout-protocol.test.ts | 14 +++++++++----- platforms/web/src/checkout.ts | 1 + 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/platforms/web/src/checkout-protocol.test.ts b/platforms/web/src/checkout-protocol.test.ts index bda72ac8e..d85d167c8 100644 --- a/platforms/web/src/checkout-protocol.test.ts +++ b/platforms/web/src/checkout-protocol.test.ts @@ -113,8 +113,8 @@ describe("", () => { }); it("keeps sender and response routing independent across elements", async () => { - const first = renderCheckout({ target: "popup", src: "https://first.example/checkout" }); - const second = renderCheckout({ target: "popup", src: "https://second.example/checkout" }); + const first = renderCheckout({ target: "popup", src: "https://shop.example/checkout" }); + const second = renderCheckout({ target: "popup", src: "https://shop.example/checkout" }); const firstWindow = createMockWindow(); const secondWindow = createMockWindow(); vi.spyOn(window, "open").mockReturnValueOnce(firstWindow).mockReturnValueOnce(secondWindow); @@ -134,9 +134,13 @@ describe("", () => { ); await flushProtocolDispatch(); - expect(firstWindow.postMessage).toHaveBeenCalledWith( - expect.objectContaining({ id: "first-ready" }), - "https://first.example", + expect(firstWindow.postMessage).toHaveBeenCalledExactlyOnceWith( + { + jsonrpc: "2.0", + id: "first-ready", + result: { ucp: { status: "success", version: EMBED_PROTOCOL_VERSION } }, + }, + "https://shop.example", ); expect(secondWindow.postMessage).not.toHaveBeenCalled(); }); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 0064c6fc2..50bb55365 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -322,6 +322,7 @@ export class ShopifyCheckout onClose: () => { this.#navigationStartedAt = undefined; this.#presentation = undefined; + /** @ignore - Events are documented by the class @event tags. */ this.dispatchEvent(new ShopifyCheckoutCloseEvent()); }, });