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

feat: virtualized assets page #8557

Merged
merged 13 commits into from
Jan 14, 2025
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@
"@shapeshiftoss/utils": "workspace:^",
"@sniptt/monads": "^0.5.10",
"@tanstack/react-query": "^5.52.0",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.2",
"@uniswap/sdk": "^3.0.3",
"@uniswap/sdk-core": "^5.3.1",
"@uniswap/v3-sdk": "^3.13.1",
Expand Down
314 changes: 314 additions & 0 deletions src/components/MarketsTableVirtualized.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,314 @@
import {
Box,
Button,
Stack,
Table,
Tag,
Tbody,
Td,
Th,
Thead,
Tr,
useMediaQuery,
} from '@chakra-ui/react'
import { bnOrZero } from '@shapeshiftoss/chain-adapters'
import type { Asset } from '@shapeshiftoss/types'
import type { ColumnDef, Row } from '@tanstack/react-table'
import { flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
import type { VirtualItem } from '@tanstack/react-virtual'
import { useVirtualizer } from '@tanstack/react-virtual'
import { truncate } from 'lodash'
import { memo, useCallback, useMemo, useRef } from 'react'
import { RiArrowRightDownFill, RiArrowRightUpFill } from 'react-icons/ri'
import { useTranslate } from 'react-polyglot'
import { useHistory } from 'react-router'
import { Amount } from 'components/Amount/Amount'
import { Display } from 'components/Display'
import { AssetCell } from 'components/StakingVaults/Cells'
import { Text } from 'components/Text'
import { SparkLine } from 'pages/Buy/components/Sparkline'
import { useFetchFiatAssetMarketData } from 'state/apis/fiatRamps/hooks'
import { selectMarketDataUserCurrency } from 'state/slices/selectors'
import { useAppSelector } from 'state/store'
import { breakpoints } from 'theme/theme'

const ROW_HEIGHT = 70

const arrowUp = <RiArrowRightUpFill />
const arrowDown = <RiArrowRightDownFill />

const tableSizeSx = { base: 'sm', md: 'md' }
const gridTemplateColumnsSx = {
base: '70% 30%',
md: '2fr 1fr 1fr 1fr 1fr 80px',
}

const assetCellSx = {
maxWidth: '350px',
}

// Hide virtual list container scrollbar across all major browsers
const tableContainerSx = {
'&::-webkit-scrollbar': {
display: 'none',
},
'-ms-overflow-style': 'none',
scrollbarWidth: 'none',
}

const headerGridSx = {
display: 'grid',
gridTemplateColumns: gridTemplateColumnsSx,
width: 'full',
gap: '4px',
}

const gridSx = {
display: 'grid',
gridTemplateColumns: gridTemplateColumnsSx,
width: '100%',
gap: 4,
alignItems: 'center',
'& td': {
base: {
paddingRight: 4,
paddingLeft: 4,
paddingEnd: 4,
paddingStart: 4,
},
sm: {
paddingRight: '0!important',
paddingLeft: '0!important',
paddingEnd: '0!important',
paddingStart: '0!important',
},
},
}

type MarketsTableVirtualizedProps = {
rows: Asset[]
onRowClick: (arg: Row<Asset>) => void
}

export const MarketsTableVirtualized: React.FC<MarketsTableVirtualizedProps> = memo(
({ rows, onRowClick }) => {
const translate = useTranslate()
const history = useHistory()
const [isLargerThanMd] = useMediaQuery(`(min-width: ${breakpoints['md']})`, { ssr: false })
const marketDataUserCurrencyById = useAppSelector(selectMarketDataUserCurrency)

const parentRef = useRef<HTMLDivElement>(null)

const rowVirtualizer = useVirtualizer({
// Magic number, but realistically, Ttat's already quite the scroll and API spew
// No point to virtualize the whole current ~20k assets list
count: Math.min(rows.length, 3000),
getScrollElement: () => parentRef.current,
estimateSize: () => ROW_HEIGHT,
// Render approximately 1vh (or more if on mobile) of items in advance to avoid blank page flickers when scrolling
overscan: 13,
})

// Only fetch market data for visible rows
// Do *NOT* memoize me, as it relies on stable rowVirtualizer
const visibleAssetIds = rowVirtualizer
.getVirtualItems()
.map(virtualItem => rows[virtualItem.index].assetId)

// Only fetch market data for visible rows
useFetchFiatAssetMarketData(visibleAssetIds)

const handleTradeClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
const assetId = e.currentTarget.getAttribute('data-asset-id')
if (!assetId) return
history.push(`/trade/${assetId}`)
},
[history],
)
const columns = useMemo<ColumnDef<Asset>[]>(
() => [
{
accessorKey: 'assetId',
header: () => <Text translation='dashboard.portfolio.asset' />,
cell: ({ row }) => (
<Box sx={assetCellSx}>
<AssetCell
assetId={row.original.assetId}
subText={truncate(row.original.symbol, { length: 6 })}
/>
</Box>
),
},
...(isLargerThanMd
? [
{
id: 'sparkline',
cell: ({ row }: { row: Row<Asset> }) => (
<Box width='full'>
<SparkLine
assetId={row.original.assetId}
themeColor={row.original.color}
height={35}
/>
</Box>
),
},
]
: []),
{
id: 'price',
header: () => <Text ml='auto' translation='dashboard.portfolio.price' />,
cell: ({ row }) => {
const change = bnOrZero(
marketDataUserCurrencyById[row.original.assetId]?.changePercent24Hr ?? '0',
).times(0.01)
const colorScheme = change.isPositive() ? 'green' : 'red'
const icon = change.isPositive() ? arrowUp : arrowDown
return (
// Already memoized
// eslint-disable-next-line react-memo/require-usememo
<Stack alignItems='flex-end'>
<Amount.Fiat
fontWeight='semibold'
value={marketDataUserCurrencyById[row.original.assetId]?.price ?? '0'}
/>
<Display.Mobile>
<Tag colorScheme={colorScheme} gap={1} size='sm'>
{icon}
<Amount.Percent value={change.abs().toString()} />
</Tag>
</Display.Mobile>
</Stack>
)
},
},
...(isLargerThanMd
? [
{
id: 'change',
header: () => <Text translation='dashboard.portfolio.priceChange' />,
cell: ({ row }: { row: Row<Asset> }) => (
<Amount.Percent
fontWeight='semibold'
value={bnOrZero(
marketDataUserCurrencyById[row.original.assetId]?.changePercent24Hr ?? '0',
)
.times(0.01)
.toString()}
autoColor
/>
),
},
]
: []),
...(isLargerThanMd
? [
{
id: 'volume',
header: () => <Text translation='dashboard.portfolio.volume' />,
cell: ({ row }: { row: Row<Asset> }) => (
<Amount.Fiat
fontWeight='semibold'
value={marketDataUserCurrencyById[row.original.assetId]?.volume ?? '0'}
/>
),
},
]
: []),
...(isLargerThanMd
? [
{
id: 'trade',
cell: ({ row }: { row: Row<Asset> }) => (
<Button data-asset-id={row.original.assetId} onClick={handleTradeClick}>
{translate('assets.assetCards.assetActions.trade')}
</Button>
),
},
]
: []),
],
[handleTradeClick, isLargerThanMd, marketDataUserCurrencyById, translate],
)
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
})

const { rows: tableRows } = table.getRowModel()

const renderRow = useCallback(
(virtualRow: VirtualItem) => {
const row = tableRows[virtualRow.index]
return (
<Tr
as={Button}
key={row.id}
height={`${ROW_HEIGHT}px`}
transform={`translateY(${virtualRow.start}px)`}
position='absolute'
top={0}
left={0}
right={0}
width='100%'
my={2}
variant='ghost'
px={2}
// eslint-disable-next-line react-memo/require-usememo
onClick={() => onRowClick(row)}
sx={gridSx}
>
{row.getVisibleCells().map(cell => {
const textAlign = (() => {
if (cell.column.id === 'assetId') return 'left'
if (cell.column.id === 'price') return 'left'
if (cell.column.id === 'sparkline') return 'center'
return 'right'
})()
return (
<Td key={cell.id} textAlign={textAlign}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</Td>
)
})}
</Tr>
)
},
[onRowClick, tableRows],
)

return (
<Box ref={parentRef} height='100vh' overflow='auto' sx={tableContainerSx}>
<Table variant='unstyled' size={tableSizeSx}>
{isLargerThanMd && (
<Thead position='sticky' top={0} bg='background.surface.base' zIndex={1}>
<Tr sx={headerGridSx}>
{table.getHeaderGroups().map(headerGroup =>
headerGroup.headers.map(header => {
const textAlign = (() => {
if (header.column.id === 'assetId') return 'left'
if (header.column.id === 'sparkline') return 'center'
return 'right'
})()

return (
<Th key={header.id} color='text.subtle' textAlign={textAlign}>
{flexRender(header.column.columnDef.header, header.getContext())}
</Th>
)
}),
)}
</Tr>
</Thead>
)}
<Tbody height={`${rowVirtualizer.getTotalSize()}px`} position='relative'>
{rowVirtualizer.getVirtualItems().map(virtualRow => renderRow(virtualRow))}
</Tbody>
</Table>
</Box>
)
},
)
4 changes: 2 additions & 2 deletions src/components/StakingVaults/Cells.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,15 @@ export const AssetCell = ({
{showPopover && <AssetTeaser assetId={assetId} />}
</Popover>
)}
<HStack flex={1}>
<HStack flex={1} width='100%'>
<SkeletonCircle isLoaded={!!asset} mr={2} width='auto' height='auto'>
{icons && icons.length > 1 ? (
<PairIcons icons={icons} iconSize='sm' bg='none' {...pairProps} />
) : (
<AssetIcon assetId={asset.assetId} size='md' pairProps={pairProps} />
)}
</SkeletonCircle>
<SkeletonText noOfLines={2} isLoaded={!!asset} flex={1}>
<SkeletonText noOfLines={2} isLoaded={!!asset} flex={1} width='50%'>
<Stack spacing={0} flex={1} alignItems='flex-start' width='full'>
<HStack alignItems='center' width='full'>
<Box
Expand Down
6 changes: 3 additions & 3 deletions src/pages/Assets/Assets.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { Flex } from '@chakra-ui/react'
import type { Asset } from '@shapeshiftoss/types'
import type { Row } from '@tanstack/react-table'
import { matchSorter } from 'match-sorter'
import { useCallback, useMemo, useState } from 'react'
import { useTranslate } from 'react-polyglot'
import { useHistory } from 'react-router'
import type { Row } from 'react-table'
import { Display } from 'components/Display'
import { PageBackButton, PageHeader } from 'components/Layout/Header/PageHeader'
import { Main } from 'components/Layout/Main'
import { SEO } from 'components/Layout/Seo'
import { MarketsTable } from 'components/MarketsTable'
import { MarketsTableVirtualized } from 'components/MarketsTableVirtualized'
import { GlobalFilter } from 'components/StakingVaults/GlobalFilter'
import { RawText } from 'components/Text'
import { selectAssetsSortedByMarketCap } from 'state/slices/selectors'
Expand Down Expand Up @@ -62,7 +62,7 @@ export const Assets = () => {
</Flex>
</PageHeader>
</Display.Mobile>
<MarketsTable rows={rows} onRowClick={handleRowClick} />
<MarketsTableVirtualized rows={rows} onRowClick={handleRowClick} />
</Main>
)
}
Loading