Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[web] Cache/Manage pricings using global context #310

Merged
merged 9 commits into from
Oct 12, 2022
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');
Comment on lines +16 to +30
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: Use IndexedDB with localForage


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);
};
};