Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-optimistic-state-in-synced-data.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tanstack/query-db-collection": patch
---

Fixed bug where optimistic state leaked into syncedData when using writeInsert inside onInsert handlers. Previously, when syncing server-generated fields (like IDs or timestamps) using writeInsert within an onInsert handler, the QueryClient cache was updated with combined visible state (including optimistic changes), which triggered the query observer to write optimistic values back to syncedData. Now the cache is correctly updated with only server-confirmed state, ensuring syncedData maintains separation from optimistic state.
2 changes: 1 addition & 1 deletion packages/query-db-collection/src/manual-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ export function performWriteOperations<
ctx.commit()

// Update query cache after successful commit
const updatedData = ctx.collection.toArray
const updatedData = Array.from(ctx.collection._state.syncedData.values())
ctx.queryClient.setQueryData(ctx.queryKey, updatedData)
}

Expand Down
89 changes: 89 additions & 0 deletions packages/query-db-collection/tests/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2013,6 +2013,95 @@ describe(`QueryCollection`, () => {
expect(collection2.has(`a`)).toBe(true)
expect(collection2.has(`b`)).toBe(true)
})

it(`should replace optimistic state with server state when writeInsert is called in onInsert handler`, async () => {
// Reproduces bug where optimistic client data overwrites server data in syncedData
// When writeInsert is called inside onInsert handler to sync server-generated fields
const queryKey = [`todos-writeinsert-bug`]
const queryFn = vi.fn().mockResolvedValue([])

type Todo = {
id: number
slug: string
title: string
checked: boolean
createdAt: string
}

let nextServerId = 1
const serverTodos: Array<Todo> = []

async function sleep(timeMs: number) {
return new Promise((resolve) => setTimeout(resolve, timeMs))
}

async function createTodos(newTodos: Array<Todo>) {
await sleep(50)
const savedTodos = newTodos.map((todo) => ({
...todo,
id: nextServerId++,
createdAt: new Date().toISOString(),
}))
serverTodos.push(...savedTodos)
return savedTodos
}

const todosCollection = createCollection(
queryCollectionOptions<Todo>({
id: `writeinsert-bug-test`,
queryKey,
queryFn,
queryClient,
getKey: (item: Todo) => item.slug,
startSync: true,
onInsert: async ({ transaction }) => {
const newItems = transaction.mutations.map((m) => m.modified)
const serverItems = await createTodos(newItems)

// Write server data with server-generated IDs to synced store
todosCollection.utils.writeBatch(() => {
serverItems.forEach((serverItem) => {
todosCollection.utils.writeInsert(serverItem)
})
})

return { refetch: false }
},
})
)

await vi.waitFor(() => {
expect(todosCollection.status).toBe(`ready`)
})

// Insert with client-side negative ID
const clientId = -999
const slug = `test-slug-${Date.now()}`

todosCollection.insert({
id: clientId,
title: `Task`,
slug,
checked: false,
createdAt: new Date().toISOString(),
})

// Wait for mutation to complete
await flushPromises()
await new Promise((resolve) => setTimeout(resolve, 100))

// Verify syncedData has server ID, not client ID
const syncedTodo = todosCollection._state.syncedData.get(slug)
expect(syncedTodo).toBeDefined()
expect(syncedTodo?.id).toBe(1) // Server-generated ID
expect(syncedTodo?.id).not.toBe(clientId) // Not client optimistic ID

// Verify visible state also shows server ID
const todo = todosCollection.get(slug)
expect(todo).toBeDefined()
expect(todo?.id).toBe(1)
expect(todo?.id).not.toBe(clientId)
})
})

it(`should call markReady when queryFn returns an empty array`, async () => {
Expand Down
Loading