From ef887c9238e1c6da1e49989970ffb27c85b31b20 Mon Sep 17 00:00:00 2001 From: ljjunh Date: Mon, 6 Apr 2026 15:49:59 +0900 Subject: [PATCH] fix(query-core): use !== undefined for timer ID checks to handle falsy 0 --- .changeset/seven-facts-unite.md | 6 ++ .../src/__tests__/queryObserver.test.tsx | 57 +++++++++++++++++++ .../src/__tests__/removable.test.tsx | 51 +++++++++++++++++ packages/query-core/src/queryObserver.ts | 4 +- packages/query-core/src/removable.ts | 2 +- 5 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 .changeset/seven-facts-unite.md create mode 100644 packages/query-core/src/__tests__/removable.test.tsx diff --git a/.changeset/seven-facts-unite.md b/.changeset/seven-facts-unite.md new file mode 100644 index 00000000000..647b91b5c30 --- /dev/null +++ b/.changeset/seven-facts-unite.md @@ -0,0 +1,6 @@ +--- +'@tanstack/query-core': patch +--- + +fix: use !== undefined instead of truthy check for timer IDs to correctly handle falsy value 0 in clearGcTimeout, + diff --git a/packages/query-core/src/__tests__/queryObserver.test.tsx b/packages/query-core/src/__tests__/queryObserver.test.tsx index 689dd8d2e19..90a4529cccf 100644 --- a/packages/query-core/src/__tests__/queryObserver.test.tsx +++ b/packages/query-core/src/__tests__/queryObserver.test.tsx @@ -10,6 +10,7 @@ import { } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryClient, QueryObserver, focusManager } from '..' +import { defaultTimeoutProvider, timeoutManager } from '../timeoutManager' import type { QueryObserverResult } from '..' describe('queryObserver', () => { @@ -1626,4 +1627,60 @@ describe('queryObserver', () => { unsubscribe2() }) }) + + describe('falsy timer ID (0) handling', () => { + test('should call clearTimeout when stale timer ID is 0', async () => { + const provider = { + setTimeout: vi.fn(() => 0), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 0), + clearInterval: vi.fn(), + } + timeoutManager.setTimeoutProvider(provider) + + const key = queryKey() + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => 'data', + staleTime: 100, + }) + + const unsubscribe = observer.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + + // setOptions triggers #clearStaleTimeout → must call clearTimeout(0) + observer.setOptions({ queryKey: key, queryFn: () => 'data', staleTime: 200 }) + + expect(provider.clearTimeout).toHaveBeenCalledWith(0) + + unsubscribe() + timeoutManager.setTimeoutProvider(defaultTimeoutProvider) + }) + + test('should call clearInterval when refetch interval timer ID is 0', async () => { + const provider = { + setTimeout: vi.fn(() => 0), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 0), + clearInterval: vi.fn(), + } + timeoutManager.setTimeoutProvider(provider) + + const key = queryKey() + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => 'data', + refetchInterval: 100, + }) + + const unsubscribe = observer.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + + unsubscribe() // destroy → #clearRefetchInterval → must call clearInterval(0) + + expect(provider.clearInterval).toHaveBeenCalledWith(0) + + timeoutManager.setTimeoutProvider(defaultTimeoutProvider) + }) + }) }) diff --git a/packages/query-core/src/__tests__/removable.test.tsx b/packages/query-core/src/__tests__/removable.test.tsx new file mode 100644 index 00000000000..40f82145991 --- /dev/null +++ b/packages/query-core/src/__tests__/removable.test.tsx @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { queryKey } from '@tanstack/query-test-utils' +import { QueryClient, QueryObserver } from '..' +import { defaultTimeoutProvider, timeoutManager } from '../timeoutManager' + +describe('removable', () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.useFakeTimers() + queryClient = new QueryClient() + queryClient.mount() + }) + + afterEach(() => { + queryClient.clear() + vi.useRealTimers() + timeoutManager.setTimeoutProvider(defaultTimeoutProvider) + }) + + describe('falsy timer ID (0) handling', () => { + test('should call clearTimeout for gcTimeout when timer ID is 0', async () => { + const provider = { + setTimeout: vi.fn(() => 0), + clearTimeout: vi.fn(), + setInterval: vi.fn(() => 0), + clearInterval: vi.fn(), + } + timeoutManager.setTimeoutProvider(provider) + + const key = queryKey() + const observer = new QueryObserver(queryClient, { + queryKey: key, + queryFn: () => 'data', + gcTime: 100, + }) + + // Subscribe then unsubscribe: no observers left → scheduleGc() sets #gcTimeout = 0 + const unsubscribe = observer.subscribe(() => undefined) + await vi.advanceTimersByTimeAsync(0) + unsubscribe() + + // Subscribe again: addObserver calls clearGcTimeout() → must call clearTimeout(0) + const unsubscribe2 = observer.subscribe(() => undefined) + + expect(provider.clearTimeout).toHaveBeenCalledWith(0) + + unsubscribe2() + }) + }) +}) diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index a290c700b58..fa950bcfff3 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -417,14 +417,14 @@ export class QueryObserver< } #clearStaleTimeout(): void { - if (this.#staleTimeoutId) { + if (this.#staleTimeoutId !== undefined) { timeoutManager.clearTimeout(this.#staleTimeoutId) this.#staleTimeoutId = undefined } } #clearRefetchInterval(): void { - if (this.#refetchIntervalId) { + if (this.#refetchIntervalId !== undefined) { timeoutManager.clearInterval(this.#refetchIntervalId) this.#refetchIntervalId = undefined } diff --git a/packages/query-core/src/removable.ts b/packages/query-core/src/removable.ts index 68545f74383..62e524219ca 100644 --- a/packages/query-core/src/removable.ts +++ b/packages/query-core/src/removable.ts @@ -30,7 +30,7 @@ export abstract class Removable { } protected clearGcTimeout() { - if (this.#gcTimeout) { + if (this.#gcTimeout !== undefined) { timeoutManager.clearTimeout(this.#gcTimeout) this.#gcTimeout = undefined }