diff --git a/.changeset/fix-optimistic-state-in-synced-data.md b/.changeset/fix-optimistic-state-in-synced-data.md new file mode 100644 index 000000000..1566a59af --- /dev/null +++ b/.changeset/fix-optimistic-state-in-synced-data.md @@ -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. diff --git a/packages/query-db-collection/src/manual-sync.ts b/packages/query-db-collection/src/manual-sync.ts index 4300553f9..e9686789a 100644 --- a/packages/query-db-collection/src/manual-sync.ts +++ b/packages/query-db-collection/src/manual-sync.ts @@ -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) } diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index e0807232e..877bbe986 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -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 = [] + + async function sleep(timeMs: number) { + return new Promise((resolve) => setTimeout(resolve, timeMs)) + } + + async function createTodos(newTodos: Array) { + await sleep(50) + const savedTodos = newTodos.map((todo) => ({ + ...todo, + id: nextServerId++, + createdAt: new Date().toISOString(), + })) + serverTodos.push(...savedTodos) + return savedTodos + } + + const todosCollection = createCollection( + queryCollectionOptions({ + 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 () => {