Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/smooth-web-lineup-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@audius/web': patch
---

Smooth out lineup infinite-scroll on Trending, Feed, and other tanquery-driven track lists. The scroll-to-bottom "chunk" had three causes stacked: a small fixed 500px threshold, skeletons that gated on tanquery's `isFetching` (so they only painted after a multi-tick state round-trip), and a per-page skeleton count of just `pageSize` (4 on Trending, ~480px tall on desktop) that didn't fill the loading window. The threshold is now ~2× the scroll parent's viewport, a synchronous trigger flag renders skeletons on the next frame, and the skeleton count is sized to fill the threshold area so the bottom stays populated even when the user scrolls in faster than the next page can return.
102 changes: 91 additions & 11 deletions packages/web/src/components/lineup/TrackLineup.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

import { ID, PlaybackSource, Name } from '@audius/common/models'
import { playbackActions, playbackSelectors } from '@audius/common/store'
Expand All @@ -19,7 +19,23 @@ import styles from './Lineup.module.css'
import { LineupVariant } from './types'

const NARROW_CONTAINER_THRESHOLD_PX = 600
const DEFAULT_LOAD_MORE_THRESHOLD = 500
// Fallback used until the scroll parent has been measured. Sized so the next
// page request fires well before the user reaches the literal bottom of the
// list. Effective threshold is `LOAD_MORE_VIEWPORTS * scrollParent.clientHeight`
// once measured.
const DEFAULT_LOAD_MORE_THRESHOLD = 1600
// Number of viewports of "remaining content" that should trigger loading the
// next page. Larger values give a bigger buffer for fast desktop scrolling so
// skeletons paint comfortably before the user reaches the bottom. Matches the
// effect of mobile's `onEndReachedThreshold` but on the larger desktop viewport
// we need more headroom to keep up with fling scrolls.
const LOAD_MORE_VIEWPORTS = 2
// Approximate rendered heights of a TrackTile in different variants — used to
// compute how many skeletons to render so the bottom-of-list "loading window"
// fills the threshold area instead of leaving the user staring at a frozen
// last entry while the next page is in flight.
const APPROX_TILE_HEIGHT_LARGE = 124
const APPROX_TILE_HEIGHT_SMALL = 80

const { getPlaying: getPlayerPlaying } = playbackSelectors
const { makeGetCurrent } = playbackSelectors
Expand Down Expand Up @@ -221,8 +237,52 @@ export const TrackLineup = ({
return trackIds.slice(0, end)
}, [trackIds, maxEntries])

// Track the scroll parent's viewport height so the load-more threshold is a
// multiple of one full viewport. Falls back to the constant until measured.
const [scrollParentHeight, setScrollParentHeight] = useState<number | null>(
null
)
useEffect(() => {
const parent =
externalScrollParent ?? document.getElementById('mainContent')
if (!parent) return
const update = () => setScrollParentHeight(parent.clientHeight || null)
update()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(update)
observer.observe(parent)
return () => observer.disconnect()
}, [externalScrollParent])

const effectiveLoadMoreThreshold = scrollParentHeight
? scrollParentHeight * LOAD_MORE_VIEWPORTS
: loadMoreThreshold

// Synchronous "load more was triggered" flag — set the moment the scroll
// handler fires so skeletons render on the next frame, without waiting for
// tanquery's `isFetching` to round-trip back through the parent. Cleared
// once the parent either delivers more entries or finishes fetching.
const [isLoadMoreTriggered, setIsLoadMoreTriggered] = useState(false)
const prevEntriesLengthRef = useRef(visibleTrackIds.length)
useEffect(() => {
if (visibleTrackIds.length !== prevEntriesLengthRef.current) {
prevEntriesLengthRef.current = visibleTrackIds.length
setIsLoadMoreTriggered(false)
}
}, [visibleTrackIds.length])
useEffect(() => {
if (!isFetching) setIsLoadMoreTriggered(false)
}, [isFetching])

const handleLoadMore = useCallback(() => {
if (!hasNextPage || isFetching || isLoadMoreTriggered) return
if (!loadNextPage) return
setIsLoadMoreTriggered(true)
loadNextPage()
}, [hasNextPage, isFetching, isLoadMoreTriggered, loadNextPage])

const renderSkeletons = useCallback(
(skeletonCount: number | undefined) => {
(skeletonCount: number | undefined, indexOffset = 0) => {
if (!skeletonCount) return null
return (
<>
Expand All @@ -232,7 +292,12 @@ export const TrackLineup = ({
<Flex
direction='column'
gap='m'
key={`skeleton-${index}`}
// Position-based key so a skeleton's React identity matches
// its absolute position in the lineup. Without this, the same
// DOM node would change its rendered order number every time
// a page resolves (skeleton-0 reused but now showing tileOrder
// for a higher index), which reads as the numbers jumping.
key={`skeleton-${indexOffset + index}`}
w='100%'
as='li'
className={cn({ [tileStyles!]: !!tileStyles })}
Expand All @@ -241,7 +306,7 @@ export const TrackLineup = ({
<Flex direction={isSmallTrackTile ? 'row' : 'column'} w='100%'>
{/* @ts-ignore - TrackTile types don't fully cover loading state */}
<TrackTile
index={index}
index={indexOffset + index}
size={tileSize}
ordered={ordered}
isLoading
Expand Down Expand Up @@ -291,7 +356,22 @@ export const TrackLineup = ({
])

const isInitialLoad = isPending && tiles.length === 0
const isEmpty = tiles.length === 0 && !isFetching && !isInitialLoad
const isEmpty =
tiles.length === 0 && !isFetching && !isInitialLoad && !isLoadMoreTriggered

// While a page is in flight we render skeletons below the loaded tiles. They
// need to fill ~one threshold's worth of vertical space so the bottom of the
// list feels populated even when the user scrolls into the trigger area
// faster than the network can return. `pageSize` is too small on its own
// (e.g. trending uses 4) so we floor by a viewport-derived count.
const approxTileHeight = isSmallTrackTile
? APPROX_TILE_HEIGHT_SMALL
: APPROX_TILE_HEIGHT_LARGE
const fillCount = Math.ceil(effectiveLoadMoreThreshold / approxTileHeight)
const loadingSkeletonCount = Math.min(
Math.max(0, maxEntries - tiles.length),
Math.max(pageSize, fillCount)
)

return (
<div
Expand All @@ -309,19 +389,19 @@ export const TrackLineup = ({
<InfiniteScroll
aria-label={ariaLabel}
pageStart={0}
loadMore={loadNextPage ?? (() => {})}
loadMore={handleLoadMore}
hasMore={!!hasNextPage && tiles.length < maxEntries}
useWindow={isMobile}
initialLoad={false}
getScrollParent={getScrollParent}
element='ol'
threshold={loadMoreThreshold}
threshold={effectiveLoadMoreThreshold}
className={cn({
[tileContainerStyles!]: !!tileContainerStyles && !isEmpty
})}
>
{tiles.length === 0
? isFetching || isInitialLoad
? isFetching || isInitialLoad || isLoadMoreTriggered
? renderSkeletons(
Math.min(maxEntries, initialPageSize ?? pageSize)
)
Expand Down Expand Up @@ -360,8 +440,8 @@ export const TrackLineup = ({
</Flex>
))}

{isFetching && tiles.length > 0
? renderSkeletons(Math.min(maxEntries - tiles.length, pageSize))
{hasNextPage && tiles.length > 0
? renderSkeletons(loadingSkeletonCount, tiles.length)
: null}
</InfiniteScroll>
</div>
Expand Down
6 changes: 2 additions & 4 deletions packages/web/src/components/track/desktop/TrackTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -336,11 +336,9 @@ export const TrackTile = ({
{/* prefix ordering */}
{tileOrder && (
<Flex column gap='2xs' alignItems='center' justifyContent='center'>
{!isLoading && tileOrder <= 10 && (
<IconCrown color='default' size='s' />
)}
{tileOrder <= 10 && <IconCrown color='default' size='s' />}
<Text variant='label' color='default'>
{!isLoading && tileOrder}
{tileOrder}
</Text>
</Flex>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ import { useMemo, useRef } from 'react'
import {
getFeedQueryKey,
FEED_INITIAL_PAGE_SIZE,
FEED_LOAD_MORE_PAGE_SIZE,
useCurrentUserId,
useFeed,
useForYouFeed,
FOR_YOU_INITIAL_PAGE_SIZE,
FOR_YOU_LOAD_MORE_PAGE_SIZE
FOR_YOU_INITIAL_PAGE_SIZE
} from '@audius/common/api'
import { Name, FeedFilter, FeedTab } from '@audius/common/models'
import {
Expand Down Expand Up @@ -56,6 +54,11 @@ const FeedPageContent = ({ containerRef }: FeedPageContentProps) => {
const feedTab = useSelector(getFeedTab)
const { data: currentUserId } = useCurrentUserId()

// Desktop viewports + fast trackpad / wheel scroll need bigger pages than
// the shared default (mobile-tuned) so successive load-mores keep up with a
// user scrolling deep into the lineup.
const desktopLoadMorePageSize = 10

const isForYou = feedTab === FeedTab.FOR_YOU
const followingFilter = isForYou
? FeedFilter.ALL
Expand All @@ -67,7 +70,7 @@ const FeedPageContent = ({ containerRef }: FeedPageContentProps) => {
userId: currentUserId,
filter: followingFilter,
initialPageSize: FEED_INITIAL_PAGE_SIZE,
loadMorePageSize: FEED_LOAD_MORE_PAGE_SIZE
loadMorePageSize: desktopLoadMorePageSize
}),
[followingFilter, currentUserId]
)
Expand All @@ -77,7 +80,7 @@ const FeedPageContent = ({ containerRef }: FeedPageContentProps) => {
const forYouFeed = useForYouFeed(
{
initialPageSize: FOR_YOU_INITIAL_PAGE_SIZE,
loadMorePageSize: FOR_YOU_LOAD_MORE_PAGE_SIZE
loadMorePageSize: desktopLoadMorePageSize
},
{ enabled: isForYou }
)
Expand Down Expand Up @@ -115,7 +118,7 @@ const FeedPageContent = ({ containerRef }: FeedPageContentProps) => {
isError: forYouFeed.isError,
hasNextPage: forYouFeed.hasNextPage,
loadNextPage: forYouFeed.loadNextPage,
pageSize: FOR_YOU_LOAD_MORE_PAGE_SIZE,
pageSize: desktopLoadMorePageSize,
initialPageSize: FOR_YOU_INITIAL_PAGE_SIZE,
querySource: undefined
}
Expand All @@ -126,7 +129,7 @@ const FeedPageContent = ({ containerRef }: FeedPageContentProps) => {
isError: followFeed.isError,
hasNextPage: followFeed.hasNextPage,
loadNextPage: followFeed.loadNextPage,
pageSize: FEED_LOAD_MORE_PAGE_SIZE,
pageSize: desktopLoadMorePageSize,
initialPageSize: FEED_INITIAL_PAGE_SIZE,
querySource: followQuerySource
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
getTrendingQueryKey,
getTrendingUndergroundQueryKey,
TRENDING_INITIAL_PAGE_SIZE,
TRENDING_LOAD_MORE_PAGE_SIZE,
useTrending,
useTrendingUnderground
} from '@audius/common/api'
Expand Down Expand Up @@ -91,10 +90,22 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => {
const bottomBarRef = useRef<HTMLDivElement>(null)
const isCondensedBar = useIsContainerNarrow(bottomBarRef, 640)

const [category, setCategory] = useState<TrendingCategory>(() => {
const [category, setCategoryState] = useState<TrendingCategory>(() => {
const { week } = parseUrlParams()
return isValidWinnersWeek(week) ? 'winners' : 'tracks'
})
const setCategory = useCallback(
(next: TrendingCategory) => {
// Switching category swaps the lineup underneath the user, but the
// outer scroll parent (mainContent) keeps its position. Reset to top
// so the new category starts from the top instead of mid-scroll.
if (containerRef?.current?.scrollTo) {
containerRef.current.scrollTo(0, 0)
}
setCategoryState(next)
},
[containerRef]
)
const [winnersWeek, setWinnersWeek] = useState<string | null>(() => {
const { week } = parseUrlParams()
return isValidWinnersWeek(week) ? week : null
Expand All @@ -106,11 +117,16 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => {
const trendingGenre = useSelector(getTrendingGenre)
const trendingTimeRange = useSelector(getTrendingTimeRange)

// Desktop viewports + fast trackpad / wheel scroll need bigger pages than
// the shared default (mobile-tuned) so successive load-mores keep up with a
// user scrolling deep into the lineup.
const desktopLoadMorePageSize = 10

// ----- Three tanquery streams, one per time range -----
const trendingArgs = useMemo(
() => ({
initialPageSize: TRENDING_INITIAL_PAGE_SIZE,
loadMorePageSize: TRENDING_LOAD_MORE_PAGE_SIZE,
loadMorePageSize: desktopLoadMorePageSize,
genre: trendingGenre
}),
[trendingGenre]
Expand Down Expand Up @@ -401,7 +417,7 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => {
isError={q.isError}
hasNextPage={q.hasNextPage}
loadNextPage={q.loadNextPage}
pageSize={TRENDING_LOAD_MORE_PAGE_SIZE}
pageSize={desktopLoadMorePageSize}
initialPageSize={TRENDING_INITIAL_PAGE_SIZE}
scrollParent={containerRef?.current ?? null}
endOfLineupElement={
Expand Down
Loading