From 72b2709d93a6a69a690f63ab351e1a3c24aa3bba Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sun, 22 Mar 2026 18:23:19 +0000 Subject: [PATCH 1/5] fix: align persisted query retention with gcTime defaults When persistedGcTime is omitted, query collections now derive persisted retention ttl from the query's effective gcTime instead of skipping retention and cleaning rows too early. This preserves expected cache lifecycle behavior and adds coverage for the fallback path. Made-with: Cursor --- packages/query-db-collection/src/query.ts | 21 ++-- .../query-db-collection/tests/query.test.ts | 99 +++++++++++++++++++ 2 files changed, 114 insertions(+), 6 deletions(-) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 45faca8f7..b3c28dfe2 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -713,6 +713,7 @@ export function queryCollectionOptions( let syncStarted = false let startupRetentionSettled = false const retainedQueriesPendingRevalidation = new Set() + const effectivePersistedGcTimes = new Map() const persistedRetentionTimers = new Map< string, ReturnType @@ -1163,6 +1164,9 @@ export function queryCollectionOptions( ...(retryDelay !== undefined && { retryDelay }), ...(staleTime !== undefined && { staleTime }), } + const effectivePersistedGcTime = + persistedGcTime ?? + queryClient.defaultQueryOptions(observerOptions).gcTime const localObserver = new QueryObserver< Array, @@ -1174,6 +1178,7 @@ export function queryCollectionOptions( hashToQueryKey.set(hashedQueryKey, key) state.observers.set(hashedQueryKey, localObserver) + effectivePersistedGcTimes.set(hashedQueryKey, effectivePersistedGcTime) // Increment reference count for this query queryRefCounts.set( @@ -1526,6 +1531,7 @@ export function queryCollectionOptions( queryToRows.delete(hashedQueryKey) hashToQueryKey.delete(hashedQueryKey) queryRefCounts.delete(hashedQueryKey) + effectivePersistedGcTimes.delete(hashedQueryKey) } /** @@ -1535,6 +1541,9 @@ export function queryCollectionOptions( const cleanupQueryIfIdle = (hashedQueryKey: string) => { const refcount = queryRefCounts.get(hashedQueryKey) || 0 const observer = state.observers.get(hashedQueryKey) + const effectivePersistedGcTime = effectivePersistedGcTimes.get( + hashedQueryKey, + ) if (refcount <= 0) { // Drop our subscription so hasListeners reflects only active consumers @@ -1561,7 +1570,7 @@ export function queryCollectionOptions( ) } - if (persistedGcTime !== undefined) { + if (effectivePersistedGcTime !== undefined) { if (metadata) { begin() metadata.collection.set( @@ -1569,20 +1578,20 @@ export function queryCollectionOptions( { queryHash: hashedQueryKey, mode: - persistedGcTime === Number.POSITIVE_INFINITY + effectivePersistedGcTime === Number.POSITIVE_INFINITY ? `until-revalidated` : `ttl`, - ...(persistedGcTime === Number.POSITIVE_INFINITY + ...(effectivePersistedGcTime === Number.POSITIVE_INFINITY ? {} - : { expiresAt: Date.now() + persistedGcTime }), + : { expiresAt: Date.now() + effectivePersistedGcTime }), }, ) commit() - if (persistedGcTime !== Number.POSITIVE_INFINITY) { + if (effectivePersistedGcTime !== Number.POSITIVE_INFINITY) { schedulePersistedRetentionExpiry({ queryHash: hashedQueryKey, mode: `ttl`, - expiresAt: Date.now() + persistedGcTime, + expiresAt: Date.now() + effectivePersistedGcTime, }) } } diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 32c552c5d..d141e88b8 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -4863,6 +4863,105 @@ describe(`QueryCollection`, () => { } }) + it(`should default persisted retention ttl to query gcTime when persistedGcTime is undefined`, async () => { + vi.useFakeTimers() + const gcTime = 120 + const gcTimeFallbackQueryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime, + staleTime: 0, + retry: false, + }, + }, + }) + + try { + const baseQueryKey = [`runtime-ttl-retention-default-gctime-test`] + const retainedQueryHash = hashKey(baseQueryKey) + const items: Array = [ + { id: `1`, name: `Retained`, category: `A` }, + ] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `runtime-ttl-retention-default-gctime-test`, + queryClient: gcTimeFallbackQueryClient, + queryKey: () => baseQueryKey, + queryFn, + getKey: (item) => item.id, + syncMode: `on-demand`, + startSync: true, + } + + const baseOptions = queryCollectionOptions(config) + const originalSync = baseOptions.sync + const metadataHarness = createInMemorySyncMetadataApi< + string | number, + CategorisedItem + >({ + persistedRows: new Map(items.map((item) => [item.id, item])), + }) + + const collection = createCollection({ + ...baseOptions, + sync: { + sync: (params: Parameters[0]) => + originalSync.sync({ + ...params, + metadata: metadataHarness.api, + }), + }, + }) + + const liveQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `A`)), + }) + + await liveQuery.preload() + await vi.waitFor(() => { + expect(collection.size).toBe(1) + }) + + await liveQuery.cleanup() + + const retentionEntry = metadataHarness.collectionMetadata.get( + `queryCollection:gc:${retainedQueryHash}`, + ) as + | { + queryHash: string + mode: `ttl` | `until-revalidated` + expiresAt?: number + } + | undefined + + expect(retentionEntry).toEqual({ + queryHash: retainedQueryHash, + mode: `ttl`, + expiresAt: expect.any(Number), + }) + expect(retentionEntry?.mode).toBe(`ttl`) + expect(retentionEntry?.expiresAt).toBeGreaterThanOrEqual(Date.now()) + expect(retentionEntry?.expiresAt).toBeLessThanOrEqual(Date.now() + gcTime) + + await vi.advanceTimersByTimeAsync(gcTime + 25) + await vi.runOnlyPendingTimersAsync() + + expect( + metadataHarness.collectionMetadata.get( + `queryCollection:gc:${retainedQueryHash}`, + ), + ).toBeUndefined() + expect(collection.has(`1`)).toBe(false) + } finally { + gcTimeFallbackQueryClient.clear() + vi.useRealTimers() + } + }) + it(`should reset refcount after query GC and reload (stale refcount bug)`, async () => { // This test catches Bug 2: stale refcounts after GC/remove // When TanStack Query GCs a query, the refcount should be cleaned up From a8f834ef377f789c1e97491bcdd8d90645544506 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sun, 22 Mar 2026 18:23:33 +0000 Subject: [PATCH 2/5] chore: add changeset for persisted gcTime fallback Document the query-db-collection patch release for the persistedGcTime fallback so versioning captures the retention behavior fix. Made-with: Cursor --- .changeset/small-mice-fold.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/small-mice-fold.md diff --git a/.changeset/small-mice-fold.md b/.changeset/small-mice-fold.md new file mode 100644 index 000000000..61b875e86 --- /dev/null +++ b/.changeset/small-mice-fold.md @@ -0,0 +1,7 @@ +--- +'@tanstack/query-db-collection': patch +--- + +fix: default persisted query retention to gcTime when omitted + +When `persistedGcTime` is not provided, query collections now use the query's effective `gcTime` as the persisted retention TTL. This prevents unexpectedly early cleanup of persisted rows. From 4eafef5da6138752f7a9d3f87bb17528d05fead3 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sun, 22 Mar 2026 18:27:48 +0000 Subject: [PATCH 3/5] refactor: tighten persisted gcTime fallback tracking Make effective persisted retention tracking unambiguous by storing only defined ttl values, and remove a redundant assertion from the fallback regression test while preserving expiry bounds validation. Made-with: Cursor --- packages/query-db-collection/src/query.ts | 8 ++++++-- packages/query-db-collection/tests/query.test.ts | 1 - 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index b3c28dfe2..891c7d21c 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -713,7 +713,7 @@ export function queryCollectionOptions( let syncStarted = false let startupRetentionSettled = false const retainedQueriesPendingRevalidation = new Set() - const effectivePersistedGcTimes = new Map() + const effectivePersistedGcTimes = new Map() const persistedRetentionTimers = new Map< string, ReturnType @@ -1178,7 +1178,11 @@ export function queryCollectionOptions( hashToQueryKey.set(hashedQueryKey, key) state.observers.set(hashedQueryKey, localObserver) - effectivePersistedGcTimes.set(hashedQueryKey, effectivePersistedGcTime) + if (effectivePersistedGcTime !== undefined) { + effectivePersistedGcTimes.set(hashedQueryKey, effectivePersistedGcTime) + } else { + effectivePersistedGcTimes.delete(hashedQueryKey) + } // Increment reference count for this query queryRefCounts.set( diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index d141e88b8..247b50bee 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -4943,7 +4943,6 @@ describe(`QueryCollection`, () => { mode: `ttl`, expiresAt: expect.any(Number), }) - expect(retentionEntry?.mode).toBe(`ttl`) expect(retentionEntry?.expiresAt).toBeGreaterThanOrEqual(Date.now()) expect(retentionEntry?.expiresAt).toBeLessThanOrEqual(Date.now() + gcTime) From 7834a3c00ca81c82356f5668a70577072c352e70 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sun, 22 Mar 2026 18:40:37 +0000 Subject: [PATCH 4/5] fix: use resolved query gcTime for persisted retention fallback Read fallback gcTime from the runtime query entry so persisted retention follows TanStack Query's effective gcTime even when defaults are implicit. Add a regression test covering implicit gcTime resolution. Made-with: Cursor --- packages/query-db-collection/src/query.ts | 9 +- .../query-db-collection/tests/query.test.ts | 111 ++++++++++++++++++ 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 891c7d21c..5f5ce5de8 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1164,10 +1164,6 @@ export function queryCollectionOptions( ...(retryDelay !== undefined && { retryDelay }), ...(staleTime !== undefined && { staleTime }), } - const effectivePersistedGcTime = - persistedGcTime ?? - queryClient.defaultQueryOptions(observerOptions).gcTime - const localObserver = new QueryObserver< Array, any, @@ -1175,6 +1171,11 @@ export function queryCollectionOptions( Array, any >(queryClient, observerOptions) + const resolvedQueryGcTime = queryClient.getQueryCache().find({ + queryKey: key, + exact: true, + })?.gcTime + const effectivePersistedGcTime = persistedGcTime ?? resolvedQueryGcTime hashToQueryKey.set(hashedQueryKey, key) state.observers.set(hashedQueryKey, localObserver) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 247b50bee..8b7907f2d 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -4961,6 +4961,117 @@ describe(`QueryCollection`, () => { } }) + it(`should default persisted retention to resolved query gcTime when queryClient gcTime is implicit`, async () => { + vi.useFakeTimers() + const implicitGcTimeQueryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 0, + retry: false, + }, + }, + }) + + try { + const baseQueryKey = [`runtime-ttl-retention-implicit-gctime-test`] + const retainedQueryHash = hashKey(baseQueryKey) + const items: Array = [ + { id: `1`, name: `Retained`, category: `A` }, + ] + const queryFn = vi.fn().mockResolvedValue(items) + + const config: QueryCollectionConfig = { + id: `runtime-ttl-retention-implicit-gctime-test`, + queryClient: implicitGcTimeQueryClient, + queryKey: () => baseQueryKey, + queryFn, + getKey: (item) => item.id, + syncMode: `on-demand`, + startSync: true, + } + + const baseOptions = queryCollectionOptions(config) + const originalSync = baseOptions.sync + const metadataHarness = createInMemorySyncMetadataApi< + string | number, + CategorisedItem + >({ + persistedRows: new Map(items.map((item) => [item.id, item])), + }) + + const collection = createCollection({ + ...baseOptions, + sync: { + sync: (params: Parameters[0]) => + originalSync.sync({ + ...params, + metadata: metadataHarness.api, + }), + }, + }) + + const liveQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => eq(item.category, `A`)), + }) + + await liveQuery.preload() + await vi.waitFor(() => { + expect(collection.size).toBe(1) + }) + + const resolvedQueryGcTime = implicitGcTimeQueryClient + .getQueryCache() + .find({ queryKey: baseQueryKey, exact: true })?.gcTime + + expect(resolvedQueryGcTime).toBeDefined() + + await liveQuery.cleanup() + + const retentionEntry = metadataHarness.collectionMetadata.get( + `queryCollection:gc:${retainedQueryHash}`, + ) as + | { + queryHash: string + mode: `ttl` | `until-revalidated` + expiresAt?: number + } + | undefined + + if (resolvedQueryGcTime === Number.POSITIVE_INFINITY) { + expect(retentionEntry).toEqual({ + queryHash: retainedQueryHash, + mode: `until-revalidated`, + }) + } else { + expect(retentionEntry).toEqual({ + queryHash: retainedQueryHash, + mode: `ttl`, + expiresAt: expect.any(Number), + }) + expect(retentionEntry?.expiresAt).toBeGreaterThanOrEqual(Date.now()) + expect(retentionEntry?.expiresAt).toBeLessThanOrEqual( + Date.now() + resolvedQueryGcTime!, + ) + + await vi.advanceTimersByTimeAsync(resolvedQueryGcTime! + 25) + await vi.runOnlyPendingTimersAsync() + + expect( + metadataHarness.collectionMetadata.get( + `queryCollection:gc:${retainedQueryHash}`, + ), + ).toBeUndefined() + expect(collection.has(`1`)).toBe(false) + } + } finally { + implicitGcTimeQueryClient.clear() + vi.useRealTimers() + } + }) + it(`should reset refcount after query GC and reload (stale refcount bug)`, async () => { // This test catches Bug 2: stale refcounts after GC/remove // When TanStack Query GCs a query, the refcount should be cleaned up From ec9a851cc1063ec4cbe6ef0685c3d1bb96456663 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 18:41:59 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- packages/query-db-collection/src/query.ts | 5 ++--- packages/query-db-collection/tests/query.test.ts | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 5f5ce5de8..689d9ce9b 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -1546,9 +1546,8 @@ export function queryCollectionOptions( const cleanupQueryIfIdle = (hashedQueryKey: string) => { const refcount = queryRefCounts.get(hashedQueryKey) || 0 const observer = state.observers.get(hashedQueryKey) - const effectivePersistedGcTime = effectivePersistedGcTimes.get( - hashedQueryKey, - ) + const effectivePersistedGcTime = + effectivePersistedGcTimes.get(hashedQueryKey) if (refcount <= 0) { // Drop our subscription so hasListeners reflects only active consumers diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 8b7907f2d..95f825b98 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -4944,7 +4944,9 @@ describe(`QueryCollection`, () => { expiresAt: expect.any(Number), }) expect(retentionEntry?.expiresAt).toBeGreaterThanOrEqual(Date.now()) - expect(retentionEntry?.expiresAt).toBeLessThanOrEqual(Date.now() + gcTime) + expect(retentionEntry?.expiresAt).toBeLessThanOrEqual( + Date.now() + gcTime, + ) await vi.advanceTimersByTimeAsync(gcTime + 25) await vi.runOnlyPendingTimersAsync()