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
53 changes: 52 additions & 1 deletion src/DynamicData.Tests/Cache/SourceCacheFixture.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks;

using DynamicData.Tests.Domain;

using DynamicData.Tests.Utilities;
using FluentAssertions;

using Xunit;
Expand Down Expand Up @@ -354,5 +355,55 @@ public void ConnectDuringDeliveryDoesNotDuplicate()
addCounts.GetValueOrDefault("k2").Should().Be(1, "k2 should appear once, not duplicated from snapshot + queued delivery");
}

// Covers https://github.com/reactivemarbles/DynamicData/issues/1129
[Fact]
public void ConnectDuringEditDoesNotDuplicate()
{
using var items = new SourceCache<int, int>(static item => item);

using var subscriptions = new CompositeDisposable();

// An initial subscription is required to initiate internal buffering of changes, during the upcoming .Edit().
// That is, we want there to be changes buffered, internally, when the mid-edit subscription comes in, to
// ensure that they don't get duplicated. This is the scenario that came in up #1129.
subscriptions.Add(items
.Connect()
.Subscribe());

CacheItemRecordingObserver<int, int>? results = null;

items.Edit(inner =>
{
inner.AddOrUpdate(1);

subscriptions.Add(items
.Connect()
.ValidateChangeSets(static item => item)
.RecordCacheItems(out results));

results.Error.Should().BeNull("no errors should have occurred");
results.RecordedChangeSets.Should().BeEmpty("no changes should be published in the middle of an edit");

inner.AddOrUpdate(2);

results.Error.Should().BeNull("no errors should have occurred");
results.RecordedChangeSets.Should().BeEmpty("no changes should be published in the middle of an edit");
});

results.Should().NotBeNull("the edit delegate should have been invoked");
results.Error.Should().BeNull("no errors should have occurred");
results.RecordedChangeSets.Should().ContainSingle("subscribers should only receive a single initial changeset");
results.RecordedItemsByKey.Should().BeEquivalentTo(
new Dictionary<int, int>()
{
[1] = 1,
[2] = 2
},
options => options.WithoutStrictOrdering(),
"all items in the source should have propagated downstream");

results.HasCompleted.Should().BeFalse("the source has not yet completed");
}

private sealed record TestItem(string Key, string Value);
}
50 changes: 50 additions & 0 deletions src/DynamicData.Tests/List/SourceListFixture.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Disposables;

using FluentAssertions;
using Xunit;

using DynamicData.Tests.Utilities;

namespace DynamicData.Tests.List;

public class SourceListFixture
{
// Covers https://github.com/reactivemarbles/DynamicData/issues/1129
[Fact]
public void ConnectDuringEditDoesNotDuplicate()
{
using var items = new SourceList<int>();

using var subscriptions = new CompositeDisposable();

// An initial subscription is required to initiate internal buffering of changes, during the upcoming .Edit().
// That is, we want there to be changes buffered, internally, when the mid-edit subscription comes in, to
// ensure that they don't get duplicated. This is the scenario that came in up #1129.
subscriptions.Add(items
.Connect()
.Subscribe());

ListItemRecordingObserver<int>? results = null;

items.Edit(inner =>
{
inner.Add(1);

subscriptions.Add(items
.Connect()
.ValidateChangeSets()
.RecordListItems(out results));

results.Error.Should().BeNull("no errors should have occurred");
results.RecordedChangeSets.Should().BeEmpty("no changes should be published in the middle of an edit");

inner.Add(2);

results.Error.Should().BeNull("no errors should have occurred");
results.RecordedChangeSets.Should().BeEmpty("no changes should be published in the middle of an edit");
});

results.Should().NotBeNull("the edit delegate should have been invoked");
results.Error.Should().BeNull("no errors should have occurred");
results.RecordedChangeSets.Should().ContainSingle("subscribers should only receive a single initial changeset");
results.RecordedItems.Should().BeEquivalentTo(
new[] { 1, 2, },
options => options.WithStrictOrdering(),
"all items in the source should have propagated downstream");

results.HasCompleted.Should().BeFalse("the source has not yet completed");
}

[Fact]
public void InitialChangeIsRange()
{
Expand Down
44 changes: 36 additions & 8 deletions src/DynamicData/Cache/ObservableCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Threading;

using DynamicData.Binding;
using DynamicData.Cache;
using DynamicData.Cache.Internal;

// ReSharper disable once CheckNamespace
Expand Down Expand Up @@ -42,6 +41,9 @@ internal sealed class ObservableCache<TObject, TKey> : IObservableCache<TObject,
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Terminated via NotifyCompleted in _cleanUp")]
private readonly DeliveryQueue<CacheUpdate> _notifications;

[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "CA2213:Disposable fields should be disposed", Justification = "Disposed with _cleanUp")]
private readonly BehaviorSubject<bool> _isEditInProgress;

private int _editLevel; // The level of recursion in editing.

private long _currentVersion; // Monotonic counter incremented under lock for each enqueued change notification.
Expand All @@ -53,6 +55,7 @@ public ObservableCache(IObservable<IChangeSet<TObject, TKey>> source)
_readerWriter = new ReaderWriter<TObject, TKey>();
_notifications = new DeliveryQueue<CacheUpdate>(_locker, new CacheUpdateObserver(this));
_suspensionTracker = new(() => new SuspensionTracker());
_isEditInProgress = new(false);

var loader = source.Subscribe(
changeSet =>
Expand Down Expand Up @@ -83,6 +86,7 @@ public ObservableCache(Func<TObject, TKey>? keySelector = null)
_readerWriter = new ReaderWriter<TObject, TKey>(keySelector);
_notifications = new DeliveryQueue<CacheUpdate>(_locker, new CacheUpdateObserver(this));
_suspensionTracker = new(() => new SuspensionTracker());
_isEditInProgress = new(false);

_cleanUp = Disposable.Create(NotifyCompleted);
}
Expand Down Expand Up @@ -115,14 +119,22 @@ public IObservable<IChangeSet<TObject, TKey>> Connect(Func<TObject, bool>? predi
{
lock (_locker)
{
var observable = (!_suspensionTracker.IsValueCreated || !_suspensionTracker.Value.AreNotificationsSuspended)
var observable = ((!_suspensionTracker.IsValueCreated || !_suspensionTracker.Value.AreNotificationsSuspended)
&& (!_isEditInProgress.Value))

// Create the Connection Observable
? CreateConnectObservable(predicate, suppressEmptyChangeSets)

// Defer until notifications are no longer suspended
: _suspensionTracker.Value.NotificationsSuspendedObservable.Do(static _ => { }, observer.OnCompleted)
.Where(static b => !b).Take(1).Select(_ => CreateConnectObservable(predicate, suppressEmptyChangeSets)).Switch();
: Observable.CombineLatest(
_suspensionTracker.Value.NotificationsSuspendedObservable,
_isEditInProgress,
static (areNotificationsSuspended, isEditInProgress) => areNotificationsSuspended || isEditInProgress)
.Do(static _ => { }, observer.OnCompleted)
.Where(static shouldConnectionBeDeferred => !shouldConnectionBeDeferred)
.Take(1)
.Select(_ => CreateConnectObservable(predicate, suppressEmptyChangeSets))
.Switch();

return observable.SubscribeSafe(observer);
}
Expand All @@ -139,14 +151,22 @@ public IObservable<Change<TObject, TKey>> Watch(TKey key) =>
{
lock (_locker)
{
var observable = (!_suspensionTracker.IsValueCreated || !_suspensionTracker.Value.AreNotificationsSuspended)
var observable = ((!_suspensionTracker.IsValueCreated || !_suspensionTracker.Value.AreNotificationsSuspended)
&& (!_isEditInProgress.Value))

// Create the Watch Observable
? CreateWatchObservable(key)

// Defer until notifications are no longer suspended
: _suspensionTracker.Value.NotificationsSuspendedObservable.Do(static _ => { }, observer.OnCompleted)
.Where(static b => !b).Take(1).Select(_ => CreateWatchObservable(key)).Switch();
: Observable.CombineLatest(
_suspensionTracker.Value.NotificationsSuspendedObservable,
_isEditInProgress,
static (areNotificationsSuspended, isEditInProgress) => areNotificationsSuspended || isEditInProgress)
.Do(static _ => { }, observer.OnCompleted)
.Where(static shouldConnectionBeDeferred => !shouldConnectionBeDeferred)
.Take(1)
.Select(_ => CreateWatchObservable(key))
.Switch();

return observable.SubscribeSafe(observer);
}
Expand Down Expand Up @@ -181,6 +201,7 @@ internal void UpdateFromIntermediate(Action<ICacheUpdater<TObject, TKey>> update
ChangeSet<TObject, TKey>? changes = null;

_editLevel++;
_isEditInProgress.OnNext(_editLevel is not 0);
if (_editLevel == 1)
{
var previewHandler = _changesPreview.HasObservers ? (Action<ChangeSet<TObject, TKey>>)InvokePreview : null;
Expand All @@ -197,6 +218,8 @@ internal void UpdateFromIntermediate(Action<ICacheUpdater<TObject, TKey>> update
{
notifications.EnqueueNext(new CacheUpdate(changes, _readerWriter.Count, ++_currentVersion));
}

_isEditInProgress.OnNext(_editLevel is not 0);
}

internal void UpdateFromSource(Action<ISourceUpdater<TObject, TKey>> updateAction)
Expand All @@ -208,6 +231,7 @@ internal void UpdateFromSource(Action<ISourceUpdater<TObject, TKey>> updateActio
ChangeSet<TObject, TKey>? changes = null;

_editLevel++;
_isEditInProgress.OnNext(_editLevel is not 0);
if (_editLevel == 1)
{
var previewHandler = _changesPreview.HasObservers ? (Action<ChangeSet<TObject, TKey>>)InvokePreview : null;
Expand All @@ -224,6 +248,8 @@ internal void UpdateFromSource(Action<ISourceUpdater<TObject, TKey>> updateActio
{
notifications.EnqueueNext(new CacheUpdate(changes, _readerWriter.Count, ++_currentVersion));
}

_isEditInProgress.OnNext(_editLevel is not 0);
}

private IObservable<IChangeSet<TObject, TKey>> CreateConnectObservable(Func<TObject, bool>? predicate, bool suppressEmptyChangeSets) =>
Expand Down Expand Up @@ -386,6 +412,7 @@ public void OnError(Exception error)
{
cache._changesPreview.OnError(error);
cache._changes.OnError(error);
cache._isEditInProgress.OnError(error);

if (cache._countChanged.IsValueCreated)
{
Expand All @@ -402,6 +429,7 @@ public void OnCompleted()
{
cache._changes.OnCompleted();
cache._changesPreview.OnCompleted();
cache._isEditInProgress.OnCompleted();

if (cache._countChanged.IsValueCreated)
{
Expand Down
Loading
Loading