Skip to content

Commit 3e9a36d

Browse files
KyleAMathewsclaude
andauthored
fix: prevent user actions from being blocked by event filtering optimizations (#308)
Co-authored-by: Claude <[email protected]>
1 parent d11dc6a commit 3e9a36d

File tree

3 files changed

+173
-22
lines changed

3 files changed

+173
-22
lines changed

.changeset/open-zebras-remain.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Fix UI responsiveness issue with rapid user interactions in collections
6+
7+
Fixed a critical issue where rapid user interactions (like clicking multiple checkboxes quickly) would cause the UI to become unresponsive when using collections with slow backend responses. The problem occurred when optimistic updates would back up and the UI would stop reflecting user actions.
8+
9+
**Root Causes:**
10+
11+
- Event filtering logic was blocking ALL events for keys with recent sync operations, including user-initiated actions
12+
- Event batching was queuing user actions instead of immediately updating the UI during high-frequency operations
13+
14+
**Solution:**
15+
16+
- Added `triggeredByUserAction` parameter to `recomputeOptimisticState()` to distinguish user actions from sync operations
17+
- Modified event filtering to allow user-initiated actions to bypass sync status checks
18+
- Enhanced `emitEvents()` with `forceEmit` parameter to skip batching for immediate user action feedback
19+
- Updated all user action code paths to properly identify themselves as user-triggered
20+
21+
This ensures the UI remains responsive during rapid user interactions while maintaining the performance benefits of event batching and duplicate event filtering for sync operations.

packages/db/src/collection.ts

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -687,7 +687,9 @@ export class CollectionImpl<
687687
/**
688688
* Recompute optimistic state from active transactions
689689
*/
690-
private recomputeOptimisticState(): void {
690+
private recomputeOptimisticState(
691+
triggeredByUserAction: boolean = false
692+
): void {
691693
// Skip redundant recalculations when we're in the middle of committing sync transactions
692694
if (this.isCommittingSyncTransactions) {
693695
return
@@ -738,13 +740,26 @@ export class CollectionImpl<
738740
this.collectOptimisticChanges(previousState, previousDeletes, events)
739741

740742
// Filter out events for recently synced keys to prevent duplicates
741-
const filteredEventsBySyncStatus = events.filter(
742-
(event) => !this.recentlySyncedKeys.has(event.key)
743-
)
743+
// BUT: Only filter out events that are actually from sync operations
744+
// New user transactions should NOT be filtered even if the key was recently synced
745+
const filteredEventsBySyncStatus = events.filter((event) => {
746+
if (!this.recentlySyncedKeys.has(event.key)) {
747+
return true // Key not recently synced, allow event through
748+
}
749+
750+
// Key was recently synced - allow if this is a user-triggered action
751+
if (triggeredByUserAction) {
752+
return true
753+
}
754+
755+
// Otherwise filter out duplicate sync events
756+
return false
757+
})
744758

745759
// Filter out redundant delete events if there are pending sync transactions
746760
// that will immediately restore the same data, but only for completed transactions
747-
if (this.pendingSyncedTransactions.length > 0) {
761+
// IMPORTANT: Skip complex filtering for user-triggered actions to prevent UI blocking
762+
if (this.pendingSyncedTransactions.length > 0 && !triggeredByUserAction) {
748763
const pendingSyncKeys = new Set<TKey>()
749764
const completedTransactionMutations = new Set<string>()
750765

@@ -788,14 +803,14 @@ export class CollectionImpl<
788803
if (filteredEvents.length > 0) {
789804
this.updateIndexes(filteredEvents)
790805
}
791-
this.emitEvents(filteredEvents)
806+
this.emitEvents(filteredEvents, triggeredByUserAction)
792807
} else {
793808
// Update indexes for all events
794809
if (filteredEventsBySyncStatus.length > 0) {
795810
this.updateIndexes(filteredEventsBySyncStatus)
796811
}
797812
// Emit all events if no pending sync transactions
798-
this.emitEvents(filteredEventsBySyncStatus)
813+
this.emitEvents(filteredEventsBySyncStatus, triggeredByUserAction)
799814
}
800815
}
801816

@@ -878,22 +893,21 @@ export class CollectionImpl<
878893
*/
879894
private emitEvents(
880895
changes: Array<ChangeMessage<T, TKey>>,
881-
endBatching = false
896+
forceEmit = false
882897
): void {
883-
if (this.shouldBatchEvents && !endBatching) {
898+
// Skip batching for user actions (forceEmit=true) to keep UI responsive
899+
if (this.shouldBatchEvents && !forceEmit) {
884900
// Add events to the batch
885901
this.batchedEvents.push(...changes)
886902
return
887903
}
888904

889-
// Either we're not batching, or we're ending the batching cycle
905+
// Either we're not batching, or we're forcing emission (user action or ending batch cycle)
890906
let eventsToEmit = changes
891907

892-
if (endBatching) {
893-
// End batching: combine any batched events with new events and clean up state
894-
if (this.batchedEvents.length > 0) {
895-
eventsToEmit = [...this.batchedEvents, ...changes]
896-
}
908+
// If we have batched events and this is a forced emit, combine them
909+
if (this.batchedEvents.length > 0 && forceEmit) {
910+
eventsToEmit = [...this.batchedEvents, ...changes]
897911
this.batchedEvents = []
898912
this.shouldBatchEvents = false
899913
}
@@ -1625,7 +1639,7 @@ export class CollectionImpl<
16251639
ambientTransaction.applyMutations(mutations)
16261640

16271641
this.transactions.set(ambientTransaction.id, ambientTransaction)
1628-
this.recomputeOptimisticState()
1642+
this.recomputeOptimisticState(true)
16291643

16301644
return ambientTransaction
16311645
} else {
@@ -1650,7 +1664,7 @@ export class CollectionImpl<
16501664

16511665
// Add the transaction to the collection's transactions store
16521666
this.transactions.set(directOpTransaction.id, directOpTransaction)
1653-
this.recomputeOptimisticState()
1667+
this.recomputeOptimisticState(true)
16541668

16551669
return directOpTransaction
16561670
}
@@ -1847,7 +1861,7 @@ export class CollectionImpl<
18471861
ambientTransaction.applyMutations(mutations)
18481862

18491863
this.transactions.set(ambientTransaction.id, ambientTransaction)
1850-
this.recomputeOptimisticState()
1864+
this.recomputeOptimisticState(true)
18511865

18521866
return ambientTransaction
18531867
}
@@ -1876,7 +1890,7 @@ export class CollectionImpl<
18761890
// Add the transaction to the collection's transactions store
18771891

18781892
this.transactions.set(directOpTransaction.id, directOpTransaction)
1879-
this.recomputeOptimisticState()
1893+
this.recomputeOptimisticState(true)
18801894

18811895
return directOpTransaction
18821896
}
@@ -1963,7 +1977,7 @@ export class CollectionImpl<
19631977
ambientTransaction.applyMutations(mutations)
19641978

19651979
this.transactions.set(ambientTransaction.id, ambientTransaction)
1966-
this.recomputeOptimisticState()
1980+
this.recomputeOptimisticState(true)
19671981

19681982
return ambientTransaction
19691983
}
@@ -1989,7 +2003,7 @@ export class CollectionImpl<
19892003
directOpTransaction.commit()
19902004

19912005
this.transactions.set(directOpTransaction.id, directOpTransaction)
1992-
this.recomputeOptimisticState()
2006+
this.recomputeOptimisticState(true)
19932007

19942008
return directOpTransaction
19952009
}
@@ -2251,6 +2265,6 @@ export class CollectionImpl<
22512265
// CRITICAL: Capture visible state BEFORE clearing optimistic state
22522266
this.capturePreSyncVisibleState()
22532267

2254-
this.recomputeOptimisticState()
2268+
this.recomputeOptimisticState(false)
22552269
}
22562270
}

packages/db/tests/collection.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1248,4 +1248,120 @@ describe(`Collection with schema validation`, () => {
12481248
expect(thirdItem!.updatedAt).toEqual(new Date(`2023-01-01T00:00:00Z`))
12491249
expect(thirdItem!.id).toBe(`task-id-3`)
12501250
})
1251+
1252+
it(`should not block user actions when keys are recently synced`, async () => {
1253+
// This test reproduces the ACTUAL issue where rapid user actions get blocked
1254+
// when optimistic updates back up with slow sync responses
1255+
const txResolvers: Array<() => void> = []
1256+
const emitter = mitt()
1257+
const changeEvents: Array<any> = []
1258+
1259+
const mutationFn = vi.fn().mockImplementation(async ({ transaction }) => {
1260+
// Simulate SLOW server operation - this is key to reproducing the issue
1261+
return new Promise((resolve) => {
1262+
txResolvers.push(() => {
1263+
emitter.emit(`sync`, transaction.mutations)
1264+
resolve(null)
1265+
})
1266+
})
1267+
})
1268+
1269+
const collection = createCollection<{ id: number; checked: boolean }>({
1270+
id: `user-action-blocking-test`,
1271+
getKey: (item) => item.id,
1272+
startSync: true,
1273+
sync: {
1274+
sync: ({ begin, write, commit, markReady }) => {
1275+
// Initialize with checkboxes
1276+
begin()
1277+
for (let i = 1; i <= 3; i++) {
1278+
write({
1279+
type: `insert`,
1280+
value: { id: i, checked: false },
1281+
})
1282+
}
1283+
commit()
1284+
markReady()
1285+
1286+
// Listen for sync events - this triggers the problematic batching
1287+
// @ts-expect-error don't trust mitt's typing
1288+
emitter.on(`*`, (_, changes: Array<PendingMutation>) => {
1289+
begin()
1290+
changes.forEach((change) => {
1291+
write({
1292+
type: change.type,
1293+
// @ts-expect-error TODO type changes
1294+
value: change.modified,
1295+
})
1296+
})
1297+
commit()
1298+
})
1299+
},
1300+
},
1301+
onUpdate: mutationFn,
1302+
})
1303+
1304+
// Listen to change events to verify they're emitted (this was the actual problem)
1305+
collection.subscribeChanges((changes) => {
1306+
changeEvents.push(...changes)
1307+
})
1308+
1309+
await collection.stateWhenReady()
1310+
1311+
// CRITICAL: Simulate rapid clicking WITHOUT waiting for transactions to complete
1312+
// This is what actually triggers the bug - multiple pending transactions
1313+
1314+
// Step 1: First click
1315+
const tx1 = collection.update(1, (draft) => {
1316+
draft.checked = true
1317+
})
1318+
expect(collection.state.get(1)?.checked).toBe(true)
1319+
const initialEventCount = changeEvents.length
1320+
1321+
// Step 2: Second click immediately (before first completes)
1322+
const tx2 = collection.update(1, (draft) => {
1323+
draft.checked = false
1324+
})
1325+
expect(collection.state.get(1)?.checked).toBe(false)
1326+
1327+
// Step 3: Third click immediately (before others complete)
1328+
const tx3 = collection.update(1, (draft) => {
1329+
draft.checked = true
1330+
})
1331+
expect(collection.state.get(1)?.checked).toBe(true)
1332+
1333+
// CRITICAL TEST: Verify events are still being emitted for rapid user actions
1334+
// Before the fix, these would be batched and UI would freeze
1335+
expect(changeEvents.length).toBeGreaterThan(initialEventCount)
1336+
expect(mutationFn).toHaveBeenCalledTimes(3)
1337+
1338+
// Now complete the first transaction to trigger sync and batching
1339+
txResolvers[0]?.()
1340+
await tx1.isPersisted.promise
1341+
1342+
// Step 4: More rapid clicks after sync starts (this is where the bug occurred)
1343+
const eventCountBeforeRapidClicks = changeEvents.length
1344+
1345+
const tx4 = collection.update(1, (draft) => {
1346+
draft.checked = false
1347+
})
1348+
const tx5 = collection.update(1, (draft) => {
1349+
draft.checked = true
1350+
})
1351+
1352+
// CRITICAL: Verify that even after sync/batching starts, user actions still emit events
1353+
expect(changeEvents.length).toBeGreaterThan(eventCountBeforeRapidClicks)
1354+
expect(collection.state.get(1)?.checked).toBe(true) // Last action should win
1355+
1356+
// Clean up remaining transactions
1357+
for (let i = 1; i < txResolvers.length; i++) {
1358+
txResolvers[i]?.()
1359+
}
1360+
await Promise.all([
1361+
tx2.isPersisted.promise,
1362+
tx3.isPersisted.promise,
1363+
tx4.isPersisted.promise,
1364+
tx5.isPersisted.promise,
1365+
])
1366+
})
12511367
})

0 commit comments

Comments
 (0)