From c1d5eb91daf884312881cd6948b7f93de3d51e01 Mon Sep 17 00:00:00 2001 From: Oliver Beattie Date: Wed, 27 Aug 2025 16:23:59 +0200 Subject: [PATCH 1/4] Automatically subscribe/unsubscribe from TanStack Query When a collection backed by a TanStack Query has no active subscribers, automatically unsubscribe from the query. --- packages/db/package.json | 3 +- packages/db/src/collection.ts | 19 ++ packages/query-db-collection/src/query.ts | 42 +++- .../query-db-collection/tests/query.test.ts | 214 +++++++++++++++++- pnpm-lock.yaml | 9 + 5 files changed, 273 insertions(+), 14 deletions(-) diff --git a/packages/db/package.json b/packages/db/package.json index d8c335884..766ec4fe0 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -4,7 +4,8 @@ "version": "0.1.7", "dependencies": { "@standard-schema/spec": "^1.0.0", - "@tanstack/db-ivm": "workspace:*" + "@tanstack/db-ivm": "workspace:*", + "nanoevents": "^9.0.0" }, "devDependencies": { "@vitest/coverage-istanbul": "^3.0.9", diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index 4bfe6296a..aae7fc469 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -1,3 +1,4 @@ +import { createNanoEvents } from "nanoevents" import { withArrayChangeTracking, withChangeTracking } from "./proxy" import { SortedMap } from "./SortedMap" import { @@ -37,6 +38,7 @@ import { UpdateKeyNotFoundError, } from "./errors" import { createFilteredCallback, currentStateAsChanges } from "./change-events" +import type { Emitter } from "nanoevents" import type { Transaction } from "./transactions" import type { StandardSchemaV1 } from "@standard-schema/spec" import type { SingleRowRefProxy } from "./query/builder/ref-proxy" @@ -70,6 +72,10 @@ interface PendingSyncedTransaction> { deletedKeys: Set } +interface CollectionEvents { + subscriberCountChanged: (count: number) => void +} + /** * Enhanced Collection interface that includes both data type T and utilities TUtils * @template T - The type of items in the collection @@ -85,6 +91,8 @@ export interface Collection< TInsertInput extends object = T, > extends CollectionImpl { readonly utils: TUtils + readonly subscriberCount: number + readonly events: Emitter } /** @@ -308,6 +316,7 @@ export class CollectionImpl< // Event system private changeListeners = new Set>() private changeKeyListeners = new Map>>() + public events = createNanoEvents() // Utilities namespace // This is populated by createCollection @@ -412,6 +421,13 @@ export class CollectionImpl< return this._status } + /** + * Gets the current number of active subscribers + */ + public get subscriberCount(): number { + return this.activeSubscribersCount + } + /** * Validates that the collection is in a usable state for data operations * @private @@ -770,6 +786,7 @@ export class CollectionImpl< private addSubscriber(): void { this.activeSubscribersCount++ this.cancelGCTimer() + this.events.emit(`subscriberCountChanged`, this.activeSubscribersCount) // Start sync if collection was cleaned up if (this._status === `cleaned-up` || this._status === `idle`) { @@ -789,6 +806,8 @@ export class CollectionImpl< } else if (this.activeSubscribersCount < 0) { throw new NegativeActiveSubscribersError() } + + this.events.emit(`subscriberCountChanged`, this.activeSubscribersCount) } /** diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index e338cee9e..0b16432f7 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -497,7 +497,10 @@ export function queryCollectionOptions< TQueryKey >(queryClient, observerOptions) - const actualUnsubscribeFn = localObserver.subscribe((result) => { + let isSubscribed = false + let actualUnsubscribeFn: (() => void) | null = null + + const handleQueryResult = (result: any) => { if (result.isSuccess) { const newItemsArray = result.data @@ -581,10 +584,43 @@ export function queryCollectionOptions< // Mark collection as ready even on error to avoid blocking apps markReady() } - }) + } + + 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.events.on( + `subscriberCountChanged`, + (count) => { + if (count > 0) { + subscribeToQuery() + } else if (count === 0) { + unsubscribeFromQuery() + } + } + ) return async () => { - actualUnsubscribeFn() + unsubscribeFromCollectionEvents() + unsubscribeFromQuery() await queryClient.cancelQueries({ queryKey }) queryClient.removeQueries({ queryKey }) } diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 4c45bc57c..6984d53c9 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -652,6 +652,16 @@ describe(`QueryCollection`, () => { expect(collection.size).toBe(1) }) + // Verify initial subscriber state - startSync=true, so even with no subscribers of the collection, there should + // be an active subscription to the query + expect(collection.subscriberCount).toBe(0) + expect(collection.status).toBe(`ready`) + + // Add explicit subscribers to test cleanup with active subscribers + const unsubscribe1 = collection.subscribeChanges(() => {}) + const unsubscribe2 = collection.subscribeChanges(() => {}) + expect(collection.subscriberCount).toBe(2) + // Cleanup the collection which should trigger sync cleanup await collection.cleanup() @@ -661,10 +671,15 @@ describe(`QueryCollection`, () => { // Verify collection status expect(collection.status).toBe(`cleaned-up`) - // Verify that the TanStack Query cleanup methods were called + // Verify that cleanup methods are called regardless of subscriber state expect(cancelQueriesSpy).toHaveBeenCalledWith({ queryKey }) expect(removeQueriesSpy).toHaveBeenCalledWith({ queryKey }) + // Verify subscribers can be safely cleaned up after collection cleanup + unsubscribe1() + unsubscribe2() + expect(collection.subscriberCount).toBe(0) + // Restore spies cancelQueriesSpy.mockRestore() removeQueriesSpy.mockRestore() @@ -692,15 +707,28 @@ describe(`QueryCollection`, () => { expect(collection.size).toBe(1) }) - // Call cleanup multiple times + // Add subscribers to test consistency during multiple cleanups + const unsubscribe1 = collection.subscribeChanges(() => {}) + const unsubscribe2 = collection.subscribeChanges(() => {}) + expect(collection.subscriberCount).toBe(2) + + // Call cleanup multiple times - subscriber count should remain consistent await collection.cleanup() expect(collection.status).toBe(`cleaned-up`) + expect(collection.subscriberCount).toBe(2) // Subscribers still tracked await collection.cleanup() await collection.cleanup() - // Should handle multiple cleanups gracefully + // Should handle multiple cleanups gracefully with consistent subscriber state expect(collection.status).toBe(`cleaned-up`) + expect(collection.subscriberCount).toBe(2) // Still consistent + + // Verify subscribers can be safely unsubscribed after multiple cleanups + unsubscribe1() + expect(collection.subscriberCount).toBe(1) + unsubscribe2() + expect(collection.subscriberCount).toBe(0) }) it(`should restart sync when collection is accessed after cleanup`, async () => { @@ -726,17 +754,31 @@ describe(`QueryCollection`, () => { expect(collection.size).toBe(1) }) - // Cleanup + // Verify initial subscriber state + expect(collection.subscriberCount).toBe(0) // startSync: true with no explicit subscribers + + // Add a subscriber before cleanup + const preCleanupUnsubscribe = collection.subscribeChanges(() => {}) + expect(collection.subscriberCount).toBe(1) + + // Cleanup - should handle active subscribers gracefully await collection.cleanup() expect(collection.status).toBe(`cleaned-up`) - // Access collection data to restart sync - const unsubscribe = collection.subscribeChanges(() => {}) + // Subscriber count should remain tracked even after cleanup + expect(collection.subscriberCount).toBe(1) + preCleanupUnsubscribe() // Clean up old subscriber + expect(collection.subscriberCount).toBe(0) + + // Access collection data to restart sync with new subscriber + const postCleanupUnsubscribe = collection.subscribeChanges(() => {}) + expect(collection.subscriberCount).toBe(1) // Subscriber count tracking works after restart // Should restart sync (might be ready immediately if query is cached) expect([`loading`, `ready`]).toContain(collection.status) - unsubscribe() + postCleanupUnsubscribe() + expect(collection.subscriberCount).toBe(0) }) it(`should handle query lifecycle during restart cycle`, async () => { @@ -935,12 +977,19 @@ describe(`QueryCollection`, () => { expect(collection.size).toBe(1) }) - // Create multiple subscriptions + // Verify initial subscriber count - startSync=true means the query should be active + expect(collection.subscriberCount).toBe(0) + expect(collection.status).toBe(`ready`) + + // Create multiple subscriptions and track count changes const changeHandler1 = vi.fn() const changeHandler2 = vi.fn() const unsubscribe1 = collection.subscribeChanges(changeHandler1) + expect(collection.subscriberCount).toBe(1) // 0 → 1 + const unsubscribe2 = collection.subscribeChanges(changeHandler2) + expect(collection.subscriberCount).toBe(2) // 1 → 2 // Change the data and trigger a refetch items = [{ id: `1`, name: `Item 1 Updated` }] @@ -955,8 +1004,10 @@ describe(`QueryCollection`, () => { expect(changeHandler1).toHaveBeenCalled() expect(changeHandler2).toHaveBeenCalled() - // Unsubscribe one + // Unsubscribe one and verify count tracking unsubscribe1() + expect(collection.subscriberCount).toBe(1) // 2 → 1 + changeHandler1.mockClear() changeHandler2.mockClear() @@ -973,8 +1024,10 @@ describe(`QueryCollection`, () => { expect(changeHandler1).not.toHaveBeenCalled() expect(changeHandler2).toHaveBeenCalled() - // Cleanup + // Final cleanup - verify query remains active due to startSync: true unsubscribe2() + expect(collection.subscriberCount).toBe(0) // 1 → 0 + expect(collection.status).toBe(`ready`) // Still ready due to startSync: true }) it(`should handle query cancellation gracefully`, async () => { @@ -1055,6 +1108,71 @@ describe(`QueryCollection`, () => { const finalItem = collection.get(`1`) expect(finalItem?.name).toMatch(/^Item \d+$/) }) + + it(`should manage startSync vs subscriber count priority correctly`, async () => { + const queryKey1 = [`startSyncTruePriorityTest`] + const queryKey2 = [`startSyncFalsePriorityTest`] + const items = [{ id: `1`, name: `Item 1` }] + const queryFn1 = vi.fn().mockResolvedValue(items) + const queryFn2 = vi.fn().mockResolvedValue(items) + + // Test case 1: startSync=true should keep query active even with 0 subscribers + const config1: QueryCollectionConfig = { + id: `startSyncTrueTest`, + queryClient, + queryKey: queryKey1, + queryFn: queryFn1, + getKey, + startSync: true, + } + + const options1 = queryCollectionOptions(config1) + const collection1 = createCollection(options1) + + await vi.waitFor(() => { + expect(collection1.status).toBe(`ready`) + }) + + expect(collection1.subscriberCount).toBe(0) + expect(queryFn1).toHaveBeenCalled() + expect(collection1.status).toBe(`ready`) // Active due to startSync: true + + // Test case 2: startSync=false should rely purely on subscriber count + const config2: QueryCollectionConfig = { + id: `startSyncFalseTest`, + queryClient, + queryKey: queryKey2, + queryFn: queryFn2, + getKey, + startSync: false, + } + + const options2 = queryCollectionOptions(config2) + const collection2 = createCollection(options2) + + await flushPromises() + + expect(collection2.subscriberCount).toBe(0) + expect(queryFn2).not.toHaveBeenCalled() // Should not be called without subscribers + expect(collection2.status).toBe(`idle`) // Inactive due to startSync: false + no subscribers + + // Add subscriber to collection2 -> should now activate + const unsubscribe = collection2.subscribeChanges(() => {}) + + await vi.waitFor(() => expect(collection2.status).toBe(`ready`)) + + expect(collection2.subscriberCount).toBe(1) + expect(queryFn2).toHaveBeenCalled() // Now called due to subscriber + + // Remove subscriber -> query may still be active but subscriber count drops + unsubscribe() + expect(collection2.subscriberCount).toBe(0) + + // Verify the core logic: startSync || subscriberCount > 0 + // collection1: startSync=true, subscriberCount=0 -> active + // collection2: startSync=false, subscriberCount=0 -> depends on implementation + expect(collection1.status).toBe(`ready`) // Always active with startSync: true + }) }) describe(`Manual Sync Operations`, () => { @@ -1603,4 +1721,80 @@ describe(`QueryCollection`, () => { expect(collection.size).toBe(0) expect(collection.status).toBe(`ready`) }) + + describe(`subscriber count tracking and auto-subscription`, () => { + it(`should not auto-subscribe when startSync=false and no subscribers`, async () => { + const queryKey = [`noSubscriptionTest`] + const items = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `noSubscriptionTest`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: false, + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Give it time to potentially subscribe (it shouldn't) + await flushPromises() + + expect(collection.subscriberCount).toBe(0) + expect(collection.status).toBe(`idle`) // Should remain idle without startSync or subscribers + expect(queryFn).not.toHaveBeenCalled() // Query should not be executed + }) + + it(`should subscribe/unsubscribe based on subscriber count transitions`, async () => { + const queryKey = [`countTransitionTest`] + const items = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `countTransition`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: false, // Start unsubscribed + } + + const options = queryCollectionOptions(config) + const collection = createCollection(options) + + // Should start unsubscribed + expect(collection.subscriberCount).toBe(0) + expect(collection.status).toBe(`idle`) + + // Add a subscriber -> should subscribe and load data + const unsubscribe1 = collection.subscribeChanges(() => {}) + + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) + }) + + expect(collection.subscriberCount).toBe(1) + expect(queryFn).toHaveBeenCalled() + + // Add another subscriber - should not trigger additional queries + const initialCallCount = queryFn.mock.calls.length + const unsubscribe2 = collection.subscribeChanges(() => {}) + expect(collection.subscriberCount).toBe(2) + + await flushPromises() + expect(queryFn.mock.calls.length).toBe(initialCallCount) // No additional calls + + // Remove first subscriber - should still be subscribed + unsubscribe1() + expect(collection.subscriberCount).toBe(1) + expect(collection.status).toBe(`ready`) + + // Remove last subscriber -> query should remain active but collection subscriber count drops to 0 + unsubscribe2() + expect(collection.subscriberCount).toBe(0) + }) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58f268d17..e623a0a0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -476,6 +476,9 @@ importers: '@tanstack/db-ivm': specifier: workspace:* version: link:../db-ivm + nanoevents: + specifier: ^9.0.0 + version: 9.1.0 typescript: specifier: '>=4.7' version: 5.8.3 @@ -5601,6 +5604,10 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nanoevents@9.1.0: + resolution: {integrity: sha512-Jd0fILWG44a9luj8v5kED4WI+zfkkgwKyRQKItTtlPfEsh7Lznfi1kr8/iZ+XAIss4Qq5GqRB0qtWbaz9ceO/A==} + engines: {node: ^18.0.0 || >=20.0.0} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -13033,6 +13040,8 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nanoevents@9.1.0: {} + nanoid@3.3.11: {} nanostores@0.11.4: {} From e333f9fcb41dac5fed07681bf113fa81ce45e59a Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sat, 27 Sep 2025 10:37:11 +0100 Subject: [PATCH 2/4] fix types during build --- packages/query-db-collection/tsconfig.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/query-db-collection/tsconfig.json b/packages/query-db-collection/tsconfig.json index 7e586bab3..623d4bd91 100644 --- a/packages/query-db-collection/tsconfig.json +++ b/packages/query-db-collection/tsconfig.json @@ -12,7 +12,9 @@ "forceConsistentCasingInFileNames": true, "jsx": "react", "paths": { - "@tanstack/store": ["../store/src"] + "@tanstack/store": ["../store/src"], + "@tanstack/db": ["../db/src"], + "@tanstack/db-ivm": ["../db-ivm/src"] } }, "include": ["src", "tests", "vite.config.ts"], From 0c3696fba1d1e3e617585fc96caf53dda3c44c8f Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 29 Sep 2025 16:24:50 -0600 Subject: [PATCH 3/4] Add changeset for staleTime bug fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/happy-parks-invite.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/happy-parks-invite.md diff --git a/.changeset/happy-parks-invite.md b/.changeset/happy-parks-invite.md new file mode 100644 index 000000000..8871910e4 --- /dev/null +++ b/.changeset/happy-parks-invite.md @@ -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. From 9866e599e734bdf1497daf2ee491be419088224b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 29 Sep 2025 16:29:15 -0600 Subject: [PATCH 4/4] Clarify startSync JSDoc to explain pause/resume behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/db/src/types.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 38ae672ce..5ca19854d 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -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 /**