diff --git a/.changeset/open-keys-create.md b/.changeset/open-keys-create.md new file mode 100644 index 0000000000..bb2ef8260c --- /dev/null +++ b/.changeset/open-keys-create.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +Fix: Always treat existing data as stale when query goes into error state. diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index c1ddefbfa5..57f8040864 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -1394,6 +1394,87 @@ 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, + retry: false, + }) + + 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, + retry: false, + }) + + 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..6005bda0d3 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -658,6 +658,9 @@ 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': return { 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..44f3241cc8 --- /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 { queryKey } from '@tanstack/query-test-utils' +import { + QueryClient, + QueryClientProvider, + QueryErrorResetBoundary, + useQuery, +} from '..' + +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) + }) +})