Skip to content
Open
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
13 changes: 11 additions & 2 deletions platforms/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,13 @@ Where the checkout is presented. Defaults to `"auto"`.
> the host page away. The component falls back to `"auto"` if you set one,
> and logs a warning at `log-level="warn"` or more verbose.

> [!NOTE]
> If the browser refuses to open the window (for example, a popup blocker, or
> `open()` called outside a user gesture), the [overlay scrim](#overlay-scrim)
> says so and offers a button to try again. Closing it dispatches `close`.
> If the overlay is hidden, nothing is shown. The component logs a warning at
> `log-level="warn"` or more verbose.

### `appearance`

Sets the checkout appearance preference. Defaults to `"storefront"`.
Expand Down Expand Up @@ -469,7 +476,9 @@ shopify-checkout {

While a popup is open the component renders a `<dialog>` scrim over the host
page, with a "Continue your purchase in the checkout window" link and a close
button. Hide it by either:
button. If the browser blocks the window, the scrim instead says "Your browser
blocked the checkout window." with an "Open checkout" button that tries again.
Hide it by either:

- Setting `display: none` on the element itself, or
- Targeting the `overlay` shadow part:
Expand All @@ -495,7 +504,7 @@ are available in `event.detail`.
| `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. |
| `close` | _(none)_ | The open session ended through `close()`, overlay dismissal, or detection of a popup the buyer closed. |
| `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
`event.detail.checkout`. It preserves checkout data, including unknown
Expand Down
199 changes: 197 additions & 2 deletions platforms/web/src/checkout-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ describe("<shopify-checkout>", () => {
expect(closeEventSpy).toHaveBeenCalledTimes(1);
});

it("closes the blocked overlay when the target attribute changes", () => {
const checkout = renderCheckout({ target: "popup" });
vi.spyOn(window, "open").mockReturnValue(null);

const closeEventSpy = vi.fn();
checkout.addEventListener("close", closeEventSpy);

checkout.open();
expect(closeEventSpy).not.toHaveBeenCalled();

checkout.setAttribute("target", "auto");

expect(closeEventSpy).toHaveBeenCalledTimes(1);
});

it("is a no-op when the target attribute is set to the same value", () => {
const checkout = renderCheckout({ target: "popup" });
const wrapper = checkout.shadowRoot!.querySelector(".Shopify-target")!;
Expand Down Expand Up @@ -254,13 +269,145 @@ describe("<shopify-checkout>", () => {
category: "navigation",
stage: "presentation",
code: "blocked",
retryable: false,
retryable: true,
isRetry: false,
});
// Should not throw error when popup is blocked
});
});

it("shows the blocked overlay when the popup is blocked", () => {
POPUP_TARGETS.forEach((target) => {
const checkout = renderCheckout({ target, "log-level": "warn" });
vi.spyOn(window, "open").mockReturnValue(null);
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const dialogShowModalSpy = vi
.spyOn(HTMLDialogElement.prototype, "showModal")
.mockImplementation(() => {});

checkout.open();

const dialog = checkout.shadowRoot!.querySelector<HTMLDialogElement>("#overlay")!;
expect(dialogShowModalSpy).toHaveBeenCalledTimes(1);
expect(dialog.dataset.state).toBe("blocked");
expect(consoleWarnSpy).toHaveBeenCalledWith(
"<shopify-checkout>: checkout window could not be opened; the browser may have blocked it",
);
});
});

it("opens checkout when the blocked overlay's retry button is clicked", () => {
POPUP_TARGETS.forEach((target) => {
const checkout = renderCheckout({ target });
const windowOpenSpy = vi
.spyOn(window, "open")
.mockReturnValueOnce(null)
.mockReturnValueOnce(createMockWindow());
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {});
const closeEventSpy = vi.fn();
checkout.addEventListener("close", closeEventSpy);

checkout.open();
checkout.shadowRoot!.querySelector<HTMLButtonElement>("#overlay-retry-button")!.click();

const dialog = checkout.shadowRoot!.querySelector<HTMLDialogElement>("#overlay")!;
expect(windowOpenSpy).toHaveBeenCalledTimes(2);
expect(dialog.dataset.state).toBeUndefined();
expect(closeEventSpy).not.toHaveBeenCalled();
});
});

it("records a retry when the popup is blocked again from the blocked overlay", () => {
POPUP_TARGETS.forEach((target) => {
const telemetrySpy = vi.spyOn(mockTelemetry(), "recordError");
const checkout = renderCheckout({ target });
vi.spyOn(window, "open").mockReturnValue(null);

checkout.open();
checkout.open();

expect(telemetrySpy).toHaveBeenLastCalledWith({
category: "navigation",
stage: "presentation",
code: "blocked",
retryable: true,
isRetry: true,
});
});
});

it("ignores the previous dialog's late close event after a retry opens checkout", () => {
POPUP_TARGETS.forEach((target) => {
const checkout = renderCheckout({ target });
const mockWindow = createMockWindow();
vi.spyOn(window, "open").mockReturnValueOnce(null).mockReturnValueOnce(mockWindow);
const closeEventSpy = vi.fn();
checkout.addEventListener("close", closeEventSpy);

checkout.open();
checkout.shadowRoot!.querySelector<HTMLButtonElement>("#overlay-retry-button")!.click();

// Browsers queue the dialog's `close` event, so it lands after the dialog is re-shown
const dialog = checkout.shadowRoot!.querySelector<HTMLDialogElement>("#overlay")!;
expect(dialog.open).toBe(true);
dialog.dispatchEvent(new Event("close"));

expect(mockWindow.close).not.toHaveBeenCalled();
expect(closeEventSpy).not.toHaveBeenCalled();
});
});

it("ignores the previous dialog's late close event when a retry is blocked again", () => {
POPUP_TARGETS.forEach((target) => {
const checkout = renderCheckout({ target });
vi.spyOn(window, "open").mockReturnValue(null);
const closeEventSpy = vi.fn();
checkout.addEventListener("close", closeEventSpy);

checkout.open();
checkout.shadowRoot!.querySelector<HTMLButtonElement>("#overlay-retry-button")!.click();

// Browsers queue the dialog's `close` event, so it lands after the dialog is re-shown
const dialog = checkout.shadowRoot!.querySelector<HTMLDialogElement>("#overlay")!;
expect(dialog.open).toBe(true);
dialog.dispatchEvent(new Event("close"));

expect(dialog.dataset.state).toBe("blocked");
expect(closeEventSpy).not.toHaveBeenCalled();
});
});

it("does not dispatch close when open() is called while the blocked overlay is showing", () => {
POPUP_TARGETS.forEach((target) => {
const checkout = renderCheckout({ target });
vi.spyOn(window, "open").mockReturnValue(null);
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {});
const closeEventSpy = vi.fn();
checkout.addEventListener("close", closeEventSpy);

checkout.open();
checkout.open();

expect(closeEventSpy).not.toHaveBeenCalled();
});
});

it("dispatches close when the blocked overlay is dismissed", () => {
POPUP_TARGETS.forEach((target) => {
const checkout = renderCheckout({ target });
vi.spyOn(window, "open").mockReturnValue(null);
const closeEventSpy = vi.fn();
checkout.addEventListener("close", closeEventSpy);

checkout.open();
checkout
.shadowRoot!.querySelector<HTMLButtonElement>("#overlay-blocked-close-button")!
.click();

expect(closeEventSpy).toHaveBeenCalledTimes(1);
});
});

it("enforces maximum window size constraints", () => {
POPUP_TARGETS.forEach((target) => {
const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(createMockWindow());
Expand Down Expand Up @@ -296,7 +443,7 @@ describe("<shopify-checkout>", () => {
checkout.open();

const dialog = checkout.shadowRoot!.querySelector("dialog") as HTMLDialogElement;
dialog.dispatchEvent(new Event("close"));
dialog.close();

expect(mockPopup.close).toHaveBeenCalled();
expect(closeEventSpy).toHaveBeenCalled();
Expand Down Expand Up @@ -562,6 +709,40 @@ describe("<shopify-checkout>", () => {
});
});

it("dispatches close event when the blocked overlay is showing", () => {
POPUP_TARGETS.forEach((target) => {
const checkout = renderCheckout({ target });
vi.spyOn(window, "open").mockReturnValue(null);

const closeEventSpy = vi.fn();
checkout.addEventListener("close", closeEventSpy);
checkout.open();
checkout.close();

expect(closeEventSpy).toHaveBeenCalledTimes(1);
});
});

it("dispatches close event when the popup was blocked and the overlay is hidden", () => {
POPUP_TARGETS.forEach((target) => {
const checkout = renderCheckout({ target });
vi.spyOn(window, "open").mockReturnValue(null);
vi.spyOn(window, "getComputedStyle").mockReturnValue({
getPropertyValue: (prop: string) => {
if (prop === "display") return "none";
return "";
},
} as CSSStyleDeclaration);

const closeEventSpy = vi.fn();
checkout.addEventListener("close", closeEventSpy);
checkout.open();
checkout.close();

expect(closeEventSpy).toHaveBeenCalledTimes(1);
});
});

it("closes the checkout scrim dialog", async () => {
POPUP_TARGETS.forEach((target) => {
const checkout = renderCheckout({ target });
Expand All @@ -586,6 +767,20 @@ describe("<shopify-checkout>", () => {
expect(dialogCloseSpy).toHaveBeenCalled();
});
});

it("does not close a session opened from a close listener", () => {
POPUP_TARGETS.forEach((target) => {
const checkout = renderCheckout({ target });
const mockWindow = createMockWindow();
vi.spyOn(window, "open").mockReturnValueOnce(null).mockReturnValue(mockWindow);

checkout.addEventListener("close", () => checkout.open(), { once: true });
checkout.open();
checkout.close();

expect(mockWindow.close).not.toHaveBeenCalled();
});
});
});
});
});
Expand Down
5 changes: 5 additions & 0 deletions platforms/web/src/checkout.css
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@
}
}

.overlay[data-state="blocked"] slot[name="overlay"],
.overlay:not([data-state="blocked"]) slot[name="overlay-blocked"] {
display: none;
}

.overlay-content-wrapper {
display: grid;
grid-template-rows: 1fr 20%;
Expand Down
Loading
Loading