From c292a968ea22cba8984a3ed49840f80ea41ee720 Mon Sep 17 00:00:00 2001 From: Mark Murray Date: Wed, 16 Sep 2026 19:00:17 +0100 Subject: [PATCH] Add internal support for popup form POST navigation --- platforms/web/src/checkout-post.test.ts | 219 ++++++++++++++++++++++++ platforms/web/src/checkout-window.ts | 58 +++++++ platforms/web/src/checkout.ts | 7 +- 3 files changed, 281 insertions(+), 3 deletions(-) create mode 100644 platforms/web/src/checkout-post.test.ts create mode 100644 platforms/web/src/checkout-window.ts diff --git a/platforms/web/src/checkout-post.test.ts b/platforms/web/src/checkout-post.test.ts new file mode 100644 index 000000000..d81ca5bd4 --- /dev/null +++ b/platforms/web/src/checkout-post.test.ts @@ -0,0 +1,219 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { openCheckoutWindow } from "./checkout-window"; + +describe("internal POST form navigation", () => { + const src = "https://checkout.example.com/start?keep=1"; + const features = "popup,width=600,height=600"; + let target: string; + let body: URLSearchParams; + let popup: Window; + + function open() { + return openCheckoutWindow(src, target, { features, body }); + } + + beforeEach(() => { + popup = { + close: vi.fn(), + closed: false, + focus: vi.fn(), + postMessage: vi.fn(), + } as unknown as Window; + vi.spyOn(window, "open").mockReturnValue(popup); + vi.spyOn(HTMLFormElement.prototype, "submit").mockImplementation(() => {}); + + target = ""; + body = new URLSearchParams([ + ["context", "example+/=&<>"], + ["item", "one"], + ["item", "two"], + ]); + }); + + afterEach(() => { + document.body.replaceChildren(); + vi.restoreAllMocks(); + }); + + it("POSTs form values and repeated fields into the opened popup", () => { + vi.mocked(HTMLFormElement.prototype.submit).mockImplementation( + function (this: HTMLFormElement) { + expect(window.open).toHaveBeenCalledExactlyOnceWith( + "about:blank", + this.target, + expect.stringContaining("width="), + ); + expect(this.isConnected).toBe(true); + expect(this.hidden).toBe(true); + expect(this.method).toBe("post"); + expect(this.enctype).toBe("application/x-www-form-urlencoded"); + expect(this.acceptCharset).toBe("UTF-8"); + expect(Array.from(new FormData(this).entries())).toEqual([ + ["context", "example+/=&<>"], + ["item", "one"], + ["item", "two"], + ]); + + const url = new URL(this.action); + expect(url.origin).toBe("https://checkout.example.com"); + expect(url.searchParams.get("keep")).toBe("1"); + expect(url.searchParams.has("context")).toBe(false); + expect(url.searchParams.has("item")).toBe(false); + }, + ); + + open(); + + expect(HTMLFormElement.prototype.submit).toHaveBeenCalledOnce(); + expect(document.querySelector("form")).toBeNull(); + }); + + it.each(["auto", "_blank", "", "_self", "_PARENT", "_top", "_unfencedTop"])( + "uses a named destination for target=%s", + (value) => { + target = value; + vi.mocked(HTMLFormElement.prototype.submit).mockImplementation( + function (this: HTMLFormElement) { + expect(this.target).toMatch(/^checkout-/); + expect(vi.mocked(window.open).mock.calls[0]?.slice(0, 2)).toEqual([ + "about:blank", + this.target, + ]); + }, + ); + + open(); + + expect(window.open).toHaveBeenCalledOnce(); + expect(HTMLFormElement.prototype.submit).toHaveBeenCalledOnce(); + }, + ); + + it("preserves an explicit named window target", () => { + target = "merchant-checkout"; + vi.mocked(HTMLFormElement.prototype.submit).mockImplementation( + function (this: HTMLFormElement) { + expect(this.target).toBe("merchant-checkout"); + }, + ); + + open(); + + expect(window.open).toHaveBeenCalledExactlyOnceWith( + "about:blank", + "merchant-checkout", + features, + ); + expect(HTMLFormElement.prototype.submit).toHaveBeenCalledOnce(); + }); + + it("returns null without submitting when the popup is blocked", () => { + vi.mocked(window.open).mockReturnValue(null); + + expect(open()).toBeNull(); + + expect(window.open).toHaveBeenCalledOnce(); + expect(HTMLFormElement.prototype.submit).not.toHaveBeenCalled(); + expect(document.querySelector("form")).toBeNull(); + }); + + it("removes the form and closes the blank popup if submission throws", () => { + vi.mocked(HTMLFormElement.prototype.submit).mockImplementation(() => { + throw new Error("Submission failed"); + }); + + expect(() => open()).toThrow("Submission failed"); + + expect(popup.close).toHaveBeenCalledOnce(); + expect(document.querySelector("form")).toBeNull(); + }); + + it("returns the opened window for protocol communication, focus, and close", () => { + expect(open()).toBe(popup); + }); + + it("uses GET when no body is supplied", () => { + const result = openCheckoutWindow(src, "", { features }); + + expect(result).toBe(popup); + expect(window.open).toHaveBeenCalledExactlyOnceWith(src, "", features); + expect(HTMLFormElement.prototype.submit).not.toHaveBeenCalled(); + }); + + it("preserves GET navigation without popup features", () => { + openCheckoutWindow(src, "auto"); + + expect(window.open).toHaveBeenCalledExactlyOnceWith(src, "auto"); + expect(HTMLFormElement.prototype.submit).not.toHaveBeenCalled(); + }); + + it("opens POST navigation without popup features", () => { + openCheckoutWindow(src, "_blank", { body }); + + expect(window.open).toHaveBeenCalledExactlyOnceWith( + "about:blank", + expect.stringMatching(/^checkout-/), + ); + expect(HTMLFormElement.prototype.submit).toHaveBeenCalledOnce(); + }); + + it("allows an empty POST body", () => { + body = new URLSearchParams(); + vi.mocked(HTMLFormElement.prototype.submit).mockImplementation( + function (this: HTMLFormElement) { + expect(Array.from(new FormData(this).entries())).toEqual([]); + }, + ); + + open(); + + expect(HTMLFormElement.prototype.submit).toHaveBeenCalledOnce(); + }); + + it("supports field names that overlap with form properties", () => { + body = new URLSearchParams({ + submit: "submit-value", + append: "append-value", + remove: "remove-value", + action: "action-value", + }); + vi.mocked(HTMLFormElement.prototype.submit).mockImplementation( + function (this: HTMLFormElement) { + expect(Array.from(new FormData(this).entries())).toEqual([ + ["submit", "submit-value"], + ["append", "append-value"], + ["remove", "remove-value"], + ["action", "action-value"], + ]); + }, + ); + + open(); + + expect(HTMLFormElement.prototype.submit).toHaveBeenCalledOnce(); + expect(document.querySelector("form")).toBeNull(); + }); + + it("rejects empty field names before opening a window", () => { + body = new URLSearchParams([["", "value"]]); + + expect(() => open()).toThrow("POST form fields must have a name"); + expect(window.open).not.toHaveBeenCalled(); + }); + + it("preserves Unicode and a supplied _charset_ field", () => { + body = new URLSearchParams({ message: "café ☕", _charset_: "custom" }); + vi.mocked(HTMLFormElement.prototype.submit).mockImplementation( + function (this: HTMLFormElement) { + expect(Array.from(new FormData(this).entries())).toEqual([ + ["message", "café ☕"], + ["_charset_", "custom"], + ]); + }, + ); + + open(); + + expect(HTMLFormElement.prototype.submit).toHaveBeenCalledOnce(); + }); +}); diff --git a/platforms/web/src/checkout-window.ts b/platforms/web/src/checkout-window.ts new file mode 100644 index 000000000..f5c0fc282 --- /dev/null +++ b/platforms/web/src/checkout-window.ts @@ -0,0 +1,58 @@ +interface CheckoutWindowOptions { + features?: string; + body?: URLSearchParams; +} + +export function openCheckoutWindow( + src: string, + target: string, + { features, body }: CheckoutWindowOptions = {}, +): WindowProxy | null { + if (body === undefined) { + return features === undefined ? window.open(src, target) : window.open(src, target, features); + } + + if (body.has("")) { + throw new TypeError("POST form fields must have a name"); + } + + const windowName = + target && target !== "auto" && !target.startsWith("_") + ? target + : `checkout-${crypto.getRandomValues(new Uint32Array(4)).join("-")}`; + const checkoutWindow = + features === undefined + ? window.open("about:blank", windowName) + : window.open("about:blank", windowName, features); + + if (!checkoutWindow) return null; + + const form = document.createElement("form"); + form.method = "post"; + form.enctype = "application/x-www-form-urlencoded"; + form.acceptCharset = "UTF-8"; + form.action = src; + form.target = windowName; + form.hidden = true; + + const fields = document.createDocumentFragment(); + for (const [name, value] of body) { + const field = document.createElement("textarea"); + field.name = name; + field.value = value; + fields.append(field); + } + form.append(fields); + + try { + document.body.append(form); + HTMLFormElement.prototype.submit.call(form); + } catch (error) { + checkoutWindow.close(); + throw error; + } finally { + Element.prototype.remove.call(form); + } + + return checkoutWindow; +} diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 42be18c56..1b6f29a81 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -9,6 +9,7 @@ import { } from "@shopify/checkout-kit-protocol"; import stylesText from "./checkout.css?inline"; +import { openCheckoutWindow } from "./checkout-window"; import { Logger, coerceLogLevel } from "./logger"; import { createTelemetry, telemetryProtocolMethod, type CheckoutKitTelemetry } from "./telemetry"; import { createTemplate, html, safe } from "./utils"; @@ -440,7 +441,7 @@ export class ShopifyCheckout switch (target) { case "popup": { const features = this.#getPopupFeatures(); - checkoutWindow = window.open(src, "", features); + checkoutWindow = openCheckoutWindow(src, "", { features }); break; } @@ -451,9 +452,9 @@ export class ShopifyCheckout this.#logger.warn( `target="${target}" would navigate the current page; falling back to "auto"`, ); - checkoutWindow = window.open(src, "auto"); + checkoutWindow = openCheckoutWindow(src, "auto"); } else { - checkoutWindow = window.open(src, target); + checkoutWindow = openCheckoutWindow(src, target); } break; }