Skip to content

Commit ac3bb9e

Browse files
authored
Feat: Adding error handling to react hooks (#833)
* Adding error handling to react hooks * Adding changeset * Adding savePayment to oneTime session creation * Adding utility function for payment session creation error handling * rewording error message * fixing the closure bug in effect cleanup * Updated the error message to be more specific to the components loaded and required * Addressing comments
1 parent 823a813 commit ac3bb9e

20 files changed

+958
-91
lines changed

.changeset/curvy-steaks-wonder.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@paypal/react-paypal-js": minor
3+
---
4+
5+
Adding error handling to payment session hooks to help merchants identify and resolve when session creation methods throw errors due to missing components.

packages/react-paypal-js/src/v6/hooks/usePayLaterOneTimePaymentSession.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ jest.mock("./usePayPal", () => ({
1313
}));
1414

1515
jest.mock("../utils", () => ({
16+
...jest.requireActual("../utils"),
1617
useProxyProps: jest.fn(),
1718
}));
1819

@@ -166,6 +167,59 @@ describe("usePayLaterOneTimePaymentSession", () => {
166167
expect(result.current.error).toBeNull();
167168
});
168169

170+
test.each([
171+
{
172+
description: "Error object",
173+
thrownError: new Error("Required components not loaded in SDK"),
174+
},
175+
{
176+
description: "non-Error string",
177+
thrownError: "String error message",
178+
},
179+
])(
180+
"should handle $description thrown by createPayLaterOneTimePaymentSession",
181+
({ thrownError }) => {
182+
const mockCreatePayLaterOneTimePaymentSession = jest
183+
.fn()
184+
.mockImplementation(() => {
185+
throw thrownError;
186+
});
187+
188+
(usePayPal as jest.Mock).mockReturnValue({
189+
sdkInstance: {
190+
createPayLaterOneTimePaymentSession:
191+
mockCreatePayLaterOneTimePaymentSession,
192+
},
193+
loadingStatus: INSTANCE_LOADING_STATE.RESOLVED,
194+
});
195+
196+
const {
197+
result: {
198+
current: { error },
199+
},
200+
} = renderHook(() =>
201+
usePayLaterOneTimePaymentSession({
202+
presentationMode: "popup",
203+
orderId: "test-order-id",
204+
onApprove: jest.fn(),
205+
onCancel: jest.fn(),
206+
onError: jest.fn(),
207+
}),
208+
);
209+
210+
expectCurrentErrorValue(error);
211+
212+
expect(error?.message).toContain("Failed to create");
213+
expect(error?.message).toContain("session");
214+
expect(error?.message).toContain(
215+
"This may occur if the required component",
216+
);
217+
expect(
218+
(error as Error & { cause: typeof thrownError })?.cause,
219+
).toBe(thrownError);
220+
},
221+
);
222+
169223
test.each([
170224
[INSTANCE_LOADING_STATE.PENDING, true],
171225
[INSTANCE_LOADING_STATE.RESOLVED, false],

packages/react-paypal-js/src/v6/hooks/usePayLaterOneTimePaymentSession.ts

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef } from "react";
33
import { usePayPal } from "./usePayPal";
44
import { useIsMountedRef } from "./useIsMounted";
55
import { useError } from "./useError";
6-
import { useProxyProps } from "../utils";
6+
import { useProxyProps, createPaymentSession } from "../utils";
77
import { INSTANCE_LOADING_STATE } from "../types/PayPalProviderEnums";
88

99
import type {
@@ -39,31 +39,53 @@ export function usePayLaterOneTimePaymentSession({
3939
const sessionRef = useRef<OneTimePaymentSession | null>(null);
4040
const proxyCallbacks = useProxyProps(callbacks);
4141
const [error, setError] = useError();
42+
43+
// Prevents retrying session creation with a failed SDK instance
44+
const failedSdkRef = useRef<unknown>(null);
45+
4246
const isPending = loadingStatus === INSTANCE_LOADING_STATE.PENDING;
4347

4448
const handleDestroy = useCallback(() => {
4549
sessionRef.current?.destroy();
4650
sessionRef.current = null;
4751
}, []);
4852

49-
// Separate error reporting effect to avoid infinite loops with proxyCallbacks
53+
// Handle SDK availability
5054
useEffect(() => {
55+
// Reset failed SDK tracking when SDK instance changes
56+
if (failedSdkRef.current !== sdkInstance) {
57+
failedSdkRef.current = null;
58+
}
59+
5160
if (sdkInstance) {
5261
setError(null);
5362
} else if (loadingStatus !== INSTANCE_LOADING_STATE.PENDING) {
5463
setError(new Error("no sdk instance available"));
5564
}
5665
}, [sdkInstance, setError, loadingStatus]);
5766

67+
// Create and manage session lifecycle
5868
useEffect(() => {
5969
if (!sdkInstance) {
6070
return;
6171
}
6272

63-
const newSession = sdkInstance.createPayLaterOneTimePaymentSession({
64-
orderId,
65-
...proxyCallbacks,
66-
});
73+
const newSession = createPaymentSession(
74+
() =>
75+
sdkInstance.createPayLaterOneTimePaymentSession({
76+
orderId,
77+
...proxyCallbacks,
78+
}),
79+
failedSdkRef,
80+
sdkInstance,
81+
setError,
82+
"paypal-payments",
83+
);
84+
85+
if (!newSession) {
86+
return;
87+
}
88+
6789
sessionRef.current = newSession;
6890

6991
// check for resume flow in redirect-based presentation modes
@@ -89,15 +111,10 @@ export function usePayLaterOneTimePaymentSession({
89111
handleReturnFromPayPal();
90112
}
91113

92-
return handleDestroy;
93-
}, [
94-
sdkInstance,
95-
orderId,
96-
proxyCallbacks,
97-
handleDestroy,
98-
presentationMode,
99-
setError,
100-
]);
114+
return () => {
115+
newSession.destroy();
116+
};
117+
}, [sdkInstance, orderId, proxyCallbacks, presentationMode, setError]);
101118

102119
const handleCancel = useCallback(() => {
103120
sessionRef.current?.cancel();

packages/react-paypal-js/src/v6/hooks/usePayPalCreditOneTimePaymentSession.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { UsePayPalCreditOneTimePaymentSessionProps } from "./usePayPalCredi
1515
jest.mock("./usePayPal");
1616

1717
jest.mock("../utils", () => ({
18+
...jest.requireActual("../utils"),
1819
useProxyProps: jest.fn(),
1920
}));
2021

@@ -125,6 +126,57 @@ describe("usePayPalCreditOneTimePaymentSession", () => {
125126
expect(result.current.error).toBeNull();
126127
});
127128

129+
test.each([
130+
{
131+
description: "Error object",
132+
thrownError: new Error("Required components not loaded in SDK"),
133+
},
134+
{
135+
description: "non-Error string",
136+
thrownError: "String error message",
137+
},
138+
])(
139+
"should handle $description thrown by createPayPalCreditOneTimePaymentSession",
140+
({ thrownError }) => {
141+
const mockSdkInstanceWithError = {
142+
createPayPalCreditOneTimePaymentSession: jest
143+
.fn()
144+
.mockImplementation(() => {
145+
throw thrownError;
146+
}),
147+
};
148+
149+
mockPayPalContext({ sdkInstance: mockSdkInstanceWithError });
150+
151+
const props: UsePayPalCreditOneTimePaymentSessionProps = {
152+
presentationMode: "popup",
153+
orderId: "test-order-id",
154+
onApprove: jest.fn(),
155+
onCancel: jest.fn(),
156+
onError: jest.fn(),
157+
};
158+
159+
const {
160+
result: {
161+
current: { error },
162+
},
163+
} = renderHook(() =>
164+
usePayPalCreditOneTimePaymentSession(props),
165+
);
166+
167+
expectCurrentErrorValue(error);
168+
169+
expect(error?.message).toContain("Failed to create");
170+
expect(error?.message).toContain("session");
171+
expect(error?.message).toContain(
172+
"This may occur if the required component",
173+
);
174+
expect(
175+
(error as Error & { cause: typeof thrownError })?.cause,
176+
).toBe(thrownError);
177+
},
178+
);
179+
128180
test.each([
129181
[INSTANCE_LOADING_STATE.PENDING, true],
130182
[INSTANCE_LOADING_STATE.RESOLVED, false],

packages/react-paypal-js/src/v6/hooks/usePayPalCreditOneTimePaymentSession.ts

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef } from "react";
33
import { usePayPal } from "./usePayPal";
44
import { useIsMountedRef } from "./useIsMounted";
55
import { useError } from "./useError";
6-
import { useProxyProps } from "../utils";
6+
import { useProxyProps, createPaymentSession } from "../utils";
77
import { INSTANCE_LOADING_STATE } from "../types/PayPalProviderEnums";
88

99
import type {
@@ -59,6 +59,10 @@ export function usePayPalCreditOneTimePaymentSession({
5959
const sessionRef = useRef<OneTimePaymentSession | null>(null);
6060
const proxyCallbacks = useProxyProps(callbacks);
6161
const [error, setError] = useError();
62+
63+
// Prevents retrying session creation with a failed SDK instance
64+
const failedSdkRef = useRef<unknown>(null);
65+
6266
const isPending = loadingStatus === INSTANCE_LOADING_STATE.PENDING;
6367

6468
const handleDestroy = useCallback(() => {
@@ -70,25 +74,41 @@ export function usePayPalCreditOneTimePaymentSession({
7074
sessionRef.current?.cancel();
7175
}, []);
7276

73-
// Separate error reporting effect to avoid infinite loops with proxyCallbacks
77+
// Handle SDK availability
7478
useEffect(() => {
79+
// Reset failed SDK tracking when SDK instance changes
80+
if (failedSdkRef.current !== sdkInstance) {
81+
failedSdkRef.current = null;
82+
}
83+
7584
if (sdkInstance) {
7685
setError(null);
7786
} else if (loadingStatus !== INSTANCE_LOADING_STATE.PENDING) {
7887
setError(new Error("no sdk instance available"));
7988
}
8089
}, [sdkInstance, setError, loadingStatus]);
8190

91+
// Create and manage session lifecycle
8292
useEffect(() => {
8393
if (!sdkInstance) {
8494
return;
8595
}
8696

87-
// Create session (can be created without orderId for resume detection)
88-
const newSession = sdkInstance.createPayPalCreditOneTimePaymentSession({
89-
orderId,
90-
...proxyCallbacks,
91-
});
97+
const newSession = createPaymentSession(
98+
() =>
99+
sdkInstance.createPayPalCreditOneTimePaymentSession({
100+
orderId,
101+
...proxyCallbacks,
102+
}),
103+
failedSdkRef,
104+
sdkInstance,
105+
setError,
106+
"paypal-payments",
107+
);
108+
109+
if (!newSession) {
110+
return;
111+
}
92112

93113
sessionRef.current = newSession;
94114

@@ -115,15 +135,10 @@ export function usePayPalCreditOneTimePaymentSession({
115135
handleReturnFromPayPal();
116136
}
117137

118-
return handleDestroy;
119-
}, [
120-
sdkInstance,
121-
orderId,
122-
proxyCallbacks,
123-
handleDestroy,
124-
presentationMode,
125-
setError,
126-
]);
138+
return () => {
139+
newSession.destroy();
140+
};
141+
}, [sdkInstance, orderId, proxyCallbacks, presentationMode, setError]);
127142

128143
const handleClick = useCallback(async () => {
129144
if (!isMountedRef.current) {

0 commit comments

Comments
 (0)