From c2b7c8016b6b43420deabfcd88ab9e47328cf9c3 Mon Sep 17 00:00:00 2001 From: melvinhagberg Date: Mon, 2 Feb 2026 18:11:50 +0100 Subject: [PATCH] fix(db): prevent cross-query data contamination in useLiveInfiniteQuery When using useLiveInfiniteQuery with syncMode: 'on-demand', navigating between different queries on the same collection causes the wrong data to appear on the first page. This happens because a new subscription's first snapshot reads from the local index, which may contain data loaded by a different subscription's loadSubset call. The fix tracks whether loadSubset has been called on a collection. When a new subscription's first snapshot runs: - If loadSubset has never been called, local data is from initial sync (valid for all queries) - If loadSubset has been called, local data may be contaminated, so we skip local reads and let loadSubset provide the correct data --- .changeset/fix-cross-query-contamination.md | 5 +++++ packages/db/src/collection/subscription.ts | 18 ++++++++++++++++-- packages/db/src/collection/sync.ts | 11 +++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-cross-query-contamination.md diff --git a/.changeset/fix-cross-query-contamination.md b/.changeset/fix-cross-query-contamination.md new file mode 100644 index 000000000..57ab669a3 --- /dev/null +++ b/.changeset/fix-cross-query-contamination.md @@ -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. diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 40060ca05..9ed77611d 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -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 = [] - 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]! @@ -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 diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 4b71e4afd..614a55f47 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -49,6 +49,13 @@ export class CollectionSyncManager< private pendingLoadSubsetPromises: Set> = 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 */ @@ -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) {