From a693d985988da2adbc9814fc32c96f250c4de260 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Mon, 29 Jun 2026 12:32:06 -0700 Subject: [PATCH] fix(events): prevent follow button flash on contest page The contest follow button briefly rendered "Follow" (unfollowed) before snapping to "Following" for users who already follow the contest. The flash happened because the button defaulted to the unfollowed state while useEventFollowState was still in-flight (data: undefined -> isFollowed false -> true once resolved). Gate the visible follow button on the query's isPending state: while loading, render a spinner-only button (fixed width keeps layout stable) instead of the misleading "Follow" label, on both the desktop web page and the React Native contest screen. Mobile web hides follow inside an overflow menu, so it has no visible flash and is left unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../screens/contest-screen/ContestScreen.tsx | 13 +++++++++++-- .../pages/contest-page/ContestPage.test.tsx | 18 ++++++++++++++++++ .../components/desktop/ContestPage.tsx | 11 +++++++++-- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/mobile/src/screens/contest-screen/ContestScreen.tsx b/packages/mobile/src/screens/contest-screen/ContestScreen.tsx index d669672cfd1..572292bfd1f 100644 --- a/packages/mobile/src/screens/contest-screen/ContestScreen.tsx +++ b/packages/mobile/src/screens/contest-screen/ContestScreen.tsx @@ -169,7 +169,8 @@ export const ContestScreen = () => { const eventId = contest?.eventId const { data: currentUserId } = useCurrentUserId() - const { data: followState } = useEventFollowState(eventId) + const { data: followState, isPending: isFollowStatePending } = + useEventFollowState(eventId) const { mutate: followEvent } = useFollowEvent() const { mutate: unfollowEvent } = useUnfollowEvent() const isOwner = !!currentUserId && currentUserId === track?.owner_id @@ -444,10 +445,18 @@ export const ContestScreen = () => { variant={followState?.isFollowed ? 'secondary' : 'primary'} size='small' onPress={handleToggleFollow} + // Show a spinner while the follow state loads instead of + // defaulting to "Follow", which would flash before snapping + // to "Following" for users who already follow the contest. + isLoading={isFollowStatePending} disabled={!currentUserId || !eventId} fullWidth > - {followState?.isFollowed ? messages.following : messages.follow} + {isFollowStatePending + ? '' + : followState?.isFollowed + ? messages.following + : messages.follow} {!isEnded ? ( diff --git a/packages/web/src/pages/contest-page/ContestPage.test.tsx b/packages/web/src/pages/contest-page/ContestPage.test.tsx index 8dac3211127..3ce7d76d918 100644 --- a/packages/web/src/pages/contest-page/ContestPage.test.tsx +++ b/packages/web/src/pages/contest-page/ContestPage.test.tsx @@ -263,6 +263,24 @@ describe('ContestPage', () => { ).not.toBeInTheDocument() }) + it('does not flash "Follow" while the follow state is still loading', () => { + // Mid-flight: no data yet. The button must not render the unfollowed + // "Follow" label, otherwise it flashes before snapping to "Following" + // for users who already follow the contest. + mocks.useEventFollowState.mockReturnValue({ + data: undefined, + isPending: true + }) + renderContestPage() + + expect( + screen.queryByRole('button', { name: /^follow$/i }) + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: /^following$/i }) + ).not.toBeInTheDocument() + }) + it('includes the track title in the page header when the contest is attached to a track', () => { renderContestPage() // The redesigned hero composes the display title as diff --git a/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx b/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx index 03b5bf07119..9eebc57adb1 100644 --- a/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx +++ b/packages/web/src/pages/contest-page/components/desktop/ContestPage.tsx @@ -220,7 +220,8 @@ const ContestPage = ({ containerRef: _containerRef }: ContestPageProps) => { const eventId = contest?.eventId const { data: currentUserId } = useCurrentUserId() - const { data: followState } = useEventFollowState(eventId) + const { data: followState, isPending: isFollowStatePending } = + useEventFollowState(eventId) const { mutate: followEvent } = useFollowEvent() const { mutate: unfollowEvent } = useUnfollowEvent() // Drives the Following → Unfollow label swap on hover for the contest @@ -452,12 +453,17 @@ const ContestPage = ({ containerRef: _containerRef }: ContestPageProps) => { size='small' variant={isFollowed ? 'primary' : 'secondary'} iconLeft={followIcon} + // While the follow state is still loading, render a spinner-only + // button (fixed width keeps layout stable) so it doesn't flash + // "Follow" before snapping to "Following" for users who already + // follow the contest. + isLoading={isFollowStatePending} onClick={handleToggleFollow} onMouseEnter={() => setIsFollowHovering(true)} onMouseLeave={() => setIsFollowHovering(false)} css={{ width: 112, justifyContent: 'center' }} > - {followLabel} + {isFollowStatePending ? '' : followLabel} {!isEnded ? (