Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion platforms/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
50 changes: 50 additions & 0 deletions platforms/web/src/browser-capabilities.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
23 changes: 23 additions & 0 deletions platforms/web/src/checkout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function expectWindowOpenArgs(spy: {
describe("<shopify-checkout>", () => {
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.
Expand Down Expand Up @@ -174,6 +175,28 @@ describe("<shopify-checkout>", () => {
});
});

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";
Expand Down
20 changes: 18 additions & 2 deletions platforms/web/src/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions platforms/web/src/models/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 27 additions & 0 deletions platforms/web/test/e2e/tests/synthetic/presentation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Loading