diff --git a/packages/web/src/components/lineup/TrackLineup.tsx b/packages/web/src/components/lineup/TrackLineup.tsx
index e8246b8a1e3..ed789422ae9 100644
--- a/packages/web/src/components/lineup/TrackLineup.tsx
+++ b/packages/web/src/components/lineup/TrackLineup.tsx
@@ -5,7 +5,6 @@ import { playbackActions, playbackSelectors } from '@audius/common/store'
import type { PlaybackTrack, PlaybackQuerySource } from '@audius/common/store'
import { Divider, Flex } from '@audius/harmony'
import cn from 'classnames'
-import InfiniteScroll from 'react-infinite-scroller'
import { useDispatch, useSelector } from 'react-redux'
import { make } from 'common/store/analytics/actions'
@@ -30,12 +29,6 @@ const DEFAULT_LOAD_MORE_THRESHOLD = 1600
// 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
@@ -208,11 +201,6 @@ export const TrackLineup = ({
]
)
- const getScrollParent = useCallback(() => {
- if (externalScrollParent) return externalScrollParent
- return document.getElementById('mainContent')
- }, [externalScrollParent])
-
// Tile sizing mirrors the legacy component.
let tileSize: TrackTileSize = TrackTileSize.LARGE
let statSize: 'small' | 'large' = 'large'
@@ -254,12 +242,14 @@ export const TrackLineup = ({
return () => observer.disconnect()
}, [externalScrollParent])
- const effectiveLoadMoreThreshold = scrollParentHeight
+ // How many viewports below the user's current bottom edge we want loadMore
+ // to fire. Passed to the IntersectionObserver as a bottom rootMargin.
+ const loadMoreRootMargin = 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
+ // Synchronous "load more was triggered" flag — set the moment the trigger
+ // 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)
@@ -281,6 +271,27 @@ export const TrackLineup = ({
loadNextPage()
}, [hasNextPage, isFetching, isLoadMoreTriggered, loadNextPage])
+ // IntersectionObserver-based trigger. A 1px sentinel
is rendered just
+ // below the last loaded tile (above the persistent skeleton block); when it
+ // enters the viewport — extended downward by `loadMoreRootMargin` — we fire
+ // loadMore. Using IO instead of react-infinite-scroller's scroll-listener
+ // means the trigger geometry is anchored to a specific DOM element and is
+ // immune to scrollHeight fluctuations from the skeleton block or anywhere
+ // else in the layout.
+ const sentinelRef = useRef(null)
+ useEffect(() => {
+ const el = sentinelRef.current
+ if (!el || !hasNextPage) return
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((e) => e.isIntersecting)) handleLoadMore()
+ },
+ { rootMargin: `0px 0px ${loadMoreRootMargin}px 0px`, threshold: 0 }
+ )
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [hasNextPage, loadMoreRootMargin, handleLoadMore])
+
const renderSkeletons = useCallback(
(skeletonCount: number | undefined, indexOffset = 0) => {
if (!skeletonCount) return null
@@ -359,18 +370,15 @@ export const TrackLineup = ({
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)
+ // Persistent skeletons render below the loaded tiles whenever more pages are
+ // available, so scrollHeight grows monotonically across fetches (no
+ // mount/unmount churn on the bottom block). A stable count keeps the block
+ // height invariant across renders — important because the scrollbar thumb is
+ // sized relative to scrollHeight. `pageSize` is too small on its own (e.g.
+ // trending uses 4) so we floor by a small constant.
const loadingSkeletonCount = Math.min(
Math.max(0, maxEntries - tiles.length),
- Math.max(pageSize, fillCount)
+ Math.max(pageSize, 10)
)
return (
@@ -386,16 +394,8 @@ export const TrackLineup = ({
[lineupContainerStyles!]: !!lineupContainerStyles
})}
>
-
))}
- {hasNextPage && tiles.length > 0
- ? renderSkeletons(loadingSkeletonCount, tiles.length)
- : null}
-
+ {hasNextPage && tiles.length > 0 ? (
+ <>
+ {/* 1px sentinel watched by IntersectionObserver to fire
+ loadMore. Placed between the loaded tiles and the persistent
+ skeleton block so the trigger geometry stays anchored to the
+ bottom of loaded content. The IO rootMargin extends the
+ viewport downward by `loadMoreRootMargin` so the trigger
+ fires that many pixels before the sentinel actually enters
+ the visible area. */}
+
+ {renderSkeletons(loadingSkeletonCount, tiles.length)}
+ >
+ ) : null}
+
{!hasNextPage && tiles.length > 0 && endOfLineupElement
? endOfLineupElement