From 410d557fcb4e147821f3c7e2071b63dd659a562a Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Fri, 8 May 2026 17:20:48 -0700 Subject: [PATCH 1/6] [Web] Smooth out lineup infinite-scroll on Trending and Feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the recent mobile fix (#14272) on the web TrackLineup. Two issues stacked at the bottom of tanquery-driven lineups: - The `react-infinite-scroller` threshold was a fixed 500px, so on tall monitors the user could blow past it before paint. - Skeletons rendered only once tanquery's `isFetching` flipped to true — a multi-tick round-trip after the scroll handler fires, long enough for the user to land on the literal bottom of the list with nothing there. This change: - Sizes the threshold to ~one viewport (clientHeight of the scroll parent, observed via ResizeObserver), matching mobile's `onEndReachedThreshold = 1`. - Adds a local `isLoadMoreTriggered` flag set the same tick the scroll handler fires, so skeletons render on the very next frame instead of waiting for `isFetching` to propagate. Cleared once new entries arrive or the parent finishes fetching. - Routes InfiniteScroll's `loadMore` through a single `handleLoadMore` guarded by the flag. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/smooth-web-lineup-scroll.md | 5 ++ .../web/src/components/lineup/TrackLineup.tsx | 64 +++++++++++++++++-- 2 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 .changeset/smooth-web-lineup-scroll.md diff --git a/.changeset/smooth-web-lineup-scroll.md b/.changeset/smooth-web-lineup-scroll.md new file mode 100644 index 00000000000..6e51e3c8675 --- /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. 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. diff --git a/packages/web/src/components/lineup/TrackLineup.tsx b/packages/web/src/components/lineup/TrackLineup.tsx index facaa0516c7..e60f4306fc1 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,11 @@ 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. Matches roughly one +// viewport on a typical laptop — sized so the next page request fires while a +// full screen of content is still below, and skeletons appear before the user +// reaches the literal bottom of the list. +const DEFAULT_LOAD_MORE_THRESHOLD = 800 const { getPlaying: getPlayerPlaying } = playbackSelectors const { makeGetCurrent } = playbackSelectors @@ -221,6 +225,48 @@ export const TrackLineup = ({ return trackIds.slice(0, end) }, [trackIds, maxEntries]) + // Track the scroll parent's viewport height so the load-more threshold is + // ~one full viewport (matches mobile's `onEndReachedThreshold = 1`). Falls + // back to the constant until measured. + const [measuredThreshold, setMeasuredThreshold] = useState( + null + ) + useEffect(() => { + const parent = externalScrollParent ?? document.getElementById('mainContent') + if (!parent) return + const update = () => setMeasuredThreshold(parent.clientHeight || null) + update() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(update) + observer.observe(parent) + return () => observer.disconnect() + }, [externalScrollParent]) + + const effectiveLoadMoreThreshold = measuredThreshold ?? 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) => { if (!skeletonCount) return null @@ -291,7 +337,11 @@ export const TrackLineup = ({ ]) const isInitialLoad = isPending && tiles.length === 0 - const isEmpty = tiles.length === 0 && !isFetching && !isInitialLoad + const isEmpty = + tiles.length === 0 && + !isFetching && + !isInitialLoad && + !isLoadMoreTriggered return (
{})} + 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,7 +410,7 @@ export const TrackLineup = ({ ))} - {isFetching && tiles.length > 0 + {(isFetching || isLoadMoreTriggered) && tiles.length > 0 ? renderSkeletons(Math.min(maxEntries - tiles.length, pageSize)) : null} From 35b34cd039e5e34d22eedaa4a36b2557d7ac367a Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Fri, 8 May 2026 17:34:07 -0700 Subject: [PATCH 2/6] chore: prettier formatting in TrackLineup Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/src/components/lineup/TrackLineup.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/web/src/components/lineup/TrackLineup.tsx b/packages/web/src/components/lineup/TrackLineup.tsx index e60f4306fc1..3d0fe42c6b4 100644 --- a/packages/web/src/components/lineup/TrackLineup.tsx +++ b/packages/web/src/components/lineup/TrackLineup.tsx @@ -232,7 +232,8 @@ export const TrackLineup = ({ null ) useEffect(() => { - const parent = externalScrollParent ?? document.getElementById('mainContent') + const parent = + externalScrollParent ?? document.getElementById('mainContent') if (!parent) return const update = () => setMeasuredThreshold(parent.clientHeight || null) update() @@ -338,10 +339,7 @@ export const TrackLineup = ({ const isInitialLoad = isPending && tiles.length === 0 const isEmpty = - tiles.length === 0 && - !isFetching && - !isInitialLoad && - !isLoadMoreTriggered + tiles.length === 0 && !isFetching && !isInitialLoad && !isLoadMoreTriggered return (
Date: Fri, 8 May 2026 18:13:56 -0700 Subject: [PATCH 3/6] [Web] Larger threshold + viewport-fill skeletons in TrackLineup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/smooth-web-lineup-scroll.md | 2 +- .../web/src/components/lineup/TrackLineup.tsx | 51 ++++++++++++++----- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/.changeset/smooth-web-lineup-scroll.md b/.changeset/smooth-web-lineup-scroll.md index 6e51e3c8675..63eb1b332b4 100644 --- a/.changeset/smooth-web-lineup-scroll.md +++ b/.changeset/smooth-web-lineup-scroll.md @@ -2,4 +2,4 @@ '@audius/web': patch --- -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. +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 3d0fe42c6b4..f0f81c8ef82 100644 --- a/packages/web/src/components/lineup/TrackLineup.tsx +++ b/packages/web/src/components/lineup/TrackLineup.tsx @@ -19,11 +19,23 @@ import styles from './Lineup.module.css' import { LineupVariant } from './types' const NARROW_CONTAINER_THRESHOLD_PX = 600 -// Fallback used until the scroll parent has been measured. Matches roughly one -// viewport on a typical laptop — sized so the next page request fires while a -// full screen of content is still below, and skeletons appear before the user -// reaches the literal bottom of the list. -const DEFAULT_LOAD_MORE_THRESHOLD = 800 +// 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 @@ -225,17 +237,16 @@ export const TrackLineup = ({ return trackIds.slice(0, end) }, [trackIds, maxEntries]) - // Track the scroll parent's viewport height so the load-more threshold is - // ~one full viewport (matches mobile's `onEndReachedThreshold = 1`). Falls - // back to the constant until measured. - const [measuredThreshold, setMeasuredThreshold] = useState( + // 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 = () => setMeasuredThreshold(parent.clientHeight || null) + const update = () => setScrollParentHeight(parent.clientHeight || null) update() if (typeof ResizeObserver === 'undefined') return const observer = new ResizeObserver(update) @@ -243,7 +254,9 @@ export const TrackLineup = ({ return () => observer.disconnect() }, [externalScrollParent]) - const effectiveLoadMoreThreshold = measuredThreshold ?? loadMoreThreshold + 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 @@ -341,6 +354,20 @@ 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) + const loadingSkeletonCount = Math.min( + Math.max(0, maxEntries - tiles.length), + Math.max(pageSize, fillCount) + ) + return (
0 - ? renderSkeletons(Math.min(maxEntries - tiles.length, pageSize)) + ? renderSkeletons(loadingSkeletonCount) : null}
From 79d00e0c5c62545715856c040f2bc551f6102f36 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Fri, 8 May 2026 19:29:41 -0700 Subject: [PATCH 4/6] [Web] Stop layout shift / scroll bounce in TrackLineup Two follow-ups to the smoother infinite-scroll work, both visible while scrolling Trending: 1. Skeleton tiles in ordered lineups now reserve the order column. The desktop TrackTile previously hid the rank number and crown when `isLoading`, so the order column collapsed to ~0 width on a skeleton and the artwork shifted left when real data arrived. With the skeleton now rendering the index it's been told (`tiles.length + i`), the number / crown stay visible and the layout doesn't jump on load. 2. Skeleton tail is now constant while `hasNextPage`. Before, skeletons were gated on `isFetching || isLoadMoreTriggered`, so the page ballooned by ~one threshold's worth of skeletons when fetching, then shrank back when the page resolved. On a fast scroll deep into that shrinking region, the browser had to clamp `scrollTop`, which felt like the page "bouncing" mid-scroll. Keeping a constant skeleton tail until the end of the lineup means the scroll height only ever grows (smoothly, by `pageSize` tile-heights per resolved page) until `hasNextPage` flips to false at end-of-lineup. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/src/components/lineup/TrackLineup.tsx | 8 ++++---- packages/web/src/components/track/desktop/TrackTile.tsx | 6 ++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/web/src/components/lineup/TrackLineup.tsx b/packages/web/src/components/lineup/TrackLineup.tsx index f0f81c8ef82..bdf7f1efff5 100644 --- a/packages/web/src/components/lineup/TrackLineup.tsx +++ b/packages/web/src/components/lineup/TrackLineup.tsx @@ -282,7 +282,7 @@ export const TrackLineup = ({ }, [hasNextPage, isFetching, isLoadMoreTriggered, loadNextPage]) const renderSkeletons = useCallback( - (skeletonCount: number | undefined) => { + (skeletonCount: number | undefined, indexOffset = 0) => { if (!skeletonCount) return null return ( <> @@ -301,7 +301,7 @@ export const TrackLineup = ({ {/* @ts-ignore - TrackTile types don't fully cover loading state */} ))} - {(isFetching || isLoadMoreTriggered) && tiles.length > 0 - ? renderSkeletons(loadingSkeletonCount) + {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} )} From bf5237c7998a9ecefb69ab6c5bcd126e168e17b0 Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Fri, 8 May 2026 19:52:58 -0700 Subject: [PATCH 5/6] [Web] Bump desktop trending/feed loadMorePageSize to 10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared default (`TRENDING_LOAD_MORE_PAGE_SIZE` / `FEED_LOAD_MORE_PAGE_SIZE = 4`) is tuned for mobile viewports and slower touch scrolling. On desktop a fast trackpad / wheel scroll covers ~2 viewports per second, but each 4-track page only adds ~480px of real content — so successive load-mores can't keep up and the lineup visibly lags behind the user even with the synchronous skeleton tail. Override at the desktop call sites only, leaving the shared constant (and mobile's behavior) untouched. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/desktop/FeedPageContent.tsx | 17 ++++++++++------- .../components/desktop/TrendingPageContent.tsx | 10 +++++++--- 2 files changed, 17 insertions(+), 10 deletions(-) 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..8b86130bd6f 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' @@ -106,11 +105,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 +405,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={ From 43dc8e6bd8bd1dc772963b182fc0d70d4b8280cc Mon Sep 17 00:00:00 2001 From: Raymond Jacobson Date: Fri, 8 May 2026 23:24:38 -0700 Subject: [PATCH 6/6] [Web] Position-based skeleton keys + reset scroll on Trending category swap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups: 1. Skeleton keys are now `skeleton-${absolutePosition}` instead of `skeleton-${localIndex}`. Before, the same DOM node (e.g. the first skeleton, key `skeleton-0`) was reused across each page-load transition with a different `index` prop — so its rendered order number changed (e.g. 11 → 21 → 31), which read as the numbers visibly jumping during scroll. Keying by absolute position lets a skeleton DOM node keep displaying the same number; pages mount new skeletons at the new tail and unmount the ones that were replaced by real tiles. 2. Switching Trending category (Tracks / Underground / Winners) now resets the scroll parent to top. The outer `mainContent` scroller keeps its position by default, so swapping lineups underneath would leave the new category mid-scroll. Wrap `setCategory` to call `containerRef.current.scrollTo(0, 0)` first. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/web/src/components/lineup/TrackLineup.tsx | 7 ++++++- .../components/desktop/TrendingPageContent.tsx | 14 +++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/web/src/components/lineup/TrackLineup.tsx b/packages/web/src/components/lineup/TrackLineup.tsx index bdf7f1efff5..e8246b8a1e3 100644 --- a/packages/web/src/components/lineup/TrackLineup.tsx +++ b/packages/web/src/components/lineup/TrackLineup.tsx @@ -292,7 +292,12 @@ export const TrackLineup = ({ { 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