Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 2 additions & 1 deletion packages/react-paypal-js/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "import"],
"plugins": ["@typescript-eslint", "import", "jsdoc"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
Expand All @@ -27,6 +27,7 @@
}
},
"rules": {
"jsdoc/no-undefined-types": 1,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This allows importing types only for use in jsdoc without the linter reporting an unused variable error.

"react/prop-types": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"curly": "warn",
Expand Down
3 changes: 2 additions & 1 deletion packages/react-paypal-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"@rollup/plugin-babel": "^6.0.4",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-replace": "^6.0.2",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^12.1.4",
"@storybook/addon-actions": "^6.4.9",
"@storybook/addon-docs": "^6.4.9",
Expand All @@ -94,6 +95,7 @@
"eslint": "^8.9.0",
"eslint-config-prettier": "^8.4.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-jsdoc": "^61.1.11",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0",
"husky": "^7.0.4",
Expand All @@ -108,7 +110,6 @@
"rimraf": "^3.0.2",
"rollup": "^4.9.1",
"rollup-plugin-cleanup": "^3.2.1",
"@rollup/plugin-terser": "^0.4.4",
"scheduler": "^0.20.2",
"semver": "^7.3.5",
"standard-version": "^9.3.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import { INSTANCE_LOADING_STATE } from "../types/PayPalProviderEnums";
import { isServer } from "../utils";

import type { CreateInstanceOptions } from "../types";
import type { PayPalContextState } from "../components/PayPalProvider";
import type { PayPalState } from "../context/PayPalProviderContext";

const TEST_CLIENT_TOKEN = "test-client-token";

function expectInitialState(state: Partial<PayPalContextState>): void {
function expectInitialState(state: Partial<PayPalState>): void {
expect(state.loadingStatus).toBe(INSTANCE_LOADING_STATE.PENDING);
expect(state.sdkInstance).toBe(null);
expect(state.eligiblePaymentMethods).toBe(null);
Expand Down Expand Up @@ -221,12 +221,11 @@ describe("usePayPalInstance SSR", () => {
});

function setupSSRTestComponent() {
const state: PayPalContextState = {
const state: PayPalState = {
loadingStatus: INSTANCE_LOADING_STATE.PENDING,
sdkInstance: null,
eligiblePaymentMethods: null,
error: null,
dispatch: jest.fn(),
};

function TestComponent({
Expand Down
73 changes: 21 additions & 52 deletions packages/react-paypal-js/src/v6/components/PayPalProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { usePayPal } from "../hooks/usePayPal";
import { INSTANCE_LOADING_STATE } from "../types/PayPalProviderEnums";

import type { CreateInstanceOptions, PayPalV6Namespace } from "../types";
import type { PayPalContextState } from "../components/PayPalProvider";
import type { PayPalState } from "../context/PayPalProviderContext";

// Test constants
export const TEST_CLIENT_TOKEN = "test-client-token";
Expand Down Expand Up @@ -74,27 +74,24 @@ function createMockPayPalNamespace(): PayPalV6Namespace {
}

// State assertion helpers
function expectPendingState(state: Partial<PayPalContextState>): void {
function expectPendingState(state: Partial<PayPalState>): void {
expect(state.loadingStatus).toBe(INSTANCE_LOADING_STATE.PENDING);
}

function expectResolvedState(state: Partial<PayPalContextState>): void {
function expectResolvedState(state: Partial<PayPalState>): void {
expect(state.loadingStatus).toBe(INSTANCE_LOADING_STATE.RESOLVED);
expect(state.sdkInstance).toBeTruthy();
expect(state.error).toBe(null);
}

function expectRejectedState(
state: Partial<PayPalContextState>,
error?: Error,
): void {
function expectRejectedState(state: Partial<PayPalState>, error?: Error): void {
expect(state.loadingStatus).toBe(INSTANCE_LOADING_STATE.REJECTED);
expect(state.sdkInstance).toBe(null);
if (error) {
expect(state.error).toEqual(error);
}
}
function expectReloadingState(state: Partial<PayPalContextState>): void {
function expectReloadingState(state: Partial<PayPalState>): void {
// When props change, only loadingStatus is reset to PENDING
// Old instance and eligibility remain until new ones are loaded
expect(state.loadingStatus).toBe(INSTANCE_LOADING_STATE.PENDING);
Expand Down Expand Up @@ -141,17 +138,12 @@ describe("PayPalProvider", () => {
});

test('should set loadingStatus to "rejected" when SDK fails to load', async () => {
await withConsoleSpy("error", async () => {
(loadCoreSdkScript as jest.Mock).mockRejectedValue(
new Error(TEST_ERROR_MESSAGE),
);
const mockError = new Error(TEST_ERROR_MESSAGE);
(loadCoreSdkScript as jest.Mock).mockRejectedValue(mockError);

const { state } = renderProvider();
const { state } = renderProvider();

await waitFor(() =>
expectRejectedState(state, new Error(TEST_ERROR_MESSAGE)),
);
});
await waitFor(() => expectRejectedState(state, mockError));
});

test.each<[string, "sandbox" | "production", string, string]>([
Expand Down Expand Up @@ -273,31 +265,21 @@ describe("PayPalProvider", () => {
});

test("should handle eligibility loading failure gracefully", async () => {
await withConsoleSpy("warn", async (spy) => {
const mockInstance = {
...createMockSdkInstance(),
findEligibleMethods: jest
.fn()
.mockRejectedValue(new Error("Eligibility failed")),
};

(loadCoreSdkScript as jest.Mock).mockResolvedValue({
createInstance: jest.fn().mockResolvedValue(mockInstance),
});
const mockError = new Error("Eligibility failed");
const mockInstance = {
...createMockSdkInstance(),
findEligibleMethods: jest.fn().mockRejectedValue(mockError),
};

const { state } = renderProvider();
(loadCoreSdkScript as jest.Mock).mockResolvedValue({
createInstance: jest.fn().mockResolvedValue(mockInstance),
});

await waitFor(() => expectResolvedState(state));
const { state } = renderProvider();

await waitFor(() =>
expect(spy).toHaveBeenCalledWith(
"Failed to get eligible payment methods:",
expect.any(Error),
),
);
await waitFor(() => expectRejectedState(state, mockError));

expect(state.eligiblePaymentMethods).toBe(null);
});
expect(state.eligiblePaymentMethods).toBe(null);
});
});

Expand Down Expand Up @@ -563,25 +545,12 @@ describe("Auto-memoization", () => {
});
});

describe("usePayPal", () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I moved this to its own test suite for usePayPal.

test("should throw an error when used without PayPalProvider", () => {
withConsoleSpy("error", () => {
const { TestComponent } = setupTestComponent();

expect(() => render(<TestComponent />)).toThrow(
"usePayPal must be used within a PayPalProvider",
);
});
});
});

function setupTestComponent() {
const state: PayPalContextState = {
const state: PayPalState = {
loadingStatus: INSTANCE_LOADING_STATE.PENDING,
sdkInstance: null,
eligiblePaymentMethods: null,
error: null,
dispatch: jest.fn(),
};

function TestComponent({
Expand Down
45 changes: 18 additions & 27 deletions packages/react-paypal-js/src/v6/components/PayPalProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,22 @@ import {
INSTANCE_LOADING_STATE,
INSTANCE_DISPATCH_ACTION,
} from "../types/PayPalProviderEnums";
import { useCompareMemoize } from "../utils";
import { toError, useCompareMemoize } from "../utils";

import type {
CreateInstanceOptions,
Components,
CreateInstanceOptions,
EligiblePaymentMethodsOutput,
LoadCoreSdkScriptOptions,
PayPalV6Namespace,
SdkInstance,
} from "../types";
import type { InstanceAction } from "../context/PayPalProviderContext";

export interface PayPalContextState {
sdkInstance: SdkInstance<readonly [Components, ...Components[]]> | null;
eligiblePaymentMethods: EligiblePaymentMethodsOutput | null;
error: Error | null;
dispatch: React.Dispatch<InstanceAction>;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

dispatch was removed from state since it's only necessary for PayPalProvider internals. Everywhere else, PayPalContextState has been replaced with PayPalState.

loadingStatus: INSTANCE_LOADING_STATE;
}
import type {
InstanceAction,
PayPalState,
} from "../context/PayPalProviderContext";
import type { usePayPal } from "../hooks/usePayPal";

type PayPalProviderProps = CreateInstanceOptions<
readonly [Components, ...Components[]]
Expand All @@ -37,6 +34,10 @@ type PayPalProviderProps = CreateInstanceOptions<
children: React.ReactNode;
};

/**
* {@link PayPalProvider} creates the SDK script, component scripts, runs eligibility, then
* provides these in {@link PayPalContext} to child components via the {@link usePayPal} hook.
*/
export const PayPalProvider: React.FC<PayPalProviderProps> = ({
clientMetadataId,
clientToken,
Expand Down Expand Up @@ -78,13 +79,9 @@ export const PayPalProvider: React.FC<PayPalProviderProps> = ({
}
} catch (error) {
if (isSubscribed) {
const errorInstance =
error instanceof Error
? error
: new Error(String(error));
dispatch({
type: INSTANCE_DISPATCH_ACTION.SET_ERROR,
value: errorInstance,
value: toError(error),
});
}
}
Expand Down Expand Up @@ -135,13 +132,9 @@ export const PayPalProvider: React.FC<PayPalProviderProps> = ({
});
} catch (error) {
if (isSubscribed) {
const errorInstance =
error instanceof Error
? error
: new Error(String(error));
dispatch({
type: INSTANCE_DISPATCH_ACTION.SET_ERROR,
value: errorInstance,
value: toError(error),
});
}
}
Expand Down Expand Up @@ -191,10 +184,10 @@ export const PayPalProvider: React.FC<PayPalProviderProps> = ({
});
} catch (error) {
if (isSubscribed) {
console.warn(
"Failed to get eligible payment methods:",
error,
);
dispatch({
type: INSTANCE_DISPATCH_ACTION.SET_ERROR,
value: toError(error),
});
}
}
};
Expand All @@ -206,20 +199,18 @@ export const PayPalProvider: React.FC<PayPalProviderProps> = ({
};
}, [state.sdkInstance, state.loadingStatus]);

const contextValue = useMemo(
const contextValue: PayPalState = useMemo(
() => ({
sdkInstance: state.sdkInstance,
eligiblePaymentMethods: state.eligiblePaymentMethods,
error: state.error,
dispatch,
loadingStatus: state.loadingStatus,
}),
[
state.sdkInstance,
state.eligiblePaymentMethods,
state.error,
state.loadingStatus,
dispatch,
],
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ import {

import type {
Components,
SdkInstance,
EligiblePaymentMethodsOutput,
SdkInstance,
} from "../types";
import type { PayPalContextState } from "../components/PayPalProvider";

export interface PayPalState {
sdkInstance: SdkInstance<readonly [Components, ...Components[]]> | null;
Expand Down Expand Up @@ -72,4 +71,4 @@ export function instanceReducer(
}
}

export const PayPalContext = createContext<PayPalContextState | null>(null);
export const PayPalContext = createContext<PayPalState | null>(null);
19 changes: 19 additions & 0 deletions packages/react-paypal-js/src/v6/hooks/useIsMounted.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { renderHook } from "@testing-library/react-hooks";

import { useIsMountedRef } from "./useIsMounted";

describe("useIsMountedRef", () => {
it("should return true if the component is still mounted", () => {
const { result } = renderHook(() => useIsMountedRef());

expect(result.current.current).toBe(true);
});

it("should return false if the component has unmounted", () => {
const { result, unmount } = renderHook(() => useIsMountedRef());

unmount();

expect(result.current.current).toBe(false);
});
});
22 changes: 22 additions & 0 deletions packages/react-paypal-js/src/v6/hooks/useIsMounted.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useEffect, useRef } from "react";

import type React from "react";

/**
* Return a {@link React.MutableRefObject} a stable ref that's `true` if the component is mounted, `false` otherwise.
*
* The return must, unfortunately be included in dependency arrays. See the issue here: [\[eslint-plugin-react-hooks\] allow configuring custom hooks as "static" #16873](https://github.com/facebook/react/issues/16873).
*/
export function useIsMountedRef(): React.MutableRefObject<boolean> {
const isMounted = useRef(false);

useEffect(() => {
isMounted.current = true;

return () => {
isMounted.current = false;
};
}, []);

return isMounted;
}
Loading
Loading