diff --git a/platforms/web/README.md b/platforms/web/README.md index 1c0c65555..e164ae242 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -506,7 +506,7 @@ are available in `event.detail`. | `start` | `{checkout}` | Checkout has loaded and is interactive. | | `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. | +| `error` | `{error}` | Checkout could not open or reported a terminal error, exposed as `{code, message}`. An open session 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. | `start`, `update`, and `complete` carry a Checkout Kit `Checkout` snapshot in @@ -548,6 +548,20 @@ checkout.addEventListener('close', () => { Protocol errors are terminal for the checkout session regardless of message severity. The component emits `error` before closing and emitting `close`. +Before opening checkout, the component verifies that the browser supports Shadow +DOM, native dialogs, and abortable event listeners. If a required capability is +missing, it dispatches an `error` event with `event.detail.error.code === +"unsupported_browser"` without opening a checkout window. Use this to offer a +normal checkout link or another fallback: + +```ts +checkout.addEventListener('error', (event) => { + if (event.detail.error.code === 'unsupported_browser') { + window.location.assign(checkout.src); + } +}); +``` + Because these events carry the full snapshot, one handler can combine fields. For example, rendering an inline cart summary on `start` requires line items, totals, and currency together: diff --git a/platforms/web/src/browser-capabilities.ts b/platforms/web/src/browser-capabilities.ts new file mode 100644 index 000000000..428279447 --- /dev/null +++ b/platforms/web/src/browser-capabilities.ts @@ -0,0 +1,50 @@ +export type UnsupportedBrowserCapability = + | "shadow_dom" + | "native_dialog" + | "abortable_event_listeners"; + +/** Returns capabilities Checkout Kit requires but the current browser does not provide. */ +export function getUnsupportedBrowserCapabilities(): UnsupportedBrowserCapability[] { + const unsupported: UnsupportedBrowserCapability[] = []; + + if (!supportsShadowDOM()) unsupported.push("shadow_dom"); + if (!supportsNativeDialog()) unsupported.push("native_dialog"); + if (!supportsAbortableEventListeners()) unsupported.push("abortable_event_listeners"); + + return unsupported; +} + +/** Whether custom elements can attach a shadow root. */ +export function supportsShadowDOM(): boolean { + return typeof HTMLElement.prototype.attachShadow === "function"; +} + +function supportsNativeDialog(): boolean { + return ( + typeof HTMLDialogElement !== "undefined" && + typeof HTMLDialogElement.prototype.showModal === "function" + ); +} + +function supportsAbortableEventListeners(): boolean { + if (typeof AbortController === "undefined") return false; + + const controller = new AbortController(); + const target = document.createElement("div"); + let listenerCalled = false; + + try { + target.addEventListener( + "checkout-kit-capability-test", + () => { + listenerCalled = true; + }, + { signal: controller.signal }, + ); + controller.abort(); + target.dispatchEvent(new Event("checkout-kit-capability-test")); + return !listenerCalled; + } catch { + return false; + } +} diff --git a/platforms/web/src/checkout.test.ts b/platforms/web/src/checkout.test.ts index fe905896e..e169cc160 100644 --- a/platforms/web/src/checkout.test.ts +++ b/platforms/web/src/checkout.test.ts @@ -28,6 +28,7 @@ function expectWindowOpenArgs(spy: { describe("", () => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); installTestTelemetryFactory(); // Disconnect elements so their global message listeners do not leak // into tests in this file or another concurrently running suite. @@ -174,6 +175,28 @@ describe("", () => { }); }); + describe("browser capabilities", () => { + it("dispatches an unsupported_browser error without opening checkout", () => { + const checkout = renderCheckout(); + const onError = vi.fn(); + const openWindow = vi.spyOn(window, "open"); + vi.stubGlobal("HTMLDialogElement", undefined); + checkout.addEventListener("error", onError); + + checkout.open(); + + expect(checkout.error).toEqual({ + code: "unsupported_browser", + message: "This browser does not support: native_dialog.", + }); + expect(onError).toHaveBeenCalledOnce(); + expect((onError.mock.calls[0]![0] as CustomEvent).detail).toEqual({ + error: checkout.error, + }); + expect(openWindow).not.toHaveBeenCalled(); + }); + }); + describe("URL generation", () => { it("preserves existing query parameters when adding ec_* parameters", () => { const originalSrc = "https://example.com/checkout?existing=param&another=value"; diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 361f642bd..bfe076d6a 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -9,6 +9,7 @@ import { type Checkout as ProtocolCheckout, } from "@shopify/checkout-kit-protocol"; +import { getUnsupportedBrowserCapabilities, supportsShadowDOM } from "./browser-capabilities"; import { toCheckout, checkoutComparisonKey } from "./models/checkout"; import { toCheckoutError } from "./models/error"; import { @@ -184,7 +185,7 @@ const SHADOW_TEMPLATE = createTemplate(html` * @event {ShopifyCheckoutStartEvent} start - Checkout has started. * @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 {ShopifyCheckoutErrorEvent} error - Checkout could not open or reported a terminal error; an open session closes after this event. * @event {ShopifyCheckoutCloseEvent} close - The checkout session closed, including after a blocked window. * * @example @@ -206,7 +207,11 @@ export class ShopifyCheckout constructor() { super(); - this.attachShadow({ mode: "open" }).appendChild(SHADOW_TEMPLATE.content.cloneNode(true)); + // Keep the element usable enough to dispatch an unsupported-browser error when + // Shadow DOM is unavailable, rather than throwing during custom-element construction. + if (supportsShadowDOM()) { + this.attachShadow({ mode: "open" }).appendChild(SHADOW_TEMPLATE.content.cloneNode(true)); + } } #checkout?: Checkout; @@ -428,6 +433,17 @@ export class ShopifyCheckout * Reveals checkout in the target. */ open(): void { + const unsupportedCapabilities = getUnsupportedBrowserCapabilities(); + if (unsupportedCapabilities.length > 0) { + this.#checkout = undefined; + this.#error = { + code: "unsupported_browser", + message: `This browser does not support: ${unsupportedCapabilities.join(", ")}.`, + }; + this.dispatchEvent(new ShopifyCheckoutErrorEvent({ error: this.#error })); + return; + } + const { target } = this; const src = this.#srcAsURL({ warnInvalidAppearance: true })?.href; diff --git a/platforms/web/src/models/error.ts b/platforms/web/src/models/error.ts index fe428f541..81a28cfcd 100644 --- a/platforms/web/src/models/error.ts +++ b/platforms/web/src/models/error.ts @@ -2,6 +2,7 @@ import type { ErrorResponse } from "@shopify/checkout-kit-protocol"; /** Stable checkout error reasons applications can use to choose recovery. */ export type CheckoutErrorCode = + | "unsupported_browser" | "storefront_password_required" | "customer_account_required" | "cart_expired" diff --git a/platforms/web/test/e2e/tests/synthetic/presentation.spec.ts b/platforms/web/test/e2e/tests/synthetic/presentation.spec.ts index 04463ad67..6f0aa6687 100644 --- a/platforms/web/test/e2e/tests/synthetic/presentation.spec.ts +++ b/platforms/web/test/e2e/tests/synthetic/presentation.spec.ts @@ -38,6 +38,33 @@ test.describe("src reflection and popup URL", () => { }); test.describe("open()", () => { + test("dispatches unsupported_browser without opening a popup when native dialogs are unavailable", async ({ + host, + page, + context, + }) => { + await page.addInitScript(() => { + Object.defineProperty(HTMLDialogElement.prototype, "showModal", { + configurable: true, + value: undefined, + }); + }); + + await host.goto(); + await host.clickBuy(); + + await host.expectEvent("error"); + await expect + .poll(() => host.error()) + .toEqual({ + code: "unsupported_browser", + message: "This browser does not support: native_dialog.", + }); + await expect.poll(() => host.eventTypes()).toEqual(["error"]); + expect(context.pages()).toHaveLength(1); + await expect(host.overlay).not.toBeVisible(); + }); + for (const { name, src } of [ { name: "empty", src: "" }, { name: "non-HTTPS", src: "http://checkout.example.test/checkout" },