From 92ece4ec7f3140d5b07b8e4e1d50745e1ac3f530 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Mon, 18 May 2026 14:40:56 -0700 Subject: [PATCH 1/3] fix(web): trigger lineup next-page fetch before user scrolls past skeletons Co-Authored-By: Claude Opus 4.7 --- packages/web/src/components/lineup/TrackLineup.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/web/src/components/lineup/TrackLineup.tsx b/packages/web/src/components/lineup/TrackLineup.tsx index e8246b8a1e3..260569e3dcd 100644 --- a/packages/web/src/components/lineup/TrackLineup.tsx +++ b/packages/web/src/components/lineup/TrackLineup.tsx @@ -33,9 +33,11 @@ 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 +// last entry while the next page is in flight. Large tile = 144px body + +// 24px mb='l' + 16px parent gap='m' ≈ 184. Small tile is rendered in a row +// layout, so a smaller estimate is fine. +const APPROX_TILE_HEIGHT_LARGE = 184 +const APPROX_TILE_HEIGHT_SMALL = 96 const { getPlaying: getPlayerPlaying } = playbackSelectors const { makeGetCurrent } = playbackSelectors @@ -440,7 +442,9 @@ export const TrackLineup = ({ ))} - {hasNextPage && tiles.length > 0 + {hasNextPage && + tiles.length > 0 && + (isFetching || isLoadMoreTriggered) ? renderSkeletons(loadingSkeletonCount, tiles.length) : null} From d1dda93036d4fb5036323db3d961aa5b59c20ed4 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Tue, 19 May 2026 10:52:22 -0700 Subject: [PATCH 2/3] fix(web): keep skeletons mounted across fetch cycles to stop scroll thumb jumping Compensate the load-more threshold for the persistent skeleton area so the trigger still fires when within LOAD_MORE_VIEWPORTS of the bottom of loaded tiles, not the bottom of the skeleton padding. Co-Authored-By: Claude Opus 4.7 --- .../web/src/components/lineup/TrackLineup.tsx | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/web/src/components/lineup/TrackLineup.tsx b/packages/web/src/components/lineup/TrackLineup.tsx index 260569e3dcd..fe087262fff 100644 --- a/packages/web/src/components/lineup/TrackLineup.tsx +++ b/packages/web/src/components/lineup/TrackLineup.tsx @@ -256,7 +256,11 @@ export const TrackLineup = ({ return () => observer.disconnect() }, [externalScrollParent]) - const effectiveLoadMoreThreshold = scrollParentHeight + // Base threshold = the "remaining content" buffer we want before the bottom + // of *loaded tiles*. We add the persistent skeleton area to this below so + // react-infinite-scroller's bottom-of-content check stays anchored to the + // loaded tiles rather than the skeleton padding. + const baseLoadMoreThreshold = scrollParentHeight ? scrollParentHeight * LOAD_MORE_VIEWPORTS : loadMoreThreshold @@ -361,19 +365,28 @@ 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. + // Persistent skeletons render below the loaded tiles whenever more pages are + // available. They 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. Keeping + // them mounted across fetch cycles (rather than gating on isFetching) means + // scrollHeight only ever grows monotonically, which keeps the scroll thumb + // stable instead of snapping wider/narrower each page. const approxTileHeight = isSmallTrackTile ? APPROX_TILE_HEIGHT_SMALL : APPROX_TILE_HEIGHT_LARGE - const fillCount = Math.ceil(effectiveLoadMoreThreshold / approxTileHeight) + const fillCount = Math.ceil(baseLoadMoreThreshold / approxTileHeight) const loadingSkeletonCount = Math.min( Math.max(0, maxEntries - tiles.length), Math.max(pageSize, fillCount) ) + // The skeleton block adds to scrollHeight; compensate the threshold so the + // trigger fires when the user is within `LOAD_MORE_VIEWPORTS` of the bottom + // of *loaded tiles*, not the bottom of the skeleton padding. + const skeletonAreaHeight = + hasNextPage && tiles.length > 0 ? loadingSkeletonCount * approxTileHeight : 0 + const effectiveLoadMoreThreshold = baseLoadMoreThreshold + skeletonAreaHeight return (
))} - {hasNextPage && - tiles.length > 0 && - (isFetching || isLoadMoreTriggered) + {hasNextPage && tiles.length > 0 ? renderSkeletons(loadingSkeletonCount, tiles.length) : null} From 2ce7fe5af2e6aa812ac01697b5b8a2c60fc5483f Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Tue, 19 May 2026 11:41:51 -0700 Subject: [PATCH 3/3] fix(web): replace lineup scroll-listener with IntersectionObserver The previous threshold-based trigger measured the scroll container's scrollHeight, which made it sensitive to any layout fluctuations in the skeleton block. Replace it with a 1px sentinel
  • placed between the loaded tiles and the persistent skeleton block, watched by an IntersectionObserver with rootMargin = LOAD_MORE_VIEWPORTS viewports. Also stabilize the skeleton count (drop the viewport-derived fillCount in favor of a constant floor) so the bottom block's height is invariant across renders. Co-Authored-By: Claude Opus 4.7 --- .../web/src/components/lineup/TrackLineup.tsx | 103 +++++++++--------- 1 file changed, 51 insertions(+), 52 deletions(-) diff --git a/packages/web/src/components/lineup/TrackLineup.tsx b/packages/web/src/components/lineup/TrackLineup.tsx index fe087262fff..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,14 +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. Large tile = 144px body + -// 24px mb='l' + 16px parent gap='m' ≈ 184. Small tile is rendered in a row -// layout, so a smaller estimate is fine. -const APPROX_TILE_HEIGHT_LARGE = 184 -const APPROX_TILE_HEIGHT_SMALL = 96 const { getPlaying: getPlayerPlaying } = playbackSelectors const { makeGetCurrent } = playbackSelectors @@ -210,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' @@ -256,16 +242,14 @@ export const TrackLineup = ({ return () => observer.disconnect() }, [externalScrollParent]) - // Base threshold = the "remaining content" buffer we want before the bottom - // of *loaded tiles*. We add the persistent skeleton area to this below so - // react-infinite-scroller's bottom-of-content check stays anchored to the - // loaded tiles rather than the skeleton padding. - const baseLoadMoreThreshold = 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) @@ -287,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 @@ -366,27 +371,15 @@ export const TrackLineup = ({ tiles.length === 0 && !isFetching && !isInitialLoad && !isLoadMoreTriggered // Persistent skeletons render below the loaded tiles whenever more pages are - // available. They 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. Keeping - // them mounted across fetch cycles (rather than gating on isFetching) means - // scrollHeight only ever grows monotonically, which keeps the scroll thumb - // stable instead of snapping wider/narrower each page. - const approxTileHeight = isSmallTrackTile - ? APPROX_TILE_HEIGHT_SMALL - : APPROX_TILE_HEIGHT_LARGE - const fillCount = Math.ceil(baseLoadMoreThreshold / approxTileHeight) + // 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) ) - // The skeleton block adds to scrollHeight; compensate the threshold so the - // trigger fires when the user is within `LOAD_MORE_VIEWPORTS` of the bottom - // of *loaded tiles*, not the bottom of the skeleton padding. - const skeletonAreaHeight = - hasNextPage && tiles.length > 0 ? loadingSkeletonCount * approxTileHeight : 0 - const effectiveLoadMoreThreshold = baseLoadMoreThreshold + skeletonAreaHeight return (
    - ))} - {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