From 534c40331d49bf6456e206535b01a2eee5ace43a Mon Sep 17 00:00:00 2001 From: Slava Date: Fri, 15 Nov 2024 17:25:36 +0300 Subject: [PATCH] chore: replace effects with react query callbacks --- .storybook/preview.tsx | 16 +++- apps/bob-pay/src/app/[lang]/send/Send.tsx | 84 ++++++++----------- .../TokenButtonGroup/TokenButtonGroup.tsx | 14 ++-- apps/bob-pay/src/hooks/useBalances.ts | 5 +- apps/bob-pay/src/lib/react-query/index.ts | 16 +++- .../src/lib/react-query/react-query.d.ts | 21 +++++ .../app/[lang]/(bridge)/hooks/useGateway.ts | 25 +++--- apps/evm/src/lib/react-query/index.ts | 16 +++- apps/evm/src/lib/react-query/react-query.d.ts | 21 +++++ apps/evm/src/test-utils/wrapper.tsx | 16 +++- packages/react-query/react-query.d.ts | 21 +++++ .../components/NumberInput/NumberInput.tsx | 12 +-- .../src/components/TokenInput/TokenInput.tsx | 11 ++- 13 files changed, 195 insertions(+), 83 deletions(-) create mode 100644 apps/bob-pay/src/lib/react-query/react-query.d.ts create mode 100644 apps/evm/src/lib/react-query/react-query.d.ts create mode 100644 packages/react-query/react-query.d.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 3cf93e143..681f7f898 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -6,9 +6,21 @@ import { CSSReset, BOBUIProvider, bobTheme } from '../packages/ui/src'; import { WagmiProvider } from '../packages/wagmi/src'; import { SatsWagmiConfig } from '../packages/sats-wagmi/src'; import './style.css'; -import { QueryClient, QueryClientProvider } from '../packages/react-query/src'; +import { QueryCache, QueryClient, QueryClientProvider } from '../packages/react-query/src'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error, query) => { + if (typeof query.meta?.onError === 'function') query.meta.onError(error, query); + }, + onSuccess(data, query) { + if (typeof query.meta?.onSuccess === 'function') query.meta.onSuccess(data, query); + }, + onSettled(data, error, query) { + if (typeof query.meta?.onSettled === 'function') query.meta.onSettled(data, error, query); + } + }) +}); const preview: Preview = { parameters: { diff --git a/apps/bob-pay/src/app/[lang]/send/Send.tsx b/apps/bob-pay/src/app/[lang]/send/Send.tsx index 88939cc60..551ccfc63 100644 --- a/apps/bob-pay/src/app/[lang]/send/Send.tsx +++ b/apps/bob-pay/src/app/[lang]/send/Send.tsx @@ -12,7 +12,7 @@ import { t, Trans } from '@lingui/macro'; import { useLingui } from '@lingui/react'; import { mergeProps } from '@react-aria/utils'; import Big from 'big.js'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { Address, encodeFunctionData, erc20Abi, isAddress } from 'viem'; import { ScannerModal, TokenButtonGroup } from './components'; @@ -66,7 +66,15 @@ const Send = ({ ticker: tickerProp = 'WBTC', recipient }: SendProps): JSX.Elemen const [isGroupAmount, setGroupAmount] = useState(false); const { getPrice } = usePrices(); - const { getBalance, isPending } = useBalances(CHAIN); + const { getBalance } = useBalances(CHAIN, { + meta: { + onSettled: () => { + if (form.values[TRANSFER_TOKEN_AMOUNT]) { + form.validateField(TRANSFER_TOKEN_AMOUNT); + } + } + } + }); const { data: tokens } = useTokens(CHAIN); @@ -137,8 +145,7 @@ const Send = ({ ticker: tickerProp = 'WBTC', recipient }: SendProps): JSX.Elemen const { data: eoaTransferTx, mutate: eoaTransfer, - isPending: isEOATransferPending, - error: eoaTransferError + isPending: isEOATransferPending } = useMutation({ mutationKey: ['eoa-transfer', amount, form.values[TRANSFER_TOKEN_RECIPIENT]], mutationFn: async ({ @@ -184,39 +191,33 @@ const Send = ({ ticker: tickerProp = 'WBTC', recipient }: SendProps): JSX.Elemen }); return txid; + }, + onError(error) { + toast.error(error.message); + // eslint-disable-next-line no-console + console.log(error); } }); - const { isLoading: isWaitingEoaTransferTxConfirmation, data: eoaTransferTransactionReceipt } = - useWaitForTransactionReceipt({ - hash: eoaTransferTx - }); - - useEffect(() => { - if (eoaTransferTransactionReceipt?.status === 'success') { - toast.success(t(i18n)`Successfully sent ${amount} ${token?.currency.symbol}`); - - form.resetForm(); - setAmount(''); - setGroupAmount(false); - setTicker(tickerProp); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [eoaTransferTransactionReceipt]); + const { isLoading: isWaitingEoaTransferTxConfirmation } = useWaitForTransactionReceipt({ + hash: eoaTransferTx, + query: { + meta: { + onSuccess: (data: { status: 'success' }) => { + if (data?.status === 'success') { + toast.success(t(i18n)`Successfully sent ${amount} ${token?.currency.symbol}`); - useEffect(() => { - if (eoaTransferError) { - toast.error(eoaTransferError.message); - // eslint-disable-next-line no-console - console.log(eoaTransferError); + form.resetForm(); + setAmount(''); + setGroupAmount(false); + setTicker(tickerProp); + } + } + } } - }, [eoaTransferError]); + }); - const { - mutate: smartAccountTransfer, - isPending: isSmartAccountTransferPending, - error: smartAccountTransferError - } = useMutation({ + const { mutate: smartAccountTransfer, isPending: isSmartAccountTransferPending } = useMutation({ mutationKey: ['smart-account-transfer', amount, form.values[TRANSFER_TOKEN_RECIPIENT]], onSuccess: (tx, variables) => { if (!tx) { @@ -234,6 +235,11 @@ const Send = ({ ticker: tickerProp = 'WBTC', recipient }: SendProps): JSX.Elemen setGroupAmount(false); setTicker(tickerProp); }, + onError(error) { + toast.error(t(i18n)`Failed to submit transaction`); + // eslint-disable-next-line no-console + console.log(error); + }, mutationFn: async ({ recipient, currencyAmount @@ -317,22 +323,6 @@ const Send = ({ ticker: tickerProp = 'WBTC', recipient }: SendProps): JSX.Elemen } }); - useEffect(() => { - if (smartAccountTransferError) { - toast.error(t(i18n)`Failed to submit transaction`); - // eslint-disable-next-line no-console - console.log(smartAccountTransferError); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [smartAccountTransferError]); - - useEffect(() => { - if (!isPending && form.values[TRANSFER_TOKEN_AMOUNT]) { - form.validateField(TRANSFER_TOKEN_AMOUNT); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isPending]); - const tokenInputItems = useMemo( () => tokens?.map((token) => { diff --git a/apps/bob-pay/src/app/[lang]/send/components/TokenButtonGroup/TokenButtonGroup.tsx b/apps/bob-pay/src/app/[lang]/send/components/TokenButtonGroup/TokenButtonGroup.tsx index 0f3c78de2..d9092cb6e 100644 --- a/apps/bob-pay/src/app/[lang]/send/components/TokenButtonGroup/TokenButtonGroup.tsx +++ b/apps/bob-pay/src/app/[lang]/send/components/TokenButtonGroup/TokenButtonGroup.tsx @@ -3,7 +3,7 @@ import { Flex, Span, useCurrencyFormatter, useLocale } from '@gobob/ui'; import { Item } from '@react-stately/collections'; import { Currency, CurrencyAmount } from '@gobob/currency'; import { usePrices } from '@gobob/react-query'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import Big from 'big.js'; import { ButtonGroup } from '../ButtonGroup'; @@ -25,12 +25,14 @@ const TokenButtonGroup = ({ isSelected, currency, balance, onSelectionChange }: const { getPrice, data: pricesData } = usePrices(); const [key, setKey] = useState(); + const [prevIsSelected, setPrevIsSelected] = useState(false); - useEffect(() => { - if (!isSelected) { - setKey(undefined); - } - }, [isSelected]); + if (!prevIsSelected && isSelected) setPrevIsSelected(true); + + if (prevIsSelected && !isSelected) { + setKey(undefined); + setPrevIsSelected(false); + } const amounts = useMemo(() => { if (currency.symbol === 'WBTC') { diff --git a/apps/bob-pay/src/hooks/useBalances.ts b/apps/bob-pay/src/hooks/useBalances.ts index 6d8a4a2b6..1fe7b357e 100644 --- a/apps/bob-pay/src/hooks/useBalances.ts +++ b/apps/bob-pay/src/hooks/useBalances.ts @@ -1,6 +1,6 @@ import { ChainId } from '@gobob/chains'; import { CurrencyAmount, ERC20Token, Ether } from '@gobob/currency'; -import { INTERVAL, useQuery } from '@gobob/react-query'; +import { DefinedInitialDataOptions, INTERVAL, useQuery } from '@gobob/react-query'; import { useCallback, useMemo } from 'react'; import { Address, erc20Abi } from 'viem'; import { usePublicClient } from 'wagmi'; @@ -10,7 +10,7 @@ import { useTokens } from './useTokens'; type Balances = Record>; -const useBalances = (chainId: ChainId) => { +const useBalances = (chainId: ChainId, query?: Pick) => { const publicClient = usePublicClient({ chainId }); const address = useDynamicAddress(); @@ -20,6 +20,7 @@ const useBalances = (chainId: ChainId) => { const native = useMemo(() => tokens.find((token) => token.currency.isNative), [tokens]); const { data: balances, ...queryResult } = useQuery({ + ...query, queryKey: ['balances', chainId, address], enabled: Boolean(address && publicClient && tokens), queryFn: async () => { diff --git a/apps/bob-pay/src/lib/react-query/index.ts b/apps/bob-pay/src/lib/react-query/index.ts index 845e508bd..cf933cbfd 100644 --- a/apps/bob-pay/src/lib/react-query/index.ts +++ b/apps/bob-pay/src/lib/react-query/index.ts @@ -1,5 +1,17 @@ -import { QueryClient } from '@gobob/react-query'; +import { QueryCache, QueryClient } from '@gobob/react-query'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error, query) => { + if (typeof query.meta?.onError === 'function') query.meta.onError(error, query); + }, + onSuccess(data, query) { + if (typeof query.meta?.onSuccess === 'function') query.meta.onSuccess(data, query); + }, + onSettled(data, error, query) { + if (typeof query.meta?.onSettled === 'function') query.meta.onSettled(data, error, query); + } + }) +}); export { queryClient }; diff --git a/apps/bob-pay/src/lib/react-query/react-query.d.ts b/apps/bob-pay/src/lib/react-query/react-query.d.ts new file mode 100644 index 000000000..7c7cdc83b --- /dev/null +++ b/apps/bob-pay/src/lib/react-query/react-query.d.ts @@ -0,0 +1,21 @@ +import '@gobob/react-query'; +import { DefaultError, Query } from '@gobob/react-query'; + +// https://tanstack.com/query/latest/docs/framework/react/typescript#typing-meta +interface CustomQueryMeta extends Record { + onSuccess?: ((data: TQueryFnData, query: Query) => void) | undefined; + onError?: ((error: DefaultError, query: Query) => void) | undefined; + onSettled?: + | (( + data: TQueryFnData | undefined, + error: DefaultError | null, + query: Query + ) => void) + | undefined; +} + +declare module '@gobob/react-query' { + interface Register extends Record { + queryMeta: CustomQueryMeta; + } +} diff --git a/apps/evm/src/app/[lang]/(bridge)/hooks/useGateway.ts b/apps/evm/src/app/[lang]/(bridge)/hooks/useGateway.ts index fee8238b1..6f53e28f2 100644 --- a/apps/evm/src/app/[lang]/(bridge)/hooks/useGateway.ts +++ b/apps/evm/src/app/[lang]/(bridge)/hooks/useGateway.ts @@ -24,8 +24,8 @@ import { toast } from '@gobob/ui'; import { Address, useAccount } from '@gobob/wagmi'; import { t } from '@lingui/macro'; import { useLingui } from '@lingui/react'; +import { Dispatch, SetStateAction, useCallback, useMemo, useState } from 'react'; import * as Sentry from '@sentry/nextjs'; -import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'; import { DebouncedState, useDebounceValue } from 'usehooks-ts'; import { isAddress } from 'viem'; @@ -213,9 +213,16 @@ const useGateway = ({ params, onError, onMutate, onSuccess }: UseGatewayLiquidit } }); + const estimateFeeErrorMessage = t(i18n)`Failed to get estimated fee`; + const feeRatesQueryResult = useSatsFeeRate({ query: { - select: feeRatesSelect + select: feeRatesSelect, + meta: { + onError() { + toast.error(estimateFeeErrorMessage); + } + } } }); @@ -227,17 +234,15 @@ const useGateway = ({ params, onError, onMutate, onSuccess }: UseGatewayLiquidit feeRate: feeRate, query: { enabled: Boolean(satsBalance && satsBalance.total > 0n && evmAddress), - select: (data) => CurrencyAmount.fromRawAmount(BITCOIN, data.amount) + select: (data) => CurrencyAmount.fromRawAmount(BITCOIN, data.amount), + meta: { + onError() { + toast.error(estimateFeeErrorMessage); + } + } } }); - useEffect(() => { - if (feeEstimateQueryResult.error || feeRatesQueryResult.error) { - toast.error(t(i18n)`Failed to get estimated fee`); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [feeRatesQueryResult.error, feeEstimateQueryResult.error]); - const balance = useMemo( () => getBalanceAmount(satsBalance?.total, feeEstimateQueryResult.data, liquidityQueryResult.data?.liquidityAmount), [liquidityQueryResult.data?.liquidityAmount, satsBalance?.total, feeEstimateQueryResult.data] diff --git a/apps/evm/src/lib/react-query/index.ts b/apps/evm/src/lib/react-query/index.ts index 3e4d856ac..402c55e81 100644 --- a/apps/evm/src/lib/react-query/index.ts +++ b/apps/evm/src/lib/react-query/index.ts @@ -1,6 +1,18 @@ -import { QueryClient } from '@gobob/react-query'; +import { QueryCache, QueryClient } from '@gobob/react-query'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error, query) => { + if (typeof query.meta?.onError === 'function') query.meta.onError(error, query); + }, + onSuccess(data, query) { + if (typeof query.meta?.onSuccess === 'function') query.meta.onSuccess(data, query); + }, + onSettled(data, error, query) { + if (typeof query.meta?.onSettled === 'function') query.meta.onSettled(data, error, query); + } + }) +}); export { queryClient }; export * from './keys'; diff --git a/apps/evm/src/lib/react-query/react-query.d.ts b/apps/evm/src/lib/react-query/react-query.d.ts new file mode 100644 index 000000000..7c7cdc83b --- /dev/null +++ b/apps/evm/src/lib/react-query/react-query.d.ts @@ -0,0 +1,21 @@ +import '@gobob/react-query'; +import { DefaultError, Query } from '@gobob/react-query'; + +// https://tanstack.com/query/latest/docs/framework/react/typescript#typing-meta +interface CustomQueryMeta extends Record { + onSuccess?: ((data: TQueryFnData, query: Query) => void) | undefined; + onError?: ((error: DefaultError, query: Query) => void) | undefined; + onSettled?: + | (( + data: TQueryFnData | undefined, + error: DefaultError | null, + query: Query + ) => void) + | undefined; +} + +declare module '@gobob/react-query' { + interface Register extends Record { + queryMeta: CustomQueryMeta; + } +} diff --git a/apps/evm/src/test-utils/wrapper.tsx b/apps/evm/src/test-utils/wrapper.tsx index d733392fb..379e8dfbf 100644 --- a/apps/evm/src/test-utils/wrapper.tsx +++ b/apps/evm/src/test-utils/wrapper.tsx @@ -1,4 +1,4 @@ -import { QueryClient, QueryClientProvider } from '@gobob/react-query'; +import { QueryCache, QueryClient, QueryClientProvider } from '@gobob/react-query'; import { BOBUIProvider } from '@gobob/ui'; import { WagmiProvider } from '@gobob/wagmi'; import { PropsWithChildren } from 'react'; @@ -6,7 +6,19 @@ import { PropsWithChildren } from 'react'; import { LinguiClientProvider } from '@/i18n/provider'; export const wrapper = ({ children }: PropsWithChildren) => { - const queryClient = new QueryClient(); + const queryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error, query) => { + if (typeof query.meta?.onError === 'function') query.meta.onError(error, query); + }, + onSuccess(data, query) { + if (typeof query.meta?.onSuccess === 'function') query.meta.onSuccess(data, query); + }, + onSettled(data, error, query) { + if (typeof query.meta?.onSettled === 'function') query.meta.onSettled(data, error, query); + } + }) + }); return ( diff --git a/packages/react-query/react-query.d.ts b/packages/react-query/react-query.d.ts new file mode 100644 index 000000000..5eca5fc4d --- /dev/null +++ b/packages/react-query/react-query.d.ts @@ -0,0 +1,21 @@ +import '@tanstack/react-query'; +import { DefaultError, Query } from '@tanstack/react-query'; + +// https://tanstack.com/query/latest/docs/framework/react/typescript#typing-meta +interface CustomQueryMeta extends Record { + onSuccess?: ((data: TQueryFnData, query: Query) => void) | undefined; + onError?: ((error: DefaultError, query: Query) => void) | undefined; + onSettled?: + | (( + data: TQueryFnData | undefined, + error: DefaultError | null, + query: Query + ) => void) + | undefined; +} + +declare module '@tanstack/react-query' { + interface Register extends Record { + queryMeta: CustomQueryMeta; + } +} diff --git a/packages/ui/src/components/NumberInput/NumberInput.tsx b/packages/ui/src/components/NumberInput/NumberInput.tsx index 84c155fc2..dbd0cf72a 100644 --- a/packages/ui/src/components/NumberInput/NumberInput.tsx +++ b/packages/ui/src/components/NumberInput/NumberInput.tsx @@ -2,7 +2,7 @@ import { AriaTextFieldOptions, useTextField } from '@react-aria/textfield'; import { mergeProps } from '@react-aria/utils'; -import { ChangeEventHandler, forwardRef, useEffect, useState } from 'react'; +import { ChangeEventHandler, forwardRef, useState } from 'react'; import { useDOMRef } from '../../hooks'; import { BaseInput, BaseInputProps } from '../Input'; @@ -44,6 +44,7 @@ const NumberInput = forwardRef( ref ): JSX.Element => { const [value, setValue] = useState(defaultValue?.toString()); + const [prevValue, setPrevValue] = useState(value); const inputRef = useDOMRef(ref); const handleChange: ChangeEventHandler = (e) => { @@ -70,11 +71,10 @@ const NumberInput = forwardRef( inputRef ); - useEffect(() => { - if (valueProp === undefined) return; - - setValue(valueProp.toString()); - }, [valueProp]); + if (prevValue !== valueProp) { + setPrevValue(value); + if (valueProp !== undefined) setValue(valueProp.toString()); + } return ( ((props, ref): J const inputRef = useDOMRef(ref); const [value, setValue] = useState(defaultValue); + const [prevValueProp, setPrevValueProp] = useState(valueProp); const defaultCurrency = useMemo(() => getDefaultCurrency(props), [props]); const [currency, setCurrency] = useState(defaultCurrency); @@ -61,11 +62,13 @@ const TokenInput = forwardRef((props, ref): J props.type === 'selectable' ? [props.items, props.selectProps?.value, props.selectProps?.defaultValue] : [] ); - useEffect(() => { - if (valueProp === undefined) return; + if (valueProp !== prevValueProp) { + setPrevValueProp(valueProp); - setValue(valueProp); - }, [valueProp]); + if (valueProp !== undefined) { + setValue(valueProp); + } + } useEffect(() => { if (value && currency) {