Skip to content

Commit

Permalink
fix: prevent script provider from loading more than one sdk script
Browse files Browse the repository at this point in the history
  • Loading branch information
gregjopa committed Apr 2, 2021
1 parent 999e33d commit 8d5dbb7
Show file tree
Hide file tree
Showing 2 changed files with 77 additions and 9 deletions.
49 changes: 45 additions & 4 deletions src/ScriptContext.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,26 @@ jest.mock("@paypal/paypal-js", () => ({
loadScript: jest.fn(),
}));

function loadScriptMockImplementation({
"client-id": clientID,
"data-react-paypal-script-id": reactPayPalScriptID,
}) {
const newScript = document.createElement("script");
newScript.src = `https://www.paypal.com/sdk/js?client-id=${clientID}`;
newScript.setAttribute("data-react-paypal-script-id", reactPayPalScriptID);

document.head.insertBefore(newScript, document.head.firstElementChild);
return Promise.resolve({});
}

describe("<PayPalScriptProvider />", () => {
beforeEach(() => {
loadScript.mockResolvedValue({});
document.head.innerHTML = "";
loadScript.mockImplementation(loadScriptMockImplementation);
});

afterEach(() => {
jest.clearAllMocks();
});

test('should set "isResolved" state to "true" after loading the script', async () => {
Expand All @@ -19,7 +36,12 @@ describe("<PayPalScriptProvider />", () => {
<TestComponent />
</PayPalScriptProvider>
);
expect(loadScript).toHaveBeenCalledWith({ "client-id": "test" });
expect(loadScript).toHaveBeenCalledWith({
"client-id": "test",
"data-react-paypal-script-id": expect.stringContaining(
"react-paypal-js"
),
});

// verify initial loading state
expect(state.isPending).toBeTruthy();
Expand All @@ -36,7 +58,12 @@ describe("<PayPalScriptProvider />", () => {
<TestComponent />
</PayPalScriptProvider>
);
expect(loadScript).toHaveBeenCalledWith({ "client-id": "test" });
expect(loadScript).toHaveBeenCalledWith({
"client-id": "test",
"data-react-paypal-script-id": expect.stringContaining(
"react-paypal-js"
),
});

// verify initial loading state
expect(state.isPending).toBeTruthy();
Expand All @@ -48,7 +75,12 @@ describe("<PayPalScriptProvider />", () => {

describe("usePayPalScriptReducer", () => {
beforeEach(() => {
loadScript.mockResolvedValue({});
document.head.innerHTML = "";
loadScript.mockImplementation(loadScriptMockImplementation);
});

afterEach(() => {
jest.clearAllMocks();
});

test("should manage state for loadScript()", async () => {
Expand Down Expand Up @@ -93,10 +125,19 @@ describe("usePayPalScriptReducer", () => {
expect(loadScript).toHaveBeenCalledWith(state.options);

await waitFor(() => expect(state.isResolved).toBeTruthy());
const firstScriptID = state.options["data-react-paypal-script-id"];

// this click dispatches the action "resetOptions" causing the script to reload
fireEvent.click(screen.getByText("Reload button"));
await waitFor(() => expect(state.isResolved).toBeTruthy());
const secondScriptID = state.options["data-react-paypal-script-id"];

expect(
document.querySelector(
`script[data-react-paypal-script-id="${secondScriptID}"]`
)
).toBeTruthy();
expect(firstScriptID).not.toBe(secondScriptID);

expect(state.options).toMatchObject({
"client-id": "xyz",
Expand Down
37 changes: 32 additions & 5 deletions src/ScriptContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,31 @@ import React, {
import { loadScript } from "@paypal/paypal-js";
import type { PayPalScriptOptions } from "@paypal/paypal-js/types/script-options";

export interface ReactPayPalScriptOptions extends PayPalScriptOptions {
"data-react-paypal-script-id": string;
}

enum SCRIPT_LOADING_STATE {
PENDING = "pending",
REJECTED = "rejected",
RESOLVED = "resolved",
}

interface ScriptContextState {
options: PayPalScriptOptions;
options: ReactPayPalScriptOptions;
loadingStatus: SCRIPT_LOADING_STATE;
}

interface ScriptContextDerivedState {
options: PayPalScriptOptions;
options: ReactPayPalScriptOptions;
isPending: boolean;
isRejected: boolean;
isResolved: boolean;
}

type ScriptReducerAction =
| { type: "setLoadingStatus"; value: SCRIPT_LOADING_STATE }
| { type: "resetOptions"; value: PayPalScriptOptions };
| { type: "resetOptions"; value: ReactPayPalScriptOptions };

type ScriptReducerDispatch = (action: ScriptReducerAction) => void;

Expand All @@ -45,9 +49,14 @@ function scriptReducer(state: ScriptContextState, action: ScriptReducerAction) {
loadingStatus: action.value,
};
case "resetOptions":
// destroy existing script to make sure only one script loads at a time
destroySDKScript(state.options["data-react-paypal-script-id"]);
return {
loadingStatus: SCRIPT_LOADING_STATE.PENDING,
options: action.value,
options: {
...action.value,
"data-react-paypal-script-id": `${getNewScriptID()}`,
},
};

default: {
Expand All @@ -56,6 +65,21 @@ function scriptReducer(state: ScriptContextState, action: ScriptReducerAction) {
}
}

function getNewScriptID() {
return `react-paypal-js-${Math.random().toString(36).substring(7)}`;
}

function destroySDKScript(reactPayPalScriptID: string) {
const scriptNode = document.querySelector(
`script[data-react-paypal-script-id="${reactPayPalScriptID}"]`
);
if (scriptNode === null) return;

if (scriptNode.parentNode) {
scriptNode.parentNode.removeChild(scriptNode);
}
}

function usePayPalScriptReducer(): [
ScriptContextDerivedState,
ScriptReducerDispatch
Expand Down Expand Up @@ -91,7 +115,10 @@ const PayPalScriptProvider: FunctionComponent<ScriptProviderProps> = ({
children,
}: ScriptProviderProps) => {
const initialState = {
options,
options: {
...options,
"data-react-paypal-script-id": `${getNewScriptID()}`,
},
loadingStatus: SCRIPT_LOADING_STATE.PENDING,
};

Expand Down

0 comments on commit 8d5dbb7

Please sign in to comment.