Skip to content

Commit 76f9113

Browse files
committed
query colleciton: Fixed bug where optimistic state leaked into syncedData when using writeInsert inside onInsert handlers
1 parent ddb56a9 commit 76f9113

3 files changed

Lines changed: 95 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/query-db-collection": patch
3+
---
4+
5+
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.

packages/query-db-collection/src/manual-sync.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ export function performWriteOperations<
204204
ctx.commit()
205205

206206
// Update query cache after successful commit
207-
const updatedData = ctx.collection.toArray
207+
const updatedData = Array.from(ctx.collection._state.syncedData.values())
208208
ctx.queryClient.setQueryData(ctx.queryKey, updatedData)
209209
}
210210

packages/query-db-collection/tests/query.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2013,6 +2013,95 @@ describe(`QueryCollection`, () => {
20132013
expect(collection2.has(`a`)).toBe(true)
20142014
expect(collection2.has(`b`)).toBe(true)
20152015
})
2016+
2017+
it(`should replace optimistic state with server state when writeInsert is called in onInsert handler`, async () => {
2018+
// Reproduces bug where optimistic client data overwrites server data in syncedData
2019+
// When writeInsert is called inside onInsert handler to sync server-generated fields
2020+
const queryKey = [`todos-writeinsert-bug`]
2021+
const queryFn = vi.fn().mockResolvedValue([])
2022+
2023+
type Todo = {
2024+
id: number
2025+
slug: string
2026+
title: string
2027+
checked: boolean
2028+
createdAt: string
2029+
}
2030+
2031+
let nextServerId = 1
2032+
const serverTodos: Array<Todo> = []
2033+
2034+
async function sleep(timeMs: number) {
2035+
return new Promise((resolve) => setTimeout(resolve, timeMs))
2036+
}
2037+
2038+
async function createTodos(newTodos: Array<Todo>) {
2039+
await sleep(50)
2040+
const savedTodos = newTodos.map((todo) => ({
2041+
...todo,
2042+
id: nextServerId++,
2043+
createdAt: new Date().toISOString(),
2044+
}))
2045+
serverTodos.push(...savedTodos)
2046+
return savedTodos
2047+
}
2048+
2049+
const todosCollection = createCollection(
2050+
queryCollectionOptions<Todo>({
2051+
id: `writeinsert-bug-test`,
2052+
queryKey,
2053+
queryFn,
2054+
queryClient,
2055+
getKey: (item: Todo) => item.slug,
2056+
startSync: true,
2057+
onInsert: async ({ transaction }) => {
2058+
const newItems = transaction.mutations.map((m) => m.modified)
2059+
const serverItems = await createTodos(newItems)
2060+
2061+
// Write server data with server-generated IDs to synced store
2062+
todosCollection.utils.writeBatch(() => {
2063+
serverItems.forEach((serverItem) => {
2064+
todosCollection.utils.writeInsert(serverItem)
2065+
})
2066+
})
2067+
2068+
return { refetch: false }
2069+
},
2070+
})
2071+
)
2072+
2073+
await vi.waitFor(() => {
2074+
expect(todosCollection.status).toBe(`ready`)
2075+
})
2076+
2077+
// Insert with client-side negative ID
2078+
const clientId = -999
2079+
const slug = `test-slug-${Date.now()}`
2080+
2081+
todosCollection.insert({
2082+
id: clientId,
2083+
title: `Task`,
2084+
slug,
2085+
checked: false,
2086+
createdAt: new Date().toISOString(),
2087+
})
2088+
2089+
// Wait for mutation to complete
2090+
await flushPromises()
2091+
await new Promise((resolve) => setTimeout(resolve, 100))
2092+
2093+
// Verify syncedData has server ID, not client ID
2094+
const syncedTodo = todosCollection._state.syncedData.get(slug)
2095+
expect(syncedTodo).toBeDefined()
2096+
expect(syncedTodo?.id).toBe(1) // Server-generated ID
2097+
expect(syncedTodo?.id).not.toBe(clientId) // Not client optimistic ID
2098+
2099+
// Verify visible state also shows server ID
2100+
const todo = todosCollection.get(slug)
2101+
expect(todo).toBeDefined()
2102+
expect(todo?.id).toBe(1)
2103+
expect(todo?.id).not.toBe(clientId)
2104+
})
20162105
})
20172106

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

0 commit comments

Comments
 (0)