Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/petite-banks-start.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@paypal/paypal-js": patch
---

Fixes an issue where loadCoreSdkScript would load 2 core scripts.
150 changes: 149 additions & 1 deletion packages/paypal-js/src/v6/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import("../utils")>("../utils");
return {
...actual,
isServer: vi.fn().mockReturnValue(false),
// default mock for insertScriptElement
insertScriptElement: vi
.fn()
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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<HTMLScriptElement>(
'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");
});
});
});
53 changes: 51 additions & 2 deletions packages/paypal-js/src/v6/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,64 @@ 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);
}

const currentScript = document.querySelector<HTMLScriptElement>(
'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<string, any>)[windowNamespace]?.version?.startsWith(
"6",
) &&
currentScript
) {
return Promise.resolve(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as Record<string, any>)[
windowNamespace
] as unknown as PayPalV6Namespace,
);
}

// Script tag exists but hasn't finished loading yet (e.g., React StrictMode double-invoke)
if (currentScript) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to add some tests for this situation? since this impacts the load logic 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea great point, I will push some 👍

return new Promise<PayPalV6Namespace>((resolve, reject) => {
const namespace = options.dataNamespace ?? "paypal";
currentScript.addEventListener(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we go this route, we should look into sharing these callbacks with what is passed into insertScriptElement with onSuccess and onError. That function uses these same event listeners under the hood

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok i'll add this to my near-term ToDos

"load",
() => {
const paypalSDK = (
window as unknown as Record<string, unknown>
)[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 } =
Expand All @@ -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<PayPalV6Namespace>((resolve, reject) => {
insertScriptElement({
url: url.toString(),
Expand Down
Loading