diff --git a/packages/common/src/api/tan-query/lineups/useTrending.ts b/packages/common/src/api/tan-query/lineups/useTrending.ts index b76ca82fa7e..0591f52d925 100644 --- a/packages/common/src/api/tan-query/lineups/useTrending.ts +++ b/packages/common/src/api/tan-query/lineups/useTrending.ts @@ -131,9 +131,11 @@ export const useTrending = ( isPending: query.isPending, isLoading: query.isLoading, isFetching: query.isFetching, + isRefetching: query.isRefetching, isSuccess: query.isSuccess, isError: query.isError, isInitialLoading: query.isInitialLoading, + dataUpdatedAt: query.dataUpdatedAt, hasNextPage: query.hasNextPage, fetchNextPage: query.fetchNextPage, loadNextPage, diff --git a/packages/mobile/src/components/lineup/TrackLineup.tsx b/packages/mobile/src/components/lineup/TrackLineup.tsx index 0e8c3fb7b1c..1c41798450c 100644 --- a/packages/mobile/src/components/lineup/TrackLineup.tsx +++ b/packages/mobile/src/components/lineup/TrackLineup.tsx @@ -63,8 +63,8 @@ const styles = StyleSheet.create({ }) type LoadingItem = { _loading: true } -type TrackEntry = { kind: 'track'; trackId: ID; uid: UID } -type CollectionEntry = { kind: 'collection'; collectionId: ID } +type TrackEntry = { kind: 'track'; trackId: ID; uid: UID; key: string } +type CollectionEntry = { kind: 'collection'; collectionId: ID; key: string } type Entry = TrackEntry | CollectionEntry type RenderItem = Entry | LoadingItem @@ -310,19 +310,31 @@ export const TrackLineup = ({ ] ) - const entries: Entry[] = useMemo( - () => - visibleItems.map((item) => - item.type === EntityType.TRACK - ? { - kind: 'track' as const, - trackId: item.id, - uid: uidFor(item.id) - } - : { kind: 'collection' as const, collectionId: item.id } - ), - [visibleItems, uidFor] - ) + // Keys follow the entity, not its position, so a refetch that reorders the + // list moves tiles instead of remounting them. A repeated entity gets a + // suffix to keep keys unique. + const entries: Entry[] = useMemo(() => { + const seen = new Map() + const keyFor = (base: string) => { + const count = seen.get(base) ?? 0 + seen.set(base, count + 1) + return count === 0 ? base : `${base}-${count}` + } + return visibleItems.map((item) => + item.type === EntityType.TRACK + ? { + kind: 'track' as const, + trackId: item.id, + uid: uidFor(item.id), + key: keyFor(`track-${item.id}`) + } + : { + kind: 'collection' as const, + collectionId: item.id, + key: keyFor(`collection-${item.id}`) + } + ) + }, [visibleItems, uidFor]) // Synchronous "load more was triggered" flag — set the moment the scroll // handler fires so skeletons render on the next frame, without waiting for @@ -342,10 +354,12 @@ export const TrackLineup = ({ const sections: Section[] = useMemo(() => { const getSkeletonCount = () => { - if (entries.length === 0 && isPending) { + if (entries.length === 0 && (isPending || isFetching)) { return Math.min(maxEntries, initialPageSize ?? pageSize) } - if (isFetching || isLoadMoreTriggered) { + // Only a load-more gets trailing skeletons. A background refetch + // replaces the list in place. + if (isLoadMoreTriggered) { return Math.min(maxEntries, pageSize) } return 0 @@ -492,10 +506,7 @@ export const TrackLineup = ({ stickySectionHeadersEnabled={false} keyExtractor={(item: any, index: number) => { if ((item as LoadingItem)._loading) return `skeleton-${index}` - const entry = item as Entry - return entry.kind === 'track' - ? `track-${entry.uid}-${index}` - : `collection-${entry.collectionId}-${index}` + return (item as Entry).key }} renderItem={renderItem} scrollIndicatorInsets={{ right: Number.MIN_VALUE }} diff --git a/packages/mobile/src/screens/trending-screen/TrendingLineup.tsx b/packages/mobile/src/screens/trending-screen/TrendingLineup.tsx index 900997b7923..6f2412744e8 100644 --- a/packages/mobile/src/screens/trending-screen/TrendingLineup.tsx +++ b/packages/mobile/src/screens/trending-screen/TrendingLineup.tsx @@ -22,6 +22,14 @@ import { TrackLineup } from 'app/components/lineup/TrackLineup' const { setTrendingTimeRange } = trendingPageActions const { getTrendingGenre } = trendingPageSelectors +// Matches the query client's default staleTime. +const STALE_TIME_MS = 1000 * 60 * 5 + +// Trending is persisted to disk so it can paint on a cold start. A snapshot +// that was already stale when this session started gets refetched on mount, +// and painting it first means showing an old order and then reshuffling. +const sessionStartedAt = Date.now() + const sourceFor = (timeRange: TimeRange) => { if (timeRange === TimeRange.WEEK) return 'DISCOVER_TRENDING_WEEK' if (timeRange === TimeRange.MONTH) return 'DISCOVER_TRENDING_MONTH' @@ -70,8 +78,20 @@ export const TrendingLineup = ({ [timeRange, genre] ) - const { trackIds, isPending, isFetching, hasNextPage, loadNextPage } = - useTrending(queryArgs) + const { + trackIds, + isPending, + isFetching, + isRefetching, + dataUpdatedAt, + hasNextPage, + loadNextPage + } = useTrending(queryArgs) + + // Show skeletons until the refetch of a stale restored snapshot lands. If + // it fails, the snapshot is shown. + const isRestoredStale = + isRefetching && dataUpdatedAt < sessionStartedAt - STALE_TIME_MS const querySource = useMemo( () => ({ queryKey: [...getTrendingQueryKey(queryArgs)] as unknown[] }), @@ -81,10 +101,10 @@ export const TrendingLineup = ({ return (