Skip to content
Merged
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
8fc4d3d
add timeoutManager class
justjake Sep 2, 2025
2fb1f1f
add additional types & export functions
justjake Sep 2, 2025
acb595c
convert all setTimeout/setInterval to managed versions
justjake Sep 2, 2025
96813b6
tweaks
justjake Sep 2, 2025
530504e
add claude-generated tests
justjake Sep 2, 2025
223371b
tests
justjake Sep 2, 2025
d3d7d1a
revert changes in query-async-storage-persister: no path to import qu…
justjake Sep 2, 2025
a953c70
re-export more types
justjake Sep 2, 2025
0fac7b2
console.warn -> non-production console.error
justjake Sep 2, 2025
47962fd
query-async-storage-persister: use query-core managedSetTimeout
justjake Sep 2, 2025
62b1dd0
pdate pnpm-lock for new dependency edge
justjake Sep 2, 2025
8d5e050
sleep: always managedSetTimeout
justjake Sep 2, 2025
fea6cce
remove managed* functions, call method directly
justjake Sep 3, 2025
1606b58
remove runtime coercion and accept unsafe any within TimeoutManager c…
justjake Sep 3, 2025
fc9092b
cleanup; fix test after changes
justjake Sep 3, 2025
5d6fe4d
name is __TEST_ONLY__
justjake Sep 3, 2025
ad1fb2b
notifyManager: default scheduler === systemSetTimeoutZero
justjake Sep 3, 2025
932c3a2
Improve TimeoutCallback comment since ai was confused
justjake Sep 3, 2025
a6d38f8
remove unnecessary timeoutManager-related exports
justjake Sep 3, 2025
81d35ac
prettier-ify index.ts (seems my editor messed with it already this pr?)
justjake Sep 3, 2025
b6fffb4
continue to export defaultTimeoutProvider for tests
justjake Sep 3, 2025
948c646
oops missing import
justjake Sep 3, 2025
08a2c5f
Merge branch 'main' into jake--timeoutmanager
TkDodo Sep 4, 2025
841ac54
fix: export systemSetTimeoutZero from core
TkDodo Sep 4, 2025
fb22c67
ref: use notifyManager.schedule in createPersister
TkDodo Sep 4, 2025
09a787e
ref: move provider check behind env check
TkDodo Sep 4, 2025
7fb57b1
docs
justjake Sep 4, 2025
4b1e8af
doc tweaks
justjake Sep 4, 2025
b6ca80d
doc tweaks
justjake Sep 4, 2025
73014f5
docs: reference timeoutManager in discussion of 24 day setTimout limit
justjake Sep 4, 2025
3f452ea
Apply suggestion from @TkDodo
TkDodo Sep 5, 2025
cca861f
Apply suggestion from @TkDodo
TkDodo Sep 5, 2025
d58e28f
chore: fix broken links
TkDodo Sep 5, 2025
7922966
docs: syntax fix
TkDodo Sep 5, 2025
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
4 changes: 4 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,10 @@
{
"label": "notifyManager",
"to": "reference/notifyManager"
},
{
"label": "timeoutManager",
"to": "reference/timeoutManager"
}
],
"frameworks": [
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/react/plugins/persistQueryClient.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ It should be set as the same value or higher than persistQueryClient's `maxAge`

You can also pass it `Infinity` to disable garbage collection behavior entirely.

Due to a Javascript limitation, the maximum allowed `gcTime` is about 24 days (see [more](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value)).
Due to a JavaScript limitation, the maximum allowed `gcTime` is about [24 days](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value), although it is possible to work around this limit using [timeoutManager.setTimeoutProvider](../../../../reference/timeoutManager.md#timeoutmanagersettimeoutprovider).

```tsx
const queryClient = new QueryClient({
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/react/reference/useMutation.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ mutate(variables, {
- `gcTime: number | Infinity`
- The time in milliseconds that unused/inactive cache data remains in memory. When a mutation's cache becomes unused or inactive, that cache data will be garbage collected after this duration. When different cache times are specified, the longest one will be used.
- If set to `Infinity`, will disable garbage collection
- Note: the maximum allowed time is about 24 days. See [more](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value).
- Note: the maximum allowed time is about [24 days](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value), although it is possible to work around this limit using [timeoutManager.setTimeoutProvider](../../../../reference/timeoutManager.md#timeoutmanagersettimeoutprovider).
- `mutationKey: unknown[]`
- Optional
- A mutation key can be set to inherit defaults set with `queryClient.setMutationDefaults`.
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/react/reference/useQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const {
- `gcTime: number | Infinity`
- Defaults to `5 * 60 * 1000` (5 minutes) or `Infinity` during SSR
- 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. When different garbage collection times are specified, the longest one will be used.
- Note: the maximum allowed time is about 24 days. See [more](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value).
- Note: the maximum allowed time is about [24 days](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value), although it is possible to work around this limit using [timeoutManager.setTimeoutProvider](../../../../reference/timeoutManager.md#timeoutmanagersettimeoutprovider).
- If set to `Infinity`, will disable garbage collection
- `queryKeyHashFn: (queryKey: QueryKey) => string`
- Optional
Expand Down
2 changes: 1 addition & 1 deletion docs/framework/solid/reference/useQuery.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ function App() {
- ##### `gcTime: number | Infinity`
- Defaults to `5 * 60 * 1000` (5 minutes) or `Infinity` during SSR
- 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. When different garbage collection times are specified, the longest one will be used.
- Note: the maximum allowed time is about 24 days. See [more](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value).
- Note: the maximum allowed time is about [24 days](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value), although it is possible to work around this limit using [timeoutManager.setTimeoutProvider](../../../../reference/timeoutManager.md#timeoutmanagersettimeoutprovider).
- If set to `Infinity`, will disable garbage collection
- ##### `networkMode: 'online' | 'always' | 'offlineFirst`
- optional
Expand Down
119 changes: 119 additions & 0 deletions docs/reference/timeoutManager.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
---
id: TimeoutManager
title: TimeoutManager
---

The `TimeoutManager` handles `setTimeout` and `setInterval` timers in TanStack Query.

TanStack Query uses timers to implement features like query `staleTime` and `gcTime`, as well as retries, throttling, and debouncing.

By default, TimeoutManager uses the global `setTimeout` and `setInterval`, but it can be configured to use custom implementations instead.

Its available methods are:

- [`timeoutManager.setTimeoutProvider`](#timeoutmanagersettimeoutprovider)
- [`TimeoutProvider`](#timeoutprovider)
- [`timeoutManager.setTimeout`](#timeoutmanagersettimeout)
- [`timeoutManager.clearTimeout`](#timeoutmanagercleartimeout)
- [`timeoutManager.setInterval`](#timeoutmanagersetinterval)
- [`timeoutManager.clearInterval`](#timeoutmanagerclearinterval)

## `timeoutManager.setTimeoutProvider`

`setTimeoutProvider` can be used to set a custom implementation of the `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval` functions, called a `TimeoutProvider`.

This may be useful if you notice event loop performance issues with thousands of queries. A custom TimeoutProvider could also support timer delays longer than the global `setTimeout` maximum delay value of about [24 days](https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value).

It is important to call `setTimeoutProvider` before creating a QueryClient or queries, so that the same provider is used consistently for all timers in the application, since different TimeoutProviders cannot cancel each others' timers.

```tsx
import { timeoutManager, QueryClient } from '@tanstack/react-query'
import { CustomTimeoutProvider } from './CustomTimeoutProvider'

timeoutManager.setTimeoutProvider(new CustomTimeoutProvider())

export const queryClient = new QueryClient()
```

### `TimeoutProvider`

Timers are very performance sensitive. Short term timers (such as those with delays less than 5 seconds) tend to be latency sensitive, where long-term timers may benefit more from [timer coalescing](https://en.wikipedia.org/wiki/Timer_coalescing) - batching timers with similar deadlines together - using a data structure like a [hierarchical time wheel](https://www.npmjs.com/package/timer-wheel).

The `TimeoutProvider` type requires that implementations handle timer ID objects that can be converted to `number` via [Symbol.toPrimitive][toPrimitive] because runtimes like NodeJS return [objects][nodejs-timeout] from their global `setTimeout` and `setInterval` functions. TimeoutProvider implementations are free to coerce timer IDs to number internally, or to return their own custom object type that implements `{ [Symbol.toPrimitive]: () => number }`.

[nodejs-timeout]: https://nodejs.org/api/timers.html#class-timeout
[toPrimitive]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toPrimitive

```tsx
type ManagedTimerId = number | { [Symbol.toPrimitive]: () => number }

type TimeoutProvider<TTimerId extends ManagedTimerId = ManagedTimerId> = {
readonly setTimeout: (callback: TimeoutCallback, delay: number) => TTimerId
readonly clearTimeout: (timeoutId: TTimerId | undefined) => void

readonly setInterval: (callback: TimeoutCallback, delay: number) => TTimerId
readonly clearInterval: (intervalId: TTimerId | undefined) => void
}
```

## `timeoutManager.setTimeout`

`setTimeout(callback, delayMs)` schedules a callback to run after approximately `delay` milliseconds, like the global [setTimeout function](https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout).The callback can be canceled with `timeoutManager.clearTimeout`.

It returns a timer ID, which may be a number or an object that can be coerced to a number via [Symbol.toPrimitive][toPrimitive].

```tsx
import { timeoutManager } from '@tanstack/react-query'

const timeoutId = timeoutManager.setTimeout(
() => console.log('ran at:', new Date()),
1000,
)

const timeoutIdNumber: number = Number(timeoutId)
```

## `timeoutManager.clearTimeout`

`clearTimeout(timerId)` cancels a timeout callback scheduled with `setTimeout`, like the global [clearTimeout function](https://developer.mozilla.org/en-US/docs/Web/API/Window/clearTimeout). It should be called with a timer ID returned by `timeoutManager.setTimeout`.

```tsx
import { timeoutManager } from '@tanstack/react-query'

const timeoutId = timeoutManager.setTimeout(
() => console.log('ran at:', new Date()),
1000,
)

timeoutManager.clearTimeout(timeoutId)
```

## `timeoutManager.setInterval`

`setInterval(callback, intervalMs)` schedules a callback to be called approximately every `intervalMs`, like the global [setInterval function](https://developer.mozilla.org/en-US/docs/Web/API/Window/setInterval).

Like `setTimeout`, it returns a timer ID, which may be a number or an object that can be coerced to a number via [Symbol.toPrimitive](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toPrimitive).

```tsx
import { timeoutManager } from '@tanstack/react-query'

const intervalId = timeoutManager.setInterval(
() => console.log('ran at:', new Date()),
1000,
)
```

## `timeoutManager.clearInterval`

`clearInterval(intervalId)` can be used to cancel an interval, like the global [clearInterval function](https://developer.mozilla.org/en-US/docs/Web/API/Window/clearInterval). It should be called with an interval ID returned by `timeoutManager.setInterval`.

```tsx
import { timeoutManager } from '@tanstack/react-query'

const intervalId = timeoutManager.setInterval(
() => console.log('ran at:', new Date()),
1000,
)

timeoutManager.clearInterval(intervalId)
```
1 change: 1 addition & 0 deletions packages/query-async-storage-persister/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"!src/__tests__"
],
"dependencies": {
"@tanstack/query-core": "workspace:*",
"@tanstack/query-persist-client-core": "workspace:*"
},
"devDependencies": {
Expand Down
5 changes: 3 additions & 2 deletions packages/query-async-storage-persister/src/asyncThrottle.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { timeoutManager } from '@tanstack/query-core'
import { noop } from './utils'

interface AsyncThrottleOptions {
Expand All @@ -21,11 +22,11 @@ export function asyncThrottle<TArgs extends ReadonlyArray<unknown>>(
if (isScheduled) return
isScheduled = true
while (isExecuting) {
await new Promise((done) => setTimeout(done, interval))
await new Promise((done) => timeoutManager.setTimeout(done, interval))
}
while (Date.now() < nextExecutionTime) {
await new Promise((done) =>
setTimeout(done, nextExecutionTime - Date.now()),
timeoutManager.setTimeout(done, nextExecutionTime - Date.now()),
)
}
isScheduled = false
Expand Down
135 changes: 135 additions & 0 deletions packages/query-core/src/__tests__/timeoutManager.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
TimeoutManager,
defaultTimeoutProvider,
systemSetTimeoutZero,
timeoutManager,
} from '../timeoutManager'

describe('timeoutManager', () => {
function createMockProvider(name: string = 'custom') {
return {
__TEST_ONLY__name: name,
setTimeout: vi.fn(() => 123),
clearTimeout: vi.fn(),
setInterval: vi.fn(() => 456),
clearInterval: vi.fn(),
}
}

let consoleErrorSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
consoleErrorSpy = vi.spyOn(console, 'error')
})

afterEach(() => {
vi.restoreAllMocks()
})

describe('TimeoutManager', () => {
let manager: TimeoutManager

beforeEach(() => {
manager = new TimeoutManager()
})

it('by default proxies calls to globalThis setTimeout/clearTimeout', () => {
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout')
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout')
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')

const callback = vi.fn()
const timeoutId = manager.setTimeout(callback, 100)
expect(setTimeoutSpy).toHaveBeenCalledWith(callback, 100)
clearTimeout(Number(timeoutId))

manager.clearTimeout(200)
expect(clearTimeoutSpy).toHaveBeenCalledWith(200)

const intervalId = manager.setInterval(callback, 300)
expect(setIntervalSpy).toHaveBeenCalledWith(callback, 300)
clearInterval(Number(intervalId))

manager.clearInterval(400)
expect(clearIntervalSpy).toHaveBeenCalledWith(400)
})

describe('setTimeoutProvider', () => {
it('proxies calls to the configured timeout provider', () => {
const customProvider = createMockProvider()
manager.setTimeoutProvider(customProvider)

const callback = vi.fn()

manager.setTimeout(callback, 100)
expect(customProvider.setTimeout).toHaveBeenCalledWith(callback, 100)

manager.clearTimeout(999)
expect(customProvider.clearTimeout).toHaveBeenCalledWith(999)

manager.setInterval(callback, 200)
expect(customProvider.setInterval).toHaveBeenCalledWith(callback, 200)

manager.clearInterval(888)
expect(customProvider.clearInterval).toHaveBeenCalledWith(888)
})

it('warns when switching providers after making call', () => {
// 1. switching before making any calls does not warn
const customProvider = createMockProvider()
manager.setTimeoutProvider(customProvider)
expect(consoleErrorSpy).not.toHaveBeenCalled()

// Make a call. The next switch should warn
manager.setTimeout(vi.fn(), 100)

// 2. switching after making a call should warn
const customProvider2 = createMockProvider('custom2')
manager.setTimeoutProvider(customProvider2)
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringMatching(
/\[timeoutManager\]: Switching .* might result in unexpected behavior\..*/,
),
expect.anything(),
)

// 3. Switching again with no intermediate calls should not warn
vi.mocked(consoleErrorSpy).mockClear()
const customProvider3 = createMockProvider('custom3')
manager.setTimeoutProvider(customProvider3)
expect(consoleErrorSpy).not.toHaveBeenCalled()
})
})
})

describe('globalThis timeoutManager instance', () => {
it('should be an instance of TimeoutManager', () => {
expect(timeoutManager).toBeInstanceOf(TimeoutManager)
})
})

describe('exported functions', () => {
let provider: ReturnType<typeof createMockProvider>
beforeEach(() => {
provider = createMockProvider()
timeoutManager.setTimeoutProvider(provider)
})
afterEach(() => {
timeoutManager.setTimeoutProvider(defaultTimeoutProvider)
})

describe('systemSetTimeoutZero', () => {
it('should use globalThis setTimeout with 0 delay', () => {
const spy = vi.spyOn(globalThis, 'setTimeout')

const callback = vi.fn()
systemSetTimeoutZero(callback)

expect(spy).toHaveBeenCalledWith(callback, 0)
clearTimeout(spy.mock.results[0]?.value)
})
})
})
})
Loading
Loading