Skip to content
Draft
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
51 changes: 46 additions & 5 deletions platforms/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ Check out our blog to
- [Popup dimensions](#popup-dimensions)
- [Overlay scrim](#overlay-scrim)
- [Checkout lifecycle](#checkout-lifecycle)
- [Universal Checkout (experimental preview)](#universal-checkout-experimental-preview)
- [Explore the sample app](#explore-the-sample-app)
- [Contributing](#contributing)
- [License](#license)
Expand Down Expand Up @@ -558,19 +559,59 @@ These properties are useful for handlers that don't have a reference to the
originating event. TypeScript users get fully typed events through overloaded
`addEventListener` signatures with no additional setup.

## Universal Checkout (experimental preview)

The repository contains a separate Universal Checkout entry point. Importing
`@shopify/checkout-kit/universal` registers
`<shopify-universal-checkout>`; importing the standard entry point above does
not register it. This experimental API may change and has not been
released as a supported public API.
Pass it a keyed Universal Checkout `continue_url` produced from the
participating shops' cart IDs, then call `open()` from a buyer click. The buyer
needs a Shop Pay session to continue through the checkout; session creation and
buyer authentication are separate steps.

```ts
import '@shopify/checkout-kit/universal';
import type {ShopifyUniversalCheckout} from '@shopify/checkout-kit/universal';

const checkout = document.querySelector<ShopifyUniversalCheckout>(
'shopify-universal-checkout',
)!;

checkout.addEventListener('update', (event) => {
// Each entry is a full snapshot for one changed shop, not a partial patch.
for (const {context, checkout: snapshot} of event.detail) {
renderShopStatus(context.shopId, snapshot.status);
}
renderSession(checkout.checkout); // Aggregate already contains the whole batch.
});
```

The Universal element emits `start`, `update`, `complete`, `error`, and
`close`. Resource event details are arrays of `{context, checkout}` entries;
`context` has `sessionId`, `revision`, and `shopId`, while `status` lives on the
checkout snapshot. `checkout.checkout` retains the aggregate of observed
resources, including shops omitted from a changed-shop update. `close` reports
presentation dismissal independently of a checkout outcome and carries
`detail: null`.

## Explore the sample app

See the [`sample/`](./sample) directory for a small Vite playground that mounts
the real `<shopify-checkout>` element next to a faux storefront. Run it from
this directory with:
See the [sample guide](./sample/README.md) for the two-page Vite playground.
The default page uses `<shopify-checkout>` with a single-shop cart builder.
The experimental Universal page selects multiple shops, prepares a session,
opens it with `<shopify-universal-checkout>`, and shows received events in a
redacted overlay. From `platforms/web`, run:

```sh
pnpm install
pnpm sample
```

Then open the dev server URL and paste a valid checkout URL into the `src`
field to try `open()` / `close()` / `focus()` and see the live event stream.
Open the local URLs printed by Vite. The sample guide describes the Universal
page's required shops, environment configuration, session preparation, and
validation limits.

## Contributing

Expand Down
182 changes: 182 additions & 0 deletions platforms/web/documentation/universal-checkout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
# Universal Checkout in Checkout Kit Web

> [!IMPORTANT]
> The Universal Checkout Web entry point is an experimental preview. Its API
> and sample are under development and are not a supported public release.

This guide describes the Web element's observable behavior and the current
integration assumptions. The [sample guide](../sample/README.md) covers the
local storefront-to-session flow. A live multi-shop buyer run is still required
before release.

## Open a session

The host creates a Universal Checkout session and passes its keyed
`continue_url` to Checkout Kit. The SDK presents that URL and handles the
embedded protocol; it does not create carts, create sessions, or accept cart
permalinks as a replacement for the session URL. Call `open()` directly from a
buyer gesture so browsers can open the popup.

Import the Universal entry point to register `<shopify-universal-checkout>`.
Importing the standard entry point alone registers only `<shopify-checkout>`.
The two elements have separate event contracts.

```html
<shopify-universal-checkout id="universal" target="popup"></shopify-universal-checkout>
<button id="continue" type="button">Continue to checkout</button>
```

```ts
import '@shopify/checkout-kit/universal';
import type {ShopifyUniversalCheckout} from '@shopify/checkout-kit/universal';

const checkout = document.querySelector<ShopifyUniversalCheckout>('#universal')!;
const listeners = new AbortController();

checkout.addEventListener('start', (event) => {
// A batch can contain more than one shop. The aggregate is already committed.
for (const {context, checkout: snapshot} of event.detail) {
renderShopStatus(context.shopId, snapshot.status);
}
renderSession(checkout.checkout);
}, {signal: listeners.signal});

checkout.addEventListener('update', (event) => {
for (const {context, checkout: snapshot} of event.detail) {
renderShopStatus(context.shopId, snapshot.status);
}
renderSession(checkout.checkout);
}, {signal: listeners.signal});

checkout.addEventListener('complete', (event) => {
for (const {context} of event.detail) markShopCompleted(context.shopId);
}, {signal: listeners.signal});

checkout.addEventListener('error', (event) => {
for (const {scope, context, error} of event.detail) {
showFailure(scope, context?.shopId, error.code);
}
}, {signal: listeners.signal});

checkout.addEventListener('close', () => showCheckoutClosed(), {
signal: listeners.signal,
});

document.querySelector('#continue')!.addEventListener('click', () => {
checkout.src = authenticatedContinueUrl;
checkout.open();
});

// Call listeners.abort() when the host view is removed.
```

The functions and `authenticatedContinueUrl` above are application-owned
placeholders. The URL can grant access to checkout: do not log, persist, or
place it in event diagnostics. The expected URL has a `/checkouts/uc/` path;
the SDK warns at the `warn` log level about other paths.

### Session creation and buyer authentication

Create an independent Storefront cart for each participating shop. A trusted
server then creates a Universal Checkout session from the cart IDs and returns
a keyed continuation URL. The local sample includes an adapter for this
preparation flow; see the [sample guide](../sample/README.md). Keep the
continuation URL private. Configure session creation endpoints on the server;
the sample exposes only the expected continuation hostname to browser code for
destination validation.

Creating a session and authenticating the buyer are separate steps. The buyer
must be signed in to the corresponding Shop Pay environment to continue through
checkout. The sample does not provide a general-purpose session creation API.

## Preview lifecycle contract

Universal event names are `start`, `update`, `complete`, `error`, and `close`.
`ec.ready` and delegated window requests are handled internally; hosts do not
subscribe to them. Granular `ec.*.change` notifications are not part of this
Universal surface.

| Event | `event.detail` | Meaning |
| --- | --- | --- |
| `start` | `readonly {context, checkout}[]` | Initial full snapshots for the child checkouts that became interactive. |
| `update` | `readonly {context, checkout}[]` | Full replacement snapshots for the shops whose public state changed. Other shops remain in `element.checkout`. A completed shop can first appear here. |
| `complete` | `readonly {context, checkout}[]` | Explicit terminal session notification, even when a final snapshot equals an earlier update. |
| `error` | `readonly {scope, context?, error}[]` | A resource failure or a Kit-local presentation failure. Producer session failures remain provisional. |
| `close` | `null` | The presentation closed. Closing alone says nothing about purchase success or failure. |

Each resource `context` contains `sessionId`, `revision`, and `shopId`.
`checkout` is a full immutable child snapshot with `status` on the checkout
itself; there is no `context.status`. Kit removes the top-level `ucp` transport
metadata and maps an unrecognized checkout status to `unknown`. Event details
contain accepted entries from a contiguous same-method, same-revision group,
so do not assume one event contains every shop in the session. Read
`element.checkout` for the current immutable aggregate `{sessionId, revision,
resources}`. It is committed for the entire accepted wire batch before the
first public listener runs. `element.error` holds the accumulated resource or
session failures separately.

An `error` entry has `scope: 'resource' | 'session'`, a Kit-owned error code and
message, and optional context for a local SDK error. Session-scoped producer
errors have not yet been verified; Kit-local presentation errors can use this
scope. Error events are delivered directly to the element and do not bubble
to global error handlers. A resource error does not by itself close or fail the other shops. Do
not infer a session failure merely from one failed resource. `complete` and
`error` are outcomes; `close` is a separate presentation event. A custom
`slot="overlay"` replaces the built-in
overlay contents, including its focus and close controls, so the host must
provide usable controls when it supplies that slot.

## Expected producer exchange for this preview

The integration fixtures cover the expected exchange below. It reflects
source inspection and synthetic tests, not a completed live buyer run. The
producer contract may change before a supported release.

1. Checkout sends one `ec.ready` request per shop in a batch. Kit replies with
one response per request. The handshake remains private to Kit.
2. An `ec.start` batch introduces full child checkout snapshots with
`{context: {session_id, revision, shop_id}, checkout}` members. A child
without an available snapshot may be omitted at startup.
3. Later `ec.update` batches contain full replacement snapshots for changed
shops. A shop can become `completed` in an update while other shops remain
active. Repeated equal snapshots need not produce another update.
4. `ec.complete` explicitly marks session completion and may repeat a snapshot
already delivered by an update. Hosts should handle it as an outcome event,
even if no visible checkout field changed.
5. Kit treats optional `undefined` fields as absent after structured-clone
delivery. Required undefined fields and malformed nested objects are still
rejected. A malformed member is dropped without discarding valid siblings;
Kit logs a safe reason and records decode telemetry for each dropped member.

Resource errors, missing initial snapshots, retry or replacement, and ejection
still need final producer agreement or live validation. Kit's error parser has
synthetic coverage, but real producer error outcomes remain unverified. Kit
removes child transport version metadata from public checkout snapshots.

## Conformance evidence and remaining gates

The Web unit suite covers batched ready replies, two-shop start, changed-shop
updates, A → B → A ordering, partial completion, explicit completion,
malformed-member isolation and diagnostics, structured-cloned `undefined`,
scoped synthetic errors, source/origin isolation, close/reopen, and immutable
state. A sample integration fixture sends trusted batches through the
registered element and checks the slotted overlay DOM. These tests use a mocked popup;
they do not prove cart/session creation or real event delivery in a browser.

Before treating the sample as complete, record the Kit and producer versions,
environment, and results of an approved two-shop browser run: domains → product
selection → separate carts → session URL → Kit open → overlay events. Exercise
partial and full completion separately, and use approved test payment flows.
Keep keyed URLs out of logs, screenshots, and reports.

| Case | Required evidence or decision |
| --- | --- |
| Resource and session errors | Agree the producer wire shape and validate real errors through Kit. |
| Child failure before its first snapshot | Agree startup and late-arrival behavior, then verify healthy shops can proceed without fabricated checkout data. |
| Retry, replacement, and resource ejection | Settle the resource lifecycle contract and add source-pinned fixtures plus browser coverage. |
| Reopen and replay | Confirm behavior after completion and test fresh presentation state without duplicate callbacks. |
| Full sample flow | Record the two-shop browser path; fixtures alone do not satisfy this gate. |
| Release readiness | Finish the wider rollout, observability, documentation, and live validation checks before supporting this API publicly. |

Keep this guide and the conformance tests synchronized with the final producer
contract.
138 changes: 138 additions & 0 deletions platforms/web/sample/universal/overlay.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import batches from "../../src/__fixtures__/provisional-universal-checkout-batches.json";
import "../../src/universal";
import { mountUniversalOverlay, type UniversalOverlay } from "./overlay";
import { isContinuationUrl } from "./policy";

const SOURCE = "https://shop.app/checkouts/uc/example?key=synthetic";
let overlay: UniversalOverlay | undefined;

function popup(): Window {
return {
closed: false,
close: vi.fn(),
focus: vi.fn(),
postMessage: vi.fn(),
} as unknown as Window;
}

function send(source: Window, data: unknown, origin = "https://shop.app"): void {
window.dispatchEvent(
new MessageEvent("message", {
data: structuredClone(data),
origin,
source,
}),
);
}

afterEach(() => {
overlay?.dispose();
overlay = undefined;
vi.restoreAllMocks();
document.body.replaceChildren();
});

describe("Universal sample with the registered Checkout Kit element", () => {
it("renders trusted two-shop batches in the slotted overlay and isolates the next presentation", () => {
document.body.innerHTML = `
<div class="events-header"><h2>Events</h2></div>
<p id="runtime-notice"></p>
<ul id="event-log"></ul>
`;
const hostLog = document.querySelector<HTMLElement>("#event-log")!;
const hostNotice = document.querySelector<HTMLElement>("#runtime-notice")!;
const hostHeader = document.querySelector<HTMLElement>(".events-header")!;
const element = document.createElement("shopify-universal-checkout");
element.telemetry = false;

const firstPopup = popup();
const secondPopup = popup();
vi.spyOn(window, "open").mockReturnValueOnce(firstPopup).mockReturnValueOnce(secondPopup);
vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(
function (this: HTMLDialogElement) {
this.setAttribute("open", "");
},
);
vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(
function (this: HTMLDialogElement) {
this.removeAttribute("open");
},
);

overlay = mountUniversalOverlay({
parent: document.body,
hostLog,
hostHeader,
hostNotice,
element,
validateSource: (value) => (isContinuationUrl(value, "production") ? value : undefined),
});
expect(
overlay.configure({ src: SOURCE, target: "popup", appearance: "", logLevel: "warn" }),
).toBe(true);
expect(overlay.attemptOpen()).toBe(true);
expect(element.shadowRoot?.querySelector("dialog")?.hasAttribute("open")).toBe(true);
expect(element.querySelector('[slot="overlay"]')).not.toBeNull();

send(firstPopup, batches.ready.request);
expect(firstPopup.postMessage).toHaveBeenCalledWith(batches.ready.response, "https://shop.app");

// Structured clone preserves explicit undefined keys from pre-fix producers.
const start = structuredClone(batches.start);
for (const entry of start) {
Object.assign(entry.params.checkout, { order: undefined, fulfillment: undefined });
}
send(firstPopup, start);

expect(overlay.snapshot.entries.map((entry) => entry.name)).toEqual(["start", "start"]);
expect(overlay.snapshot.resources.map((resource) => resource.label)).toEqual([
"Shop 1",
"Shop 2",
]);
expect(hostLog.children).toHaveLength(2);
expect(element.querySelector(".uc-overlay-event-log")?.children).toHaveLength(2);
expect(hostNotice.textContent).toContain("receiving events");
expect(hostLog.textContent).not.toContain("gid://shopify/");
expect(hostLog.textContent).not.toContain("synthetic");

const partial = structuredClone(batches.complete[0]!);
Object.assign(partial, { method: "ec.update" });
partial.params.context.revision = 2;
send(firstPopup, [partial]);
expect(overlay.snapshot.phase).toBe("active");
expect(overlay.snapshot.resources.map((resource) => resource.status)).toEqual([
"completed",
"incomplete",
]);

send(firstPopup, batches.complete);
expect(overlay.snapshot.phase).toBe("complete");
expect(overlay.snapshot.resources.map((resource) => resource.status)).toEqual([
"completed",
"completed",
]);
expect(overlay.snapshot.entries.map((entry) => entry.name)).toEqual([
"start",
"start",
"update",
"complete",
"complete",
]);

element.close();
expect(overlay.snapshot.phase).toBe("closed");
expect(overlay.snapshot.entries.at(-1)?.name).toBe("close");

expect(overlay.attemptOpen()).toBe(true);
expect(overlay.snapshot.presentation).toBe(2);
expect(overlay.snapshot.entries).toHaveLength(0);
send(firstPopup, start);
send(secondPopup, start, "https://foreign.example.test");
expect(overlay.snapshot.entries).toHaveLength(0);
send(secondPopup, batches.start);
expect(overlay.snapshot.entries.map((entry) => entry.name)).toEqual(["start", "start"]);
expect(hostLog.children).toHaveLength(2);
});
});
Loading