diff --git a/.changeset/petite-banks-start.md b/.changeset/petite-banks-start.md new file mode 100644 index 00000000..b0128ec3 --- /dev/null +++ b/.changeset/petite-banks-start.md @@ -0,0 +1,5 @@ +--- +"@paypal/paypal-js": patch +--- + +Fixes an issue where loadCoreSdkScript would load 2 core scripts. diff --git a/packages/paypal-js/src/v6/index.test.ts b/packages/paypal-js/src/v6/index.test.ts index 8320a1e8..1f5902d5 100644 --- a/packages/paypal-js/src/v6/index.test.ts +++ b/packages/paypal-js/src/v6/index.test.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { loadCoreSdkScript } from "./index"; -import { insertScriptElement, type ScriptElement } from "../utils"; +import { insertScriptElement, isServer, type ScriptElement } from "../utils"; vi.mock("../utils", async () => { const actual = await vi.importActual("../utils"); return { ...actual, + isServer: vi.fn().mockReturnValue(false), // default mock for insertScriptElement insertScriptElement: vi .fn() @@ -18,11 +19,27 @@ vi.mock("../utils", async () => { }); const mockedInsertScriptElement = vi.mocked(insertScriptElement); +const mockedIsServer = vi.mocked(isServer); + +/** + * Inserts a fake V6 core script tag into the DOM to simulate + * the "script already exists" branches. + */ +function insertFakeCoreScript(): HTMLScriptElement { + const script = document.createElement("script"); + script.src = "https://www.sandbox.paypal.com/web-sdk/v6/core"; + document.head.appendChild(script); + return script; +} describe("loadCoreSdkScript()", () => { beforeEach(() => { document.head.innerHTML = ""; vi.clearAllMocks(); + // Reset to default (non-server) for each test + mockedIsServer.mockReturnValue(false); + // Clean up any stubs on window.paypal + vi.unstubAllGlobals(); }); test("should default to using the sandbox environment", async () => { @@ -152,4 +169,135 @@ describe("loadCoreSdkScript()", () => { 'The "environment" option must be either "production" or "sandbox"', ); }); + + describe("server-side rendering", () => { + test("should resolve with null in a server environment", async () => { + mockedIsServer.mockReturnValue(true); + const result = await loadCoreSdkScript(); + expect(result).toBeNull(); + expect(mockedInsertScriptElement).not.toHaveBeenCalled(); + }); + }); + + describe("script already loaded", () => { + test("should return cached namespace when script exists and window.paypal is available", async () => { + insertFakeCoreScript(); + vi.stubGlobal("paypal", { version: "6.1.0" }); + + const result = await loadCoreSdkScript(); + + expect(result).toBeDefined(); + expect(result?.version).toBe("6.1.0"); + expect(mockedInsertScriptElement).not.toHaveBeenCalled(); + }); + + test("should not return cached namespace when version does not start with 6", async () => { + insertFakeCoreScript(); + vi.stubGlobal("paypal", { version: "5.0.0" }); + + // Since version doesn't start with "6", it falls through to the + // "script exists but not loaded" branch which waits for load/error + const promise = loadCoreSdkScript(); + + // Trigger the script's load event to resolve + const script = document.querySelector( + 'script[src*="/web-sdk/v6/core"]', + )!; + vi.stubGlobal("paypal", { version: "6.0.0" }); + script.dispatchEvent(new Event("load")); + + const result = await promise; + expect(result?.version).toBe("6.0.0"); + expect(mockedInsertScriptElement).not.toHaveBeenCalled(); + }); + }); + + describe("script exists but not yet loaded (e.g. React StrictMode)", () => { + test("should resolve when the pending script fires its load event", async () => { + const script = insertFakeCoreScript(); + + const promise = loadCoreSdkScript(); + + // Simulate the script finishing loading + vi.stubGlobal("paypal", { version: "6.0.0" }); + script.dispatchEvent(new Event("load")); + + const result = await promise; + expect(result).toBeDefined(); + expect(result?.version).toBe("6.0.0"); + expect(mockedInsertScriptElement).not.toHaveBeenCalled(); + }); + + test("should resolve with custom namespace when dataNamespace is set", async () => { + const script = insertFakeCoreScript(); + const customNamespace = "myPayPal"; + + const promise = loadCoreSdkScript({ + dataNamespace: customNamespace, + }); + + // Simulate the script setting the custom namespace + vi.stubGlobal(customNamespace, { version: "6.0.0" }); + script.dispatchEvent(new Event("load")); + + const result = await promise; + expect(result).toBeDefined(); + expect(result?.version).toBe("6.0.0"); + expect(mockedInsertScriptElement).not.toHaveBeenCalled(); + }); + + test("should reject when the pending script loads but namespace is missing", async () => { + const script = insertFakeCoreScript(); + + const promise = loadCoreSdkScript(); + + // Fire load without setting window.paypal + script.dispatchEvent(new Event("load")); + + await expect(promise).rejects.toEqual( + "The window.paypal global variable is not available", + ); + expect(mockedInsertScriptElement).not.toHaveBeenCalled(); + }); + + test("should reject when the pending script fires an error event", async () => { + const script = insertFakeCoreScript(); + + const promise = loadCoreSdkScript(); + + script.dispatchEvent(new Event("error")); + + await expect(promise).rejects.toThrow( + `The script "https://www.sandbox.paypal.com/web-sdk/v6/core" failed to load. Check the HTTP status code and response body in DevTools to learn more.`, + ); + expect(mockedInsertScriptElement).not.toHaveBeenCalled(); + }); + }); + + describe("new script insertion", () => { + test("should reject when namespace is not available after script loads", async () => { + mockedInsertScriptElement.mockImplementationOnce( + ({ onSuccess }: ScriptElement) => { + // Do NOT set window.paypal — simulate missing namespace + process.nextTick(() => onSuccess()); + }, + ); + + await expect(loadCoreSdkScript()).rejects.toEqual( + "The window.paypal global variable is not available", + ); + }); + + test("should reject when script fails to load", async () => { + mockedInsertScriptElement.mockImplementationOnce( + ({ onError }: ScriptElement) => { + process.nextTick(() => { + if (onError) onError(new Event("error") as ErrorEvent); + }); + }, + ); + + await expect(loadCoreSdkScript()).rejects.toThrow("failed to load"); + }); + }); }); diff --git a/packages/paypal-js/src/v6/index.ts b/packages/paypal-js/src/v6/index.ts index 16736b7f..35754d0c 100644 --- a/packages/paypal-js/src/v6/index.ts +++ b/packages/paypal-js/src/v6/index.ts @@ -10,6 +10,7 @@ function loadCoreSdkScript(options: LoadCoreSdkScriptOptions = {}) { validateArguments(options); const isServerEnv = isServer(); + // Early resolve in SSR environments where DOM APIs are unavailable if (isServerEnv) { return Promise.resolve(null); } @@ -17,9 +18,56 @@ function loadCoreSdkScript(options: LoadCoreSdkScriptOptions = {}) { const currentScript = document.querySelector( 'script[src*="/web-sdk/v6/core"]', ); + const windowNamespace = options.dataNamespace ?? "paypal"; - if (window.paypal?.version.startsWith("6") && currentScript) { - return Promise.resolve(window.paypal as unknown as PayPalV6Namespace); + // Script already loaded and namespace is available — return immediately + if ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as Record)[windowNamespace]?.version?.startsWith( + "6", + ) && + currentScript + ) { + return Promise.resolve( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as Record)[ + windowNamespace + ] as unknown as PayPalV6Namespace, + ); + } + + // Script tag exists but hasn't finished loading yet (e.g., React StrictMode double-invoke) + if (currentScript) { + return new Promise((resolve, reject) => { + const namespace = options.dataNamespace ?? "paypal"; + currentScript.addEventListener( + "load", + () => { + const paypalSDK = ( + window as unknown as Record + )[namespace] as PayPalV6Namespace | undefined; + if (paypalSDK) { + resolve(paypalSDK); + } else { + reject( + `The window.${namespace} global variable is not available`, + ); + } + }, + { once: true }, + ); + currentScript.addEventListener( + "error", + () => { + reject( + new Error( + `The script "${currentScript.src}" failed to load. Check the HTTP status code and response body in DevTools to learn more.`, + ), + ); + }, + { once: true }, + ); + }); } const { environment, debug, dataNamespace, dataSdkIntegrationSource } = @@ -44,6 +92,7 @@ function loadCoreSdkScript(options: LoadCoreSdkScriptOptions = {}) { attributes["data-sdk-integration-source"] = dataSdkIntegrationSource; } + // No existing script found — insert a new one and wait for it to load return new Promise((resolve, reject) => { insertScriptElement({ url: url.toString(),