Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/rude-melons-wear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@paypal/react-paypal-js": patch
---

- Default `PayPalProvider` `components` to `["paypal-payments"]`.
- Update session hooks to check `loadingStatus` before returning an error for no `sdkInstance`.
- `PayPalContext` export was removed since merchants won't need to use that directly.
- Check only `window` for `isServer` SSR function.
Original file line number Diff line number Diff line change
Expand Up @@ -57,19 +57,26 @@ const createInstanceOptions: CreateInstanceOptions<["paypal-payments"]> = {
};

function renderProvider(
instanceOptions = createInstanceOptions,
environment: "sandbox" | "production" = "sandbox",
debug = false,
children?: React.ReactNode,
props: Partial<React.ComponentProps<typeof PayPalProvider>> = {},
) {
const { state, TestComponent } = setupTestComponent();

const {
children,
clientToken = createInstanceOptions.clientToken,
components = createInstanceOptions.components,
debug = false,
environment = "sandbox",
...restProps
} = props;

const result = render(
<PayPalProvider
components={instanceOptions.components}
clientToken={instanceOptions.clientToken}
environment={environment}
components={components}
clientToken={clientToken}
debug={debug}
environment={environment}
{...restProps}
>
<TestComponent>{children}</TestComponent>
</PayPalProvider>,
Expand Down Expand Up @@ -184,7 +191,7 @@ describe("PayPalProvider", () => {
return Promise.resolve(createMockPayPalNamespace());
});

renderProvider(createInstanceOptions, environment);
renderProvider({ environment, ...createInstanceOptions });

expect(loadCoreSdkScript).toHaveBeenCalledWith({
environment,
Expand Down Expand Up @@ -220,6 +227,29 @@ describe("PayPalProvider", () => {
);
});

test("should use default components array when not provided", async () => {
const mockCreateInstance = jest
.fn()
.mockResolvedValue(createMockSdkInstance());

(loadCoreSdkScript as jest.Mock).mockResolvedValue({
createInstance: mockCreateInstance,
});

const { state } = renderProvider({
clientToken: TEST_CLIENT_TOKEN,
});

await waitFor(() => expectResolvedState(state));

expect(mockCreateInstance).toHaveBeenCalledWith(
expect.objectContaining({
components: ["paypal-payments"],
clientToken: TEST_CLIENT_TOKEN,
}),
);
});

test("should handle createInstance failure", async () => {
const instanceError = new Error("Instance creation failed");
const mockCreateInstance = jest
Expand Down Expand Up @@ -255,7 +285,6 @@ describe("PayPalProvider", () => {
(loadCoreSdkScript as jest.Mock).mockResolvedValue({
createInstance: mockCreateInstance,
});
// @ts-expect-error renderProvider is typed for single component only
const { state } = renderProvider(multiComponentOptions);

await waitFor(() => expectResolvedState(state));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ import type {
import type { PayPalState } from "../context/PayPalProviderContext";
import type { usePayPal } from "../hooks/usePayPal";

type PayPalProviderProps = CreateInstanceOptions<
readonly [Components, ...Components[]]
type PayPalProviderProps = Omit<
CreateInstanceOptions<readonly [Components, ...Components[]]>,
"components"
> &
LoadCoreSdkScriptOptions & {
components?: Components[];
eligibleMethodsResponse?: FindEligiblePaymentMethodsResponse;
eligibleMethodsPayload?: FindEligiblePaymentMethodsRequestPayload;
children: React.ReactNode;
Expand All @@ -43,7 +45,7 @@ type PayPalProviderProps = CreateInstanceOptions<
export const PayPalProvider: React.FC<PayPalProviderProps> = ({
clientMetadataId,
clientToken,
components,
components = ["paypal-payments"],
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Most merchants will be using paypal-payments so it's a safe default.

locale,
pageType,
partnerAttributionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { expectCurrentErrorValue } from "./useErrorTestUtil";
import { usePayPal } from "./usePayPal";
import { usePayLaterOneTimePaymentSession } from "./usePayLaterOneTimePaymentSession";
import { useProxyProps } from "../utils";
import { INSTANCE_LOADING_STATE } from "../types/PayPalProviderEnums";

import type { OneTimePaymentSession } from "../types";

Expand Down Expand Up @@ -67,8 +68,16 @@ describe("usePayLaterOneTimePaymentSession", () => {
});
});

test("should error if there is no sdkInstance when called", () => {
test("should error if there is no sdkInstance when called and not create a session", () => {
const mockOrderId = "123";
const mockSession: OneTimePaymentSession = {
cancel: jest.fn(),
destroy: jest.fn(),
start: jest.fn(),
};
const mockCreatePayLaterOneTimePaymentSession = jest
.fn()
.mockReturnValue(mockSession);

(usePayPal as jest.Mock).mockReturnValue({ sdkInstance: null });

Expand All @@ -87,6 +96,74 @@ describe("usePayLaterOneTimePaymentSession", () => {
expectCurrentErrorValue(error);

expect(error).toEqual(new Error("no sdk instance available"));
expect(mockCreatePayLaterOneTimePaymentSession).not.toHaveBeenCalled();
});

test("should not error if there is no sdkInstance but loading is still pending", () => {
const mockOrderId = "123";

(usePayPal as jest.Mock).mockReturnValue({
sdkInstance: null,
loadingStatus: INSTANCE_LOADING_STATE.PENDING,
});

const {
result: {
current: { error },
},
} = renderHook(() =>
usePayLaterOneTimePaymentSession({
presentationMode: "auto",
orderId: mockOrderId,
onApprove: jest.fn(),
}),
);

expect(error).toBeNull();
});

test("should clear any sdkInstance related errors if the sdkInstance becomes available", () => {
const mockOrderId = "123";
const mockSession: OneTimePaymentSession = {
cancel: jest.fn(),
destroy: jest.fn(),
start: jest.fn(),
};
const mockCreatePayLaterOneTimePaymentSession = jest
.fn()
.mockReturnValue(mockSession);

// First render: no sdkInstance, should error
(usePayPal as jest.Mock).mockReturnValueOnce({
sdkInstance: null,
loadingStatus: INSTANCE_LOADING_STATE.REJECTED,
});

const { result, rerender } = renderHook(() =>
usePayLaterOneTimePaymentSession({
presentationMode: "auto",
orderId: mockOrderId,
onApprove: jest.fn(),
}),
);

expectCurrentErrorValue(result.current.error);
expect(result.current.error).toEqual(
new Error("no sdk instance available"),
);

// Second render: sdkInstance becomes available, error should clear
(usePayPal as jest.Mock).mockReturnValue({
sdkInstance: {
createPayLaterOneTimePaymentSession:
mockCreatePayLaterOneTimePaymentSession,
},
loadingStatus: INSTANCE_LOADING_STATE.RESOLVED,
});

rerender();

expect(result.current.error).toBeNull();
});

test("should provide a click handler that calls session start", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { usePayPal } from "./usePayPal";
import { useIsMountedRef } from "./useIsMounted";
import { useError } from "./useError";
import { useProxyProps } from "../utils";
import { INSTANCE_LOADING_STATE } from "../types/PayPalProviderEnums";

import type {
BasePaymentSessionReturn,
Expand Down Expand Up @@ -33,7 +34,7 @@ export function usePayLaterOneTimePaymentSession({
orderId,
...callbacks
}: PayLaterOneTimePaymentSessionProps): BasePaymentSessionReturn {
const { sdkInstance } = usePayPal();
const { sdkInstance, loadingStatus } = usePayPal();
const isMountedRef = useIsMountedRef();
const sessionRef = useRef<OneTimePaymentSession | null>(null); // handle cleanup
const proxyCallbacks = useProxyProps(callbacks);
Expand All @@ -46,10 +47,12 @@ export function usePayLaterOneTimePaymentSession({

// Separate error reporting effect to avoid infinite loops with proxyCallbacks
useEffect(() => {
if (!sdkInstance) {
if (sdkInstance) {
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 change prevents an error when there's no sdkInstance because the instance is still being loaded. I put this change in all of the session hooks.

Copy link
Contributor

Choose a reason for hiding this comment

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

Sweet change, will make sure to add this as well to the new CardFieldsProvider component that checks the sdkInstance instance.

setError(null);
} else if (loadingStatus !== INSTANCE_LOADING_STATE.PENDING) {
setError(new Error("no sdk instance available"));
}
}, [sdkInstance, setError]);
}, [sdkInstance, setError, loadingStatus]);

useEffect(() => {
if (!sdkInstance) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ describe("usePayPalOneTimePaymentSession", () => {
});

describe("initialization", () => {
test("should error if there is no sdkInstance when called", () => {
test("should not create session when no SDK instance is available", () => {
mockUsePayPal.mockReturnValue({
sdkInstance: null,
loadingStatus: INSTANCE_LOADING_STATE.PENDING,
loadingStatus: INSTANCE_LOADING_STATE.REJECTED,
eligiblePaymentMethods: null,
error: null,
});
Expand All @@ -78,6 +78,73 @@ describe("usePayPalOneTimePaymentSession", () => {
expectCurrentErrorValue(error);

expect(error).toEqual(new Error("no sdk instance available"));
expect(
mockSdkInstance.createPayPalOneTimePaymentSession,
).not.toHaveBeenCalled();
});

test("should not error if there is no sdkInstance but loading is still pending", () => {
mockUsePayPal.mockReturnValue({
sdkInstance: null,
loadingStatus: INSTANCE_LOADING_STATE.PENDING,
eligiblePaymentMethods: null,
error: null,
});

const props: UsePayPalOneTimePaymentSessionProps = {
presentationMode: "popup",
orderId: "test-order-id",
onApprove: jest.fn(),
};

const {
result: {
current: { error },
},
} = renderHook(() => usePayPalOneTimePaymentSession(props));

expect(error).toBeNull();
});

test("should clear any sdkInstance related errors if the sdkInstance becomes available", () => {
const mockSession = createMockPayPalSession();
const mockSdkInstanceNew = createMockSdkInstance(mockSession);

// First render: no sdkInstance and not in PENDING state, should error
mockUsePayPal.mockReturnValue({
sdkInstance: null,
loadingStatus: INSTANCE_LOADING_STATE.REJECTED,
eligiblePaymentMethods: null,
error: null,
});

const props: UsePayPalOneTimePaymentSessionProps = {
presentationMode: "popup",
orderId: "test-order-id",
onApprove: jest.fn(),
};

const { result, rerender } = renderHook(() =>
usePayPalOneTimePaymentSession(props),
);

expectCurrentErrorValue(result.current.error);
expect(result.current.error).toEqual(
new Error("no sdk instance available"),
);

// Second render: sdkInstance becomes available, error should clear
mockUsePayPal.mockReturnValue({
// @ts-expect-error mocking sdk instance
sdkInstance: mockSdkInstanceNew,
loadingStatus: INSTANCE_LOADING_STATE.RESOLVED,
eligiblePaymentMethods: null,
error: null,
});

rerender();

expect(result.current.error).toBeNull();
});

test("should create a PayPal payment session when the hook is called with orderId", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { usePayPal } from "./usePayPal";
import { useIsMountedRef } from "./useIsMounted";
import { useError } from "./useError";
import { useProxyProps } from "../utils";
import { INSTANCE_LOADING_STATE } from "../types/PayPalProviderEnums";
import {
OneTimePaymentSession,
PayPalPresentationModeOptions,
Expand Down Expand Up @@ -31,7 +32,7 @@ export function usePayPalOneTimePaymentSession({
orderId,
...callbacks
}: UsePayPalOneTimePaymentSessionProps): BasePaymentSessionReturn {
const { sdkInstance } = usePayPal();
const { sdkInstance, loadingStatus } = usePayPal();
const isMountedRef = useIsMountedRef();
const sessionRef = useRef<OneTimePaymentSession | null>(null);
const proxyCallbacks = useProxyProps(callbacks);
Expand All @@ -48,10 +49,12 @@ export function usePayPalOneTimePaymentSession({

// Separate error reporting effect to avoid infinite loops with proxyCallbacks
useEffect(() => {
if (!sdkInstance) {
if (sdkInstance) {
setError(null);
} else if (loadingStatus !== INSTANCE_LOADING_STATE.PENDING) {
setError(new Error("no sdk instance available"));
}
}, [sdkInstance, setError]);
}, [sdkInstance, setError, loadingStatus]);
Comment on lines 50 to +57
Copy link
Contributor

@AleGastelum AleGastelum Dec 4, 2025

Choose a reason for hiding this comment

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

How this conditions are set up would make the error state persist new Error("no sdk instance available") value until a new instance of sdkInstance is loaded successfuly. Is this desired behavior? or should the error state be handled by the useEffect clean up? something like this:

useEffect(() => {
    if (!sdkInstance && loadingStatus !== INSTANCE_LOADING_STATE.PENDING) {
        setError(new Error("no sdk instance available"));
    }
    
    return () => {
        setError(null);
    }
}, [sdkInstance, setError, loadingStatus])

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If there's no sdkInstance and the loadingStatus is not "pending", then there should be an error returned by the hook. That error should exist until there's an sdkInstance. So, I think the way I have the logic is equivalent to your suggested changes but IMO a bit clearer.

How this conditions are set up would make the error state persist new Error("no sdk instance available") value until a new instance of sdkInstance is loaded successfuly.

Yes this is what we want, I believe; unless, is there a non-error state I'm missing when there's no sdkInstance and the loadingStatus is not "pending"?

Copy link
Contributor

@AleGastelum AleGastelum Dec 4, 2025

Choose a reason for hiding this comment

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

This is the case I was thinking when making my suggestion:

  1. sdkInstance fails to load, loadingStatus becomes REJECTED, hook error is set.
  2. sdkInstance attempts to load again, loadingStatus becomes PENDING again
  • Current implementation would persist error (and only remove it if sdkInstance resolves successfully)
  • My suggestion would set the error to null (and only set an error again in case of a second REJECTED)

Based on your response it seems current implementation achieves the desired experience.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah right, yes, I think we want the error to persist in this case. Though maybe there's more discussion to be had here at some point.


useEffect(() => {
if (!sdkInstance) {
Expand Down
Loading
Loading