Commit 029f3acb authored by lynn's avatar lynn Committed by GitHub

fix: fix explore table filtering and sorting bugs (#4705)

* fix explore table bugs

* temp

* fix search bar incongruency when navigating to other tab + remove flickering data

* add local cache

* more clear names

* add back useTopTokens return type interface

* respond to comments and dedup repeated code

* respond to cmcewen comments
parent 0b9fda5b
...@@ -2,7 +2,7 @@ import { Trans } from '@lingui/macro' ...@@ -2,7 +2,7 @@ import { Trans } from '@lingui/macro'
import searchIcon from 'assets/svg/search.svg' import searchIcon from 'assets/svg/search.svg'
import xIcon from 'assets/svg/x.svg' import xIcon from 'assets/svg/x.svg'
import useDebounce from 'hooks/useDebounce' import useDebounce from 'hooks/useDebounce'
import { useUpdateAtom } from 'jotai/utils' import { useAtomValue, useUpdateAtom } from 'jotai/utils'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
...@@ -59,10 +59,15 @@ const SearchInput = styled.input` ...@@ -59,10 +59,15 @@ const SearchInput = styled.input`
` `
export default function SearchBar() { export default function SearchBar() {
const [localFilterString, setLocalFilterString] = useState('') const currentString = useAtomValue(filterStringAtom)
const [localFilterString, setLocalFilterString] = useState(currentString)
const setFilterString = useUpdateAtom(filterStringAtom) const setFilterString = useUpdateAtom(filterStringAtom)
const debouncedLocalFilterString = useDebounce(localFilterString, 300) const debouncedLocalFilterString = useDebounce(localFilterString, 300)
useEffect(() => {
setLocalFilterString(currentString)
}, [currentString])
useEffect(() => { useEffect(() => {
setFilterString(debouncedLocalFilterString) setFilterString(debouncedLocalFilterString)
}, [debouncedLocalFilterString, setFilterString]) }, [debouncedLocalFilterString, setFilterString])
......
...@@ -38,8 +38,6 @@ import { ...@@ -38,8 +38,6 @@ import {
import { formatDelta, getDeltaArrow } from '../TokenDetails/PriceChart' import { formatDelta, getDeltaArrow } from '../TokenDetails/PriceChart'
import { DISPLAYS } from './TimeSelector' import { DISPLAYS } from './TimeSelector'
export const MAX_TOKENS_TO_LOAD = 100
const Cell = styled.div` const Cell = styled.div`
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -520,7 +518,7 @@ export const LoadedRow = forwardRef((props: LoadedRowProps, ref: ForwardedRef<HT ...@@ -520,7 +518,7 @@ export const LoadedRow = forwardRef((props: LoadedRowProps, ref: ForwardedRef<HT
<FavoriteIcon isFavorited={isFavorited} /> <FavoriteIcon isFavorited={isFavorited} />
</ClickFavorited> </ClickFavorited>
} }
listNumber={sortAscending ? MAX_TOKENS_TO_LOAD - tokenListIndex : tokenListIndex + 1} listNumber={sortAscending ? tokenListLength - tokenListIndex : tokenListIndex + 1}
tokenInfo={ tokenInfo={
<ClickableName> <ClickableName>
<LogoContainer> <LogoContainer>
......
import { Trans } from '@lingui/macro' import { Trans } from '@lingui/macro'
import { showFavoritesAtom } from 'components/Tokens/state' import { showFavoritesAtom } from 'components/Tokens/state'
import { usePrefetchTopTokens, useTopTokens } from 'graphql/data/TopTokens' import { PAGE_SIZE, useTopTokens } from 'graphql/data/TopTokens'
import { useAtomValue } from 'jotai/utils' import { useAtomValue } from 'jotai/utils'
import { ReactNode, useCallback, useRef } from 'react' import { ReactNode, useCallback, useRef } from 'react'
import { AlertTriangle } from 'react-feather' import { AlertTriangle } from 'react-feather'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
import { MAX_WIDTH_MEDIA_BREAKPOINT } from '../constants' import { MAX_WIDTH_MEDIA_BREAKPOINT } from '../constants'
import { HeaderRow, LoadedRow, LoadingRow, MAX_TOKENS_TO_LOAD } from './TokenRow' import { HeaderRow, LoadedRow, LoadingRow } from './TokenRow'
const LOADING_ROWS_COUNT = 3 const LOADING_ROWS_COUNT = 3
const ROWS_PER_PAGE_FETCH = 20
const GridContainer = styled.div` const GridContainer = styled.div`
display: flex; display: flex;
...@@ -55,13 +54,13 @@ function NoTokensState({ message }: { message: ReactNode }) { ...@@ -55,13 +54,13 @@ function NoTokensState({ message }: { message: ReactNode }) {
} }
const LoadingMoreRows = Array(LOADING_ROWS_COUNT).fill(<LoadingRow />) const LoadingMoreRows = Array(LOADING_ROWS_COUNT).fill(<LoadingRow />)
const InitialLoadingRows = Array(ROWS_PER_PAGE_FETCH).fill(<LoadingRow />) const LoadingRows = (rowCount?: number) => Array(rowCount ?? PAGE_SIZE).fill(<LoadingRow />)
export function LoadingTokenTable() { export function LoadingTokenTable({ rowCount }: { rowCount?: number }) {
return ( return (
<GridContainer> <GridContainer>
<HeaderRow /> <HeaderRow />
<TokenDataContainer>{InitialLoadingRows}</TokenDataContainer> <TokenDataContainer>{LoadingRows(rowCount)}</TokenDataContainer>
</GridContainer> </GridContainer>
) )
} }
...@@ -70,9 +69,8 @@ export default function TokenTable() { ...@@ -70,9 +69,8 @@ export default function TokenTable() {
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 prefetchedTokens = usePrefetchTopTokens() const { loading, tokens, tokensWithoutPriceHistoryCount, hasMore, loadMoreTokens } = useTopTokens()
const { loading, tokens, loadMoreTokens } = useTopTokens(prefetchedTokens) const showMoreLoadingRows = Boolean(loading && hasMore)
const hasMore = !tokens || tokens.length < MAX_TOKENS_TO_LOAD
const observer = useRef<IntersectionObserver>() const observer = useRef<IntersectionObserver>()
const lastTokenRef = useCallback( const lastTokenRef = useCallback(
...@@ -91,7 +89,7 @@ export default function TokenTable() { ...@@ -91,7 +89,7 @@ export default function TokenTable() {
/* loading and error state */ /* loading and error state */
if (loading && (!tokens || tokens?.length === 0)) { if (loading && (!tokens || tokens?.length === 0)) {
return <LoadingTokenTable /> return <LoadingTokenTable rowCount={Math.min(tokensWithoutPriceHistoryCount, PAGE_SIZE)} />
} else { } else {
if (!tokens) { if (!tokens) {
return ( return (
...@@ -118,14 +116,14 @@ export default function TokenTable() { ...@@ -118,14 +116,14 @@ export default function TokenTable() {
<TokenDataContainer> <TokenDataContainer>
{tokens.map((token, index) => ( {tokens.map((token, index) => (
<LoadedRow <LoadedRow
key={token?.name} key={token?.address}
tokenListIndex={index} tokenListIndex={index}
tokenListLength={tokens?.length ?? 0} tokenListLength={tokens?.length ?? 0}
token={token} token={token}
ref={tokens.length === index + 1 ? lastTokenRef : undefined} ref={index + 1 === tokens.length ? lastTokenRef : undefined}
/> />
))} ))}
{loading && LoadingMoreRows} {showMoreLoadingRows && LoadingMoreRows}
</TokenDataContainer> </TokenDataContainer>
</GridContainer> </GridContainer>
</> </>
......
...@@ -8,18 +8,17 @@ import { ...@@ -8,18 +8,17 @@ import {
sortMethodAtom, sortMethodAtom,
} 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 { useCallback, useLayoutEffect, useMemo, useState } from 'react'
import { fetchQuery, useLazyLoadQuery, useRelayEnvironment } from 'react-relay' import { fetchQuery, useLazyLoadQuery, useRelayEnvironment } from 'react-relay'
import { ContractInput, TopTokens_TokensQuery } from './__generated__/TopTokens_TokensQuery.graphql' import { ContractInput, HistoryDuration, TopTokens_TokensQuery } from './__generated__/TopTokens_TokensQuery.graphql'
import type { TopTokens100Query } from './__generated__/TopTokens100Query.graphql' import type { TopTokens100Query } from './__generated__/TopTokens100Query.graphql'
import { toHistoryDuration, useCurrentChainName } from './util' import { toHistoryDuration } from './util'
import { useCurrentChainName } from './util'
export function usePrefetchTopTokens() { export function usePrefetchTopTokens(duration: HistoryDuration) {
const duration = toHistoryDuration(useAtomValue(filterTimeAtom))
const chain = useCurrentChainName() const chain = useCurrentChainName()
const args = useMemo(() => ({ chain, duration }), [chain, duration]) return useLazyLoadQuery<TopTokens100Query>(topTokens100Query, { duration, chain })
return useLazyLoadQuery<TopTokens100Query>(topTokens100Query, args)
} }
const topTokens100Query = graphql` const topTokens100Query = graphql`
...@@ -120,7 +119,8 @@ function useFilteredTokens(tokens: PrefetchedTopToken[]) { ...@@ -120,7 +119,8 @@ function useFilteredTokens(tokens: PrefetchedTopToken[]) {
}, [tokens, showFavorites, lowercaseFilterString, favorites]) }, [tokens, showFavorites, lowercaseFilterString, favorites])
} }
const PAGE_SIZE = 20 // Number of items to render in each fetch in infinite scroll.
export const PAGE_SIZE = 20
function toContractInput(token: PrefetchedTopToken) { function toContractInput(token: PrefetchedTopToken) {
return { return {
...@@ -129,63 +129,114 @@ function toContractInput(token: PrefetchedTopToken) { ...@@ -129,63 +129,114 @@ function toContractInput(token: PrefetchedTopToken) {
} }
} }
// Map of key: ${chain} + ${address} and value: TopToken object.
// Acts as a local cache.
const tokensWithPriceHistoryCache: Record<string, TopToken> = {}
const checkIfAllTokensCached = (tokens: PrefetchedTopToken[]) => {
let everyTokenInCache = true
const cachedTokens: TopToken[] = []
const checkCache = (token: PrefetchedTopToken) => {
const tokenCacheKey = !!token ? `${token.chain}${token.address}` : ''
if (tokenCacheKey in tokensWithPriceHistoryCache) {
cachedTokens.push(tokensWithPriceHistoryCache[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<TopTokens_TokensQuery['response']['tokens']>[number]
interface UseTopTokensReturnValue { interface UseTopTokensReturnValue {
loading: boolean loading: boolean
tokens: TopToken[] tokens: TopToken[] | undefined
tokensWithoutPriceHistoryCount: number
hasMore: boolean
loadMoreTokens: () => void loadMoreTokens: () => void
} }
export function useTopTokens(prefetchedData: TopTokens100Query['response']): UseTopTokensReturnValue { export function useTopTokens(): UseTopTokensReturnValue {
const duration = toHistoryDuration(useAtomValue(filterTimeAtom)) const duration = toHistoryDuration(useAtomValue(filterTimeAtom))
const environment = useRelayEnvironment()
const [tokens, setTokens] = useState<TopToken[]>([])
const [page, setPage] = useState(0)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [tokens, setTokens] = useState<TopToken[]>()
const [page, setPage] = useState(0)
const prefetchedData = usePrefetchTopTokens(duration)
const prefetchedSelectedTokensWithoutPriceHistory = useFilteredTokens(useSortedTokens(prefetchedData.topTokens))
const appendTokens = useCallback( const hasMore = !tokens || tokens.length < prefetchedSelectedTokensWithoutPriceHistory.length
(newTokens: TopToken[]) => {
setTokens( const environment = useRelayEnvironment()
Object.values(
tokens
.concat(newTokens)
.reduce((acc, token) => (token?.address ? { ...acc, [token.address]: token } : acc), {})
)
)
},
[tokens]
)
const loadMoreTokens = useCallback(() => setPage(page + 1), [page])
// TopTokens should ideally be fetched with usePaginationFragment. The backend does not current support graphql cursors; // 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 // in the meantime, fetchQuery is used, as other relay hooks do not allow the refreshing and lazy loading we need
const prefetchedSelectedTokens = useFilteredTokens(useSortedTokens(prefetchedData.topTokens)) const loadTokensWithPriceHistory = useCallback(
const contracts: ContractInput[] = useMemo( ({
() => prefetchedSelectedTokens.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE).map(toContractInput), contracts,
[page, prefetchedSelectedTokens] appendingTokens,
page,
tokens,
}: {
contracts: ContractInput[]
appendingTokens: boolean
page: number
tokens?: TopToken[]
}) => {
fetchQuery<TopTokens_TokensQuery>(
environment,
tokensQuery,
{ contracts, duration },
{ fetchPolicy: 'store-or-network' }
)
.toPromise()
.then((data) => {
if (data?.tokens) {
data.tokens.map((token) =>
!!token ? (tokensWithPriceHistoryCache[`${token.chain}${token.address}`] = token) : null
)
appendingTokens ? setTokens([...(tokens ?? []), ...data.tokens]) : setTokens([...data.tokens])
setLoading(false)
setPage(page + 1)
}
})
},
[duration, environment]
) )
useEffect(() => { const loadMoreTokens = useCallback(() => {
const subscription = fetchQuery<TopTokens_TokensQuery>( setLoading(true)
environment, const contracts = prefetchedSelectedTokensWithoutPriceHistory
tokensQuery, .slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
{ contracts, duration }, .map(toContractInput)
{ fetchPolicy: 'store-or-network' } loadTokensWithPriceHistory({ contracts, appendingTokens: true, page, tokens })
).subscribe({ }, [prefetchedSelectedTokensWithoutPriceHistory, page, loadTokensWithPriceHistory, tokens])
start() {
setLoading(true) // Reset count when filters are changed
}, useLayoutEffect(() => {
complete() { const { everyTokenInCache, cachedTokens } = checkIfAllTokensCached(prefetchedSelectedTokensWithoutPriceHistory)
setLoading(false) if (everyTokenInCache) {
}, setTokens(cachedTokens)
next(data) { setLoading(false)
appendTokens(data.tokens as TopToken[]) return
}, } else {
}) setLoading(true)
return subscription.unsubscribe setTokens([])
}, [appendTokens, contracts, duration, environment]) const contracts = prefetchedSelectedTokensWithoutPriceHistory.slice(0, PAGE_SIZE).map(toContractInput)
loadTokensWithPriceHistory({ contracts, appendingTokens: false, page: 0 })
return { loading, tokens: useFilteredTokens(useSortedTokens(tokens)) as TopToken[], loadMoreTokens } }
}, [loadTokensWithPriceHistory, prefetchedSelectedTokensWithoutPriceHistory])
return {
loading,
tokens,
hasMore,
tokensWithoutPriceHistoryCount: prefetchedSelectedTokensWithoutPriceHistory.length,
loadMoreTokens,
}
} }
export const tokensQuery = graphql` export const tokensQuery = graphql`
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment