Skip to content

Commit 9b2ee3c

Browse files
[Web] Larger threshold + viewport-fill skeletons in TrackLineup
The first pass at smoothing infinite-scroll wasn't enough on desktop because two more things stacked on top of the late-skeleton problem: - A 1-viewport threshold isn't enough buffer for fast desktop scrolls (mouse fling, trackpad, PgDn). Bumped to 2 viewports so the next page request fires while there's still meaningful content below. - The skeleton count was `pageSize`, which is only 4 on Trending / Feed. At ~124px per desktop tile that's ~480px of skeletons — half a viewport — so the user could blow right through them and land on the literal bottom of the list while waiting for the network. Skeleton count is now `max(pageSize, ceil(threshold / approxTileHeight))` so the loading window always fills the threshold area. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 928227d commit 9b2ee3c

2 files changed

Lines changed: 40 additions & 13 deletions

File tree

.changeset/smooth-web-lineup-scroll.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
'@audius/web': patch
33
---
44

5-
Smooth out lineup infinite-scroll on Trending, Feed, and other tanquery-driven track lists. Skeletons used to gate on tanquery's `isFetching`, so the next page only painted after the scroll handler → `loadNextPage` → tanquery state round-trip — long enough that scrolling fast left a visible "stuck at the bottom" gap. The threshold is now ~one viewport (matching mobile) and a synchronous trigger flag renders skeletons on the next frame instead of waiting for `isFetching` to propagate.
5+
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.

packages/web/src/components/lineup/TrackLineup.tsx

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,23 @@ import styles from './Lineup.module.css'
1919
import { LineupVariant } from './types'
2020

2121
const NARROW_CONTAINER_THRESHOLD_PX = 600
22-
// Fallback used until the scroll parent has been measured. Matches roughly one
23-
// viewport on a typical laptop — sized so the next page request fires while a
24-
// full screen of content is still below, and skeletons appear before the user
25-
// reaches the literal bottom of the list.
26-
const DEFAULT_LOAD_MORE_THRESHOLD = 800
22+
// Fallback used until the scroll parent has been measured. Sized so the next
23+
// page request fires well before the user reaches the literal bottom of the
24+
// list. Effective threshold is `LOAD_MORE_VIEWPORTS * scrollParent.clientHeight`
25+
// once measured.
26+
const DEFAULT_LOAD_MORE_THRESHOLD = 1600
27+
// Number of viewports of "remaining content" that should trigger loading the
28+
// next page. Larger values give a bigger buffer for fast desktop scrolling so
29+
// skeletons paint comfortably before the user reaches the bottom. Matches the
30+
// effect of mobile's `onEndReachedThreshold` but on the larger desktop viewport
31+
// we need more headroom to keep up with fling scrolls.
32+
const LOAD_MORE_VIEWPORTS = 2
33+
// Approximate rendered heights of a TrackTile in different variants — used to
34+
// compute how many skeletons to render so the bottom-of-list "loading window"
35+
// fills the threshold area instead of leaving the user staring at a frozen
36+
// last entry while the next page is in flight.
37+
const APPROX_TILE_HEIGHT_LARGE = 124
38+
const APPROX_TILE_HEIGHT_SMALL = 80
2739

2840
const { getPlaying: getPlayerPlaying } = playbackSelectors
2941
const { makeGetCurrent } = playbackSelectors
@@ -225,25 +237,26 @@ export const TrackLineup = ({
225237
return trackIds.slice(0, end)
226238
}, [trackIds, maxEntries])
227239

228-
// Track the scroll parent's viewport height so the load-more threshold is
229-
// ~one full viewport (matches mobile's `onEndReachedThreshold = 1`). Falls
230-
// back to the constant until measured.
231-
const [measuredThreshold, setMeasuredThreshold] = useState<number | null>(
240+
// Track the scroll parent's viewport height so the load-more threshold is a
241+
// multiple of one full viewport. Falls back to the constant until measured.
242+
const [scrollParentHeight, setScrollParentHeight] = useState<number | null>(
232243
null
233244
)
234245
useEffect(() => {
235246
const parent =
236247
externalScrollParent ?? document.getElementById('mainContent')
237248
if (!parent) return
238-
const update = () => setMeasuredThreshold(parent.clientHeight || null)
249+
const update = () => setScrollParentHeight(parent.clientHeight || null)
239250
update()
240251
if (typeof ResizeObserver === 'undefined') return
241252
const observer = new ResizeObserver(update)
242253
observer.observe(parent)
243254
return () => observer.disconnect()
244255
}, [externalScrollParent])
245256

246-
const effectiveLoadMoreThreshold = measuredThreshold ?? loadMoreThreshold
257+
const effectiveLoadMoreThreshold = scrollParentHeight
258+
? scrollParentHeight * LOAD_MORE_VIEWPORTS
259+
: loadMoreThreshold
247260

248261
// Synchronous "load more was triggered" flag — set the moment the scroll
249262
// handler fires so skeletons render on the next frame, without waiting for
@@ -341,6 +354,20 @@ export const TrackLineup = ({
341354
const isEmpty =
342355
tiles.length === 0 && !isFetching && !isInitialLoad && !isLoadMoreTriggered
343356

357+
// While a page is in flight we render skeletons below the loaded tiles. They
358+
// need to fill ~one threshold's worth of vertical space so the bottom of the
359+
// list feels populated even when the user scrolls into the trigger area
360+
// faster than the network can return. `pageSize` is too small on its own
361+
// (e.g. trending uses 4) so we floor by a viewport-derived count.
362+
const approxTileHeight = isSmallTrackTile
363+
? APPROX_TILE_HEIGHT_SMALL
364+
: APPROX_TILE_HEIGHT_LARGE
365+
const fillCount = Math.ceil(effectiveLoadMoreThreshold / approxTileHeight)
366+
const loadingSkeletonCount = Math.min(
367+
Math.max(0, maxEntries - tiles.length),
368+
Math.max(pageSize, fillCount)
369+
)
370+
344371
return (
345372
<div
346373
className={cn(lineupStyle, {
@@ -409,7 +436,7 @@ export const TrackLineup = ({
409436
))}
410437

411438
{(isFetching || isLoadMoreTriggered) && tiles.length > 0
412-
? renderSkeletons(Math.min(maxEntries - tiles.length, pageSize))
439+
? renderSkeletons(loadingSkeletonCount)
413440
: null}
414441
</InfiniteScroll>
415442
</div>

0 commit comments

Comments
 (0)