Skip to content

Commit 2fb0af0

Browse files
authored
Merge pull request #687 from paypal/fix/cardfields-callbacks-stale-state
(fix) Extended useProxyProps to card fields to prevent stale state in callbacks
2 parents 0575877 + e37f286 commit 2fb0af0

File tree

10 files changed

+1065
-12
lines changed

10 files changed

+1065
-12
lines changed

.changeset/long-pears-shout.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+
(fix) Proxy props added to Card Fields to prevent stale closure

packages/react-paypal-js/src/components/cardFields/PayPalCVVField.test.tsx

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import React from "react";
2-
import { render, waitFor } from "@testing-library/react";
2+
import { render, waitFor, screen, fireEvent } from "@testing-library/react";
33
import { ErrorBoundary } from "react-error-boundary";
4-
import { loadScript } from "@paypal/paypal-js";
4+
import {
5+
loadScript,
6+
PayPalCardFieldsIndividualFieldOptions,
7+
} from "@paypal/paypal-js";
58
import { mock } from "jest-mock-extended";
69

710
import { PayPalCVVField } from "./PayPalCVVField";
@@ -134,6 +137,122 @@ describe("PayPalCVVField", () => {
134137
spyConsoleError.mockRestore();
135138
});
136139

140+
test("should not create stale closure when passing callbacks", async () => {
141+
const spyConsoleError = jest
142+
.spyOn(console, "error")
143+
.mockImplementation();
144+
145+
const MOCK_CARDFIELD_ID = "mock-cardfield";
146+
147+
window.paypal = {
148+
CardFields: jest.fn(
149+
() =>
150+
({
151+
isEligible: jest.fn().mockReturnValue(true),
152+
CVVField: jest.fn(
153+
(
154+
options: PayPalCardFieldsIndividualFieldOptions,
155+
) => ({
156+
render: jest.fn(
157+
(element: string | HTMLElement) => {
158+
// Simulate adding element
159+
if (typeof element !== "string") {
160+
const child =
161+
document.createElement("div");
162+
child.setAttribute(
163+
"id",
164+
MOCK_CARDFIELD_ID,
165+
);
166+
child.setAttribute(
167+
"data-testid",
168+
MOCK_CARDFIELD_ID,
169+
);
170+
if (options?.inputEvents?.onFocus) {
171+
child.addEventListener(
172+
"focus",
173+
options.inputEvents
174+
.onFocus as unknown as EventListener,
175+
);
176+
}
177+
element.append(child);
178+
}
179+
return Promise.resolve();
180+
},
181+
),
182+
close: jest.fn(() => Promise.resolve()),
183+
}),
184+
),
185+
}) as unknown as PayPalCardFieldsComponent,
186+
),
187+
version: "",
188+
};
189+
190+
const onFocusFn = jest.fn();
191+
192+
const Wrapper = () => {
193+
const [count, setCount] = React.useState(0);
194+
195+
function onFocus() {
196+
onFocusFn(count);
197+
}
198+
199+
return (
200+
<div>
201+
<button
202+
data-testid="count-button"
203+
onClick={() => setCount(count + 1)}
204+
>
205+
Count: {count}
206+
</button>
207+
<PayPalScriptProvider
208+
options={{
209+
clientId: "test-client",
210+
currency: "USD",
211+
intent: "authorize",
212+
components: "card-fields",
213+
dataClientToken: "test-data-client-token",
214+
}}
215+
>
216+
<PayPalCardFieldsProvider
217+
onApprove={mockOnApprove}
218+
createOrder={mockCreateOrder}
219+
onError={mockOnError}
220+
>
221+
<PayPalCVVField
222+
inputEvents={{
223+
onFocus: onFocus,
224+
}}
225+
/>
226+
</PayPalCardFieldsProvider>
227+
</PayPalScriptProvider>
228+
{wrapper}
229+
</div>
230+
);
231+
};
232+
233+
render(<Wrapper />);
234+
await waitFor(() => expect(onError).toHaveBeenCalledTimes(0));
235+
236+
const countButton = screen.getByTestId("count-button");
237+
const cvvField = screen.getByTestId(MOCK_CARDFIELD_ID);
238+
239+
expect(screen.getByText("Count: 0")).toBeInTheDocument();
240+
fireEvent.focus(cvvField);
241+
expect(onFocusFn).toHaveBeenCalledWith(0);
242+
243+
fireEvent.click(countButton);
244+
expect(await screen.findByText("Count: 1")).toBeInTheDocument();
245+
fireEvent.focus(cvvField);
246+
expect(onFocusFn).toHaveBeenCalledWith(1);
247+
248+
fireEvent.click(countButton);
249+
expect(await screen.findByText("Count: 2")).toBeInTheDocument();
250+
fireEvent.focus(cvvField);
251+
expect(onFocusFn).toHaveBeenCalledWith(2);
252+
253+
spyConsoleError.mockRestore();
254+
});
255+
137256
test("should render component with a specific placeholder", async () => {
138257
const spyConsoleError = jest
139258
.spyOn(console, "error")

packages/react-paypal-js/src/components/cardFields/PayPalCardField.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from "react";
33
import { usePayPalCardFields } from "./hooks";
44
import { hasChildren } from "./utils";
55
import { CARD_FIELDS_CONTEXT_ERROR } from "../../constants";
6+
import { useProxyProps } from "../../hooks/useProxyProps";
67

78
import type {
89
FieldComponentName,
@@ -18,6 +19,9 @@ export const PayPalCardField: React.FC<
1819
usePayPalCardFields();
1920

2021
const containerRef = useRef<HTMLDivElement>(null);
22+
const proxyInputEvents = useProxyProps(
23+
options.inputEvents as Record<PropertyKey, unknown>,
24+
);
2125

2226
// Set errors is state so that they can be caught by React's error boundary
2327
const [, setError] = useState(null);
@@ -36,6 +40,9 @@ export const PayPalCardField: React.FC<
3640
if (!containerRef.current) {
3741
return closeComponent;
3842
}
43+
if (options.inputEvents) {
44+
options.inputEvents = proxyInputEvents;
45+
}
3946

4047
const registeredField = registerField(
4148
fieldName,

packages/react-paypal-js/src/components/cardFields/PayPalCardFieldsProvider.test.tsx

Lines changed: 105 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import React from "react";
2-
import { render, waitFor } from "@testing-library/react";
1+
import React, { useState } from "react";
2+
import { render, waitFor, screen, fireEvent } from "@testing-library/react";
33
import { ErrorBoundary } from "react-error-boundary";
44
import { PayPalNamespace, loadScript } from "@paypal/paypal-js";
55
import { mock } from "jest-mock-extended";
@@ -11,7 +11,10 @@ import { PayPalNumberField } from "./PayPalNumberField";
1111
import { PayPalCVVField } from "./PayPalCVVField";
1212
import { PayPalExpiryField } from "./PayPalExpiryField";
1313

14-
import type { PayPalCardFieldsComponent } from "@paypal/paypal-js";
14+
import type {
15+
PayPalCardFieldsComponent,
16+
PayPalCardFieldsComponentOptions,
17+
} from "@paypal/paypal-js";
1518
import type { ReactNode } from "react";
1619

1720
const MOCK_ELEMENT_ID = "mock-element";
@@ -377,4 +380,103 @@ describe("PayPalCardFieldsProvider", () => {
377380

378381
spyConsoleError.mockRestore();
379382
});
383+
384+
test("should not create a stale closure when passing callbacks", async () => {
385+
window.paypal = {
386+
CardFields: jest.fn(
387+
(options?: PayPalCardFieldsComponentOptions) =>
388+
({
389+
isEligible: jest.fn().mockReturnValue(true),
390+
NumberField: jest.fn().mockReturnValue({
391+
render: jest.fn((element: string | HTMLElement) => {
392+
// Simulate adding element
393+
if (typeof element !== "string") {
394+
const child = document.createElement("div");
395+
child.setAttribute("id", MOCK_ELEMENT_ID);
396+
child.setAttribute(
397+
"data-testid",
398+
MOCK_ELEMENT_ID,
399+
);
400+
if (options?.inputEvents?.onFocus) {
401+
child.addEventListener(
402+
"focus",
403+
options.inputEvents
404+
.onFocus as unknown as EventListener,
405+
);
406+
}
407+
element.append(child);
408+
}
409+
return Promise.resolve();
410+
}),
411+
close: jest.fn(() => Promise.resolve()),
412+
}),
413+
}) as unknown as PayPalCardFieldsComponent,
414+
),
415+
version: "",
416+
};
417+
418+
const onFocusFn = jest.fn();
419+
420+
const Wrapper = () => {
421+
const [count, setCount] = useState(0);
422+
423+
function onFocus() {
424+
onFocusFn(count);
425+
}
426+
427+
return (
428+
<div>
429+
<button
430+
data-testid="count-button"
431+
onClick={() => setCount(count + 1)}
432+
>
433+
Count: {count}
434+
</button>
435+
<PayPalScriptProvider
436+
options={{
437+
clientId: "test-client",
438+
currency: "USD",
439+
intent: "authorize",
440+
dataClientToken: "test-data-client-token",
441+
components: "card-fields",
442+
}}
443+
>
444+
<PayPalCardFieldsProvider
445+
onApprove={mockOnApprove}
446+
createOrder={mockCreateOrder}
447+
onError={mockOnError}
448+
inputEvents={{
449+
onFocus,
450+
}}
451+
>
452+
<PayPalNumberField />
453+
</PayPalCardFieldsProvider>
454+
</PayPalScriptProvider>
455+
</div>
456+
);
457+
};
458+
459+
render(<Wrapper />);
460+
461+
await waitFor(() => {
462+
expect(getMockElementsRendered().length).toEqual(1);
463+
});
464+
465+
const countButton = screen.getByTestId("count-button");
466+
const numberField = screen.getByTestId(MOCK_ELEMENT_ID);
467+
468+
expect(screen.getByText("Count: 0")).toBeInTheDocument();
469+
fireEvent.focus(numberField);
470+
expect(onFocusFn).toHaveBeenCalledWith(0);
471+
472+
fireEvent.click(countButton);
473+
expect(await screen.findByText("Count: 1")).toBeInTheDocument();
474+
fireEvent.focus(numberField);
475+
expect(onFocusFn).toHaveBeenCalledWith(1);
476+
477+
fireEvent.click(countButton);
478+
expect(await screen.findByText("Count: 2")).toBeInTheDocument();
479+
fireEvent.focus(numberField);
480+
expect(onFocusFn).toHaveBeenCalledWith(2);
481+
});
380482
});

packages/react-paypal-js/src/components/cardFields/PayPalCardFieldsProvider.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { generateMissingCardFieldsError } from "./utils";
77
import { PayPalCardFieldsContext } from "./context";
88
import { usePayPalCardFieldsRegistry } from "./hooks";
99
import { FullWidthContainer } from "../ui/FullWidthContainer";
10+
import { useProxyProps } from "../../hooks/useProxyProps";
1011

1112
import type {
1213
PayPalCardFieldsComponentOptions,
@@ -32,6 +33,10 @@ export const PayPalCardFieldsProvider = ({
3233
children,
3334
...props
3435
}: CardFieldsProviderProps): JSX.Element => {
36+
const proxyInputEvents = useProxyProps(
37+
props.inputEvents as Record<PropertyKey, unknown>,
38+
);
39+
const proxyProps = useProxyProps(props);
3540
const [{ isResolved, options }] = usePayPalScriptReducer();
3641
const { fields, registerField, unregisterField } =
3742
usePayPalCardFieldsRegistry();
@@ -48,13 +53,16 @@ export const PayPalCardFieldsProvider = ({
4853
if (!isResolved) {
4954
return;
5055
}
56+
if (props.inputEvents) {
57+
proxyProps.inputEvents = proxyInputEvents;
58+
}
5159

5260
try {
5361
cardFieldsInstance.current =
5462
getPayPalWindowNamespace(
5563
options[SDK_SETTINGS.DATA_NAMESPACE],
5664
).CardFields?.({
57-
...props,
65+
...proxyProps,
5866
}) ?? null;
5967
} catch (error) {
6068
setError(() => {

0 commit comments

Comments
 (0)