Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/happy-parks-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@tanstack/query-db-collection": patch
---

Fix `staleTime` behavior by automatically subscribing/unsubscribing from TanStack Query based on collection subscriber count.

Previously, query collections kept a QueryObserver permanently subscribed, which broke TanStack Query's `staleTime` and window-focus refetch behavior. Now the QueryObserver properly goes inactive when the collection has no subscribers, restoring normal `staleTime`/`gcTime` semantics.
2 changes: 1 addition & 1 deletion packages/db/src/collection/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export class CollectionEventsManager {
this.listeners.get(event)!.add(callback)

return () => {
this.listeners.get(event)!.delete(callback)
this.listeners.get(event)?.delete(callback)
}
}

Expand Down
10 changes: 8 additions & 2 deletions packages/db/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,14 @@ export interface BaseCollectionConfig<
*/
gcTime?: number
/**
* Whether to start syncing immediately when the collection is created.
* Defaults to false for lazy loading. Set to true to immediately sync.
* Whether to eagerly start syncing on collection creation.
* When true, syncing begins immediately. When false, syncing starts when the first subscriber attaches.
*
* Note: Even with startSync=true, collections will pause syncing when there are no active
* subscribers (typically when components querying the collection unmount), resuming when new
* subscribers attach. This preserves normal staleTime/gcTime behavior.
*
* @default false
*/
startSync?: boolean
/**
Expand Down
43 changes: 39 additions & 4 deletions packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,7 @@ export function queryCollectionOptions(
throw new QueryClientRequiredError()
}

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!getKey) {
throw new GetKeyRequiredError()
}
Expand Down Expand Up @@ -379,8 +380,11 @@ export function queryCollectionOptions(
any
>(queryClient, observerOptions)

let isSubscribed = false
let actualUnsubscribeFn: (() => void) | null = null

type UpdateHandler = Parameters<typeof localObserver.subscribe>[0]
const handleUpdate: UpdateHandler = (result) => {
const handleQueryResult: UpdateHandler = (result) => {
if (result.isSuccess) {
// Clear error state
lastError = undefined
Expand Down Expand Up @@ -472,14 +476,45 @@ export function queryCollectionOptions(
}
}

const actualUnsubscribeFn = localObserver.subscribe(handleUpdate)
const subscribeToQuery = () => {
if (!isSubscribed) {
actualUnsubscribeFn = localObserver.subscribe(handleQueryResult)
isSubscribed = true
}
}

const unsubscribeFromQuery = () => {
if (isSubscribed && actualUnsubscribeFn) {
actualUnsubscribeFn()
actualUnsubscribeFn = null
isSubscribed = false
}
}

// If startSync=true or there are subscribers to the collection, subscribe to the query straight away
if (config.startSync || collection.subscriberCount > 0) {
subscribeToQuery()
}

// Set up event listener for subscriber changes
const unsubscribeFromCollectionEvents = collection.on(
`subscribers:change`,
({ subscriberCount }) => {
if (subscriberCount > 0) {
subscribeToQuery()
} else if (subscriberCount === 0) {
unsubscribeFromQuery()
}
}
)

// Ensure we process any existing query data (QueryObserver doesn't invoke its callback automatically with initial
// state)
handleUpdate(localObserver.getCurrentResult())
handleQueryResult(localObserver.getCurrentResult())

return async () => {
actualUnsubscribeFn()
unsubscribeFromCollectionEvents()
unsubscribeFromQuery()
await queryClient.cancelQueries({ queryKey })
queryClient.removeQueries({ queryKey })
}
Expand Down
3 changes: 2 additions & 1 deletion packages/query-db-collection/tests/query.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expectTypeOf, it } from "vitest"
import {
and,
createCollection,
createLiveQueryCollection,
eq,
Expand Down Expand Up @@ -166,7 +167,7 @@ describe(`Query collection type resolution tests`, () => {
query: (q) =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.active, true) && gt(user.age, 18))
.where(({ user }) => and(eq(user.active, true), gt(user.age, 18)))
.select(({ user }) => ({
id: user.id,
name: user.name,
Expand Down
Loading
Loading