Bug Report
Description
When an onInsert handler syncs server data back to the collection (via writeInsert, refetch, or any sync path), the optimistic insert under the original client key is never removed if the server returns a different key. This causes:
- Duplication: Both the client-key item (
$synced: false, $origin: local) and the server-key item ($synced: true, $origin: remote) appear in the collection. The client-key item persists forever.
- Stale
$synced (same-key variant): When client and server use the same key, $synced stays false permanently and the optimistic data shadows the server data.
Both violate the 0.6 contract: "when your mutation handler promise resolves, the optimistic state is removed."
Reproduction
Different key — via writeInsert:
const collection = createCollection(
queryCollectionOptions({
id: 'assignments',
getKey: (item) => item.id,
queryKey: ['assignments'],
queryFn: async () => api.getAssignments(),
queryClient,
onInsert: async ({ transaction, collection }) => {
const newItems = transaction.mutations.map((m) => m.modified)
// Client uses id: -Date.now(), server returns sequential DB id
const serverItems = await api.createAssignments(newItems)
collection.utils.writeInsert(serverItems)
return { refetch: false }
},
}),
)
collection.insert({ id: -Date.now(), resource_id: 42, name: 'New' })
// Result: two items visible — one under client key, one under server key
Different key — via refetch:
onInsert: async ({ transaction, collection }) => {
const newItems = transaction.mutations.map((m) => m.modified)
const serverItems = await api.createAssignments(newItems)
// No writeInsert — just refetch. Server returns the item under a different key.
await collection.utils.refetch()
},
Same result: the optimistic item under the client key persists alongside the refetched server item.
Same key — via writeInsert:
onInsert: async ({ transaction, collection }) => {
const newItems = transaction.mutations.map((m) => m.modified)
// Server returns same key but adds server-computed fields
const serverItems = await api.createAssignments(newItems)
collection.utils.writeInsert(serverItems)
return { refetch: false }
},
Result: single item, but $synced stays false and the optimistic data (without server-computed fields) shadows the server data from writeInsert.
Failing Tests
Three reproduction tests in packages/query-db-collection/tests/query.test.ts:
should not duplicate items when writeInsert uses a different key than the optimistic insert
should mark item as synced when writeInsert uses the same key as the optimistic insert
should not duplicate items when refetch returns a different key than the optimistic insert
All fail on current main with the @tanstack/db dist rebuilt.
Root Cause
Introduced in 9952921e ("Virtual props implementation #1213"). The pendingOptimisticDirectUpserts set was added to keep optimistic state visible between transaction completion and sync confirmation (for correct $synced tracking).
The sequence:
collection.insert() → creates direct transaction, mutation key = clientKey
onInsert runs (tx state = persisting)
- Sync data is committed (via
writeInsert or refetch) — commitPendingTransactions clears pendingOptimisticDirectUpserts for the sync key (which may differ from clientKey)
- Handler returns →
commit() sets state to completed → touchCollection() → recomputeOptimisticState(false)
- In
recomputeOptimisticState: the completed-transaction loop unconditionally re-adds clientKey to pendingOptimisticDirectUpserts (line 503)
isPersisted.resolve() → microtask: scheduleTransactionCleanup removes the transaction from the map
- Any subsequent
recomputeOptimisticState (from observer refetch, etc.) finds clientKey in pendingOptimisticDirectUpserts but no transaction to process → key persists forever, resurrected into optimisticUpserts via the seeding step (line 555)
Proposed Fix: Track which direct transactions had sync writes
Add a Set<string> (directTransactionsWithSyncWrites) to CollectionStateManager. When commitPendingTransactions processes an immediate sync transaction (which only comes from writeInsert/writeUpdate/writeDelete called inside a handler), record the ID of any persisting direct transaction. Then in recomputeOptimisticState, skip re-adding to pendingOptimisticDirectUpserts for transactions in this set.
This only changes behavior when a sync write was committed during the handler — the non-writeInsert flows are unaffected.
Trade-off: This approach uses an indirect signal — it infers "writeInsert was called" by observing that an immediate sync transaction was processed while a direct transaction was persisting. If a future change introduces another source of immediate sync transactions, it could incorrectly suppress the pending optimistic state. It also adds a Set that must stay in sync across three locations (commitPendingTransactions, recomputeOptimisticState, scheduleTransactionCleanup).
A potentially cleaner alternative would be a first-class flag on the transaction itself (e.g., transaction.hasSyncWrites = true) set explicitly by writeInsert/writeUpdate/writeDelete. That's a direct signal rather than an inferred one, but requires touching transactions.ts as well.
| Flow |
Result |
| writeInsert + different key |
fixed |
| writeInsert + same key |
fixed |
| refetch + different key |
fixed |
| No writeInsert + default refetch |
safe |
| No writeInsert + refetch: false |
safe |
Environment
Bug Report
Description
When an
onInserthandler syncs server data back to the collection (viawriteInsert,refetch, or any sync path), the optimistic insert under the original client key is never removed if the server returns a different key. This causes:$synced: false,$origin: local) and the server-key item ($synced: true,$origin: remote) appear in the collection. The client-key item persists forever.$synced(same-key variant): When client and server use the same key,$syncedstaysfalsepermanently and the optimistic data shadows the server data.Both violate the 0.6 contract: "when your mutation handler promise resolves, the optimistic state is removed."
Reproduction
Different key — via writeInsert:
Different key — via refetch:
Same result: the optimistic item under the client key persists alongside the refetched server item.
Same key — via writeInsert:
Result: single item, but
$syncedstaysfalseand the optimistic data (without server-computed fields) shadows the server data fromwriteInsert.Failing Tests
Three reproduction tests in
packages/query-db-collection/tests/query.test.ts:should not duplicate items when writeInsert uses a different key than the optimistic insertshould mark item as synced when writeInsert uses the same key as the optimistic insertshould not duplicate items when refetch returns a different key than the optimistic insertAll fail on current
mainwith the@tanstack/dbdist rebuilt.Root Cause
Introduced in
9952921e("Virtual props implementation #1213"). ThependingOptimisticDirectUpsertsset was added to keep optimistic state visible between transaction completion and sync confirmation (for correct$syncedtracking).The sequence:
collection.insert()→ creates direct transaction, mutation key =clientKeyonInsertruns (tx state =persisting)writeInsertorrefetch) —commitPendingTransactionsclearspendingOptimisticDirectUpsertsfor the sync key (which may differ fromclientKey)commit()sets state tocompleted→touchCollection()→recomputeOptimisticState(false)recomputeOptimisticState: the completed-transaction loop unconditionally re-addsclientKeytopendingOptimisticDirectUpserts(line 503)isPersisted.resolve()→ microtask:scheduleTransactionCleanupremoves the transaction from the maprecomputeOptimisticState(from observer refetch, etc.) findsclientKeyinpendingOptimisticDirectUpsertsbut no transaction to process → key persists forever, resurrected intooptimisticUpsertsvia the seeding step (line 555)Proposed Fix: Track which direct transactions had sync writes
Add a
Set<string>(directTransactionsWithSyncWrites) toCollectionStateManager. WhencommitPendingTransactionsprocesses animmediatesync transaction (which only comes fromwriteInsert/writeUpdate/writeDeletecalled inside a handler), record the ID of any persisting direct transaction. Then inrecomputeOptimisticState, skip re-adding topendingOptimisticDirectUpsertsfor transactions in this set.This only changes behavior when a sync write was committed during the handler — the non-
writeInsertflows are unaffected.Trade-off: This approach uses an indirect signal — it infers "writeInsert was called" by observing that an
immediatesync transaction was processed while a direct transaction was persisting. If a future change introduces another source ofimmediatesync transactions, it could incorrectly suppress the pending optimistic state. It also adds aSetthat must stay in sync across three locations (commitPendingTransactions,recomputeOptimisticState,scheduleTransactionCleanup).A potentially cleaner alternative would be a first-class flag on the transaction itself (e.g.,
transaction.hasSyncWrites = true) set explicitly bywriteInsert/writeUpdate/writeDelete. That's a direct signal rather than an inferred one, but requires touchingtransactions.tsas well.Environment
@tanstack/[email protected]@tanstack/[email protected]@tanstack/[email protected]