Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,13 @@ private sealed class Level : IDisposable
/// <summary>This link's index/argument array (non-null only for indexer links), cached once.</summary>
private readonly object?[]? _arguments;

/// <summary>Whether this level is subscribing to its link's notifications.</summary>
/// <remarks>
/// Only read under the gate, so only a notification the subscribing thread raises from inside
/// <c>Subscribe</c> sees it set.
/// </remarks>
private bool _attaching;

/// <summary>Initializes a new instance of the <see cref="Level"/> class.</summary>
/// <param name="sink">The owning chain sink.</param>
/// <param name="index">This watcher's position in the chain.</param>
Expand Down Expand Up @@ -341,10 +348,14 @@ public void SetParent(object? parent)

// Subscribe before reading, so a change between the two is reported rather than lost. The
// caller holds the gate, so a notification that races this window queues behind it and
// re-reports the value the kicker is about to push; the sink drops that one repeat.
// re-reports the value the kicker is about to push; the sink drops that one repeat. A
// notification raised from inside Subscribe, such as the one a POCO link emits, is ignored:
// the kicker reads the same value straight after.
_attaching = true;
_subscription.Disposable = ReactiveNotifyPropertyChangedMixins
.NotifyForProperty(parent, link, _sink._beforeChange, _sink._suppressWarnings)
.Subscribe(new Observer(this));
_attaching = false;

Push(ReadValue(parent), fromKicker: true);
}
Expand All @@ -359,7 +370,7 @@ private void OnNotification(IObservedChange<object?, object?> change)
{
lock (_sink._gate)
{
if (_sink._disposed)
if (_sink._disposed || _attaching)
{
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
// ReactiveUI and Contributors licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

using System.ComponentModel;
using ReactiveUI.Binding.Tests.Fallback;
using ReactiveUI.Binding.Tests.TestModels;

namespace ReactiveUI.Binding.Tests.Mixins;

/// <summary>
/// A runtime view-first binding whose view raises no change notification reads the view through links that emit
/// once on subscribe. The binding still sees one initial value per side.
/// </summary>
[NotInParallel]
public class PocoLinkUnsafeBindingTests
{
/// <summary>The value the view model starts out holding.</summary>
private const string ViewModelValue = "model";

/// <summary>The value typed into the view.</summary>
private const string EditedValue = "edited";

/// <summary>Creating a two-way binding writes the view model's value to the view and never back.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
public async Task BindUnsafe_ViewWithoutNotification_DoesNotWriteBackOnCreate()
{
RuntimeObservationFallbackTests.EnsureInitialized();
var viewModel = new CountingViewModel { Name = ViewModelValue };
var view = new SilentView { ViewModel = viewModel };
viewModel.NameWrites = 0;

using var binding = view.BindUnsafe(viewModel, static vm => vm.Name, static v => v.Editor.IsNotNullString);
var writesOnCreate = viewModel.NameWrites;
var viewOnCreate = view.Editor.IsNotNullString;
view.Editor.IsNotNullString = EditedValue;

using (Assert.Multiple())
{
await Assert.That(writesOnCreate).IsEqualTo(0);
await Assert.That(viewOnCreate).IsEqualTo(ViewModelValue);
await Assert.That(viewModel.Name).IsEqualTo(EditedValue);
}
}

/// <summary>A view model that counts the writes to its bound property.</summary>
internal sealed class CountingViewModel : INotifyPropertyChanged
{
/// <inheritdoc/>
public event PropertyChangedEventHandler? PropertyChanged;

/// <summary>Gets or sets the number of times <see cref="Name"/> has been written.</summary>
public int NameWrites { get; set; }

/// <summary>Gets or sets the bound name.</summary>
public string Name
{
get;
set
{
NameWrites++;
field = value;
PropertyChanged?.Invoke(this, new(nameof(Name)));
}
} = string.Empty;
}

/// <summary>A view that raises no change notification and holds a control that does.</summary>
internal sealed class SilentView : IViewFor<CountingViewModel>
{
/// <inheritdoc/>
public CountingViewModel? ViewModel { get; set; }

/// <inheritdoc/>
object? IViewFor.ViewModel
{
get => ViewModel;
set => ViewModel = (CountingViewModel?)value;
}

/// <summary>Gets the notifying control the binding writes to.</summary>
public TestFixture Editor { get; } = new();
}
}
12 changes: 12 additions & 0 deletions src/tests/ReactiveUI.Binding.Tests/TestModels/PocoChainRoot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
// ReactiveUI and Contributors licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

namespace ReactiveUI.Binding.Tests.TestModels;

/// <summary>A root with no change notification that holds an object which notifies.</summary>
public class PocoChainRoot
{
/// <summary>Gets the notifying object at the end of the chain.</summary>
public TestFixture Leaf { get; } = new();
}
60 changes: 60 additions & 0 deletions src/tests/ReactiveUI.Binding.Tests/WhenAny/ExpressionChainTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,66 @@ public async Task WithIsDistinct_DeduplicatesSameValues()
await Assert.That(values[1]).IsEqualTo("B");
}

/// <summary>
/// A link with no change notification emits once on subscribe. The chain reads the same value straight after,
/// so it reports the initial value once, and the notifying leaf behind it still reports its changes.
/// </summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
public async Task PocoLinkBeforeNotifyingLeaf_EmitsTheInitialValueOnce()
{
EnsureInitialized();

var root = new PocoChainRoot();
root.Leaf.IsNotNullString = StartValue;
Expression<Func<PocoChainRoot, string>> expr = x => x.Leaf.IsNotNullString;
var values = new List<string>();

using var sub = root.SubscribeToExpressionChain<PocoChainRoot, string>(
expr.Body,
false,
false,
false)
.Select(static x => x.Value)
.Subscribe(values.Add);
var initial = values.ToArray();

root.Leaf.IsNotNullString = ReplacementValue;

using (Assert.Multiple())
{
await Assert.That(initial).IsEquivalentTo([StartValue]);
await Assert.That(values).IsEquivalentTo([StartValue, ReplacementValue]);
}
}

/// <summary>A leaf with no change notification reports its value once, and skipping the initial value leaves nothing.</summary>
/// <param name="skipInitial">Whether the first value is dropped.</param>
/// <param name="expectedCount">The number of values the chain reports.</param>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
[Arguments(false, 1)]
[Arguments(true, 0)]
public async Task PocoLeaf_ReportsTheInitialValueAtMostOnce(bool skipInitial, int expectedCount)
{
EnsureInitialized();

var fixture = new PocoModel { Value = StartValue };
Expression<Func<PocoModel, string>> expr = x => x.Value;
var values = new List<string>();

using var sub = fixture.SubscribeToExpressionChain<PocoModel, string>(
expr.Body,
false,
skipInitial,
false,
true)
.Select(static x => x.Value)
.Subscribe(values.Add);

await Assert.That(values.Count).IsEqualTo(expectedCount);
}

/// <summary>Verifies that null in a chain propagates correctly.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
Expand Down
Loading