diff --git a/packages/common/src/api/tan-query/events/index.ts b/packages/common/src/api/tan-query/events/index.ts index 58487ae1827..02b651d0aa3 100644 --- a/packages/common/src/api/tan-query/events/index.ts +++ b/packages/common/src/api/tan-query/events/index.ts @@ -1,6 +1,7 @@ // Queries export * from './useAllEvents' export * from './useAllRemixContests' +export * from './useUserRemixContests' export * from './useEvent' export * from './useEventFollowers' export * from './useEvents' diff --git a/packages/common/src/api/tan-query/events/useUserRemixContests.ts b/packages/common/src/api/tan-query/events/useUserRemixContests.ts index 9d0a7d0d77d..b8c08df1093 100644 --- a/packages/common/src/api/tan-query/events/useUserRemixContests.ts +++ b/packages/common/src/api/tan-query/events/useUserRemixContests.ts @@ -28,9 +28,9 @@ type UseUserRemixContestsArgs = { userId: ID | null | undefined pageSize?: number /** - * Filter by contest status. Defaults to `'all'` (the backend's default), - * which returns active contests first (ordered by soonest-ending end_date) - * followed by ended contests (most-recently-ended first). + * Filter by contest status. Defaults to `'all'`, which returns active + * contests first (ordered by soonest-ending end_date) followed by ended + * contests (most-recently-ended first). */ status?: UserRemixContestStatus } @@ -41,20 +41,21 @@ export const getUserRemixContestsQueryKey = ({ status = GetContestsByUserStatusEnum.All }: UseUserRemixContestsArgs) => [ - QUERY_KEYS.userRemixContests, - { userId, pageSize, status } + QUERY_KEYS.userRemixContestsList, + userId, + { pageSize, status } ] as unknown as QueryKey /** - * Hook to fetch remix contest events hosted by a specific user with infinite - * query support. Calls the dedicated endpoint - * `GET /v1/users/{id}/contests` (SDK: `users.getContestsByUser`), which returns - * events ordered with currently-active contests first (by soonest-ending - * end_date) followed by ended contests. + * Hook to fetch the remix contests hosted by a specific user with infinite + * query support. Calls `GET /v1/users/{id}/contests` (SDK: + * `users.getContestsByUser`), which returns events ordered with + * currently-active contests first (by soonest-ending end_date) followed by + * ended contests. * - * Each page is mapped to the remix contest's parent track ID - * (`event.entityId`) so consumers like `ContestCard` can receive a - * `trackId` prop and resolve the event internally via `useRemixContest`. + * Each page is mapped to the contest's parent track ID (`event.entityId`) + * so consumers like `ContestCard` can take a `trackId` prop and resolve the + * event internally via `useRemixContest`. */ export const useUserRemixContests = ( { @@ -75,22 +76,24 @@ export const useUserRemixContests = ( return allPages.length * pageSize }, queryFn: async ({ pageParam }) => { + if (!userId) return [] const sdk = await audiusSdk() const { data, related } = await sdk.users.getContestsByUser({ id: Id.parse(userId), limit: pageSize, - offset: pageParam as number, + offset: pageParam, status }) // Prime related tracks + users (full objects, delivered alongside the - // event list on the per-user endpoint, same shape as the discovery - // endpoint). + // event list). This turns ContestCard's useTrack / useUser into cache + // hits so the grid can paint with one network round-trip instead of + // N+1. primeRelatedData({ related, queryClient }) // Prime useRemixes({ trackId, pageSize: 0, isContestEntry: true }) so // ContestCard's entry-count badge doesn't fire a count-only request - // per card. + // per card. Mirrors the priming in useAllRemixContests. const entryCounts = related?.entryCounts ?? {} for (const [hashedTrackId, count] of Object.entries(entryCounts)) { const trackId = OptionalHashId.parse(hashedTrackId) @@ -114,7 +117,7 @@ export const useUserRemixContests = ( .map((sdkEvent: SDKEvent) => { const event = eventMetadataFromSDK(sdkEvent) if (!event) return null - // Prime the per-event cache so useEvent hits immediately downstream. + // Prime per-event cache so useEvent hits immediately downstream. queryClient.setQueryData(getEventQueryKey(event.eventId), event) // useRemixContest resolves via useEventIdsByEntityId keyed by // (entityId, entityType=Track, eventType=RemixContest). Prime that @@ -136,8 +139,8 @@ export const useUserRemixContests = ( }) .filter(removeNullable) }, - enabled: !!userId && options?.enabled !== false, select: (data) => data.pages.flat(), + enabled: options?.enabled !== false && !!userId, ...options }) } diff --git a/packages/common/src/api/tan-query/queryKeys.ts b/packages/common/src/api/tan-query/queryKeys.ts index ba150d5ea1f..9e93c06fda6 100644 --- a/packages/common/src/api/tan-query/queryKeys.ts +++ b/packages/common/src/api/tan-query/queryKeys.ts @@ -99,7 +99,7 @@ export const QUERY_KEYS = { events: 'events', eventsByEntityId: 'eventsByEntityId', remixContestsList: 'remixContestsList', - userRemixContests: 'userRemixContests', + userRemixContestsList: 'userRemixContestsList', walletOwner: 'walletOwner', tokenPrice: 'tokenPrice', usdcBalance: 'usdcBalance', diff --git a/packages/mobile/src/screens/profile-screen/ProfileTabs/ContestsTab.tsx b/packages/mobile/src/screens/profile-screen/ProfileTabs/ContestsTab.tsx index ab67cab8bd4..46f7b4e3456 100644 --- a/packages/mobile/src/screens/profile-screen/ProfileTabs/ContestsTab.tsx +++ b/packages/mobile/src/screens/profile-screen/ProfileTabs/ContestsTab.tsx @@ -1,15 +1,5 @@ -import { useEffect, useMemo } from 'react' - -import { - getEventIdsByEntityIdQueryKey, - getEventQueryKey, - useAllRemixContests, - useProfileUser -} from '@audius/common/api' -import type { Event, ID } from '@audius/common/models' -import { EventEntityTypeEnum, EventEventTypeEnum } from '@audius/sdk' +import { useProfileUser, useUserRemixContests } from '@audius/common/api' import { useIsFocused } from '@react-navigation/native' -import { useQueryClient } from '@tanstack/react-query' import { View } from 'react-native' import { Box, Flex, LoadingSpinner } from '@audius/harmony-native' @@ -28,20 +18,9 @@ import { EmptyProfileTile } from '../EmptyProfileTile' * integrates with the parent `CollapsibleTabNavigator`'s scroll-tracking * — a regular `ScrollView` here breaks the collapsible header behaviour. * - * Filtering by host: - * The discovery `getRemixContests` endpoint doesn't yet support filtering - * by host userId, so we paginate the global list and filter - * client-side. We do the filter at the parent level by reading the - * already-primed event from React Query's cache (useAllRemixContests - * primes each event via `queryClient.setQueryData`). This avoids - * per-row `useRemixContest` calls (which can race when multiple cards - * mount at once) and gives a single deterministic list of trackIds to - * render. - * - * Auto-paginates: we proactively fetch all pages until exhausted so the - * tab is reliable for hosts whose contests sit beyond the first page of - * the global list — `onEndReached` alone wouldn't fire for users with - * just one or two visible cards (no scroll needed). + * Backed by `GET /v1/users/{id}/contests` (active first by soonest end, + * then ended) so the tab no longer needs to walk the global list and + * filter client-side. */ export const ContestsTab = () => { const { user_id: hostUserId } = @@ -49,64 +28,19 @@ export const ContestsTab = () => { select: (user) => ({ user_id: user.user_id }) }).user ?? {} const isFocused = useIsFocused() - const queryClient = useQueryClient() - - // Larger page size + auto-pagination below: the discovery endpoint - // doesn't support `host=…`, so we paginate the global list and - // filter client-side. Bumped from 50 → 100 because hosts whose - // contests sit deep in the global list (ended contests, smaller - // accounts) were missing from the tab — Julian's @dimensionx - // report. Together with the `useEffect` below that drains pages - // until exhausted, this guarantees the host's contests appear once - // they're anywhere in the result set. - const { - data: trackIds, - isPending, - isFetching, - hasNextPage, - fetchNextPage, - isFetchingNextPage - } = useAllRemixContests({ pageSize: 100 }, { enabled: isFocused }) - const allTrackIds = useMemo(() => trackIds ?? [], [trackIds]) - - // Filter to contests hosted by THIS profile by reading each contest's - // event from the cache (primed by useAllRemixContests). Re-derives on - // every render so newly-arrived pages flow in immediately. - const contestTrackIds = useMemo(() => { - if (hostUserId === undefined) return [] - return allTrackIds.filter((trackId) => { - const eventIds = queryClient.getQueryData( - getEventIdsByEntityIdQueryKey({ - entityId: trackId, - entityType: EventEntityTypeEnum.Track, - eventType: EventEventTypeEnum.RemixContest - }) - ) - const eventId = eventIds?.[0] - if (!eventId) return false - const event = queryClient.getQueryData(getEventQueryKey(eventId)) - return event?.userId === hostUserId - }) - }, [allTrackIds, hostUserId, queryClient]) + const { data: trackIds, isPending } = useUserRemixContests( + { userId: hostUserId, pageSize: 50 }, + { enabled: isFocused && !!hostUserId } + ) - // Auto-fetch subsequent pages until exhausted — see hook docstring. - // The host's contests can sit anywhere in the global list, so a single - // page isn't enough to guarantee we've seen them all. - useEffect(() => { - if (hasNextPage && !isFetchingNextPage && isFocused) { - fetchNextPage() - } - }, [hasNextPage, isFetchingNextPage, isFocused, fetchNextPage]) + const contestTrackIds = trackIds ?? [] if (!hostUserId) { return null } - if ( - (isPending || isFetchingNextPage || hasNextPage) && - contestTrackIds.length === 0 - ) { + if (isPending) { return ( @@ -116,7 +50,7 @@ export const ContestsTab = () => { ) } - if (!isFetching && contestTrackIds.length === 0) { + if (contestTrackIds.length === 0) { return }