From fa15eb5601ac33008a87e16a784d7a5d46dbb088 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sat, 7 Nov 2026 10:43:38 -0800 Subject: [PATCH] Refresh personal competitions while showing cached results --- .../MyCompetitions.query.test.tsx | 108 ++++++++++++++++-- .../MyCompetitions/MyCompetitions.query.ts | 2 + .../MyCompetitions/MyCompetitions.tsx | 6 +- 3 files changed, 107 insertions(+), 9 deletions(-) diff --git a/src/containers/MyCompetitions/MyCompetitions.query.test.tsx b/src/containers/MyCompetitions/MyCompetitions.query.test.tsx index 28c27b1..378255d 100644 --- a/src/containers/MyCompetitions/MyCompetitions.query.test.tsx +++ b/src/containers/MyCompetitions/MyCompetitions.query.test.tsx @@ -1,7 +1,8 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { renderHook, waitFor } from '@testing-library/react'; +import { dehydrate, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { PropsWithChildren } from 'react'; -import { fetchUserWithCompetitions } from '@/lib/api'; +import { fetchUserWithCompetitions, UserCompsResponse } from '@/lib/api'; +import { QueryProvider, queryClient } from '@/providers/QueryProvider'; import { useMyCompetitionsQuery } from './MyCompetitions.query'; jest.mock('@/hooks/UsePinnedCompetitions', () => ({ @@ -15,12 +16,10 @@ jest.mock('@/lib/api', () => ({ })); jest.mock('@/lib/localStorage', () => { - const storage = new Map(); - return { - getLocalStorage: jest.fn((key: string) => storage.get(key) ?? null), + getLocalStorage: jest.fn((key: string) => window.localStorage.getItem(key)), setLocalStorage: jest.fn((key: string, value: string) => { - storage.set(key, value); + window.localStorage.setItem(key, value); }), }; }); @@ -59,6 +58,12 @@ const createWrapper = () => { beforeEach(() => { jest.clearAllMocks(); + window.localStorage.clear(); +}); + +afterEach(() => { + queryClient.clear(); + window.localStorage.clear(); }); it('loads my competitions from the public user competitions endpoint', async () => { @@ -102,3 +107,92 @@ it('uses cached competitions as stale startup data and still refetches', async ( await waitFor(() => expect(result.current.competitions).toEqual([ongoingCompetition])); expect(fetchUserWithCompetitions).toHaveBeenCalledWith(String(user.id)); }); + +it.each([ + { name: 'empty', cachedCompetitions: [] }, + { name: 'populated', cachedCompetitions: [upcomingCompetition] }, +])( + 'refreshes a fresh $name persisted cache while keeping its data visible', + async ({ cachedCompetitions }) => { + const reloadedUser = { ...user, id: 8184 }; + const previousClient = new QueryClient(); + previousClient.setQueryData(['userCompetitions', reloadedUser.id], { + user: reloadedUser, + upcoming_competitions: cachedCompetitions, + ongoing_competitions: [], + }); + window.localStorage.setItem('user', JSON.stringify(reloadedUser)); + window.localStorage.setItem( + 'REACT_QUERY_OFFLINE_CACHE', + JSON.stringify({ + timestamp: Date.now(), + buster: 'wcif-v2', + clientState: dehydrate(previousClient), + }), + ); + previousClient.clear(); + let resolveResponse!: (response: UserCompsResponse) => void; + jest.mocked(fetchUserWithCompetitions).mockReturnValue( + new Promise((resolve) => { + resolveResponse = resolve; + }), + ); + + const { result } = renderHook(() => useMyCompetitionsQuery(reloadedUser.id), { + wrapper: QueryProvider, + }); + + await waitFor(() => expect(fetchUserWithCompetitions).toHaveBeenCalledWith('8184')); + expect(result.current.competitions).toEqual(cachedCompetitions); + expect(result.current.isFetching).toBe(true); + expect(result.current.isLoading).toBe(false); + + await act(async () => { + resolveResponse({ + user: reloadedUser, + upcoming_competitions: [], + ongoing_competitions: [ongoingCompetition], + }); + }); + + await waitFor(() => expect(result.current.competitions).toEqual([ongoingCompetition])); + }, +); + +it('refreshes again on a return visit even when the previous request is still fresh', async () => { + const wrapper = createWrapper(); + jest.mocked(fetchUserWithCompetitions).mockResolvedValueOnce({ + user, + upcoming_competitions: [upcomingCompetition], + ongoing_competitions: [], + }); + + const firstVisit = renderHook(() => useMyCompetitionsQuery(user.id), { wrapper }); + await waitFor(() => + expect(firstVisit.result.current.competitions).toEqual([upcomingCompetition]), + ); + firstVisit.unmount(); + + let resolveResponse!: (response: UserCompsResponse) => void; + jest.mocked(fetchUserWithCompetitions).mockReturnValueOnce( + new Promise((resolve) => { + resolveResponse = resolve; + }), + ); + const returnVisit = renderHook(() => useMyCompetitionsQuery(user.id), { wrapper }); + + await waitFor(() => expect(fetchUserWithCompetitions).toHaveBeenCalledTimes(2)); + expect(returnVisit.result.current.competitions).toEqual([upcomingCompetition]); + expect(returnVisit.result.current.isFetching).toBe(true); + + await act(async () => { + resolveResponse({ + user, + upcoming_competitions: [], + ongoing_competitions: [ongoingCompetition], + }); + }); + await waitFor(() => + expect(returnVisit.result.current.competitions).toEqual([ongoingCompetition]), + ); +}); diff --git a/src/containers/MyCompetitions/MyCompetitions.query.ts b/src/containers/MyCompetitions/MyCompetitions.query.ts index 778cc1d..d7bcdcd 100644 --- a/src/containers/MyCompetitions/MyCompetitions.query.ts +++ b/src/containers/MyCompetitions/MyCompetitions.query.ts @@ -46,6 +46,8 @@ export const useMyCompetitionsQuery = (userId?: number, options: { enabled?: boo }, staleTime: FIVE_MINUTES, gcTime: FIVE_MINUTES, + // Recheck persisted results on each visit, even when a cached empty list is fresh. + refetchOnMount: 'always', enabled: Boolean(userId && (options.enabled ?? true)), initialData: () => getCachedUserCompetitions(userId), initialDataUpdatedAt: 0, diff --git a/src/containers/MyCompetitions/MyCompetitions.tsx b/src/containers/MyCompetitions/MyCompetitions.tsx index 585a472..0ac83b0 100644 --- a/src/containers/MyCompetitions/MyCompetitions.tsx +++ b/src/containers/MyCompetitions/MyCompetitions.tsx @@ -1,3 +1,4 @@ +import { useIsRestoring } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { CompetitionListFragment } from '@/components'; import { useAuth } from '@/providers/AuthProvider'; @@ -7,7 +8,8 @@ import { useMyCompetitionsQuery } from './MyCompetitions.query'; export function MyCompetitions() { const { t } = useTranslation(); const { user } = useAuth(); - const { error, status, isLoading, competitions, pinnedCompetitions } = useMyCompetitionsQuery( + const isRestoring = useIsRestoring(); + const { error, status, isFetching, competitions, pinnedCompetitions } = useMyCompetitionsQuery( user?.id, ); @@ -20,7 +22,7 @@ export function MyCompetitions() { c.id) || []} showWhenEmpty={Boolean(user)} />