-
Notifications
You must be signed in to change notification settings - Fork 185
Description
Summary
When using on-demand sync mode with multiple useLiveQuery calls, deleting an item and then re-inserting it causes the deleted item to reappear alongside the new one. The collection ends up with both the old (deleted) item and the new item in syncedData.
This only happens when:
- A live query is destroyed and recreated with the same predicate (e.g. a UI toggle that changes
useLiveQuerydeps) - The
QueryClientusesgcTime > 0(the default — 5 minutes)
How to reproduce
- Have 2+
useLiveQuerycalls on the sameon-demandcollection with differentwherepredicates - Have one query whose predicate changes based on UI state (e.g. a popover toggle changes
inArrayvalues, causinguseLiveQueryto recreate the live query collection) - Insert an item → toggle the UI (destroys the wide-predicate query)
- Delete the item → predicate narrows
- Toggle the UI back (recreates the wide-predicate query — same predicate as step 3)
- Insert the item again
- After
onInsertcompletes → the deleted item is back insyncedDataalongside the new one
Why it happens
When the wide-predicate query is destroyed in step 3, TanStack Query keeps the cached data because gcTime > 0. The cache still contains the item that will be deleted in step 4.
When the same predicate is recreated in step 5, the QueryObserver picks up this stale cache. makeQueryResultHandler sees the deleted item in the cache but not in syncedData, so it creates a sync transaction to insert it. This transaction is non-immediate, and gets deferred because the re-insert mutation from step 6 is persisting.
When writeInsert runs inside onInsert (with immediate: true), commitPendingTransactions flushes all committed transactions together — including the deferred one that re-inserts the deleted item.
Setting gcTime: 0 prevents this because stale cache is evicted immediately when a query is destroyed, so there's no stale data for the new observer to read.
Failing test
import { afterEach, describe, expect, it, vi } from 'vitest'
import { QueryClient } from '@tanstack/query-core'
import {
createCollection,
createLiveQueryCollection,
eq,
inArray,
} from '@tanstack/db'
import { queryCollectionOptions } from '../src/query'
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0))
describe('Ghost item duplication bug', () => {
let queryClient: QueryClient
afterEach(() => {
queryClient.clear()
})
it('should not re-insert deleted item when query predicate toggles back to stale cache', async () => {
// gcTime: Infinity matches production. Using gcTime: 0 hides the bug.
queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
gcTime: Infinity,
retry: false,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
},
},
})
interface Assignment {
id: number
task_id: number
resource_id: number
hours_per_day: number
}
const MUTATION_DELAY = 50
let serverItems: Array<Assignment> = [
{ id: 1, task_id: 10, resource_id: 1, hours_per_day: 8 },
{ id: 2, task_id: 10, resource_id: 2, hours_per_day: 4 },
{ id: 3, task_id: 20, resource_id: 3, hours_per_day: 8 },
]
let nextId = 100
const apiCreate = async (
item: Omit<Assignment, 'id'>,
): Promise<Assignment> => {
await sleep(MUTATION_DELAY)
const created = { ...item, id: nextId++ }
serverItems.push(created)
return created
}
const apiDelete = async (id: number): Promise<void> => {
await sleep(MUTATION_DELAY)
serverItems = serverItems.filter((a) => a.id !== id)
}
const collection = createCollection(
queryCollectionOptions<Assignment>({
id: `ghost-repro-${Date.now()}-${Math.random()}`,
queryClient,
queryKey: ['ghost-repro', String(Date.now()), String(Math.random())],
queryFn: async () => [...serverItems],
getKey: (item) => item.id,
syncMode: 'on-demand',
startSync: true,
onInsert: async ({ transaction, collection: col }) => {
const { id: _id, ...rest } = transaction.mutations[0]!
.modified as Assignment
const serverItem = await apiCreate(rest)
col.utils.writeInsert(serverItem)
return { refetch: false }
},
onDelete: async ({ transaction, collection: col }) => {
const id = transaction.mutations[0]!.key as number
await apiDelete(id)
col.utils.writeDelete(id)
return { refetch: false }
},
}),
)
// Always-active queries
const taskQuery = createLiveQueryCollection({
query: (q) =>
q
.from({ assignment: collection })
.where(({ assignment }) => inArray(assignment.task_id, [10])),
})
await taskQuery.preload()
const projectQuery = createLiveQueryCollection({
query: (q) =>
q
.from({ assignment: collection })
.where(({ assignment }) => eq(assignment.task_id, 10)),
})
await projectQuery.preload()
await vi.waitFor(() => {
expect(collection.size).toBe(3)
})
// Step 1: Toggle ON → add item → toggle OFF
let workloadQuery = createLiveQueryCollection({
query: (q) =>
q
.from({ assignment: collection })
.where(({ assignment }) =>
inArray(assignment.resource_id, [1, 2, 3, 4]),
),
})
await workloadQuery.preload()
collection.insert({
id: -1,
task_id: 10,
resource_id: 4,
hours_per_day: 8,
})
workloadQuery.cleanup()
workloadQuery = createLiveQueryCollection({
query: (q) =>
q
.from({ assignment: collection })
.where(({ assignment }) =>
inArray(assignment.resource_id, [1, 2, 4]),
),
})
await workloadQuery.preload()
await flushPromises()
await sleep(MUTATION_DELAY + 50)
await flushPromises()
const carolId = 100
expect(collection.has(carolId)).toBe(true)
// Step 2: Delete
collection.delete(carolId)
workloadQuery.cleanup()
workloadQuery = createLiveQueryCollection({
query: (q) =>
q
.from({ assignment: collection })
.where(({ assignment }) => inArray(assignment.resource_id, [1, 2])),
})
await workloadQuery.preload()
await flushPromises()
await sleep(MUTATION_DELAY + 50)
await flushPromises()
expect(collection.has(carolId)).toBe(false)
expect(collection._state.syncedData.has(carolId)).toBe(false)
// Step 3: Toggle ON again (stale cache)
workloadQuery.cleanup()
workloadQuery = createLiveQueryCollection({
query: (q) =>
q
.from({ assignment: collection })
.where(({ assignment }) =>
inArray(assignment.resource_id, [1, 2, 3, 4]),
),
})
await workloadQuery.preload()
await flushPromises()
// Step 4: Re-add → toggle OFF
const t2 = collection.insert({
id: -2,
task_id: 10,
resource_id: 4,
hours_per_day: 8,
})
workloadQuery.cleanup()
workloadQuery = createLiveQueryCollection({
query: (q) =>
q
.from({ assignment: collection })
.where(({ assignment }) =>
inArray(assignment.resource_id, [1, 2, 4]),
),
})
await workloadQuery.preload()
await flushPromises()
await t2.isPersisted.promise
await flushPromises()
await sleep(50)
// The deleted item (id=100) must NOT be in syncedData
expect(collection._state.syncedData.has(carolId)).toBe(false)
// The new item (id=101) should exist
expect(collection.has(101)).toBe(true)
// Only one assignment with resource_id=4
const allItems = Array.from(collection.values())
const carolAssignments = allItems.filter((a) => a.resource_id === 4)
expect(carolAssignments).toHaveLength(1)
expect(carolAssignments[0]?.id).toBe(101)
expect(collection.size).toBe(4)
workloadQuery.cleanup()
taskQuery.cleanup()
projectQuery.cleanup()
})
})