From 8fc4d3d43a556eec2754da9100e2772551c2c36e Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Tue, 2 Sep 2025 12:19:00 -0400 Subject: [PATCH 01/33] add timeoutManager class --- packages/query-core/src/timeoutManager.ts | 81 +++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 packages/query-core/src/timeoutManager.ts diff --git a/packages/query-core/src/timeoutManager.ts b/packages/query-core/src/timeoutManager.ts new file mode 100644 index 0000000000..ab36fa2280 --- /dev/null +++ b/packages/query-core/src/timeoutManager.ts @@ -0,0 +1,81 @@ +/** + * Wrapping `setTimeout` is awkward from a typing perspective because platform + * typings may extend the return type of `setTimeout`. For example, NodeJS + * typings add `NodeJS.Timeout`; but a non-default `timeoutManager` may not be + * able to return such a type. + * + * Still, we can downlevel `NodeJS.Timeout` to `number` as it implements + * Symbol.toPrimitive. + */ +export type TimeoutProviderId = number | { [Symbol.toPrimitive]: () => number } + +export type TimeoutProvider = { + setTimeout: (callback: () => void, delay: number) => TimeoutProviderId + clearTimeout: (timeoutId: number | undefined) => void +} + +const defaultTimeoutProvider: TimeoutProvider = { + setTimeout: (callback, delay) => setTimeout(callback, delay), + clearTimeout: (timeoutId) => clearTimeout(timeoutId), +} + +/** + * Allows customization of how timeouts are created. + * + * @tanstack/query-core makes liberal use of timeouts to implement `staleTime` + * and `gcTime`. The default TimeoutManager provider uses the platform's global + * `setTimeout` implementation, which is known to have scalability issues with + * thousands of timeouts on the event loop. + * + * If you hit this limitation, consider providing a custom TimeoutProvider that + * coalesces timeouts. + */ +export class TimeoutManager implements TimeoutProvider { + #provider: TimeoutProvider = defaultTimeoutProvider + #setTimeoutCalls = 0 + + setTimeoutProvider(provider: TimeoutProvider): void { + if (this.#setTimeoutCalls > 0) { + // After changing providers, `clearTimeout` will not work as expected for + // timeouts from the previous provider. + // + // Since they may allocate the same timeout ID, clearTimeout may cancel an + // arbitrary different timeout, or unexpected no-op. + // + // We could protect against this by mixing the timeout ID bits + // deterministically with some per-provider bits. + // + // We could internally queue `setTimeout` calls to `TimeoutManager` until + // some API call to set the initial provider. + console.warn( + '[timeoutManager]: Provider changed after setTimeout calls were made. This might result in unexpected behavior.', + ) + } + + this.#provider = provider + } + + setTimeout(callback: () => void, delay: number): number { + this.#setTimeoutCalls++ + return Number(this.#provider.setTimeout(callback, delay)) + } + + clearTimeout(timeoutId: number | undefined): void { + this.#provider.clearTimeout(timeoutId) + } +} + +export const timeoutManager = new TimeoutManager() + +// Exporting functions that use `setTimeout` to reduce bundle size impact, since +// method names on objects are usually not minified. + +/** A version of `setTimeout` that uses {@link timeoutManager} to set the timeout. */ +export function managedSetTimeout(callback: () => void, delay: number): number { + return timeoutManager.setTimeout(callback, delay) +} + +/** A version of `clearTimeout` that uses {@link timeoutManager} to set the timeout. */ +export function managedClearTimeout(timeoutId: number | undefined): void { + timeoutManager.clearTimeout(timeoutId) +} From 2fb1f1f29d6d51f205abc981a75a5dd247178486 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Tue, 2 Sep 2025 12:49:49 -0400 Subject: [PATCH 02/33] add additional types & export functions --- packages/query-core/src/index.ts | 9 ++ packages/query-core/src/timeoutManager.ts | 105 +++++++++++++++++++--- 2 files changed, 102 insertions(+), 12 deletions(-) diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index 889c8b02b7..4557caf11f 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -11,6 +11,15 @@ export { MutationCache } from './mutationCache' export type { MutationCacheNotifyEvent } from './mutationCache' export { MutationObserver } from './mutationObserver' export { notifyManager, defaultScheduler } from './notifyManager' +export { + managedSetTimeout, + managedClearTimeout, + managedSetInterval, + managedClearInterval, + systemSetTimeoutZero, + defaultTimeoutProvider, + type ManagedTimerId, +} from './timeoutManager' export { focusManager } from './focusManager' export { onlineManager } from './onlineManager' export { diff --git a/packages/query-core/src/timeoutManager.ts b/packages/query-core/src/timeoutManager.ts index ab36fa2280..f36b0b08f3 100644 --- a/packages/query-core/src/timeoutManager.ts +++ b/packages/query-core/src/timeoutManager.ts @@ -8,17 +8,38 @@ * Symbol.toPrimitive. */ export type TimeoutProviderId = number | { [Symbol.toPrimitive]: () => number } +export type TimeoutCallback = (_: void) => void export type TimeoutProvider = { - setTimeout: (callback: () => void, delay: number) => TimeoutProviderId - clearTimeout: (timeoutId: number | undefined) => void + /** Used in error messages. */ + readonly name: string + + readonly setTimeout: ( + callback: TimeoutCallback, + delay: number, + ) => TimeoutProviderId + readonly clearTimeout: (timeoutId: number | undefined) => void + + readonly setInterval: ( + callback: TimeoutCallback, + delay: number, + ) => TimeoutProviderId + readonly clearInterval: (intervalId: number | undefined) => void } -const defaultTimeoutProvider: TimeoutProvider = { +export const defaultTimeoutProvider: TimeoutProvider = { + name: 'default', + setTimeout: (callback, delay) => setTimeout(callback, delay), clearTimeout: (timeoutId) => clearTimeout(timeoutId), + + setInterval: (callback, delay) => setInterval(callback, delay), + clearInterval: (intervalId) => clearInterval(intervalId), } +/** Timeout ID returned by {@link TimeoutManager} */ +export type ManagedTimerId = number + /** * Allows customization of how timeouts are created. * @@ -31,11 +52,13 @@ const defaultTimeoutProvider: TimeoutProvider = { * coalesces timeouts. */ export class TimeoutManager implements TimeoutProvider { + public readonly name = 'TimeoutManager' + #provider: TimeoutProvider = defaultTimeoutProvider #setTimeoutCalls = 0 setTimeoutProvider(provider: TimeoutProvider): void { - if (this.#setTimeoutCalls > 0) { + if (this.#setTimeoutCalls > 0 && provider !== this.#provider) { // After changing providers, `clearTimeout` will not work as expected for // timeouts from the previous provider. // @@ -48,21 +71,48 @@ export class TimeoutManager implements TimeoutProvider { // We could internally queue `setTimeout` calls to `TimeoutManager` until // some API call to set the initial provider. console.warn( - '[timeoutManager]: Provider changed after setTimeout calls were made. This might result in unexpected behavior.', + `[timeoutManager]: Switching to ${provider.name} provider after setTimeout calls were made with ${this.#provider.name} provider might result in unexpected behavior.`, ) } this.#provider = provider } - setTimeout(callback: () => void, delay: number): number { + setTimeout(callback: TimeoutCallback, delay: number): ManagedTimerId { this.#setTimeoutCalls++ - return Number(this.#provider.setTimeout(callback, delay)) + return providerIdToNumber( + this.#provider, + this.#provider.setTimeout(callback, delay), + ) } - clearTimeout(timeoutId: number | undefined): void { + clearTimeout(timeoutId: ManagedTimerId | undefined): void { this.#provider.clearTimeout(timeoutId) } + + setInterval(callback: TimeoutCallback, delay: number): ManagedTimerId { + return providerIdToNumber( + this.#provider, + this.#provider.setInterval(callback, delay), + ) + } + + clearInterval(intervalId: ManagedTimerId | undefined): void { + this.#provider.clearInterval(intervalId) + } +} + +function providerIdToNumber( + provider: TimeoutProvider, + providerId: TimeoutProviderId, +): ManagedTimerId { + const numberId = Number(providerId) + if (isNaN(numberId)) { + throw new Error( + `TimeoutManager: could not convert ${provider.name} provider timeout ID to valid number`, + ) + } + return numberId } export const timeoutManager = new TimeoutManager() @@ -70,12 +120,43 @@ export const timeoutManager = new TimeoutManager() // Exporting functions that use `setTimeout` to reduce bundle size impact, since // method names on objects are usually not minified. -/** A version of `setTimeout` that uses {@link timeoutManager} to set the timeout. */ -export function managedSetTimeout(callback: () => void, delay: number): number { +/** A version of `setTimeout` controlled by {@link timeoutManager}. */ +export function managedSetTimeout( + callback: TimeoutCallback, + delay: number, +): ManagedTimerId { return timeoutManager.setTimeout(callback, delay) } -/** A version of `clearTimeout` that uses {@link timeoutManager} to set the timeout. */ -export function managedClearTimeout(timeoutId: number | undefined): void { +/** A version of `clearTimeout` controlled by {@link timeoutManager}. */ +export function managedClearTimeout( + timeoutId: ManagedTimerId | undefined, +): void { timeoutManager.clearTimeout(timeoutId) } + +/** A version of `setInterval` controlled by {@link timeoutManager}. */ +export function managedSetInterval( + callback: TimeoutCallback, + delay: number, +): ManagedTimerId { + return timeoutManager.setInterval(callback, delay) +} + +/** A version of `clearInterval` controlled by {@link timeoutManager}. */ +export function managedClearInterval( + intervalId: ManagedTimerId | undefined, +): void { + timeoutManager.clearInterval(intervalId) +} + +/** + * In many cases code wants to delay to the next event loop tick; this is not + * mediated by {@link timeoutManager}. + * + * This function is provided to make auditing the `tanstack/query-core` for + * incorrect use of system `setTimeout` easier. + */ +export function systemSetTimeoutZero(callback: TimeoutCallback): void { + setTimeout(callback, 0) +} From acb595cf8aca54ff83ed5e6c7c3d56e9b02d01b9 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Tue, 2 Sep 2025 12:50:06 -0400 Subject: [PATCH 03/33] convert all setTimeout/setInterval to managed versions --- .../src/asyncThrottle.ts | 5 +++-- packages/query-core/src/notifyManager.ts | 5 ++++- packages/query-core/src/queryObserver.ts | 19 +++++++++++++------ packages/query-core/src/removable.ts | 8 +++++--- packages/query-core/src/utils.ts | 5 ++++- .../src/createPersister.ts | 13 +++++++++---- .../query-sync-storage-persister/src/index.ts | 6 ++++-- 7 files changed, 42 insertions(+), 19 deletions(-) diff --git a/packages/query-async-storage-persister/src/asyncThrottle.ts b/packages/query-async-storage-persister/src/asyncThrottle.ts index f37737c8b5..30b7e7d62f 100644 --- a/packages/query-async-storage-persister/src/asyncThrottle.ts +++ b/packages/query-async-storage-persister/src/asyncThrottle.ts @@ -1,3 +1,4 @@ +import { managedSetTimeout } from '../../query-core/src/timeoutManager' import { noop } from './utils' interface AsyncThrottleOptions { @@ -21,11 +22,11 @@ export function asyncThrottle>( if (isScheduled) return isScheduled = true while (isExecuting) { - await new Promise((done) => setTimeout(done, interval)) + await new Promise((done) => managedSetTimeout(done, interval)) } while (Date.now() < nextExecutionTime) { await new Promise((done) => - setTimeout(done, nextExecutionTime - Date.now()), + managedSetTimeout(done, nextExecutionTime - Date.now()), ) } isScheduled = false diff --git a/packages/query-core/src/notifyManager.ts b/packages/query-core/src/notifyManager.ts index 63187ed52a..a517e9ed88 100644 --- a/packages/query-core/src/notifyManager.ts +++ b/packages/query-core/src/notifyManager.ts @@ -1,5 +1,7 @@ // TYPES +import { systemSetTimeoutZero } from './timeoutManager' + type NotifyCallback = () => void type NotifyFunction = (callback: () => void) => void @@ -10,7 +12,8 @@ type BatchCallsCallback> = (...args: T) => void type ScheduleFunction = (callback: () => void) => void -export const defaultScheduler: ScheduleFunction = (cb) => setTimeout(cb, 0) +export const defaultScheduler: ScheduleFunction = (cb) => + systemSetTimeoutZero(cb) export function createNotifyManager() { let queue: Array = [] diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 1137503694..91d7fff2ff 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -13,6 +13,13 @@ import { shallowEqualObjects, timeUntilStale, } from './utils' +import { + managedClearInterval, + managedClearTimeout, + managedSetInterval, + managedSetTimeout, +} from './timeoutManager' +import type { ManagedTimerId } from './timeoutManager' import type { FetchOptions, Query, QueryState } from './query' import type { QueryClient } from './queryClient' import type { PendingThenable, Thenable } from './thenable' @@ -62,8 +69,8 @@ export class QueryObserver< // This property keeps track of the last query with defined data. // It will be used to pass the previous data and query to the placeholder function between renders. #lastQueryWithDefinedData?: Query - #staleTimeoutId?: ReturnType - #refetchIntervalId?: ReturnType + #staleTimeoutId?: ManagedTimerId + #refetchIntervalId?: ManagedTimerId #currentRefetchInterval?: number | false #trackedProps = new Set() @@ -365,7 +372,7 @@ export class QueryObserver< // To mitigate this issue we always add 1 ms to the timeout. const timeout = time + 1 - this.#staleTimeoutId = setTimeout(() => { + this.#staleTimeoutId = managedSetTimeout(() => { if (!this.#currentResult.isStale) { this.updateResult() } @@ -394,7 +401,7 @@ export class QueryObserver< return } - this.#refetchIntervalId = setInterval(() => { + this.#refetchIntervalId = managedSetInterval(() => { if ( this.options.refetchIntervalInBackground || focusManager.isFocused() @@ -411,14 +418,14 @@ export class QueryObserver< #clearStaleTimeout(): void { if (this.#staleTimeoutId) { - clearTimeout(this.#staleTimeoutId) + managedClearTimeout(this.#staleTimeoutId) this.#staleTimeoutId = undefined } } #clearRefetchInterval(): void { if (this.#refetchIntervalId) { - clearInterval(this.#refetchIntervalId) + managedClearInterval(this.#refetchIntervalId) this.#refetchIntervalId = undefined } } diff --git a/packages/query-core/src/removable.ts b/packages/query-core/src/removable.ts index bf353266ca..be5ce82806 100644 --- a/packages/query-core/src/removable.ts +++ b/packages/query-core/src/removable.ts @@ -1,8 +1,10 @@ +import { managedClearTimeout, managedSetTimeout } from './timeoutManager' import { isServer, isValidTimeout } from './utils' +import type { ManagedTimerId } from './timeoutManager' export abstract class Removable { gcTime!: number - #gcTimeout?: ReturnType + #gcTimeout?: ManagedTimerId destroy(): void { this.clearGcTimeout() @@ -12,7 +14,7 @@ export abstract class Removable { this.clearGcTimeout() if (isValidTimeout(this.gcTime)) { - this.#gcTimeout = setTimeout(() => { + this.#gcTimeout = managedSetTimeout(() => { this.optionalRemove() }, this.gcTime) } @@ -28,7 +30,7 @@ export abstract class Removable { protected clearGcTimeout() { if (this.#gcTimeout) { - clearTimeout(this.#gcTimeout) + managedClearTimeout(this.#gcTimeout) this.#gcTimeout = undefined } } diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index fc3a47a210..419c1d5d1e 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,3 +1,4 @@ +import { managedSetTimeout, systemSetTimeoutZero } from './timeoutManager' import type { DefaultError, Enabled, @@ -361,7 +362,9 @@ function hasObjectPrototype(o: any): boolean { export function sleep(timeout: number): Promise { return new Promise((resolve) => { - setTimeout(resolve, timeout) + const setTimeoutFn = + timeout === 0 ? systemSetTimeoutZero : managedSetTimeout + setTimeoutFn(resolve, timeout) }) } diff --git a/packages/query-persist-client-core/src/createPersister.ts b/packages/query-persist-client-core/src/createPersister.ts index 63e835dec6..ab45e12fb8 100644 --- a/packages/query-persist-client-core/src/createPersister.ts +++ b/packages/query-persist-client-core/src/createPersister.ts @@ -1,4 +1,9 @@ -import { hashKey, matchQuery, partialMatchKey } from '@tanstack/query-core' +import { + hashKey, + matchQuery, + partialMatchKey, + systemSetTimeoutZero, +} from '@tanstack/query-core' import type { Query, QueryClient, @@ -125,7 +130,7 @@ export function experimental_createQueryPersister({ } else { if (afterRestoreMacroTask) { // Just after restoring we want to get fresh data from the server if it's stale - setTimeout(() => afterRestoreMacroTask(persistedQuery), 0) + systemSetTimeoutZero(() => afterRestoreMacroTask(persistedQuery)) } // We must resolve the promise here, as otherwise we will have `loading` state in the app until `queryFn` resolves return persistedQuery.state.data as T @@ -213,9 +218,9 @@ export function experimental_createQueryPersister({ if (matchesFilter && storage != null) { // Persist if we have storage defined, we use timeout to get proper state to be persisted - setTimeout(() => { + systemSetTimeoutZero(() => { persistQuery(query) - }, 0) + }) } return Promise.resolve(queryFnResult) diff --git a/packages/query-sync-storage-persister/src/index.ts b/packages/query-sync-storage-persister/src/index.ts index ee25ca7376..5a3505a68b 100644 --- a/packages/query-sync-storage-persister/src/index.ts +++ b/packages/query-sync-storage-persister/src/index.ts @@ -1,4 +1,6 @@ +import { managedSetTimeout } from '@tanstack/query-core' import { noop } from './utils' +import type { ManagedTimerId } from '@tanstack/query-core' import type { PersistRetryer, PersistedClient, @@ -100,12 +102,12 @@ function throttle>( func: (...args: TArgs) => any, wait = 100, ) { - let timer: ReturnType | null = null + let timer: ManagedTimerId | null = null let params: TArgs return function (...args: TArgs) { params = args if (timer === null) { - timer = setTimeout(() => { + timer = managedSetTimeout(() => { func(...params) timer = null }, wait) From 96813b625b7fa9e029807d88920b585d2f4a6e22 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Tue, 2 Sep 2025 13:03:03 -0400 Subject: [PATCH 04/33] tweaks --- packages/query-core/src/timeoutManager.ts | 28 ++++++++++++++++------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/query-core/src/timeoutManager.ts b/packages/query-core/src/timeoutManager.ts index f36b0b08f3..e545d957c8 100644 --- a/packages/query-core/src/timeoutManager.ts +++ b/packages/query-core/src/timeoutManager.ts @@ -1,3 +1,9 @@ +/** + * Timeout manager does not support passing arguments to the callback. + * (`void` is the argument type inferred by TypeScript's default typings for `setTimeout(cb, number)`) + */ +export type TimeoutCallback = (_: void) => void + /** * Wrapping `setTimeout` is awkward from a typing perspective because platform * typings may extend the return type of `setTimeout`. For example, NodeJS @@ -8,8 +14,10 @@ * Symbol.toPrimitive. */ export type TimeoutProviderId = number | { [Symbol.toPrimitive]: () => number } -export type TimeoutCallback = (_: void) => void +/** + * Backend for timer functions. + */ export type TimeoutProvider = { /** Used in error messages. */ readonly name: string @@ -51,14 +59,16 @@ export type ManagedTimerId = number * If you hit this limitation, consider providing a custom TimeoutProvider that * coalesces timeouts. */ -export class TimeoutManager implements TimeoutProvider { - public readonly name = 'TimeoutManager' - +export class TimeoutManager implements Omit { #provider: TimeoutProvider = defaultTimeoutProvider - #setTimeoutCalls = 0 + #providerCalled = false setTimeoutProvider(provider: TimeoutProvider): void { - if (this.#setTimeoutCalls > 0 && provider !== this.#provider) { + if (provider === this.#provider) { + return + } + + if (this.#providerCalled) { // After changing providers, `clearTimeout` will not work as expected for // timeouts from the previous provider. // @@ -71,15 +81,16 @@ export class TimeoutManager implements TimeoutProvider { // We could internally queue `setTimeout` calls to `TimeoutManager` until // some API call to set the initial provider. console.warn( - `[timeoutManager]: Switching to ${provider.name} provider after setTimeout calls were made with ${this.#provider.name} provider might result in unexpected behavior.`, + `[timeoutManager]: Switching to ${provider.name} provider after calls to ${this.#provider.name} provider might result in unexpected behavior.`, ) } this.#provider = provider + this.#providerCalled = false } setTimeout(callback: TimeoutCallback, delay: number): ManagedTimerId { - this.#setTimeoutCalls++ + this.#providerCalled = true return providerIdToNumber( this.#provider, this.#provider.setTimeout(callback, delay), @@ -91,6 +102,7 @@ export class TimeoutManager implements TimeoutProvider { } setInterval(callback: TimeoutCallback, delay: number): ManagedTimerId { + this.#providerCalled = true return providerIdToNumber( this.#provider, this.#provider.setInterval(callback, delay), From 530504edee00677001dce4eca76e905810c56e5c Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Tue, 2 Sep 2025 13:12:36 -0400 Subject: [PATCH 05/33] add claude-generated tests --- .../src/__tests__/timeoutManager.test.tsx | 710 ++++++++++++++++++ 1 file changed, 710 insertions(+) create mode 100644 packages/query-core/src/__tests__/timeoutManager.test.tsx diff --git a/packages/query-core/src/__tests__/timeoutManager.test.tsx b/packages/query-core/src/__tests__/timeoutManager.test.tsx new file mode 100644 index 0000000000..6e240a30a8 --- /dev/null +++ b/packages/query-core/src/__tests__/timeoutManager.test.tsx @@ -0,0 +1,710 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { + TimeoutManager, + TimeoutProvider, + defaultTimeoutProvider, + timeoutManager, + managedSetTimeout, + managedClearTimeout, + managedSetInterval, + managedClearInterval, + systemSetTimeoutZero, + type TimeoutProviderId, + type TimeoutCallback, + type ManagedTimerId, +} from '../timeoutManager' + +describe('timeoutManager', () => { + let originalConsoleWarn: typeof console.warn + + beforeEach(() => { + originalConsoleWarn = console.warn + console.warn = vi.fn() + }) + + afterEach(() => { + console.warn = originalConsoleWarn + vi.restoreAllMocks() + }) + + describe('types', () => { + it('should have correct type definitions', () => { + const callback: TimeoutCallback = () => {} + const timeoutId: TimeoutProviderId = 123 + const nodeTimeoutId = { [Symbol.toPrimitive]: () => 456 } + const managedId: ManagedTimerId = 789 + + expect(typeof callback).toBe('function') + expect(typeof timeoutId).toBe('number') + expect(typeof nodeTimeoutId[Symbol.toPrimitive]()).toBe('number') + expect(typeof managedId).toBe('number') + }) + }) + + describe('defaultTimeoutProvider', () => { + it('should have correct name', () => { + expect(defaultTimeoutProvider.name).toBe('default') + }) + + it('should use global setTimeout', () => { + const callback = vi.fn() + const delay = 100 + + const timeoutId = defaultTimeoutProvider.setTimeout(callback, delay) + + // In Node.js, setTimeout can return a Timeout object or number + expect(timeoutId).toBeDefined() + expect(Number(timeoutId)).toBeGreaterThan(0) + + defaultTimeoutProvider.clearTimeout(Number(timeoutId)) + }) + + it('should use global setInterval', () => { + const callback = vi.fn() + const delay = 100 + + const intervalId = defaultTimeoutProvider.setInterval(callback, delay) + + // In Node.js, setInterval can return an Interval object or number + expect(intervalId).toBeDefined() + expect(Number(intervalId)).toBeGreaterThan(0) + + defaultTimeoutProvider.clearInterval(Number(intervalId)) + }) + + it('should handle clearTimeout with undefined', () => { + expect(() => defaultTimeoutProvider.clearTimeout(undefined)).not.toThrow() + }) + + it('should handle clearInterval with undefined', () => { + expect(() => defaultTimeoutProvider.clearInterval(undefined)).not.toThrow() + }) + }) + + describe('TimeoutManager', () => { + let manager: TimeoutManager + + beforeEach(() => { + manager = new TimeoutManager() + }) + + describe('constructor and properties', () => { + it('should start with default provider', () => { + const callback = vi.fn() + manager.setTimeout(callback, 0) + expect(console.warn).not.toHaveBeenCalled() + }) + }) + + describe('setTimeoutProvider', () => { + it('should set a new timeout provider', () => { + const customProvider: TimeoutProvider = { + name: 'custom', + setTimeout: vi.fn(() => 123), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 456), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + const callback = vi.fn() + manager.setTimeout(callback, 100) + + expect(customProvider.setTimeout).toHaveBeenCalledWith(callback, 100) + }) + + it('should warn when switching providers after provider calls', () => { + const callback = vi.fn() + + // Make a setTimeout call with default provider + manager.setTimeout(callback, 0) + + const customProvider: TimeoutProvider = { + name: 'custom', + setTimeout: vi.fn(() => 123), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 456), + clearInterval: vi.fn(), + } + + // Switch to custom provider + manager.setTimeoutProvider(customProvider) + + expect(console.warn).toHaveBeenCalledWith( + '[timeoutManager]: Switching to custom provider after calls to default provider might result in unexpected behavior.' + ) + }) + + it('should not warn when switching to the same provider', () => { + const callback = vi.fn() + + // Make a setTimeout call + manager.setTimeout(callback, 0) + + // Set the same provider again + manager.setTimeoutProvider(defaultTimeoutProvider) + + expect(console.warn).not.toHaveBeenCalled() + }) + + it('should not warn on first provider switch before provider calls', () => { + const customProvider: TimeoutProvider = { + name: 'custom', + setTimeout: vi.fn(() => 123), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 456), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + expect(console.warn).not.toHaveBeenCalled() + }) + + it('should return early when setting the same provider', () => { + const customProvider: TimeoutProvider = { + name: 'custom', + setTimeout: vi.fn(() => 123), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 456), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + manager.setTimeoutProvider(customProvider) // Set same provider again + + expect(console.warn).not.toHaveBeenCalled() + }) + + it('should reset providerCalled flag when switching providers', () => { + const callback = vi.fn() + + // Make a setTimeout call + manager.setTimeout(callback, 0) + + const customProvider: TimeoutProvider = { + name: 'custom', + setTimeout: vi.fn(() => 123), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 456), + clearInterval: vi.fn(), + } + + // Switch providers (will warn) + manager.setTimeoutProvider(customProvider) + expect(console.warn).toHaveBeenCalledTimes(1) + + const anotherProvider: TimeoutProvider = { + name: 'another', + setTimeout: vi.fn(() => 789), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 101), + clearInterval: vi.fn(), + } + + // Switch again without making any calls - should not warn since flag was reset + manager.setTimeoutProvider(anotherProvider) + expect(console.warn).toHaveBeenCalledTimes(1) + }) + }) + + describe('setTimeout', () => { + it('should call provider setTimeout and return number', () => { + const callback = vi.fn() + const delay = 100 + + const timeoutId = manager.setTimeout(callback, delay) + + expect(typeof timeoutId).toBe('number') + expect(timeoutId).toBeGreaterThan(0) + }) + + it('should set providerCalled flag on setTimeout', () => { + const callback = vi.fn() + + manager.setTimeout(callback, 0) + + const customProvider: TimeoutProvider = { + name: 'custom', + setTimeout: vi.fn(() => 123), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 456), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + expect(console.warn).toHaveBeenCalledWith( + '[timeoutManager]: Switching to custom provider after calls to default provider might result in unexpected behavior.' + ) + }) + + it('should set providerCalled flag on setInterval', () => { + const callback = vi.fn() + + manager.setInterval(callback, 100) + + const customProvider: TimeoutProvider = { + name: 'custom', + setTimeout: vi.fn(() => 123), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 456), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + expect(console.warn).toHaveBeenCalledWith( + '[timeoutManager]: Switching to custom provider after calls to default provider might result in unexpected behavior.' + ) + }) + + it('should handle provider returning object with Symbol.toPrimitive', () => { + const nodeTimeoutLike = { + [Symbol.toPrimitive]: () => 42 + } + + const customProvider: TimeoutProvider = { + name: 'custom', + setTimeout: vi.fn(() => nodeTimeoutLike), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 456), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + const callback = vi.fn() + const timeoutId = manager.setTimeout(callback, 100) + + expect(timeoutId).toBe(42) + }) + + it('should throw error when provider returns non-convertible value', () => { + const invalidValue = { invalid: true } as any + + const customProvider: TimeoutProvider = { + name: 'badProvider', + setTimeout: vi.fn(() => invalidValue), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 456), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + const callback = vi.fn() + + expect(() => manager.setTimeout(callback, 100)).toThrow( + 'TimeoutManager: could not convert badProvider provider timeout ID to valid number' + ) + }) + }) + + describe('clearTimeout', () => { + it('should call provider clearTimeout', () => { + const customProvider: TimeoutProvider = { + name: 'custom', + setTimeout: vi.fn(() => 123), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 456), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + const timeoutId = 42 + manager.clearTimeout(timeoutId) + + expect(customProvider.clearTimeout).toHaveBeenCalledWith(timeoutId) + }) + + it('should handle undefined timeoutId', () => { + const customProvider: TimeoutProvider = { + name: 'custom', + setTimeout: vi.fn(() => 123), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 456), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + expect(() => manager.clearTimeout(undefined)).not.toThrow() + expect(customProvider.clearTimeout).toHaveBeenCalledWith(undefined) + }) + }) + + describe('setInterval', () => { + it('should call provider setInterval and return number', () => { + const callback = vi.fn() + const delay = 100 + + const intervalId = manager.setInterval(callback, delay) + + expect(typeof intervalId).toBe('number') + expect(intervalId).toBeGreaterThan(0) + }) + + it('should handle provider returning object with Symbol.toPrimitive', () => { + const nodeIntervalLike = { + [Symbol.toPrimitive]: () => 99 + } + + const customProvider: TimeoutProvider = { + name: 'custom', + setTimeout: vi.fn(() => 123), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => nodeIntervalLike), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + const callback = vi.fn() + const intervalId = manager.setInterval(callback, 100) + + expect(intervalId).toBe(99) + }) + + it('should throw error when provider returns non-convertible value', () => { + const invalidValue = { invalid: true } as any + + const customProvider: TimeoutProvider = { + name: 'badProvider', + setTimeout: vi.fn(() => 123), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => invalidValue), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + const callback = vi.fn() + + expect(() => manager.setInterval(callback, 100)).toThrow( + 'TimeoutManager: could not convert badProvider provider timeout ID to valid number' + ) + }) + }) + + describe('clearInterval', () => { + it('should call provider clearInterval', () => { + const customProvider: TimeoutProvider = { + name: 'custom', + setTimeout: vi.fn(() => 123), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 456), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + const intervalId = 88 + manager.clearInterval(intervalId) + + expect(customProvider.clearInterval).toHaveBeenCalledWith(intervalId) + }) + + it('should handle undefined intervalId', () => { + const customProvider: TimeoutProvider = { + name: 'custom', + setTimeout: vi.fn(() => 123), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 456), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + expect(() => manager.clearInterval(undefined)).not.toThrow() + expect(customProvider.clearInterval).toHaveBeenCalledWith(undefined) + }) + }) + }) + + describe('global timeoutManager instance', () => { + it('should be an instance of TimeoutManager', () => { + expect(timeoutManager).toBeInstanceOf(TimeoutManager) + }) + }) + + describe('managed utility functions', () => { + describe('managedSetTimeout', () => { + it('should call timeoutManager.setTimeout', () => { + const spy = vi.spyOn(timeoutManager, 'setTimeout') + const callback = vi.fn() + const delay = 50 + + managedSetTimeout(callback, delay) + + expect(spy).toHaveBeenCalledWith(callback, delay) + + spy.mockRestore() + }) + + it('should return timeout ID', () => { + const callback = vi.fn() + const timeoutId = managedSetTimeout(callback, 0) + + expect(typeof timeoutId).toBe('number') + expect(timeoutId).toBeGreaterThan(0) + + managedClearTimeout(timeoutId) + }) + }) + + describe('managedClearTimeout', () => { + it('should call timeoutManager.clearTimeout', () => { + const spy = vi.spyOn(timeoutManager, 'clearTimeout') + const timeoutId = 123 + + managedClearTimeout(timeoutId) + + expect(spy).toHaveBeenCalledWith(timeoutId) + + spy.mockRestore() + }) + + it('should handle undefined timeoutId', () => { + const spy = vi.spyOn(timeoutManager, 'clearTimeout') + + expect(() => managedClearTimeout(undefined)).not.toThrow() + expect(spy).toHaveBeenCalledWith(undefined) + + spy.mockRestore() + }) + }) + + describe('managedSetInterval', () => { + it('should call timeoutManager.setInterval', () => { + const spy = vi.spyOn(timeoutManager, 'setInterval') + const callback = vi.fn() + const delay = 50 + + managedSetInterval(callback, delay) + + expect(spy).toHaveBeenCalledWith(callback, delay) + + spy.mockRestore() + }) + + it('should return interval ID', () => { + const callback = vi.fn() + const intervalId = managedSetInterval(callback, 100) + + expect(typeof intervalId).toBe('number') + expect(intervalId).toBeGreaterThan(0) + + managedClearInterval(intervalId) + }) + }) + + describe('managedClearInterval', () => { + it('should call timeoutManager.clearInterval', () => { + const spy = vi.spyOn(timeoutManager, 'clearInterval') + const intervalId = 456 + + managedClearInterval(intervalId) + + expect(spy).toHaveBeenCalledWith(intervalId) + + spy.mockRestore() + }) + + it('should handle undefined intervalId', () => { + const spy = vi.spyOn(timeoutManager, 'clearInterval') + + expect(() => managedClearInterval(undefined)).not.toThrow() + expect(spy).toHaveBeenCalledWith(undefined) + + spy.mockRestore() + }) + }) + }) + + describe('systemSetTimeoutZero', () => { + it('should use global setTimeout with 0 delay', () => { + const originalSetTimeout = global.setTimeout + const setTimeoutSpy = vi.fn() + global.setTimeout = setTimeoutSpy + + const callback = vi.fn() + systemSetTimeoutZero(callback) + + expect(setTimeoutSpy).toHaveBeenCalledWith(callback, 0) + + global.setTimeout = originalSetTimeout + }) + + it('should call callback on next tick', async () => { + let called = false + const callback = () => { + called = true + } + + systemSetTimeoutZero(callback) + expect(called).toBe(false) + + // Wait for next tick + await new Promise(resolve => setTimeout(resolve, 1)) + expect(called).toBe(true) + }) + }) + + describe('providerIdToNumber function (via public API)', () => { + it('should handle regular numbers', () => { + const manager = new TimeoutManager() + const callback = vi.fn() + + const customProvider: TimeoutProvider = { + name: 'test', + setTimeout: vi.fn(() => 42), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 99), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + expect(manager.setTimeout(callback, 0)).toBe(42) + expect(manager.setInterval(callback, 0)).toBe(99) + }) + + it('should handle objects with Symbol.toPrimitive', () => { + const manager = new TimeoutManager() + const callback = vi.fn() + + const timeoutObj = { [Symbol.toPrimitive]: () => 100 } + const intervalObj = { [Symbol.toPrimitive]: () => 200 } + + const customProvider: TimeoutProvider = { + name: 'test', + setTimeout: vi.fn(() => timeoutObj), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => intervalObj), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + expect(manager.setTimeout(callback, 0)).toBe(100) + expect(manager.setInterval(callback, 0)).toBe(200) + }) + + it('should throw error for non-convertible values', () => { + const manager = new TimeoutManager() + const callback = vi.fn() + + const customProvider: TimeoutProvider = { + name: 'errorProvider', + setTimeout: vi.fn(() => ({ invalid: true } as any)), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => ({ invalid: true } as any)), + clearInterval: vi.fn(), + } + + manager.setTimeoutProvider(customProvider) + + expect(() => manager.setTimeout(callback, 0)).toThrow( + 'TimeoutManager: could not convert errorProvider provider timeout ID to valid number' + ) + + expect(() => manager.setInterval(callback, 0)).toThrow( + 'TimeoutManager: could not convert errorProvider provider timeout ID to valid number' + ) + }) + }) + + describe('integration tests', () => { + it('should work with real timeouts', async () => { + let executed = false + const callback = () => { + executed = true + } + + const timeoutId = managedSetTimeout(callback, 10) + expect(typeof timeoutId).toBe('number') + expect(executed).toBe(false) + + await new Promise(resolve => setTimeout(resolve, 15)) + expect(executed).toBe(true) + }) + + it('should work with clearing timeouts', async () => { + let executed = false + const callback = () => { + executed = true + } + + const timeoutId = managedSetTimeout(callback, 10) + managedClearTimeout(timeoutId) + + await new Promise(resolve => setTimeout(resolve, 20)) + expect(executed).toBe(false) + }) + + it('should work with real intervals', async () => { + let count = 0 + const callback = () => { + count++ + } + + const intervalId = managedSetInterval(callback, 10) + expect(typeof intervalId).toBe('number') + + await new Promise(resolve => setTimeout(resolve, 25)) + managedClearInterval(intervalId) + + expect(count).toBeGreaterThanOrEqual(2) + }) + + it('should handle custom provider workflow', () => { + const customTimeouts = new Map void; delay: number }>() + let nextId = 1 + + const customProvider: TimeoutProvider = { + name: 'customTest', + setTimeout: (callback, delay) => { + const id = nextId++ + customTimeouts.set(id, { callback, delay }) + return id + }, + clearTimeout: (id) => { + if (id !== undefined) { + customTimeouts.delete(id) + } + }, + setInterval: (callback, delay) => { + const id = nextId++ + customTimeouts.set(id, { callback, delay }) + return id + }, + clearInterval: (id) => { + if (id !== undefined) { + customTimeouts.delete(id) + } + }, + } + + const manager = new TimeoutManager() + manager.setTimeoutProvider(customProvider) + + const callback1 = vi.fn() + const callback2 = vi.fn() + + const timeout1 = manager.setTimeout(callback1, 100) + const timeout2 = manager.setTimeout(callback2, 200) + + expect(customTimeouts.size).toBe(2) + expect(customTimeouts.get(timeout1)?.delay).toBe(100) + expect(customTimeouts.get(timeout2)?.delay).toBe(200) + + manager.clearTimeout(timeout1) + expect(customTimeouts.size).toBe(1) + expect(customTimeouts.has(timeout1)).toBe(false) + expect(customTimeouts.has(timeout2)).toBe(true) + }) + }) +}) \ No newline at end of file From 223371bc13b5c973a6267fb676c6ee243c09d91a Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Tue, 2 Sep 2025 13:49:42 -0400 Subject: [PATCH 06/33] tests --- .../src/__tests__/timeoutManager.test.tsx | 722 +++--------------- 1 file changed, 108 insertions(+), 614 deletions(-) diff --git a/packages/query-core/src/__tests__/timeoutManager.test.tsx b/packages/query-core/src/__tests__/timeoutManager.test.tsx index 6e240a30a8..b5303717b9 100644 --- a/packages/query-core/src/__tests__/timeoutManager.test.tsx +++ b/packages/query-core/src/__tests__/timeoutManager.test.tsx @@ -1,457 +1,155 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TimeoutManager, - TimeoutProvider, defaultTimeoutProvider, - timeoutManager, - managedSetTimeout, + managedClearInterval, managedClearTimeout, managedSetInterval, - managedClearInterval, + managedSetTimeout, systemSetTimeoutZero, - type TimeoutProviderId, - type TimeoutCallback, - type ManagedTimerId, + timeoutManager, } from '../timeoutManager' describe('timeoutManager', () => { - let originalConsoleWarn: typeof console.warn + function createMockProvider(name: string = 'custom') { + return { + name, + setTimeout: vi.fn(() => 123), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 456), + clearInterval: vi.fn(), + } + } beforeEach(() => { - originalConsoleWarn = console.warn - console.warn = vi.fn() + vi.spyOn(console, 'warn') }) afterEach(() => { - console.warn = originalConsoleWarn vi.restoreAllMocks() }) - describe('types', () => { - it('should have correct type definitions', () => { - const callback: TimeoutCallback = () => {} - const timeoutId: TimeoutProviderId = 123 - const nodeTimeoutId = { [Symbol.toPrimitive]: () => 456 } - const managedId: ManagedTimerId = 789 - - expect(typeof callback).toBe('function') - expect(typeof timeoutId).toBe('number') - expect(typeof nodeTimeoutId[Symbol.toPrimitive]()).toBe('number') - expect(typeof managedId).toBe('number') - }) - }) + describe('TimeoutManager', () => { + let manager: TimeoutManager - describe('defaultTimeoutProvider', () => { - it('should have correct name', () => { - expect(defaultTimeoutProvider.name).toBe('default') + beforeEach(() => { + manager = new TimeoutManager() }) - it('should use global setTimeout', () => { - const callback = vi.fn() - const delay = 100 - - const timeoutId = defaultTimeoutProvider.setTimeout(callback, delay) - - // In Node.js, setTimeout can return a Timeout object or number - expect(timeoutId).toBeDefined() - expect(Number(timeoutId)).toBeGreaterThan(0) - - defaultTimeoutProvider.clearTimeout(Number(timeoutId)) - }) + 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') - it('should use global setInterval', () => { const callback = vi.fn() - const delay = 100 - - const intervalId = defaultTimeoutProvider.setInterval(callback, delay) - - // In Node.js, setInterval can return an Interval object or number - expect(intervalId).toBeDefined() - expect(Number(intervalId)).toBeGreaterThan(0) - - defaultTimeoutProvider.clearInterval(Number(intervalId)) - }) - - it('should handle clearTimeout with undefined', () => { - expect(() => defaultTimeoutProvider.clearTimeout(undefined)).not.toThrow() - }) - - it('should handle clearInterval with undefined', () => { - expect(() => defaultTimeoutProvider.clearInterval(undefined)).not.toThrow() - }) - }) + const timeoutId = manager.setTimeout(callback, 100) + expect(setTimeoutSpy).toHaveBeenCalledWith(callback, 100) + clearTimeout(timeoutId) - describe('TimeoutManager', () => { - let manager: TimeoutManager + manager.clearTimeout(200) + expect(clearTimeoutSpy).toHaveBeenCalledWith(200) - beforeEach(() => { - manager = new TimeoutManager() - }) + const intervalId = manager.setInterval(callback, 300) + expect(setIntervalSpy).toHaveBeenCalledWith(callback, 300) + clearInterval(intervalId) - describe('constructor and properties', () => { - it('should start with default provider', () => { - const callback = vi.fn() - manager.setTimeout(callback, 0) - expect(console.warn).not.toHaveBeenCalled() - }) + manager.clearInterval(400) + expect(clearIntervalSpy).toHaveBeenCalledWith(400) }) describe('setTimeoutProvider', () => { - it('should set a new timeout provider', () => { - const customProvider: TimeoutProvider = { - name: 'custom', - setTimeout: vi.fn(() => 123), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 456), - clearInterval: vi.fn(), - } - + 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) - }) - it('should warn when switching providers after provider calls', () => { - const callback = vi.fn() - - // Make a setTimeout call with default provider - manager.setTimeout(callback, 0) - - const customProvider: TimeoutProvider = { - name: 'custom', - setTimeout: vi.fn(() => 123), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 456), - clearInterval: vi.fn(), - } - - // Switch to custom provider - manager.setTimeoutProvider(customProvider) - - expect(console.warn).toHaveBeenCalledWith( - '[timeoutManager]: Switching to custom provider after calls to default provider might result in unexpected behavior.' - ) - }) + manager.clearTimeout(999) + expect(customProvider.clearTimeout).toHaveBeenCalledWith(999) - it('should not warn when switching to the same provider', () => { - const callback = vi.fn() - - // Make a setTimeout call - manager.setTimeout(callback, 0) - - // Set the same provider again - manager.setTimeoutProvider(defaultTimeoutProvider) - - expect(console.warn).not.toHaveBeenCalled() - }) + manager.setInterval(callback, 200) + expect(customProvider.setInterval).toHaveBeenCalledWith(callback, 200) - it('should not warn on first provider switch before provider calls', () => { - const customProvider: TimeoutProvider = { - name: 'custom', - setTimeout: vi.fn(() => 123), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 456), - clearInterval: vi.fn(), - } - - manager.setTimeoutProvider(customProvider) - - expect(console.warn).not.toHaveBeenCalled() + manager.clearInterval(888) + expect(customProvider.clearInterval).toHaveBeenCalledWith(888) }) - it('should return early when setting the same provider', () => { - const customProvider: TimeoutProvider = { - name: 'custom', - setTimeout: vi.fn(() => 123), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 456), - clearInterval: vi.fn(), - } - + it('warns when switching providers after making call', () => { + // 1. switching before making any calls does not warn + const customProvider = createMockProvider() manager.setTimeoutProvider(customProvider) - manager.setTimeoutProvider(customProvider) // Set same provider again - expect(console.warn).not.toHaveBeenCalled() - }) - it('should reset providerCalled flag when switching providers', () => { - const callback = vi.fn() - - // Make a setTimeout call - manager.setTimeout(callback, 0) - - const customProvider: TimeoutProvider = { - name: 'custom', - setTimeout: vi.fn(() => 123), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 456), - clearInterval: vi.fn(), - } - - // Switch providers (will warn) - manager.setTimeoutProvider(customProvider) - expect(console.warn).toHaveBeenCalledTimes(1) - - const anotherProvider: TimeoutProvider = { - name: 'another', - setTimeout: vi.fn(() => 789), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 101), - clearInterval: vi.fn(), - } - - // Switch again without making any calls - should not warn since flag was reset - manager.setTimeoutProvider(anotherProvider) - expect(console.warn).toHaveBeenCalledTimes(1) - }) - }) + // Make a call. The next switch should warn + manager.setTimeout(vi.fn(), 100) - describe('setTimeout', () => { - it('should call provider setTimeout and return number', () => { - const callback = vi.fn() - const delay = 100 - - const timeoutId = manager.setTimeout(callback, delay) - - expect(typeof timeoutId).toBe('number') - expect(timeoutId).toBeGreaterThan(0) - }) - - it('should set providerCalled flag on setTimeout', () => { - const callback = vi.fn() - - manager.setTimeout(callback, 0) - - const customProvider: TimeoutProvider = { - name: 'custom', - setTimeout: vi.fn(() => 123), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 456), - clearInterval: vi.fn(), - } - - manager.setTimeoutProvider(customProvider) - - expect(console.warn).toHaveBeenCalledWith( - '[timeoutManager]: Switching to custom provider after calls to default provider might result in unexpected behavior.' - ) - }) - - it('should set providerCalled flag on setInterval', () => { - const callback = vi.fn() - - manager.setInterval(callback, 100) - - const customProvider: TimeoutProvider = { - name: 'custom', - setTimeout: vi.fn(() => 123), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 456), - clearInterval: vi.fn(), - } - - manager.setTimeoutProvider(customProvider) - + // 2. switching after making a call should warn + const customProvider2 = createMockProvider('custom2') + manager.setTimeoutProvider(customProvider2) expect(console.warn).toHaveBeenCalledWith( - '[timeoutManager]: Switching to custom provider after calls to default provider might result in unexpected behavior.' + '[timeoutManager]: Switching to custom2 provider after calls to custom provider might result in unexpected behavior.', ) - }) - - it('should handle provider returning object with Symbol.toPrimitive', () => { - const nodeTimeoutLike = { - [Symbol.toPrimitive]: () => 42 - } - - const customProvider: TimeoutProvider = { - name: 'custom', - setTimeout: vi.fn(() => nodeTimeoutLike), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 456), - clearInterval: vi.fn(), - } - - manager.setTimeoutProvider(customProvider) - - const callback = vi.fn() - const timeoutId = manager.setTimeout(callback, 100) - - expect(timeoutId).toBe(42) - }) - it('should throw error when provider returns non-convertible value', () => { - const invalidValue = { invalid: true } as any - - const customProvider: TimeoutProvider = { - name: 'badProvider', - setTimeout: vi.fn(() => invalidValue), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 456), - clearInterval: vi.fn(), - } - - manager.setTimeoutProvider(customProvider) - - const callback = vi.fn() - - expect(() => manager.setTimeout(callback, 100)).toThrow( - 'TimeoutManager: could not convert badProvider provider timeout ID to valid number' - ) - }) - }) - - describe('clearTimeout', () => { - it('should call provider clearTimeout', () => { - const customProvider: TimeoutProvider = { - name: 'custom', - setTimeout: vi.fn(() => 123), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 456), - clearInterval: vi.fn(), - } - - manager.setTimeoutProvider(customProvider) - - const timeoutId = 42 - manager.clearTimeout(timeoutId) - - expect(customProvider.clearTimeout).toHaveBeenCalledWith(timeoutId) - }) - - it('should handle undefined timeoutId', () => { - const customProvider: TimeoutProvider = { - name: 'custom', - setTimeout: vi.fn(() => 123), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 456), - clearInterval: vi.fn(), - } - - manager.setTimeoutProvider(customProvider) - - expect(() => manager.clearTimeout(undefined)).not.toThrow() - expect(customProvider.clearTimeout).toHaveBeenCalledWith(undefined) + // 3. Switching again with no intermediate calls should not warn + vi.mocked(console.warn).mockClear() + const customProvider3 = createMockProvider('custom3') + manager.setTimeoutProvider(customProvider3) + expect(console.warn).not.toHaveBeenCalled() }) }) - describe('setInterval', () => { - it('should call provider setInterval and return number', () => { - const callback = vi.fn() - const delay = 100 - - const intervalId = manager.setInterval(callback, delay) - - expect(typeof intervalId).toBe('number') - expect(intervalId).toBeGreaterThan(0) - }) - - it('should handle provider returning object with Symbol.toPrimitive', () => { - const nodeIntervalLike = { - [Symbol.toPrimitive]: () => 99 - } - - const customProvider: TimeoutProvider = { - name: 'custom', - setTimeout: vi.fn(() => 123), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => nodeIntervalLike), - clearInterval: vi.fn(), - } - - manager.setTimeoutProvider(customProvider) - - const callback = vi.fn() - const intervalId = manager.setInterval(callback, 100) - - expect(intervalId).toBe(99) - }) + it('throws if provider returns non-convertible value from setTimeout/setInterval', () => { + const invalidValue = { invalid: true } as any + const customProvider = createMockProvider('badProvider') + customProvider.setTimeout = vi.fn(() => invalidValue) + customProvider.setInterval = vi.fn(() => invalidValue) + manager.setTimeoutProvider(customProvider) - it('should throw error when provider returns non-convertible value', () => { - const invalidValue = { invalid: true } as any - - const customProvider: TimeoutProvider = { - name: 'badProvider', - setTimeout: vi.fn(() => 123), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => invalidValue), - clearInterval: vi.fn(), - } - - manager.setTimeoutProvider(customProvider) - - const callback = vi.fn() - - expect(() => manager.setInterval(callback, 100)).toThrow( - 'TimeoutManager: could not convert badProvider provider timeout ID to valid number' - ) - }) - }) + const callback = vi.fn() - describe('clearInterval', () => { - it('should call provider clearInterval', () => { - const customProvider: TimeoutProvider = { - name: 'custom', - setTimeout: vi.fn(() => 123), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 456), - clearInterval: vi.fn(), - } - - manager.setTimeoutProvider(customProvider) - - const intervalId = 88 - manager.clearInterval(intervalId) - - expect(customProvider.clearInterval).toHaveBeenCalledWith(intervalId) - }) + expect(() => manager.setTimeout(callback, 100)).toThrow( + 'TimeoutManager: could not convert badProvider provider timeout ID to valid number', + ) - it('should handle undefined intervalId', () => { - const customProvider: TimeoutProvider = { - name: 'custom', - setTimeout: vi.fn(() => 123), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 456), - clearInterval: vi.fn(), - } - - manager.setTimeoutProvider(customProvider) - - expect(() => manager.clearInterval(undefined)).not.toThrow() - expect(customProvider.clearInterval).toHaveBeenCalledWith(undefined) - }) + expect(() => manager.setInterval(callback, 100)).toThrow( + 'TimeoutManager: could not convert badProvider provider timeout ID to valid number', + ) }) }) - describe('global timeoutManager instance', () => { + describe('globalThis timeoutManager instance', () => { it('should be an instance of TimeoutManager', () => { expect(timeoutManager).toBeInstanceOf(TimeoutManager) }) }) - describe('managed utility functions', () => { + describe('exported functions', () => { + let provider: ReturnType + let callNumber = 0 + beforeEach(() => { + callNumber = 0 + provider = createMockProvider() + timeoutManager.setTimeoutProvider(provider) + }) + afterEach(() => { + timeoutManager.setTimeoutProvider(defaultTimeoutProvider) + }) + + const callbackArgs = () => [vi.fn(), 100 * ++callNumber] as const + describe('managedSetTimeout', () => { it('should call timeoutManager.setTimeout', () => { const spy = vi.spyOn(timeoutManager, 'setTimeout') - const callback = vi.fn() - const delay = 50 - - managedSetTimeout(callback, delay) - - expect(spy).toHaveBeenCalledWith(callback, delay) - - spy.mockRestore() - }) + const args = callbackArgs() - it('should return timeout ID', () => { - const callback = vi.fn() - const timeoutId = managedSetTimeout(callback, 0) - - expect(typeof timeoutId).toBe('number') - expect(timeoutId).toBeGreaterThan(0) - - managedClearTimeout(timeoutId) + const result = managedSetTimeout(...args) + + expect(spy).toHaveBeenCalledWith(...args) + expect(result).toBe(123) }) }) @@ -459,20 +157,11 @@ describe('timeoutManager', () => { it('should call timeoutManager.clearTimeout', () => { const spy = vi.spyOn(timeoutManager, 'clearTimeout') const timeoutId = 123 - + managedClearTimeout(timeoutId) - + expect(spy).toHaveBeenCalledWith(timeoutId) - - spy.mockRestore() - }) - it('should handle undefined timeoutId', () => { - const spy = vi.spyOn(timeoutManager, 'clearTimeout') - - expect(() => managedClearTimeout(undefined)).not.toThrow() - expect(spy).toHaveBeenCalledWith(undefined) - spy.mockRestore() }) }) @@ -480,24 +169,12 @@ describe('timeoutManager', () => { describe('managedSetInterval', () => { it('should call timeoutManager.setInterval', () => { const spy = vi.spyOn(timeoutManager, 'setInterval') - const callback = vi.fn() - const delay = 50 - - managedSetInterval(callback, delay) - - expect(spy).toHaveBeenCalledWith(callback, delay) - - spy.mockRestore() - }) + const args = callbackArgs() - it('should return interval ID', () => { - const callback = vi.fn() - const intervalId = managedSetInterval(callback, 100) - - expect(typeof intervalId).toBe('number') - expect(intervalId).toBeGreaterThan(0) - - managedClearInterval(intervalId) + const result = managedSetInterval(...args) + + expect(spy).toHaveBeenCalledWith(...args) + expect(result).toBe(456) }) }) @@ -505,206 +182,23 @@ describe('timeoutManager', () => { it('should call timeoutManager.clearInterval', () => { const spy = vi.spyOn(timeoutManager, 'clearInterval') const intervalId = 456 - + managedClearInterval(intervalId) - - expect(spy).toHaveBeenCalledWith(intervalId) - - spy.mockRestore() - }) - it('should handle undefined intervalId', () => { - const spy = vi.spyOn(timeoutManager, 'clearInterval') - - expect(() => managedClearInterval(undefined)).not.toThrow() - expect(spy).toHaveBeenCalledWith(undefined) - - spy.mockRestore() + expect(spy).toHaveBeenCalledWith(intervalId) }) }) - }) - describe('systemSetTimeoutZero', () => { - it('should use global setTimeout with 0 delay', () => { - const originalSetTimeout = global.setTimeout - const setTimeoutSpy = vi.fn() - global.setTimeout = setTimeoutSpy - - const callback = vi.fn() - systemSetTimeoutZero(callback) - - expect(setTimeoutSpy).toHaveBeenCalledWith(callback, 0) - - global.setTimeout = originalSetTimeout - }) + describe('systemSetTimeoutZero', () => { + it('should use globalThis setTimeout with 0 delay', () => { + const spy = vi.spyOn(globalThis, 'setTimeout') - it('should call callback on next tick', async () => { - let called = false - const callback = () => { - called = true - } - - systemSetTimeoutZero(callback) - expect(called).toBe(false) - - // Wait for next tick - await new Promise(resolve => setTimeout(resolve, 1)) - expect(called).toBe(true) - }) - }) - - describe('providerIdToNumber function (via public API)', () => { - it('should handle regular numbers', () => { - const manager = new TimeoutManager() - const callback = vi.fn() - - const customProvider: TimeoutProvider = { - name: 'test', - setTimeout: vi.fn(() => 42), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => 99), - clearInterval: vi.fn(), - } - - manager.setTimeoutProvider(customProvider) - - expect(manager.setTimeout(callback, 0)).toBe(42) - expect(manager.setInterval(callback, 0)).toBe(99) - }) - - it('should handle objects with Symbol.toPrimitive', () => { - const manager = new TimeoutManager() - const callback = vi.fn() - - const timeoutObj = { [Symbol.toPrimitive]: () => 100 } - const intervalObj = { [Symbol.toPrimitive]: () => 200 } - - const customProvider: TimeoutProvider = { - name: 'test', - setTimeout: vi.fn(() => timeoutObj), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => intervalObj), - clearInterval: vi.fn(), - } - - manager.setTimeoutProvider(customProvider) - - expect(manager.setTimeout(callback, 0)).toBe(100) - expect(manager.setInterval(callback, 0)).toBe(200) - }) - - it('should throw error for non-convertible values', () => { - const manager = new TimeoutManager() - const callback = vi.fn() - - const customProvider: TimeoutProvider = { - name: 'errorProvider', - setTimeout: vi.fn(() => ({ invalid: true } as any)), - clearTimeout: vi.fn(), - setInterval: vi.fn(() => ({ invalid: true } as any)), - clearInterval: vi.fn(), - } - - manager.setTimeoutProvider(customProvider) - - expect(() => manager.setTimeout(callback, 0)).toThrow( - 'TimeoutManager: could not convert errorProvider provider timeout ID to valid number' - ) - - expect(() => manager.setInterval(callback, 0)).toThrow( - 'TimeoutManager: could not convert errorProvider provider timeout ID to valid number' - ) - }) - }) - - describe('integration tests', () => { - it('should work with real timeouts', async () => { - let executed = false - const callback = () => { - executed = true - } - - const timeoutId = managedSetTimeout(callback, 10) - expect(typeof timeoutId).toBe('number') - expect(executed).toBe(false) - - await new Promise(resolve => setTimeout(resolve, 15)) - expect(executed).toBe(true) - }) - - it('should work with clearing timeouts', async () => { - let executed = false - const callback = () => { - executed = true - } - - const timeoutId = managedSetTimeout(callback, 10) - managedClearTimeout(timeoutId) - - await new Promise(resolve => setTimeout(resolve, 20)) - expect(executed).toBe(false) - }) - - it('should work with real intervals', async () => { - let count = 0 - const callback = () => { - count++ - } - - const intervalId = managedSetInterval(callback, 10) - expect(typeof intervalId).toBe('number') - - await new Promise(resolve => setTimeout(resolve, 25)) - managedClearInterval(intervalId) - - expect(count).toBeGreaterThanOrEqual(2) - }) + const callback = vi.fn() + systemSetTimeoutZero(callback) - it('should handle custom provider workflow', () => { - const customTimeouts = new Map void; delay: number }>() - let nextId = 1 - - const customProvider: TimeoutProvider = { - name: 'customTest', - setTimeout: (callback, delay) => { - const id = nextId++ - customTimeouts.set(id, { callback, delay }) - return id - }, - clearTimeout: (id) => { - if (id !== undefined) { - customTimeouts.delete(id) - } - }, - setInterval: (callback, delay) => { - const id = nextId++ - customTimeouts.set(id, { callback, delay }) - return id - }, - clearInterval: (id) => { - if (id !== undefined) { - customTimeouts.delete(id) - } - }, - } - - const manager = new TimeoutManager() - manager.setTimeoutProvider(customProvider) - - const callback1 = vi.fn() - const callback2 = vi.fn() - - const timeout1 = manager.setTimeout(callback1, 100) - const timeout2 = manager.setTimeout(callback2, 200) - - expect(customTimeouts.size).toBe(2) - expect(customTimeouts.get(timeout1)?.delay).toBe(100) - expect(customTimeouts.get(timeout2)?.delay).toBe(200) - - manager.clearTimeout(timeout1) - expect(customTimeouts.size).toBe(1) - expect(customTimeouts.has(timeout1)).toBe(false) - expect(customTimeouts.has(timeout2)).toBe(true) + expect(spy).toHaveBeenCalledWith(callback, 0) + clearTimeout(spy.mock.results[0]?.value) + }) }) }) -}) \ No newline at end of file +}) From d3d7d1aca7939ca5a5fb736d42e6dc99b4a55927 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Tue, 2 Sep 2025 13:51:42 -0400 Subject: [PATCH 07/33] revert changes in query-async-storage-persister: no path to import query-core --- packages/query-async-storage-persister/src/asyncThrottle.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/query-async-storage-persister/src/asyncThrottle.ts b/packages/query-async-storage-persister/src/asyncThrottle.ts index 30b7e7d62f..f37737c8b5 100644 --- a/packages/query-async-storage-persister/src/asyncThrottle.ts +++ b/packages/query-async-storage-persister/src/asyncThrottle.ts @@ -1,4 +1,3 @@ -import { managedSetTimeout } from '../../query-core/src/timeoutManager' import { noop } from './utils' interface AsyncThrottleOptions { @@ -22,11 +21,11 @@ export function asyncThrottle>( if (isScheduled) return isScheduled = true while (isExecuting) { - await new Promise((done) => managedSetTimeout(done, interval)) + await new Promise((done) => setTimeout(done, interval)) } while (Date.now() < nextExecutionTime) { await new Promise((done) => - managedSetTimeout(done, nextExecutionTime - Date.now()), + setTimeout(done, nextExecutionTime - Date.now()), ) } isScheduled = false From a953c702054998ee0619ba4e3119dc89799995bb Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Tue, 2 Sep 2025 13:59:01 -0400 Subject: [PATCH 08/33] re-export more types --- packages/query-core/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index 4557caf11f..e9349ed2ff 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -18,7 +18,11 @@ export { managedClearInterval, systemSetTimeoutZero, defaultTimeoutProvider, + timeoutManager, type ManagedTimerId, + type TimeoutCallback, + type TimeoutProvider, + type TimeoutProviderId, } from './timeoutManager' export { focusManager } from './focusManager' export { onlineManager } from './onlineManager' From 0fac7b284330dcc2a3e32012ab0fc792e2dff609 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Tue, 2 Sep 2025 14:18:56 -0400 Subject: [PATCH 09/33] console.warn -> non-production console.error --- .../src/__tests__/timeoutManager.test.tsx | 12 ++--- packages/query-core/src/timeoutManager.ts | 44 +++++++++++-------- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/packages/query-core/src/__tests__/timeoutManager.test.tsx b/packages/query-core/src/__tests__/timeoutManager.test.tsx index b5303717b9..576d24af4c 100644 --- a/packages/query-core/src/__tests__/timeoutManager.test.tsx +++ b/packages/query-core/src/__tests__/timeoutManager.test.tsx @@ -21,8 +21,10 @@ describe('timeoutManager', () => { } } + let consoleErrorSpy: ReturnType + beforeEach(() => { - vi.spyOn(console, 'warn') + consoleErrorSpy = vi.spyOn(console, 'error') }) afterEach(() => { @@ -82,7 +84,7 @@ describe('timeoutManager', () => { // 1. switching before making any calls does not warn const customProvider = createMockProvider() manager.setTimeoutProvider(customProvider) - expect(console.warn).not.toHaveBeenCalled() + expect(consoleErrorSpy).not.toHaveBeenCalled() // Make a call. The next switch should warn manager.setTimeout(vi.fn(), 100) @@ -90,15 +92,15 @@ describe('timeoutManager', () => { // 2. switching after making a call should warn const customProvider2 = createMockProvider('custom2') manager.setTimeoutProvider(customProvider2) - expect(console.warn).toHaveBeenCalledWith( + expect(consoleErrorSpy).toHaveBeenCalledWith( '[timeoutManager]: Switching to custom2 provider after calls to custom provider might result in unexpected behavior.', ) // 3. Switching again with no intermediate calls should not warn - vi.mocked(console.warn).mockClear() + vi.mocked(consoleErrorSpy).mockClear() const customProvider3 = createMockProvider('custom3') manager.setTimeoutProvider(customProvider3) - expect(console.warn).not.toHaveBeenCalled() + expect(consoleErrorSpy).not.toHaveBeenCalled() }) }) diff --git a/packages/query-core/src/timeoutManager.ts b/packages/query-core/src/timeoutManager.ts index e545d957c8..ba5aaca264 100644 --- a/packages/query-core/src/timeoutManager.ts +++ b/packages/query-core/src/timeoutManager.ts @@ -68,29 +68,35 @@ export class TimeoutManager implements Omit { return } - if (this.#providerCalled) { - // After changing providers, `clearTimeout` will not work as expected for - // timeouts from the previous provider. - // - // Since they may allocate the same timeout ID, clearTimeout may cancel an - // arbitrary different timeout, or unexpected no-op. - // - // We could protect against this by mixing the timeout ID bits - // deterministically with some per-provider bits. - // - // We could internally queue `setTimeout` calls to `TimeoutManager` until - // some API call to set the initial provider. - console.warn( - `[timeoutManager]: Switching to ${provider.name} provider after calls to ${this.#provider.name} provider might result in unexpected behavior.`, - ) + if (process.env.NODE_ENV !== 'production') { + if (this.#providerCalled) { + // After changing providers, `clearTimeout` will not work as expected for + // timeouts from the previous provider. + // + // Since they may allocate the same timeout ID, clearTimeout may cancel an + // arbitrary different timeout, or unexpected no-op. + // + // We could protect against this by mixing the timeout ID bits + // deterministically with some per-provider bits. + // + // We could internally queue `setTimeout` calls to `TimeoutManager` until + // some API call to set the initial provider. + console.error( + `[timeoutManager]: Switching to ${provider.name} provider after calls to ${this.#provider.name} provider might result in unexpected behavior.`, + ) + } } this.#provider = provider - this.#providerCalled = false + if (process.env.NODE_ENV !== 'production') { + this.#providerCalled = false + } } setTimeout(callback: TimeoutCallback, delay: number): ManagedTimerId { - this.#providerCalled = true + if (process.env.NODE_ENV !== 'production') { + this.#providerCalled = true + } return providerIdToNumber( this.#provider, this.#provider.setTimeout(callback, delay), @@ -102,7 +108,9 @@ export class TimeoutManager implements Omit { } setInterval(callback: TimeoutCallback, delay: number): ManagedTimerId { - this.#providerCalled = true + if (process.env.NODE_ENV !== 'production') { + this.#providerCalled = true + } return providerIdToNumber( this.#provider, this.#provider.setInterval(callback, delay), From 47962fdf93e16b67302d9bd90c6ab2ee15ae7100 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Tue, 2 Sep 2025 14:28:28 -0400 Subject: [PATCH 10/33] query-async-storage-persister: use query-core managedSetTimeout --- packages/query-async-storage-persister/package.json | 1 + packages/query-async-storage-persister/src/asyncThrottle.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/query-async-storage-persister/package.json b/packages/query-async-storage-persister/package.json index ee289fb862..fb2aeba6ea 100644 --- a/packages/query-async-storage-persister/package.json +++ b/packages/query-async-storage-persister/package.json @@ -59,6 +59,7 @@ "!src/__tests__" ], "dependencies": { + "@tanstack/query-core": "workspace:*", "@tanstack/query-persist-client-core": "workspace:*" }, "devDependencies": { diff --git a/packages/query-async-storage-persister/src/asyncThrottle.ts b/packages/query-async-storage-persister/src/asyncThrottle.ts index f37737c8b5..254a0e412b 100644 --- a/packages/query-async-storage-persister/src/asyncThrottle.ts +++ b/packages/query-async-storage-persister/src/asyncThrottle.ts @@ -1,3 +1,4 @@ +import { managedSetTimeout } from '@tanstack/query-core' import { noop } from './utils' interface AsyncThrottleOptions { @@ -21,11 +22,11 @@ export function asyncThrottle>( if (isScheduled) return isScheduled = true while (isExecuting) { - await new Promise((done) => setTimeout(done, interval)) + await new Promise((done) => managedSetTimeout(done, interval)) } while (Date.now() < nextExecutionTime) { await new Promise((done) => - setTimeout(done, nextExecutionTime - Date.now()), + managedSetTimeout(done, nextExecutionTime - Date.now()), ) } isScheduled = false From 62b1dd04695907253406050be32720d9930e1531 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Tue, 2 Sep 2025 14:29:08 -0400 Subject: [PATCH 11/33] pdate pnpm-lock for new dependency edge --- pnpm-lock.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 12e5e74a78..3939ea1d6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2373,6 +2373,9 @@ importers: packages/query-async-storage-persister: dependencies: + '@tanstack/query-core': + specifier: workspace:* + version: link:../query-core '@tanstack/query-persist-client-core': specifier: workspace:* version: link:../query-persist-client-core @@ -12884,6 +12887,7 @@ packages: react-native-vector-icons@10.1.0: resolution: {integrity: sha512-fdQjCHIdoXmRoTZ5gvN1FmT4sGLQ2wmQiNZHKJQUYnE2tkIwjGnxNch+6Nd4lHAACvMWO7LOzBNot2u/zlOmkw==} + deprecated: react-native-vector-icons package has moved to a new model of per-icon-family packages. See the https://github.com/oblador/react-native-vector-icons/blob/master/MIGRATION.md on how to migrate hasBin: true react-native-web@0.19.13: From 8d5e05070ebdab92f073e6cfbbaa0a22a6d7841b Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Tue, 2 Sep 2025 14:36:30 -0400 Subject: [PATCH 12/33] sleep: always managedSetTimeout --- packages/query-core/src/utils.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 419c1d5d1e..065e09b907 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -362,9 +362,7 @@ function hasObjectPrototype(o: any): boolean { export function sleep(timeout: number): Promise { return new Promise((resolve) => { - const setTimeoutFn = - timeout === 0 ? systemSetTimeoutZero : managedSetTimeout - setTimeoutFn(resolve, timeout) + managedSetTimeout(resolve, timeout) }) } From fea6ccefa777b65b0955818357b3d8afc7299ea5 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 3 Sep 2025 12:18:24 -0400 Subject: [PATCH 13/33] remove managed* functions, call method directly --- .../src/asyncThrottle.ts | 6 +-- packages/query-core/src/index.ts | 5 -- packages/query-core/src/queryObserver.ts | 15 ++---- packages/query-core/src/removable.ts | 6 +-- packages/query-core/src/timeoutManager.ts | 46 ------------------- packages/query-core/src/utils.ts | 4 +- .../query-sync-storage-persister/src/index.ts | 4 +- 7 files changed, 15 insertions(+), 71 deletions(-) diff --git a/packages/query-async-storage-persister/src/asyncThrottle.ts b/packages/query-async-storage-persister/src/asyncThrottle.ts index 254a0e412b..6983b16a3a 100644 --- a/packages/query-async-storage-persister/src/asyncThrottle.ts +++ b/packages/query-async-storage-persister/src/asyncThrottle.ts @@ -1,4 +1,4 @@ -import { managedSetTimeout } from '@tanstack/query-core' +import { timeoutManager } from '@tanstack/query-core' import { noop } from './utils' interface AsyncThrottleOptions { @@ -22,11 +22,11 @@ export function asyncThrottle>( if (isScheduled) return isScheduled = true while (isExecuting) { - await new Promise((done) => managedSetTimeout(done, interval)) + await new Promise((done) => timeoutManager.setTimeout(done, interval)) } while (Date.now() < nextExecutionTime) { await new Promise((done) => - managedSetTimeout(done, nextExecutionTime - Date.now()), + timeoutManager.setTimeout(done, nextExecutionTime - Date.now()), ) } isScheduled = false diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index e9349ed2ff..42cd8ae5ad 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -12,17 +12,12 @@ export type { MutationCacheNotifyEvent } from './mutationCache' export { MutationObserver } from './mutationObserver' export { notifyManager, defaultScheduler } from './notifyManager' export { - managedSetTimeout, - managedClearTimeout, - managedSetInterval, - managedClearInterval, systemSetTimeoutZero, defaultTimeoutProvider, timeoutManager, type ManagedTimerId, type TimeoutCallback, type TimeoutProvider, - type TimeoutProviderId, } from './timeoutManager' export { focusManager } from './focusManager' export { onlineManager } from './onlineManager' diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 91d7fff2ff..1fe6aae676 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -13,12 +13,7 @@ import { shallowEqualObjects, timeUntilStale, } from './utils' -import { - managedClearInterval, - managedClearTimeout, - managedSetInterval, - managedSetTimeout, -} from './timeoutManager' +import { timeoutManager } from './timeoutManager' import type { ManagedTimerId } from './timeoutManager' import type { FetchOptions, Query, QueryState } from './query' import type { QueryClient } from './queryClient' @@ -372,7 +367,7 @@ export class QueryObserver< // To mitigate this issue we always add 1 ms to the timeout. const timeout = time + 1 - this.#staleTimeoutId = managedSetTimeout(() => { + this.#staleTimeoutId = timeoutManager.setTimeout(() => { if (!this.#currentResult.isStale) { this.updateResult() } @@ -401,7 +396,7 @@ export class QueryObserver< return } - this.#refetchIntervalId = managedSetInterval(() => { + this.#refetchIntervalId = timeoutManager.setInterval(() => { if ( this.options.refetchIntervalInBackground || focusManager.isFocused() @@ -418,14 +413,14 @@ export class QueryObserver< #clearStaleTimeout(): void { if (this.#staleTimeoutId) { - managedClearTimeout(this.#staleTimeoutId) + timeoutManager.clearTimeout(this.#staleTimeoutId) this.#staleTimeoutId = undefined } } #clearRefetchInterval(): void { if (this.#refetchIntervalId) { - managedClearInterval(this.#refetchIntervalId) + timeoutManager.clearInterval(this.#refetchIntervalId) this.#refetchIntervalId = undefined } } diff --git a/packages/query-core/src/removable.ts b/packages/query-core/src/removable.ts index be5ce82806..8642ab36ec 100644 --- a/packages/query-core/src/removable.ts +++ b/packages/query-core/src/removable.ts @@ -1,4 +1,4 @@ -import { managedClearTimeout, managedSetTimeout } from './timeoutManager' +import { timeoutManager } from './timeoutManager' import { isServer, isValidTimeout } from './utils' import type { ManagedTimerId } from './timeoutManager' @@ -14,7 +14,7 @@ export abstract class Removable { this.clearGcTimeout() if (isValidTimeout(this.gcTime)) { - this.#gcTimeout = managedSetTimeout(() => { + this.#gcTimeout = timeoutManager.setTimeout(() => { this.optionalRemove() }, this.gcTime) } @@ -30,7 +30,7 @@ export abstract class Removable { protected clearGcTimeout() { if (this.#gcTimeout) { - managedClearTimeout(this.#gcTimeout) + timeoutManager.clearTimeout(this.#gcTimeout) this.#gcTimeout = undefined } } diff --git a/packages/query-core/src/timeoutManager.ts b/packages/query-core/src/timeoutManager.ts index ba5aaca264..189e2b6e22 100644 --- a/packages/query-core/src/timeoutManager.ts +++ b/packages/query-core/src/timeoutManager.ts @@ -122,54 +122,8 @@ export class TimeoutManager implements Omit { } } -function providerIdToNumber( - provider: TimeoutProvider, - providerId: TimeoutProviderId, -): ManagedTimerId { - const numberId = Number(providerId) - if (isNaN(numberId)) { - throw new Error( - `TimeoutManager: could not convert ${provider.name} provider timeout ID to valid number`, - ) - } - return numberId -} - export const timeoutManager = new TimeoutManager() -// Exporting functions that use `setTimeout` to reduce bundle size impact, since -// method names on objects are usually not minified. - -/** A version of `setTimeout` controlled by {@link timeoutManager}. */ -export function managedSetTimeout( - callback: TimeoutCallback, - delay: number, -): ManagedTimerId { - return timeoutManager.setTimeout(callback, delay) -} - -/** A version of `clearTimeout` controlled by {@link timeoutManager}. */ -export function managedClearTimeout( - timeoutId: ManagedTimerId | undefined, -): void { - timeoutManager.clearTimeout(timeoutId) -} - -/** A version of `setInterval` controlled by {@link timeoutManager}. */ -export function managedSetInterval( - callback: TimeoutCallback, - delay: number, -): ManagedTimerId { - return timeoutManager.setInterval(callback, delay) -} - -/** A version of `clearInterval` controlled by {@link timeoutManager}. */ -export function managedClearInterval( - intervalId: ManagedTimerId | undefined, -): void { - timeoutManager.clearInterval(intervalId) -} - /** * In many cases code wants to delay to the next event loop tick; this is not * mediated by {@link timeoutManager}. diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 065e09b907..366de0fa38 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,4 +1,3 @@ -import { managedSetTimeout, systemSetTimeoutZero } from './timeoutManager' import type { DefaultError, Enabled, @@ -13,6 +12,7 @@ import type { } from './types' import type { Mutation } from './mutation' import type { FetchOptions, Query } from './query' +import { timeoutManager } from './timeoutManager' // TYPES @@ -362,7 +362,7 @@ function hasObjectPrototype(o: any): boolean { export function sleep(timeout: number): Promise { return new Promise((resolve) => { - managedSetTimeout(resolve, timeout) + timeoutManager.setTimeout(resolve, timeout) }) } diff --git a/packages/query-sync-storage-persister/src/index.ts b/packages/query-sync-storage-persister/src/index.ts index 5a3505a68b..a9b79f539d 100644 --- a/packages/query-sync-storage-persister/src/index.ts +++ b/packages/query-sync-storage-persister/src/index.ts @@ -1,4 +1,4 @@ -import { managedSetTimeout } from '@tanstack/query-core' +import { timeoutManager } from '@tanstack/query-core' import { noop } from './utils' import type { ManagedTimerId } from '@tanstack/query-core' import type { @@ -107,7 +107,7 @@ function throttle>( return function (...args: TArgs) { params = args if (timer === null) { - timer = managedSetTimeout(() => { + timer = timeoutManager.setTimeout(() => { func(...params) timer = null }, wait) From 1606b5801314d0d2445629ca3358ebc2defc5072 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 3 Sep 2025 12:19:15 -0400 Subject: [PATCH 14/33] remove runtime coercion and accept unsafe any within TimeoutManager class --- packages/query-core/src/timeoutManager.ts | 68 ++++++++++++----------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/packages/query-core/src/timeoutManager.ts b/packages/query-core/src/timeoutManager.ts index 189e2b6e22..59496f7806 100644 --- a/packages/query-core/src/timeoutManager.ts +++ b/packages/query-core/src/timeoutManager.ts @@ -13,31 +13,33 @@ export type TimeoutCallback = (_: void) => void * Still, we can downlevel `NodeJS.Timeout` to `number` as it implements * Symbol.toPrimitive. */ -export type TimeoutProviderId = number | { [Symbol.toPrimitive]: () => number } +export type ManagedTimerId = number | { [Symbol.toPrimitive]: () => number } /** * Backend for timer functions. */ -export type TimeoutProvider = { - /** Used in error messages. */ - readonly name: string +export type TimeoutProvider = + { + readonly setTimeout: (callback: TimeoutCallback, delay: number) => TTimerId + readonly clearTimeout: (timeoutId: TTimerId | undefined) => void - readonly setTimeout: ( - callback: TimeoutCallback, - delay: number, - ) => TimeoutProviderId - readonly clearTimeout: (timeoutId: number | undefined) => void - - readonly setInterval: ( - callback: TimeoutCallback, - delay: number, - ) => TimeoutProviderId - readonly clearInterval: (intervalId: number | undefined) => void -} - -export const defaultTimeoutProvider: TimeoutProvider = { - name: 'default', + readonly setInterval: (callback: TimeoutCallback, delay: number) => TTimerId + readonly clearInterval: (intervalId: TTimerId | undefined) => void + } +export const defaultTimeoutProvider: TimeoutProvider< + ReturnType +> = { + // We need the wrapper function syntax below instead of direct references to + // global setTimeout etc. + // + // BAD: `setTimeout: setTimeout` + // GOOD: `setTimeout: (cb, delay) => setTimeout(cb, delay)` + // + // If we use direct references here, then anything that wants to spy on or + // replace the global setTimeout (like tests) won't work since we'll already + // have a hard reference to the original implementation at the time when this + // file was imported. setTimeout: (callback, delay) => setTimeout(callback, delay), clearTimeout: (timeoutId) => clearTimeout(timeoutId), @@ -45,9 +47,6 @@ export const defaultTimeoutProvider: TimeoutProvider = { clearInterval: (intervalId) => clearInterval(intervalId), } -/** Timeout ID returned by {@link TimeoutManager} */ -export type ManagedTimerId = number - /** * Allows customization of how timeouts are created. * @@ -60,10 +59,18 @@ export type ManagedTimerId = number * coalesces timeouts. */ export class TimeoutManager implements Omit { - #provider: TimeoutProvider = defaultTimeoutProvider + // We cannot have TimeoutManager as we must instantiate it with a concrete + // type at app boot; and if we leave that type, then any new timer provider + // would need to support ReturnType, which is infeasible. + // + // We settle for type safety for the TimeoutProvider type, and accept that + // this class is unsafe internally to allow for extension. + #provider: TimeoutProvider = defaultTimeoutProvider #providerCalled = false - setTimeoutProvider(provider: TimeoutProvider): void { + setTimeoutProvider( + provider: TimeoutProvider, + ): void { if (provider === this.#provider) { return } @@ -82,7 +89,8 @@ export class TimeoutManager implements Omit { // We could internally queue `setTimeout` calls to `TimeoutManager` until // some API call to set the initial provider. console.error( - `[timeoutManager]: Switching to ${provider.name} provider after calls to ${this.#provider.name} provider might result in unexpected behavior.`, + `[timeoutManager]: Switching provider after calls to previous provider might result in unexpected behavior.`, + { previous: this.#provider, provider }, ) } } @@ -97,10 +105,7 @@ export class TimeoutManager implements Omit { if (process.env.NODE_ENV !== 'production') { this.#providerCalled = true } - return providerIdToNumber( - this.#provider, - this.#provider.setTimeout(callback, delay), - ) + return this.#provider.setTimeout(callback, delay) } clearTimeout(timeoutId: ManagedTimerId | undefined): void { @@ -111,10 +116,7 @@ export class TimeoutManager implements Omit { if (process.env.NODE_ENV !== 'production') { this.#providerCalled = true } - return providerIdToNumber( - this.#provider, - this.#provider.setInterval(callback, delay), - ) + return this.#provider.setInterval(callback, delay) } clearInterval(intervalId: ManagedTimerId | undefined): void { From fc9092bc3a809a0fc8e27c506393504328404bf4 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 3 Sep 2025 12:25:20 -0400 Subject: [PATCH 15/33] cleanup; fix test after changes --- .../src/__tests__/timeoutManager.test.tsx | 81 +------------------ packages/query-core/src/index.ts | 64 ++++++--------- packages/query-core/src/utils.ts | 2 +- 3 files changed, 30 insertions(+), 117 deletions(-) diff --git a/packages/query-core/src/__tests__/timeoutManager.test.tsx b/packages/query-core/src/__tests__/timeoutManager.test.tsx index 576d24af4c..a9fbd51066 100644 --- a/packages/query-core/src/__tests__/timeoutManager.test.tsx +++ b/packages/query-core/src/__tests__/timeoutManager.test.tsx @@ -2,10 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TimeoutManager, defaultTimeoutProvider, - managedClearInterval, - managedClearTimeout, - managedSetInterval, - managedSetTimeout, systemSetTimeoutZero, timeoutManager, } from '../timeoutManager' @@ -47,14 +43,14 @@ describe('timeoutManager', () => { const callback = vi.fn() const timeoutId = manager.setTimeout(callback, 100) expect(setTimeoutSpy).toHaveBeenCalledWith(callback, 100) - clearTimeout(timeoutId) + clearTimeout(Number(timeoutId)) manager.clearTimeout(200) expect(clearTimeoutSpy).toHaveBeenCalledWith(200) const intervalId = manager.setInterval(callback, 300) expect(setIntervalSpy).toHaveBeenCalledWith(callback, 300) - clearInterval(intervalId) + clearInterval(Number(intervalId)) manager.clearInterval(400) expect(clearIntervalSpy).toHaveBeenCalledWith(400) @@ -93,7 +89,8 @@ describe('timeoutManager', () => { const customProvider2 = createMockProvider('custom2') manager.setTimeoutProvider(customProvider2) expect(consoleErrorSpy).toHaveBeenCalledWith( - '[timeoutManager]: Switching to custom2 provider after calls to custom provider might result in unexpected behavior.', + expect.stringMatching(/\[timeoutManager\]: Switching .* might result in unexpected behavior\..*/), + expect.anything(), ) // 3. Switching again with no intermediate calls should not warn @@ -103,24 +100,6 @@ describe('timeoutManager', () => { expect(consoleErrorSpy).not.toHaveBeenCalled() }) }) - - it('throws if provider returns non-convertible value from setTimeout/setInterval', () => { - const invalidValue = { invalid: true } as any - const customProvider = createMockProvider('badProvider') - customProvider.setTimeout = vi.fn(() => invalidValue) - customProvider.setInterval = vi.fn(() => invalidValue) - manager.setTimeoutProvider(customProvider) - - const callback = vi.fn() - - expect(() => manager.setTimeout(callback, 100)).toThrow( - 'TimeoutManager: could not convert badProvider provider timeout ID to valid number', - ) - - expect(() => manager.setInterval(callback, 100)).toThrow( - 'TimeoutManager: could not convert badProvider provider timeout ID to valid number', - ) - }) }) describe('globalThis timeoutManager instance', () => { @@ -131,9 +110,7 @@ describe('timeoutManager', () => { describe('exported functions', () => { let provider: ReturnType - let callNumber = 0 beforeEach(() => { - callNumber = 0 provider = createMockProvider() timeoutManager.setTimeoutProvider(provider) }) @@ -141,56 +118,6 @@ describe('timeoutManager', () => { timeoutManager.setTimeoutProvider(defaultTimeoutProvider) }) - const callbackArgs = () => [vi.fn(), 100 * ++callNumber] as const - - describe('managedSetTimeout', () => { - it('should call timeoutManager.setTimeout', () => { - const spy = vi.spyOn(timeoutManager, 'setTimeout') - const args = callbackArgs() - - const result = managedSetTimeout(...args) - - expect(spy).toHaveBeenCalledWith(...args) - expect(result).toBe(123) - }) - }) - - describe('managedClearTimeout', () => { - it('should call timeoutManager.clearTimeout', () => { - const spy = vi.spyOn(timeoutManager, 'clearTimeout') - const timeoutId = 123 - - managedClearTimeout(timeoutId) - - expect(spy).toHaveBeenCalledWith(timeoutId) - - spy.mockRestore() - }) - }) - - describe('managedSetInterval', () => { - it('should call timeoutManager.setInterval', () => { - const spy = vi.spyOn(timeoutManager, 'setInterval') - const args = callbackArgs() - - const result = managedSetInterval(...args) - - expect(spy).toHaveBeenCalledWith(...args) - expect(result).toBe(456) - }) - }) - - describe('managedClearInterval', () => { - it('should call timeoutManager.clearInterval', () => { - const spy = vi.spyOn(timeoutManager, 'clearInterval') - const intervalId = 456 - - managedClearInterval(intervalId) - - expect(spy).toHaveBeenCalledWith(intervalId) - }) - }) - describe('systemSetTimeoutZero', () => { it('should use globalThis setTimeout with 0 delay', () => { const spy = vi.spyOn(globalThis, 'setTimeout') diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index 42cd8ae5ad..d32ef58116 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -1,58 +1,44 @@ /* istanbul ignore file */ -export { CancelledError } from './retryer' -export { QueryCache } from './queryCache' -export type { QueryCacheNotifyEvent } from './queryCache' -export { QueryClient } from './queryClient' -export { QueryObserver } from './queryObserver' -export { QueriesObserver } from './queriesObserver' +export { focusManager } from './focusManager' +export { + defaultShouldDehydrateMutation, defaultShouldDehydrateQuery, dehydrate, + hydrate +} from './hydration' export { InfiniteQueryObserver } from './infiniteQueryObserver' export { MutationCache } from './mutationCache' export type { MutationCacheNotifyEvent } from './mutationCache' export { MutationObserver } from './mutationObserver' -export { notifyManager, defaultScheduler } from './notifyManager' +export { defaultScheduler, notifyManager } from './notifyManager' +export { onlineManager } from './onlineManager' +export { QueriesObserver } from './queriesObserver' +export { QueryCache } from './queryCache' +export type { QueryCacheNotifyEvent } from './queryCache' +export { QueryClient } from './queryClient' +export { QueryObserver } from './queryObserver' +export { CancelledError, isCancelledError } from './retryer' export { - systemSetTimeoutZero, - defaultTimeoutProvider, - timeoutManager, + defaultTimeoutProvider, systemSetTimeoutZero, timeoutManager, type ManagedTimerId, type TimeoutCallback, - type TimeoutProvider, + type TimeoutProvider } from './timeoutManager' -export { focusManager } from './focusManager' -export { onlineManager } from './onlineManager' export { - hashKey, - partialMatchKey, - replaceEqualDeep, - isServer, - matchQuery, - matchMutation, - keepPreviousData, - skipToken, - noop, - shouldThrowError, + hashKey, isServer, keepPreviousData, matchMutation, matchQuery, noop, partialMatchKey, + replaceEqualDeep, shouldThrowError, skipToken } from './utils' -export type { MutationFilters, QueryFilters, Updater, SkipToken } from './utils' -export { isCancelledError } from './retryer' -export { - dehydrate, - hydrate, - defaultShouldDehydrateQuery, - defaultShouldDehydrateMutation, -} from './hydration' +export type { MutationFilters, QueryFilters, SkipToken, Updater } from './utils' export { streamedQuery as experimental_streamedQuery } from './streamedQuery' // Types -export * from './types' -export type { QueryState } from './query' -export { Query } from './query' -export type { MutationState } from './mutation' -export { Mutation } from './mutation' export type { - DehydrateOptions, - DehydratedState, - HydrateOptions, + DehydratedState, DehydrateOptions, HydrateOptions } from './hydration' +export { Mutation } from './mutation' +export type { MutationState } from './mutation' export type { QueriesObserverOptions } from './queriesObserver' +export { Query } from './query' +export type { QueryState } from './query' +export * from './types' + diff --git a/packages/query-core/src/utils.ts b/packages/query-core/src/utils.ts index 366de0fa38..b4da471a9b 100644 --- a/packages/query-core/src/utils.ts +++ b/packages/query-core/src/utils.ts @@ -1,3 +1,4 @@ +import { timeoutManager } from './timeoutManager' import type { DefaultError, Enabled, @@ -12,7 +13,6 @@ import type { } from './types' import type { Mutation } from './mutation' import type { FetchOptions, Query } from './query' -import { timeoutManager } from './timeoutManager' // TYPES From 5d6fe4daad035b706bf7f6bff10b2ac0d300b2fb Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 3 Sep 2025 12:26:07 -0400 Subject: [PATCH 16/33] name is __TEST_ONLY__ --- packages/query-core/src/__tests__/timeoutManager.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/query-core/src/__tests__/timeoutManager.test.tsx b/packages/query-core/src/__tests__/timeoutManager.test.tsx index a9fbd51066..558a7369d6 100644 --- a/packages/query-core/src/__tests__/timeoutManager.test.tsx +++ b/packages/query-core/src/__tests__/timeoutManager.test.tsx @@ -9,7 +9,7 @@ import { describe('timeoutManager', () => { function createMockProvider(name: string = 'custom') { return { - name, + __TEST_ONLY__name: name, setTimeout: vi.fn(() => 123), clearTimeout: vi.fn(), setInterval: vi.fn(() => 456), From ad1fb2b6410c0053d3ddef632f2d171db9344f8c Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 3 Sep 2025 12:27:56 -0400 Subject: [PATCH 17/33] notifyManager: default scheduler === systemSetTimeoutZero --- packages/query-core/src/notifyManager.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/query-core/src/notifyManager.ts b/packages/query-core/src/notifyManager.ts index a517e9ed88..f4deb73194 100644 --- a/packages/query-core/src/notifyManager.ts +++ b/packages/query-core/src/notifyManager.ts @@ -12,8 +12,7 @@ type BatchCallsCallback> = (...args: T) => void type ScheduleFunction = (callback: () => void) => void -export const defaultScheduler: ScheduleFunction = (cb) => - systemSetTimeoutZero(cb) +export const defaultScheduler: ScheduleFunction = systemSetTimeoutZero export function createNotifyManager() { let queue: Array = [] From 932c3a2672cdfcd8f658439d0fc57ebfd9db76a1 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 3 Sep 2025 12:31:34 -0400 Subject: [PATCH 18/33] Improve TimeoutCallback comment since ai was confused --- packages/query-core/src/timeoutManager.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/query-core/src/timeoutManager.ts b/packages/query-core/src/timeoutManager.ts index 59496f7806..ac9eba8e99 100644 --- a/packages/query-core/src/timeoutManager.ts +++ b/packages/query-core/src/timeoutManager.ts @@ -1,6 +1,10 @@ /** - * Timeout manager does not support passing arguments to the callback. - * (`void` is the argument type inferred by TypeScript's default typings for `setTimeout(cb, number)`) + * {@link TimeoutManager} does not support passing arguments to the callback. + * + * `(_: void)` is the argument type inferred by TypeScript's default typings for + * `setTimeout(cb, number)`. + * If we don't accept a single void argument, then + * `new Promise(resolve => timeoutManager.setTimeout(resolve, N))` is a type error. */ export type TimeoutCallback = (_: void) => void From a6d38f8050a2ada42cf98c84ff7e8b9704915208 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 3 Sep 2025 12:33:20 -0400 Subject: [PATCH 19/33] remove unnecessary timeoutManager-related exports --- packages/query-core/src/index.ts | 2 +- packages/query-core/src/timeoutManager.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index d32ef58116..ee29b18cb4 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -18,7 +18,7 @@ export { QueryClient } from './queryClient' export { QueryObserver } from './queryObserver' export { CancelledError, isCancelledError } from './retryer' export { - defaultTimeoutProvider, systemSetTimeoutZero, timeoutManager, + timeoutManager, type ManagedTimerId, type TimeoutCallback, type TimeoutProvider diff --git a/packages/query-core/src/timeoutManager.ts b/packages/query-core/src/timeoutManager.ts index ac9eba8e99..e07604897f 100644 --- a/packages/query-core/src/timeoutManager.ts +++ b/packages/query-core/src/timeoutManager.ts @@ -13,9 +13,6 @@ export type TimeoutCallback = (_: void) => void * typings may extend the return type of `setTimeout`. For example, NodeJS * typings add `NodeJS.Timeout`; but a non-default `timeoutManager` may not be * able to return such a type. - * - * Still, we can downlevel `NodeJS.Timeout` to `number` as it implements - * Symbol.toPrimitive. */ export type ManagedTimerId = number | { [Symbol.toPrimitive]: () => number } @@ -31,7 +28,7 @@ export type TimeoutProvider = readonly clearInterval: (intervalId: TTimerId | undefined) => void } -export const defaultTimeoutProvider: TimeoutProvider< +const defaultTimeoutProvider: TimeoutProvider< ReturnType > = { // We need the wrapper function syntax below instead of direct references to From 81d35ace627a7f9c255ea3d0751aa75ee0009146 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 3 Sep 2025 12:35:41 -0400 Subject: [PATCH 20/33] prettier-ify index.ts (seems my editor messed with it already this pr?) --- packages/query-core/src/index.ts | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index ee29b18cb4..a7763cf648 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -2,8 +2,10 @@ export { focusManager } from './focusManager' export { - defaultShouldDehydrateMutation, defaultShouldDehydrateQuery, dehydrate, - hydrate + defaultShouldDehydrateMutation, + defaultShouldDehydrateQuery, + dehydrate, + hydrate, } from './hydration' export { InfiniteQueryObserver } from './infiniteQueryObserver' export { MutationCache } from './mutationCache' @@ -21,11 +23,19 @@ export { timeoutManager, type ManagedTimerId, type TimeoutCallback, - type TimeoutProvider + type TimeoutProvider, } from './timeoutManager' export { - hashKey, isServer, keepPreviousData, matchMutation, matchQuery, noop, partialMatchKey, - replaceEqualDeep, shouldThrowError, skipToken + hashKey, + isServer, + keepPreviousData, + matchMutation, + matchQuery, + noop, + partialMatchKey, + replaceEqualDeep, + shouldThrowError, + skipToken, } from './utils' export type { MutationFilters, QueryFilters, SkipToken, Updater } from './utils' @@ -33,7 +43,9 @@ export { streamedQuery as experimental_streamedQuery } from './streamedQuery' // Types export type { - DehydratedState, DehydrateOptions, HydrateOptions + DehydratedState, + DehydrateOptions, + HydrateOptions, } from './hydration' export { Mutation } from './mutation' export type { MutationState } from './mutation' @@ -41,4 +53,3 @@ export type { QueriesObserverOptions } from './queriesObserver' export { Query } from './query' export type { QueryState } from './query' export * from './types' - From b6fffb4cbdc5a3e494ecfa2cbf04ed6167dc6858 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 3 Sep 2025 12:36:48 -0400 Subject: [PATCH 21/33] continue to export defaultTimeoutProvider for tests --- packages/query-core/src/__tests__/timeoutManager.test.tsx | 5 +++-- packages/query-core/src/timeoutManager.ts | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/query-core/src/__tests__/timeoutManager.test.tsx b/packages/query-core/src/__tests__/timeoutManager.test.tsx index 558a7369d6..339abc6a76 100644 --- a/packages/query-core/src/__tests__/timeoutManager.test.tsx +++ b/packages/query-core/src/__tests__/timeoutManager.test.tsx @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TimeoutManager, - defaultTimeoutProvider, systemSetTimeoutZero, timeoutManager, } from '../timeoutManager' @@ -89,7 +88,9 @@ describe('timeoutManager', () => { const customProvider2 = createMockProvider('custom2') manager.setTimeoutProvider(customProvider2) expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringMatching(/\[timeoutManager\]: Switching .* might result in unexpected behavior\..*/), + expect.stringMatching( + /\[timeoutManager\]: Switching .* might result in unexpected behavior\..*/, + ), expect.anything(), ) diff --git a/packages/query-core/src/timeoutManager.ts b/packages/query-core/src/timeoutManager.ts index e07604897f..410243a616 100644 --- a/packages/query-core/src/timeoutManager.ts +++ b/packages/query-core/src/timeoutManager.ts @@ -28,7 +28,7 @@ export type TimeoutProvider = readonly clearInterval: (intervalId: TTimerId | undefined) => void } -const defaultTimeoutProvider: TimeoutProvider< +export const defaultTimeoutProvider: TimeoutProvider< ReturnType > = { // We need the wrapper function syntax below instead of direct references to From 948c646e47dcc04b15d00edd556fa090ddd3c7a4 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 3 Sep 2025 13:22:01 -0400 Subject: [PATCH 22/33] oops missing import --- packages/query-core/src/__tests__/timeoutManager.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/query-core/src/__tests__/timeoutManager.test.tsx b/packages/query-core/src/__tests__/timeoutManager.test.tsx index 339abc6a76..218e45cd97 100644 --- a/packages/query-core/src/__tests__/timeoutManager.test.tsx +++ b/packages/query-core/src/__tests__/timeoutManager.test.tsx @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TimeoutManager, + defaultTimeoutProvider, systemSetTimeoutZero, timeoutManager, } from '../timeoutManager' From 841ac54e18b1c482e9b9893ca68b20f0e00f76d3 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 4 Sep 2025 15:05:08 +0200 Subject: [PATCH 23/33] fix: export systemSetTimeoutZero from core --- packages/query-core/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index a7763cf648..afd7dc60bb 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -20,6 +20,7 @@ export { QueryClient } from './queryClient' export { QueryObserver } from './queryObserver' export { CancelledError, isCancelledError } from './retryer' export { + systemSetTimeoutZero, timeoutManager, type ManagedTimerId, type TimeoutCallback, From fb22c6763c1a182be5dc97eb4e31a6ec4a13950a Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 4 Sep 2025 15:08:52 +0200 Subject: [PATCH 24/33] ref: use notifyManager.schedule in createPersister because it needs to work with whatever scheduleFn we have set there --- packages/query-core/src/index.ts | 1 - packages/query-persist-client-core/src/createPersister.ts | 8 +++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/query-core/src/index.ts b/packages/query-core/src/index.ts index afd7dc60bb..a7763cf648 100644 --- a/packages/query-core/src/index.ts +++ b/packages/query-core/src/index.ts @@ -20,7 +20,6 @@ export { QueryClient } from './queryClient' export { QueryObserver } from './queryObserver' export { CancelledError, isCancelledError } from './retryer' export { - systemSetTimeoutZero, timeoutManager, type ManagedTimerId, type TimeoutCallback, diff --git a/packages/query-persist-client-core/src/createPersister.ts b/packages/query-persist-client-core/src/createPersister.ts index ab45e12fb8..f3912b6da2 100644 --- a/packages/query-persist-client-core/src/createPersister.ts +++ b/packages/query-persist-client-core/src/createPersister.ts @@ -1,8 +1,8 @@ import { hashKey, matchQuery, + notifyManager, partialMatchKey, - systemSetTimeoutZero, } from '@tanstack/query-core' import type { Query, @@ -130,7 +130,9 @@ export function experimental_createQueryPersister({ } else { if (afterRestoreMacroTask) { // Just after restoring we want to get fresh data from the server if it's stale - systemSetTimeoutZero(() => afterRestoreMacroTask(persistedQuery)) + notifyManager.schedule(() => + afterRestoreMacroTask(persistedQuery), + ) } // We must resolve the promise here, as otherwise we will have `loading` state in the app until `queryFn` resolves return persistedQuery.state.data as T @@ -218,7 +220,7 @@ export function experimental_createQueryPersister({ if (matchesFilter && storage != null) { // Persist if we have storage defined, we use timeout to get proper state to be persisted - systemSetTimeoutZero(() => { + notifyManager.schedule(() => { persistQuery(query) }) } From 09a787e2f82ac6e86f234f20f1c8a1427a7f1301 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Thu, 4 Sep 2025 15:16:56 +0200 Subject: [PATCH 25/33] ref: move provider check behind env check --- packages/query-core/src/timeoutManager.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/query-core/src/timeoutManager.ts b/packages/query-core/src/timeoutManager.ts index 410243a616..97f0870eea 100644 --- a/packages/query-core/src/timeoutManager.ts +++ b/packages/query-core/src/timeoutManager.ts @@ -72,12 +72,8 @@ export class TimeoutManager implements Omit { setTimeoutProvider( provider: TimeoutProvider, ): void { - if (provider === this.#provider) { - return - } - if (process.env.NODE_ENV !== 'production') { - if (this.#providerCalled) { + if (this.#providerCalled && provider !== this.#provider) { // After changing providers, `clearTimeout` will not work as expected for // timeouts from the previous provider. // From 7fb57b1a8aa020c59c54941b7cfb972fbfde77d6 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Thu, 4 Sep 2025 14:25:33 -0400 Subject: [PATCH 26/33] docs --- docs/config.json | 4 ++ docs/reference/timeoutManager.md | 116 +++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 docs/reference/timeoutManager.md diff --git a/docs/config.json b/docs/config.json index 5f1bc1913e..31182f860b 100644 --- a/docs/config.json +++ b/docs/config.json @@ -757,6 +757,10 @@ { "label": "notifyManager", "to": "reference/notifyManager" + }, + { + "label": "timeoutManager", + "to": "reference/timeoutManager" } ], "frameworks": [ diff --git a/docs/reference/timeoutManager.md b/docs/reference/timeoutManager.md new file mode 100644 index 0000000000..969a29285c --- /dev/null +++ b/docs/reference/timeoutManager.md @@ -0,0 +1,116 @@ +--- +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 other timeoutManager methods, 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 browser maximum delay value of [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 because runtimes like NodeJS return [objects][nodejs-timeout] from their global `setTimeout` and `setInterval` functions. You are free to coerce timer IDs to number internally, or to return your own custom object type. + +[nodejs-timeout]: https://nodejs.org/api/timers.html#class-timeout + +```tsx +type ManagedTimerId = number | { [Symbol.toPrimitive]: () => number } + +type TimeoutProvider = { + 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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toPrimitive). + +```tsx +import { timeoutManager } from '@tanstack/react-query' + +const timeoutId = timeoutManager.setTimeout( + () => console.log('ran at:', new Date()), + 1000, +) +``` + +## `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.setTimeout( + () => 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.setTimeout( + () => console.log('ran at:', new Date()), + 1000, +) + +timeoutManager.clearInterval(intervalId) +``` From 4b1e8afc6f2bebb514d31c768161008335aa8d50 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Thu, 4 Sep 2025 14:32:49 -0400 Subject: [PATCH 27/33] doc tweaks --- docs/reference/timeoutManager.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/reference/timeoutManager.md b/docs/reference/timeoutManager.md index 969a29285c..22c2ea83b1 100644 --- a/docs/reference/timeoutManager.md +++ b/docs/reference/timeoutManager.md @@ -39,9 +39,10 @@ export const queryClient = new QueryClient() 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 because runtimes like NodeJS return [objects][nodejs-timeout] from their global `setTimeout` and `setInterval` functions. You are free to coerce timer IDs to number internally, or to return your own custom object type. +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 } @@ -59,7 +60,7 @@ type TimeoutProvider = { `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](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/toPrimitive). +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' @@ -68,6 +69,8 @@ const timeoutId = timeoutManager.setTimeout( () => console.log('ran at:', new Date()), 1000, ) + +const timeoutIdNumber: number = Number(timeoutId) ``` ## `timeoutManager.clearTimeout` From b6ca80da99cc259912180671598c2d183743eb0a Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Thu, 4 Sep 2025 15:11:34 -0400 Subject: [PATCH 28/33] doc tweaks --- docs/reference/timeoutManager.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/timeoutManager.md b/docs/reference/timeoutManager.md index 22c2ea83b1..92dcc7edfa 100644 --- a/docs/reference/timeoutManager.md +++ b/docs/reference/timeoutManager.md @@ -20,9 +20,9 @@ Its available methods are: ## `timeoutManager.setTimeoutProvider` -`setTimeoutProvider` can be used to set a custom implementation of the other timeoutManager methods, called a `TimeoutProvider`. +`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 browser maximum delay value of [24 days](https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value). +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. From 73014f5b0c6e7c8464cd53ccf198a1d85f170428 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Thu, 4 Sep 2025 15:11:52 -0400 Subject: [PATCH 29/33] docs: reference timeoutManager in discussion of 24 day setTimout limit --- docs/framework/react/plugins/persistQueryClient.md | 2 +- docs/framework/react/reference/useMutation.md | 2 +- docs/framework/react/reference/useQuery.md | 2 +- docs/framework/solid/reference/useQuery.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/framework/react/plugins/persistQueryClient.md b/docs/framework/react/plugins/persistQueryClient.md index c93c99a532..d840efea39 100644 --- a/docs/framework/react/plugins/persistQueryClient.md +++ b/docs/framework/react/plugins/persistQueryClient.md @@ -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({ diff --git a/docs/framework/react/reference/useMutation.md b/docs/framework/react/reference/useMutation.md index e3800b0025..4537d26967 100644 --- a/docs/framework/react/reference/useMutation.md +++ b/docs/framework/react/reference/useMutation.md @@ -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`. diff --git a/docs/framework/react/reference/useQuery.md b/docs/framework/react/reference/useQuery.md index 2eda63b605..bc564b8027 100644 --- a/docs/framework/react/reference/useQuery.md +++ b/docs/framework/react/reference/useQuery.md @@ -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 diff --git a/docs/framework/solid/reference/useQuery.md b/docs/framework/solid/reference/useQuery.md index f36ec906b8..fd84ae7f1a 100644 --- a/docs/framework/solid/reference/useQuery.md +++ b/docs/framework/solid/reference/useQuery.md @@ -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 From 3f452ea3e8c6a0df35ddecfa9b2f20d43a2be5ce Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Fri, 5 Sep 2025 11:57:21 +0200 Subject: [PATCH 30/33] Apply suggestion from @TkDodo --- docs/reference/timeoutManager.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/timeoutManager.md b/docs/reference/timeoutManager.md index 92dcc7edfa..825a673260 100644 --- a/docs/reference/timeoutManager.md +++ b/docs/reference/timeoutManager.md @@ -97,7 +97,7 @@ Like `setTimeout`, it returns a timer ID, which may be a number or an object tha ```tsx import { timeoutManager } from '@tanstack/react-query' -const intervalId = timeoutManager.setTimeout( +const intervalId = timeoutManager.setInterval( () => console.log('ran at:', new Date()), 1000, ) From cca861f288b9a61191f7d59d070ca1eecff484f8 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Fri, 5 Sep 2025 11:57:28 +0200 Subject: [PATCH 31/33] Apply suggestion from @TkDodo --- docs/reference/timeoutManager.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/timeoutManager.md b/docs/reference/timeoutManager.md index 825a673260..4ce710484d 100644 --- a/docs/reference/timeoutManager.md +++ b/docs/reference/timeoutManager.md @@ -110,7 +110,7 @@ const intervalId = timeoutManager.setInterval( ```tsx import { timeoutManager } from '@tanstack/react-query' -const intervalId = timeoutManager.setTimeout( +const intervalId = timeoutManager.setInterval( () => console.log('ran at:', new Date()), 1000, ) From d58e28f0ae99e12625db666c3f4a5e4a75dab956 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 5 Sep 2025 13:27:27 +0200 Subject: [PATCH 32/33] chore: fix broken links --- docs/framework/react/plugins/persistQueryClient.md | 2 +- docs/framework/react/reference/useMutation.md | 2 +- docs/framework/react/reference/useQuery.md | 2 +- docs/framework/solid/reference/useQuery.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/framework/react/plugins/persistQueryClient.md b/docs/framework/react/plugins/persistQueryClient.md index d840efea39..98ccf76cf5 100644 --- a/docs/framework/react/plugins/persistQueryClient.md +++ b/docs/framework/react/plugins/persistQueryClient.md @@ -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](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). +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({ diff --git a/docs/framework/react/reference/useMutation.md b/docs/framework/react/reference/useMutation.md index 4537d26967..76d88900df 100644 --- a/docs/framework/react/reference/useMutation.md +++ b/docs/framework/react/reference/useMutation.md @@ -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](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). + - 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`. diff --git a/docs/framework/react/reference/useQuery.md b/docs/framework/react/reference/useQuery.md index bc564b8027..54be8ac837 100644 --- a/docs/framework/react/reference/useQuery.md +++ b/docs/framework/react/reference/useQuery.md @@ -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](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). + - 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 diff --git a/docs/framework/solid/reference/useQuery.md b/docs/framework/solid/reference/useQuery.md index fd84ae7f1a..69b3d6b06e 100644 --- a/docs/framework/solid/reference/useQuery.md +++ b/docs/framework/solid/reference/useQuery.md @@ -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](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). + - 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 From 7922966810988298e6c40761b8888597d6e0e0d7 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Fri, 5 Sep 2025 13:33:01 +0200 Subject: [PATCH 33/33] docs: syntax fix --- docs/framework/solid/reference/useQuery.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/framework/solid/reference/useQuery.md b/docs/framework/solid/reference/useQuery.md index 69b3d6b06e..e3c042618a 100644 --- a/docs/framework/solid/reference/useQuery.md +++ b/docs/framework/solid/reference/useQuery.md @@ -225,7 +225,7 @@ function App() { - 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](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` + - ##### `networkMode: 'online' | 'always' | 'offlineFirst'` - optional - defaults to `'online'` - see [Network Mode](../../guides/network-mode.md) for more information.