Skip to content

Ghost item: deleted item re-inserted when live query predicate toggles with gcTime > 0 #1354

@goatrenterguy

Description

@goatrenterguy

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:

  1. A live query is destroyed and recreated with the same predicate (e.g. a UI toggle that changes useLiveQuery deps)
  2. The QueryClient uses gcTime > 0 (the default — 5 minutes)

How to reproduce

  1. Have 2+ useLiveQuery calls on the same on-demand collection with different where predicates
  2. Have one query whose predicate changes based on UI state (e.g. a popover toggle changes inArray values, causing useLiveQuery to recreate the live query collection)
  3. Insert an item → toggle the UI (destroys the wide-predicate query)
  4. Delete the item → predicate narrows
  5. Toggle the UI back (recreates the wide-predicate query — same predicate as step 3)
  6. Insert the item again
  7. After onInsert completes → the deleted item is back in syncedData alongside 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()
  })
})

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions