diff --git a/platforms/web/sample/README.md b/platforms/web/sample/README.md index a7e2e2e6..b3a7d949 100644 --- a/platforms/web/sample/README.md +++ b/platforms/web/sample/README.md @@ -14,7 +14,7 @@ pnpm sample Vite serves at `http://localhost:5173`. The page has three panels: -- **Options** — form for `src`, `target` (`auto` | `popup` | `inline`), +- **Options** — form for `src`, `target` (`auto` | `popup`), `preload`, and `debug`, plus buttons for `open()`, `close()`, and `focus()`. - **Demo Storefront** — a mocked product card with **Buy now** calling `checkout.open()`. The button stays disabled until a checkout URL is set. @@ -24,8 +24,7 @@ Vite serves at `http://localhost:5173`. The page has three panels: time. The element is mounted on ``. For `popup` / `auto`, the visible UI is -mostly the overlay scrim when checkout is open; for **inline**, checkout renders -in an iframe inside the component shadow tree. +mostly the overlay scrim while checkout is open in a separate window or tab. ## Build diff --git a/platforms/web/src/checkout.styles.ts b/platforms/web/src/checkout.styles.ts index 1a1dbd1d..d05d6ba0 100644 --- a/platforms/web/src/checkout.styles.ts +++ b/platforms/web/src/checkout.styles.ts @@ -28,7 +28,6 @@ export const STYLES: SafeMarkup = css` box-sizing: border-box; } - #checkout-iframe, #shopify-element-wrapper, .Shopify-target { inline-size: 100%; @@ -36,20 +35,6 @@ export const STYLES: SafeMarkup = css` border: none; } - #checkout-iframe { - display: none; - } - - .Shopify-target--inline { - #checkout-iframe { - display: block; - } - - .overlay { - display: none; - } - } - :host { @media (prefers-reduced-motion: reduce) { --shopify-checkout-overlay-transition-duration: 1ms; diff --git a/platforms/web/src/checkout.test.ts b/platforms/web/src/checkout.test.ts index 0c050920..03dfba93 100644 --- a/platforms/web/src/checkout.test.ts +++ b/platforms/web/src/checkout.test.ts @@ -97,19 +97,6 @@ describe("", () => { checkout.target = newTarget; expect(checkout.getAttribute("target")).toBe(newTarget); }); - - describe('when target is "inline"', () => { - it("renders an iframe on mount without needing open()", () => { - const checkout = renderCheckout({ target: "inline" }); - - const iframe = checkout.shadowRoot!.querySelector("iframe"); - expect(iframe).not.toBeNull(); - - const url = new URL(iframe!.src); - expect(url.searchParams.get("ec_version")).toBe(EMBED_PROTOCOL_VERSION); - expect(url.searchParams.get("ec_delegate")).toBe("window.open"); - }); - }); }); describe("preload", () => { @@ -143,7 +130,7 @@ describe("", () => { expect(checkout.getAttribute("preload")).toBeNull(); }); - it("adds a preload link to the iframe src when set to true", () => { + it("adds a preload link when set to true", () => { const checkout = renderCheckout(); checkout.preload = true; @@ -246,13 +233,10 @@ describe("", () => { "blob:https://shop.example.com/abc", "file:///etc/passwd", "not a url", - ])("renders empty iframe src and no overlay href when src is %s", (badSrc) => { - const checkout = renderCheckout({ src: badSrc, target: "inline" }); + ])("leaves overlay link without href when src is %s", (badSrc) => { + const checkout = renderCheckout({ src: badSrc }); - const iframe = checkout.shadowRoot!.querySelector("#checkout-iframe"); - // #srcAsURL returns undefined for non-https values, so the iframe - // src is set to '' rather than the raw input. - expect(iframe!.getAttribute("src")).toBe(""); + expect(checkout.shadowRoot!.querySelector("iframe")).toBeNull(); const overlayLink = checkout.shadowRoot!.querySelector("#overlay-link"); expect(overlayLink!.hasAttribute("href")).toBe(false); @@ -263,7 +247,7 @@ describe("", () => { // The URL constructor accepts this (the `"` becomes part of the // pathname); the value is rendered via DOM APIs rather than string // interpolation, so no markup is parsed into the shadow root. - const checkout = renderCheckout({ src, target: "inline" }); + const checkout = renderCheckout({ src }); expect(checkout.shadowRoot!.querySelector("script")).toBeNull(); expect((window as unknown as { __xssed?: boolean }).__xssed).toBeUndefined(); @@ -477,40 +461,6 @@ describe("", () => { }); }); - describe('when target="inline"', () => { - it("shows the checkout in an iframe", () => { - const checkout = renderCheckout({ target: "inline" }); - - const iframe = checkout.shadowRoot!.querySelector("iframe"); - expect(iframe).not.toBeNull(); - expect(iframe!.getAttribute("allow")).toBe( - "publickey-credentials-get https://pay.shopify.com https://shop.app; geolocation", - ); - - const url = new URL(iframe!.src); - expect(url.searchParams.get("ec_version")).toBe(EMBED_PROTOCOL_VERSION); - expect(url.searchParams.get("ec_delegate")).toBe("window.open"); - }); - - it("sets the correct iframe security attributes", () => { - const checkout = renderCheckout({ target: "inline" }); - - const iframe = checkout.shadowRoot!.querySelector("iframe"); - expect(iframe).not.toBeNull(); - - expect(iframe!.id).toBe("checkout-iframe"); - expect(iframe!.title).toBe("Checkout"); - - expect(iframe!.getAttribute("allow")).toBe( - "publickey-credentials-get https://pay.shopify.com https://shop.app; geolocation", - ); - - expect(iframe!.getAttribute("sandbox")).toBe( - "allow-scripts allow-same-origin allow-forms allow-popups", - ); - }); - }); - describe('when target="_blank", "auto", or undefined', () => { NEW_TAB_TARGETS.forEach((target) => { it("opens in a new window", () => { @@ -544,8 +494,6 @@ describe("", () => { describe("focus", () => { it("focuses the checkout window", () => { - // Note: 'inline' target is excluded because the open() method returns early - // for inline targets, so #checkoutWindow is never set and focus() has no effect [...POPUP_TARGETS, "_blank"].forEach((target) => { const checkout = renderCheckout({ target }); const mockPopup = { @@ -563,16 +511,6 @@ describe("", () => { }); describe("close", () => { - describe('when target="inline"', () => { - it("does not dispatch a close event", () => { - const checkout = renderCheckout({ target: "inline" }); - const closeEventSpy = vi.fn(); - checkout.addEventListener("checkout:close", closeEventSpy); - checkout.close(); - expect(closeEventSpy).not.toHaveBeenCalled(); - }); - }); - describe('when target="popup", "auto", or undefined', () => { it("dispatches close event when popup is closed", () => { POPUP_TARGETS.forEach((target) => { @@ -623,10 +561,6 @@ describe("", () => { describe("it subscribes to checkout-protocol events", () => { describe("ec.ready handshake", () => { it("auto-responds with an empty result and does not dispatch a DOM event", async () => { - // Use popup target so #checkoutWindow is a controllable mock - // window we can use as the message source and spy on for the - // wire-level response. (Inline iframes inside shadow DOM have a - // null contentWindow in jsdom.) const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onReadySpy = vi.fn(); // ec:ready is no longer a public event; cast through `never` to verify @@ -664,12 +598,12 @@ describe("", () => { describe("ec:start", () => { it("updates the checkout property and dispatches an ec:start event", async () => { - const checkout = renderCheckout({ target: "inline" }); + const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onStartSpy = vi.fn(); const listenForEvent = waitForEvent(checkout, "ec:start", onStartSpy); const payload = makeCheckoutPayload(); - simulateProtocolMessageEvent(checkout, "ec.start", payload); + simulateProtocolMessageEvent(checkout, "ec.start", payload, { source: mockCheckoutWindow }); await listenForEvent; expect(checkout.checkout).toBe(payload.checkout); @@ -679,12 +613,14 @@ describe("", () => { describe("ec:complete", () => { it("updates the checkout property and dispatches an ec:complete event", async () => { - const checkout = renderCheckout({ target: "inline" }); + const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onCompleteSpy = vi.fn(); const listenForEvent = waitForEvent(checkout, "ec:complete", onCompleteSpy); const payload = makeCheckoutPayload(); - simulateProtocolMessageEvent(checkout, "ec.complete", payload); + simulateProtocolMessageEvent(checkout, "ec.complete", payload, { + source: mockCheckoutWindow, + }); await listenForEvent; expect(checkout.checkout).toBe(payload.checkout); @@ -694,12 +630,14 @@ describe("", () => { describe("ec:error", () => { it("updates the error property and dispatches an ec:error event", async () => { - const checkout = renderCheckout({ target: "inline" }); + const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onErrorSpy = vi.fn(); const listenForEvent = waitForEvent(checkout, "ec:error", onErrorSpy); const errorPayload = makeErrorPayload(); - simulateProtocolMessageEvent(checkout, "ec.error", errorPayload); + simulateProtocolMessageEvent(checkout, "ec.error", errorPayload, { + source: mockCheckoutWindow, + }); await listenForEvent; expect(checkout.error).toStrictEqual(errorPayload); @@ -709,12 +647,14 @@ describe("", () => { describe("ec:lineItemsChange", () => { it("updates the checkout property and dispatches an ec:lineItemsChange event", async () => { - const checkout = renderCheckout({ target: "inline" }); + const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onLineItemsChangeSpy = vi.fn(); const listenForEvent = waitForEvent(checkout, "ec:lineItemsChange", onLineItemsChangeSpy); const payload = makeCheckoutPayload(); - simulateProtocolMessageEvent(checkout, "ec.line_items.change", payload); + simulateProtocolMessageEvent(checkout, "ec.line_items.change", payload, { + source: mockCheckoutWindow, + }); await listenForEvent; expect(checkout.checkout).toBe(payload.checkout); @@ -724,12 +664,14 @@ describe("", () => { describe("ec:buyerChange", () => { it("updates the checkout property and dispatches an ec:buyerChange event", async () => { - const checkout = renderCheckout({ target: "inline" }); + const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onBuyerChangeSpy = vi.fn(); const listenForEvent = waitForEvent(checkout, "ec:buyerChange", onBuyerChangeSpy); const payload = makeCheckoutPayload(); - simulateProtocolMessageEvent(checkout, "ec.buyer.change", payload); + simulateProtocolMessageEvent(checkout, "ec.buyer.change", payload, { + source: mockCheckoutWindow, + }); await listenForEvent; expect(checkout.checkout).toBe(payload.checkout); @@ -739,12 +681,14 @@ describe("", () => { describe("ec:totalsChange", () => { it("updates the checkout property and dispatches an ec:totalsChange event", async () => { - const checkout = renderCheckout({ target: "inline" }); + const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onTotalsChangeSpy = vi.fn(); const listenForEvent = waitForEvent(checkout, "ec:totalsChange", onTotalsChangeSpy); const payload = makeCheckoutPayload(); - simulateProtocolMessageEvent(checkout, "ec.totals.change", payload); + simulateProtocolMessageEvent(checkout, "ec.totals.change", payload, { + source: mockCheckoutWindow, + }); await listenForEvent; expect(checkout.checkout).toBe(payload.checkout); @@ -754,12 +698,14 @@ describe("", () => { describe("ec:messagesChange", () => { it("updates the checkout property and dispatches an ec:messagesChange event", async () => { - const checkout = renderCheckout({ target: "inline" }); + const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onMessagesChangeSpy = vi.fn(); const listenForEvent = waitForEvent(checkout, "ec:messagesChange", onMessagesChangeSpy); const payload = makeCheckoutPayload(); - simulateProtocolMessageEvent(checkout, "ec.messages.change", payload); + simulateProtocolMessageEvent(checkout, "ec.messages.change", payload, { + source: mockCheckoutWindow, + }); await listenForEvent; expect(checkout.checkout).toBe(payload.checkout); @@ -976,14 +922,6 @@ describe("", () => { const url = new URL(expectWindowOpenArgs(windowOpenSpy)[0] as string); expect(url.searchParams.get("ec_delegate")).toBe("window.open"); }); - - it("declares window.open delegation in inline iframe URL", () => { - const checkout = renderCheckout({ target: "inline" }); - - const iframe = checkout.shadowRoot!.querySelector("#checkout-iframe") as HTMLIFrameElement; - const url = new URL(iframe.src); - expect(url.searchParams.get("ec_delegate")).toBe("window.open"); - }); }); describe("debug attribute", () => { @@ -1039,27 +977,15 @@ describe("", () => { describe("lifecycle", () => { it("preserves the shadow tree across element moves", () => { - const checkout = renderCheckout({ target: "inline" }); + const checkout = renderCheckout(); const wrapper = checkout.shadowRoot!.querySelector("#shopify-element-wrapper"); - const iframe = checkout.shadowRoot!.querySelector("#checkout-iframe"); const newParent = document.createElement("div"); document.body.appendChild(newParent); newParent.appendChild(checkout); expect(checkout.shadowRoot!.querySelector("#shopify-element-wrapper")).toBe(wrapper); - expect(checkout.shadowRoot!.querySelector("#checkout-iframe")).toBe(iframe); - }); - - it("does not add a duplicate iframe on reconnect of an inline element", () => { - const checkout = renderCheckout({ target: "inline" }); - expect(checkout.shadowRoot!.querySelectorAll("#checkout-iframe")).toHaveLength(1); - - const newParent = document.createElement("div"); - document.body.appendChild(newParent); - newParent.appendChild(checkout); - - expect(checkout.shadowRoot!.querySelectorAll("#checkout-iframe")).toHaveLength(1); + expect(checkout.shadowRoot!.querySelector("iframe")).toBeNull(); }); it("drops protocol messages while the element is disconnected", async () => { @@ -1143,10 +1069,9 @@ describe("", () => { * `origin` are derived from `checkout` so that the component's strict * source-and-origin validation passes: * - * - `source`: the inline iframe's `contentWindow` if one exists, - * otherwise `null`. Tests that exercise popup/new-tab targets must - * pass `source` explicitly (typically the same mock window returned - * from `window.open`). + * - `source`: pass the checkout browsing context (the mock window returned + * from `window.open` after `open()`, or another `MessageEventSource` to + * test drops). When omitted, defaults to `null` (messages are dropped). * - `origin`: the origin of `checkout.src`. Override `origin` to test * that messages from foreign origins are dropped. */ @@ -1160,8 +1085,7 @@ function simulateProtocolMessageEvent("#checkout-iframe"); - const source = options?.source === undefined ? (iframe?.contentWindow ?? null) : options.source; + const source = options?.source !== undefined ? options.source : null; let origin = options?.origin; if (origin === undefined) { @@ -1243,9 +1167,8 @@ function createMockWindow() { /** * Sets up a popup-target checkout whose `#checkoutWindow` is a controllable * mock window. Tests that exercise the strict source-and-origin validation - * in `#handleMessage` should use this rather than inline target, because - * jsdom does not create a browsing context for iframes inside shadow DOM - * (so an inline iframe's `contentWindow` is always `null`). + * in `#handleMessage` should use this helper and pass `mockCheckoutWindow` + * as `source` in `simulateProtocolMessageEvent`. * * Callers receive the checkout, the mock window (use as both `source` for * `simulateProtocolMessageEvent` and the spy target for response diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index e48e8fd6..2a01a389 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -72,14 +72,13 @@ const SHADOW_TEMPLATE = createTemplate(html` `); /** - * An element that renders a Shopify Checkout. Checkout can be displayed either as a popup window (default) - * or embedded as an iframe by setting the `mode` attribute. To use, create a `shopify-checkout` element, - * set the `src` attribute to the checkout URL (typically retrieved from the `cart.checkoutUrl` field), - * and then call `open()`. + * 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 + * checkout URL (typically retrieved from the `cart.checkoutUrl` field), and then call `open()`. * * @attribute src - The URL of the checkout to load. * @attribute preload - Whether to preload critical assets and data - * @attribute target - Where the checkout is presented (auto, popup, new tab, or inline). + * @attribute target - Where the checkout is presented (auto, popup, new tab, or a named window). * * @event ec:start - Dispatched when the checkout has started * @event ec:complete - Dispatched when the checkout was successfully completed @@ -98,12 +97,6 @@ const SHADOW_TEMPLATE = createTemplate(html` * checkout.setAttribute("src", cart.checkoutUrl); * document.body.append(checkout); * checkout.open(); - * - * // Inline target - * const checkout = document.createElement("shopify-checkout"); - * checkout.setAttribute("src", cart.checkoutUrl); - * checkout.setAttribute("target", "inline"); - * document.body.append(checkout); * ``` */ export class ShopifyCheckout @@ -262,10 +255,6 @@ export class ShopifyCheckout return this.#error; } - get #iframeElement(): HTMLIFrameElement | undefined { - return this.shadowRoot?.querySelector("#checkout-iframe") ?? undefined; - } - get #dialogElement(): HTMLDialogElement | undefined { return this.shadowRoot?.querySelector("#overlay") ?? undefined; } @@ -298,11 +287,6 @@ export class ShopifyCheckout const { target } = this; const src = this.#srcAsURL()?.href; - // Inline targets render an iframe directly in the DOM when the element connects or target changes, - // so no explicit open() call is needed. The close() method also has no effect on - // inline targets since iframes don't respond to iframe.contentWindow.close(). - if (target === "inline") return; - if (!src) { // eslint-disable-next-line no-console console.warn("``: src property is empty or invalid, cannot open checkout"); @@ -433,15 +417,6 @@ export class ShopifyCheckout this.#checkoutWindow?.focus(); } - #updateIframeSrc() { - const src = this.#srcAsURL()?.href; - const iframeElement = this.#iframeElement; - - if (src && iframeElement && iframeElement.src !== src) { - iframeElement.src = src; - } - } - /** * Sets the overlay link href to the validated, parametrised checkout * URL (matching what the popup would open) @@ -541,30 +516,13 @@ export class ShopifyCheckout return features; } - #addIframe() { - const iframeEl = document.createElement("iframe"); - iframeEl.id = "checkout-iframe"; - iframeEl.title = "Checkout"; - iframeEl.setAttribute( - "allow", - "publickey-credentials-get https://pay.shopify.com https://shop.app; geolocation", - ); - iframeEl.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups"); - iframeEl.src = this.#srcAsURL()?.href ?? ""; - - this.#targetElement?.appendChild(iframeEl); - this.#checkoutWindow = iframeEl.contentWindow ?? null; - } - /* ------------------------------------------------------------ * Events * ------------------------------------------------------------ */ /** - * Determines if a protocol message should dispatch a respondable event. - * Only inline targets can respond to messages, and the message must have - * an ID (requests) rather than being a notification. + * JSON-RPC request messages carry an `id`; notifications do not. */ #isRespondableRequest( message: CheckoutProtocolMessage, @@ -699,14 +657,6 @@ export class ShopifyCheckout this.#applyTargetClass(); this.#updateOverlayLink(); - if (this.target === "inline") { - if (this.#iframeElement) { - this.#checkoutWindow = this.#iframeElement.contentWindow ?? null; - } else { - this.#addIframe(); - } - } - this.#initCheckoutProtocol(); } @@ -728,18 +678,10 @@ export class ShopifyCheckout this.#updatePreloadLink(); break; case "src": - this.#updateIframeSrc(); this.#updatePreloadLink(); this.#updateOverlayLink(); break; case "target": { - if (oldValue === "inline" && newValue !== "inline") { - this.#iframeElement?.remove(); - this.#checkoutWindow = null; - } else if (newValue === "inline") { - this.#addIframe(); - } - if (oldValue !== newValue && this.#currentOpen) { this.close(); } diff --git a/platforms/web/src/checkout.types.ts b/platforms/web/src/checkout.types.ts index 809595c9..940996d7 100644 --- a/platforms/web/src/checkout.types.ts +++ b/platforms/web/src/checkout.types.ts @@ -44,7 +44,7 @@ import type { Checkout, EcReadyParams, ShopCash, UcpErrorResponse } from "./ucp- // Documentation-safe types: -export type CheckoutTarget = "auto" | "popup" | "inline" | "_blank"; +export type CheckoutTarget = "auto" | "popup" | "_blank"; export interface CheckoutAttributes { src?: string; @@ -89,7 +89,6 @@ export interface CheckoutProperties { /** * The mode in which to display the checkout when opened. Defaults to `'auto'`. * - `'popup'`: Opens checkout in a popup window - * - `'inline'`: Embeds checkout in an iframe within the component * - `'_blank' | `'auto'`: Opens checkout in a new tab (default) * - `string`: Opens checkout in a new named window * diff --git a/platforms/web/vite.config.ts b/platforms/web/vite.config.ts index 43ea93e3..1ca1a12d 100644 --- a/platforms/web/vite.config.ts +++ b/platforms/web/vite.config.ts @@ -42,7 +42,7 @@ export default defineConfig({ passWithNoTests: true, environmentOptions: { happyDOM: { - // Prevent iframes from actually fetching their src in unit tests. + // Prevent checkout URLs from being fetched in unit tests. settings: { disableIframePageLoading: true, disableErrorCapturing: true,