-
-
Notifications
You must be signed in to change notification settings - Fork 3.5k
feat(query-core): add timeoutManager to allow changing setTimeout/setInterval #9612
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 33 commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
8fc4d3d
add timeoutManager class
justjake 2fb1f1f
add additional types & export functions
justjake acb595c
convert all setTimeout/setInterval to managed versions
justjake 96813b6
tweaks
justjake 530504e
add claude-generated tests
justjake 223371b
tests
justjake d3d7d1a
revert changes in query-async-storage-persister: no path to import qu…
justjake a953c70
re-export more types
justjake 0fac7b2
console.warn -> non-production console.error
justjake 47962fd
query-async-storage-persister: use query-core managedSetTimeout
justjake 62b1dd0
pdate pnpm-lock for new dependency edge
justjake 8d5e050
sleep: always managedSetTimeout
justjake fea6cce
remove managed* functions, call method directly
justjake 1606b58
remove runtime coercion and accept unsafe any within TimeoutManager c…
justjake fc9092b
cleanup; fix test after changes
justjake 5d6fe4d
name is __TEST_ONLY__
justjake ad1fb2b
notifyManager: default scheduler === systemSetTimeoutZero
justjake 932c3a2
Improve TimeoutCallback comment since ai was confused
justjake a6d38f8
remove unnecessary timeoutManager-related exports
justjake 81d35ac
prettier-ify index.ts (seems my editor messed with it already this pr?)
justjake b6fffb4
continue to export defaultTimeoutProvider for tests
justjake 948c646
oops missing import
justjake 08a2c5f
Merge branch 'main' into jake--timeoutmanager
TkDodo 841ac54
fix: export systemSetTimeoutZero from core
TkDodo fb22c67
ref: use notifyManager.schedule in createPersister
TkDodo 09a787e
ref: move provider check behind env check
TkDodo 7fb57b1
docs
justjake 4b1e8af
doc tweaks
justjake b6ca80d
doc tweaks
justjake 73014f5
docs: reference timeoutManager in discussion of 24 day setTimout limit
justjake 3f452ea
Apply suggestion from @TkDodo
TkDodo cca861f
Apply suggestion from @TkDodo
TkDodo d58e28f
chore: fix broken links
TkDodo 7922966
docs: syntax fix
TkDodo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
``` | ||
TkDodo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
## `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) | ||
``` | ||
TkDodo marked this conversation as resolved.
Show resolved
Hide resolved
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
135 changes: 135 additions & 0 deletions
135
packages/query-core/src/__tests__/timeoutManager.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
TkDodo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
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) | ||
}) | ||
}) | ||
}) | ||
}) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.