Skip to content

Fix staleTime behavior in query-db-collection by tracking component subscriptions #348

@KyleAMathews

Description

@KyleAMathews

Background

Currently, query-db-collection creates a single QueryObserver that stays subscribed to TanStack Query for the entire lifecycle of the collection. This breaks TanStack Query's staleTime behavior because from Query's perspective, there's always an "active" observer.

The Problem

Expected behavior (like useQuery):

  1. Component mounts → Query becomes active
  2. Component unmounts → Query becomes inactive, staleTime timer starts
  3. Component remounts within staleTime → No refetch occurs
  4. Component remounts after staleTime → Refetch occurs

Current behavior in query-db-collection:

  1. Collection starts sync → QueryObserver subscribes and stays subscribed
  2. Query is ALWAYS active from TanStack Query's perspective
  3. staleTime never applies because query never goes "inactive"
  4. refetchOnWindowFocus always triggers regardless of staleTime

Evidence

From query.ts:303-332:

const internalSync: SyncConfig<TItem>['sync'] = (params) => {
  // Creates observer when collection starts sync
  const localObserver = new QueryObserver(queryClient, observerOptions)
  
  // Subscribes immediately and stays subscribed
  const actualUnsubscribeFn = localObserver.subscribe((result) => {
    // ... sync logic
  })
  
  // Only unsubscribes when collection is cleaned up
  return async () => {
    actualUnsubscribeFn()
    // ...
  }
}

Root Cause

Collections already track subscriber count (lines 663-685 in collection.ts), but query-db-collection doesn't use this information to manage its QueryObserver lifecycle.

Proposed Solution

Modify query-db-collection to subscribe/unsubscribe its QueryObserver based on the collection's subscriber count:

const internalSync: SyncConfig<TItem>['sync'] = (params) => {
  const { collection } = params
  let localObserver: QueryObserver | null = null
  let queryUnsubscribe: (() => void) | null = null

  // Track when collection gains/loses subscribers
  const originalAddSubscriber = collection.addSubscriber
  const originalRemoveSubscriber = collection.removeSubscriber

  // Override to manage QueryObserver lifecycle
  collection.addSubscriber = function() {
    const wasInactive = this.activeSubscribersCount === 0
    originalAddSubscriber.call(this)
    
    // Create QueryObserver when first subscriber added
    if (wasInactive && \!localObserver) {
      localObserver = new QueryObserver(queryClient, observerOptions)
      queryUnsubscribe = localObserver.subscribe((result) => {
        // ... existing sync logic
      })
    }
  }

  collection.removeSubscriber = function() {
    originalRemoveSubscriber.call(this)
    
    // Cleanup QueryObserver when last subscriber removed
    if (this.activeSubscribersCount === 0 && localObserver) {
      queryUnsubscribe?.()
      localObserver = null
      queryUnsubscribe = null
      // Now TanStack Query's staleTime timer can start
    }
  }

  return async () => {
    // Restore original methods
    collection.addSubscriber = originalAddSubscriber
    collection.removeSubscriber = originalRemoveSubscriber
    
    // Final cleanup
    queryUnsubscribe?.()
    await queryClient.cancelQueries({ queryKey })
    queryClient.removeQueries({ queryKey })
  }
}

Alternative Approach

Instead of overriding methods, collections could expose subscriber count changes via events:

// In collection.ts
private notifySubscriberCountChange(count: number) {
  this.emit('subscriberCountChanged', count)
}

// In query-db-collection
collection.on('subscriberCountChanged', (count) => {
  if (count === 0 && localObserver) {
    // Unsubscribe QueryObserver
  } else if (count === 1 && \!localObserver) {
    // Subscribe QueryObserver  
  }
})

Benefits

  1. Proper staleTime behavior: Data goes stale when no components are using it
  2. Better performance: Avoids unnecessary refetches within staleTime window
  3. Memory efficiency: Queries can be garbage collected after gcTime
  4. TanStack Query compatibility: Behaves like regular useQuery hooks

Test Cases

  1. Component mounts → Query should become active, fetch if stale
  2. Component unmounts → Query should become inactive after delay
  3. Component remounts within staleTime → Should not refetch
  4. Component remounts after staleTime → Should refetch
  5. Multiple components → Query stays active until all unmount
  6. Window focus within staleTime → Should not refetch
  7. Window focus after staleTime → Should refetch

Breaking Changes

This should be backwards compatible - existing behavior is preserved, but staleTime will now work correctly.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions