diff --git a/frontends/web/src/api/market.ts b/frontends/web/src/api/market.ts index 96831b2e70..cd92398166 100644 --- a/frontends/web/src/api/market.ts +++ b/frontends/web/src/api/market.ts @@ -87,7 +87,7 @@ export type TBTCDirectInfoResponse = { address?: string; } | { success: false; - errorMessage: string; + errorMessage: string; // TODO: add 'syncInProgress' }; export const getBTCDirectInfo = async (action: TMarketAction, code: string): Promise => { diff --git a/frontends/web/src/components/aopp/aopp.tsx b/frontends/web/src/components/aopp/aopp.tsx index 5b2fc002a9..86da56ab4e 100644 --- a/frontends/web/src/components/aopp/aopp.tsx +++ b/frontends/web/src/components/aopp/aopp.tsx @@ -164,7 +164,7 @@ export const Aopp = () => { -

{t('aopp.syncing')}

+

{t('account.syncing')}

diff --git a/frontends/web/src/hooks/account.test.ts b/frontends/web/src/hooks/account.test.ts new file mode 100644 index 0000000000..81d6dd0f44 --- /dev/null +++ b/frontends/web/src/hooks/account.test.ts @@ -0,0 +1,149 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useState } from 'react'; +import { useAccountSynced } from './account'; +import { syncdone } from '@/api/accountsync'; +import * as utils from './mount'; + +vi.mock('@/api/accountsync', () => ({ + syncdone: vi.fn(() => () => {}), +})); + +const mockSyncdone = vi.mocked(syncdone); + +const useMountedRefSpy = vi.spyOn(utils, 'useMountedRef'); + +describe('useAccountSynced', () => { + beforeEach(() => { + useMountedRefSpy.mockReturnValue({ current: true }); + vi.clearAllMocks(); + }); + + it('returns undefined while loading', () => { + const mockApiCall = vi.fn().mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => ( + useAccountSynced('account-code-1' as any, mockApiCall) + )); + + expect(result.current).toBeUndefined(); + }); + + it('returns api result on success', async () => { + const mockApiCall = vi.fn().mockResolvedValue('result'); + + const { result } = renderHook(() => ( + useAccountSynced('account-code-1' as any, mockApiCall) + )); + + await waitFor(() => expect(result.current).toBe('result')); + + expect(mockApiCall).toHaveBeenCalledTimes(1); + expect(mockSyncdone).toHaveBeenCalledWith( + 'account-code-1', + expect.any(Function), + ); + }); + + it('clears previous result when code changes', async () => { + let resolveApiCall: ((value: string) => void) | undefined; + + const mockApiCall = vi.fn().mockImplementation(() => ( + new Promise((resolve) => { + resolveApiCall = resolve; + }) + )); + + const { result } = renderHook(() => { + const [code, setCode] = useState('account-code-1'); + + const value = useAccountSynced( + code as any, + mockApiCall, + ); + + return { + value, + setCode, + }; + }); + + act(() => { + resolveApiCall?.('result-1'); + }); + + await waitFor(() => { + expect(result.current.value).toBe('result-1'); + }); + + act(() => { + result.current.setCode('account-code-2'); + }); + + expect(result.current.value).toBeUndefined(); + + act(() => { + resolveApiCall?.('result-2'); + }); + + await waitFor(() => { + expect(result.current.value).toBe('result-2'); + }); + + expect(mockApiCall).toHaveBeenCalledTimes(2); + }); + + it('ignores stale api responses', async () => { + let resolveFirst: ((value: string) => void) | undefined; + let resolveSecond: ((value: string) => void) | undefined; + + const mockApiCall = vi + .fn() + .mockImplementationOnce(() => ( + new Promise((resolve) => { + resolveFirst = resolve; + }) + )) + .mockImplementationOnce(() => ( + new Promise((resolve) => { + resolveSecond = resolve; + }) + )); + + const { result } = renderHook(() => { + const [code, setCode] = useState('account-code-1'); + + const value = useAccountSynced( + code as any, + mockApiCall, + ); + + return { + value, + setCode, + }; + }); + + act(() => { + result.current.setCode('account-code-2'); + }); + + act(() => { + resolveFirst?.('stale-result'); + }); + + expect(result.current.value).toBeUndefined(); + + act(() => { + resolveSecond?.('fresh-result'); + }); + + await waitFor(() => { + expect(result.current.value).toBe('fresh-result'); + }); + + expect(result.current.value).not.toBe('stale-result'); + }); +}); diff --git a/frontends/web/src/hooks/account.ts b/frontends/web/src/hooks/account.ts new file mode 100644 index 0000000000..52b2af101f --- /dev/null +++ b/frontends/web/src/hooks/account.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { AccountCode } from '@/api/account'; +import { syncdone } from '@/api/accountsync'; +import { useMountedRef } from './mount'; + +export const useAccountSynced = ( + code: AccountCode, + apiCall: () => Promise, +): T | undefined => { + const isMounted = useMountedRef(); + const apiRequestId = useRef(0); + const [result, setResult] = useState(undefined); + + const callApi = useCallback(async () => { + const requestId = ++apiRequestId.current; + try { + const response = await apiCall(); + if ( + isMounted.current + && requestId === apiRequestId.current + ) { + setResult(response); + } + } catch (error) { + console.error(error); + } + }, [apiCall, isMounted]); + + useEffect(() => { + setResult(undefined); + callApi(); + return syncdone(code, callApi); + }, [code, callApi]); + + return result; +}; diff --git a/frontends/web/src/locales/en/app.json b/frontends/web/src/locales/en/app.json index 655c4f8e3e..25f1f5bacb 100644 --- a/frontends/web/src/locales/en/app.json +++ b/frontends/web/src/locales/en/app.json @@ -14,6 +14,7 @@ "maybeProxyError": "Tor proxy enabled. Ensure that your Tor proxy is running properly, or disable the proxy setting.", "reconnecting": "Lost connection, trying to reconnect…", "syncedAddressesCount": "Scanned {{count}} addresses", + "syncing": "Syncing the account, please wait…", "uncoveredFunds": "You have coins on the following uncovered address types of your {{name}} account: {{uncovered}}.\nSince the account is insured, only coins received via the Native Segwit address type are covered. Coins on different address types, even if they are on the same account, are not insured.\nPlease move all your coins from the unsupported address types to the Native Segwit address type, so all your coins on this account are insured.", "uncoveredFundsLink": "Follow this guide on how to move your coins.", "warning": "Warning!" @@ -122,7 +123,6 @@ "message": "Proceed on {{host}}", "title": "Address successfully sent" }, - "syncing": "Syncing the account, please wait.", "title": "Address request", "xpubRequested": "Sharing your xpub lets external services see your account addresses. Your coins are still safe and remain in your control." }, diff --git a/frontends/web/src/routes/market/bitrefill.tsx b/frontends/web/src/routes/market/bitrefill.tsx index e92ec3b098..48cc26a64b 100644 --- a/frontends/web/src/routes/market/bitrefill.tsx +++ b/frontends/web/src/routes/market/bitrefill.tsx @@ -20,6 +20,7 @@ import { getURLOrigin } from '@/utils/url'; import { ConfirmBitrefill } from './bitrefill-confirm'; import { AppContext } from '@/contexts/AppContext'; import { useVendorIframeResizeHeight, useVendorTerms } from '@/hooks/vendor-iframe'; +import { useAccountSynced } from '@/hooks/account'; import style from './iframe.module.css'; // Map coins supported by Bitrefill @@ -48,7 +49,9 @@ export const Bitrefill = ({ const { isDevServers } = useContext(AppContext); const account = findAccount(accounts, code); - const bitrefillInfo = useLoad(() => getBitrefillInfo('spend', code)); + const fetchBitrefillInfo = useCallback(() => getBitrefillInfo('spend', code), [code]); + const bitrefillInfo = useAccountSynced(code, fetchBitrefillInfo); + const config = useLoad(getConfig); const { containerRef, height, iframeLoaded, iframeRef, onIframeLoad } = useVendorIframeResizeHeight(); @@ -189,12 +192,7 @@ export const Bitrefill = ({ }; }, [handleMessage]); - if ( - !account - || !config - || !bitrefillInfo?.success - || !bitrefillInfo.address - ) { + if (!account || !config) { return null; } @@ -222,7 +220,9 @@ export const Bitrefill = ({ /> ) : (
- {!iframeLoaded && } + {!iframeLoaded && ( + + )} { bitrefillInfo?.success && ( + { pocketInfo?.success ? ( + + ) : ( + pocketInfo?.success === false && ( + + {pocketInfo?.errorMessage + ? pocketInfo.errorMessage + : t('genericError')} + + ) + )}
)}