Skip to content

Commit 8cd0876

Browse files
KyleAMathewsclaude
andauthored
feat: implement idle cleanup for collection garbage collection (#590)
Co-authored-by: Claude <[email protected]>
1 parent c21adb9 commit 8cd0876

File tree

4 files changed

+161
-22
lines changed

4 files changed

+161
-22
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Implement idle cleanup for collection garbage collection
6+
7+
Collection cleanup operations now use `requestIdleCallback()` to prevent blocking the UI thread during garbage collection. This improvement ensures better performance by scheduling cleanup during browser idle time rather than immediately when collections have no active subscribers.
8+
9+
**Key improvements:**
10+
11+
- Non-blocking cleanup operations that don't interfere with user interactions
12+
- Automatic fallback to `setTimeout` for older browsers without `requestIdleCallback` support
13+
- Proper callback management to prevent race conditions during cleanup rescheduling
14+
- Maintains full backward compatibility with existing collection lifecycle behavior
15+
16+
This addresses performance concerns where collection cleanup could cause UI thread blocking during active application usage.

packages/db/src/collection/lifecycle.ts

Lines changed: 85 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import {
22
CollectionInErrorStateError,
33
InvalidCollectionStatusTransitionError,
44
} from "../errors"
5+
import {
6+
safeCancelIdleCallback,
7+
safeRequestIdleCallback,
8+
} from "../utils/browser-polyfills"
9+
import type { IdleCallbackDeadline } from "../utils/browser-polyfills"
510
import type { StandardSchemaV1 } from "@standard-schema/spec"
611
import type { CollectionConfig, CollectionStatus } from "../types"
712
import type { CollectionEventsManager } from "./events"
@@ -29,6 +34,7 @@ export class CollectionLifecycleManager<
2934
public hasReceivedFirstCommit = false
3035
public onFirstReadyCallbacks: Array<() => void> = []
3136
public gcTimeoutId: ReturnType<typeof setTimeout> | null = null
37+
private idleCallbackId: number | null = null
3238

3339
/**
3440
* Creates a new CollectionLifecycleManager instance
@@ -167,9 +173,8 @@ export class CollectionLifecycleManager<
167173

168174
this.gcTimeoutId = setTimeout(() => {
169175
if (this.changes.activeSubscribersCount === 0) {
170-
// We call the main collection cleanup, not just the one for the
171-
// lifecycle manager
172-
this.cleanup()
176+
// Schedule cleanup during idle time to avoid blocking the UI thread
177+
this.scheduleIdleCleanup()
173178
}
174179
}, gcTime)
175180
}
@@ -183,6 +188,77 @@ export class CollectionLifecycleManager<
183188
clearTimeout(this.gcTimeoutId)
184189
this.gcTimeoutId = null
185190
}
191+
// Also cancel any pending idle cleanup
192+
if (this.idleCallbackId !== null) {
193+
safeCancelIdleCallback(this.idleCallbackId)
194+
this.idleCallbackId = null
195+
}
196+
}
197+
198+
/**
199+
* Schedule cleanup to run during browser idle time
200+
* This prevents blocking the UI thread during cleanup operations
201+
*/
202+
private scheduleIdleCleanup(): void {
203+
// Cancel any existing idle callback
204+
if (this.idleCallbackId !== null) {
205+
safeCancelIdleCallback(this.idleCallbackId)
206+
}
207+
208+
// Schedule cleanup with a timeout of 1 second
209+
// This ensures cleanup happens even if the browser is busy
210+
this.idleCallbackId = safeRequestIdleCallback(
211+
(deadline) => {
212+
// Perform cleanup if we still have no subscribers
213+
if (this.changes.activeSubscribersCount === 0) {
214+
const cleanupCompleted = this.performCleanup(deadline)
215+
// Only clear the callback ID if cleanup actually completed
216+
if (cleanupCompleted) {
217+
this.idleCallbackId = null
218+
}
219+
} else {
220+
// No need to cleanup, clear the callback ID
221+
this.idleCallbackId = null
222+
}
223+
},
224+
{ timeout: 1000 }
225+
)
226+
}
227+
228+
/**
229+
* Perform cleanup operations, optionally in chunks during idle time
230+
* @returns true if cleanup was completed, false if it was rescheduled
231+
*/
232+
private performCleanup(deadline?: IdleCallbackDeadline): boolean {
233+
// If we have a deadline, we can potentially split cleanup into chunks
234+
// For now, we'll do all cleanup at once but check if we have time
235+
const hasTime =
236+
!deadline || deadline.timeRemaining() > 0 || deadline.didTimeout
237+
238+
if (hasTime) {
239+
// Perform all cleanup operations
240+
this.events.cleanup()
241+
this.sync.cleanup()
242+
this.state.cleanup()
243+
this.changes.cleanup()
244+
this.indexes.cleanup()
245+
246+
if (this.gcTimeoutId) {
247+
clearTimeout(this.gcTimeoutId)
248+
this.gcTimeoutId = null
249+
}
250+
251+
this.hasBeenReady = false
252+
this.onFirstReadyCallbacks = []
253+
254+
// Set status to cleaned-up
255+
this.setStatus(`cleaned-up`)
256+
return true
257+
} else {
258+
// If we don't have time, reschedule for the next idle period
259+
this.scheduleIdleCleanup()
260+
return false
261+
}
186262
}
187263

188264
/**
@@ -201,21 +277,13 @@ export class CollectionLifecycleManager<
201277
}
202278

203279
public cleanup(): void {
204-
this.events.cleanup()
205-
this.sync.cleanup()
206-
this.state.cleanup()
207-
this.changes.cleanup()
208-
this.indexes.cleanup()
209-
210-
if (this.gcTimeoutId) {
211-
clearTimeout(this.gcTimeoutId)
212-
this.gcTimeoutId = null
280+
// Cancel any pending idle cleanup
281+
if (this.idleCallbackId !== null) {
282+
safeCancelIdleCallback(this.idleCallbackId)
283+
this.idleCallbackId = null
213284
}
214285

215-
this.hasBeenReady = false
216-
this.onFirstReadyCallbacks = []
217-
218-
// Set status to cleaned-up
219-
this.setStatus(`cleaned-up`)
286+
// Perform cleanup immediately (used when explicitly called)
287+
this.performCleanup()
220288
}
221289
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Type definitions for requestIdleCallback - compatible with existing browser types
2+
export type IdleCallbackDeadline = {
3+
didTimeout: boolean
4+
timeRemaining: () => number
5+
}
6+
7+
export type IdleCallbackFunction = (deadline: IdleCallbackDeadline) => void
8+
9+
const requestIdleCallbackPolyfill = (
10+
callback: IdleCallbackFunction
11+
): number => {
12+
// Use a very small timeout for the polyfill to simulate idle time
13+
const timeout = 0
14+
const timeoutId = setTimeout(() => {
15+
callback({
16+
didTimeout: true, // Always indicate timeout for the polyfill
17+
timeRemaining: () => 50, // Return some time remaining for polyfill
18+
})
19+
}, timeout)
20+
return timeoutId as unknown as number
21+
}
22+
23+
const cancelIdleCallbackPolyfill = (id: number): void => {
24+
clearTimeout(id as unknown as ReturnType<typeof setTimeout>)
25+
}
26+
27+
export const safeRequestIdleCallback: (
28+
callback: IdleCallbackFunction,
29+
options?: { timeout?: number }
30+
) => number =
31+
typeof window !== `undefined` && `requestIdleCallback` in window
32+
? (callback, options) =>
33+
(window as any).requestIdleCallback(callback, options)
34+
: (callback, _options) => requestIdleCallbackPolyfill(callback)
35+
36+
export const safeCancelIdleCallback: (id: number) => void =
37+
typeof window !== `undefined` && `cancelIdleCallback` in window
38+
? (id) => (window as any).cancelIdleCallback(id)
39+
: cancelIdleCallbackPolyfill

packages/db/tests/collection-lifecycle.test.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ describe(`Collection Lifecycle Management`, () => {
2525
timeoutCallbacks.delete(id)
2626
})
2727

28+
// Mock requestIdleCallback - in tests, it falls back to setTimeout
29+
// which we're already mocking, so the idle callback will be triggered
30+
// through our mockSetTimeout
31+
2832
global.setTimeout = mockSetTimeout as any
2933
global.clearTimeout = mockClearTimeout as any
3034
})
@@ -43,6 +47,14 @@ describe(`Collection Lifecycle Management`, () => {
4347
}
4448
}
4549

50+
const triggerAllTimeouts = () => {
51+
const callbacks = Array.from(timeoutCallbacks.entries())
52+
callbacks.forEach(([id, callback]) => {
53+
callback()
54+
timeoutCallbacks.delete(id)
55+
})
56+
}
57+
4658
describe(`Collection Status Tracking`, () => {
4759
it(`should start with idle status and transition to ready after first commit when startSync is false`, () => {
4860
let beginCallback: (() => void) | undefined
@@ -300,14 +312,18 @@ describe(`Collection Lifecycle Management`, () => {
300312
const subscription = collection.subscribeChanges(() => {})
301313
subscription.unsubscribe()
302314

303-
expect(collection.status).toBe(`loading`) // or "ready"
315+
expect(collection.status).toBe(`loading`)
304316

305-
// Trigger GC timeout
306-
const timerId = mockSetTimeout.mock.results[0]?.value
307-
if (timerId) {
308-
triggerTimeout(timerId)
317+
// Trigger GC timeout - this will schedule the idle cleanup
318+
const gcTimerId = mockSetTimeout.mock.results[0]?.value
319+
if (gcTimerId) {
320+
triggerTimeout(gcTimerId)
309321
}
310322

323+
// Now trigger all remaining timeouts to handle the idle callback
324+
// (which is implemented via setTimeout in our polyfill)
325+
triggerAllTimeouts()
326+
311327
expect(collection.status).toBe(`cleaned-up`)
312328
})
313329

0 commit comments

Comments
 (0)