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
5 changes: 5 additions & 0 deletions .changeset/fix-cross-query-contamination.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

Fix cross-query data contamination in `useLiveInfiniteQuery` when navigating between different queries on the same collection with `syncMode: 'on-demand'`. The first page now correctly loads from `loadSubset` instead of potentially stale local index data.
18 changes: 16 additions & 2 deletions packages/db/src/collection/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,20 @@ export class CollectionSubscription
// For multi-column orderBy, we use the first column value for index operations (wide bounds)
// This may load some duplicates but ensures we never miss any rows.
let keys: Array<string | number> = []
if (hasMinValue) {

// FIX: Skip local data for first snapshot to prevent cross-query contamination.
// When loadSubset has been called on this collection by ANY subscription,
// the local index may contain data from a DIFFERENT query
// (e.g., user viewed Inbox first, then navigated to All Emails).
// In that case, let loadSubset provide the correct data for this query instead.
//
// However, if no loadSubset has been called yet, the data came from the sync
// layer's initial write() calls, which is valid for all queries.
const isFirstSnapshot = this.loadedSubsets.length === 0
const hasContaminatedData =
isFirstSnapshot && this.collection._sync.hasLoadSubsetBeenCalled

if (!hasContaminatedData && hasMinValue) {
// First, get all items with the same FIRST COLUMN value as minValue
// This provides wide bounds for the local index
const { expression } = orderBy[0]!
Expand All @@ -502,10 +515,11 @@ export class CollectionSubscription
} else {
keys = index.take(limit, minValueForIndex!, filterFn)
}
} else {
} else if (!hasContaminatedData) {
// No min value provided, start from the beginning
keys = index.takeFromStart(limit, filterFn)
}
// else: hasContaminatedData is true, keys stays empty - loadSubset will provide the data

const valuesNeeded = () => Math.max(limit - changes.length, 0)
const collectionExhausted = () => keys.length === 0
Expand Down
11 changes: 11 additions & 0 deletions packages/db/src/collection/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ export class CollectionSyncManager<

private pendingLoadSubsetPromises: Set<Promise<void>> = new Set()

/**
* Tracks whether ANY loadSubset has been called on this collection.
* Used to detect cross-query contamination: when a new subscription sees
* data loaded by a DIFFERENT subscription's loadSubset call.
*/
public hasLoadSubsetBeenCalled = false

/**
* Creates a new CollectionSyncManager instance
*/
Expand Down Expand Up @@ -349,6 +356,10 @@ export class CollectionSyncManager<
}

if (this.syncLoadSubsetFn) {
// Mark that loadSubset has been called on this collection.
// This is used to detect cross-query contamination in subscriptions.
this.hasLoadSubsetBeenCalled = true

const result = this.syncLoadSubsetFn(options)
// If the result is a promise, track it
if (result instanceof Promise) {
Expand Down
Loading