From 55055bbd1623c96d021bbdaf8f2a27eb72c72206 Mon Sep 17 00:00:00 2001 From: SamJB123 Date: Thu, 2 Apr 2026 23:20:00 +1100 Subject: [PATCH 1/2] feat(db): allow externally-provided previousValue in sync update change detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When collection values are live-reading proxy objects (e.g. Yjs v14 Y.Type proxies via getAttr()), deepEquals(previousVisibleValue, newVisibleValue) always returns true — both sides read through the already-mutated proxy and see identical current values. First updates on proxy-backed rows are silently dropped. ChangeMessage already has an optional previousValue field, and sync sources like Yjs deltas already know the pre-mutation state (SetAttrOp.prevValue). This change lets commitPendingTransactions use sync-provided previousValue for the comparison instead of the captured currentVisibleState, which is unreadable when the value is a live proxy. When previousValue is not provided, behavior is unchanged. --- packages/db/src/collection/state.ts | 38 ++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index af65cb801..6c63c1208 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -123,6 +123,20 @@ export class CollectionStateManager< public recentlySyncedKeys = new Set() public hasReceivedFirstCommit = false public isCommittingSyncTransactions = false + + /** + * Collects previousValue from sync update operations within a single + * commitPendingTransactions call. When a sync source (e.g. a Yjs-backed + * collection using live-reading proxy objects) provides previousValue + * on an update message, it takes precedence over the captured + * currentVisibleState for the deepEquals comparison. + * + * This allows live-reading proxy objects to work correctly as collection + * values — the proxy always returns the current state, but the sync + * source knows the previous state from its own diff system (e.g. Yjs + * delta's SetAttrOp.prevValue). + */ + private _syncPreviousValues: Map | null = null public isLocalOnly = false /** @@ -967,6 +981,18 @@ export class CollectionStateManager< const key = operation.key as TKey this.syncedKeys.add(key) + // Collect sync-provided previousValue for live-proxy-aware diffing + if ( + operation.type === `update` && + `previousValue` in operation && + operation.previousValue !== undefined + ) { + if (this._syncPreviousValues === null) { + this._syncPreviousValues = new Map() + } + this._syncPreviousValues.set(key, operation.previousValue) + } + // Determine origin: 'local' for local-only collections or pending local changes const origin: VirtualOrigin = this.isLocalOnly || @@ -1160,9 +1186,19 @@ export class CollectionStateManager< } } + // Retrieve and clear sync-provided previousValues for this commit + const syncPreviousValues = this._syncPreviousValues + this._syncPreviousValues = null + // Now check what actually changed in the final visible state for (const key of changedKeys) { - const previousVisibleValue = currentVisibleState.get(key) + // If the sync source provided a previousValue (e.g. from a Yjs delta), + // use it instead of the captured currentVisibleState. This is necessary + // when collection values are live-reading proxy objects — the proxy + // returns the current (post-mutation) state at capture time, making + // the before/after indistinguishable. The sync source's previousValue + // carries the actual pre-mutation state from its own diff system. + const previousVisibleValue = syncPreviousValues?.get(key) ?? currentVisibleState.get(key) const newVisibleValue = this.get(key) // This returns the new derived state const previousVirtualProps = this.getVirtualPropsSnapshotForState(key, { rowOrigins: previousRowOrigins, From edbd2935f9c21011980c7fae3967cfdea32c9db2 Mon Sep 17 00:00:00 2001 From: SamJB123 Date: Thu, 2 Apr 2026 23:41:43 +1100 Subject: [PATCH 2/2] upd --- .changeset/sync-previous-value.md | 7 +++++++ packages/db/src/collection/state.ts | 9 +++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 .changeset/sync-previous-value.md diff --git a/.changeset/sync-previous-value.md b/.changeset/sync-previous-value.md new file mode 100644 index 000000000..2dc509af4 --- /dev/null +++ b/.changeset/sync-previous-value.md @@ -0,0 +1,7 @@ +--- +'@tanstack/db': patch +--- + +feat: allow externally-provided `previousValue` in sync update change detection + +When sync update messages include `previousValue`, `commitPendingTransactions` now uses it for the before/after comparison instead of the captured `currentVisibleState`. This allows live-reading proxy objects (e.g. Yjs Y.Type proxies) to work as collection values — the proxy always returns current state, but the sync source can provide the pre-mutation state from its own diff system. No behavior change when `previousValue` is omitted. diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 6c63c1208..ffcd9d4a2 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -981,7 +981,10 @@ export class CollectionStateManager< const key = operation.key as TKey this.syncedKeys.add(key) - // Collect sync-provided previousValue for live-proxy-aware diffing + // Collect sync-provided previousValue for live-proxy-aware diffing. + // Only store the first previousValue per key within a batch — for + // multi-step updates (A→B→C), the pre-batch value (A) is the + // correct previousValue, not the intermediate (B). if ( operation.type === `update` && `previousValue` in operation && @@ -990,7 +993,9 @@ export class CollectionStateManager< if (this._syncPreviousValues === null) { this._syncPreviousValues = new Map() } - this._syncPreviousValues.set(key, operation.previousValue) + if (!this._syncPreviousValues.has(key)) { + this._syncPreviousValues.set(key, operation.previousValue) + } } // Determine origin: 'local' for local-only collections or pending local changes