Skip to content

Commit

Permalink
[web] Cache/Manage pricings using global context (#310)
Browse files Browse the repository at this point in the history
  • Loading branch information
junhoyeo authored Oct 12, 2022
1 parent 0b93db7 commit 07932dc
Show file tree
Hide file tree
Showing 7 changed files with 244 additions and 61 deletions.
35 changes: 19 additions & 16 deletions packages/bento-web/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -65,22 +66,24 @@ const App = ({ Component, pageProps }: AppProps) => {
<ToastProvider />

<SessionManager />
<WalletsProvider>
<Container>
<LoadingProgress
isRouteChanging={loadingState.isRouteChanging}
key={loadingState.loadingKey}
/>
<NavigationBar />

<Component {...pageProps} />
</Container>

<div id="portal" />
<div id="profile-edit" />
<div id="mobile-menu" />
<div id="landing-background" />
</WalletsProvider>
<PricingsProvider>
<WalletsProvider>
<Container>
<LoadingProgress
isRouteChanging={loadingState.isRouteChanging}
key={loadingState.loadingKey}
/>
<NavigationBar />

<Component {...pageProps} />
</Container>

<div id="portal" />
<div id="profile-edit" />
<div id="mobile-menu" />
<div id="landing-background" />
</WalletsProvider>
</PricingsProvider>
</React.Fragment>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export const useMultipleRequests = <T extends any>(
...responsesRef.current[requestKey],
isLoading: true,
};
setCount((prev) => prev + 1);

axios
.get<T>(requestKey)
Expand Down
8 changes: 5 additions & 3 deletions packages/bento-web/src/dashboard/utils/useNFTBalances.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -14,6 +15,7 @@ type Options = {
};

export const useNFTBalances = ({ wallets }: Options) => {
const { getCachedPrice } = useCachedPricings();
const [openSeaNFTBalance, setOpenSeaNFTBalance] = useState<
NFTWalletBalance[]
>([]);
Expand Down Expand Up @@ -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)
Expand Down
61 changes: 20 additions & 41 deletions packages/bento-web/src/dashboard/utils/useWalletBalances.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<keyof typeof KEYS_BY_NETWORK, [Key, Address[]]> =
Expand Down Expand Up @@ -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<string[]>(
() => balances.flatMap((v) => v.coinGeckoId || []),
[balances],
);

const [coinGeckoPricesByIds, setCoinGeckoPricesByIds] = useState<
Record<string, number | undefined>
>({});

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,
Expand Down
150 changes: 150 additions & 0 deletions packages/bento-web/src/hooks/pricings.tsx
Original file line number Diff line number Diff line change
@@ -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 = <T extends any>(prefix: string) => ({
get: async (key: string): Promise<T | null> => {
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<string, number>;
export const pricingsAtom = atom<PricingMap>({});
export const coinGeckoIdsAtom = atom<string[]>([]);

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<GetCachedPrice>(((
v: string | string[],
) => (typeof v === 'string' ? 0 : [0])) as GetCachedPrice);

export const PricingsProvider: React.FC<React.PropsWithChildren> = ({
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 (
<GetCachedPriceContext.Provider value={getCachedPrice}>
{children}
</GetCachedPriceContext.Provider>
);
};

export const useCachedPricings = () => {
const getCachedPrice = React.useContext(GetCachedPriceContext);
return { getCachedPrice };
};
28 changes: 28 additions & 0 deletions packages/bento-web/src/hooks/useLazyEffect.ts
Original file line number Diff line number Diff line change
@@ -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 | (() => void)>();
const effectRef = useRef<EffectCallback>();
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;
}, []);
}
22 changes: 22 additions & 0 deletions packages/bento-web/src/utils/debounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Config } from './Config';

export const debounce = <Arguments extends any[]>(
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);
};
};

0 comments on commit 07932dc

Please sign in to comment.