From 20a0880b15b5a815e32138fe8addfe901f0af4b2 Mon Sep 17 00:00:00 2001 From: Robin Somlette Date: Mon, 21 Sep 2026 10:06:11 -0400 Subject: [PATCH 1/2] feat(web): add wallet element lifecycle contract --- platforms/web/src/wallets-adapter.ts | 30 ++ platforms/web/src/wallets-index.ts | 4 + platforms/web/src/wallets-lifecycle.test.ts | 344 ++++++++++++++++++++ platforms/web/src/wallets-ssr.test.ts | 18 + platforms/web/src/wallets-web-component.ts | 6 +- platforms/web/src/wallets.ts | 272 +++++++++++++++- platforms/web/src/wallets.types.ts | 19 ++ 7 files changed, 682 insertions(+), 11 deletions(-) create mode 100644 platforms/web/src/wallets-adapter.ts create mode 100644 platforms/web/src/wallets-lifecycle.test.ts create mode 100644 platforms/web/src/wallets-ssr.test.ts diff --git a/platforms/web/src/wallets-adapter.ts b/platforms/web/src/wallets-adapter.ts new file mode 100644 index 000000000..7ba55c864 --- /dev/null +++ b/platforms/web/src/wallets-adapter.ts @@ -0,0 +1,30 @@ +import type { GetCart, WalletLayout, WalletPurchaseSnapshot } from "./wallets.types"; + +export interface WalletAdapterRequest { + purchase: Readonly; + walletCount: number; + layout?: WalletLayout; + getCart?: GetCart; + signal: AbortSignal; +} + +export type WalletAdapterOutcome = + | { status: "ready"; wallets: ReadonlyArray; failed?: ReadonlyArray } + | { status: "unavailable"; reason: "no_wallet" | "setup_error" }; + +export interface WalletAdapter { + start(request: WalletAdapterRequest): Promise; + stop?(): void; +} + +type WalletAdapterFactory = () => WalletAdapter | undefined; + +let factory: WalletAdapterFactory = () => undefined; + +export function createWalletAdapter(): WalletAdapter | undefined { + return factory(); +} + +export function setWalletAdapterFactoryForTesting(nextFactory?: WalletAdapterFactory): void { + factory = nextFactory ?? (() => undefined); +} diff --git a/platforms/web/src/wallets-index.ts b/platforms/web/src/wallets-index.ts index 4c7a62227..13a120312 100644 --- a/platforms/web/src/wallets-index.ts +++ b/platforms/web/src/wallets-index.ts @@ -1,15 +1,19 @@ // Registers `` synchronously. export { ShopifyAcceleratedCheckoutButtons } from "./wallets-web-component"; +export { EXPRESS_CHECKOUT_EVENTS } from "./wallets"; export type { CartIdentifier, GetCart, GetCartRequest, KnownWalletErrorCode, + WalletAvailability, WalletCallbacks, WalletConfiguration, WalletDisplayError, + WalletErrorEventDetail, WalletLayout, WalletPurchaseSnapshot, + WalletRenderEventDetail, WalletsAttributes, WalletsProperties, } from "./wallets.types"; diff --git a/platforms/web/src/wallets-lifecycle.test.ts b/platforms/web/src/wallets-lifecycle.test.ts new file mode 100644 index 000000000..24122d8c9 --- /dev/null +++ b/platforms/web/src/wallets-lifecycle.test.ts @@ -0,0 +1,344 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + setWalletAdapterFactoryForTesting, + type WalletAdapter, + type WalletAdapterOutcome, + type WalletAdapterRequest, +} from "./wallets-adapter"; +import { EXPRESS_CHECKOUT_EVENTS } from "./wallets-index"; +import type { ShopifyAcceleratedCheckoutButtons } from "./wallets-index"; +import type { + GetCart, + WalletDisplayError, + WalletErrorEventDetail, + WalletRenderEventDetail, +} from "./wallets.types"; + +const tagName = "shopify-accelerated-checkout-buttons"; +const getCart: GetCart = vi.fn().mockResolvedValue("created-cart-reference"); + +type Deferred = { + promise: Promise; + resolve(value: T): void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +function createAdapter(...outcomes: Array>) { + let index = 0; + return { + start: vi.fn((_request: WalletAdapterRequest) => outcomes[index++]!.promise), + stop: vi.fn(), + } satisfies WalletAdapter; +} + +function createElement(): ShopifyAcceleratedCheckoutButtons { + return document.createElement(tagName); +} + +function configureProduct( + element: ShopifyAcceleratedCheckoutButtons, + overrides: Partial[0]> = {}, +): void { + element.configure({ + storeDomain: "example.myshopify.com", + country: "CA", + locale: "en-CA", + currency: "CAD", + variantId: "gid://shopify/ProductVariant/1", + getCart, + ...overrides, + }); +} + +async function expectStarts( + adapter: ReturnType, + count: number, +): Promise { + await vi.waitFor(() => expect(adapter.start).toHaveBeenCalledTimes(count)); +} + +describe("accelerated checkout lifecycle", () => { + beforeEach(() => { + document.body.replaceChildren(); + }); + + afterEach(() => { + setWalletAdapterFactoryForTesting(); + document.body.replaceChildren(); + vi.restoreAllMocks(); + }); + + it("publishes ready state before the callback and mirrored render event", async () => { + const outcome = deferred(); + const adapter = createAdapter(outcome); + setWalletAdapterFactoryForTesting(() => adapter); + const element = createElement(); + const observations: Array = []; + + configureProduct(element, { + walletCount: 2, + layout: "vertical", + callbacks: { + ready: () => observations.push(`callback:${element.availability.state}`), + }, + }); + element.addEventListener(EXPRESS_CHECKOUT_EVENTS.render, (event) => { + const detail = (event as CustomEvent).detail; + observations.push(`event:${detail.availability.state}`); + }); + document.body.append(element); + + expect(element.availability).toEqual({ state: "loading" }); + await expectStarts(adapter, 1); + expect(adapter.start).toHaveBeenCalledWith( + expect.objectContaining({ + purchase: expect.objectContaining({ + variantId: "gid://shopify/ProductVariant/1", + cartId: undefined, + }), + walletCount: 2, + layout: "vertical", + getCart, + signal: expect.any(AbortSignal), + }), + ); + + outcome.resolve({ status: "ready", wallets: ["apple_pay"], failed: ["paypal"] }); + + await vi.waitFor(() => expect(element.availability.state).toBe("ready")); + expect(element.availability).toEqual({ + state: "ready", + wallets: ["apple_pay"], + failed: ["paypal"], + }); + expect(observations).toEqual(["callback:ready", "event:ready"]); + expect(element.shadowRoot?.querySelector('[part="root"]')?.getAttribute("data-state")).toBe( + "ready", + ); + }); + + it("coalesces updates, cancels stale work, and ignores stale outcomes", async () => { + const first = deferred(); + const second = deferred(); + const adapter = createAdapter(first, second); + setWalletAdapterFactoryForTesting(() => adapter); + const ready = vi.fn(); + const element = createElement(); + + configureProduct(element, { callbacks: { ready } }); + document.body.append(element); + await expectStarts(adapter, 1); + const firstSignal = adapter.start.mock.calls[0]![0].signal; + + element.configure({ + variantId: "gid://shopify/ProductVariant/2", + sellingPlanId: "gid://shopify/SellingPlan/3", + }); + await expectStarts(adapter, 2); + + expect(firstSignal.aborted).toBe(true); + expect(adapter.stop).toHaveBeenCalledTimes(1); + expect(adapter.start.mock.calls[1]![0].purchase).toMatchObject({ + variantId: "gid://shopify/ProductVariant/2", + sellingPlanId: "gid://shopify/SellingPlan/3", + }); + + element.configure({ + variantId: "gid://shopify/ProductVariant/2", + sellingPlanId: "gid://shopify/SellingPlan/3", + }); + await Promise.resolve(); + expect(adapter.start).toHaveBeenCalledTimes(2); + + second.resolve({ status: "ready", wallets: ["paypal"] }); + await vi.waitFor(() => expect(element.availability.state).toBe("ready")); + first.resolve({ status: "ready", wallets: ["apple_pay"] }); + await Promise.resolve(); + + expect(element.availability).toMatchObject({ state: "ready", wallets: ["paypal"] }); + expect(ready).toHaveBeenCalledTimes(1); + }); + + it("reports invalid product configuration once and clears it before recovery", async () => { + const outcome = deferred(); + const adapter = createAdapter(outcome); + setWalletAdapterFactoryForTesting(() => adapter); + const errors: Array = []; + const errorEvents: Array = []; + const element = createElement(); + + element.addEventListener(EXPRESS_CHECKOUT_EVENTS.error, (event) => { + errorEvents.push((event as CustomEvent).detail.error); + }); + configureProduct(element, { + getCart: undefined, + callbacks: { + error: (error) => { + expect(element.error).toBe(error); + errors.push(error); + }, + }, + }); + document.body.append(element); + + await vi.waitFor(() => expect(element.error?.code).toBe("purchase_configuration_invalid")); + expect(element.availability).toEqual({ state: "unavailable", reason: "setup_error" }); + expect(adapter.start).not.toHaveBeenCalled(); + + element.configure({ variantId: "gid://shopify/ProductVariant/1" }); + await Promise.resolve(); + expect(errors).toHaveLength(1); + + element.configure({ getCart }); + await expectStarts(adapter, 1); + expect(errors).toEqual([ + expect.objectContaining({ code: "purchase_configuration_invalid" }), + null, + ]); + expect(errorEvents).toEqual(errors); + + outcome.resolve({ status: "ready", wallets: ["shop_pay"] }); + await vi.waitFor(() => expect(element.availability.state).toBe("ready")); + }); + + it("gives an existing cart priority over product inputs", async () => { + const first = deferred(); + const second = deferred(); + const adapter = createAdapter(first, second); + setWalletAdapterFactoryForTesting(() => adapter); + const element = createElement(); + + element.configure({ + storeDomain: "example.myshopify.com", + country: "CA", + locale: "en-CA", + currency: "CAD", + cartId: "existing-cart-reference", + variantId: "gid://shopify/ProductVariant/ignored", + }); + document.body.append(element); + await expectStarts(adapter, 1); + + expect(adapter.start.mock.calls[0]![0]).toMatchObject({ + purchase: { + cartId: "existing-cart-reference", + variantId: undefined, + sellingPlanId: undefined, + }, + getCart: undefined, + }); + }); + + it("treats an empty ready outcome as unavailable without calling ready", async () => { + const outcome = deferred(); + const adapter = createAdapter(outcome); + setWalletAdapterFactoryForTesting(() => adapter); + const ready = vi.fn(); + const render = vi.fn(); + const element = createElement(); + + configureProduct(element, { callbacks: { ready } }); + element.addEventListener(EXPRESS_CHECKOUT_EVENTS.render, render); + document.body.append(element); + await expectStarts(adapter, 1); + outcome.resolve({ status: "ready", wallets: [] }); + + await vi.waitFor(() => + expect(element.availability).toEqual({ state: "unavailable", reason: "no_wallet" }), + ); + expect(ready).not.toHaveBeenCalled(); + expect(render).toHaveBeenCalledTimes(1); + }); + + it("normalizes adapter failures without exposing thrown messages", async () => { + const adapter = { + start: vi.fn().mockRejectedValue(new Error("private adapter details")), + stop: vi.fn(), + } satisfies WalletAdapter; + setWalletAdapterFactoryForTesting(() => adapter); + const errorEvent = vi.fn(); + const element = createElement(); + + configureProduct(element, { + callbacks: { + error: () => { + throw new Error("merchant callback failure"); + }, + }, + }); + element.addEventListener(EXPRESS_CHECKOUT_EVENTS.error, errorEvent); + document.body.append(element); + + await vi.waitFor(() => expect(element.error?.code).toBe("unexpected_error")); + expect(element.error).toEqual({ phase: "initialization", code: "unexpected_error" }); + expect(element.availability).toEqual({ state: "unavailable", reason: "setup_error" }); + expect(errorEvent).toHaveBeenCalledOnce(); + expect(JSON.stringify(element.error)).not.toContain("private adapter details"); + }); + + it("cancels on disconnect and restarts cleanly on remount", async () => { + const first = deferred(); + const second = deferred(); + const adapter = createAdapter(first, second); + setWalletAdapterFactoryForTesting(() => adapter); + const ready = vi.fn(); + const element = createElement(); + + configureProduct(element, { callbacks: { ready } }); + document.body.append(element); + await expectStarts(adapter, 1); + const firstSignal = adapter.start.mock.calls[0]![0].signal; + + element.remove(); + expect(firstSignal.aborted).toBe(true); + expect(element.availability).toEqual({ state: "loading" }); + + first.resolve({ status: "ready", wallets: ["stale_wallet"] }); + await Promise.resolve(); + expect(ready).not.toHaveBeenCalled(); + + document.body.append(element); + await expectStarts(adapter, 2); + second.resolve({ status: "ready", wallets: ["apple_pay"] }); + await vi.waitFor(() => expect(element.availability.state).toBe("ready")); + expect(ready).toHaveBeenCalledTimes(1); + }); + + it("isolates callback failures and multiple element instances", async () => { + const firstOutcome = deferred(); + const secondOutcome = deferred(); + const adapters = [createAdapter(firstOutcome), createAdapter(secondOutcome)]; + setWalletAdapterFactoryForTesting(() => adapters.shift()); + const first = createElement(); + const second = createElement(); + const secondReady = vi.fn(); + + configureProduct(first, { + callbacks: { + ready: () => { + throw new Error("merchant callback failure"); + }, + }, + }); + configureProduct(second, { callbacks: { ready: secondReady } }); + document.body.append(first, second); + + await vi.waitFor(() => { + expect(adapters).toHaveLength(0); + }); + firstOutcome.resolve({ status: "ready", wallets: ["apple_pay"] }); + secondOutcome.resolve({ status: "ready", wallets: ["paypal"] }); + + await vi.waitFor(() => expect(secondReady).toHaveBeenCalledOnce()); + expect(first.availability).toMatchObject({ state: "ready", wallets: ["apple_pay"] }); + expect(second.availability).toMatchObject({ state: "ready", wallets: ["paypal"] }); + }); +}); diff --git a/platforms/web/src/wallets-ssr.test.ts b/platforms/web/src/wallets-ssr.test.ts new file mode 100644 index 000000000..1b2163775 --- /dev/null +++ b/platforms/web/src/wallets-ssr.test.ts @@ -0,0 +1,18 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("accelerated checkout server import", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("does not require browser custom-element globals during module evaluation", async () => { + vi.stubGlobal("HTMLElement", undefined); + vi.stubGlobal("customElements", undefined); + vi.resetModules(); + + await expect(import("./wallets-index")).resolves.toEqual( + expect.objectContaining({ ShopifyAcceleratedCheckoutButtons: expect.any(Function) }), + ); + }); +}); diff --git a/platforms/web/src/wallets-web-component.ts b/platforms/web/src/wallets-web-component.ts index 4140c9be8..59efcee40 100644 --- a/platforms/web/src/wallets-web-component.ts +++ b/platforms/web/src/wallets-web-component.ts @@ -9,13 +9,13 @@ declare global { } const tagName = "shopify-accelerated-checkout-buttons"; -const registeredConstructor = customElements.get(tagName); +const registeredConstructor = globalThis.customElements?.get(tagName); export type ShopifyAcceleratedCheckoutButtons = LocalShopifyAcceleratedCheckoutButtons; export const ShopifyAcceleratedCheckoutButtons: typeof LocalShopifyAcceleratedCheckoutButtons = (registeredConstructor as typeof LocalShopifyAcceleratedCheckoutButtons | undefined) ?? LocalShopifyAcceleratedCheckoutButtons; -if (!registeredConstructor) { - customElements.define(tagName, ShopifyAcceleratedCheckoutButtons); +if (globalThis.customElements && !registeredConstructor) { + globalThis.customElements.define(tagName, ShopifyAcceleratedCheckoutButtons); } diff --git a/platforms/web/src/wallets.ts b/platforms/web/src/wallets.ts index 247a38c93..933b985fe 100644 --- a/platforms/web/src/wallets.ts +++ b/platforms/web/src/wallets.ts @@ -1,13 +1,26 @@ +import { createWalletAdapter, type WalletAdapter } from "./wallets-adapter"; import type { CartIdentifier, GetCart, + WalletAvailability, WalletCallbacks, WalletConfiguration, + WalletDisplayError, + WalletErrorEventDetail, WalletLayout, + WalletPurchaseSnapshot, + WalletRenderEventDetail, WalletsProperties, } from "./wallets.types"; const ROOT_PART = "root"; +const HTMLElementBase: typeof HTMLElement = + globalThis.HTMLElement ?? (Object as unknown as typeof HTMLElement); + +export const EXPRESS_CHECKOUT_EVENTS = { + render: "shopify:express-checkouts:render", + error: "shopify:express-checkouts:error", +} as const; const scalarProperties = [ "storeDomain", @@ -23,6 +36,16 @@ const scalarProperties = [ const upgradableProperties = [...scalarProperties, "getCart", "callbacks"] as const; +type ConfigurationState = + | { status: "incomplete" } + | { status: "invalid" } + | { + status: "ready"; + key: string; + purchase: Readonly; + getCart?: GetCart; + }; + /** * Checkout Kit's merchant-facing accelerated checkout wallet element. * @@ -38,7 +61,10 @@ const upgradableProperties = [...scalarProperties, "getCart", "callbacks"] as co * @attribute wallet-count - Maximum number of wallets to render. Zero means all. * @attribute layout - Requested horizontal or vertical wallet layout. */ -export class ShopifyAcceleratedCheckoutButtons extends HTMLElement implements WalletsProperties { +export class ShopifyAcceleratedCheckoutButtons + extends HTMLElementBase + implements WalletsProperties +{ static observedAttributes = [ "store-domain", "country", @@ -53,22 +79,46 @@ export class ShopifyAcceleratedCheckoutButtons extends HTMLElement implements Wa #cartId: CartIdentifier | undefined; #getCart: GetCart | undefined; #callbacks: WalletCallbacks | undefined; + #availability: WalletAvailability = { state: "loading" }; + #error: WalletDisplayError | null = null; + #adapter: WalletAdapter | undefined; + #root: HTMLDivElement; + #connected = false; + #reconcileScheduled = false; + #generation = 0; + #controller: AbortController | undefined; + #startedKey: string | undefined; constructor() { super(); for (const property of upgradableProperties) this.#upgradeProperty(property); - const root = document.createElement("div"); - root.setAttribute("part", ROOT_PART); - root.setAttribute("role", "group"); - root.setAttribute("aria-label", "Accelerated checkout"); + this.#adapter = createWalletAdapter(); + this.#root = document.createElement("div"); + this.#root.setAttribute("part", ROOT_PART); + this.#root.setAttribute("role", "group"); + this.#root.setAttribute("aria-label", "Accelerated checkout"); + this.#root.setAttribute("data-state", "loading"); - this.attachShadow({ mode: "open" }).append(root); + this.attachShadow({ mode: "open" }).append(this.#root); } connectedCallback(): void { for (const property of upgradableProperties) this.#upgradeProperty(property); + this.#connected = true; + this.#scheduleReconcile(); + } + + disconnectedCallback(): void { + this.#connected = false; + this.#stopAdapter(); + this.#startedKey = undefined; + this.#setAvailability({ state: "loading" }, false); + } + + attributeChangedCallback(_name: string, oldValue: string | null, newValue: string | null): void { + if (oldValue !== newValue) this.#scheduleReconcile(); } get storeDomain(): string | undefined { @@ -108,7 +158,11 @@ export class ShopifyAcceleratedCheckoutButtons extends HTMLElement implements Wa } set cartId(value: CartIdentifier | null | undefined) { - this.#cartId = value ?? undefined; + const normalized = value ?? undefined; + if (this.#cartId === normalized) return; + this.#cartId = normalized; + this.#startedKey = undefined; + this.#scheduleReconcile(); } get variantId(): string | undefined { @@ -156,7 +210,11 @@ export class ShopifyAcceleratedCheckoutButtons extends HTMLElement implements Wa } set getCart(value: GetCart | null | undefined) { - this.#getCart = value ?? undefined; + const normalized = value ?? undefined; + if (this.#getCart === normalized) return; + this.#getCart = normalized; + this.#startedKey = undefined; + this.#scheduleReconcile(); } get callbacks(): WalletCallbacks | undefined { @@ -167,6 +225,14 @@ export class ShopifyAcceleratedCheckoutButtons extends HTMLElement implements Wa this.#callbacks = value ?? undefined; } + get availability(): WalletAvailability { + return this.#availability; + } + + get error(): WalletDisplayError | null { + return this.#error; + } + configure(configuration: WalletConfiguration): void { const values = configuration as Record; @@ -177,6 +243,193 @@ export class ShopifyAcceleratedCheckoutButtons extends HTMLElement implements Wa if (Object.hasOwn(configuration, "getCart")) this.getCart = configuration.getCart; if (Object.hasOwn(configuration, "callbacks")) this.callbacks = configuration.callbacks; + this.#scheduleReconcile(); + } + + #scheduleReconcile(): void { + if (!this.#connected || this.#reconcileScheduled) return; + this.#reconcileScheduled = true; + + queueMicrotask(() => { + this.#reconcileScheduled = false; + void this.#reconcile(); + }); + } + + async #reconcile(): Promise { + if (!this.#connected) return; + + const configuration = this.#configurationState(); + if (configuration.status === "incomplete") { + this.#deactivate(); + this.#clearError(); + this.#setAvailability({ state: "loading" }, false); + return; + } + if (configuration.status === "invalid") { + this.#deactivate(); + const availability: WalletAvailability = { state: "unavailable", reason: "setup_error" }; + this.#setAvailability(availability, false); + const changed = this.#setError({ + phase: "initialization", + code: "purchase_configuration_invalid", + }); + if (changed) this.#dispatchRender(availability); + return; + } + if (configuration.key === this.#startedKey) return; + + this.#stopAdapter(); + this.#startedKey = configuration.key; + this.#clearError(); + this.#setAvailability({ state: "loading" }, false); + + if (!this.#adapter) return; + + const generation = this.#generation; + const controller = new AbortController(); + this.#controller = controller; + + try { + const outcome = await this.#adapter.start({ + purchase: configuration.purchase, + walletCount: this.walletCount, + layout: this.layout, + getCart: configuration.getCart, + signal: controller.signal, + }); + if (!this.#isCurrent(generation, controller)) return; + + if (outcome.status === "ready" && outcome.wallets.length > 0) { + const availability: WalletAvailability = { + state: "ready", + wallets: Object.freeze([...outcome.wallets]), + failed: Object.freeze([...(outcome.failed ?? [])]), + }; + this.#setAvailability(availability, false); + this.#call(() => this.#callbacks?.ready?.()); + this.#dispatchRender(availability); + return; + } + + this.#setAvailability({ + state: "unavailable", + reason: outcome.status === "ready" ? "no_wallet" : outcome.reason, + }); + } catch { + if (!this.#isCurrent(generation, controller)) return; + const availability: WalletAvailability = { state: "unavailable", reason: "setup_error" }; + this.#setAvailability(availability, false); + this.#setError({ phase: "initialization", code: "unexpected_error" }); + this.#dispatchRender(availability); + } + } + + #configurationState(): ConfigurationState { + const { storeDomain, country, locale, currency, cartId, variantId, sellingPlanId, layout } = + this; + + if (!storeDomain || !country || !locale || !currency) return { status: "incomplete" }; + if (layout && layout !== "horizontal" && layout !== "vertical") return { status: "invalid" }; + + const purchase = Object.freeze({ + storeDomain, + country, + locale, + currency, + cartId, + variantId: cartId ? undefined : variantId, + sellingPlanId: cartId ? undefined : sellingPlanId, + }); + + if (cartId) return { status: "ready", key: this.#configurationKey(purchase), purchase }; + if (!variantId && !sellingPlanId) return { status: "incomplete" }; + if (!variantId || !this.#getCart) return { status: "invalid" }; + + return { + status: "ready", + key: this.#configurationKey(purchase), + purchase, + getCart: this.#getCart, + }; + } + + #configurationKey(purchase: Readonly): string { + return JSON.stringify([purchase, this.walletCount, this.layout, Boolean(this.#getCart)]); + } + + #isCurrent(generation: number, controller: AbortController): boolean { + return this.#connected && this.#generation === generation && !controller.signal.aborted; + } + + #deactivate(): void { + this.#stopAdapter(); + this.#startedKey = undefined; + } + + #stopAdapter(): void { + if (this.#startedKey === undefined && !this.#controller) return; + + this.#generation += 1; + this.#controller?.abort(); + this.#controller = undefined; + this.#adapter?.stop?.(); + } + + #setAvailability(availability: WalletAvailability, notify = true): void { + this.#availability = Object.freeze(availability); + this.#root.setAttribute("data-state", availability.state); + if (notify) this.#dispatchRender(this.#availability); + } + + #dispatchRender(availability: WalletAvailability): void { + this.dispatchEvent( + new CustomEvent(EXPRESS_CHECKOUT_EVENTS.render, { + bubbles: true, + composed: true, + detail: { availability }, + }), + ); + } + + #setError(error: WalletDisplayError): boolean { + if ( + this.#error?.phase === error.phase && + this.#error.code === error.code && + this.#error.message === error.message + ) { + return false; + } + + this.#error = Object.freeze(error); + this.#call(() => this.#callbacks?.error?.(this.#error)); + this.#dispatchError(this.#error); + return true; + } + + #clearError(): void { + if (!this.#error) return; + this.#error = null; + this.#call(() => this.#callbacks?.error?.(null)); + this.#dispatchError(null); + } + + #dispatchError(error: WalletDisplayError | null): void { + this.dispatchEvent( + new CustomEvent(EXPRESS_CHECKOUT_EVENTS.error, { + bubbles: true, + composed: true, + detail: { error }, + }), + ); + } + + #call(callback: () => void): void { + try { + callback(); + } catch { + // Merchant callbacks are observational and cannot break Checkout Kit. + } } #upgradeProperty(property: (typeof upgradableProperties)[number]): void { @@ -203,11 +456,14 @@ export type { GetCart, GetCartRequest, KnownWalletErrorCode, + WalletAvailability, WalletCallbacks, WalletConfiguration, WalletDisplayError, + WalletErrorEventDetail, WalletLayout, WalletPurchaseSnapshot, + WalletRenderEventDetail, WalletsAttributes, WalletsProperties, } from "./wallets.types"; diff --git a/platforms/web/src/wallets.types.ts b/platforms/web/src/wallets.types.ts index a266d5cf3..335a748ba 100644 --- a/platforms/web/src/wallets.types.ts +++ b/platforms/web/src/wallets.types.ts @@ -24,6 +24,23 @@ export interface WalletCallbacks { error?(error: WalletDisplayError | null): void; } +export type WalletAvailability = + | { state: "loading" } + | { + state: "ready"; + wallets: ReadonlyArray; + failed: ReadonlyArray; + } + | { state: "unavailable"; reason: "no_wallet" | "setup_error" }; + +export interface WalletRenderEventDetail { + availability: WalletAvailability; +} + +export interface WalletErrorEventDetail { + error: WalletDisplayError | null; +} + export interface WalletPurchaseSnapshot { readonly storeDomain?: string; readonly country?: string; @@ -88,5 +105,7 @@ export interface WalletsProperties { layout?: WalletLayout; getCart?: GetCart; callbacks?: WalletCallbacks; + readonly availability: WalletAvailability; + readonly error: WalletDisplayError | null; configure(configuration: WalletConfiguration): void; } From 6fc0a487d28e71d3aa1f63e7542a201e01573dc8 Mon Sep 17 00:00:00 2001 From: Robin Somlette Date: Thu, 24 Sep 2026 09:45:05 -0400 Subject: [PATCH 2/2] fix(web): stabilize wallet element remounts --- platforms/web/src/wallets-lifecycle.test.ts | 57 +++++++++++++++++++++ platforms/web/src/wallets.ts | 14 +++-- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/platforms/web/src/wallets-lifecycle.test.ts b/platforms/web/src/wallets-lifecycle.test.ts index 24122d8c9..fc00a4766 100644 --- a/platforms/web/src/wallets-lifecycle.test.ts +++ b/platforms/web/src/wallets-lifecycle.test.ts @@ -209,6 +209,35 @@ describe("accelerated checkout lifecycle", () => { await vi.waitFor(() => expect(element.availability.state).toBe("ready")); }); + it("reports the same invalid configuration again after remount", async () => { + const adapter = createAdapter(); + setWalletAdapterFactoryForTesting(() => adapter); + const errorCallback = vi.fn(); + const errorEvent = vi.fn(); + const element = createElement(); + + configureProduct(element, { + getCart: undefined, + callbacks: { error: errorCallback }, + }); + element.addEventListener(EXPRESS_CHECKOUT_EVENTS.error, errorEvent); + document.body.append(element); + + await vi.waitFor(() => expect(element.error?.code).toBe("purchase_configuration_invalid")); + expect(errorCallback).toHaveBeenCalledTimes(1); + expect(errorEvent).toHaveBeenCalledTimes(1); + + element.remove(); + expect(element.availability).toEqual({ state: "loading" }); + expect(element.error).toBeNull(); + + document.body.append(element); + await vi.waitFor(() => expect(errorCallback).toHaveBeenCalledTimes(2)); + expect(errorEvent).toHaveBeenCalledTimes(2); + expect(element.error?.code).toBe("purchase_configuration_invalid"); + expect(adapter.start).not.toHaveBeenCalled(); + }); + it("gives an existing cart priority over product inputs", async () => { const first = deferred(); const second = deferred(); @@ -237,6 +266,34 @@ describe("accelerated checkout lifecycle", () => { }); }); + it("does not restart an existing-cart flow when getCart changes", async () => { + const outcome = deferred(); + const adapter = createAdapter(outcome); + setWalletAdapterFactoryForTesting(() => adapter); + const element = createElement(); + + element.configure({ + storeDomain: "example.myshopify.com", + country: "CA", + locale: "en-CA", + currency: "CAD", + cartId: "existing-cart-reference", + getCart, + }); + document.body.append(element); + await expectStarts(adapter, 1); + outcome.resolve({ status: "ready", wallets: ["apple_pay"] }); + await vi.waitFor(() => expect(element.availability.state).toBe("ready")); + + element.configure({ getCart: vi.fn().mockResolvedValue("another-cart-reference") }); + await Promise.resolve(); + await Promise.resolve(); + + expect(adapter.start).toHaveBeenCalledTimes(1); + expect(adapter.stop).not.toHaveBeenCalled(); + expect(element.availability.state).toBe("ready"); + }); + it("treats an empty ready outcome as unavailable without calling ready", async () => { const outcome = deferred(); const adapter = createAdapter(outcome); diff --git a/platforms/web/src/wallets.ts b/platforms/web/src/wallets.ts index 933b985fe..a67c772b0 100644 --- a/platforms/web/src/wallets.ts +++ b/platforms/web/src/wallets.ts @@ -114,6 +114,7 @@ export class ShopifyAcceleratedCheckoutButtons this.#connected = false; this.#stopAdapter(); this.#startedKey = undefined; + this.#clearError(false); this.#setAvailability({ state: "loading" }, false); } @@ -213,8 +214,11 @@ export class ShopifyAcceleratedCheckoutButtons const normalized = value ?? undefined; if (this.#getCart === normalized) return; this.#getCart = normalized; - this.#startedKey = undefined; - this.#scheduleReconcile(); + + if (!this.#cartId) { + this.#startedKey = undefined; + this.#scheduleReconcile(); + } } get callbacks(): WalletCallbacks | undefined { @@ -355,7 +359,8 @@ export class ShopifyAcceleratedCheckoutButtons } #configurationKey(purchase: Readonly): string { - return JSON.stringify([purchase, this.walletCount, this.layout, Boolean(this.#getCart)]); + const usesGetCart = !purchase.cartId && Boolean(this.#getCart); + return JSON.stringify([purchase, this.walletCount, this.layout, usesGetCart]); } #isCurrent(generation: number, controller: AbortController): boolean { @@ -407,9 +412,10 @@ export class ShopifyAcceleratedCheckoutButtons return true; } - #clearError(): void { + #clearError(notify = true): void { if (!this.#error) return; this.#error = null; + if (!notify) return; this.#call(() => this.#callbacks?.error?.(null)); this.#dispatchError(null); }