From dc861dd29ee18c0d75abde7909d2d49c01417613 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:11:40 -0600 Subject: [PATCH 1/7] add a condition and logic to resolve a pending script and prevent double loading core scripts --- packages/paypal-js/src/v6/index.ts | 35 +++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/paypal-js/src/v6/index.ts b/packages/paypal-js/src/v6/index.ts index 16736b7f..5baf2685 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); } @@ -18,10 +19,41 @@ function loadCoreSdkScript(options: LoadCoreSdkScriptOptions = {}) { 'script[src*="/web-sdk/v6/core"]', ); - if (window.paypal?.version.startsWith("6") && currentScript) { + // Script already loaded and namespace is available — return immediately + if (window.paypal?.version?.startsWith("6") && currentScript) { return Promise.resolve(window.paypal 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 PayPal SDK script failed to load.`)); + }, + { once: true }, + ); + }); + } + const { environment, debug, dataNamespace, dataSdkIntegrationSource } = options; const attributes: Record = {}; @@ -44,6 +76,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(), From fc8d4787c8d300ab16c7ebb1091618a06a6c103b Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:12:58 -0600 Subject: [PATCH 2/7] chore: add changeset --- .changeset/petite-banks-start.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/petite-banks-start.md 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. From 916d38641cced208f1121c0937bc650e8ba5c01f Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:44:33 -0600 Subject: [PATCH 3/7] add tests --- packages/paypal-js/src/v6/index.test.ts | 150 +++++++++++++++++++++++- 1 file changed, 149 insertions(+), 1 deletion(-) diff --git a/packages/paypal-js/src/v6/index.test.ts b/packages/paypal-js/src/v6/index.test.ts index 8320a1e8..c703b38d 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 PayPal SDK script failed to load.", + ); + 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"); + }); + }); }); From e1cfe65814bba079fe6e4551c475b239e843f292 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:23:49 -0600 Subject: [PATCH 4/7] update to use custom window namespace --- packages/paypal-js/src/v6/index.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/paypal-js/src/v6/index.ts b/packages/paypal-js/src/v6/index.ts index 5baf2685..99278afe 100644 --- a/packages/paypal-js/src/v6/index.ts +++ b/packages/paypal-js/src/v6/index.ts @@ -18,10 +18,22 @@ function loadCoreSdkScript(options: LoadCoreSdkScriptOptions = {}) { const currentScript = document.querySelector( 'script[src*="/web-sdk/v6/core"]', ); + const windowNamespace = options.dataNamespace ?? "paypal"; // Script already loaded and namespace is available — return immediately - if (window.paypal?.version?.startsWith("6") && currentScript) { - return Promise.resolve(window.paypal as unknown as PayPalV6Namespace); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ( + (window as Record)[windowNamespace]?.version?.startsWith( + "6", + ) && + currentScript + ) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return Promise.resolve( + (window as Record)[ + windowNamespace + ] as unknown as PayPalV6Namespace, + ); } // Script tag exists but hasn't finished loading yet (e.g., React StrictMode double-invoke) From 47a755ea2557bb630f2412d4d2842eca5f907a97 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:27:29 -0600 Subject: [PATCH 5/7] fix issues from auto-formatting --- packages/paypal-js/src/v6/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/paypal-js/src/v6/index.ts b/packages/paypal-js/src/v6/index.ts index 99278afe..97cfad3f 100644 --- a/packages/paypal-js/src/v6/index.ts +++ b/packages/paypal-js/src/v6/index.ts @@ -21,15 +21,15 @@ function loadCoreSdkScript(options: LoadCoreSdkScriptOptions = {}) { const windowNamespace = options.dataNamespace ?? "paypal"; // Script already loaded and namespace is available — return immediately - // eslint-disable-next-line @typescript-eslint/no-explicit-any if ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as Record)[windowNamespace]?.version?.startsWith( "6", ) && currentScript ) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any return Promise.resolve( + // eslint-disable-next-line @typescript-eslint/no-explicit-any (window as Record)[ windowNamespace ] as unknown as PayPalV6Namespace, From 6325d5cbe08376c6f395fe7a572057db0de41f07 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:40:27 -0600 Subject: [PATCH 6/7] update error message --- packages/paypal-js/src/v6/index.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/paypal-js/src/v6/index.ts b/packages/paypal-js/src/v6/index.ts index 97cfad3f..17adbaba 100644 --- a/packages/paypal-js/src/v6/index.ts +++ b/packages/paypal-js/src/v6/index.ts @@ -59,7 +59,13 @@ function loadCoreSdkScript(options: LoadCoreSdkScriptOptions = {}) { currentScript.addEventListener( "error", () => { - reject(new Error(`The PayPal SDK script failed to load.`)); + 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 }, ); From 6093419dec57a4dbb21f055f4c2b6a4007a076b3 Mon Sep 17 00:00:00 2001 From: Evan Reinstein <42251756+EvanReinstein@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:48:59 -0600 Subject: [PATCH 7/7] fix tests --- packages/paypal-js/src/v6/index.test.ts | 2 +- packages/paypal-js/src/v6/index.ts | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/paypal-js/src/v6/index.test.ts b/packages/paypal-js/src/v6/index.test.ts index c703b38d..1f5902d5 100644 --- a/packages/paypal-js/src/v6/index.test.ts +++ b/packages/paypal-js/src/v6/index.test.ts @@ -268,7 +268,7 @@ describe("loadCoreSdkScript()", () => { script.dispatchEvent(new Event("error")); await expect(promise).rejects.toThrow( - "The PayPal SDK script failed to load.", + `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(); }); diff --git a/packages/paypal-js/src/v6/index.ts b/packages/paypal-js/src/v6/index.ts index 17adbaba..35754d0c 100644 --- a/packages/paypal-js/src/v6/index.ts +++ b/packages/paypal-js/src/v6/index.ts @@ -61,9 +61,7 @@ function loadCoreSdkScript(options: LoadCoreSdkScriptOptions = {}) { () => { reject( new Error( - `The script "${currentScript.src}" failed to load. - Check the HTTP status code and response body in DevTools to - learn more.`, + `The script "${currentScript.src}" failed to load. Check the HTTP status code and response body in DevTools to learn more.`, ), ); },