Skip to content

Commit a0b96db

Browse files
justjakeTkDodo
andauthored
feat(query-core): add timeoutManager to allow changing setTimeout/setInterval (#9612)
* add timeoutManager class * add additional types & export functions * convert all setTimeout/setInterval to managed versions * tweaks * add claude-generated tests * tests * revert changes in query-async-storage-persister: no path to import query-core * re-export more types * console.warn -> non-production console.error * query-async-storage-persister: use query-core managedSetTimeout * pdate pnpm-lock for new dependency edge * sleep: always managedSetTimeout * remove managed* functions, call method directly * remove runtime coercion and accept unsafe any within TimeoutManager class * cleanup; fix test after changes * name is __TEST_ONLY__ * notifyManager: default scheduler === systemSetTimeoutZero * Improve TimeoutCallback comment since ai was confused * remove unnecessary timeoutManager-related exports * prettier-ify index.ts (seems my editor messed with it already this pr?) * continue to export defaultTimeoutProvider for tests * oops missing import * fix: export systemSetTimeoutZero from core * ref: use notifyManager.schedule in createPersister because it needs to work with whatever scheduleFn we have set there * ref: move provider check behind env check * docs * doc tweaks * doc tweaks * docs: reference timeoutManager in discussion of 24 day setTimout limit * Apply suggestion from @TkDodo * Apply suggestion from @TkDodo * chore: fix broken links * docs: syntax fix --------- Co-authored-by: Dominik Dorfmeister <office@dorfmeister.cc>
1 parent 2283633 commit a0b96db

File tree

18 files changed

+471
-51
lines changed

18 files changed

+471
-51
lines changed

docs/config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -757,6 +757,10 @@
757757
{
758758
"label": "notifyManager",
759759
"to": "reference/notifyManager"
760+
},
761+
{
762+
"label": "timeoutManager",
763+
"to": "reference/timeoutManager"
760764
}
761765
],
762766
"frameworks": [

docs/framework/react/plugins/persistQueryClient.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ It should be set as the same value or higher than persistQueryClient's `maxAge`
2121

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

24-
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)).
24+
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).
2525

2626
```tsx
2727
const queryClient = new QueryClient({

docs/framework/react/reference/useMutation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ mutate(variables, {
5555
- `gcTime: number | Infinity`
5656
- 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.
5757
- If set to `Infinity`, will disable garbage collection
58-
- Note: the maximum allowed time is about 24 days. See [more](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value).
58+
- 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).
5959
- `mutationKey: unknown[]`
6060
- Optional
6161
- A mutation key can be set to inherit defaults set with `queryClient.setMutationDefaults`.

docs/framework/react/reference/useQuery.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ const {
101101
- `gcTime: number | Infinity`
102102
- Defaults to `5 * 60 * 1000` (5 minutes) or `Infinity` during SSR
103103
- 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.
104-
- Note: the maximum allowed time is about 24 days. See [more](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value).
104+
- 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).
105105
- If set to `Infinity`, will disable garbage collection
106106
- `queryKeyHashFn: (queryKey: QueryKey) => string`
107107
- Optional

docs/framework/solid/reference/useQuery.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,9 @@ function App() {
223223
- ##### `gcTime: number | Infinity`
224224
- Defaults to `5 * 60 * 1000` (5 minutes) or `Infinity` during SSR
225225
- 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.
226-
- Note: the maximum allowed time is about 24 days. See [more](https://developer.mozilla.org/en-US/docs/Web/API/setTimeout#maximum_delay_value).
226+
- 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).
227227
- If set to `Infinity`, will disable garbage collection
228-
- ##### `networkMode: 'online' | 'always' | 'offlineFirst`
228+
- ##### `networkMode: 'online' | 'always' | 'offlineFirst'`
229229
- optional
230230
- defaults to `'online'`
231231
- see [Network Mode](../../guides/network-mode.md) for more information.

docs/reference/timeoutManager.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
---
2+
id: TimeoutManager
3+
title: TimeoutManager
4+
---
5+
6+
The `TimeoutManager` handles `setTimeout` and `setInterval` timers in TanStack Query.
7+
8+
TanStack Query uses timers to implement features like query `staleTime` and `gcTime`, as well as retries, throttling, and debouncing.
9+
10+
By default, TimeoutManager uses the global `setTimeout` and `setInterval`, but it can be configured to use custom implementations instead.
11+
12+
Its available methods are:
13+
14+
- [`timeoutManager.setTimeoutProvider`](#timeoutmanagersettimeoutprovider)
15+
- [`TimeoutProvider`](#timeoutprovider)
16+
- [`timeoutManager.setTimeout`](#timeoutmanagersettimeout)
17+
- [`timeoutManager.clearTimeout`](#timeoutmanagercleartimeout)
18+
- [`timeoutManager.setInterval`](#timeoutmanagersetinterval)
19+
- [`timeoutManager.clearInterval`](#timeoutmanagerclearinterval)
20+
21+
## `timeoutManager.setTimeoutProvider`
22+
23+
`setTimeoutProvider` can be used to set a custom implementation of the `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval` functions, called a `TimeoutProvider`.
24+
25+
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).
26+
27+
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.
28+
29+
```tsx
30+
import { timeoutManager, QueryClient } from '@tanstack/react-query'
31+
import { CustomTimeoutProvider } from './CustomTimeoutProvider'
32+
33+
timeoutManager.setTimeoutProvider(new CustomTimeoutProvider())
34+
35+
export const queryClient = new QueryClient()
36+
```
37+
38+
### `TimeoutProvider`
39+
40+
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).
41+
42+
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 }`.
43+
44+
[nodejs-timeout]: https://nodejs.org/api/timers.html#class-timeout
45+
[toPrimitive]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toPrimitive
46+
47+
```tsx
48+
type ManagedTimerId = number | { [Symbol.toPrimitive]: () => number }
49+
50+
type TimeoutProvider<TTimerId extends ManagedTimerId = ManagedTimerId> = {
51+
readonly setTimeout: (callback: TimeoutCallback, delay: number) => TTimerId
52+
readonly clearTimeout: (timeoutId: TTimerId | undefined) => void
53+
54+
readonly setInterval: (callback: TimeoutCallback, delay: number) => TTimerId
55+
readonly clearInterval: (intervalId: TTimerId | undefined) => void
56+
}
57+
```
58+
59+
## `timeoutManager.setTimeout`
60+
61+
`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`.
62+
63+
It returns a timer ID, which may be a number or an object that can be coerced to a number via [Symbol.toPrimitive][toPrimitive].
64+
65+
```tsx
66+
import { timeoutManager } from '@tanstack/react-query'
67+
68+
const timeoutId = timeoutManager.setTimeout(
69+
() => console.log('ran at:', new Date()),
70+
1000,
71+
)
72+
73+
const timeoutIdNumber: number = Number(timeoutId)
74+
```
75+
76+
## `timeoutManager.clearTimeout`
77+
78+
`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`.
79+
80+
```tsx
81+
import { timeoutManager } from '@tanstack/react-query'
82+
83+
const timeoutId = timeoutManager.setTimeout(
84+
() => console.log('ran at:', new Date()),
85+
1000,
86+
)
87+
88+
timeoutManager.clearTimeout(timeoutId)
89+
```
90+
91+
## `timeoutManager.setInterval`
92+
93+
`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).
94+
95+
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).
96+
97+
```tsx
98+
import { timeoutManager } from '@tanstack/react-query'
99+
100+
const intervalId = timeoutManager.setInterval(
101+
() => console.log('ran at:', new Date()),
102+
1000,
103+
)
104+
```
105+
106+
## `timeoutManager.clearInterval`
107+
108+
`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`.
109+
110+
```tsx
111+
import { timeoutManager } from '@tanstack/react-query'
112+
113+
const intervalId = timeoutManager.setInterval(
114+
() => console.log('ran at:', new Date()),
115+
1000,
116+
)
117+
118+
timeoutManager.clearInterval(intervalId)
119+
```

packages/query-async-storage-persister/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"!src/__tests__"
6060
],
6161
"dependencies": {
62+
"@tanstack/query-core": "workspace:*",
6263
"@tanstack/query-persist-client-core": "workspace:*"
6364
},
6465
"devDependencies": {

packages/query-async-storage-persister/src/asyncThrottle.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { timeoutManager } from '@tanstack/query-core'
12
import { noop } from './utils'
23

34
interface AsyncThrottleOptions {
@@ -21,11 +22,11 @@ export function asyncThrottle<TArgs extends ReadonlyArray<unknown>>(
2122
if (isScheduled) return
2223
isScheduled = true
2324
while (isExecuting) {
24-
await new Promise((done) => setTimeout(done, interval))
25+
await new Promise((done) => timeoutManager.setTimeout(done, interval))
2526
}
2627
while (Date.now() < nextExecutionTime) {
2728
await new Promise((done) =>
28-
setTimeout(done, nextExecutionTime - Date.now()),
29+
timeoutManager.setTimeout(done, nextExecutionTime - Date.now()),
2930
)
3031
}
3132
isScheduled = false
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import {
3+
TimeoutManager,
4+
defaultTimeoutProvider,
5+
systemSetTimeoutZero,
6+
timeoutManager,
7+
} from '../timeoutManager'
8+
9+
describe('timeoutManager', () => {
10+
function createMockProvider(name: string = 'custom') {
11+
return {
12+
__TEST_ONLY__name: name,
13+
setTimeout: vi.fn(() => 123),
14+
clearTimeout: vi.fn(),
15+
setInterval: vi.fn(() => 456),
16+
clearInterval: vi.fn(),
17+
}
18+
}
19+
20+
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
21+
22+
beforeEach(() => {
23+
consoleErrorSpy = vi.spyOn(console, 'error')
24+
})
25+
26+
afterEach(() => {
27+
vi.restoreAllMocks()
28+
})
29+
30+
describe('TimeoutManager', () => {
31+
let manager: TimeoutManager
32+
33+
beforeEach(() => {
34+
manager = new TimeoutManager()
35+
})
36+
37+
it('by default proxies calls to globalThis setTimeout/clearTimeout', () => {
38+
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout')
39+
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout')
40+
const setIntervalSpy = vi.spyOn(globalThis, 'setInterval')
41+
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval')
42+
43+
const callback = vi.fn()
44+
const timeoutId = manager.setTimeout(callback, 100)
45+
expect(setTimeoutSpy).toHaveBeenCalledWith(callback, 100)
46+
clearTimeout(Number(timeoutId))
47+
48+
manager.clearTimeout(200)
49+
expect(clearTimeoutSpy).toHaveBeenCalledWith(200)
50+
51+
const intervalId = manager.setInterval(callback, 300)
52+
expect(setIntervalSpy).toHaveBeenCalledWith(callback, 300)
53+
clearInterval(Number(intervalId))
54+
55+
manager.clearInterval(400)
56+
expect(clearIntervalSpy).toHaveBeenCalledWith(400)
57+
})
58+
59+
describe('setTimeoutProvider', () => {
60+
it('proxies calls to the configured timeout provider', () => {
61+
const customProvider = createMockProvider()
62+
manager.setTimeoutProvider(customProvider)
63+
64+
const callback = vi.fn()
65+
66+
manager.setTimeout(callback, 100)
67+
expect(customProvider.setTimeout).toHaveBeenCalledWith(callback, 100)
68+
69+
manager.clearTimeout(999)
70+
expect(customProvider.clearTimeout).toHaveBeenCalledWith(999)
71+
72+
manager.setInterval(callback, 200)
73+
expect(customProvider.setInterval).toHaveBeenCalledWith(callback, 200)
74+
75+
manager.clearInterval(888)
76+
expect(customProvider.clearInterval).toHaveBeenCalledWith(888)
77+
})
78+
79+
it('warns when switching providers after making call', () => {
80+
// 1. switching before making any calls does not warn
81+
const customProvider = createMockProvider()
82+
manager.setTimeoutProvider(customProvider)
83+
expect(consoleErrorSpy).not.toHaveBeenCalled()
84+
85+
// Make a call. The next switch should warn
86+
manager.setTimeout(vi.fn(), 100)
87+
88+
// 2. switching after making a call should warn
89+
const customProvider2 = createMockProvider('custom2')
90+
manager.setTimeoutProvider(customProvider2)
91+
expect(consoleErrorSpy).toHaveBeenCalledWith(
92+
expect.stringMatching(
93+
/\[timeoutManager\]: Switching .* might result in unexpected behavior\..*/,
94+
),
95+
expect.anything(),
96+
)
97+
98+
// 3. Switching again with no intermediate calls should not warn
99+
vi.mocked(consoleErrorSpy).mockClear()
100+
const customProvider3 = createMockProvider('custom3')
101+
manager.setTimeoutProvider(customProvider3)
102+
expect(consoleErrorSpy).not.toHaveBeenCalled()
103+
})
104+
})
105+
})
106+
107+
describe('globalThis timeoutManager instance', () => {
108+
it('should be an instance of TimeoutManager', () => {
109+
expect(timeoutManager).toBeInstanceOf(TimeoutManager)
110+
})
111+
})
112+
113+
describe('exported functions', () => {
114+
let provider: ReturnType<typeof createMockProvider>
115+
beforeEach(() => {
116+
provider = createMockProvider()
117+
timeoutManager.setTimeoutProvider(provider)
118+
})
119+
afterEach(() => {
120+
timeoutManager.setTimeoutProvider(defaultTimeoutProvider)
121+
})
122+
123+
describe('systemSetTimeoutZero', () => {
124+
it('should use globalThis setTimeout with 0 delay', () => {
125+
const spy = vi.spyOn(globalThis, 'setTimeout')
126+
127+
const callback = vi.fn()
128+
systemSetTimeoutZero(callback)
129+
130+
expect(spy).toHaveBeenCalledWith(callback, 0)
131+
clearTimeout(spy.mock.results[0]?.value)
132+
})
133+
})
134+
})
135+
})

0 commit comments

Comments
 (0)