feat: lazy load sparklines (#4827)

* testing removing sparklines

* fixed build

* filter working

* added lazy loading of sparklines

* fixed bugs

* removed comments

* add back memo

Co-authored-by: Connor McEwen <connor.mcewen@gmail.com>
This commit is contained in:
cartcrom 2022-10-10 16:51:45 -04:00 committed by GitHub
parent 351f66a83e
commit d8677d8a6d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 174 additions and 349 deletions

@ -1,14 +1,20 @@
import { SparkLineLoadingBubble } from 'components/Tokens/TokenTable/TokenRow'
import { curveCardinal, scaleLinear } from 'd3'
import { filterPrices } from 'graphql/data/Token'
import { TopToken } from 'graphql/data/TopTokens'
import { PricePoint } from 'graphql/data/Token'
import { SparklineMap, TopToken } from 'graphql/data/TopTokens'
import { TimePeriod } from 'graphql/data/util'
import React from 'react'
import { useTheme } from 'styled-components/macro'
import { memo } from 'react'
import styled, { useTheme } from 'styled-components/macro'
import { DATA_EMPTY, getPriceBounds } from '../Tokens/TokenDetails/PriceChart'
import { getPriceBounds } from '../Tokens/TokenDetails/PriceChart'
import LineChart from './LineChart'
type PricePoint = { value: number; timestamp: number }
const LoadingContainer = styled.div`
height: 100%;
display: flex;
justify-content: center;
align-items: center;
`
interface SparklineChartProps {
width: number
@ -16,15 +22,32 @@ interface SparklineChartProps {
tokenData: TopToken
pricePercentChange: number | undefined | null
timePeriod: TimePeriod
sparklineMap: SparklineMap
}
function SparklineChart({ width, height, tokenData, pricePercentChange, timePeriod }: SparklineChartProps) {
function _SparklineChart({
width,
height,
tokenData,
pricePercentChange,
timePeriod,
sparklineMap,
}: SparklineChartProps) {
const theme = useTheme()
// for sparkline
const pricePoints = filterPrices(tokenData?.market?.priceHistory) ?? []
const hasData = pricePoints.length !== 0
const startingPrice = hasData ? pricePoints[0] : DATA_EMPTY
const endingPrice = hasData ? pricePoints[pricePoints.length - 1] : DATA_EMPTY
const pricePoints = tokenData?.address ? sparklineMap[tokenData.address] : null
// Don't display if there's one or less pricepoints
if (!pricePoints || pricePoints.length <= 1) {
return (
<LoadingContainer>
<SparkLineLoadingBubble />
</LoadingContainer>
)
}
const startingPrice = pricePoints[0]
const endingPrice = pricePoints[pricePoints.length - 1]
const widthScale = scaleLinear()
.domain(
// the range of possible input values
@ -52,4 +75,4 @@ function SparklineChart({ width, height, tokenData, pricePercentChange, timePeri
)
}
export default React.memo(SparklineChart)
export default memo(_SparklineChart)

@ -1,11 +1,11 @@
import { Trans } from '@lingui/macro'
import { TokenSortMethod } from 'graphql/data/TopTokens'
import { ReactNode } from 'react'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
import { textFadeIn } from 'theme/animations'
import { formatDollar } from 'utils/formatDollarAmt'
import { TokenSortMethod } from '../state'
import { HEADER_DESCRIPTIONS } from '../TokenTable/TokenRow'
import InfoTip from './InfoTip'

@ -6,7 +6,7 @@ import SparklineChart from 'components/Charts/SparklineChart'
import CurrencyLogo from 'components/CurrencyLogo'
import { getChainInfo } from 'constants/chainInfo'
import { FavoriteTokensVariant, useFavoriteTokensFlag } from 'featureFlags/flags/favoriteTokens'
import { TokenSortMethod, TopToken } from 'graphql/data/TopTokens'
import { SparklineMap, TopToken } from 'graphql/data/TopTokens'
import { CHAIN_NAME_TO_CHAIN_ID, getTokenDetailsURL } from 'graphql/data/util'
import { useAtomValue } from 'jotai/utils'
import { ForwardedRef, forwardRef } from 'react'
@ -30,6 +30,7 @@ import {
filterTimeAtom,
sortAscendingAtom,
sortMethodAtom,
TokenSortMethod,
useIsFavorited,
useSetSortMethod,
useToggleFavorite,
@ -310,7 +311,7 @@ const IconLoadingBubble = styled(LoadingBubble)`
border-radius: 50%;
width: 24px;
`
const SparkLineLoadingBubble = styled(LongLoadingBubble)`
export const SparkLineLoadingBubble = styled(LongLoadingBubble)`
height: 4px;
`
@ -395,7 +396,7 @@ export function TokenRow({
marketCap: ReactNode
price: ReactNode
percentChange: ReactNode
sparkLine: ReactNode
sparkLine?: ReactNode
tokenInfo: ReactNode
volume: ReactNode
last?: boolean
@ -466,6 +467,7 @@ interface LoadedRowProps {
tokenListIndex: number
tokenListLength: number
token: NonNullable<TopToken>
sparklineMap: SparklineMap
}
/* Loaded State: row component with token information */
@ -477,6 +479,7 @@ export const LoadedRow = forwardRef((props: LoadedRowProps, ref: ForwardedRef<HT
const isFavorited = useIsFavorited(tokenAddress)
const toggleFavorite = useToggleFavorite(tokenAddress)
const filterString = useAtomValue(filterStringAtom)
const sortAscending = useAtomValue(sortAscendingAtom)
const lowercaseChainName = useParams<{ chainName?: string }>().chainName?.toUpperCase() ?? 'ethereum'
const filterNetwork = lowercaseChainName.toUpperCase()
@ -515,7 +518,7 @@ export const LoadedRow = forwardRef((props: LoadedRowProps, ref: ForwardedRef<HT
<FavoriteIcon isFavorited={isFavorited} />
</ClickFavorited>
}
listNumber={tokenListIndex + 1}
listNumber={sortAscending ? tokenListLength - tokenListIndex : tokenListIndex + 1}
tokenInfo={
<ClickableName>
<LogoContainer>
@ -558,15 +561,18 @@ export const LoadedRow = forwardRef((props: LoadedRowProps, ref: ForwardedRef<HT
sparkLine={
<SparkLine>
<ParentSize>
{({ width, height }) => (
{({ width, height }) =>
props.sparklineMap && (
<SparklineChart
width={width}
height={height}
tokenData={token}
pricePercentChange={token.market?.pricePercentChange?.value}
timePeriod={timePeriod}
sparklineMap={props.sparklineMap}
/>
)}
)
}
</ParentSize>
</SparkLine>
}

@ -3,7 +3,7 @@ import { showFavoritesAtom } from 'components/Tokens/state'
import { PAGE_SIZE, useTopTokens } from 'graphql/data/TopTokens'
import { validateUrlChainParam } from 'graphql/data/util'
import { useAtomValue } from 'jotai/utils'
import { ReactNode, useCallback, useRef } from 'react'
import { ReactNode } from 'react'
import { AlertTriangle } from 'react-feather'
import { useParams } from 'react-router-dom'
import styled from 'styled-components/macro'
@ -11,8 +11,6 @@ import styled from 'styled-components/macro'
import { MAX_WIDTH_MEDIA_BREAKPOINT } from '../constants'
import { HeaderRow, LoadedRow, LoadingRow } from './TokenRow'
const LOADING_ROWS_COUNT = 3
const GridContainer = styled.div`
display: flex;
flex-direction: column;
@ -58,13 +56,19 @@ function NoTokensState({ message }: { message: ReactNode }) {
)
}
const LoadingRows = (rowCount?: number) =>
Array(rowCount ?? PAGE_SIZE)
const LoadingRowsWrapper = styled.div`
margin-top: 8px;
`
const LoadingRows = (rowCount?: number) => (
<LoadingRowsWrapper>
{Array(rowCount ?? PAGE_SIZE)
.fill(null)
.map((_, index) => {
return <LoadingRow key={index} />
})
const LoadingMoreRows = LoadingRows(LOADING_ROWS_COUNT)
})}
</LoadingRowsWrapper>
)
export function LoadingTokenTable({ rowCount }: { rowCount?: number }) {
return (
@ -75,34 +79,16 @@ export function LoadingTokenTable({ rowCount }: { rowCount?: number }) {
)
}
export default function TokenTable() {
export default function TokenTable({ setRowCount }: { setRowCount: (c: number) => void }) {
const showFavorites = useAtomValue<boolean>(showFavoritesAtom)
// TODO: consider moving prefetched call into app.tsx and passing it here, use a preloaded call & updated on interval every 60s
const chainName = validateUrlChainParam(useParams<{ chainName?: string }>().chainName)
const { error, loading, tokens, hasMore, loadMoreTokens, loadingRowCount } = useTopTokens(chainName)
const showMoreLoadingRows = Boolean(loading && hasMore)
const observer = useRef<IntersectionObserver>()
const lastTokenRef = useCallback(
(node: HTMLDivElement) => {
if (loading) return
if (observer.current) observer.current.disconnect()
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
loadMoreTokens()
}
})
if (node) observer.current.observe(node)
},
[loading, hasMore, loadMoreTokens]
)
const { tokens, sparklines } = useTopTokens(chainName)
setRowCount(tokens?.length ?? PAGE_SIZE)
/* loading and error state */
if (loading && (!tokens || tokens?.length === 0)) {
return <LoadingTokenTable rowCount={loadingRowCount} />
} else {
if (error || !tokens) {
if (!tokens) {
return (
<NoTokensState
message={
@ -121,7 +107,6 @@ export default function TokenTable() {
)
} else {
return (
<>
<GridContainer>
<HeaderRow />
<TokenDataContainer>
@ -133,15 +118,12 @@ export default function TokenTable() {
tokenListIndex={index}
tokenListLength={tokens.length}
token={token}
ref={index + 1 === tokens.length ? lastTokenRef : undefined}
sparklineMap={sparklines}
/>
)
)}
{showMoreLoadingRows && LoadingMoreRows}
</TokenDataContainer>
</GridContainer>
</>
)
}
}
}

@ -1,9 +1,15 @@
import { TokenSortMethod } from 'graphql/data/TopTokens'
import { TimePeriod } from 'graphql/data/util'
import { atom, useAtom } from 'jotai'
import { atomWithReset, atomWithStorage, useAtomValue } from 'jotai/utils'
import { useCallback, useMemo } from 'react'
export enum TokenSortMethod {
PRICE = 'Price',
PERCENT_CHANGE = 'Change',
TOTAL_VALUE_LOCKED = 'TVL',
VOLUME = 'Volume',
}
export const favoritesAtom = atomWithStorage<string[]>('favorites', [])
export const showFavoritesAtom = atomWithStorage<boolean>('showFavorites', false)
export const filterStringAtom = atomWithReset<string>('')

@ -6,18 +6,15 @@ import {
showFavoritesAtom,
sortAscendingAtom,
sortMethodAtom,
TokenSortMethod,
} from 'components/Tokens/state'
import { useAtomValue } from 'jotai/utils'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { fetchQuery, useRelayEnvironment } from 'react-relay'
import { useEffect, useMemo, useState } from 'react'
import { fetchQuery, useLazyLoadQuery, useRelayEnvironment } from 'react-relay'
import {
Chain,
ContractInput,
HistoryDuration,
TopTokens_TokensQuery,
} from './__generated__/TopTokens_TokensQuery.graphql'
import type { TopTokens100Query } from './__generated__/TopTokens100Query.graphql'
import type { Chain, TopTokens100Query } from './__generated__/TopTokens100Query.graphql'
import { TopTokensSparklineQuery } from './__generated__/TopTokensSparklineQuery.graphql'
import { filterPrices, PricePoint } from './Token'
import { toHistoryDuration } from './util'
const topTokens100Query = graphql`
@ -46,16 +43,26 @@ const topTokens100Query = graphql`
currency
}
}
project {
logoUrl
}
}
}
`
export enum TokenSortMethod {
PRICE = 'Price',
PERCENT_CHANGE = 'Change',
TOTAL_VALUE_LOCKED = 'TVL',
VOLUME = 'Volume',
}
const tokenSparklineQuery = graphql`
query TopTokensSparklineQuery($duration: HistoryDuration!, $chain: Chain!) {
topTokens(pageSize: 100, page: 1, chain: $chain) {
address
market(currency: USD) {
priceHistory(duration: $duration) {
timestamp
value
}
}
}
}
`
export type PrefetchedTopToken = NonNullable<TopTokens100Query['response']['topTokens']>[number]
@ -90,7 +97,7 @@ function useSortedTokens(tokens: TopTokens100Query['response']['topTokens'] | un
}, [tokens, sortMethod, sortAscending])
}
function useFilteredTokens(tokens: PrefetchedTopToken[]) {
function useFilteredTokens(tokens: NonNullable<TopTokens100Query['response']>['topTokens']) {
const filterString = useAtomValue(filterStringAtom)
const favorites = useAtomValue(favoritesAtom)
const showFavorites = useAtomValue(showFavoritesAtom)
@ -121,240 +128,43 @@ function useFilteredTokens(tokens: PrefetchedTopToken[]) {
// Number of items to render in each fetch in infinite scroll.
export const PAGE_SIZE = 20
function toContractInput(token: PrefetchedTopToken) {
return {
address: token?.address ?? '',
chain: token?.chain ?? 'ETHEREUM',
}
}
// Map of key: ${HistoryDuration} and value: another Map, of key:${chain} + ${address} and value: TopToken object.
// Acts as a local cache.
let tokensWithPriceHistoryCache: Record<HistoryDuration, Record<string, TopToken>> = {
DAY: {},
HOUR: {},
MAX: {},
MONTH: {},
WEEK: {},
YEAR: {},
'%future added value': {},
}
let cachedChain: Chain | undefined
const resetTokensWithPriceHistoryCache = () => {
tokensWithPriceHistoryCache = {
DAY: {},
HOUR: {},
MAX: {},
MONTH: {},
WEEK: {},
YEAR: {},
'%future added value': {},
}
}
const checkIfAllTokensCached = (duration: HistoryDuration, tokens: PrefetchedTopToken[]) => {
let everyTokenInCache = true
const cachedTokens: TopToken[] = []
const checkCache = (token: PrefetchedTopToken) => {
const tokenCacheKey = !!token ? `${token.chain}${token.address}` : ''
if (tokenCacheKey in tokensWithPriceHistoryCache[duration]) {
cachedTokens.push(tokensWithPriceHistoryCache[duration][tokenCacheKey])
return true
} else {
everyTokenInCache = false
cachedTokens.length = 0
return false
}
}
tokens.every((token) => checkCache(token))
return { everyTokenInCache, cachedTokens }
}
export type TopToken = NonNullable<TopTokens_TokensQuery['response']['tokens']>[number]
export type TopToken = NonNullable<NonNullable<TopTokens100Query['response']>['topTokens']>[number]
export type SparklineMap = { [key: string]: PricePoint[] | undefined }
interface UseTopTokensReturnValue {
error: Error | undefined
loading: boolean
tokens: TopToken[] | undefined
hasMore: boolean
loadMoreTokens: () => void
loadingRowCount: number
sparklines: SparklineMap
}
export function useTopTokens(chain: Chain): UseTopTokensReturnValue {
const duration = toHistoryDuration(useAtomValue(filterTimeAtom))
const [loadingTokensWithoutPriceHistory, setLoadingTokensWithoutPriceHistory] = useState(true)
const [loadingTokensWithPriceHistory, setLoadingTokensWithPriceHistory] = useState(true)
const [tokens, setTokens] = useState<TopToken[]>()
const [prefetchedData, setPrefetchedData] = useState<PrefetchedTopToken[]>()
if (chain !== cachedChain) {
cachedChain = chain
resetTokensWithPriceHistoryCache()
}
const [page, setPage] = useState(0)
const [error, setError] = useState<Error | undefined>()
const [prefetchedDataDuration, setPrefetchedDataDuration] = useState<HistoryDuration>()
const prefetchedSelectedTokensWithoutPriceHistory = useFilteredTokens(useSortedTokens(prefetchedData))
const { everyTokenInCache, cachedTokens } = useMemo(
() => checkIfAllTokensCached(duration, prefetchedSelectedTokensWithoutPriceHistory),
[duration, prefetchedSelectedTokensWithoutPriceHistory]
)
// loadingRowCount defaults to PAGE_SIZE when no prefetchedData is available yet because the initial load
// count will always be PAGE_SIZE.
const loadingRowCount = useMemo(
() => (prefetchedData ? Math.min(prefetchedSelectedTokensWithoutPriceHistory.length, PAGE_SIZE) : PAGE_SIZE),
[prefetchedSelectedTokensWithoutPriceHistory, prefetchedData]
)
const hasMore = !tokens || tokens.length < prefetchedSelectedTokensWithoutPriceHistory.length
const environment = useRelayEnvironment()
const loadTokensWithoutPriceHistory = useCallback(
({ duration, chain }: { duration: HistoryDuration; chain: Chain }) => {
setTokens([])
fetchQuery<TopTokens100Query>(
environment,
topTokens100Query,
{ duration, chain },
{ fetchPolicy: 'store-or-network' }
).subscribe({
next: (data) => {
if (data?.topTokens) setPrefetchedData([...data?.topTokens])
},
error: setError,
complete: () => {
setLoadingTokensWithoutPriceHistory(false)
setPrefetchedDataDuration(duration)
setLoadingTokensWithPriceHistory(true)
},
})
},
[environment]
)
// TopTokens should ideally be fetched with usePaginationFragment. The backend does not current support graphql cursors;
// in the meantime, fetchQuery is used, as other relay hooks do not allow the refreshing and lazy loading we need
const loadTokensWithPriceHistory = useCallback(
({
contracts,
appendingTokens,
page,
tokens,
}: {
contracts: ContractInput[]
appendingTokens: boolean
page: number
tokens?: TopToken[]
}) => {
fetchQuery<TopTokens_TokensQuery>(
environment,
tokensQuery,
{ contracts, duration },
{ fetchPolicy: 'store-or-network' }
).subscribe({
next: (data) => {
if (data?.tokens) {
const priceHistoryCacheForCurrentDuration = tokensWithPriceHistoryCache[duration]
data.tokens.map((token) =>
!!token ? (priceHistoryCacheForCurrentDuration[`${token.chain}${token.address}`] = token) : null
)
appendingTokens ? setTokens([...(tokens ?? []), ...data.tokens]) : setTokens([...data.tokens])
setLoadingTokensWithPriceHistory(false)
setPage(page + 1)
}
},
error: setError,
complete: () => setLoadingTokensWithPriceHistory(false),
})
},
[duration, environment]
)
const loadMoreTokens = useCallback(() => {
setLoadingTokensWithPriceHistory(true)
const contracts = prefetchedSelectedTokensWithoutPriceHistory
.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
.map(toContractInput)
loadTokensWithPriceHistory({ contracts, appendingTokens: true, page, tokens })
}, [prefetchedSelectedTokensWithoutPriceHistory, page, loadTokensWithPriceHistory, tokens])
// Load tokens from cache when everything is available.
useEffect(() => {
if (everyTokenInCache) {
setTokens(cachedTokens)
setLoadingTokensWithPriceHistory(false)
}
}, [everyTokenInCache, cachedTokens])
// Load new token with price history data when prefetchedSelectedTokensWithoutPriceHistory for current
// duration has already been resolved.
useEffect(() => {
if (!everyTokenInCache) {
setLoadingTokensWithPriceHistory(true)
setTokens([])
if (duration === prefetchedDataDuration) {
const contracts = prefetchedSelectedTokensWithoutPriceHistory.slice(0, PAGE_SIZE).map(toContractInput)
loadTokensWithPriceHistory({ contracts, appendingTokens: false, page: 0 })
}
}
}, [
everyTokenInCache,
prefetchedSelectedTokensWithoutPriceHistory,
loadTokensWithPriceHistory,
const [sparklines, setSparklines] = useState<SparklineMap>({})
useMemo(() => {
fetchQuery<TopTokensSparklineQuery>(environment, tokenSparklineQuery, {
duration,
prefetchedDataDuration,
])
chain,
}).subscribe({
next(data) {
const map: SparklineMap = {}
data.topTokens?.forEach(
(current) => current?.address && (map[current.address] = filterPrices(current?.market?.priceHistory))
)
setSparklines(map)
},
})
}, [chain, duration, environment])
// Trigger fetching top 100 tokens without price history on first load, and on
// each change of chain or duration.
useEffect(() => {
setLoadingTokensWithoutPriceHistory(true)
loadTokensWithoutPriceHistory({ duration, chain })
}, [chain, duration, loadTokensWithoutPriceHistory])
setSparklines({})
}, [duration])
const tokens = useFilteredTokens(
useLazyLoadQuery<TopTokens100Query>(topTokens100Query, { duration, chain }).topTokens
)
return {
error,
loading: loadingTokensWithPriceHistory || loadingTokensWithoutPriceHistory,
tokens,
hasMore,
loadMoreTokens,
loadingRowCount,
tokens: useSortedTokens(tokens),
sparklines,
}
}
export const tokensQuery = graphql`
query TopTokens_TokensQuery($contracts: [ContractInput!]!, $duration: HistoryDuration!) {
tokens(contracts: $contracts) {
id @required(action: LOG)
name
chain @required(action: LOG)
address @required(action: LOG)
symbol
market(currency: USD) {
totalValueLocked {
value
currency
}
priceHistory(duration: $duration) {
timestamp
value
}
price {
value
currency
}
volume(duration: $duration) {
value
currency
}
pricePercentChange(duration: $duration) {
currency
value
}
}
project {
logoUrl
}
}
}
`

@ -42,7 +42,7 @@ import RemoveLiquidity from './RemoveLiquidity'
import RemoveLiquidityV3 from './RemoveLiquidity/V3'
import Swap from './Swap'
import { OpenClaimAddressModalAndRedirectToSwap, RedirectPathToSwapOnly, RedirectToSwap } from './Swap/redirects'
import Tokens, { LoadingTokens } from './Tokens'
import Tokens from './Tokens'
const TokenDetails = lazy(() => import('./TokenDetails'))
const Vote = lazy(() => import('./Vote'))
@ -168,14 +168,7 @@ export default function App() {
<Routes>
{tokensFlag === TokensVariant.Enabled && (
<>
<Route
path="tokens"
element={
<Suspense fallback={<LoadingTokens />}>
<Tokens />
</Suspense>
}
>
<Route path="tokens" element={<Tokens />}>
<Route path=":chainName" />
</Route>
<Route

@ -10,10 +10,11 @@ import SearchBar from 'components/Tokens/TokenTable/SearchBar'
import TimeSelector from 'components/Tokens/TokenTable/TimeSelector'
import TokenTable, { LoadingTokenTable } from 'components/Tokens/TokenTable/TokenTable'
import { FavoriteTokensVariant, useFavoriteTokensFlag } from 'featureFlags/flags/favoriteTokens'
import { PAGE_SIZE } from 'graphql/data/TopTokens'
import { chainIdToBackendName, isValidBackendChainName } from 'graphql/data/util'
import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
import { useResetAtom } from 'jotai/utils'
import { useEffect } from 'react'
import { Suspense, useEffect, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
import styled from 'styled-components/macro'
import { ThemedText } from 'theme'
@ -76,6 +77,8 @@ const Tokens = () => {
const { chainId: connectedChainId } = useWeb3React()
const connectedChainName = chainIdToBackendName(connectedChainId)
const [rowCount, setRowCount] = useState(PAGE_SIZE)
useEffect(() => {
resetFilterString()
}, [location, resetFilterString])
@ -110,7 +113,9 @@ const Tokens = () => {
<SearchBar />
</SearchContainer>
</FiltersWrapper>
<TokenTable />
<Suspense fallback={<LoadingTokenTable rowCount={rowCount} />}>
<TokenTable setRowCount={setRowCount} />
</Suspense>
</ExploreContainer>
</Trace>
)