From 8eb63df9f8ca948d1f555468fe74e4a6cda2400e Mon Sep 17 00:00:00 2001 From: Gal Naor Date: Thu, 29 Oct 2026 10:32:33 +0200 Subject: [PATCH] feat(payments): preloadCheckout() loads the provider's script before mounting mountCheckout() fetches the payments config and loads the provider's script only after the checkout exists, so the PayPal button waits for both on top of order creation. preloadCheckout() starts them when the checkout form opens. It never rejects: mountCheckout() retries and reports any failure. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/src/modules/payments-checkout.ts | 21 ++++++- packages/sdk/src/modules/payments.ts | 4 ++ packages/sdk/src/modules/payments.types.ts | 20 +++++++ packages/sdk/tests/types/payments.types.ts | 5 ++ packages/sdk/tests/unit/payments.test.ts | 57 +++++++++++++++++++ 5 files changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/modules/payments-checkout.ts b/packages/sdk/src/modules/payments-checkout.ts index a9b1df43..236a3e4e 100644 --- a/packages/sdk/src/modules/payments-checkout.ts +++ b/packages/sdk/src/modules/payments-checkout.ts @@ -93,7 +93,7 @@ export interface MountCheckoutDeps { async function scriptUrlFor( provider: "paypal" | "stripe", - deps: MountCheckoutDeps + deps: Pick ): Promise { const value = (await deps.getConfig())?.[provider]?.script_url; let url: URL | undefined; @@ -337,6 +337,25 @@ async function mountPayPal( }; } +/** + * Fetches the payments config and loads the provider's script ahead of mountCheckout(). + * @internal + */ +export async function preloadCheckout( + provider: "paypal" | "stripe", + deps: Pick +): Promise { + if (typeof window === "undefined" || typeof document === "undefined") return; + const read = () => (provider === "paypal" ? browserGlobals().paypal : browserGlobals().Stripe); + try { + if (!read()) { + await deps.loadScript(await scriptUrlFor(provider, deps)); + } + } catch { + // A hint only: mountCheckout() fetches and loads again and reports the failure. + } +} + /** * Mounts the embedded checkout that createCheckout() returned. * @internal diff --git a/packages/sdk/src/modules/payments.ts b/packages/sdk/src/modules/payments.ts index 4355218e..666961bd 100644 --- a/packages/sdk/src/modules/payments.ts +++ b/packages/sdk/src/modules/payments.ts @@ -3,6 +3,7 @@ import { loadScript, mountCheckout, PaymentsCheckoutConfig, + preloadCheckout, } from "./payments-checkout.js"; import { CancelSubscriptionOptions, @@ -101,6 +102,9 @@ export function createPaymentsModule( loadScript, }); }, + preloadCheckout(provider) { + return preloadCheckout(provider, { getConfig, loadScript }); + }, }; } diff --git a/packages/sdk/src/modules/payments.types.ts b/packages/sdk/src/modules/payments.types.ts index e027f21f..dc1a2cf6 100644 --- a/packages/sdk/src/modules/payments.types.ts +++ b/packages/sdk/src/modules/payments.types.ts @@ -568,6 +568,26 @@ export interface PaymentsModule { checkout: MountableCheckout, options?: MountCheckoutOptions ): Promise; + + /** + * Loads the provider's checkout script before you mount a checkout. + * + * Call it when the checkout page or form opens, while the buyer is still filling it in. A later {@linkcode mountCheckout | mountCheckout()} then skips loading the script and renders sooner. + * + * It never rejects, so you don't need to await it or catch errors. If the script can't load, `mountCheckout()` tries again and rejects with the error. In the backend it does nothing. + * + * @param provider - The provider the app is connected to. + * @returns Promise resolving once the script has loaded or failed to load. + * + * @example + * ```tsx + * // Start loading PayPal when the checkout form opens + * useEffect(() => { + * base44.payments.preloadCheckout('paypal'); + * }, []); + * ``` + */ + preloadCheckout(provider: "paypal" | "stripe"): Promise; } /** diff --git a/packages/sdk/tests/types/payments.types.ts b/packages/sdk/tests/types/payments.types.ts index de00516b..bc09edfd 100644 --- a/packages/sdk/tests/types/payments.types.ts +++ b/packages/sdk/tests/types/payments.types.ts @@ -27,6 +27,11 @@ base44.payments.refund; // @ts-expect-error The service role runs in backend code, where there is nothing to mount. base44.asServiceRole.payments.mountCheckout; +base44.payments.preloadCheckout("paypal"); + +// @ts-expect-error Only connected checkout providers have a script to preload. +base44.payments.preloadCheckout("wix"); + base44.asServiceRole.payments.listPurchases({ buyer_email: "buyer@example.com" }); // @ts-expect-error Only the service role can filter by another buyer's email. diff --git a/packages/sdk/tests/unit/payments.test.ts b/packages/sdk/tests/unit/payments.test.ts index 97e5d2b2..a2eec6fc 100644 --- a/packages/sdk/tests/unit/payments.test.ts +++ b/packages/sdk/tests/unit/payments.test.ts @@ -773,3 +773,60 @@ describe("payments config", () => { expect(scope.isDone()).toBe(true); }); }); + +describe("preloadCheckout", () => { + const appId = "test-app-id"; + const serverUrl = "https://base44.app"; + const configPath = `/api/apps/${appId}/payments/config`; + const scriptUrl = "https://www.sandbox.paypal.com/web-sdk/v6/core"; + + afterEach(() => { + nock.cleanAll(); + vi.unstubAllGlobals(); + }); + + async function setup() { + vi.resetModules(); + const { createClient: create } = await import("../../src/index.ts"); + const scripts: any[] = []; + vi.stubGlobal("window", {}); + vi.stubGlobal("document", { + createElement: () => ({ remove: vi.fn() }), + head: { appendChild: (script: any) => scripts.push(script) }, + }); + const base44 = create({ serverUrl, appId, token: "user-token", analytics: { enabled: false } }); + return { base44, scripts }; + } + + test("fetches the config and loads the script once across calls", async () => { + const { base44, scripts } = await setup(); + const scope = nock(serverUrl).get(configPath).once().reply(200, { paypal: { script_url: scriptUrl } }); + + const preload = base44.payments.preloadCheckout("paypal"); + await vi.waitFor(() => expect(scripts).toHaveLength(1)); + scripts[0].onload(); + await expect(preload).resolves.toBeUndefined(); + base44.payments.preloadCheckout("paypal"); + + expect(scripts[0].src).toBe(scriptUrl); + expect(scripts).toHaveLength(1); + expect(scope.isDone()).toBe(true); + }); + + test("resolves without loading anything when the config has no valid script URL", async () => { + const { base44, scripts } = await setup(); + nock(serverUrl).get(configPath).reply(200, { paypal: { script_url: "https://evil.example.com/sdk.js" } }); + + await expect(base44.payments.preloadCheckout("paypal")).resolves.toBeUndefined(); + expect(scripts).toHaveLength(0); + }); + + test("resolves when the config fetch fails", async () => { + const { base44 } = await setup(); + nock(serverUrl) + .get(configPath) + .reply(503, { error: { code: "provider_unavailable", message: "Down", details: {} } }); + + await expect(base44.payments.preloadCheckout("paypal")).resolves.toBeUndefined(); + }); +});