Commit 5325b5f8 authored by Zach Pomerantz's avatar Zach Pomerantz Committed by GitHub

fix: nft waterfalls requests (#5168)

* fix: request all sweep data in parallel

* fix: trigger collection query from a wrapping screen

* load sweep for correct markets

* add preload logic for assets query

* add load query to explore table

* fix: cleanup AssetFetcherParams

* fix: preload trending collections

* fix: graphql array argument

* fix: actually use preloaded asset query

* fix: use network and suspense to actually parallelize
Co-authored-by: default avatarCharlie <charles@bachmeier.io>
parent 27936cf3
......@@ -3,8 +3,8 @@ import { parseEther } from 'ethers/lib/utils'
import useInterval from 'lib/hooks/useInterval'
import ms from 'ms.macro'
import { GenieAsset, Trait } from 'nft/types'
import { useCallback, useMemo, useState } from 'react'
import { fetchQuery, useLazyLoadQuery, usePaginationFragment, useRelayEnvironment } from 'react-relay'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { fetchQuery, useLazyLoadQuery, usePaginationFragment, useQueryLoader, useRelayEnvironment } from 'react-relay'
import { AssetPaginationQuery } from './__generated__/AssetPaginationQuery.graphql'
import {
......@@ -178,22 +178,42 @@ function formatAssetQueryData(queryAsset: NftAssetsQueryAsset, totalCount?: numb
}
}
export function useAssetsQuery(
address: string,
orderBy: NftAssetSortableField,
asc: boolean,
filter: NftAssetsFilterInput,
first?: number,
after?: string,
last?: number,
export const ASSET_PAGE_SIZE = 25
export interface AssetFetcherParams {
address: string
orderBy: NftAssetSortableField
asc: boolean
filter: NftAssetsFilterInput
first?: number
after?: string
last?: number
before?: string
) {
const vars = useMemo(
() => ({ address, orderBy, asc, filter, first, after, last, before }),
[address, after, asc, before, filter, first, last, orderBy]
)
const [queryOptions, setQueryOptions] = useState({ fetchKey: 0 })
const queryData = useLazyLoadQuery<AssetQuery>(assetQuery, vars, queryOptions)
}
const defaultAssetFetcherParams: Omit<AssetQuery$variables, 'address'> = {
orderBy: 'PRICE',
asc: true,
// tokenSearchQuery must be specified so that this exactly matches the initial query.
filter: { listed: false, tokenSearchQuery: '' },
first: ASSET_PAGE_SIZE,
}
export function useLoadAssetsQuery(address?: string) {
const [, loadQuery] = useQueryLoader<AssetQuery>(assetQuery)
useEffect(() => {
if (address) {
loadQuery({ ...defaultAssetFetcherParams, address })
}
}, [address, loadQuery])
}
export function useLazyLoadAssetsQuery(params: AssetFetcherParams) {
const vars = useMemo(() => ({ ...defaultAssetFetcherParams, ...params }), [params])
const [fetchKey, setFetchKey] = useState(0)
// Use the store if it is available (eg from polling), or the network if it is not (eg from an incorrect preload).
const fetchPolicy = 'store-or-network'
const queryData = useLazyLoadQuery<AssetQuery>(assetQuery, vars, { fetchKey, fetchPolicy }) // this will suspend if not yet loaded
const { data, hasNext, loadNext, isLoadingNext } = usePaginationFragment<AssetPaginationQuery, any>(
assetPaginationQuery,
......@@ -208,14 +228,11 @@ export function useAssetsQuery(
// Initiate a network request. When it resolves, refresh the UI from store (to avoid re-triggering Suspense);
// see: https://relay.dev/docs/guided-tour/refetching/refreshing-queries/#if-you-need-to-avoid-suspense-1.
await fetchQuery<AssetQuery>(environment, assetQuery, { ...vars, first: length }).toPromise()
setQueryOptions(({ fetchKey }) => ({
fetchKey: fetchKey + 1,
fetchPolicy: 'store-only',
}))
setFetchKey((fetchKey) => fetchKey + 1)
}, [data.nftAssets?.edges?.length, environment, vars])
// NB: This will poll every POLLING_INTERVAL, *not* every POLLING_INTERVAL from the last successful poll.
// TODO(WEB-2004): Update useInterval to wait for the fn to complete before rescheduling.
useInterval(refresh, POLLING_INTERVAL)
useInterval(refresh, POLLING_INTERVAL, /* leading= */ false)
// It is especially important for this to be memoized to avoid re-rendering from polling if data is unchanged.
const assets: GenieAsset[] = useMemo(
......@@ -231,19 +248,16 @@ export function useAssetsQuery(
const DEFAULT_SWEEP_AMOUNT = 50
export function useSweepAssetsQuery({
contractAddress,
markets,
price,
traits,
}: {
export interface SweepFetcherParams {
contractAddress: string
markets?: string[]
price?: { high?: number | string; low?: number | string; symbol: string }
traits?: Trait[]
}): GenieAsset[] {
const filter: NftAssetsFilterInput = useMemo(() => {
return {
}
function useSweepFetcherVars({ contractAddress, markets, price, traits }: SweepFetcherParams): AssetQuery$variables {
const filter: NftAssetsFilterInput = useMemo(
() => ({
listed: true,
maxPrice: price?.high?.toString(),
minPrice: price?.low?.toString(),
......@@ -255,27 +269,43 @@ export function useSweepAssetsQuery({
: undefined,
marketplaces:
markets && markets.length > 0 ? markets?.map((market) => market.toUpperCase() as NftMarketplace) : undefined,
}
}, [price, traits, markets])
const vars: AssetQuery$variables = useMemo(() => {
return {
}),
[markets, price?.high, price?.low, traits]
)
return useMemo(
() => ({
address: contractAddress,
orderBy: 'PRICE',
asc: true,
first: DEFAULT_SWEEP_AMOUNT,
filter,
}),
[contractAddress, filter]
)
}
export function useLoadSweepAssetsQuery(params: SweepFetcherParams, enabled = true) {
const [, loadQuery] = useQueryLoader<AssetQuery>(assetQuery)
const vars = useSweepFetcherVars(params)
useEffect(() => {
if (enabled) {
loadQuery(vars)
}
}, [contractAddress, filter])
}, [loadQuery, enabled, vars])
}
const queryData = useLazyLoadQuery<AssetQuery>(assetQuery, vars)
// Lazy-loads an already loaded AssetsQuery.
// This will *not* trigger a query - that must be done from a parent component to ensure proper query coalescing and to
// prevent waterfalling. Use useLoadSweepAssetsQuery to trigger the query.
export function useLazyLoadSweepAssetsQuery(params: SweepFetcherParams): GenieAsset[] {
const vars = useSweepFetcherVars(params)
const queryData = useLazyLoadQuery(assetQuery, vars, { fetchPolicy: 'store-only' }) // this will suspend if not yet loaded
const { data } = usePaginationFragment<AssetPaginationQuery, any>(assetPaginationQuery, queryData)
const assets: GenieAsset[] = useMemo(
return useMemo<GenieAsset[]>(
() =>
data.nftAssets?.edges?.map((queryAsset: NftAssetsQueryAsset) => {
return formatAssetQueryData(queryAsset, data.nftAssets?.totalCount)
}),
[data.nftAssets?.edges, data.nftAssets?.totalCount]
)
return assets
}
import graphql from 'babel-plugin-relay/macro'
import { GenieCollection, Trait } from 'nft/types'
import { useLazyLoadQuery } from 'react-relay'
import { useEffect } from 'react'
import { useLazyLoadQuery, useQueryLoader } from 'react-relay'
import { CollectionQuery } from './__generated__/CollectionQuery.graphql'
const collectionQuery = graphql`
query CollectionQuery($address: String!) {
nftCollections(filter: { addresses: [$address] }) {
query CollectionQuery($addresses: [String!]!) {
nftCollections(filter: { addresses: $addresses }) {
edges {
cursor
node {
......@@ -86,8 +87,24 @@ const collectionQuery = graphql`
}
`
export function useLoadCollectionQuery(address?: string | string[]): void {
const [, loadQuery] = useQueryLoader(collectionQuery)
useEffect(() => {
if (address) {
loadQuery({ addresses: Array.isArray(address) ? address : [address] })
}
}, [address, loadQuery])
}
// Lazy-loads an already loaded CollectionQuery.
// This will *not* trigger a query - that must be done from a parent component to ensure proper query coalescing and to
// prevent waterfalling. Use useLoadCollectionQuery to trigger the query.
export function useCollectionQuery(address: string): GenieCollection | undefined {
const queryData = useLazyLoadQuery<CollectionQuery>(collectionQuery, { address })
const queryData = useLazyLoadQuery<CollectionQuery>( // this will suspend if not yet loaded
collectionQuery,
{ addresses: [address] },
{ fetchPolicy: 'store-or-network' }
)
const queryCollection = queryData.nftCollections?.edges[0]?.node
const market = queryCollection?.markets && queryCollection?.markets[0]
......
......@@ -5,7 +5,12 @@ import clsx from 'clsx'
import { loadingAnimation } from 'components/Loader/styled'
import { parseEther } from 'ethers/lib/utils'
import { NftAssetTraitInput, NftMarketplace } from 'graphql/data/nft/__generated__/AssetQuery.graphql'
import { useAssetsQuery } from 'graphql/data/nft/Asset'
import {
ASSET_PAGE_SIZE,
AssetFetcherParams,
useLazyLoadAssetsQuery,
useLoadSweepAssetsQuery,
} from 'graphql/data/nft/Asset'
import useDebounce from 'hooks/useDebounce'
import { AnimatedBox, Box } from 'nft/components/Box'
import { CollectionSearch, FilterButton } from 'nft/components/collection'
......@@ -29,7 +34,7 @@ import {
} from 'nft/hooks'
import { useIsCollectionLoading } from 'nft/hooks/useIsCollectionLoading'
import { usePriceRange } from 'nft/hooks/usePriceRange'
import { DropDownOption, GenieCollection, TokenType, UniformHeight, UniformHeights } from 'nft/types'
import { DropDownOption, GenieCollection, Markets, TokenType, UniformHeight, UniformHeights } from 'nft/types'
import { getRarityStatus } from 'nft/utils/asset'
import { pluralize } from 'nft/utils/roundAndPluralize'
import { scrollToTop } from 'nft/utils/scrollToTop'
......@@ -42,7 +47,7 @@ import { ThemedText } from 'theme'
import { CollectionAssetLoading } from './CollectionAssetLoading'
import { MARKETPLACE_ITEMS } from './MarketplaceSelect'
import { Sweep } from './Sweep'
import { Sweep, useSweepFetcherParams } from './Sweep'
import { TraitChip } from './TraitChip'
interface CollectionNftsProps {
......@@ -163,11 +168,9 @@ export const LoadingButton = styled.div`
background-size: 400%;
`
export const DEFAULT_ASSET_QUERY_AMOUNT = 25
const loadingAssets = (
<>
{Array.from(Array(DEFAULT_ASSET_QUERY_AMOUNT), (_, index) => (
{Array.from(Array(ASSET_PAGE_SIZE), (_, index) => (
<CollectionAssetLoading key={index} />
))}
</>
......@@ -229,17 +232,19 @@ export const CollectionNfts = ({ contractAddress, collectionStats, rarityVerifie
const debouncedSearchByNameText = useDebounce(searchByNameText, 500)
const [sweepIsOpen, setSweepOpen] = useState(false)
const {
assets: collectionNfts,
loadNext,
hasNext,
isLoadingNext,
} = useAssetsQuery(
contractAddress,
SortByQueries[sortBy].field,
SortByQueries[sortBy].asc,
{
// Load all sweep queries. Loading them on the parent allows lazy-loading, but avoids waterfalling requests.
const collectionParams = useSweepFetcherParams(contractAddress, 'others', debouncedMinPrice, debouncedMaxPrice)
const nftxParams = useSweepFetcherParams(contractAddress, Markets.NFTX, debouncedMinPrice, debouncedMaxPrice)
const nft20Params = useSweepFetcherParams(contractAddress, Markets.NFT20, debouncedMinPrice, debouncedMaxPrice)
useLoadSweepAssetsQuery(collectionParams, sweepIsOpen)
useLoadSweepAssetsQuery(nftxParams, sweepIsOpen)
useLoadSweepAssetsQuery(nft20Params, sweepIsOpen)
const assetQueryParams: AssetFetcherParams = {
address: contractAddress,
orderBy: SortByQueries[sortBy].field,
asc: SortByQueries[sortBy].asc,
filter: {
listed: buyNow,
marketplaces: markets.length > 0 ? markets.map((market) => market.toUpperCase() as NftMarketplace) : undefined,
maxPrice: debouncedMaxPrice ? parseEther(debouncedMaxPrice).toString() : undefined,
......@@ -252,8 +257,10 @@ export const CollectionNfts = ({ contractAddress, collectionStats, rarityVerifie
})
: undefined,
},
DEFAULT_ASSET_QUERY_AMOUNT
)
first: ASSET_PAGE_SIZE,
}
const { assets: collectionNfts, loadNext, hasNext, isLoadingNext } = useLazyLoadAssetsQuery(assetQueryParams)
const [uniformHeight, setUniformHeight] = useState<UniformHeight>(UniformHeights.unset)
const [currentTokenPlayingMedia, setCurrentTokenPlayingMedia] = useState<string | undefined>()
......@@ -447,12 +454,9 @@ export const CollectionNfts = ({ contractAddress, collectionStats, rarityVerifie
</SweepButton>
) : null}
</ActionsContainer>
<Sweep
contractAddress={contractAddress}
minPrice={debouncedMinPrice}
maxPrice={debouncedMaxPrice}
showSweep={sweepIsOpen && buyNow && !hasErc1155s}
/>
{sweepIsOpen && (
<Sweep contractAddress={contractAddress} minPrice={debouncedMinPrice} maxPrice={debouncedMaxPrice} />
)}
<Row
paddingTop={!!markets.length || !!traits.length || minMaxPriceChipText ? '12' : '0'}
gap="8"
......@@ -508,7 +512,7 @@ export const CollectionNfts = ({ contractAddress, collectionStats, rarityVerifie
</Box>
</AnimatedBox>
<InfiniteScroll
next={() => loadNext(DEFAULT_ASSET_QUERY_AMOUNT)}
next={() => loadNext(ASSET_PAGE_SIZE)}
hasMore={hasNext}
loader={hasNext && hasNfts ? loadingAssets : null}
dataLength={collectionNfts?.length ?? 0}
......
......@@ -2,7 +2,7 @@ import 'rc-slider/assets/index.css'
import { BigNumber } from '@ethersproject/bignumber'
import { formatEther, parseEther } from '@ethersproject/units'
import { useSweepAssetsQuery } from 'graphql/data/nft/Asset'
import { SweepFetcherParams, useLazyLoadSweepAssetsQuery } from 'graphql/data/nft/Asset'
import { useBag, useCollectionFilters } from 'nft/hooks'
import { GenieAsset, Markets } from 'nft/types'
import { calcPoolPrice, formatWeiToDecimal } from 'nft/utils'
......@@ -11,8 +11,8 @@ import { useEffect, useMemo, useReducer, useState } from 'react'
import styled, { useTheme } from 'styled-components/macro'
import { ThemedText } from 'theme'
const SweepContainer = styled.div<{ showSweep: boolean }>`
display: ${({ showSweep }) => (showSweep ? 'flex' : 'none')};
const SweepContainer = styled.div`
display: flex;
gap: 60px;
margin-top: 20px;
padding: 16px;
......@@ -152,10 +152,9 @@ interface SweepProps {
contractAddress: string
minPrice: string
maxPrice: string
showSweep: boolean
}
export const Sweep = ({ contractAddress, minPrice, maxPrice, showSweep }: SweepProps) => {
export const Sweep = ({ contractAddress, minPrice, maxPrice }: SweepProps) => {
const theme = useTheme()
const [isItemsToggled, toggleSweep] = useReducer((state) => !state, true)
......@@ -169,58 +168,22 @@ export const Sweep = ({ contractAddress, minPrice, maxPrice, showSweep }: SweepP
const traits = useCollectionFilters((state) => state.traits)
const markets = useCollectionFilters((state) => state.markets)
const getSweepFetcherParams = (market: Markets.NFTX | Markets.NFT20 | 'others') => {
const isMarketFiltered = !!markets.length
const allOtherMarkets = [Markets.Opensea, Markets.X2Y2, Markets.LooksRare]
if (isMarketFiltered) {
if (market === 'others') {
return { contractAddress, traits, markets }
}
if (!markets.includes(market)) return { contractAddress: '', traits: [], markets: [] }
}
switch (market) {
case Markets.NFTX:
case Markets.NFT20:
return {
contractAddress,
traits,
markets: [market],
price: {
low: minPrice,
high: maxPrice,
symbol: 'ETH',
},
}
case 'others':
return {
contractAddress,
traits,
markets: allOtherMarkets,
price: {
low: minPrice,
high: maxPrice,
symbol: 'ETH',
},
}
}
}
const collectionAssets = useSweepAssetsQuery(getSweepFetcherParams('others'))
const nftxCollectionAssets = useSweepAssetsQuery(getSweepFetcherParams(Markets.NFTX))
const nft20CollectionAssets = useSweepAssetsQuery(getSweepFetcherParams(Markets.NFT20))
const collectionParams = useSweepFetcherParams(contractAddress, 'others', minPrice, maxPrice)
const nftxParams = useSweepFetcherParams(contractAddress, Markets.NFTX, minPrice, maxPrice)
const nft20Params = useSweepFetcherParams(contractAddress, Markets.NFT20, minPrice, maxPrice)
// These calls will suspend if the query is not yet loaded.
const collectionAssets = useLazyLoadSweepAssetsQuery(collectionParams)
const nftxAssets = useLazyLoadSweepAssetsQuery(nftxParams)
const nft20Assets = useLazyLoadSweepAssetsQuery(nft20Params)
const { sortedAssets, sortedAssetsTotalEth } = useMemo(() => {
if (!collectionAssets || !nftxCollectionAssets || !nft20CollectionAssets)
if (!collectionAssets || !nftxAssets || !nft20Assets)
return { sortedAssets: undefined, sortedAssetsTotalEth: BigNumber.from(0) }
let counterNFTX = 0
let counterNFT20 = 0
let jointCollections = [...nftxCollectionAssets, ...nft20CollectionAssets]
let jointCollections = [...nftxAssets, ...nft20Assets]
jointCollections.forEach((asset) => {
if (!asset.susFlag) {
......@@ -243,10 +206,7 @@ export const Sweep = ({ contractAddress, minPrice, maxPrice, showSweep }: SweepP
(asset) => BigNumber.from(asset.priceInfo.ETHPrice).gte(0) && !asset.susFlag
)
validAssets = validAssets.slice(
0,
Math.max(collectionAssets.length, nftxCollectionAssets.length, nft20CollectionAssets.length)
)
validAssets = validAssets.slice(0, Math.max(collectionAssets.length, nftxAssets.length, nft20Assets.length))
return {
sortedAssets: validAssets,
......@@ -255,7 +215,7 @@ export const Sweep = ({ contractAddress, minPrice, maxPrice, showSweep }: SweepP
BigNumber.from(0)
),
}
}, [collectionAssets, nftxCollectionAssets, nft20CollectionAssets])
}, [collectionAssets, nftxAssets, nft20Assets])
const { sweepItemsInBag, sweepEthPrice } = useMemo(() => {
const sweepItemsInBag = itemsInBag
......@@ -366,7 +326,7 @@ export const Sweep = ({ contractAddress, minPrice, maxPrice, showSweep }: SweepP
}
return (
<SweepContainer showSweep={showSweep}>
<SweepContainer>
<SweepLeftmostContainer>
<SweepHeaderContainer>
<ThemedText.SubHeaderSmall color="textPrimary" lineHeight="20px" paddingTop="6px" paddingBottom="6px">
......@@ -434,3 +394,54 @@ export const Sweep = ({ contractAddress, minPrice, maxPrice, showSweep }: SweepP
</SweepContainer>
)
}
const ALL_OTHER_MARKETS = [Markets.Opensea, Markets.X2Y2, Markets.LooksRare]
export function useSweepFetcherParams(
contractAddress: string,
market: Markets.NFTX | Markets.NFT20 | 'others',
minPrice: string,
maxPrice: string
): SweepFetcherParams {
const traits = useCollectionFilters((state) => state.traits)
const markets = useCollectionFilters((state) => state.markets)
const isMarketFiltered = !!markets.length
return useMemo(() => {
if (isMarketFiltered) {
if (market === 'others') {
return { contractAddress, traits, markets }
}
if (!markets.includes(market)) return { contractAddress: '', traits: [], markets: [] }
}
switch (market) {
case Markets.NFTX:
case Markets.NFT20:
return {
contractAddress,
traits,
markets: [market],
price: {
low: minPrice,
high: maxPrice,
symbol: 'ETH',
},
}
case 'others':
return {
contractAddress,
traits,
markets: ALL_OTHER_MARKETS,
price: {
low: minPrice,
high: maxPrice,
symbol: 'ETH',
},
}
}
}, [contractAddress, isMarketFiltered, market, markets, maxPrice, minPrice, traits])
}
import { useLoadCollectionQuery } from 'graphql/data/nft/Collection'
import { useIsMobile } from 'nft/hooks'
import { fetchTrendingCollections } from 'nft/queries'
import { TimePeriod } from 'nft/types'
import { Suspense } from 'react'
import { Suspense, useMemo } from 'react'
import { useQuery } from 'react-query'
import { useNavigate } from 'react-router-dom'
import styled from 'styled-components/macro'
......@@ -80,6 +81,10 @@ const Banner = () => {
}
)
// Trigger queries for the top trending collections, so that the data is immediately available if the user clicks through.
const collectionAddresses = useMemo(() => collections?.map(({ address }) => address), [collections])
useLoadCollectionQuery(collectionAddresses)
return (
<BannerContainer>
<HeaderContainer>
......
import { Trace } from '@uniswap/analytics'
import { PageName } from '@uniswap/analytics-events'
import { useWeb3React } from '@web3-react/core'
import { useCollectionQuery } from 'graphql/data/nft/Collection'
import { useLoadAssetsQuery } from 'graphql/data/nft/Asset'
import { useCollectionQuery, useLoadCollectionQuery } from 'graphql/data/nft/Collection'
import { MobileHoverBag } from 'nft/components/bag/MobileHoverBag'
import { AnimatedBox, Box } from 'nft/components/Box'
import { Activity, ActivitySwitcher, CollectionNfts, CollectionStats, Filters } from 'nft/components/collection'
import { CollectionNftsAndMenuLoading } from 'nft/components/collection/CollectionNfts'
import { CollectionPageSkeleton } from 'nft/components/collection/CollectionPageSkeleton'
import { Column, Row } from 'nft/components/Flex'
import { useBag, useCollectionFilters, useFiltersExpanded, useIsMobile } from 'nft/hooks'
import * as styles from 'nft/pages/collection/index.css'
......@@ -32,7 +34,6 @@ const CollectionDisplaySection = styled(Row)`
const Collection = () => {
const { contractAddress } = useParams()
const isMobile = useIsMobile()
const [isFiltersExpanded, setFiltersExpanded] = useFiltersExpanded()
const { pathname } = useLocation()
......@@ -154,4 +155,21 @@ const Collection = () => {
)
}
export default Collection
// The page is responsible for any queries that must be run on initial load.
// Triggering query load from the page prevents waterfalled requests, as lazy-loading them in components would prevent
// any children from rendering.
const CollectionPage = () => {
const { contractAddress } = useParams()
useLoadCollectionQuery(contractAddress)
useLoadAssetsQuery(contractAddress)
// The Collection must be wrapped in suspense so that it does not suspend the CollectionPage,
// which is needed to trigger query loads.
return (
<Suspense fallback={<CollectionPageSkeleton />}>
<Collection />
</Suspense>
)
}
export default CollectionPage
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