diff --git a/.changeset/smart-crabs-flow.md b/.changeset/smart-crabs-flow.md new file mode 100644 index 0000000000..ce36c5865f --- /dev/null +++ b/.changeset/smart-crabs-flow.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +Introduce a centralized GCManager that consolidates individual timeouts in queries and mutations into a single scanning interval. diff --git a/packages/query-core/src/__tests__/gcManager.test.tsx b/packages/query-core/src/__tests__/gcManager.test.tsx new file mode 100644 index 0000000000..2aeeea6b0b --- /dev/null +++ b/packages/query-core/src/__tests__/gcManager.test.tsx @@ -0,0 +1,781 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' +import { GCManager } from '../gcManager' +import type { Removable } from '../removable' + +/** + * Creates a mock Removable item for testing + */ +function createMockRemovable(config: { + gcTime: number + markedAt?: number + isEligibleFn?: () => boolean + shouldRemove?: boolean +}): Removable { + const markedAt = config.markedAt ?? Date.now() + const shouldRemove = config.shouldRemove ?? true + + const isEligibleForGcFn = config.isEligibleFn + ? vi.fn(config.isEligibleFn) + : vi.fn(() => { + if (config.gcTime === Infinity) { + return false + } + return Date.now() >= markedAt + config.gcTime + }) + + return { + isEligibleForGc: isEligibleForGcFn, + optionalRemove: vi.fn(() => shouldRemove), + getGcAtTimestamp: () => { + if (config.gcTime === Infinity) { + return Infinity + } + return markedAt + config.gcTime + }, + } as unknown as Removable +} + +describe('gcManager', () => { + let gcManager: GCManager + + beforeEach(() => { + vi.useFakeTimers() + gcManager = new GCManager() + }) + + afterEach(() => { + gcManager.clear() + vi.useRealTimers() + }) + + describe('initialization and configuration', () => { + test('should not start scanning initially when no items are marked for GC', () => { + // GC manager should not be scanning initially + expect(gcManager.isScanning()).toBe(false) + expect(gcManager.getEligibleItemCount()).toBe(0) + }) + + test('should handle default configuration', () => { + const defaultGcManager = new GCManager() + + expect(defaultGcManager.isScanning()).toBe(false) + expect(defaultGcManager.getEligibleItemCount()).toBe(0) + + defaultGcManager.clear() + }) + }) + + describe('basic tracking and scanning', () => { + test('should start scanning when an item is marked for GC', async () => { + const item = createMockRemovable({ gcTime: 100 }) + + // Track the item - this should start scanning + gcManager.trackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + + // GC manager should now be scanning and tracking the item + expect(gcManager.isScanning()).toBe(true) + expect(gcManager.getEligibleItemCount()).toBe(1) + }) + + test('should stop scanning when all items are garbage collected', async () => { + const item = createMockRemovable({ gcTime: 10 }) + + gcManager.trackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.isScanning()).toBe(true) + expect(gcManager.getEligibleItemCount()).toBe(1) + + // Advance time past gcTime + await vi.advanceTimersByTimeAsync(20) + + // Item should be collected and GC should stop + expect(gcManager.isScanning()).toBe(false) + expect(gcManager.getEligibleItemCount()).toBe(0) + expect(item.optionalRemove).toHaveBeenCalled() + }) + + test('should restart scanning when a new item is marked after stopping', async () => { + const item1 = createMockRemovable({ gcTime: 10 }) + + gcManager.trackEligibleItem(item1) + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.isScanning()).toBe(true) + + // Wait for first item to be collected + await vi.advanceTimersByTimeAsync(20) + expect(gcManager.isScanning()).toBe(false) + + // Create second item + const item2 = createMockRemovable({ gcTime: 10 }) + + gcManager.trackEligibleItem(item2) + await vi.advanceTimersByTimeAsync(0) + + // GC should restart + expect(gcManager.isScanning()).toBe(true) + expect(gcManager.getEligibleItemCount()).toBe(1) + }) + }) + + describe('multiple items with different gc times', () => { + test('should handle multiple items being marked and collected', async () => { + const item1 = createMockRemovable({ gcTime: 10 }) + const item2 = createMockRemovable({ gcTime: 20 }) + const item3 = createMockRemovable({ gcTime: 30 }) + + gcManager.trackEligibleItem(item1) + gcManager.trackEligibleItem(item2) + gcManager.trackEligibleItem(item3) + + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.isScanning()).toBe(true) + expect(gcManager.getEligibleItemCount()).toBe(3) + + // First item should be collected + await vi.advanceTimersByTimeAsync(15) + expect(gcManager.getEligibleItemCount()).toBe(2) + expect(gcManager.isScanning()).toBe(true) // Still have 2 items + expect(item1.optionalRemove).toHaveBeenCalled() + + // Second item should be collected + await vi.advanceTimersByTimeAsync(10) + expect(gcManager.getEligibleItemCount()).toBe(1) + expect(gcManager.isScanning()).toBe(true) // Still have 1 item + expect(item2.optionalRemove).toHaveBeenCalled() + + // Third item should be collected and GC should stop + await vi.advanceTimersByTimeAsync(10) + expect(gcManager.getEligibleItemCount()).toBe(0) + expect(gcManager.isScanning()).toBe(false) + expect(item3.optionalRemove).toHaveBeenCalled() + }) + + test('should schedule next scan for the nearest gc time', async () => { + const item1 = createMockRemovable({ gcTime: 50 }) + const item2 = createMockRemovable({ gcTime: 100 }) + const item3 = createMockRemovable({ gcTime: 150 }) + + gcManager.trackEligibleItem(item1) + gcManager.trackEligibleItem(item2) + gcManager.trackEligibleItem(item3) + + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.getEligibleItemCount()).toBe(3) + + // Should collect the first one at 50ms + await vi.advanceTimersByTimeAsync(55) + expect(gcManager.getEligibleItemCount()).toBe(2) + expect(item1.optionalRemove).toHaveBeenCalled() + + // Should still have the other two + expect(item2.optionalRemove).not.toHaveBeenCalled() + expect(item3.optionalRemove).not.toHaveBeenCalled() + }) + + test('should handle items added at different times', async () => { + const item1 = createMockRemovable({ gcTime: 100, markedAt: Date.now() }) + + gcManager.trackEligibleItem(item1) + await vi.advanceTimersByTimeAsync(0) + expect(gcManager.getEligibleItemCount()).toBe(1) + + // Advance time but not enough to collect + await vi.advanceTimersByTimeAsync(50) + + // Add second item + const item2 = createMockRemovable({ + gcTime: 30, + markedAt: Date.now(), + }) + + gcManager.trackEligibleItem(item2) + await vi.advanceTimersByTimeAsync(0) + expect(gcManager.getEligibleItemCount()).toBe(2) + + // Second item should be collected first (30ms from its mark time) + await vi.advanceTimersByTimeAsync(35) + expect(gcManager.getEligibleItemCount()).toBe(1) + expect(item2.optionalRemove).toHaveBeenCalled() + expect(item1.optionalRemove).not.toHaveBeenCalled() + + // First item should be collected next + await vi.advanceTimersByTimeAsync(20) + expect(gcManager.getEligibleItemCount()).toBe(0) + expect(item1.optionalRemove).toHaveBeenCalled() + }) + }) + + describe('tracking and un-tracking', () => { + test('should un-track item when it is no longer eligible', async () => { + const item = createMockRemovable({ gcTime: 100 }) + + gcManager.trackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.isScanning()).toBe(true) + expect(gcManager.getEligibleItemCount()).toBe(1) + + // Untrack the item + gcManager.untrackEligibleItem(item) + + expect(gcManager.getEligibleItemCount()).toBe(0) + }) + + test('should not track same item twice', async () => { + const item = createMockRemovable({ gcTime: 100 }) + + gcManager.trackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.getEligibleItemCount()).toBe(1) + + // Try to track again - should not increase count + gcManager.trackEligibleItem(item) + + expect(gcManager.getEligibleItemCount()).toBe(1) + }) + + test('should handle un-tracking non-existent item', () => { + const mockItem = createMockRemovable({ gcTime: 100 }) + + // Untrack without tracking first - should not throw + expect(() => { + gcManager.untrackEligibleItem(mockItem) + }).not.toThrow() + + expect(gcManager.getEligibleItemCount()).toBe(0) + }) + + test('should stop scanning when un-tracking last item while scanning', async () => { + const item = createMockRemovable({ gcTime: 100 }) + + gcManager.trackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.isScanning()).toBe(true) + expect(gcManager.getEligibleItemCount()).toBe(1) + + // Untrack the item + gcManager.untrackEligibleItem(item) + + expect(gcManager.getEligibleItemCount()).toBe(0) + expect(gcManager.isScanning()).toBe(false) + }) + + test('should reschedule scan when un-tracking item but others remain', async () => { + const item1 = createMockRemovable({ gcTime: 100 }) + const item2 = createMockRemovable({ gcTime: 200 }) + + gcManager.trackEligibleItem(item1) + gcManager.trackEligibleItem(item2) + + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.getEligibleItemCount()).toBe(2) + expect(gcManager.isScanning()).toBe(true) + + // Untrack first item + gcManager.untrackEligibleItem(item1) + + expect(gcManager.getEligibleItemCount()).toBe(1) + expect(gcManager.isScanning()).toBe(true) + }) + + test('should stop scanning and clear timers when un-tracking eligible item', async () => { + const gcTime = 100 + const item = createMockRemovable({ + gcTime, + isEligibleFn: () => false, // Not eligible yet, to prevent immediate removal + }) + + // Track the item - this should schedule a scan + gcManager.trackEligibleItem(item) + + // Wait for microtask to complete so the scan timeout is scheduled + await vi.advanceTimersByTimeAsync(0) + + // Verify item is tracked and scanning is active + expect(gcManager.getEligibleItemCount()).toBe(1) + expect(gcManager.isScanning()).toBe(true) + + // Un-track the item - this should stop scanning and clear timers + gcManager.untrackEligibleItem(item) + + // Verify scanning stopped immediately after un-tracking + expect(gcManager.isScanning()).toBe(false) + expect(gcManager.getEligibleItemCount()).toBe(0) + + // Verify timers are cleared by advancing time significantly past the original gcTime + await vi.advanceTimersByTimeAsync(gcTime + 100) + + // Verify the scan callback never fired (isEligibleForGc was never called) + // This proves the scheduled timeout was cleared + expect(item.isEligibleForGc).not.toHaveBeenCalled() + expect(item.optionalRemove).not.toHaveBeenCalled() + }) + }) + + describe('edge cases', () => { + test('should handle items with infinite gcTime', async () => { + const item = createMockRemovable({ gcTime: Infinity }) + + gcManager.trackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + + // Item with infinite gcTime should not be tracked (returns Infinity from getGcAtTimestamp) + // But actually, it will be tracked, just won't schedule a scan + expect(gcManager.getEligibleItemCount()).toBe(1) + expect(gcManager.isScanning()).toBe(false) + + // Item should still exist after a long time + await vi.advanceTimersByTimeAsync(100000) + expect(gcManager.getEligibleItemCount()).toBe(1) + }) + + test('should handle items with zero gcTime', async () => { + const item = createMockRemovable({ gcTime: 0 }) + + gcManager.trackEligibleItem(item) + + // With gcTime 0, the item is collected almost immediately + await vi.advanceTimersByTimeAsync(0) + + // The item should be eligible and scanning should have started + // But it might already be collected by the time we check + expect(gcManager.isScanning()).toBe(false) + expect(gcManager.getEligibleItemCount()).toBe(0) + expect(item.optionalRemove).toHaveBeenCalled() + }) + + test('should handle very large gcTime values', async () => { + // Use a large but reasonable value (1 day in ms) + const largeGcTime = 24 * 60 * 60 * 1000 + const item = createMockRemovable({ gcTime: largeGcTime }) + + gcManager.trackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.isScanning()).toBe(true) + expect(gcManager.getEligibleItemCount()).toBe(1) + + // Item should not be collected after a reasonable time + await vi.advanceTimersByTimeAsync(1000) + expect(gcManager.getEligibleItemCount()).toBe(1) + expect(item.optionalRemove).not.toHaveBeenCalled() + }) + + test('should not run continuously when application is idle', async () => { + // Start with no items + expect(gcManager.isScanning()).toBe(false) + + // Advance time - GC should not start on its own + await vi.advanceTimersByTimeAsync(10000) + expect(gcManager.isScanning()).toBe(false) + + // Add an item + const item = createMockRemovable({ gcTime: 10 }) + gcManager.trackEligibleItem(item) + + await vi.advanceTimersByTimeAsync(0) + + // GC should start + expect(gcManager.isScanning()).toBe(true) + + // Wait for collection + await vi.advanceTimersByTimeAsync(20) + + // GC should stop after collection + expect(gcManager.isScanning()).toBe(false) + + // Advance time again - GC should remain stopped + await vi.advanceTimersByTimeAsync(10000) + expect(gcManager.isScanning()).toBe(false) + }) + + test('should handle items with very small gcTime (edge of zero)', async () => { + const item = createMockRemovable({ gcTime: 1 }) + + gcManager.trackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.isScanning()).toBe(true) + expect(gcManager.getEligibleItemCount()).toBe(1) + + // Should be collected very quickly + await vi.advanceTimersByTimeAsync(5) + expect(gcManager.getEligibleItemCount()).toBe(0) + expect(item.optionalRemove).toHaveBeenCalled() + }) + + test('should handle mix of finite and infinite gcTime items', async () => { + const item1 = createMockRemovable({ gcTime: Infinity }) + const item2 = createMockRemovable({ gcTime: 50 }) + + gcManager.trackEligibleItem(item1) + gcManager.trackEligibleItem(item2) + + await vi.advanceTimersByTimeAsync(0) + + // Should schedule based on the item with finite time + expect(gcManager.isScanning()).toBe(true) + expect(gcManager.getEligibleItemCount()).toBe(2) + + // Collect the finite one + await vi.advanceTimersByTimeAsync(60) + expect(gcManager.getEligibleItemCount()).toBe(1) + expect(item2.optionalRemove).toHaveBeenCalled() + expect(item1.optionalRemove).not.toHaveBeenCalled() + }) + + test('should not schedule scan when all items have Infinity gcTime', async () => { + const item1 = createMockRemovable({ gcTime: Infinity }) + const item2 = createMockRemovable({ gcTime: Infinity }) + + gcManager.trackEligibleItem(item1) + gcManager.trackEligibleItem(item2) + + await vi.advanceTimersByTimeAsync(0) + + // Should not schedule scan when all items have Infinity + expect(gcManager.isScanning()).toBe(false) + expect(gcManager.getEligibleItemCount()).toBe(2) + }) + }) + + describe('error handling', () => { + test('should continue scanning other items when one throws during isEligibleForGc', async () => { + const item1 = createMockRemovable({ gcTime: 10 }) + const item2 = createMockRemovable({ gcTime: 10 }) + + // Mock first item to throw + item1.isEligibleForGc = vi.fn(() => { + throw new Error('Test error') + }) + + gcManager.trackEligibleItem(item1) + gcManager.trackEligibleItem(item2) + + await vi.advanceTimersByTimeAsync(0) + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + await vi.advanceTimersByTimeAsync(15) + + // Second item should still be collected despite first one throwing + expect(gcManager.getEligibleItemCount()).toBe(1) + expect(item2.optionalRemove).toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + }) + + test('should continue scanning other items when one throws during optionalRemove', async () => { + const item1 = createMockRemovable({ + gcTime: 10, + shouldRemove: true, + }) + const item2 = createMockRemovable({ gcTime: 10 }) + + // Mock first item to throw + item1.optionalRemove = vi.fn(() => { + throw new Error('Test error') + }) + + gcManager.trackEligibleItem(item1) + gcManager.trackEligibleItem(item2) + + await vi.advanceTimersByTimeAsync(0) + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + await vi.advanceTimersByTimeAsync(15) + + // Second item should still be collected despite first one throwing + expect(gcManager.getEligibleItemCount()).toBe(1) + expect(item2.optionalRemove).toHaveBeenCalled() + expect(consoleErrorSpy).toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + }) + + test('should not log errors in production', async () => { + const originalEnv = process.env.NODE_ENV + process.env.NODE_ENV = 'production' + + const item = createMockRemovable({ gcTime: 10 }) + + // Mock item to throw + item.isEligibleForGc = vi.fn(() => { + throw new Error('Test error') + }) + + gcManager.trackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}) + + await vi.advanceTimersByTimeAsync(15) + + // Should not log in production + expect(consoleErrorSpy).not.toHaveBeenCalled() + + consoleErrorSpy.mockRestore() + process.env.NODE_ENV = originalEnv + }) + }) + + describe('stopScanning', () => { + test('should stop scanning and clear timeout', async () => { + const item = createMockRemovable({ gcTime: 100 }) + + gcManager.trackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.isScanning()).toBe(true) + + // Stop scanning manually + gcManager.stopScanning() + + expect(gcManager.isScanning()).toBe(false) + + // Item should not be collected even after gcTime passes + await vi.advanceTimersByTimeAsync(150) + expect(gcManager.getEligibleItemCount()).toBe(1) + expect(item.optionalRemove).not.toHaveBeenCalled() + }) + + test('should be safe to call stopScanning multiple times', () => { + expect(() => { + gcManager.stopScanning() + gcManager.stopScanning() + gcManager.stopScanning() + }).not.toThrow() + + expect(gcManager.isScanning()).toBe(false) + }) + + test('should be safe to call stopScanning when not scanning', () => { + expect(gcManager.isScanning()).toBe(false) + expect(() => { + gcManager.stopScanning() + }).not.toThrow() + expect(gcManager.isScanning()).toBe(false) + }) + }) + + describe('clear', () => { + test('should clear all eligible items and stop scanning', async () => { + const item1 = createMockRemovable({ gcTime: 100 }) + const item2 = createMockRemovable({ gcTime: 100 }) + + gcManager.trackEligibleItem(item1) + gcManager.trackEligibleItem(item2) + + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.getEligibleItemCount()).toBe(2) + expect(gcManager.isScanning()).toBe(true) + + // Clear everything + gcManager.clear() + + expect(gcManager.getEligibleItemCount()).toBe(0) + expect(gcManager.isScanning()).toBe(false) + }) + + test('should be safe to call clear when not scanning', () => { + expect(() => { + gcManager.clear() + }).not.toThrow() + + expect(gcManager.getEligibleItemCount()).toBe(0) + expect(gcManager.isScanning()).toBe(false) + }) + + test('should be safe to call clear multiple times', () => { + expect(() => { + gcManager.clear() + gcManager.clear() + gcManager.clear() + }).not.toThrow() + + expect(gcManager.getEligibleItemCount()).toBe(0) + expect(gcManager.isScanning()).toBe(false) + }) + }) + + describe('scheduling behavior', () => { + test('should not double-schedule when a schedule is already queued (microtask guard)', async () => { + const item1 = createMockRemovable({ gcTime: 100 }) + const item2 = createMockRemovable({ gcTime: 50 }) + // Schedule first item + gcManager.trackEligibleItem(item1) + // Add second item before the microtask runs; second scheduleScan should be a no-op + gcManager.trackEligibleItem(item2) + await vi.advanceTimersByTimeAsync(0) + expect(gcManager.getEligibleItemCount()).toBe(2) + expect(gcManager.isScanning()).toBe(true) + }) + + test('should cancel previous timeout when rescheduling', async () => { + const item1 = createMockRemovable({ gcTime: 100 }) + const item2 = createMockRemovable({ gcTime: 20 }) + + gcManager.trackEligibleItem(item1) + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.isScanning()).toBe(true) + + // Add item with shorter time + gcManager.trackEligibleItem(item2) + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.getEligibleItemCount()).toBe(2) + + // Should collect the shorter one first + await vi.advanceTimersByTimeAsync(25) + + expect(item2.optionalRemove).toHaveBeenCalled() + expect(item1.optionalRemove).not.toHaveBeenCalled() + }) + + test('should handle stopScanning called before schedule completes', async () => { + const item = createMockRemovable({ gcTime: 100 }) + + gcManager.trackEligibleItem(item) + + // Don't wait for microtask scheduling to complete + // Immediately stop scanning - this tests the #isScheduledScan flag behavior + gcManager.stopScanning() + + await vi.advanceTimersByTimeAsync(0) + + // After microtask, scanning should still be stopped + expect(gcManager.isScanning()).toBe(false) + + // Item should not be collected + await vi.advanceTimersByTimeAsync(150) + expect(gcManager.getEligibleItemCount()).toBe(1) + expect(item.optionalRemove).not.toHaveBeenCalled() + }) + }) + + describe('integration scenarios', () => { + test('should handle rapid track/un-track cycles', async () => { + const item = createMockRemovable({ gcTime: 50 }) + + // Rapid cycles + for (let i = 0; i < 10; i++) { + gcManager.trackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + gcManager.untrackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + } + + // Make sure we have something to track + gcManager.trackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.getEligibleItemCount()).toBe(1) + expect(gcManager.isScanning()).toBe(true) + + await vi.advanceTimersByTimeAsync(60) + + expect(gcManager.getEligibleItemCount()).toBe(0) + expect(item.optionalRemove).toHaveBeenCalled() + }) + + test('should handle many items being added simultaneously', async () => { + const items = [] + + // Create many items + for (let i = 0; i < 50; i++) { + const item = createMockRemovable({ gcTime: 100 + i * 10 }) + items.push(item) + gcManager.trackEligibleItem(item) + } + + await vi.advanceTimersByTimeAsync(0) + + expect(gcManager.getEligibleItemCount()).toBe(50) + expect(gcManager.isScanning()).toBe(true) + + // Collect all + await vi.advanceTimersByTimeAsync(800) + + expect(gcManager.getEligibleItemCount()).toBe(0) + expect(gcManager.isScanning()).toBe(false) + + // Verify all items were attempted to be removed + items.forEach((item) => { + expect(item.optionalRemove).toHaveBeenCalled() + }) + }) + + test('should handle items that fail to be removed', async () => { + const item = createMockRemovable({ gcTime: 10, shouldRemove: false }) + + gcManager.trackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(15) + + // Item should still be tracked since it wasn't removed + expect(gcManager.getEligibleItemCount()).toBe(1) + expect(gcManager.isScanning()).toBe(true) + expect(item.optionalRemove).toHaveBeenCalled() + + // Create a new item that can be removed (since we can't change the mock function easily) + const newItem = createMockRemovable({ gcTime: 10, shouldRemove: true }) + gcManager.untrackEligibleItem(item) + gcManager.trackEligibleItem(newItem) + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(15) + + expect(gcManager.getEligibleItemCount()).toBe(0) + expect(newItem.optionalRemove).toHaveBeenCalled() + }) + + test('should handle items becoming ineligible during scan', async () => { + const item = createMockRemovable({ + gcTime: 10, + isEligibleFn: () => false, // Never eligible + }) + + gcManager.trackEligibleItem(item) + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(15) + + // Item should still exist since it's not eligible + expect(gcManager.getEligibleItemCount()).toBe(1) + expect(item.optionalRemove).not.toHaveBeenCalled() + + // Now make it eligible by creating a new item + const newItem = createMockRemovable({ + gcTime: 10, + isEligibleFn: () => true, + }) + gcManager.untrackEligibleItem(item) + gcManager.trackEligibleItem(newItem) + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(15) + + expect(gcManager.getEligibleItemCount()).toBe(0) + expect(newItem.optionalRemove).toHaveBeenCalled() + }) + }) +}) diff --git a/packages/query-core/src/__tests__/mutationCache.test.tsx b/packages/query-core/src/__tests__/mutationCache.test.tsx index e52498c64f..c4bda4754a 100644 --- a/packages/query-core/src/__tests__/mutationCache.test.tsx +++ b/packages/query-core/src/__tests__/mutationCache.test.tsx @@ -416,7 +416,7 @@ describe('mutationCache', () => { unsubscribe() - await vi.advanceTimersByTimeAsync(10) + await vi.advanceTimersByTimeAsync(11) expect(queryClient.getMutationCache().getAll()).toHaveLength(0) expect(onSuccess).toHaveBeenCalledTimes(1) }) diff --git a/packages/query-core/src/gcManager.ts b/packages/query-core/src/gcManager.ts new file mode 100644 index 0000000000..507a72a34f --- /dev/null +++ b/packages/query-core/src/gcManager.ts @@ -0,0 +1,197 @@ +import { timeoutManager } from './timeoutManager' +import type { Removable } from './removable' +import type { ManagedTimerId } from './timeoutManager' + +/** + * Configuration for the GC manager + */ +export interface GCManagerConfig { + /** + * Force disable garbage collection. + * @default false + */ + forceDisable?: boolean +} + +/** + * Manages garbage collection across all caches. + * + * Instead of each query/mutation having its own timeout, + * the GCManager schedules a single timeout for when the nearest + * item becomes eligible for removal. After scanning, it reschedules + * for the next nearest item. + */ +export class GCManager { + #isScanning = false + #forceDisable = false + #eligibleItems = new Set() + #scheduledScanTimeoutId: ManagedTimerId | null = null + #isScheduledScan = false + + constructor(config: GCManagerConfig = {}) { + this.#forceDisable = config.forceDisable ?? false + } + + #scheduleScan(): void { + if (this.#forceDisable || this.#isScheduledScan) { + return + } + + this.#isScheduledScan = true + + queueMicrotask(() => { + if (!this.#isScheduledScan) { + return + } + + this.#isScheduledScan = false + + let minTimeUntilGc = Infinity + + for (const item of this.#eligibleItems) { + const timeUntilGc = getTimeUntilGc(item) + + if (timeUntilGc < minTimeUntilGc) { + minTimeUntilGc = timeUntilGc + } + } + + if (minTimeUntilGc === Infinity) { + return + } + + if (this.#scheduledScanTimeoutId !== null) { + timeoutManager.clearTimeout(this.#scheduledScanTimeoutId) + } + + this.#isScanning = true + this.#scheduledScanTimeoutId = timeoutManager.setTimeout(() => { + this.#isScanning = false + this.#scheduledScanTimeoutId = null + + this.#performScan() + + // If there are still eligible items, schedule the next scan + if (this.#eligibleItems.size > 0) { + this.#scheduleScan() + } + }, minTimeUntilGc) + }) + } + + /** + * Stop scanning by clearing the scheduled timeout. Safe to call multiple times. + */ + stopScanning(): void { + this.#isScanning = false + this.#isScheduledScan = false + + if (this.#scheduledScanTimeoutId === null) { + return + } + + timeoutManager.clearTimeout(this.#scheduledScanTimeoutId) + + this.#scheduledScanTimeoutId = null + } + + /** + * Check if a scan is scheduled (timeout is pending). + * + * @returns true if a timeout is scheduled to perform a scan + */ + isScanning(): boolean { + return this.#isScanning + } + + /** + * Track an item that has been marked for garbage collection. + * Schedules a timeout to scan when the item becomes eligible (or reschedules + * if a timeout is already pending and this item will be ready sooner). + * + * @param item - The query or mutation marked for GC + */ + trackEligibleItem(item: Removable): void { + if (this.#forceDisable) { + return + } + + if (this.#eligibleItems.has(item)) { + return + } + + this.#eligibleItems.add(item) + + this.#scheduleScan() + } + + /** + * Untrack an item that is no longer eligible for garbage collection. + * If a timeout is scheduled and no items remain eligible, stops scanning. + * If a timeout is scheduled and items remain, reschedules for the next nearest item. + * + * @param item - The query or mutation no longer eligible for GC + */ + untrackEligibleItem(item: Removable): void { + if (this.#forceDisable) { + return + } + + if (!this.#eligibleItems.has(item)) { + return + } + + this.#eligibleItems.delete(item) + + if (this.isScanning()) { + if (this.getEligibleItemCount() === 0) { + this.stopScanning() + } else { + this.#scheduleScan() + } + } + } + + /** + * Get the number of items currently eligible for garbage collection. + */ + getEligibleItemCount(): number { + return this.#eligibleItems.size + } + + #performScan(): void { + // Iterate through all eligible items and attempt to collect them + for (const item of this.#eligibleItems) { + try { + if (item.isEligibleForGc()) { + const wasCollected = item.optionalRemove() + + if (wasCollected) { + this.#eligibleItems.delete(item) + } + } + } catch (error) { + // Log but don't throw - one cache error shouldn't stop others + if (process.env.NODE_ENV !== 'production') { + console.error('[GCManager] Error during garbage collection:', error) + } + } + } + } + + /** + * Clear all eligible items and stop any scheduled scans. + */ + clear(): void { + this.#eligibleItems.clear() + this.stopScanning() + } +} + +function getTimeUntilGc(item: Removable): number { + const gcAt = item.getGcAtTimestamp() + if (gcAt === null) { + return Infinity + } + return Math.max(0, gcAt - Date.now()) +} diff --git a/packages/query-core/src/mutation.ts b/packages/query-core/src/mutation.ts index a6b68699eb..7e05fa84c1 100644 --- a/packages/query-core/src/mutation.ts +++ b/packages/query-core/src/mutation.ts @@ -12,6 +12,7 @@ import type { MutationCache } from './mutationCache' import type { MutationObserver } from './mutationObserver' import type { Retryer } from './retryer' import type { QueryClient } from './queryClient' +import type { GCManager } from './gcManager' // TYPES @@ -108,9 +109,8 @@ export class Mutation< this.#mutationCache = config.mutationCache this.#observers = [] this.state = config.state || getDefaultState() - this.setOptions(config.options) - this.scheduleGc() + this.markForGc() } setOptions( @@ -125,12 +125,16 @@ export class Mutation< return this.options.meta } + protected getGcManager(): GCManager { + return this.#client.getGcManager() + } + addObserver(observer: MutationObserver): void { if (!this.#observers.includes(observer)) { this.#observers.push(observer) // Stop the mutation from being garbage collected - this.clearGcTimeout() + this.clearGcMark() this.#mutationCache.notify({ type: 'observerAdded', @@ -143,7 +147,9 @@ export class Mutation< removeObserver(observer: MutationObserver): void { this.#observers = this.#observers.filter((x) => x !== observer) - this.scheduleGc() + if (this.isSafeToRemove()) { + this.markForGc() + } this.#mutationCache.notify({ type: 'observerRemoved', @@ -152,14 +158,21 @@ export class Mutation< }) } - protected optionalRemove() { + private isSafeToRemove(): boolean { + return this.state.status !== 'pending' && this.#observers.length === 0 + } + + optionalRemove(): boolean { if (!this.#observers.length) { if (this.state.status === 'pending') { - this.scheduleGc() + this.markForGc() } else { this.#mutationCache.remove(this) + return true } } + + return false } continue(): Promise { @@ -370,6 +383,10 @@ export class Mutation< } this.state = reducer(this.state) + if (this.isSafeToRemove()) { + this.markForGc() + } + notifyManager.batch(() => { this.#observers.forEach((observer) => { observer.onMutationUpdate(action) diff --git a/packages/query-core/src/query.ts b/packages/query-core/src/query.ts index a34c8630dc..1bd5f8e55b 100644 --- a/packages/query-core/src/query.ts +++ b/packages/query-core/src/query.ts @@ -28,6 +28,7 @@ import type { } from './types' import type { QueryObserver } from './queryObserver' import type { Retryer } from './retryer' +import type { GCManager } from './gcManager' // TYPES @@ -172,6 +173,7 @@ export class Query< #cache: QueryCache #client: QueryClient #retryer?: Retryer + #gcManager: GCManager observers: Array> #defaultOptions?: QueryOptions #abortSignalConsumed: boolean @@ -179,18 +181,21 @@ export class Query< constructor(config: QueryConfig) { super() + this.#client = config.client + this.#gcManager = config.client.getGcManager() + this.#cache = this.#client.getQueryCache() this.#abortSignalConsumed = false this.#defaultOptions = config.defaultOptions - this.setOptions(config.options) this.observers = [] - this.#client = config.client - this.#cache = this.#client.getQueryCache() + this.setOptions(config.options) this.queryKey = config.queryKey this.queryHash = config.queryHash this.#initialState = getDefaultState(this.options) this.state = config.state ?? this.#initialState - this.scheduleGc() + + this.markForGc() } + get meta(): QueryMeta | undefined { return this.options.meta } @@ -199,6 +204,10 @@ export class Query< return this.#retryer?.promise } + protected getGcManager(): GCManager { + return this.#gcManager + } + setOptions( options?: QueryOptions, ): void { @@ -219,10 +228,18 @@ export class Query< } } - protected optionalRemove() { - if (!this.observers.length && this.state.fetchStatus === 'idle') { + optionalRemove(): boolean { + if (this.isSafeToRemove()) { this.#cache.remove(this) + return true + } else { + this.clearGcMark() } + return false + } + + private isSafeToRemove(): boolean { + return this.observers.length === 0 && this.state.fetchStatus === 'idle' } setData( @@ -346,7 +363,7 @@ export class Query< this.observers.push(observer) // Stop the query from being garbage collected - this.clearGcTimeout() + this.clearGcMark() this.#cache.notify({ type: 'observerAdded', query: this, observer }) } @@ -367,7 +384,9 @@ export class Query< } } - this.scheduleGc() + if (this.isSafeToRemove()) { + this.markForGc() + } } this.#cache.notify({ type: 'observerRemoved', query: this, observer }) @@ -390,7 +409,7 @@ export class Query< ): Promise { if ( this.state.fetchStatus !== 'idle' && - // If the promise in the retyer is already rejected, we have to definitely + // If the promise in the retryer is already rejected, we have to definitely // re-start the fetch; there is a chance that the query is still in a // pending state when that happens this.#retryer?.status() !== 'rejected' @@ -600,8 +619,10 @@ export class Query< throw error // rethrow the error for further handling } finally { - // Schedule query gc after fetching - this.scheduleGc() + if (this.isSafeToRemove()) { + // Schedule query gc after fetching + this.markForGc() + } } } diff --git a/packages/query-core/src/queryClient.ts b/packages/query-core/src/queryClient.ts index 80cc36668a..38e24c17ae 100644 --- a/packages/query-core/src/queryClient.ts +++ b/packages/query-core/src/queryClient.ts @@ -2,6 +2,7 @@ import { functionalUpdate, hashKey, hashQueryKeyByOptions, + isServer, noop, partialMatchKey, resolveStaleTime, @@ -12,6 +13,7 @@ import { MutationCache } from './mutationCache' import { focusManager } from './focusManager' import { onlineManager } from './onlineManager' import { notifyManager } from './notifyManager' +import { GCManager } from './gcManager' import { infiniteQueryBehavior } from './infiniteQueryBehavior' import type { CancelOptions, @@ -59,6 +61,7 @@ interface MutationDefaults { // CLASS export class QueryClient { + #gcManager: GCManager #queryCache: QueryCache #mutationCache: MutationCache #defaultOptions: DefaultOptions @@ -72,6 +75,7 @@ export class QueryClient { this.#queryCache = config.queryCache || new QueryCache() this.#mutationCache = config.mutationCache || new MutationCache() this.#defaultOptions = config.defaultOptions || {} + this.#gcManager = new GCManager({ forceDisable: isServer }) this.#queryDefaults = new Map() this.#mutationDefaults = new Map() this.#mountCount = 0 @@ -454,6 +458,10 @@ export class QueryClient { return Promise.resolve() } + getGcManager(): GCManager { + return this.#gcManager + } + getQueryCache(): QueryCache { return this.#queryCache } @@ -632,6 +640,7 @@ export class QueryClient { if (options?._defaulted) { return options } + return { ...this.#defaultOptions.mutations, ...(options?.mutationKey && @@ -644,5 +653,6 @@ export class QueryClient { clear(): void { this.#queryCache.clear() this.#mutationCache.clear() + this.#gcManager.clear() } } diff --git a/packages/query-core/src/removable.ts b/packages/query-core/src/removable.ts index 8642ab36ec..6798a4ca44 100644 --- a/packages/query-core/src/removable.ts +++ b/packages/query-core/src/removable.ts @@ -1,25 +1,113 @@ -import { timeoutManager } from './timeoutManager' import { isServer, isValidTimeout } from './utils' -import type { ManagedTimerId } from './timeoutManager' +import type { GCManager } from './gcManager' +/** + * Base class for objects that can be garbage collected. + * + * Instead of scheduling individual timeouts, this class + * marks objects as eligible for GC with a timestamp. + * The GCManager schedules timeouts to scan and remove eligible items + * when they become ready for collection. + */ export abstract class Removable { + /** + * Garbage collection time in milliseconds. + * When different gcTime values are specified, the longest one is used. + */ gcTime!: number - #gcTimeout?: ManagedTimerId + /** + * Timestamp when this item was marked for garbage collection. + * null means the item is active and should not be collected. + */ + gcMarkedAt: number | null = null + + /** + * Clean up resources when destroyed + */ destroy(): void { - this.clearGcTimeout() + this.clearGcMark() } - protected scheduleGc(): void { - this.clearGcTimeout() - + /** + * Mark this item as eligible for garbage collection. + * Sets gcMarkedAt to the current time. + * + * Called when: + * - Last observer unsubscribes + * - Fetch completes (queries) + * - Item is constructed with no observers + */ + protected markForGc(): void { + // Only mark if gcTime is valid (not Infinity, not negative) if (isValidTimeout(this.gcTime)) { - this.#gcTimeout = timeoutManager.setTimeout(() => { - this.optionalRemove() - }, this.gcTime) + this.gcMarkedAt = Date.now() + this.getGcManager().trackEligibleItem(this) + } else { + this.clearGcMark() + } + } + + protected abstract getGcManager(): GCManager + + /** + * Clear the GC mark, making this item ineligible for collection. + * + * Called when: + * - An observer subscribes + * - Item becomes active again + */ + protected clearGcMark(): void { + this.gcMarkedAt = null + this.getGcManager().untrackEligibleItem(this) + } + + /** + * Check if this item is eligible for garbage collection. + * + * An item is eligible if: + * 1. It has been marked (gcMarkedAt is not null) + * 2. Current time has passed the marked time plus gcTime + * + * @returns true if eligible for GC + */ + isEligibleForGc(): boolean { + if (this.gcMarkedAt === null) { + return false } + if (this.gcTime === Infinity) { + return false + } + + return Date.now() >= this.gcMarkedAt + this.gcTime } + /** + * Get the timestamp when this item will be eligible for garbage collection. + * + * @returns The timestamp (gcMarkedAt + gcTime), or null if not marked, + * or Infinity if gcTime is Infinity + */ + getGcAtTimestamp(): number | null { + if (this.gcMarkedAt === null) { + return null + } + + if (this.gcTime === Infinity) { + return Infinity + } + + return this.gcMarkedAt + this.gcTime + } + + /** + * Update the garbage collection time. + * Uses the maximum of the current gcTime and the new gcTime. + * + * Defaults to 5 minutes on client, Infinity on server. + * + * @param newGcTime - New garbage collection time in milliseconds + */ protected updateGcTime(newGcTime: number | undefined): void { // Default to 5 minutes (Infinity for server-side) if no gcTime is set this.gcTime = Math.max( @@ -28,12 +116,14 @@ export abstract class Removable { ) } - protected clearGcTimeout() { - if (this.#gcTimeout) { - timeoutManager.clearTimeout(this.#gcTimeout) - this.#gcTimeout = undefined - } - } - - protected abstract optionalRemove(): void + /** + * Attempt to remove this item if it meets removal criteria. + * Subclasses implement the actual removal logic. + * + * Typically checks: + * - No active observers + * - Not currently fetching/pending + * - Any other subclass-specific criteria + */ + abstract optionalRemove(): boolean } diff --git a/packages/react-query/src/__tests__/useMutation.test.tsx b/packages/react-query/src/__tests__/useMutation.test.tsx index 30800c9b08..4c080b731c 100644 --- a/packages/react-query/src/__tests__/useMutation.test.tsx +++ b/packages/react-query/src/__tests__/useMutation.test.tsx @@ -902,7 +902,7 @@ describe('useMutation', () => { fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) fireEvent.click(rendered.getByRole('button', { name: /hide/i })) - await vi.advanceTimersByTimeAsync(10) + await vi.advanceTimersByTimeAsync(11) expect( queryClient.getMutationCache().findAll({ mutationKey }), ).toHaveLength(0) diff --git a/packages/react-query/src/__tests__/useQuery.test.tsx b/packages/react-query/src/__tests__/useQuery.test.tsx index 39393379c0..e3adb84c30 100644 --- a/packages/react-query/src/__tests__/useQuery.test.tsx +++ b/packages/react-query/src/__tests__/useQuery.test.tsx @@ -4018,21 +4018,24 @@ describe('useQuery', () => { await vi.advanceTimersByTimeAsync(0) rendered.getByText('fetched data') - const setTimeoutSpy = vi.spyOn(globalThis.window, 'setTimeout') rendered.unmount() - expect(setTimeoutSpy).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(0) + + const item = queryClient.getQueryCache().find({ queryKey: key }) + expect(item!.gcMarkedAt).toBeNull() }) test('should schedule garbage collection, if gcTimeout is not set to infinity', async () => { const key = queryKey() + const gcTime = 1000 * 60 * 10 // 10 Minutes function Page() { const query = useQuery({ queryKey: key, queryFn: () => 'fetched data', - gcTime: 1000 * 60 * 10, // 10 Minutes + gcTime, }) return
{query.data}
} @@ -4042,14 +4045,21 @@ describe('useQuery', () => { await vi.advanceTimersByTimeAsync(0) rendered.getByText('fetched data') - const setTimeoutSpy = vi.spyOn(globalThis.window, 'setTimeout') + const query = queryClient.getQueryCache().find({ queryKey: key }) + + expect(query).toBeDefined() + expect(query!.gcMarkedAt).toBeNull() + + vi.setSystemTime(new Date(1970, 0, 1, 0, 0, 0, 0)) rendered.unmount() - expect(setTimeoutSpy).toHaveBeenLastCalledWith( - expect.any(Function), - 1000 * 60 * 10, - ) + expect(query!.gcMarkedAt).not.toBeNull() + expect(query!.gcMarkedAt).toBe(new Date(1970, 0, 1, 0, 0, 0, 0).getTime()) + + await vi.advanceTimersByTimeAsync(gcTime) + + expect(queryClient.getQueryCache().find({ queryKey: key })).toBeUndefined() }) it('should not cause memo churn when data does not change', async () => { diff --git a/packages/solid-query/src/__tests__/useMutation.test.tsx b/packages/solid-query/src/__tests__/useMutation.test.tsx index 2ad4008191..b1e78f120e 100644 --- a/packages/solid-query/src/__tests__/useMutation.test.tsx +++ b/packages/solid-query/src/__tests__/useMutation.test.tsx @@ -989,7 +989,7 @@ describe('useMutation', () => { fireEvent.click(rendered.getByRole('button', { name: /mutate/i })) fireEvent.click(rendered.getByRole('button', { name: /hide/i })) - await vi.advanceTimersByTimeAsync(10) + await vi.advanceTimersByTimeAsync(11) expect( queryClient.getMutationCache().findAll({ mutationKey: mutationKey }), ).toHaveLength(0) diff --git a/packages/solid-query/src/__tests__/useQuery.test.tsx b/packages/solid-query/src/__tests__/useQuery.test.tsx index 799a8e240e..09343ace19 100644 --- a/packages/solid-query/src/__tests__/useQuery.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, expectTypeOf, it, vi } from 'vitest' +import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { ErrorBoundary, Match, @@ -38,6 +38,10 @@ describe('useQuery', () => { const queryCache = new QueryCache() const queryClient = new QueryClient({ queryCache }) + afterEach(() => { + vi.useRealTimers() + }) + it('should return the correct types', () => { const key = queryKey() @@ -3900,6 +3904,7 @@ describe('useQuery', () => { }) it('should not schedule garbage collection, if gcTimeout is set to `Infinity`', async () => { + vi.useFakeTimers() const key = queryKey() function Page() { @@ -3918,21 +3923,24 @@ describe('useQuery', () => { )) await waitFor(() => rendered.getByText('fetched data')) - const setTimeoutSpy = vi.spyOn(window, 'setTimeout') rendered.unmount() - expect(setTimeoutSpy).not.toHaveBeenCalled() + const item = queryClient.getQueryCache().find({ queryKey: key }) + expect(item!.gcMarkedAt).toBeNull() }) it('should schedule garbage collection, if gcTimeout is not set to `Infinity`', async () => { + vi.useFakeTimers() const key = queryKey() + const gcTime = 1000 * 60 * 10 // 10 Minutes + const systemTime = new Date(1970, 0, 1, 0, 0, 0, 0) function Page() { const query = useQuery(() => ({ queryKey: key, queryFn: () => 'fetched data', - gcTime: 1000 * 60 * 10, // 10 Minutes + gcTime, })) return
{query.data}
} @@ -3944,14 +3952,22 @@ describe('useQuery', () => { )) await waitFor(() => rendered.getByText('fetched data')) - const setTimeoutSpy = vi.spyOn(window, 'setTimeout') + + const query = queryClient.getQueryCache().find({ queryKey: key }) + + expect(query).toBeDefined() + expect(query!.gcMarkedAt).toBeNull() + + vi.setSystemTime(systemTime) rendered.unmount() - expect(setTimeoutSpy).toHaveBeenLastCalledWith( - expect.any(Function), - 1000 * 60 * 10, - ) + expect(query!.gcMarkedAt).not.toBeNull() + expect(query!.gcMarkedAt).toBe(systemTime.getTime()) + + await vi.advanceTimersByTimeAsync(gcTime) + + expect(queryClient.getQueryCache().find({ queryKey: key })).toBeUndefined() }) it('should not cause memo churn when data does not change', async () => {