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/quiet-retries-keep-running.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-core': patch
---

Keep retrying failed queries while the tab is in the background when `refetchIntervalInBackground` is `true`. Previously the retryer paused until the page regained focus, which defeated the purpose of `refetchIntervalInBackground`.
36 changes: 36 additions & 0 deletions packages/query-core/src/__tests__/query.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,42 @@ describe('query', () => {
expect(result).toBe('data3')
})

it('should keep retrying while unfocused when refetchIntervalInBackground is true', async () => {
const key = queryKey()

// make page unfocused
const visibilityMock = mockVisibilityState('hidden')

let count = 0
let result

const promise = queryClient.fetchQuery({
queryKey: key,
queryFn: () => {
count++

if (count === 3) {
return `data${count}`
}

throw new Error(`error${count}`)
},
retry: 3,
retryDelay: 1,
refetchIntervalInBackground: true,
})

promise.then((data) => {
result = data
})

// Retries proceed even though the page stays unfocused
await vi.advanceTimersByTimeAsync(50)
expect(result).toBe('data3')

visibilityMock.mockRestore()
})

Comment on lines +108 to +143

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Restore the visibility mock in a finally block.

If the fetch or assertion fails, mockRestore() is skipped and can contaminate subsequent tests in the same worker. Await the promise directly inside try/finally.

Proposed fix
-    let result
-
     const promise = queryClient.fetchQuery({
       ...
     })
 
-    promise.then((data) => {
-      result = data
-    })
-
-    await vi.advanceTimersByTimeAsync(50)
-    expect(result).toBe('data3')
-
-    visibilityMock.mockRestore()
+    try {
+      await vi.advanceTimersByTimeAsync(50)
+      await expect(promise).resolves.toBe('data3')
+    } finally {
+      visibilityMock.mockRestore()
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should keep retrying while unfocused when refetchIntervalInBackground is true', async () => {
const key = queryKey()
// make page unfocused
const visibilityMock = mockVisibilityState('hidden')
let count = 0
let result
const promise = queryClient.fetchQuery({
queryKey: key,
queryFn: () => {
count++
if (count === 3) {
return `data${count}`
}
throw new Error(`error${count}`)
},
retry: 3,
retryDelay: 1,
refetchIntervalInBackground: true,
})
promise.then((data) => {
result = data
})
// Retries proceed even though the page stays unfocused
await vi.advanceTimersByTimeAsync(50)
expect(result).toBe('data3')
visibilityMock.mockRestore()
})
it('should keep retrying while unfocused when refetchIntervalInBackground is true', async () => {
const key = queryKey()
// make page unfocused
const visibilityMock = mockVisibilityState('hidden')
let count = 0
const promise = queryClient.fetchQuery({
queryKey: key,
queryFn: () => {
count++
if (count === 3) {
return `data${count}`
}
throw new Error(`error${count}`)
},
retry: 3,
retryDelay: 1,
refetchIntervalInBackground: true,
})
try {
await vi.advanceTimersByTimeAsync(50)
await expect(promise).resolves.toBe('data3')
} finally {
visibilityMock.mockRestore()
}
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/query-core/src/__tests__/query.test.tsx` around lines 108 - 143,
Update the test around queryClient.fetchQuery so visibilityMock.mockRestore()
always runs in a finally block. Await the fetch promise directly within try,
perform the existing result assertion after it resolves, and preserve the retry
behavior and expectations.

it('should continue retry after reconnect and resolve all promises', async () => {
const key = queryKey()

Expand Down
1 change: 1 addition & 0 deletions packages/query-core/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,7 @@ export class Query<
retry: context.options.retry,
retryDelay: context.options.retryDelay,
networkMode: context.options.networkMode,
refetchIntervalInBackground: context.options.refetchIntervalInBackground,
canRun: () => true,
})

Expand Down
3 changes: 2 additions & 1 deletion packages/query-core/src/retryer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface RetryerConfig<TData = unknown, TError = DefaultError> {
retry?: RetryValue<TError>
retryDelay?: RetryDelayValue<TError>
networkMode: NetworkMode | undefined
refetchIntervalInBackground?: boolean
canRun: () => boolean
}

Expand Down Expand Up @@ -102,7 +103,7 @@ export function createRetryer<TData = unknown, TError = DefaultError>(
}

const canContinue = () =>
focusManager.isFocused() &&
(config.refetchIntervalInBackground === true || focusManager.isFocused()) &&
(config.networkMode === 'always' || onlineManager.isOnline()) &&
config.canRun()

Expand Down
10 changes: 5 additions & 5 deletions packages/query-core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,11 @@ export interface QueryOptions<
retry?: RetryValue<TError>
retryDelay?: RetryDelayValue<TError>
networkMode?: NetworkMode
/**
* If set to `true`, the query will continue to refetch (and retry) while their tab/window is in the background.
* Defaults to `false`.
*/
refetchIntervalInBackground?: boolean
/**
* The time in milliseconds that unused/inactive cache data remains in memory.
* When a query's cache becomes unused or inactive, that cache data will be garbage collected after this duration.
Expand Down Expand Up @@ -340,11 +345,6 @@ export interface QueryObserverOptions<
| ((
query: Query<TQueryFnData, TError, TQueryData, TQueryKey>,
) => number | false | undefined)
/**
* If set to `true`, the query will continue to refetch while their tab/window is in the background.
* Defaults to `false`.
*/
refetchIntervalInBackground?: boolean
/**
* If set to `true`, the query will refetch on window focus if the data is stale.
* If set to `false`, the query will not refetch on window focus.
Expand Down