Skip to content

Commit 0212046

Browse files
nityaspNitya
andauthored
Feature/client token check provider (#781)
* checking clientToken * checking clientToken to render Provider children * Updating testcases * Changeset added * addressing PR comments * Using deferLoading option * Removing deferLoading prop and allowing clientToken to be a string or promise * improving clientToken Promise error with descriptive message --------- Co-authored-by: Nitya <nsattaru@paypal.com>
1 parent 35f2a0b commit 0212046

File tree

5 files changed

+278
-37
lines changed

5 files changed

+278
-37
lines changed

.changeset/mighty-towns-dream.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": patch
3+
---
4+
5+
Checking clientToken to render the PayPalProvider children while rendering the provider

packages/react-paypal-js/src/v6/components/PayPalProvider.test.tsx

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,148 @@ describe("PayPalProvider", () => {
290290
});
291291
});
292292

293+
describe("ClientToken Handling", () => {
294+
test("should wait for clientToken and render children immediately", async () => {
295+
const mockCreateInstance = jest
296+
.fn()
297+
.mockResolvedValue(createMockSdkInstance());
298+
299+
(loadCoreSdkScript as jest.Mock).mockResolvedValue({
300+
createInstance: mockCreateInstance,
301+
});
302+
303+
const childRenderSpy = jest.fn();
304+
const TestChild = () => {
305+
childRenderSpy();
306+
return <div>Test Child Content</div>;
307+
};
308+
309+
const { state, TestComponent } = setupTestComponent();
310+
const { rerender, getByText } = render(
311+
<PayPalProvider
312+
components={["paypal-payments"]}
313+
clientToken={undefined}
314+
environment="sandbox"
315+
>
316+
<TestComponent>
317+
<TestChild />
318+
</TestComponent>
319+
</PayPalProvider>,
320+
);
321+
322+
expect(childRenderSpy).toHaveBeenCalled();
323+
expect(getByText("Test Child Content")).toBeInTheDocument();
324+
expect(loadCoreSdkScript).not.toHaveBeenCalled();
325+
expect(state.loadingStatus).toBe(INSTANCE_LOADING_STATE.PENDING);
326+
expect(state.sdkInstance).toBe(null);
327+
328+
rerender(
329+
<PayPalProvider
330+
components={["paypal-payments"]}
331+
clientToken={TEST_CLIENT_TOKEN}
332+
environment="sandbox"
333+
>
334+
<TestComponent>
335+
<TestChild />
336+
</TestComponent>
337+
</PayPalProvider>,
338+
);
339+
340+
await waitFor(() => expect(loadCoreSdkScript).toHaveBeenCalled());
341+
await waitFor(() => expectResolvedState(state));
342+
expect(mockCreateInstance).toHaveBeenCalledWith(
343+
expect.objectContaining({
344+
clientToken: TEST_CLIENT_TOKEN,
345+
}),
346+
);
347+
});
348+
349+
test("should create instance immediately if clientToken is provided on mount", async () => {
350+
const mockCreateInstance = jest
351+
.fn()
352+
.mockResolvedValue(createMockSdkInstance());
353+
354+
(loadCoreSdkScript as jest.Mock).mockResolvedValue({
355+
createInstance: mockCreateInstance,
356+
});
357+
358+
const { state } = renderProvider({
359+
clientToken: TEST_CLIENT_TOKEN,
360+
});
361+
362+
await waitFor(() => expectResolvedState(state));
363+
expect(mockCreateInstance).toHaveBeenCalledWith(
364+
expect.objectContaining({
365+
clientToken: TEST_CLIENT_TOKEN,
366+
}),
367+
);
368+
});
369+
370+
test("should handle Promise rejection for clientToken", async () => {
371+
const tokenError = new Error("Token fetch failed");
372+
const tokenPromise = Promise.reject(tokenError);
373+
374+
tokenPromise.catch(() => {});
375+
376+
const { state } = renderProvider({
377+
clientToken: tokenPromise,
378+
});
379+
380+
await waitFor(() => {
381+
expect(state.loadingStatus).toBe(
382+
INSTANCE_LOADING_STATE.REJECTED,
383+
);
384+
expect(state.error?.message).toBe(
385+
"Failed to resolve clientToken. Expected a Promise that resolves to a string, but it was rejected with: Token fetch failed",
386+
);
387+
});
388+
expect(loadCoreSdkScript).not.toHaveBeenCalled();
389+
});
390+
391+
test("should resolve Promise and load SDK when clientToken is a Promise", async () => {
392+
const mockCreateInstance = jest
393+
.fn()
394+
.mockResolvedValue(createMockSdkInstance());
395+
396+
(loadCoreSdkScript as jest.Mock).mockResolvedValue({
397+
createInstance: mockCreateInstance,
398+
});
399+
400+
const childRenderSpy = jest.fn();
401+
const TestChild = () => {
402+
childRenderSpy();
403+
return <div>Test Child Content</div>;
404+
};
405+
406+
let resolveToken: (value: string) => void;
407+
const tokenPromise = new Promise<string>((resolve) => {
408+
resolveToken = resolve;
409+
});
410+
411+
const { state, TestComponent } = setupTestComponent();
412+
const { getByText } = render(
413+
<PayPalProvider
414+
components={["paypal-payments"]}
415+
clientToken={tokenPromise}
416+
environment="sandbox"
417+
>
418+
<TestComponent>
419+
<TestChild />
420+
</TestComponent>
421+
</PayPalProvider>,
422+
);
423+
424+
expect(childRenderSpy).toHaveBeenCalled();
425+
expect(getByText("Test Child Content")).toBeInTheDocument();
426+
expect(loadCoreSdkScript).not.toHaveBeenCalled();
427+
428+
resolveToken!(TEST_CLIENT_TOKEN);
429+
430+
await waitFor(() => expect(loadCoreSdkScript).toHaveBeenCalled());
431+
await waitFor(() => expectResolvedState(state));
432+
});
433+
});
434+
293435
describe("Eligibility Loading", () => {
294436
test("should load eligible payment methods", async () => {
295437
const { state } = renderProvider();

packages/react-paypal-js/src/v6/components/PayPalProvider.tsx

Lines changed: 83 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -29,26 +29,57 @@ import type { usePayPal } from "../hooks/usePayPal";
2929

3030
type PayPalProviderProps = Omit<
3131
CreateInstanceOptions<readonly [Components, ...Components[]]>,
32-
"components"
32+
"components" | "clientToken"
3333
> &
3434
LoadCoreSdkScriptOptions & {
3535
components?: Components[];
3636
eligibleMethodsResponse?: FindEligiblePaymentMethodsResponse;
3737
eligibleMethodsPayload?: FindEligiblePaymentMethodsRequestPayload;
3838
children: React.ReactNode;
39+
clientToken?: string | Promise<string>;
3940
};
4041

4142
/**
4243
* {@link PayPalProvider} creates the SDK script, component scripts, runs eligibility, then
4344
* provides these in context to child components via the {@link usePayPal} hook.
4445
*
46+
* SDK loading is automatically deferred until clientToken is available.
47+
* clientToken can be a string, Promise, or undefined.
48+
*
4549
* @example
50+
* // With string token
4651
* <PayPalProvider
47-
* clientToken={clientToken}
52+
* clientToken={token}
4853
* components={["paypal-payments", "venmo-payments"]}
4954
* pageType="checkout"
50-
* >
51-
* // payment buttons
55+
* >
56+
* <PayPalButton />
57+
* </PayPalProvider>
58+
*
59+
* @example
60+
* // With Promise token (memoize to prevent re-fetching)
61+
* const tokenPromise = useMemo(() => fetchClientToken(), []);
62+
*
63+
* <PayPalProvider
64+
* clientToken={tokenPromise}
65+
* pageType="checkout"
66+
* >
67+
* <PayPalButton />
68+
* </PayPalProvider>
69+
*
70+
* @example
71+
* // With deferred loading
72+
* const [clientToken, setClientToken] = useState<string>();
73+
*
74+
* useEffect(() => {
75+
* fetchClientToken().then(setClientToken);
76+
* }, []);
77+
*
78+
* <PayPalProvider
79+
* clientToken={clientToken}
80+
* pageType="checkout"
81+
* >
82+
* <PayPalButton />
5283
* </PayPalProvider>
5384
*/
5485
export const PayPalProvider: React.FC<PayPalProviderProps> = ({
@@ -70,6 +101,9 @@ export const PayPalProvider: React.FC<PayPalProviderProps> = ({
70101
const [paypalNamespace, setPaypalNamespace] =
71102
useState<PayPalV6Namespace | null>(null);
72103
const [state, dispatch] = useReducer(instanceReducer, initialState);
104+
const [clientTokenValue, setClientTokenValue] = useState<
105+
string | undefined
106+
>(undefined);
73107
// Ref to hold script options to avoid re-running effect
74108
const loadCoreScriptOptions = useRef(scriptOptions);
75109
// Using the error hook here so it can participate in side-effects provided by the hook. The actual error
@@ -78,7 +112,7 @@ export const PayPalProvider: React.FC<PayPalProviderProps> = ({
78112

79113
const { eligibleMethods, isLoading } = useEligibleMethods({
80114
eligibleMethodsResponse,
81-
clientToken,
115+
clientToken: clientTokenValue,
82116
payload: eligibleMethodsPayload,
83117
environment: loadCoreScriptOptions.current.environment,
84118
});
@@ -92,33 +126,58 @@ export const PayPalProvider: React.FC<PayPalProviderProps> = ({
92126
}
93127
}, [isLoading, eligibleMethods]);
94128

95-
// Load Core SDK Script
129+
// Load Core SDK script
96130
useEffect(() => {
97131
let isSubscribed = true;
98132

99133
const loadSdk = async () => {
134+
let token: string | undefined;
135+
136+
if (!clientToken) {
137+
setClientTokenValue(undefined);
138+
return;
139+
}
140+
141+
if (typeof clientToken === "string") {
142+
token = clientToken;
143+
setClientTokenValue(token);
144+
} else {
145+
try {
146+
token = await clientToken;
147+
setClientTokenValue(token);
148+
} catch (error) {
149+
const clientTokenError = new Error(
150+
`Failed to resolve clientToken. Expected a Promise that resolves to a string, but it was rejected with: ${toError(error).message}`,
151+
);
152+
setError(clientTokenError);
153+
dispatch({
154+
type: INSTANCE_DISPATCH_ACTION.SET_ERROR,
155+
value: clientTokenError,
156+
});
157+
return;
158+
}
159+
}
160+
161+
if (!isSubscribed || !token) {
162+
return;
163+
}
164+
100165
try {
101166
const paypal = await loadCoreSdkScript({
102167
environment: loadCoreScriptOptions.current.environment,
103168
debug: loadCoreScriptOptions.current.debug,
104169
dataNamespace: loadCoreScriptOptions.current.dataNamespace,
105170
});
106171

107-
if (!isSubscribed) {
108-
return;
109-
}
110-
111172
if (paypal) {
112173
setPaypalNamespace(paypal);
113174
}
114175
} catch (error) {
115-
if (isSubscribed) {
116-
setError(error);
117-
dispatch({
118-
type: INSTANCE_DISPATCH_ACTION.SET_ERROR,
119-
value: toError(error),
120-
});
121-
}
176+
setError(error);
177+
dispatch({
178+
type: INSTANCE_DISPATCH_ACTION.SET_ERROR,
179+
value: toError(error),
180+
});
122181
}
123182
};
124183

@@ -127,14 +186,18 @@ export const PayPalProvider: React.FC<PayPalProviderProps> = ({
127186
return () => {
128187
isSubscribed = false;
129188
};
130-
}, [setError]);
189+
}, [clientToken, setError]);
131190

132191
// Create SDK Instance
133192
useEffect(() => {
134193
if (!paypalNamespace) {
135194
return;
136195
}
137196

197+
if (!clientTokenValue) {
198+
return;
199+
}
200+
138201
// This dispatch is for instance creations after initial mount
139202
dispatch({
140203
type: INSTANCE_DISPATCH_ACTION.SET_LOADING_STATUS,
@@ -148,7 +211,7 @@ export const PayPalProvider: React.FC<PayPalProviderProps> = ({
148211
// Create SDK instance
149212
const instance = await paypalNamespace.createInstance({
150213
clientMetadataId,
151-
clientToken,
214+
clientToken: clientTokenValue,
152215
components: memoizedComponents,
153216
locale,
154217
pageType,
@@ -183,7 +246,7 @@ export const PayPalProvider: React.FC<PayPalProviderProps> = ({
183246
};
184247
}, [
185248
clientMetadataId,
186-
clientToken,
249+
clientTokenValue,
187250
locale,
188251
memoizedComponents,
189252
pageType,

0 commit comments

Comments
 (0)