Skip to content

Commit a064cb4

Browse files
obeattiesamwillisKyleAMathewsclaude
authored
Automatically subscribe/unsubscribe from TanStack Query based on collection subscriber count (#462)
* Automatically subscribe/unsubscribe from TanStack Query When a collection backed by a TanStack Query has no active subscribers, automatically unsubscribe from the query. * fix types during build * Add changeset for staleTime bug fix 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Clarify startSync JSDoc to explain pause/resume behavior 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Co-authored-by: Sam Willis <[email protected]> Co-authored-by: Kyle Mathews <[email protected]> Co-authored-by: Claude <[email protected]>
1 parent 3d5f782 commit a064cb4

File tree

7 files changed

+264
-19
lines changed

7 files changed

+264
-19
lines changed

.changeset/happy-parks-invite.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@tanstack/query-db-collection": patch
3+
---
4+
5+
Fix `staleTime` behavior by automatically subscribing/unsubscribing from TanStack Query based on collection subscriber count.
6+
7+
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.

packages/db/src/collection/events.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export class CollectionEventsManager {
7070
this.listeners.get(event)!.add(callback)
7171

7272
return () => {
73-
this.listeners.get(event)!.delete(callback)
73+
this.listeners.get(event)?.delete(callback)
7474
}
7575
}
7676

packages/db/src/types.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -334,8 +334,14 @@ export interface BaseCollectionConfig<
334334
*/
335335
gcTime?: number
336336
/**
337-
* Whether to start syncing immediately when the collection is created.
338-
* Defaults to false for lazy loading. Set to true to immediately sync.
337+
* Whether to eagerly start syncing on collection creation.
338+
* When true, syncing begins immediately. When false, syncing starts when the first subscriber attaches.
339+
*
340+
* Note: Even with startSync=true, collections will pause syncing when there are no active
341+
* subscribers (typically when components querying the collection unmount), resuming when new
342+
* subscribers attach. This preserves normal staleTime/gcTime behavior.
343+
*
344+
* @default false
339345
*/
340346
startSync?: boolean
341347
/**

packages/query-db-collection/src/query.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,7 @@ export function queryCollectionOptions(
338338
throw new QueryClientRequiredError()
339339
}
340340

341+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
341342
if (!getKey) {
342343
throw new GetKeyRequiredError()
343344
}
@@ -379,8 +380,11 @@ export function queryCollectionOptions(
379380
any
380381
>(queryClient, observerOptions)
381382

383+
let isSubscribed = false
384+
let actualUnsubscribeFn: (() => void) | null = null
385+
382386
type UpdateHandler = Parameters<typeof localObserver.subscribe>[0]
383-
const handleUpdate: UpdateHandler = (result) => {
387+
const handleQueryResult: UpdateHandler = (result) => {
384388
if (result.isSuccess) {
385389
// Clear error state
386390
lastError = undefined
@@ -472,14 +476,45 @@ export function queryCollectionOptions(
472476
}
473477
}
474478

475-
const actualUnsubscribeFn = localObserver.subscribe(handleUpdate)
479+
const subscribeToQuery = () => {
480+
if (!isSubscribed) {
481+
actualUnsubscribeFn = localObserver.subscribe(handleQueryResult)
482+
isSubscribed = true
483+
}
484+
}
485+
486+
const unsubscribeFromQuery = () => {
487+
if (isSubscribed && actualUnsubscribeFn) {
488+
actualUnsubscribeFn()
489+
actualUnsubscribeFn = null
490+
isSubscribed = false
491+
}
492+
}
493+
494+
// If startSync=true or there are subscribers to the collection, subscribe to the query straight away
495+
if (config.startSync || collection.subscriberCount > 0) {
496+
subscribeToQuery()
497+
}
498+
499+
// Set up event listener for subscriber changes
500+
const unsubscribeFromCollectionEvents = collection.on(
501+
`subscribers:change`,
502+
({ subscriberCount }) => {
503+
if (subscriberCount > 0) {
504+
subscribeToQuery()
505+
} else if (subscriberCount === 0) {
506+
unsubscribeFromQuery()
507+
}
508+
}
509+
)
476510

477511
// Ensure we process any existing query data (QueryObserver doesn't invoke its callback automatically with initial
478512
// state)
479-
handleUpdate(localObserver.getCurrentResult())
513+
handleQueryResult(localObserver.getCurrentResult())
480514

481515
return async () => {
482-
actualUnsubscribeFn()
516+
unsubscribeFromCollectionEvents()
517+
unsubscribeFromQuery()
483518
await queryClient.cancelQueries({ queryKey })
484519
queryClient.removeQueries({ queryKey })
485520
}

packages/query-db-collection/tests/query.test-d.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expectTypeOf, it } from "vitest"
22
import {
3+
and,
34
createCollection,
45
createLiveQueryCollection,
56
eq,
@@ -166,7 +167,7 @@ describe(`Query collection type resolution tests`, () => {
166167
query: (q) =>
167168
q
168169
.from({ user: usersCollection })
169-
.where(({ user }) => eq(user.active, true) && gt(user.age, 18))
170+
.where(({ user }) => and(eq(user.active, true), gt(user.age, 18)))
170171
.select(({ user }) => ({
171172
id: user.id,
172173
name: user.name,

0 commit comments

Comments
 (0)