Skip to content
Open
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
13 changes: 13 additions & 0 deletions .changeset/fix-1017-reconciliation-gap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@tanstack/db": patch
---

fix: item disappears during optimistic-to-synced transition (#1017)

`collection.insert()` calls `commit()` (which synchronously enters `mutationFn`/`onInsert`)
before `recomputeOptimisticState(true)` sets up the optimistic entry. This means `collection.has(key)`
returns `false` inside `onInsert`, and any sync data delivered during `onInsert` (e.g., Electric's
txid handshake) cannot find the item.

Fix: move `transactions.set()`, `scheduleTransactionCleanup()`, and `recomputeOptimisticState(true)`
before `commit()` so the item is in `optimisticUpserts` when `onInsert` runs.
9 changes: 6 additions & 3 deletions packages/db/src/collection/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,14 +250,17 @@ export class CollectionMutationsManager<
// Apply mutations to the new transaction
directOpTransaction.applyMutations(mutations)
this.markPendingLocalOrigins(mutations)
// Errors still reject tx.isPersisted.promise; this catch only prevents global unhandled rejections
directOpTransaction.commit().catch(() => undefined)

// Add the transaction to the collection's transactions store
// Add the transaction and recompute optimistic state BEFORE commit.
// commit() synchronously enters mutationFn (onInsert) which may check
// collection.has(key). The item must be visible at that point (#1017).
state.transactions.set(directOpTransaction.id, directOpTransaction)
state.scheduleTransactionCleanup(directOpTransaction)
state.recomputeOptimisticState(true)

// Errors still reject tx.isPersisted.promise; this catch only prevents global unhandled rejections
directOpTransaction.commit().catch(() => undefined)

return directOpTransaction
}
}
Expand Down
130 changes: 130 additions & 0 deletions packages/db/tests/reconciliation-gap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* Regression test for TanStack/db#1017
*
* When a direct insert's onInsert handler syncs data back (e.g., Electric's
* txid handshake), the item must never disappear from the collection during
* the optimistic → synced transition.
*/
import { describe, expect, it } from 'vitest'
import { createCollection } from '../src/collection/index.js'

interface Item {
id: string
title: string
}

describe(`Reconciliation gap (#1017)`, () => {
it(`item should not disappear when onInsert syncs data back`, async () => {
let syncBegin: (() => void) | undefined
let syncWrite:
| ((msg: { type: `insert`; value: Item }) => void)
| undefined
let syncCommit: (() => void) | undefined
let syncMarkReady: (() => void) | undefined

const collection = createCollection<Item>({
id: `reconciliation-test`,
getKey: (item) => item.id,
startSync: true,
sync: {
sync: (params) => {
syncBegin = params.begin
syncWrite = params.write
syncCommit = params.commit
syncMarkReady = params.markReady
},
},
onInsert: async ({ transaction }) => {
const item = transaction.mutations[0].modified

// Simulate Electric's onInsert flow:
// 1. Server accepts the write (REST API call)
// 2. Electric streams the committed row back via WAL
// 3. Sync delivers the row to the collection
syncBegin!()
syncWrite!({ type: `insert`, value: item })
syncCommit!()

// 4. Return (like Electric's awaitTxId resolving)
return {}
},
})

syncMarkReady!()
await collection.stateWhenReady()

// Insert — triggers optimistic insert + onInsert sync cycle.
collection.insert({ id: `item-1`, title: `Test item` })

// The item must ALWAYS be visible — never undefined.
// Before the fix, touchCollection() called onTransactionStateChange()
// (clearing optimistic state) before commitPendingTransactions()
// (writing synced data), creating a gap.
expect(collection.get(`item-1`)).toBeDefined()
expect(collection.has(`item-1`)).toBe(true)

// Allow async settlement.
await new Promise((resolve) => setTimeout(resolve, 50))

// Still visible after full settlement.
expect(collection.get(`item-1`)).toBeDefined()
})

it(`item visibility should have no gap during transition`, async () => {
let syncBegin: (() => void) | undefined
let syncWrite:
| ((msg: { type: `insert`; value: Item }) => void)
| undefined
let syncCommit: (() => void) | undefined
let syncMarkReady: (() => void) | undefined
const visibility: Array<boolean> = []

const collection = createCollection<Item>({
id: `visibility-gap-test`,
getKey: (item) => item.id,
startSync: true,
sync: {
sync: (params) => {
syncBegin = params.begin
syncWrite = params.write
syncCommit = params.commit
syncMarkReady = params.markReady
},
},
onInsert: async ({ transaction }) => {
const item = transaction.mutations[0].modified

// Capture visibility before sync delivery.
visibility.push(collection.has(`item-1`))

syncBegin!()
syncWrite!({ type: `insert`, value: item })
syncCommit!()

// Capture visibility after sync delivery.
visibility.push(collection.has(`item-1`))
return {}
},
})

syncMarkReady!()
await collection.stateWhenReady()

// Before insert: not visible.
visibility.push(collection.has(`item-1`))

collection.insert({ id: `item-1`, title: `Visibility test` })

// After insert returns: must be visible.
visibility.push(collection.has(`item-1`))

await new Promise((resolve) => setTimeout(resolve, 50))

// After settlement: still visible.
visibility.push(collection.has(`item-1`))

// Expected: [false, true, true, true, true]
// The item should be visible at every checkpoint after creation.
expect(visibility).toEqual([false, true, true, true, true])
})
})