From d10cca30a0233f97831af596b1e59438ab425a9c Mon Sep 17 00:00:00 2001 From: xiangnuans Date: Fri, 28 Nov 2025 00:55:55 +0800 Subject: [PATCH 1/7] fix(query-core): ensure query refetches on mount/retry when status is error (#9728) --- .changeset/open-keys-create.md | 5 + packages/query-core/src/queryObserver.ts | 6 +- .../src/__tests__/issue-9728.test.tsx | 108 ++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 .changeset/open-keys-create.md create mode 100644 packages/react-query/src/__tests__/issue-9728.test.tsx diff --git a/.changeset/open-keys-create.md b/.changeset/open-keys-create.md new file mode 100644 index 0000000000..5b66e68ae3 --- /dev/null +++ b/.changeset/open-keys-create.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +Fix: Ensure queries refetch on mount or retry when in error state, even if data is not stale. diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 92978673f6..0d22724ec2 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -781,7 +781,11 @@ function shouldFetchOn( ) { const value = typeof field === 'function' ? field(query) : field - return value === 'always' || (value !== false && isStale(query, options)) + return ( + value === 'always' || + (value !== false && + (isStale(query, options) || query.state.status === 'error')) + ) } return false } diff --git a/packages/react-query/src/__tests__/issue-9728.test.tsx b/packages/react-query/src/__tests__/issue-9728.test.tsx new file mode 100644 index 0000000000..a12a437025 --- /dev/null +++ b/packages/react-query/src/__tests__/issue-9728.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest' +import { fireEvent, render } from '@testing-library/react' +import * as React from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { + QueryClient, + QueryClientProvider, + QueryErrorResetBoundary, + useQuery, +} from '..' +import { queryKey } from '@tanstack/query-test-utils' + +describe('issue 9728', () => { + it('should refetch after error when staleTime is Infinity and previous data exists', async () => { + const key = queryKey() + const queryFn = vi.fn() + let count = 0 + + queryFn.mockImplementation(async () => { + count++ + if (count === 2) { + throw new Error('Error ' + count) + } + return 'Success ' + count + }) + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }) + + function Page() { + const [_, forceUpdate] = React.useState(0) + + React.useEffect(() => { + forceUpdate(1) + }, []) + + const { data, refetch } = useQuery({ + queryKey: key, + queryFn, + throwOnError: true, + }) + + return ( +
+
Data: {data}
+ +
+ ) + } + + function App() { + return ( + + {({ reset }) => ( + ( +
+
Status: error
+ +
+ )} + > + Loading...}> + + +
+ )} +
+ ) + } + + const { getByText, findByText } = render( + + + + + , + ) + + // 1. First mount -> Success + await findByText('Data: Success 1') + expect(queryFn).toHaveBeenCalledTimes(1) + + // 2. Click Refetch -> Triggers fetch -> Fails (Error 2) -> ErrorBoundary + fireEvent.click(getByText('Refetch')) + + // Wait for error UI + await findByText('Status: error') + expect(queryFn).toHaveBeenCalledTimes(2) + + // 3. Click Retry -> Remounts + // Because staleTime is Infinity and we have Data from (1), + // AND we are in Error state. + fireEvent.click(getByText('Retry')) + + // Should call queryFn again (3rd time) and succeed + await findByText('Data: Success 3') + expect(queryFn).toHaveBeenCalledTimes(3) + }) +}) From b5d5e737cd0dc7c2ac6dcc1e557dbbb18df96f05 Mon Sep 17 00:00:00 2001 From: xiangnuans Date: Sun, 28 Dec 2025 23:01:52 +0800 Subject: [PATCH 2/7] fix: mark queries as invalidated on background errors instead of checking error status - Remove error status check in shouldFetchOn - Set isInvalidated: true in reducer when background error occurs (only if data exists) - Add tests for staleTime: 'static' and non-static queries with background errors This approach centralizes stale logic in isStale/isStaleByTime and prevents regression where staleTime: 'static' queries would incorrectly refetch on window focus after a background error. --- .../src/__tests__/queryObserver.test.tsx | 79 +++++++++++++++++++ packages/query-core/src/query.ts | 2 + packages/query-core/src/queryObserver.ts | 6 +- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index c1ddefbfa5..fbfacdfe18 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -1394,6 +1394,85 @@ describe('queryObserver', () => { unsubscribe() }) + test('should not refetchOnWindowFocus when staleTime is static and query has background error', async () => { + const key = queryKey() + let callCount = 0 + const queryFn = vi.fn(async () => { + callCount++ + if (callCount === 1) { + return 'data' + } + throw new Error('background error') + }) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + staleTime: 'static', + refetchOnWindowFocus: true, + }) + + const unsubscribe = observer.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + expect(queryFn).toHaveBeenCalledTimes(1) + expect(observer.getCurrentResult().data).toBe('data') + expect(observer.getCurrentResult().status).toBe('success') + + await observer.refetch() + await vi.advanceTimersByTimeAsync(0) + expect(queryFn).toHaveBeenCalledTimes(2) + expect(observer.getCurrentResult().status).toBe('error') + expect(observer.getCurrentResult().data).toBe('data') + + focusManager.setFocused(false) + focusManager.setFocused(true) + await vi.advanceTimersByTimeAsync(0) + expect(queryFn).toHaveBeenCalledTimes(2) + + unsubscribe() + }) + + test('should refetchOnWindowFocus when query has background error and staleTime is not static', async () => { + const key = queryKey() + let callCount = 0 + const queryFn = vi.fn(async () => { + callCount++ + if (callCount === 1) { + return 'data' + } + if (callCount === 2) { + throw new Error('background error') + } + return 'new data' + }) + + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn, + staleTime: 1000, + refetchOnWindowFocus: true, + }) + + const unsubscribe = observer.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + expect(queryFn).toHaveBeenCalledTimes(1) + expect(observer.getCurrentResult().data).toBe('data') + expect(observer.getCurrentResult().status).toBe('success') + + await observer.refetch() + await vi.advanceTimersByTimeAsync(0) + expect(queryFn).toHaveBeenCalledTimes(2) + expect(observer.getCurrentResult().status).toBe('error') + expect(observer.getCurrentResult().data).toBe('data') + + focusManager.setFocused(false) + focusManager.setFocused(true) + await vi.advanceTimersByTimeAsync(0) + expect(queryFn).toHaveBeenCalledTimes(3) + + unsubscribe() + }) + test('should set fetchStatus to idle when _optimisticResults is isRestoring', () => { const key = queryKey() const observer = new QueryObserver(queryClient, { diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 6895c156db..2eede61852 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -658,6 +658,8 @@ export class Query< fetchFailureReason: error, fetchStatus: 'idle', status: 'error', + isInvalidated: + state.data !== undefined ? true : state.isInvalidated, } case 'invalidate': return { diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 0d22724ec2..92978673f6 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -781,11 +781,7 @@ function shouldFetchOn( ) { const value = typeof field === 'function' ? field(query) : field - return ( - value === 'always' || - (value !== false && - (isStale(query, options) || query.state.status === 'error')) - ) + return value === 'always' || (value !== false && isStale(query, options)) } return false } From 5f316e3108fe2ddb9550ad1da7b728ffada4a5e3 Mon Sep 17 00:00:00 2001 From: xiangnuans Date: Mon, 29 Dec 2025 00:19:13 +0800 Subject: [PATCH 3/7] refactor: simplify isInvalidated logic in error case Set isInvalidated: true unconditionally since queries with no data are always considered stale per isStaleByTime logic. --- packages/query-core/src/query.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 2eede61852..4735395dee 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -658,8 +658,7 @@ export class Query< fetchFailureReason: error, fetchStatus: 'idle', status: 'error', - isInvalidated: - state.data !== undefined ? true : state.isInvalidated, + isInvalidated: true, } case 'invalidate': return { From 26035a44743b1af13880a5d2898467be8c9225c2 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sun, 28 Dec 2025 20:15:43 +0100 Subject: [PATCH 4/7] Apply suggestion from @TkDodo --- packages/query-core/src/query.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index 4735395dee..6005bda0d3 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -658,6 +658,8 @@ export class Query< fetchFailureReason: error, fetchStatus: 'idle', status: 'error', + // flag existing data as invalidated if we get a background error + // note that "no data" always means stale so we can set unconditionally here isInvalidated: true, } case 'invalidate': From c0cea841ebe9b2d1aa6eb7b0ec77e94b7ad01903 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sun, 28 Dec 2025 20:16:55 +0100 Subject: [PATCH 5/7] Apply suggestions from code review --- .changeset/open-keys-create.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/open-keys-create.md b/.changeset/open-keys-create.md index 5b66e68ae3..bb2ef8260c 100644 --- a/.changeset/open-keys-create.md +++ b/.changeset/open-keys-create.md @@ -2,4 +2,4 @@ '@tanstack/query-core': patch --- -Fix: Ensure queries refetch on mount or retry when in error state, even if data is not stale. +Fix: Always treat existing data as stale when query goes into error state. From 08a6f2bb0f21da91fd26726035e400dc3b9917a8 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sun, 28 Dec 2025 20:34:06 +0100 Subject: [PATCH 6/7] fix: eslint --- packages/react-query/src/__tests__/issue-9728.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-query/src/__tests__/issue-9728.test.tsx b/packages/react-query/src/__tests__/issue-9728.test.tsx index a12a437025..44f3241cc8 100644 --- a/packages/react-query/src/__tests__/issue-9728.test.tsx +++ b/packages/react-query/src/__tests__/issue-9728.test.tsx @@ -3,13 +3,13 @@ import { describe, expect, it, vi } from 'vitest' import { fireEvent, render } from '@testing-library/react' import * as React from 'react' import { ErrorBoundary } from 'react-error-boundary' +import { queryKey } from '@tanstack/query-test-utils' import { QueryClient, QueryClientProvider, QueryErrorResetBoundary, useQuery, } from '..' -import { queryKey } from '@tanstack/query-test-utils' describe('issue 9728', () => { it('should refetch after error when staleTime is Infinity and previous data exists', async () => { From 909a50445ac5d74f39db63b2c9357dd67d26e631 Mon Sep 17 00:00:00 2001 From: xiangnuans Date: Mon, 29 Dec 2025 16:41:20 +0800 Subject: [PATCH 7/7] fix: disable retry in background error tests to prevent timeout The tests were timing out because when refetch() throws an error, the default retry mechanism (3 retries with exponential backoff) was being triggered. With fake timers, the retry delays weren't being advanced, causing the tests to hang. Adding retry: false to both tests disables retries and allows them to complete immediately after the error, which is appropriate for these tests that specifically check background error behavior. --- packages/query-core/src/__tests__/queryObserver.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index fbfacdfe18..57f8040864 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -1410,6 +1410,7 @@ describe('queryObserver', () => { queryFn, staleTime: 'static', refetchOnWindowFocus: true, + retry: false, }) const unsubscribe = observer.subscribe(() => undefined) @@ -1451,6 +1452,7 @@ describe('queryObserver', () => { queryFn, staleTime: 1000, refetchOnWindowFocus: true, + retry: false, }) const unsubscribe = observer.subscribe(() => undefined)