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:
parent
351f66a83e
commit
d8677d8a6d
@ -1,14 +1,20 @@
|
|||||||
|
import { SparkLineLoadingBubble } from 'components/Tokens/TokenTable/TokenRow'
|
||||||
import { curveCardinal, scaleLinear } from 'd3'
|
import { curveCardinal, scaleLinear } from 'd3'
|
||||||
import { filterPrices } from 'graphql/data/Token'
|
import { PricePoint } from 'graphql/data/Token'
|
||||||
import { TopToken } from 'graphql/data/TopTokens'
|
import { SparklineMap, TopToken } from 'graphql/data/TopTokens'
|
||||||
import { TimePeriod } from 'graphql/data/util'
|
import { TimePeriod } from 'graphql/data/util'
|
||||||
import React from 'react'
|
import { memo } from 'react'
|
||||||
import { useTheme } from 'styled-components/macro'
|
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'
|
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 {
|
interface SparklineChartProps {
|
||||||
width: number
|
width: number
|
||||||
@ -16,15 +22,32 @@ interface SparklineChartProps {
|
|||||||
tokenData: TopToken
|
tokenData: TopToken
|
||||||
pricePercentChange: number | undefined | null
|
pricePercentChange: number | undefined | null
|
||||||
timePeriod: TimePeriod
|
timePeriod: TimePeriod
|
||||||
|
sparklineMap: SparklineMap
|
||||||
}
|
}
|
||||||
|
|
||||||
function SparklineChart({ width, height, tokenData, pricePercentChange, timePeriod }: SparklineChartProps) {
|
function _SparklineChart({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
tokenData,
|
||||||
|
pricePercentChange,
|
||||||
|
timePeriod,
|
||||||
|
sparklineMap,
|
||||||
|
}: SparklineChartProps) {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
// for sparkline
|
// for sparkline
|
||||||
const pricePoints = filterPrices(tokenData?.market?.priceHistory) ?? []
|
const pricePoints = tokenData?.address ? sparklineMap[tokenData.address] : null
|
||||||
const hasData = pricePoints.length !== 0
|
|
||||||
const startingPrice = hasData ? pricePoints[0] : DATA_EMPTY
|
// Don't display if there's one or less pricepoints
|
||||||
const endingPrice = hasData ? pricePoints[pricePoints.length - 1] : DATA_EMPTY
|
if (!pricePoints || pricePoints.length <= 1) {
|
||||||
|
return (
|
||||||
|
<LoadingContainer>
|
||||||
|
<SparkLineLoadingBubble />
|
||||||
|
</LoadingContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startingPrice = pricePoints[0]
|
||||||
|
const endingPrice = pricePoints[pricePoints.length - 1]
|
||||||
const widthScale = scaleLinear()
|
const widthScale = scaleLinear()
|
||||||
.domain(
|
.domain(
|
||||||
// the range of possible input values
|
// 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 { Trans } from '@lingui/macro'
|
||||||
import { TokenSortMethod } from 'graphql/data/TopTokens'
|
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import styled from 'styled-components/macro'
|
import styled from 'styled-components/macro'
|
||||||
import { ThemedText } from 'theme'
|
import { ThemedText } from 'theme'
|
||||||
import { textFadeIn } from 'theme/animations'
|
import { textFadeIn } from 'theme/animations'
|
||||||
import { formatDollar } from 'utils/formatDollarAmt'
|
import { formatDollar } from 'utils/formatDollarAmt'
|
||||||
|
|
||||||
|
import { TokenSortMethod } from '../state'
|
||||||
import { HEADER_DESCRIPTIONS } from '../TokenTable/TokenRow'
|
import { HEADER_DESCRIPTIONS } from '../TokenTable/TokenRow'
|
||||||
import InfoTip from './InfoTip'
|
import InfoTip from './InfoTip'
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import SparklineChart from 'components/Charts/SparklineChart'
|
|||||||
import CurrencyLogo from 'components/CurrencyLogo'
|
import CurrencyLogo from 'components/CurrencyLogo'
|
||||||
import { getChainInfo } from 'constants/chainInfo'
|
import { getChainInfo } from 'constants/chainInfo'
|
||||||
import { FavoriteTokensVariant, useFavoriteTokensFlag } from 'featureFlags/flags/favoriteTokens'
|
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 { CHAIN_NAME_TO_CHAIN_ID, getTokenDetailsURL } from 'graphql/data/util'
|
||||||
import { useAtomValue } from 'jotai/utils'
|
import { useAtomValue } from 'jotai/utils'
|
||||||
import { ForwardedRef, forwardRef } from 'react'
|
import { ForwardedRef, forwardRef } from 'react'
|
||||||
@ -30,6 +30,7 @@ import {
|
|||||||
filterTimeAtom,
|
filterTimeAtom,
|
||||||
sortAscendingAtom,
|
sortAscendingAtom,
|
||||||
sortMethodAtom,
|
sortMethodAtom,
|
||||||
|
TokenSortMethod,
|
||||||
useIsFavorited,
|
useIsFavorited,
|
||||||
useSetSortMethod,
|
useSetSortMethod,
|
||||||
useToggleFavorite,
|
useToggleFavorite,
|
||||||
@ -310,7 +311,7 @@ const IconLoadingBubble = styled(LoadingBubble)`
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
`
|
`
|
||||||
const SparkLineLoadingBubble = styled(LongLoadingBubble)`
|
export const SparkLineLoadingBubble = styled(LongLoadingBubble)`
|
||||||
height: 4px;
|
height: 4px;
|
||||||
`
|
`
|
||||||
|
|
||||||
@ -395,7 +396,7 @@ export function TokenRow({
|
|||||||
marketCap: ReactNode
|
marketCap: ReactNode
|
||||||
price: ReactNode
|
price: ReactNode
|
||||||
percentChange: ReactNode
|
percentChange: ReactNode
|
||||||
sparkLine: ReactNode
|
sparkLine?: ReactNode
|
||||||
tokenInfo: ReactNode
|
tokenInfo: ReactNode
|
||||||
volume: ReactNode
|
volume: ReactNode
|
||||||
last?: boolean
|
last?: boolean
|
||||||
@ -466,6 +467,7 @@ interface LoadedRowProps {
|
|||||||
tokenListIndex: number
|
tokenListIndex: number
|
||||||
tokenListLength: number
|
tokenListLength: number
|
||||||
token: NonNullable<TopToken>
|
token: NonNullable<TopToken>
|
||||||
|
sparklineMap: SparklineMap
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loaded State: row component with token information */
|
/* 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 isFavorited = useIsFavorited(tokenAddress)
|
||||||
const toggleFavorite = useToggleFavorite(tokenAddress)
|
const toggleFavorite = useToggleFavorite(tokenAddress)
|
||||||
const filterString = useAtomValue(filterStringAtom)
|
const filterString = useAtomValue(filterStringAtom)
|
||||||
|
const sortAscending = useAtomValue(sortAscendingAtom)
|
||||||
|
|
||||||
const lowercaseChainName = useParams<{ chainName?: string }>().chainName?.toUpperCase() ?? 'ethereum'
|
const lowercaseChainName = useParams<{ chainName?: string }>().chainName?.toUpperCase() ?? 'ethereum'
|
||||||
const filterNetwork = lowercaseChainName.toUpperCase()
|
const filterNetwork = lowercaseChainName.toUpperCase()
|
||||||
@ -515,7 +518,7 @@ export const LoadedRow = forwardRef((props: LoadedRowProps, ref: ForwardedRef<HT
|
|||||||
<FavoriteIcon isFavorited={isFavorited} />
|
<FavoriteIcon isFavorited={isFavorited} />
|
||||||
</ClickFavorited>
|
</ClickFavorited>
|
||||||
}
|
}
|
||||||
listNumber={tokenListIndex + 1}
|
listNumber={sortAscending ? tokenListLength - tokenListIndex : tokenListIndex + 1}
|
||||||
tokenInfo={
|
tokenInfo={
|
||||||
<ClickableName>
|
<ClickableName>
|
||||||
<LogoContainer>
|
<LogoContainer>
|
||||||
@ -558,15 +561,18 @@ export const LoadedRow = forwardRef((props: LoadedRowProps, ref: ForwardedRef<HT
|
|||||||
sparkLine={
|
sparkLine={
|
||||||
<SparkLine>
|
<SparkLine>
|
||||||
<ParentSize>
|
<ParentSize>
|
||||||
{({ width, height }) => (
|
{({ width, height }) =>
|
||||||
<SparklineChart
|
props.sparklineMap && (
|
||||||
width={width}
|
<SparklineChart
|
||||||
height={height}
|
width={width}
|
||||||
tokenData={token}
|
height={height}
|
||||||
pricePercentChange={token.market?.pricePercentChange?.value}
|
tokenData={token}
|
||||||
timePeriod={timePeriod}
|
pricePercentChange={token.market?.pricePercentChange?.value}
|
||||||
/>
|
timePeriod={timePeriod}
|
||||||
)}
|
sparklineMap={props.sparklineMap}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
</ParentSize>
|
</ParentSize>
|
||||||
</SparkLine>
|
</SparkLine>
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { showFavoritesAtom } from 'components/Tokens/state'
|
|||||||
import { PAGE_SIZE, useTopTokens } from 'graphql/data/TopTokens'
|
import { PAGE_SIZE, useTopTokens } from 'graphql/data/TopTokens'
|
||||||
import { validateUrlChainParam } from 'graphql/data/util'
|
import { validateUrlChainParam } from 'graphql/data/util'
|
||||||
import { useAtomValue } from 'jotai/utils'
|
import { useAtomValue } from 'jotai/utils'
|
||||||
import { ReactNode, useCallback, useRef } from 'react'
|
import { ReactNode } from 'react'
|
||||||
import { AlertTriangle } from 'react-feather'
|
import { AlertTriangle } from 'react-feather'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import styled from 'styled-components/macro'
|
import styled from 'styled-components/macro'
|
||||||
@ -11,8 +11,6 @@ import styled from 'styled-components/macro'
|
|||||||
import { MAX_WIDTH_MEDIA_BREAKPOINT } from '../constants'
|
import { MAX_WIDTH_MEDIA_BREAKPOINT } from '../constants'
|
||||||
import { HeaderRow, LoadedRow, LoadingRow } from './TokenRow'
|
import { HeaderRow, LoadedRow, LoadingRow } from './TokenRow'
|
||||||
|
|
||||||
const LOADING_ROWS_COUNT = 3
|
|
||||||
|
|
||||||
const GridContainer = styled.div`
|
const GridContainer = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -58,13 +56,19 @@ function NoTokensState({ message }: { message: ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const LoadingRows = (rowCount?: number) =>
|
const LoadingRowsWrapper = styled.div`
|
||||||
Array(rowCount ?? PAGE_SIZE)
|
margin-top: 8px;
|
||||||
.fill(null)
|
`
|
||||||
.map((_, index) => {
|
|
||||||
return <LoadingRow key={index} />
|
const LoadingRows = (rowCount?: number) => (
|
||||||
})
|
<LoadingRowsWrapper>
|
||||||
const LoadingMoreRows = LoadingRows(LOADING_ROWS_COUNT)
|
{Array(rowCount ?? PAGE_SIZE)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, index) => {
|
||||||
|
return <LoadingRow key={index} />
|
||||||
|
})}
|
||||||
|
</LoadingRowsWrapper>
|
||||||
|
)
|
||||||
|
|
||||||
export function LoadingTokenTable({ rowCount }: { rowCount?: number }) {
|
export function LoadingTokenTable({ rowCount }: { rowCount?: number }) {
|
||||||
return (
|
return (
|
||||||
@ -75,73 +79,51 @@ export function LoadingTokenTable({ rowCount }: { rowCount?: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TokenTable() {
|
export default function TokenTable({ setRowCount }: { setRowCount: (c: number) => void }) {
|
||||||
const showFavorites = useAtomValue<boolean>(showFavoritesAtom)
|
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
|
// 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 chainName = validateUrlChainParam(useParams<{ chainName?: string }>().chainName)
|
||||||
const { error, loading, tokens, hasMore, loadMoreTokens, loadingRowCount } = useTopTokens(chainName)
|
const { tokens, sparklines } = useTopTokens(chainName)
|
||||||
const showMoreLoadingRows = Boolean(loading && hasMore)
|
setRowCount(tokens?.length ?? PAGE_SIZE)
|
||||||
|
|
||||||
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]
|
|
||||||
)
|
|
||||||
|
|
||||||
/* loading and error state */
|
/* loading and error state */
|
||||||
if (loading && (!tokens || tokens?.length === 0)) {
|
if (!tokens) {
|
||||||
return <LoadingTokenTable rowCount={loadingRowCount} />
|
return (
|
||||||
|
<NoTokensState
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<AlertTriangle size={16} />
|
||||||
|
<Trans>An error occurred loading tokens. Please try again.</Trans>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else if (tokens?.length === 0) {
|
||||||
|
return showFavorites ? (
|
||||||
|
<NoTokensState message={<Trans>You have no favorited tokens</Trans>} />
|
||||||
|
) : (
|
||||||
|
<NoTokensState message={<Trans>No tokens found</Trans>} />
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
if (error || !tokens) {
|
return (
|
||||||
return (
|
<GridContainer>
|
||||||
<NoTokensState
|
<HeaderRow />
|
||||||
message={
|
<TokenDataContainer>
|
||||||
<>
|
{tokens.map(
|
||||||
<AlertTriangle size={16} />
|
(token, index) =>
|
||||||
<Trans>An error occurred loading tokens. Please try again.</Trans>
|
token && (
|
||||||
</>
|
<LoadedRow
|
||||||
}
|
key={token?.address}
|
||||||
/>
|
tokenListIndex={index}
|
||||||
)
|
tokenListLength={tokens.length}
|
||||||
} else if (tokens?.length === 0) {
|
token={token}
|
||||||
return showFavorites ? (
|
sparklineMap={sparklines}
|
||||||
<NoTokensState message={<Trans>You have no favorited tokens</Trans>} />
|
/>
|
||||||
) : (
|
)
|
||||||
<NoTokensState message={<Trans>No tokens found</Trans>} />
|
)}
|
||||||
)
|
</TokenDataContainer>
|
||||||
} else {
|
</GridContainer>
|
||||||
return (
|
)
|
||||||
<>
|
|
||||||
<GridContainer>
|
|
||||||
<HeaderRow />
|
|
||||||
<TokenDataContainer>
|
|
||||||
{tokens.map(
|
|
||||||
(token, index) =>
|
|
||||||
token && (
|
|
||||||
<LoadedRow
|
|
||||||
key={token?.address}
|
|
||||||
tokenListIndex={index}
|
|
||||||
tokenListLength={tokens.length}
|
|
||||||
token={token}
|
|
||||||
ref={index + 1 === tokens.length ? lastTokenRef : undefined}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{showMoreLoadingRows && LoadingMoreRows}
|
|
||||||
</TokenDataContainer>
|
|
||||||
</GridContainer>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,15 @@
|
|||||||
import { TokenSortMethod } from 'graphql/data/TopTokens'
|
|
||||||
import { TimePeriod } from 'graphql/data/util'
|
import { TimePeriod } from 'graphql/data/util'
|
||||||
import { atom, useAtom } from 'jotai'
|
import { atom, useAtom } from 'jotai'
|
||||||
import { atomWithReset, atomWithStorage, useAtomValue } from 'jotai/utils'
|
import { atomWithReset, atomWithStorage, useAtomValue } from 'jotai/utils'
|
||||||
import { useCallback, useMemo } from 'react'
|
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 favoritesAtom = atomWithStorage<string[]>('favorites', [])
|
||||||
export const showFavoritesAtom = atomWithStorage<boolean>('showFavorites', false)
|
export const showFavoritesAtom = atomWithStorage<boolean>('showFavorites', false)
|
||||||
export const filterStringAtom = atomWithReset<string>('')
|
export const filterStringAtom = atomWithReset<string>('')
|
||||||
|
@ -6,18 +6,15 @@ import {
|
|||||||
showFavoritesAtom,
|
showFavoritesAtom,
|
||||||
sortAscendingAtom,
|
sortAscendingAtom,
|
||||||
sortMethodAtom,
|
sortMethodAtom,
|
||||||
|
TokenSortMethod,
|
||||||
} from 'components/Tokens/state'
|
} from 'components/Tokens/state'
|
||||||
import { useAtomValue } from 'jotai/utils'
|
import { useAtomValue } from 'jotai/utils'
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { fetchQuery, useRelayEnvironment } from 'react-relay'
|
import { fetchQuery, useLazyLoadQuery, useRelayEnvironment } from 'react-relay'
|
||||||
|
|
||||||
import {
|
import type { Chain, TopTokens100Query } from './__generated__/TopTokens100Query.graphql'
|
||||||
Chain,
|
import { TopTokensSparklineQuery } from './__generated__/TopTokensSparklineQuery.graphql'
|
||||||
ContractInput,
|
import { filterPrices, PricePoint } from './Token'
|
||||||
HistoryDuration,
|
|
||||||
TopTokens_TokensQuery,
|
|
||||||
} from './__generated__/TopTokens_TokensQuery.graphql'
|
|
||||||
import type { TopTokens100Query } from './__generated__/TopTokens100Query.graphql'
|
|
||||||
import { toHistoryDuration } from './util'
|
import { toHistoryDuration } from './util'
|
||||||
|
|
||||||
const topTokens100Query = graphql`
|
const topTokens100Query = graphql`
|
||||||
@ -46,16 +43,26 @@ const topTokens100Query = graphql`
|
|||||||
currency
|
currency
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
project {
|
||||||
|
logoUrl
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
export enum TokenSortMethod {
|
const tokenSparklineQuery = graphql`
|
||||||
PRICE = 'Price',
|
query TopTokensSparklineQuery($duration: HistoryDuration!, $chain: Chain!) {
|
||||||
PERCENT_CHANGE = 'Change',
|
topTokens(pageSize: 100, page: 1, chain: $chain) {
|
||||||
TOTAL_VALUE_LOCKED = 'TVL',
|
address
|
||||||
VOLUME = 'Volume',
|
market(currency: USD) {
|
||||||
}
|
priceHistory(duration: $duration) {
|
||||||
|
timestamp
|
||||||
|
value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export type PrefetchedTopToken = NonNullable<TopTokens100Query['response']['topTokens']>[number]
|
export type PrefetchedTopToken = NonNullable<TopTokens100Query['response']['topTokens']>[number]
|
||||||
|
|
||||||
@ -90,7 +97,7 @@ function useSortedTokens(tokens: TopTokens100Query['response']['topTokens'] | un
|
|||||||
}, [tokens, sortMethod, sortAscending])
|
}, [tokens, sortMethod, sortAscending])
|
||||||
}
|
}
|
||||||
|
|
||||||
function useFilteredTokens(tokens: PrefetchedTopToken[]) {
|
function useFilteredTokens(tokens: NonNullable<TopTokens100Query['response']>['topTokens']) {
|
||||||
const filterString = useAtomValue(filterStringAtom)
|
const filterString = useAtomValue(filterStringAtom)
|
||||||
const favorites = useAtomValue(favoritesAtom)
|
const favorites = useAtomValue(favoritesAtom)
|
||||||
const showFavorites = useAtomValue(showFavoritesAtom)
|
const showFavorites = useAtomValue(showFavoritesAtom)
|
||||||
@ -121,240 +128,43 @@ function useFilteredTokens(tokens: PrefetchedTopToken[]) {
|
|||||||
// Number of items to render in each fetch in infinite scroll.
|
// Number of items to render in each fetch in infinite scroll.
|
||||||
export const PAGE_SIZE = 20
|
export const PAGE_SIZE = 20
|
||||||
|
|
||||||
function toContractInput(token: PrefetchedTopToken) {
|
export type TopToken = NonNullable<NonNullable<TopTokens100Query['response']>['topTokens']>[number]
|
||||||
return {
|
export type SparklineMap = { [key: string]: PricePoint[] | undefined }
|
||||||
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]
|
|
||||||
interface UseTopTokensReturnValue {
|
interface UseTopTokensReturnValue {
|
||||||
error: Error | undefined
|
|
||||||
loading: boolean
|
|
||||||
tokens: TopToken[] | undefined
|
tokens: TopToken[] | undefined
|
||||||
hasMore: boolean
|
sparklines: SparklineMap
|
||||||
loadMoreTokens: () => void
|
|
||||||
loadingRowCount: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTopTokens(chain: Chain): UseTopTokensReturnValue {
|
export function useTopTokens(chain: Chain): UseTopTokensReturnValue {
|
||||||
const duration = toHistoryDuration(useAtomValue(filterTimeAtom))
|
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 environment = useRelayEnvironment()
|
||||||
|
const [sparklines, setSparklines] = useState<SparklineMap>({})
|
||||||
|
useMemo(() => {
|
||||||
|
fetchQuery<TopTokensSparklineQuery>(environment, tokenSparklineQuery, {
|
||||||
|
duration,
|
||||||
|
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])
|
||||||
|
|
||||||
const loadTokensWithoutPriceHistory = useCallback(
|
useEffect(() => {
|
||||||
({ duration, chain }: { duration: HistoryDuration; chain: Chain }) => {
|
setSparklines({})
|
||||||
setTokens([])
|
}, [duration])
|
||||||
fetchQuery<TopTokens100Query>(
|
|
||||||
environment,
|
const tokens = useFilteredTokens(
|
||||||
topTokens100Query,
|
useLazyLoadQuery<TopTokens100Query>(topTokens100Query, { duration, chain }).topTokens
|
||||||
{ 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,
|
|
||||||
duration,
|
|
||||||
prefetchedDataDuration,
|
|
||||||
])
|
|
||||||
|
|
||||||
// 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])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
error,
|
tokens: useSortedTokens(tokens),
|
||||||
loading: loadingTokensWithPriceHistory || loadingTokensWithoutPriceHistory,
|
sparklines,
|
||||||
tokens,
|
|
||||||
hasMore,
|
|
||||||
loadMoreTokens,
|
|
||||||
loadingRowCount,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 RemoveLiquidityV3 from './RemoveLiquidity/V3'
|
||||||
import Swap from './Swap'
|
import Swap from './Swap'
|
||||||
import { OpenClaimAddressModalAndRedirectToSwap, RedirectPathToSwapOnly, RedirectToSwap } from './Swap/redirects'
|
import { OpenClaimAddressModalAndRedirectToSwap, RedirectPathToSwapOnly, RedirectToSwap } from './Swap/redirects'
|
||||||
import Tokens, { LoadingTokens } from './Tokens'
|
import Tokens from './Tokens'
|
||||||
|
|
||||||
const TokenDetails = lazy(() => import('./TokenDetails'))
|
const TokenDetails = lazy(() => import('./TokenDetails'))
|
||||||
const Vote = lazy(() => import('./Vote'))
|
const Vote = lazy(() => import('./Vote'))
|
||||||
@ -168,14 +168,7 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
{tokensFlag === TokensVariant.Enabled && (
|
{tokensFlag === TokensVariant.Enabled && (
|
||||||
<>
|
<>
|
||||||
<Route
|
<Route path="tokens" element={<Tokens />}>
|
||||||
path="tokens"
|
|
||||||
element={
|
|
||||||
<Suspense fallback={<LoadingTokens />}>
|
|
||||||
<Tokens />
|
|
||||||
</Suspense>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route path=":chainName" />
|
<Route path=":chainName" />
|
||||||
</Route>
|
</Route>
|
||||||
<Route
|
<Route
|
||||||
|
@ -10,10 +10,11 @@ import SearchBar from 'components/Tokens/TokenTable/SearchBar'
|
|||||||
import TimeSelector from 'components/Tokens/TokenTable/TimeSelector'
|
import TimeSelector from 'components/Tokens/TokenTable/TimeSelector'
|
||||||
import TokenTable, { LoadingTokenTable } from 'components/Tokens/TokenTable/TokenTable'
|
import TokenTable, { LoadingTokenTable } from 'components/Tokens/TokenTable/TokenTable'
|
||||||
import { FavoriteTokensVariant, useFavoriteTokensFlag } from 'featureFlags/flags/favoriteTokens'
|
import { FavoriteTokensVariant, useFavoriteTokensFlag } from 'featureFlags/flags/favoriteTokens'
|
||||||
|
import { PAGE_SIZE } from 'graphql/data/TopTokens'
|
||||||
import { chainIdToBackendName, isValidBackendChainName } from 'graphql/data/util'
|
import { chainIdToBackendName, isValidBackendChainName } from 'graphql/data/util'
|
||||||
import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
|
import { useOnGlobalChainSwitch } from 'hooks/useGlobalChainSwitch'
|
||||||
import { useResetAtom } from 'jotai/utils'
|
import { useResetAtom } from 'jotai/utils'
|
||||||
import { useEffect } from 'react'
|
import { Suspense, useEffect, useState } from 'react'
|
||||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||||
import styled from 'styled-components/macro'
|
import styled from 'styled-components/macro'
|
||||||
import { ThemedText } from 'theme'
|
import { ThemedText } from 'theme'
|
||||||
@ -76,6 +77,8 @@ const Tokens = () => {
|
|||||||
const { chainId: connectedChainId } = useWeb3React()
|
const { chainId: connectedChainId } = useWeb3React()
|
||||||
const connectedChainName = chainIdToBackendName(connectedChainId)
|
const connectedChainName = chainIdToBackendName(connectedChainId)
|
||||||
|
|
||||||
|
const [rowCount, setRowCount] = useState(PAGE_SIZE)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
resetFilterString()
|
resetFilterString()
|
||||||
}, [location, resetFilterString])
|
}, [location, resetFilterString])
|
||||||
@ -110,7 +113,9 @@ const Tokens = () => {
|
|||||||
<SearchBar />
|
<SearchBar />
|
||||||
</SearchContainer>
|
</SearchContainer>
|
||||||
</FiltersWrapper>
|
</FiltersWrapper>
|
||||||
<TokenTable />
|
<Suspense fallback={<LoadingTokenTable rowCount={rowCount} />}>
|
||||||
|
<TokenTable setRowCount={setRowCount} />
|
||||||
|
</Suspense>
|
||||||
</ExploreContainer>
|
</ExploreContainer>
|
||||||
</Trace>
|
</Trace>
|
||||||
)
|
)
|
||||||
|
Loading…
Reference in New Issue
Block a user