From 6c4f4972516b90173d55c42a1bbbd5ad9c12ab90 Mon Sep 17 00:00:00 2001 From: vkpdeveloper Date: Thu, 22 Oct 2026 07:05:02 +0000 Subject: [PATCH] fix(react-query): avoid outdated key fetches on Activity reveal --- .changeset/quiet-queries-reveal.md | 5 + .../PersistQueryClientProvider.test.tsx | 24 +--- .../src/__tests__/Activity.test.tsx | 121 ++++++++++++++++++ packages/react-query/src/useBaseQuery.ts | 9 +- packages/react-query/src/useQueries.ts | 15 ++- 5 files changed, 142 insertions(+), 32 deletions(-) create mode 100644 .changeset/quiet-queries-reveal.md create mode 100644 packages/react-query/src/__tests__/Activity.test.tsx diff --git a/.changeset/quiet-queries-reveal.md b/.changeset/quiet-queries-reveal.md new file mode 100644 index 00000000000..2faa5320943 --- /dev/null +++ b/.changeset/quiet-queries-reveal.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-query': patch +--- + +Avoid fetching an outdated query key when an Activity tree is revealed after its query options changed while hidden. diff --git a/packages/react-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx b/packages/react-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx index 1c78ad80659..886e8aa51c1 100644 --- a/packages/react-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx +++ b/packages/react-query-persist-client/src/__tests__/PersistQueryClientProvider.test.tsx @@ -110,7 +110,7 @@ describe('PersistQueryClientProvider', () => { await act(() => vi.advanceTimersByTimeAsync(11)) expect(rendered.getByText('fetched')).toBeInTheDocument() - expect(states).toHaveLength(4) + expect(states).toHaveLength(3) expect(states[0]).toMatchObject({ status: 'pending', @@ -125,12 +125,6 @@ describe('PersistQueryClientProvider', () => { }) expect(states[2]).toMatchObject({ - status: 'success', - fetchStatus: 'fetching', - data: 'hydrated', - }) - - expect(states[3]).toMatchObject({ status: 'success', fetchStatus: 'idle', data: 'fetched', @@ -343,7 +337,7 @@ describe('PersistQueryClientProvider', () => { await act(() => vi.advanceTimersByTimeAsync(11)) expect(rendered.getByText('fetched')).toBeInTheDocument() - expect(states).toHaveLength(4) + expect(states).toHaveLength(3) expect(states[0]).toMatchObject({ status: 'success', @@ -358,12 +352,6 @@ describe('PersistQueryClientProvider', () => { }) expect(states[2]).toMatchObject({ - status: 'success', - fetchStatus: 'fetching', - data: 'hydrated', - }) - - expect(states[3]).toMatchObject({ status: 'success', fetchStatus: 'idle', data: 'fetched', @@ -704,7 +692,7 @@ describe('PersistQueryClientProvider', () => { expect(queryFn2).toHaveBeenCalledTimes(1) expect(onSuccess).toHaveBeenCalledTimes(1) - expect(states).toHaveLength(5) + expect(states).toHaveLength(4) expect(states[0]).toMatchObject({ status: 'pending', @@ -725,12 +713,6 @@ describe('PersistQueryClientProvider', () => { }) expect(states[3]).toMatchObject({ - status: 'success', - fetchStatus: 'fetching', - data: 'hydrated', - }) - - expect(states[4]).toMatchObject({ status: 'success', fetchStatus: 'idle', data: 'queryFn2', diff --git a/packages/react-query/src/__tests__/Activity.test.tsx b/packages/react-query/src/__tests__/Activity.test.tsx new file mode 100644 index 00000000000..8a0400b29cf --- /dev/null +++ b/packages/react-query/src/__tests__/Activity.test.tsx @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act } from '@testing-library/react' +import * as React from 'react' +import { queryKey, sleep } from '@tanstack/query-test-utils' +import { + QueryClient, + useQueries, + useQuery, + useSuspenseQueries, + useSuspenseQuery, +} from '..' +import { renderWithClient } from './utils' + +type Options = { + queryKey: Array + queryFn: () => Promise +} + +describe.each([ + { + name: 'useQuery', + useTestQuery: (options: Options) => useQuery(options).data, + }, + { + name: 'useSuspenseQuery', + useTestQuery: (options: Options) => useSuspenseQuery(options).data, + }, + { + name: 'useQueries', + useTestQuery: (options: Options) => + useQueries({ queries: [options] })[0].data, + }, + { + name: 'useSuspenseQueries', + useTestQuery: (options: Options) => + useSuspenseQueries({ queries: [options] })[0].data, + }, +])('$name with Activity', ({ useTestQuery }) => { + let queryClient: QueryClient + + beforeEach(() => { + vi.useFakeTimers() + queryClient = new QueryClient() + }) + + afterEach(() => { + queryClient.clear() + vi.useRealTimers() + }) + + it('should not fetch the old key when revealed after a hidden key change', async () => { + const key = queryKey() + const fetched: Array = [] + + function Page({ filter }: { filter: string }) { + const data = useTestQuery({ + queryKey: [...key, filter], + queryFn: async () => { + fetched.push(filter) + await sleep(10) + return filter + }, + }) + + return

data: {data}

+ } + + function App({ + mode, + filter, + }: { + mode: 'visible' | 'hidden' + filter: string + }) { + return ( + + + + + + ) + } + + const rendered = renderWithClient( + queryClient, + , + ) + await act(() => vi.advanceTimersByTimeAsync(11)) + expect(rendered.getByText('data: a')).toBeVisible() + + rendered.rerender() + // Hidden trees render without running effects. Also let suspense's minimum + // staleTime elapse so that subscribing to the old key would refetch it. + await act(() => vi.advanceTimersByTimeAsync(1100)) + expect( + queryClient + .getQueryCache() + .find({ queryKey: [...key, 'a'] })! + .getObserversCount(), + ).toBe(0) + expect( + queryClient + .getQueryCache() + .find({ queryKey: [...key, 'b'] })! + .getObserversCount(), + ).toBe(0) + + rendered.rerender() + await act(() => vi.advanceTimersByTimeAsync(50)) + + expect(rendered.getByText('data: b')).toBeVisible() + expect(fetched).toContain('b') + expect(fetched.filter((filter) => filter === 'a')).toStrictEqual(['a']) + expect( + queryClient + .getQueryCache() + .find({ queryKey: [...key, 'b'] })! + .getObserversCount(), + ).toBe(1) + }) +}) diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index ce1a615aff7..44993e9e8b6 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -104,6 +104,11 @@ export function useBaseQuery< // note: this must be called before useSyncExternalStore const result = observer.getOptimisticResult(defaultedOptions) + // Update options before subscribing so that an Activity reveal uses the latest query. + React.useEffect(() => { + observer.setOptions(defaultedOptions) + }, [defaultedOptions, observer]) + const shouldSubscribe = !isRestoring && subscribed React.useSyncExternalStore( React.useCallback( @@ -124,10 +129,6 @@ export function useBaseQuery< () => observer.getCurrentResult(), ) - React.useEffect(() => { - observer.setOptions(defaultedOptions) - }, [defaultedOptions, observer]) - // Handle suspense if (shouldSuspend(defaultedOptions, result)) { throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary) diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index 6d5886698c2..4bb6fa6d712 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -431,6 +431,14 @@ export function useQueries< (options as QueriesObserverOptions).combine, ) + // Update queries before subscribing so that an Activity reveal uses the latest queries. + React.useEffect(() => { + observer.setQueries( + defaultedQueries, + options as QueriesObserverOptions, + ) + }, [defaultedQueries, options, observer]) + const shouldSubscribe = !isRestoring && subscribed React.useSyncExternalStore( React.useCallback( @@ -444,13 +452,6 @@ export function useQueries< () => observer.getCurrentResult(), ) - React.useEffect(() => { - observer.setQueries( - defaultedQueries, - options as QueriesObserverOptions, - ) - }, [defaultedQueries, options, observer]) - const shouldAtLeastOneSuspend = optimisticResult.some((result, index) => shouldSuspend(defaultedQueries[index], result), )