From 24f4dc73aa9ca63adce9c811102442e6f19179b2 Mon Sep 17 00:00:00 2001 From: Yu Long Date: Wed, 3 Jan 2024 15:03:35 +0100 Subject: [PATCH 1/4] refactor: improve the QR payments status check call, wait for the previous call to finish --- .../components/internal/QRLoader/QRLoader.tsx | 56 ++++++++++--------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/lib/src/components/internal/QRLoader/QRLoader.tsx b/packages/lib/src/components/internal/QRLoader/QRLoader.tsx index 6e60be3729..85e6870eb0 100644 --- a/packages/lib/src/components/internal/QRLoader/QRLoader.tsx +++ b/packages/lib/src/components/internal/QRLoader/QRLoader.tsx @@ -19,7 +19,7 @@ import useAutoFocus from '../../../utils/useAutoFocus'; const QRCODE_URL = 'barcode.shtml?barcodeType=qrCode&fileType=png&data='; class QRLoader extends Component { - private interval; + private timeoutId; constructor(props) { super(props); @@ -46,35 +46,39 @@ class QRLoader extends Component { introduction: 'wechatpay.scanqrcode' }; - // Retry until getting a complete response from the server or it times out\ - // Changes interval time to 10 seconds after 1 minute (60 seconds) - public statusInterval = () => { - this.checkStatus(); - - this.setState({ timePassed: this.state.timePassed + this.props.delay }); - - if (this.state.timePassed >= this.props.throttleTime) { - this.setState({ delay: this.props.throttledInterval }); - } - }; - componentDidMount() { - this.interval = setInterval(this.statusInterval, this.state.delay); + this.statusInterval(); } - public redirectToApp = url => { + componentWillUnmount() { + clearTimeout(this.timeoutId); + } + + public redirectToApp = (url: string | URL) => { window.location.assign(url); }; - componentDidUpdate(prevProps, prevState) { - if (prevState.delay !== this.state.delay) { - clearInterval(this.interval); - this.interval = setInterval(this.statusInterval, this.state.delay); - } - } + // Retry until getting a complete response from the server, or it times out + public statusInterval = (responseTime = 0) => { + // If we are already in the final statuses, do not poll! + if (this.state.expired || this.state.completed) return; - componentWillUnmount() { - clearInterval(this.interval); + this.setState(previous => ({ timePassed: previous.timePassed + this.props.delay + responseTime })); + // Changes interval time to 10 seconds after 1 minute (60 seconds) + const newDelay = this.state.timePassed >= this.props.throttleTime ? this.props.throttledInterval : this.state.delay; + this.pollStatus(newDelay); + }; + + private pollStatus(delay: number) { + clearTimeout(this.timeoutId); + this.timeoutId = setTimeout(async () => { + // Wait for previous status call to finish. + // Also taking the server response time into the consideration to calculate timePassed. + const start = performance.now(); + await this.checkStatus(); + const end = performance.now(); + this.statusInterval(Math.round(end - start)); + }, delay); } private onTick = (time): void => { @@ -83,12 +87,12 @@ class QRLoader extends Component { private onTimeUp = (): void => { this.setState({ expired: true }); - clearInterval(this.interval); + clearTimeout(this.timeoutId); this.props.onError(new AdyenCheckoutError('ERROR', 'Payment Expired')); }; private onComplete = (status: StatusObject): void => { - clearInterval(this.interval); + clearTimeout(this.timeoutId); this.setState({ completed: true, loading: false }); const state = { @@ -102,7 +106,7 @@ class QRLoader extends Component { }; private onError = (status: StatusObject): void => { - clearInterval(this.interval); + clearTimeout(this.timeoutId); this.setState({ expired: true, loading: false }); if (status.props.payload) { From a8c4204a72c140cc7758fd9a2470c75c22351ffc Mon Sep 17 00:00:00 2001 From: Yu Long Date: Wed, 3 Jan 2024 16:28:15 +0100 Subject: [PATCH 2/4] added changeset --- .changeset/gorgeous-cameras-grab.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/gorgeous-cameras-grab.md diff --git a/.changeset/gorgeous-cameras-grab.md b/.changeset/gorgeous-cameras-grab.md new file mode 100644 index 0000000000..ff10c0f8a4 --- /dev/null +++ b/.changeset/gorgeous-cameras-grab.md @@ -0,0 +1,5 @@ +--- +'@adyen/adyen-web': patch +--- + +Improve the payment status check call for QR payments. From 5c6471b3ed2d1c15d156575b1b50e9143c4980c3 Mon Sep 17 00:00:00 2001 From: Yu Long Date: Mon, 8 Jan 2024 10:17:17 +0100 Subject: [PATCH 3/4] refactor: added the timeout for the default http calls and the status check call --- packages/lib/src/components/internal/Await/Await.tsx | 4 ++-- packages/lib/src/components/internal/QRLoader/QRLoader.tsx | 4 ++-- packages/lib/src/core/Services/http.ts | 6 ++++-- packages/lib/src/core/Services/payment-status.ts | 6 ++++-- packages/lib/src/core/config.ts | 5 ++++- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/lib/src/components/internal/Await/Await.tsx b/packages/lib/src/components/internal/Await/Await.tsx index 0f6812dd06..f1971dc65b 100644 --- a/packages/lib/src/components/internal/Await/Await.tsx +++ b/packages/lib/src/components/internal/Await/Await.tsx @@ -76,14 +76,14 @@ function Await(props: AwaitComponentProps) { }; const checkStatus = (): void => { - const { paymentData, clientKey } = props; + const { paymentData, clientKey, throttleInterval } = props; if (!hasCalledActionHandled) { props.onActionHandled({ componentType: props.type, actionDescription: 'polling-started' }); setHasCalledActionHandled(true); } - checkPaymentStatus(paymentData, clientKey, loadingContext) + checkPaymentStatus(paymentData, clientKey, loadingContext, throttleInterval) .then(processResponse) .catch(({ message, ...response }) => ({ type: 'network-error', diff --git a/packages/lib/src/components/internal/QRLoader/QRLoader.tsx b/packages/lib/src/components/internal/QRLoader/QRLoader.tsx index 85e6870eb0..52476c9a50 100644 --- a/packages/lib/src/components/internal/QRLoader/QRLoader.tsx +++ b/packages/lib/src/components/internal/QRLoader/QRLoader.tsx @@ -124,9 +124,9 @@ class QRLoader extends Component { }; private checkStatus = () => { - const { paymentData, clientKey, loadingContext } = this.props; + const { paymentData, clientKey, loadingContext, throttledInterval } = this.props; - return checkPaymentStatus(paymentData, clientKey, loadingContext) + return checkPaymentStatus(paymentData, clientKey, loadingContext, throttledInterval) .then(processResponse) .catch(response => ({ type: 'network-error', props: response })) .then((status: StatusObject) => { diff --git a/packages/lib/src/core/Services/http.ts b/packages/lib/src/core/Services/http.ts index 9780151aae..9cdc42ce95 100644 --- a/packages/lib/src/core/Services/http.ts +++ b/packages/lib/src/core/Services/http.ts @@ -1,5 +1,5 @@ import fetch from './fetch'; -import { FALLBACK_CONTEXT } from '../config'; +import { DEFAULT_HTTP_TIMEOUT, FALLBACK_CONTEXT } from '../config'; import AdyenCheckoutError from '../Errors/AdyenCheckoutError'; interface HttpOptions { @@ -11,6 +11,7 @@ interface HttpOptions { method?: string; path: string; errorLevel?: ErrorLevel; + timeout?: number; } type ErrorLevel = 'silent' | 'info' | 'warn' | 'error' | 'fatal'; @@ -27,7 +28,7 @@ function isAdyenErrorResponse(data: any): data is AdyenErrorResponse { } export function http(options: HttpOptions, data?: any): Promise { - const { headers = [], errorLevel = 'warn', loadingContext = FALLBACK_CONTEXT, method = 'GET', path } = options; + const { headers = [], errorLevel = 'warn', loadingContext = FALLBACK_CONTEXT, method = 'GET', path, timeout = DEFAULT_HTTP_TIMEOUT } = options; const request: RequestInit = { method, @@ -41,6 +42,7 @@ export function http(options: HttpOptions, data?: any): Promise { }, redirect: 'follow', referrerPolicy: 'no-referrer-when-downgrade', + signal: AbortSignal.timeout(timeout), ...(data && { body: JSON.stringify(data) }) }; diff --git a/packages/lib/src/core/Services/payment-status.ts b/packages/lib/src/core/Services/payment-status.ts index c89e3b04e8..4b577bb340 100644 --- a/packages/lib/src/core/Services/payment-status.ts +++ b/packages/lib/src/core/Services/payment-status.ts @@ -5,16 +5,18 @@ import { httpPost } from './http'; * @param paymentData - * @param clientKey - * @param loadingContext - + * @param timeout - in milliseconds * @returns a promise containing the response of the call */ -export default function checkPaymentStatus(paymentData, clientKey, loadingContext) { +export default function checkPaymentStatus(paymentData, clientKey, loadingContext, timeout) { if (!paymentData || !clientKey) { throw new Error('Could not check the payment status'); } const options = { loadingContext, - path: `services/PaymentInitiation/v1/status?clientKey=${clientKey}` + path: `services/PaymentInitiation/v1/status?clientKey=${clientKey}`, + timeout }; return httpPost(options, { paymentData }); diff --git a/packages/lib/src/core/config.ts b/packages/lib/src/core/config.ts index ef81e05816..655b4cabe2 100644 --- a/packages/lib/src/core/config.ts +++ b/packages/lib/src/core/config.ts @@ -34,7 +34,10 @@ export const GENERIC_OPTIONS = [ 'setStatusAutomatically' ]; +export const DEFAULT_HTTP_TIMEOUT = 60000; + export default { FALLBACK_CONTEXT, - GENERIC_OPTIONS + GENERIC_OPTIONS, + DEFAULT_HTTP_TIMEOUT }; From dbf5339e4dd90c51e66ac96293481e3de04cbb46 Mon Sep 17 00:00:00 2001 From: Yu Long Date: Mon, 8 Jan 2024 16:45:41 +0100 Subject: [PATCH 4/4] refactor: add unit test --- .../internal/QRLoader/QRLoader.test.tsx | 33 +++++++++++++++++++ packages/lib/src/core/Services/http.ts | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/lib/src/components/internal/QRLoader/QRLoader.test.tsx b/packages/lib/src/components/internal/QRLoader/QRLoader.test.tsx index a7d29747de..afdd4de5fb 100644 --- a/packages/lib/src/components/internal/QRLoader/QRLoader.test.tsx +++ b/packages/lib/src/components/internal/QRLoader/QRLoader.test.tsx @@ -5,6 +5,7 @@ import checkPaymentStatus from '../../../core/Services/payment-status'; import Language from '../../../language/Language'; jest.mock('../../../core/Services/payment-status'); +jest.useFakeTimers(); const i18n = { get: key => key } as Language; @@ -15,6 +16,11 @@ describe('WeChat', () => { }); }); + afterEach(() => { + jest.clearAllTimers(); + jest.restoreAllMocks(); + }); + describe('checkStatus', () => { // Pending status test('checkStatus processes a pending response', () => { @@ -84,5 +90,32 @@ describe('WeChat', () => { expect(onErrorMock.mock.calls.length).toBe(1); }); }); + + describe('statusInterval', () => { + let qrLoader; + + beforeEach(() => { + const checkPaymentStatusValue = { payload: 'Ab02b4c0!', resultCode: 'pending', type: 'complete' }; + (checkPaymentStatus as jest.Mock).mockResolvedValue(checkPaymentStatusValue); + }); + + test('should set a timeout recursively', async () => { + jest.spyOn(global, 'setTimeout'); + qrLoader = new QRLoader({ delay: 1000 }); + qrLoader.statusInterval(); + expect(setTimeout).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1000); + await jest.runOnlyPendingTimersAsync(); + expect(setTimeout).toHaveBeenCalledTimes(2); + }); + + test('should change the delay to the throttledInterval if the timePassed exceeds the throttleTime', async () => { + jest.spyOn(global, 'setTimeout'); + qrLoader = new QRLoader({ throttleTime: 0, throttledInterval: 2000, delay: 1000 }); + qrLoader.statusInterval(); + expect(setTimeout).toHaveBeenCalledTimes(1); + expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 2000); + }); + }); }); }); diff --git a/packages/lib/src/core/Services/http.ts b/packages/lib/src/core/Services/http.ts index 9cdc42ce95..31a8dc199a 100644 --- a/packages/lib/src/core/Services/http.ts +++ b/packages/lib/src/core/Services/http.ts @@ -42,7 +42,7 @@ export function http(options: HttpOptions, data?: any): Promise { }, redirect: 'follow', referrerPolicy: 'no-referrer-when-downgrade', - signal: AbortSignal.timeout(timeout), + ...(AbortSignal?.timeout && { signal: AbortSignal?.timeout(timeout) }), ...(data && { body: JSON.stringify(data) }) };