Skip to content

Commit 8663d7f

Browse files
authored
feat(v6): add support for deterministic script loading (#849)
1 parent 9b31cad commit 8663d7f

File tree

3 files changed

+244
-103
lines changed

3 files changed

+244
-103
lines changed

.changeset/sixty-tips-bow.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@paypal/paypal-js": minor
3+
---
4+
5+
feat(v6): add support for deterministic script loading
Lines changed: 155 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,186 @@
11
import { beforeEach, describe, expect, test, vi } from "vitest";
22

33
import { loadCoreSdkScript } from "./index";
4-
import { insertScriptElement, type ScriptElement } from "../utils";
5-
6-
vi.mock("../utils", async () => {
7-
const actual = await vi.importActual<typeof import("../utils")>("../utils");
8-
return {
9-
...actual,
10-
// default mock for insertScriptElement
11-
insertScriptElement: vi
12-
.fn()
13-
.mockImplementation(({ onSuccess }: ScriptElement) => {
14-
vi.stubGlobal("paypal", { version: "6" });
15-
process.nextTick(() => onSuccess());
16-
}),
17-
};
18-
});
19-
20-
const mockedInsertScriptElement = vi.mocked(insertScriptElement);
214

225
describe("loadCoreSdkScript()", () => {
6+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7+
let scriptAppendChildSpy: any;
8+
239
beforeEach(() => {
2410
document.head.innerHTML = "";
2511
vi.clearAllMocks();
12+
vi.unstubAllGlobals();
13+
14+
scriptAppendChildSpy = vi
15+
.spyOn(document.head, "appendChild")
16+
.mockImplementation((node) => {
17+
if (node instanceof HTMLScriptElement) {
18+
const namespace =
19+
node.getAttribute("data-namespace") ?? "paypal";
20+
vi.stubGlobal(namespace, { version: "6" });
21+
process.nextTick(() =>
22+
node.dispatchEvent(new Event("load")),
23+
);
24+
}
25+
return node;
26+
});
2627
});
2728

2829
test("should default to using the sandbox environment", async () => {
29-
await loadCoreSdkScript();
30-
expect(mockedInsertScriptElement.mock.calls[0][0].url).toEqual(
30+
const result = await loadCoreSdkScript();
31+
expect(scriptAppendChildSpy).toHaveBeenCalledTimes(1);
32+
const scriptElement = scriptAppendChildSpy.mock.calls[0][0];
33+
expect(scriptElement.src).toBe(
3134
"https://www.sandbox.paypal.com/web-sdk/v6/core",
3235
);
33-
expect(mockedInsertScriptElement).toHaveBeenCalledTimes(1);
36+
expect(scriptElement.getAttribute("data-loading-state")).toBe(
37+
"resolved",
38+
);
39+
expect(result).toBeDefined();
40+
expect(window.paypal).toBeDefined();
3441
});
3542

3643
test("should support options for using production environment", async () => {
37-
await loadCoreSdkScript({ environment: "production" });
38-
expect(mockedInsertScriptElement.mock.calls[0][0].url).toEqual(
44+
const result = await loadCoreSdkScript({ environment: "production" });
45+
expect(scriptAppendChildSpy).toHaveBeenCalledTimes(1);
46+
const scriptElement = scriptAppendChildSpy.mock.calls[0][0];
47+
expect(scriptElement.src).toBe(
3948
"https://www.paypal.com/web-sdk/v6/core",
4049
);
41-
expect(mockedInsertScriptElement).toHaveBeenCalledTimes(1);
50+
expect(scriptElement.getAttribute("data-loading-state")).toBe(
51+
"resolved",
52+
);
53+
expect(result).toBeDefined();
54+
expect(window.paypal).toBeDefined();
4255
});
4356

4457
test("should support enabling debugging", async () => {
45-
await loadCoreSdkScript({ debug: true });
46-
expect(mockedInsertScriptElement.mock.calls[0][0].url).toEqual(
58+
const result = await loadCoreSdkScript({ debug: true });
59+
expect(scriptAppendChildSpy).toHaveBeenCalledTimes(1);
60+
const scriptElement = scriptAppendChildSpy.mock.calls[0][0];
61+
expect(scriptElement.src).toBe(
4762
"https://www.sandbox.paypal.com/web-sdk/v6/core?debug=true",
4863
);
49-
expect(mockedInsertScriptElement).toHaveBeenCalledTimes(1);
64+
expect(scriptElement.getAttribute("data-loading-state")).toBe(
65+
"resolved",
66+
);
67+
expect(result).toBeDefined();
68+
expect(window.paypal).toBeDefined();
69+
});
70+
71+
test("should avoid inserting two script elements when called twice sequentially", async () => {
72+
const result1 = await loadCoreSdkScript();
73+
const result2 = await loadCoreSdkScript();
74+
// should only insert the script once
75+
// the existing loaded window.paypal reference is returned on the second call
76+
expect(scriptAppendChildSpy).toHaveBeenCalledTimes(1);
77+
const scriptElement = scriptAppendChildSpy.mock.calls[0][0];
78+
expect(scriptElement.src).toBe(
79+
"https://www.sandbox.paypal.com/web-sdk/v6/core",
80+
);
81+
expect(scriptElement.getAttribute("data-loading-state")).toBe(
82+
"resolved",
83+
);
84+
expect(result1).toBeDefined();
85+
expect(result2).toBeDefined();
86+
expect(result1).toBe(result2);
87+
expect(window.paypal).toBeDefined();
88+
});
89+
90+
test("should avoid inserting two script elements when called twice in parallel", async () => {
91+
const [result1, result2] = await Promise.all([
92+
loadCoreSdkScript(),
93+
loadCoreSdkScript(),
94+
]);
95+
// should only insert the script once
96+
expect(scriptAppendChildSpy).toHaveBeenCalledTimes(1);
97+
const scriptElement = scriptAppendChildSpy.mock.calls[0][0];
98+
expect(scriptElement.src).toBe(
99+
"https://www.sandbox.paypal.com/web-sdk/v6/core",
100+
);
101+
expect(scriptElement.getAttribute("data-loading-state")).toBe(
102+
"resolved",
103+
);
104+
expect(result1).toBeDefined();
105+
expect(result2).toBeDefined();
106+
expect(result1).toBe(result2);
107+
expect(window.paypal).toBeDefined();
108+
});
109+
110+
test("should return reference to existing script when loading state is pending", async () => {
111+
document.head.innerHTML = `<script src="https://www.sandbox.paypal.com/web-sdk/v6/core" data-loading-state="pending"></script>`;
112+
const loadCoreSdkScriptReference = loadCoreSdkScript();
113+
114+
process.nextTick(() => {
115+
vi.stubGlobal("paypal", { version: "6" });
116+
document
117+
.querySelector('script[src*="/web-sdk/v6/core"]')!
118+
.dispatchEvent(new Event("load"));
119+
});
120+
121+
const result = await loadCoreSdkScriptReference;
122+
123+
// should NOT insert the script since it already exists in the DOM in pending state
124+
expect(scriptAppendChildSpy).toHaveBeenCalledTimes(0);
125+
expect(
126+
document
127+
.querySelector('script[src*="/web-sdk/v6/core"]')!
128+
.getAttribute("data-loading-state"),
129+
).toBe("resolved");
130+
expect(result).toBeDefined();
131+
expect(window.paypal).toBeDefined();
132+
});
133+
134+
test("should reject when the script fails to load", async () => {
135+
vi.spyOn(document.head, "appendChild").mockImplementationOnce(
136+
(node) => {
137+
process.nextTick(() => node.dispatchEvent(new Event("error")));
138+
return node;
139+
},
140+
);
141+
142+
expect(async () => {
143+
await loadCoreSdkScript();
144+
}).rejects.toThrowError(
145+
'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.',
146+
);
147+
});
148+
149+
test("should error due to unvalid input", async () => {
150+
expect(async () => {
151+
// @ts-expect-error invalid arguments
152+
await loadCoreSdkScript(123);
153+
}).rejects.toThrowError("Expected an options object");
154+
155+
expect(async () => {
156+
// @ts-expect-error invalid arguments
157+
await loadCoreSdkScript({ environment: "bad_value" });
158+
}).rejects.toThrowError(
159+
'The "environment" option must be either "production" or "sandbox"',
160+
);
50161
});
51162

52163
describe("dataNamespace option", () => {
53164
test("should support custom data-namespace attribute", async () => {
54165
const customNamespace = "myCustomNamespace";
55166

56-
// Update mock to set the custom namespace instead of window.paypal
57-
mockedInsertScriptElement.mockImplementationOnce(
58-
({ onSuccess }: ScriptElement) => {
59-
vi.stubGlobal(customNamespace, { version: "6" });
60-
process.nextTick(() => onSuccess());
61-
},
62-
);
63-
64167
const result = await loadCoreSdkScript({
65168
dataNamespace: customNamespace,
66169
});
67170

68-
expect(mockedInsertScriptElement.mock.calls[0][0].url).toEqual(
171+
expect(scriptAppendChildSpy).toHaveBeenCalledTimes(1);
172+
const scriptElement = scriptAppendChildSpy.mock.calls[0][0];
173+
expect(scriptElement.src).toBe(
69174
"https://www.sandbox.paypal.com/web-sdk/v6/core",
70175
);
71-
expect(
72-
mockedInsertScriptElement.mock.calls[0][0].attributes,
73-
).toEqual({
74-
"data-namespace": customNamespace,
75-
});
76-
expect(mockedInsertScriptElement).toHaveBeenCalledTimes(1);
176+
177+
expect(scriptElement.getAttribute("data-namespace")).toBe(
178+
customNamespace,
179+
);
180+
77181
expect(result).toBeDefined();
182+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
183+
expect(window[customNamespace as any]).toBeDefined();
78184
});
79185

80186
test("should error when dataNamespace is an empty string", async () => {
@@ -102,16 +208,18 @@ describe("loadCoreSdkScript()", () => {
102208
dataSdkIntegrationSource: integrationSource,
103209
});
104210

105-
expect(mockedInsertScriptElement.mock.calls[0][0].url).toEqual(
211+
expect(scriptAppendChildSpy).toHaveBeenCalledTimes(1);
212+
const scriptElement = scriptAppendChildSpy.mock.calls[0][0];
213+
expect(scriptElement.src).toBe(
106214
"https://www.sandbox.paypal.com/web-sdk/v6/core",
107215
);
216+
108217
expect(
109-
mockedInsertScriptElement.mock.calls[0][0].attributes,
110-
).toEqual({
111-
"data-sdk-integration-source": integrationSource,
112-
});
113-
expect(mockedInsertScriptElement).toHaveBeenCalledTimes(1);
218+
scriptElement.getAttribute("data-sdk-integration-source"),
219+
).toBe(integrationSource);
220+
114221
expect(result).toBeDefined();
222+
expect(window.paypal).toBeDefined();
115223
});
116224

117225
test("should error when dataSdkIntegrationSource is an empty string", async () => {
@@ -130,26 +238,4 @@ describe("loadCoreSdkScript()", () => {
130238
);
131239
});
132240
});
133-
134-
test("should return PayPal namespace with version property", async () => {
135-
const result = await loadCoreSdkScript();
136-
expect(result).toBeDefined();
137-
expect(result?.version).toBeDefined();
138-
expect(result?.version).toBe("6");
139-
expect(typeof result?.version).toBe("string");
140-
});
141-
142-
test("should error due to unvalid input", async () => {
143-
expect(async () => {
144-
// @ts-expect-error invalid arguments
145-
await loadCoreSdkScript(123);
146-
}).rejects.toThrowError("Expected an options object");
147-
148-
expect(async () => {
149-
// @ts-expect-error invalid arguments
150-
await loadCoreSdkScript({ environment: "bad_value" });
151-
}).rejects.toThrowError(
152-
'The "environment" option must be either "production" or "sandbox"',
153-
);
154-
});
155241
});

0 commit comments

Comments
 (0)