diff --git a/.changeset/smooth-web-lineup-scroll.md b/.changeset/smooth-web-lineup-scroll.md new file mode 100644 index 00000000000..63eb1b332b4 --- /dev/null +++ b/.changeset/smooth-web-lineup-scroll.md @@ -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. diff --git a/packages/web/src/components/lineup/TrackLineup.tsx b/packages/web/src/components/lineup/TrackLineup.tsx index facaa0516c7..e8246b8a1e3 100644 --- a/packages/web/src/components/lineup/TrackLineup.tsx +++ b/packages/web/src/components/lineup/TrackLineup.tsx @@ -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' @@ -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 @@ -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( + 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 ( <> @@ -232,7 +292,12 @@ export const TrackLineup = ({ {/* @ts-ignore - TrackTile types don't fully cover loading state */} {})} + 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) ) @@ -360,8 +440,8 @@ export const TrackLineup = ({ ))} - {isFetching && tiles.length > 0 - ? renderSkeletons(Math.min(maxEntries - tiles.length, pageSize)) + {hasNextPage && tiles.length > 0 + ? renderSkeletons(loadingSkeletonCount, tiles.length) : null} diff --git a/packages/web/src/components/track/desktop/TrackTile.tsx b/packages/web/src/components/track/desktop/TrackTile.tsx index ea29c65503e..58a8e62c238 100644 --- a/packages/web/src/components/track/desktop/TrackTile.tsx +++ b/packages/web/src/components/track/desktop/TrackTile.tsx @@ -336,11 +336,9 @@ export const TrackTile = ({ {/* prefix ordering */} {tileOrder && ( - {!isLoading && tileOrder <= 10 && ( - - )} + {tileOrder <= 10 && } - {!isLoading && tileOrder} + {tileOrder} )} diff --git a/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx b/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx index 2b168d96db1..96b8c323378 100644 --- a/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx +++ b/packages/web/src/pages/feed-page/components/desktop/FeedPageContent.tsx @@ -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 { @@ -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 @@ -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] ) @@ -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 } ) @@ -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 } @@ -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 } diff --git a/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx b/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx index 00307140883..b968f594bed 100644 --- a/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx +++ b/packages/web/src/pages/trending-page/components/desktop/TrendingPageContent.tsx @@ -4,7 +4,6 @@ import { getTrendingQueryKey, getTrendingUndergroundQueryKey, TRENDING_INITIAL_PAGE_SIZE, - TRENDING_LOAD_MORE_PAGE_SIZE, useTrending, useTrendingUnderground } from '@audius/common/api' @@ -91,10 +90,22 @@ const TrendingPageContent = ({ containerRef }: TrendingPageContentProps) => { const bottomBarRef = useRef(null) const isCondensedBar = useIsContainerNarrow(bottomBarRef, 640) - const [category, setCategory] = useState(() => { + const [category, setCategoryState] = useState(() => { 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(() => { const { week } = parseUrlParams() return isValidWinnersWeek(week) ? week : null @@ -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] @@ -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={