Skip to content

Commit a1a484e

Browse files
authored
fix(query-db-collection): use deep equality for object field comparison (#967)
* test: add test for object field update rollback issue Add test that verifies object field updates with refetch: false don't rollback to previous values after server response. * fix: use deep equality for object field comparison in query observer Replace shallow equality (===) with deep equality when comparing items in the query observer callback. This fixes an issue where updating object fields with refetch: false would cause rollback to previous values every other update. * chore: add changeset for object field update rollback fix
1 parent f458e05 commit a1a484e

File tree

4 files changed

+103
-26
lines changed

4 files changed

+103
-26
lines changed
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@tanstack/query-db-collection": patch
3+
"@tanstack/db": patch
4+
---
5+
6+
fix(query-db-collection): use deep equality for object field comparison in query observer
7+
8+
Fixed an issue where updating object fields (non-primitives) with `refetch: false` in `onUpdate` handlers would cause the value to rollback to the previous state every other update. The query observer was using shallow equality (`===`) to compare items, which compares object properties by reference rather than by value. This caused the observer to incorrectly detect differences and write stale data back to syncedData. Now uses `deepEquals` for proper value comparison.

packages/db/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export * from "./optimistic-action"
1313
export * from "./local-only"
1414
export * from "./local-storage"
1515
export * from "./errors"
16+
export { deepEquals } from "./utils"
1617
export * from "./paced-mutations"
1718
export * from "./strategies/index.js"
1819

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

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { QueryObserver, hashKey } from "@tanstack/query-core"
2+
import { deepEquals } from "@tanstack/db"
23
import {
34
GetKeyRequiredError,
45
QueryClientRequiredError,
@@ -799,39 +800,14 @@ export function queryCollectionOptions(
799800

800801
begin()
801802

802-
// Helper function for shallow equality check of objects
803-
const shallowEqual = (
804-
obj1: Record<string, any>,
805-
obj2: Record<string, any>
806-
): boolean => {
807-
// Get all keys from both objects
808-
const keys1 = Object.keys(obj1)
809-
const keys2 = Object.keys(obj2)
810-
811-
// If number of keys is different, objects are not equal
812-
if (keys1.length !== keys2.length) return false
813-
814-
// Check if all keys in obj1 have the same values in obj2
815-
return keys1.every((key) => {
816-
// Skip comparing functions and complex objects deeply
817-
if (typeof obj1[key] === `function`) return true
818-
return obj1[key] === obj2[key]
819-
})
820-
}
821-
822803
currentSyncedItems.forEach((oldItem, key) => {
823804
const newItem = newItemsMap.get(key)
824805
if (!newItem) {
825806
const needToRemove = removeRow(key, hashedQueryKey) // returns true if the row is no longer referenced by any queries
826807
if (needToRemove) {
827808
write({ type: `delete`, value: oldItem })
828809
}
829-
} else if (
830-
!shallowEqual(
831-
oldItem as Record<string, any>,
832-
newItem as Record<string, any>
833-
)
834-
) {
810+
} else if (!deepEquals(oldItem, newItem)) {
835811
// Only update if there are actual differences in the properties
836812
write({ type: `update`, value: newItem })
837813
}

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2112,6 +2112,98 @@ describe(`QueryCollection`, () => {
21122112
expect(todo?.id).toBe(1)
21132113
expect(todo?.id).not.toBe(clientId)
21142114
})
2115+
2116+
it(`should not rollback object field updates after server response with refetch: false`, async () => {
2117+
const queryKey = [`object-field-update-test`]
2118+
2119+
type Todo = {
2120+
id: string
2121+
metadata: { createdBy: string }
2122+
}
2123+
2124+
const serverTodos: Array<Todo> = [
2125+
{ id: `1`, metadata: { createdBy: `user1` } },
2126+
]
2127+
2128+
const queryFn = vi
2129+
.fn()
2130+
.mockImplementation(() => Promise.resolve([...serverTodos]))
2131+
2132+
async function updateTodo(id: string, changes: Partial<Todo>) {
2133+
await new Promise((resolve) => setTimeout(resolve, 10))
2134+
const todo = serverTodos.find((t) => t.id === id)
2135+
if (todo) {
2136+
Object.assign(todo, changes)
2137+
}
2138+
return todo
2139+
}
2140+
2141+
const todosCollection = createCollection(
2142+
queryCollectionOptions<Todo>({
2143+
id: `object-field-update-test`,
2144+
queryKey,
2145+
queryFn,
2146+
queryClient,
2147+
getKey: (item: Todo) => item.id,
2148+
startSync: true,
2149+
onUpdate: async ({ transaction }) => {
2150+
const updates = transaction.mutations.map((m) => ({
2151+
id: m.key as string,
2152+
changes: m.changes,
2153+
}))
2154+
2155+
const serverItems = await Promise.all(
2156+
updates.map((update) => updateTodo(update.id, update.changes))
2157+
)
2158+
2159+
todosCollection.utils.writeBatch(() => {
2160+
serverItems.forEach((serverItem) => {
2161+
if (serverItem) {
2162+
todosCollection.utils.writeUpdate(serverItem)
2163+
}
2164+
})
2165+
})
2166+
2167+
return { refetch: false }
2168+
},
2169+
})
2170+
)
2171+
2172+
await vi.waitFor(() => {
2173+
expect(todosCollection.status).toBe(`ready`)
2174+
})
2175+
2176+
// Verify initial state
2177+
expect(todosCollection.get(`1`)?.metadata.createdBy).toBe(`user1`)
2178+
2179+
// Update 1: change metadata from user1 to user456
2180+
todosCollection.update(`1`, (draft) => {
2181+
draft.metadata = { createdBy: `user456` }
2182+
})
2183+
2184+
// Wait for mutation to complete
2185+
await new Promise((resolve) => setTimeout(resolve, 50))
2186+
2187+
// Verify Update 1 worked
2188+
expect(todosCollection.get(`1`)?.metadata.createdBy).toBe(`user456`)
2189+
expect(
2190+
todosCollection._state.syncedData.get(`1`)?.metadata.createdBy
2191+
).toBe(`user456`)
2192+
2193+
// Update 2: change metadata from user456 to user789
2194+
todosCollection.update(`1`, (draft) => {
2195+
draft.metadata = { createdBy: `user789` }
2196+
})
2197+
2198+
// Wait for mutation to complete
2199+
await new Promise((resolve) => setTimeout(resolve, 50))
2200+
2201+
// Verify Update 2 persisted correctly
2202+
expect(
2203+
todosCollection._state.syncedData.get(`1`)?.metadata.createdBy
2204+
).toBe(`user789`)
2205+
expect(todosCollection.get(`1`)?.metadata.createdBy).toBe(`user789`)
2206+
})
21152207
})
21162208

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

0 commit comments

Comments
 (0)