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
11 changes: 11 additions & 0 deletions .changeset/fix-write-delete-in-handlers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@tanstack/query-db-collection": patch
---

Fix writeDelete/writeUpdate validation to check synced store only

Fixed issue where calling `writeDelete()` or `writeUpdate()` inside mutation handlers (like `onDelete`) would throw errors when optimistic updates were active. These write operations now correctly validate against the synced store only, not the combined view (synced + optimistic).

This allows patterns like calling `writeDelete()` inside an `onDelete` handler to work correctly, enabling users to write directly to the synced store while the mutation is being persisted to the backend.

Fixes #706
18 changes: 12 additions & 6 deletions packages/query-db-collection/src/manual-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,14 @@ function validateOperations<
seenKeys.add(op.key)

// Validate operation-specific requirements
// NOTE: These validations check the synced store only, not the combined view (synced + optimistic)
// This allows write operations to work correctly even when items are optimistically modified
if (op.type === `update`) {
if (!ctx.collection.has(op.key)) {
if (!ctx.collection._state.syncedData.has(op.key)) {
throw new UpdateOperationItemNotFoundError(op.key)
}
} else if (op.type === `delete`) {
if (!ctx.collection.has(op.key)) {
if (!ctx.collection._state.syncedData.has(op.key)) {
throw new DeleteOperationItemNotFoundError(op.key)
}
}
Expand Down Expand Up @@ -149,7 +151,8 @@ export function performWriteOperations<
break
}
case `update`: {
const currentItem = ctx.collection.get(op.key)!
// Get from synced store only, not the combined view
const currentItem = ctx.collection._state.syncedData.get(op.key)!
const updatedItem = {
...currentItem,
...op.data,
Expand All @@ -166,20 +169,23 @@ export function performWriteOperations<
break
}
case `delete`: {
const currentItem = ctx.collection.get(op.key)!
// Get from synced store only, not the combined view
const currentItem = ctx.collection._state.syncedData.get(op.key)!
ctx.write({
type: `delete`,
value: currentItem,
})
break
}
case `upsert`: {
// Check synced store only, not the combined view
const existsInSyncedStore = ctx.collection._state.syncedData.has(op.key)
const resolved = ctx.collection.validateData(
op.data,
ctx.collection.has(op.key) ? `update` : `insert`,
existsInSyncedStore ? `update` : `insert`,
op.key
)
if (ctx.collection.has(op.key)) {
if (existsInSyncedStore) {
ctx.write({
type: `update`,
value: resolved,
Expand Down
44 changes: 44 additions & 0 deletions packages/query-db-collection/tests/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2339,5 +2339,49 @@ describe(`QueryCollection`, () => {
expect(collection.status).toBe(`ready`)
expect(collection.size).toBe(items.length)
})

it(`should allow writeDelete in onDelete handler to write to synced store`, async () => {
const queryKey = [`writeDelete-in-onDelete-test`]
const items: Array<TestItem> = [
{ id: `1`, name: `Item 1` },
{ id: `2`, name: `Item 2` },
]

const queryFn = vi.fn().mockResolvedValue(items)

const onDelete = vi.fn(async ({ transaction, collection }) => {
const deletedItem = transaction.mutations[0]?.original
// Call writeDelete inside onDelete handler - this should work without throwing
collection.utils.writeDelete(deletedItem.id)
return { refetch: false }
})

const config: QueryCollectionConfig<TestItem> = {
id: `writeDelete-in-onDelete-test`,
queryClient,
queryKey,
queryFn,
getKey,
startSync: true,
onDelete,
}

const options = queryCollectionOptions(config)
const collection = createCollection(options)

await vi.waitFor(() => {
expect(collection.status).toBe(`ready`)
expect(collection.size).toBe(2)
})

const transaction = collection.delete(`1`)
await transaction.isPersisted.promise

// Verify the fix: writeDelete should work, transaction completes, item is deleted
expect(transaction.state).toBe(`completed`)
expect(onDelete).toHaveBeenCalledTimes(1)
expect(collection.has(`1`)).toBe(false)
expect(collection.size).toBe(1)
})
})
})
Loading