diff --git a/libs/react-scripts/package.json b/libs/react-scripts/package.json index b8c67dc19..eb4d915da 100644 --- a/libs/react-scripts/package.json +++ b/libs/react-scripts/package.json @@ -58,7 +58,7 @@ "jest-resolve": "^28.1.3", "jest-watch-typeahead": "^2.0.0", "mini-css-extract-plugin": "^2.6.1", - "postcss": "^8.4.14", + "postcss": "^8.4.16", "postcss-flexbugs-fixes": "^5.0.2", "postcss-loader": "^7.0.1", "postcss-normalize": "^10.0.1", diff --git a/package.json b/package.json index 9d0216868..444a18a8e 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@testing-library/user-event": "^14.4.2", "@types/big.js": "^6.1.5", "@types/node": "^18.6.4", - "@types/react": "^18.0.15", + "@types/react": "^18.0.16", "@types/react-dom": "^18.0.6", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.32.0", diff --git a/packages/widget/package.json b/packages/widget/package.json index 058e11e72..1a11968f9 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -61,6 +61,7 @@ "@sentry/react": "^7.9.0", "@sentry/tracing": "^7.9.0", "@tanstack/react-query": "^4.0.10", + "@tanstack/react-virtual": "^3.0.0-beta.17", "big.js": "^6.2.1", "i18next": "^21.8.16", "immer": "^9.0.15", @@ -68,10 +69,8 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.34.0", "react-i18next": "^11.18.3", - "react-resize-detector": "^7.1.2", "react-router-dom": "^6.3.0", "react-timer-hook": "^3.0.5", - "react-virtual": "^2.10.4", "zustand": "^4.0.0" }, "eslintConfig": { diff --git a/packages/widget/src/components/TokenList/TokenList.tsx b/packages/widget/src/components/TokenList/TokenList.tsx index 0961946fb..ced537d72 100644 --- a/packages/widget/src/components/TokenList/TokenList.tsx +++ b/packages/widget/src/components/TokenList/TokenList.tsx @@ -1,18 +1,18 @@ import { TokenAmount } from '@lifi/sdk'; -import { Box, List, Typography } from '@mui/material'; -import { FC, useCallback, useEffect, useMemo, useRef } from 'react'; +import { useTokenSearch } from '@lifi/widget/hooks/useTokenSearch'; +import { Box, Typography } from '@mui/material'; +import { FC, useCallback, useMemo, useRef, useState } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useVirtual } from 'react-virtual'; import { useDebouncedWatch, useTokenBalances } from '../../hooks'; import { SwapFormKey, SwapFormKeyHelper, } from '../../providers/SwapFormProvider'; import { useWallet } from '../../providers/WalletProvider'; -import { TokenListItem, TokenListItemSkeleton } from './TokenListItem'; import { TokenListProps } from './types'; -import { createTokenAmountSkeletons, skeletonKey } from './utils'; +import { createTokenAmountSkeletons } from './utils'; +import { VirtualizedTokenList } from './VirtualizedTokenList'; export const TokenList: FC = ({ formType, @@ -20,8 +20,10 @@ export const TokenList: FC = ({ onClick, }) => { const { t } = useTranslation(); + const parentRef = useRef(null); const { account } = useWallet(); const { setValue, getValues } = useFormContext(); + const [tokenSkeletons] = useState(createTokenAmountSkeletons); const [selectedChainId] = useWatch({ name: [SwapFormKeyHelper.getChainKey(formType)], }); @@ -37,14 +39,10 @@ export const TokenList: FC = ({ isBalanceLoading, } = useTokenBalances(selectedChainId); - const tokens = tokensWithBalance ?? tokensWithoutBalance; - - const chainTokens = useMemo(() => { - let chainTokens = tokens ?? []; + const filteredTokens = useMemo(() => { + let chainTokens = tokensWithBalance ?? tokensWithoutBalance ?? []; const searchFilter = searchTokensFilter?.toUpperCase() ?? ''; - chainTokens = isLoading - ? createTokenAmountSkeletons() - : searchTokensFilter + chainTokens = searchTokensFilter ? chainTokens.filter( (token) => token.name.toUpperCase().includes(searchFilter) || @@ -53,22 +51,21 @@ export const TokenList: FC = ({ ) : chainTokens; return chainTokens; - }, [isLoading, searchTokensFilter, tokens]); + }, [searchTokensFilter, tokensWithBalance, tokensWithoutBalance]); - const parentRef = useRef(null); + const tokenSearchEnabled = !filteredTokens.length && !isLoading; - const { virtualItems, totalSize, scrollToIndex } = useVirtual({ - size: chainTokens.length, - parentRef, - overscan: 3, - paddingEnd: 12, - estimateSize: useCallback(() => 64, []), - keyExtractor: (index) => chainTokens[index].address ?? index, - }); + const { token: searchedToken, isLoading: isSearchedTokenLoading } = + useTokenSearch(searchTokensFilter, selectedChainId, tokenSearchEnabled); - useEffect(() => { - scrollToIndex(0); - }, [scrollToIndex, selectedChainId]); + const tokens = + isLoading || (tokenSearchEnabled && isSearchedTokenLoading) + ? tokenSkeletons + : filteredTokens.length + ? filteredTokens + : searchedToken + ? [searchedToken] + : filteredTokens; const handleTokenClick = useCallback( (tokenAddress: string) => { @@ -94,36 +91,19 @@ export const TokenList: FC = ({ return ( - {!virtualItems.length ? ( + {!tokens.length ? ( {t('swap.couldntFindTokens')} ) : null} - - {virtualItems.map((item) => { - const token = chainTokens[item.index] as TokenAmount; - if (token.name.includes(skeletonKey)) { - return ( - - ); - } - return ( - - ); - })} - + ); }; diff --git a/packages/widget/src/components/TokenList/VirtualizedTokenList.tsx b/packages/widget/src/components/TokenList/VirtualizedTokenList.tsx new file mode 100644 index 000000000..0263c64bc --- /dev/null +++ b/packages/widget/src/components/TokenList/VirtualizedTokenList.tsx @@ -0,0 +1,57 @@ +import { TokenAmount } from '@lifi/sdk'; +import { List } from '@mui/material'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { FC, useEffect } from 'react'; +import { TokenListItem, TokenListItemSkeleton } from './TokenListItem'; +import { VirtualizedTokenListProps } from './types'; +import { skeletonKey } from './utils'; + +export const VirtualizedTokenList: FC = ({ + tokens, + scrollElementRef, + onClick, + chainId, + isBalanceLoading, + showBalance, +}) => { + const { getVirtualItems, getTotalSize, scrollToIndex } = useVirtualizer({ + count: tokens.length, + getScrollElement: () => scrollElementRef.current, + overscan: 3, + paddingEnd: 12, + estimateSize: () => 64, + getItemKey: (index) => tokens[index].address ?? index, + }); + + useEffect(() => { + scrollToIndex(0, { align: 'start', smoothScroll: false }); + }, [scrollToIndex, chainId]); + + return ( + + {getVirtualItems().map((item) => { + const token = tokens[item.index] as TokenAmount; + if (token.name.includes(skeletonKey)) { + return ( + + ); + } + return ( + + ); + })} + + ); +}; diff --git a/packages/widget/src/components/TokenList/types.ts b/packages/widget/src/components/TokenList/types.ts index 19ba7d1aa..c3c5bf09e 100644 --- a/packages/widget/src/components/TokenList/types.ts +++ b/packages/widget/src/components/TokenList/types.ts @@ -1,4 +1,5 @@ import { TokenAmount } from '@lifi/sdk'; +import { MutableRefObject } from 'react'; import { SwapFormDirection } from '../../providers/SwapFormProvider'; export interface TokenListProps { @@ -7,8 +8,17 @@ export interface TokenListProps { onClick?(): void; } +export interface VirtualizedTokenListProps { + tokens: TokenAmount[]; + scrollElementRef: MutableRefObject; + onClick(tokenAddress: string): void; + isBalanceLoading: boolean; + chainId: number; + showBalance?: boolean; +} + export interface TokenListItemBaseProps { - onClick?(token: string): void; + onClick?(tokenAddress: string): void; size: number; start: number; } diff --git a/packages/widget/src/hooks/useTokenBalances.ts b/packages/widget/src/hooks/useTokenBalances.ts index d7da8f5f8..759148abf 100644 --- a/packages/widget/src/hooks/useTokenBalances.ts +++ b/packages/widget/src/hooks/useTokenBalances.ts @@ -17,7 +17,8 @@ export const useTokenBalances = (selectedChainId: number) => { defaultRefetchInterval, ); - const isBalanceLoadingEnabled = Boolean(account.address) && Boolean(tokens); + const isBalanceLoadingEnabled = + Boolean(account.address) && Boolean(tokens?.length); const { data: tokensWithBalance, @@ -25,7 +26,7 @@ export const useTokenBalances = (selectedChainId: number) => { isFetched: isBalanceFetched, refetch, } = useQuery( - ['token-balances', selectedChainId, account.address], + ['token-balances', selectedChainId, account.address, tokens?.length], async ({ queryKey: [, , accountAddress] }) => { if (!accountAddress || !tokens) { return; diff --git a/packages/widget/src/hooks/useTokenSearch.ts b/packages/widget/src/hooks/useTokenSearch.ts new file mode 100644 index 000000000..3cec9f22b --- /dev/null +++ b/packages/widget/src/hooks/useTokenSearch.ts @@ -0,0 +1,38 @@ +import { ChainId, Token } from '@lifi/sdk'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { LiFi } from '../config/lifi'; + +export const useTokenSearch = ( + token: string, + chainId: number, + enabled?: boolean, +) => { + const queryClient = useQueryClient(); + const { data, isLoading, isFetching, isFetched } = useQuery( + ['token-search', chainId, token], + async ({ queryKey: [, chainId, token], signal }) => { + const data = await LiFi.getToken(chainId as ChainId, token as string, { + signal, + }); + if (data) { + queryClient.setQueryData(['tokens', chainId], (tokens?: Token[]) => { + if (!tokens?.some((token) => token.address === data.address)) { + tokens?.push(data); + } + return tokens; + }); + } + return data; + }, + { + enabled, + retry: false, + }, + ); + return { + token: data, + isLoading, + isFetching, + isFetched, + }; +}; diff --git a/packages/widget/src/pages/SelectTokenPage/ChainSelect.tsx b/packages/widget/src/pages/SelectTokenPage/ChainSelect.tsx index b61a32aec..232402e32 100644 --- a/packages/widget/src/pages/SelectTokenPage/ChainSelect.tsx +++ b/packages/widget/src/pages/SelectTokenPage/ChainSelect.tsx @@ -13,6 +13,7 @@ import { Card, CardTitle } from '../../components/Card'; import { Select } from '../../components/Select'; import { useChains } from '../../hooks'; import { + SwapFormKey, SwapFormKeyHelper, SwapFormTypeProps, } from '../../providers/SwapFormProvider'; @@ -31,6 +32,7 @@ export const ChainSelect = ({ formType }: SwapFormTypeProps) => { setValue(SwapFormKeyHelper.getChainKey(formType), event.target.value); setValue(SwapFormKeyHelper.getTokenKey(formType), ''); setValue(SwapFormKeyHelper.getAmountKey(formType), ''); + setValue(SwapFormKey.SearchTokensFilter, ''); }; return !isLoading ? ( diff --git a/packages/widget/src/providers/SwapFormProvider/types.ts b/packages/widget/src/providers/SwapFormProvider/types.ts index cecf7330d..d23412aca 100644 --- a/packages/widget/src/providers/SwapFormProvider/types.ts +++ b/packages/widget/src/providers/SwapFormProvider/types.ts @@ -25,8 +25,6 @@ export const SwapFormKeyHelper = { `${formType}Token`, getAmountKey: (formType: SwapFormDirection): 'fromAmount' | 'toAmount' => `${formType}Amount`, - getSearchTokensFilterKey: (formType: SwapFormDirection) => - `${formType}SearchTokensFilter`, }; export interface SwapFormTypeProps { diff --git a/yarn.lock b/yarn.lock index 5f8f58230..5885835a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3081,11 +3081,6 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.5.tgz#db5a11bf66bdab39569719555b0f76e138d7bd64" integrity sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw== -"@reach/observe-rect@^1.1.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@reach/observe-rect/-/observe-rect-1.2.0.tgz#d7a6013b8aafcc64c778a0ccb83355a11204d3b2" - integrity sha512-Ba7HmkFgfQxZqqaeIWWkNK0rEhpxVQHIoVyW1YDSkGsGIXzcaW4deC8B0pZrNSSyLTdIk7y+5olKt5+g0GmFIQ== - "@rollup/plugin-babel@^5.2.0": version "5.3.1" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283" @@ -3348,6 +3343,18 @@ "@types/use-sync-external-store" "^0.0.3" use-sync-external-store "^1.2.0" +"@tanstack/react-virtual@^3.0.0-beta.17": + version "3.0.0-beta.17" + resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.0.0-beta.17.tgz#490335fd4544a98f415aab84825c432fc27e2246" + integrity sha512-Rgy/D5cWf+q12XxR/Jo2lfStL/6FdcqPX5+nMjDkIu2Twfx1zjACvoi3UeieVbQjlZCynRcxw9T0BkhkNHKDjA== + dependencies: + "@tanstack/virtual-core" "3.0.0-beta.17" + +"@tanstack/virtual-core@3.0.0-beta.17": + version "3.0.0-beta.17" + resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.0.0-beta.17.tgz#e9e2fa3bb00a4836d0b913ce00fb01dece6a3cb2" + integrity sha512-p4bBBwHkBCCDRE8umXtLJ8UGJ03efZj39WklRwTQReVq5JOx7hvvPYY6zN9zRbbGCseNXX95OLgXiggDL657Jw== + "@testing-library/dom@^8.5.0": version "8.16.0" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.16.0.tgz#d6fc50250aed17b1035ca1bd64655e342db3936a" @@ -3656,7 +3663,7 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^18.0.15": +"@types/react@*": version "18.0.15" resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.15.tgz#d355644c26832dc27f3e6cbf0c4f4603fc4ab7fe" integrity sha512-iz3BtLuIYH1uWdsv6wXYdhozhqj20oD4/Hk2DNXIn1kFsmp9x8d9QB6FnPhfkbhd2PgEONt9Q1x/ebkwjfFLow== @@ -3665,6 +3672,15 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@^18.0.16": + version "18.0.16" + resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.16.tgz#8de9bf7feebfa520777e8d30815070b39cb663c0" + integrity sha512-3vX1dzVucqc2nhXtzyaParTIIRZeNbisRqLE7QdeLomVybEyeiuAouzZXgz71P+2kbJOqj3dy0fzoATg2I06GQ== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/resolve@1.17.1": version "1.17.1" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" @@ -10857,6 +10873,15 @@ postcss@^8.2.14, postcss@^8.4.13, postcss@^8.4.14, postcss@^8.4.7: picocolors "^1.0.0" source-map-js "^1.0.2" +postcss@^8.4.16: + version "8.4.16" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.16.tgz#33a1d675fac39941f5f445db0de4db2b6e01d43c" + integrity sha512-ipHE1XBvKzm5xI7hiHCZJCSugxvsdq2mPnsq5+UF+VHCjiBvtDrlxJfMBToWaP9D5XlgNmcFGqoHmUn0EYEaRQ== + dependencies: + nanoid "^3.3.4" + picocolors "^1.0.0" + source-map-js "^1.0.2" + preact@10.4.1: version "10.4.1" resolved "https://registry.yarnpkg.com/preact/-/preact-10.4.1.tgz#9b3ba020547673a231c6cf16f0fbaef0e8863431" @@ -11189,13 +11214,6 @@ react-refresh@^0.14.0: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== -react-resize-detector@^7.1.2: - version "7.1.2" - resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-7.1.2.tgz#8ef975dd8c3d56f9a5160ac382ef7136dcd2d86c" - integrity sha512-zXnPJ2m8+6oq9Nn8zsep/orts9vQv3elrpA+R8XTcW7DVVUJ9vwDwMXaBtykAYjMnkCIaOoK9vObyR7ZgFNlOw== - dependencies: - lodash "^4.17.21" - react-router-dom@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d" @@ -11226,13 +11244,6 @@ react-transition-group@^4.4.5: loose-envify "^1.4.0" prop-types "^15.6.2" -react-virtual@^2.10.4: - version "2.10.4" - resolved "https://registry.yarnpkg.com/react-virtual/-/react-virtual-2.10.4.tgz#08712f0acd79d7d6f7c4726f05651a13b24d8704" - integrity sha512-Ir6+oPQZTVHfa6+JL9M7cvMILstFZH/H3jqeYeKI4MSUX+rIruVwFC6nGVXw9wqAw8L0Kg2KvfXxI85OvYQdpQ== - dependencies: - "@reach/observe-rect" "^1.1.0" - react@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5"