Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cancelled-retry-pause.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tanstack/query-core": patch
---

Prevent a cancelled retry from pausing the query when its retry delay expires.
49 changes: 49 additions & 0 deletions packages/query-core/src/__tests__/query.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
QueryClient,
QueryObserver,
dehydrate,
focusManager,
hydrate,
noop,
} from '..'
Expand All @@ -34,6 +35,54 @@ describe('query', () => {
vi.useRealTimers()
})

it('should not let a cancelled retry pause its replacement fetch', async () => {
const key = queryKey()
const initial = queryClient.query({
queryKey: key,
queryFn: () => Promise.reject(new Error('synthetic failure')),
retry: 1,
retryDelay: 100,
})
initial.catch(noop)
await vi.advanceTimersByTimeAsync(0)
await queryClient.cancelQueries({ queryKey: key })
focusManager.setFocused(false)
try {
const replacement = queryClient.query({
queryKey: key,
queryFn: () => sleep(200).then(() => 'fresh'),
})
await vi.advanceTimersByTimeAsync(100)
const fetchStatus = queryClient.getQueryState(key)?.fetchStatus
await vi.advanceTimersByTimeAsync(100)
await expect(replacement).resolves.toBe('fresh')
expect(fetchStatus).toBe('fetching')
} finally {
focusManager.setFocused(undefined)
}
})

it('should collect a cancelled query after its retry delay expires', async () => {
const key = queryKey()
const initial = queryClient.query({
queryKey: key,
queryFn: () => Promise.reject(new Error('synthetic failure')),
retry: 1,
retryDelay: 100,
gcTime: 200,
})
initial.catch(noop)
await vi.advanceTimersByTimeAsync(0)
await queryClient.cancelQueries({ queryKey: key })
focusManager.setFocused(false)
try {
await vi.advanceTimersByTimeAsync(250)
expect(queryClient.getQueryState(key)).toBeUndefined()
} finally {
focusManager.setFocused(undefined)
}
})

it('should use the longest garbage collection time it has seen', async () => {
const key = queryKey()
await queryClient
Expand Down
25 changes: 25 additions & 0 deletions packages/query-core/src/__tests__/retryer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,31 @@ describe('createRetryer', () => {
vi.useRealTimers()
})

it.each(['offline', 'unfocused'])(
'should not pause a cancelled retry after its delay expires (%s)',
async (condition) => {
const onPause = vi.fn()
const fn = vi.fn().mockRejectedValue(new Error('synthetic failure'))
const retryer = createRetryer({
fn,
retry: 1,
retryDelay: 100,
onPause,
networkMode: 'online',
canRun: () => true,
})
const promise = retryer.start()
await vi.advanceTimersByTimeAsync(0)
retryer.cancel()
await expect(promise).rejects.toBeInstanceOf(CancelledError)
if (condition === 'offline') onlineManager.setOnline(false)
else focusManager.setFocused(false)
await vi.advanceTimersByTimeAsync(100)
expect(onPause).not.toHaveBeenCalled()
expect(fn).toHaveBeenCalledTimes(1)
},
)

it('should resolve with the result of fn and set status to resolved', async () => {
const fn = vi.fn().mockResolvedValue('success')
const retryer = createRetryer<string>({
Expand Down
2 changes: 1 addition & 1 deletion packages/query-core/src/retryer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export function createRetryer<TData = unknown, TError = DefaultError>(
sleep(delay)
// Pause if the document is not visible or when the device is offline
.then(() => {
return canContinue() ? undefined : pause()
return isResolved() || canContinue() ? undefined : pause()
})
.then(() => {
if (isRetryCancelled) {
Expand Down
35 changes: 34 additions & 1 deletion packages/react-query/src/__tests__/useQuery.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render } from '@testing-library/react'
import { act, fireEvent, render, renderHook } from '@testing-library/react'
import * as React from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import {
Expand All @@ -12,6 +12,7 @@ import {
QueryCache,
QueryClient,
dehydrate,
focusManager,
hydrate,
keepPreviousData,
noop,
Expand Down Expand Up @@ -44,6 +45,38 @@ describe('useQuery', () => {
vi.useRealTimers()
})

it.each([false, true])(
'should stay idle after a cancelled retry delay (StrictMode: %s)',
async (strict) => {
const key = queryKey()
const view = renderHook(
() =>
useQuery(
{
queryKey: key,
queryFn: () => Promise.reject(new Error('synthetic failure')),
retry: 1,
retryDelay: 100,
},
queryClient,
),
{ wrapper: strict ? React.StrictMode : undefined },
)
try {
await act(() => vi.advanceTimersByTimeAsync(0))
await act(() => queryClient.cancelQueries({ queryKey: key }))
await act(() => vi.advanceTimersByTimeAsync(1))
expect(view.result.current.fetchStatus).toBe('idle')
focusManager.setFocused(false)
await act(() => vi.advanceTimersByTimeAsync(100))
expect(view.result.current.fetchStatus).toBe('idle')
} finally {
view.unmount()
focusManager.setFocused(undefined)
}
},
)

// See https://github.com/tannerlinsley/react-query/issues/105
it('should allow to set default data value', async () => {
const key = queryKey()
Expand Down