From 07932dcd389881d7eb942f343674259c85978ff0 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Wed, 12 Oct 2022 10:17:16 +0900 Subject: [PATCH] [web] Cache/Manage pricings using global context (#310) --- packages/bento-web/pages/_app.tsx | 35 ++-- .../dashboard/utils/useMultipleRequests.ts | 1 - .../src/dashboard/utils/useNFTBalances.ts | 8 +- .../src/dashboard/utils/useWalletBalances.ts | 61 +++---- packages/bento-web/src/hooks/pricings.tsx | 150 ++++++++++++++++++ packages/bento-web/src/hooks/useLazyEffect.ts | 28 ++++ packages/bento-web/src/utils/debounce.ts | 22 +++ 7 files changed, 244 insertions(+), 61 deletions(-) create mode 100644 packages/bento-web/src/hooks/pricings.tsx create mode 100644 packages/bento-web/src/hooks/useLazyEffect.ts create mode 100644 packages/bento-web/src/utils/debounce.ts diff --git a/packages/bento-web/pages/_app.tsx b/packages/bento-web/pages/_app.tsx index c46a3eca..833247d3 100644 --- a/packages/bento-web/pages/_app.tsx +++ b/packages/bento-web/pages/_app.tsx @@ -16,6 +16,7 @@ import styled from 'styled-components'; import { LoadingProgress } from '@/components/LoadingProgress'; import { NavigationBar } from '@/components/NavigationBar'; +import { PricingsProvider } from '@/hooks/pricings'; import { GlobalStyle } from '@/styles/GlobalStyle'; Analytics.initialize(); @@ -65,22 +66,24 @@ const App = ({ Component, pageProps }: AppProps) => { - - - - - - - - -
-
-
-
- + + + + + + + + + +
+
+
+
+ + ); }; diff --git a/packages/bento-web/src/dashboard/utils/useMultipleRequests.ts b/packages/bento-web/src/dashboard/utils/useMultipleRequests.ts index bdca857a..274a6774 100644 --- a/packages/bento-web/src/dashboard/utils/useMultipleRequests.ts +++ b/packages/bento-web/src/dashboard/utils/useMultipleRequests.ts @@ -19,7 +19,6 @@ export const useMultipleRequests = ( ...responsesRef.current[requestKey], isLoading: true, }; - setCount((prev) => prev + 1); axios .get(requestKey) diff --git a/packages/bento-web/src/dashboard/utils/useNFTBalances.ts b/packages/bento-web/src/dashboard/utils/useNFTBalances.ts index 3f36e8b8..341456ef 100644 --- a/packages/bento-web/src/dashboard/utils/useNFTBalances.ts +++ b/packages/bento-web/src/dashboard/utils/useNFTBalances.ts @@ -1,10 +1,11 @@ import { Wallet, safeAsyncFlatMap, safePromiseAll } from '@bento/common'; import { OpenSea, OpenSeaAsset } from '@bento/core'; -import { priceFromCoinGecko } from '@bento/core'; import chunk from 'lodash.chunk'; import groupBy from 'lodash.groupby'; import { useEffect, useState } from 'react'; +import { useCachedPricings } from '@/hooks/pricings'; + import { NFTWalletBalance } from '@/dashboard/types/WalletBalance'; const CHUNK_SIZE = 5; @@ -14,6 +15,7 @@ type Options = { }; export const useNFTBalances = ({ wallets }: Options) => { + const { getCachedPrice } = useCachedPricings(); const [openSeaNFTBalance, setOpenSeaNFTBalance] = useState< NFTWalletBalance[] >([]); @@ -63,8 +65,8 @@ export const useNFTBalances = ({ wallets }: Options) => { }; main(); - priceFromCoinGecko('ethereum').then(setEthereumPrice); - }, [JSON.stringify(wallets)]); + setEthereumPrice(getCachedPrice('ethereum')); + }, [getCachedPrice, JSON.stringify(wallets)]); useEffect(() => { const flattedAssets = Object.values(fetchedAssets) diff --git a/packages/bento-web/src/dashboard/utils/useWalletBalances.ts b/packages/bento-web/src/dashboard/utils/useWalletBalances.ts index 2d2554b4..b48ea3c0 100644 --- a/packages/bento-web/src/dashboard/utils/useWalletBalances.ts +++ b/packages/bento-web/src/dashboard/utils/useWalletBalances.ts @@ -1,7 +1,8 @@ import { Wallet } from '@bento/common'; -import { pricesFromCoinGecko } from '@bento/core'; import produce from 'immer'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; + +import { useCachedPricings } from '@/hooks/pricings'; import { KEYS_BY_NETWORK } from '@/constants/networks'; import { @@ -25,6 +26,8 @@ type Options = { }; export const useWalletBalances = ({ wallets }: Options) => { + const { getCachedPrice } = useCachedPricings(); + const calculatedRequests = useMemo(() => { // TODO: Clean this thing up const data: PartialRecord = @@ -67,48 +70,24 @@ export const useWalletBalances = ({ wallets }: Options) => { useInterval(refetch, 60 * 1_000); const balances = useMemo(() => result.flatMap((v) => v.data ?? []), [result]); - - const coinGeckoIds = useMemo( - () => balances.flatMap((v) => v.coinGeckoId || []), - [balances], - ); - - const [coinGeckoPricesByIds, setCoinGeckoPricesByIds] = useState< - Record - >({}); - - const fetchPrices = useCallback(() => { - if (!coinGeckoIds.length) { - return; - } - - pricesFromCoinGecko(coinGeckoIds) - .then(setCoinGeckoPricesByIds) - .catch((error) => { - console.error(error); - }); - }, [JSON.stringify(coinGeckoIds)]); + const [balancesWithPrices, setBalancesWithPrices] = useState< + (EVMWalletBalance | CosmosSDKWalletBalance | SolanaWalletBalance)[] + >([]); useEffect(() => { - fetchPrices(); - }, [fetchPrices]); - useInterval(fetchPrices, 60 * 1_000); - - const balancesWithPrices = useMemo( - () => - produce(balances, (draft) => { - draft.forEach((token) => { - if (typeof token.price === 'undefined') { - if (!!token.coinGeckoId) { - token.price = coinGeckoPricesByIds[token.coinGeckoId] ?? 0; - } else { - token.price = 0; - } + const result = produce(balances, (draft) => { + draft.forEach((token) => { + if (typeof token.price === 'undefined') { + if (!!token.coinGeckoId) { + token.price = getCachedPrice(token.coinGeckoId); + } else { + token.price = 0; } - }); - }), - [balances, JSON.stringify(coinGeckoPricesByIds)], - ); + } + }); + }); + setBalancesWithPrices(result); + }, [balances, getCachedPrice]); return { balances: balancesWithPrices, diff --git a/packages/bento-web/src/hooks/pricings.tsx b/packages/bento-web/src/hooks/pricings.tsx new file mode 100644 index 00000000..3d8e852c --- /dev/null +++ b/packages/bento-web/src/hooks/pricings.tsx @@ -0,0 +1,150 @@ +import { safePromiseAll } from '@bento/common'; +import { pricesFromCoinGecko } from '@bento/core'; +import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'; +import React, { useCallback } from 'react'; + +import { useInterval } from '@/hooks/useInterval'; + +import { Config } from '@/utils'; + +import { useLazyEffect } from './useLazyEffect'; + +const SECONDS = 1_000; +const CACHE_TIME = 60 * SECONDS; + +type ValueVO = [v: number, t: number]; +const buildCacheStore = (prefix: string) => ({ + get: async (key: string): Promise => { + const v = localStorage.getItem(prefix); + if (!v) { + return null; + } + return JSON.parse(v)[key] || null; + }, + set: async (key: string, value: T) => { + const v = localStorage.getItem(prefix); + const prev = !v ? {} : JSON.parse(v); + localStorage.setItem(prefix, JSON.stringify({ ...prev, [key]: value })); + }, +}); +const CacheStore = buildCacheStore('@pricings'); + +type PricingMap = Record; +export const pricingsAtom = atom({}); +export const coinGeckoIdsAtom = atom([]); + +const useCoinGeckoPrices = () => { + const coinGeckoIds = useAtomValue(coinGeckoIdsAtom); + const [prices, setPrices] = useAtom(pricingsAtom); + + const fetchPrices = useCallback(async () => { + if (!coinGeckoIds.length) { + return; + } + + if (Config.ENVIRONMENT !== 'production') { + console.log('hit'); + } + + const cachedIds: string[] = []; + const cachedPrices = await safePromiseAll( + coinGeckoIds.map(async (coinGeckoId) => { + const cachedValue = await CacheStore.get(coinGeckoId).catch(() => null); + if (!cachedValue) { + return [coinGeckoId, 0]; + } + const [value, cachedAt] = cachedValue as ValueVO; + + if (cachedAt >= Date.now() - CACHE_TIME) { + cachedIds.push(coinGeckoId); + return [coinGeckoId, value]; + } + + return [coinGeckoId, 0]; + }), + ); + let cachedPricesObject: PricingMap | null = null; + if (cachedPrices.length > 0) { + cachedPricesObject = Object.fromEntries(cachedPrices); + setPrices(cachedPricesObject as PricingMap); + } + + const cachedIdsSet = new Set(cachedIds); + const uncachedIds = coinGeckoIds.filter((id) => !cachedIdsSet.has(id)); + + const fetchedPrices = + uncachedIds.length === 0 + ? [] + : await pricesFromCoinGecko(uncachedIds).then((prices) => + Object.entries(prices), + ); + await safePromiseAll( + fetchedPrices.map(([key, price]) => + CacheStore.set(key, [price, Date.now()]), + ), + ); + + const fetchedPricesObject = Object.fromEntries(fetchedPrices); + + setPrices({ + ...cachedPricesObject, + ...fetchedPricesObject, + }); + }, [JSON.stringify(coinGeckoIds)]); + + useLazyEffect( + () => { + fetchPrices(); + }, + [fetchPrices], + 2_000, + ); + + useInterval(fetchPrices, CACHE_TIME); + + return prices; +}; + +interface GetCachedPrice { + (coinGeckoId: string): number; + (coinGeckoIds: string[]): number[]; +} +const GetCachedPriceContext = React.createContext((( + v: string | string[], +) => (typeof v === 'string' ? 0 : [0])) as GetCachedPrice); + +export const PricingsProvider: React.FC = ({ + children, +}) => { + useCoinGeckoPrices(); + + const [prices] = useAtom(pricingsAtom); + const setCoinGeckoIds = useSetAtom(coinGeckoIdsAtom); + + const getCachedPrice = useCallback( + (coinGeckoId: string | string[]) => { + const single = typeof coinGeckoId === 'string'; + const ids = single ? [coinGeckoId] : coinGeckoId; + const res = ids.map((id) => { + if (id in prices) { + return prices[id]; + } + setCoinGeckoIds((prev) => (prev.includes(id) ? prev : [...prev, id])); + return 0; + }); + return single ? res[0] : res; + }, + [prices], + ) as GetCachedPrice; + + return ( + + {children} + + ); +}; + +export const useCachedPricings = () => { + const getCachedPrice = React.useContext(GetCachedPriceContext); + return { getCachedPrice }; +}; diff --git a/packages/bento-web/src/hooks/useLazyEffect.ts b/packages/bento-web/src/hooks/useLazyEffect.ts new file mode 100644 index 00000000..19c242c3 --- /dev/null +++ b/packages/bento-web/src/hooks/useLazyEffect.ts @@ -0,0 +1,28 @@ +import { + DependencyList, + EffectCallback, + useCallback, + useEffect, + useRef, +} from 'react'; + +import { debounce } from '@/utils/debounce'; + +export function useLazyEffect( + effect: EffectCallback, + deps: DependencyList = [], + wait: number, +) { + const cleanUp = useRef void)>(); + const effectRef = useRef(); + effectRef.current = useCallback(effect, deps); + const lazyEffect = useCallback( + debounce(() => (cleanUp.current = effectRef.current?.()), wait), + [], + ); + useEffect(lazyEffect, deps); + useEffect(() => { + return () => + cleanUp.current instanceof Function ? cleanUp.current() : undefined; + }, []); +} diff --git a/packages/bento-web/src/utils/debounce.ts b/packages/bento-web/src/utils/debounce.ts new file mode 100644 index 00000000..5ce2ee21 --- /dev/null +++ b/packages/bento-web/src/utils/debounce.ts @@ -0,0 +1,22 @@ +import { Config } from './Config'; + +export const debounce = ( + func: (...args: Arguments) => any, + wait: number, + immediate?: boolean, +) => { + let timeout: NodeJS.Timeout | null = null; + return function (...args: Arguments) { + if (timeout) { + clearTimeout(timeout); + } + if (Config.ENVIRONMENT !== 'production') { + console.log('debounce'); + } + timeout = setTimeout(function () { + timeout = null; + if (!immediate) func(...args); + }, wait); + if (immediate && !timeout) func(...args); + }; +};