Skip to content

Commit f8a8979

Browse files
authored
Updated WhenAnyValue and Attributes to minimise AOT warnings (#4096)
<!-- Please be sure to read the [Contribute](https://github.com/reactiveui/reactiveui#contribute) section of the README --> **What kind of change does this PR introduce?** <!-- Bug fix, feature, docs update, ... --> Update **What is the current behavior?** <!-- You can also link to an open issue here. --> Large scale reflection, AOT attributes missing or misplaced **What is the new behavior?** <!-- If this is a feature change --> Added [RequiresDynamicCode] and [RequiresUnreferencedCode] attributes to various methods for .NET 6+ to improve AOT compatibility. Updated usages of WhenAnyValue and related methods to use property name overloads and explicit generic type parameters for better AOT support. Also removed redundant AOT annotations from Activation/ViewForMixins.cs and made minor code style improvements. **What might this PR break?** All tests pass, additional tests added to cover new functions **Please check if the PR fulfills these requirements** - [x] Tests for the changes have been added (for bug fixes / features) - [ ] Docs have been added / updated (for bug fixes / features) **Other information**: Part of a larger breaking change
1 parent b135729 commit f8a8979

34 files changed

+3733
-231
lines changed

src/ReactiveUI.AOTTests/AOTCompatibilityTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ public void WhenAnyValue_StringPropertyNames_WorksInAOT()
9191
string? observedValue = null;
9292

9393
// Using string property names should work in AOT
94-
obj.WhenAnyValue(x => x.TestProperty)
94+
obj.WhenAnyValue<TestReactiveObject, string>(nameof(TestReactiveObject.TestProperty))
9595
.Subscribe(value => observedValue = value);
9696

9797
obj.TestProperty = "test value";
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved.
2+
// Licensed to the .NET Foundation under one or more agreements.
3+
// The .NET Foundation licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using System.Reactive.Concurrency;
7+
using System.Reactive.Linq;
8+
9+
namespace ReactiveUI.AOTTests;
10+
11+
/// <summary>
12+
/// StringBasedObservationTests.
13+
/// </summary>
14+
public class StringBasedObservationTests
15+
{
16+
/// <summary>
17+
/// Observables for property string name emits initial then changes.
18+
/// </summary>
19+
[Fact]
20+
public void ObservableForProperty_StringName_EmitsInitialThenChanges()
21+
{
22+
var s = new Sample { IntValue = 5 };
23+
var values = new List<int>();
24+
25+
s.ObservableForProperty<Sample, int>(nameof(Sample.IntValue), beforeChange: false, skipInitial: false, isDistinct: true)
26+
.Select(x => x.Value)
27+
.Subscribe(values.Add);
28+
29+
s.IntValue = 7;
30+
s.IntValue = 7; // distinct should suppress duplicate
31+
s.IntValue = 9;
32+
33+
Assert.Equal(new[] { 5, 7, 9 }, values);
34+
}
35+
36+
/// <summary>
37+
/// Observables for property before change emits before setter.
38+
/// </summary>
39+
[Fact]
40+
public void ObservableForProperty_BeforeChange_EmitsBeforeSetter()
41+
{
42+
var s = new Sample { IntValue = 1 };
43+
var before = new List<int>();
44+
45+
s.ObservableForProperty<Sample, int>(nameof(Sample.IntValue), beforeChange: true, skipInitial: true, isDistinct: false)
46+
.Select(x => x.Value)
47+
.Subscribe(before.Add);
48+
49+
s.IntValue = 2; // should emit previous value (1) before change
50+
s.IntValue = 3; // should emit 2
51+
52+
Assert.Equal(new[] { 1, 2 }, before);
53+
}
54+
55+
/// <summary>
56+
/// Whens any value string name works and is distinct.
57+
/// </summary>
58+
[Fact]
59+
public void WhenAnyValue_StringName_WorksAndIsDistinct()
60+
{
61+
var s = new Sample { Name = "a" };
62+
var values = new List<string?>();
63+
64+
s.WhenAnyValue<Sample, string?>(nameof(Sample.Name))
65+
.Subscribe(values.Add);
66+
67+
s.Name = "b";
68+
s.Name = "b"; // duplicate should be filtered by default overload
69+
s.Name = "c";
70+
71+
Assert.Equal(new[] { "a", "b", "c" }, values);
72+
}
73+
74+
/// <summary>
75+
/// Whens any value string name not distinct when requested.
76+
/// </summary>
77+
[Fact]
78+
public void WhenAnyValue_StringName_NotDistinctWhenRequested()
79+
{
80+
var s = new Sample { Name = "x" };
81+
var values = new List<string?>();
82+
83+
s.WhenAnyValue<Sample, string?>(nameof(Sample.Name), isDistinct: false)
84+
.Subscribe(values.Add);
85+
86+
s.Name = "y";
87+
s.Name = "y"; // should be included
88+
89+
Assert.Equal(new[] { "x", "y", "y" }, values);
90+
}
91+
92+
private sealed class Sample : ReactiveObject
93+
{
94+
private int _intValue;
95+
private string? _name;
96+
97+
public int IntValue
98+
{
99+
get => _intValue;
100+
set => this.RaiseAndSetIfChanged(ref _intValue, value);
101+
}
102+
103+
public string? Name
104+
{
105+
get => _name;
106+
set
107+
{
108+
// Using RaisePropertyChanged to ensure property change notification
109+
_name = value;
110+
this.RaisePropertyChanged(nameof(Name));
111+
}
112+
}
113+
}
114+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved.
2+
// Licensed to the .NET Foundation under one or more agreements.
3+
// The .NET Foundation licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using System.Reactive.Linq;
7+
8+
namespace ReactiveUI.AOTTests;
9+
10+
/// <summary>
11+
/// Verifies the string-based ObservableForProperty and WhenAnyValue semantics.
12+
/// Ensures initial emission, beforeChange behavior, distinct filtering, and tuple combinations.
13+
/// </summary>
14+
public class StringBasedSemanticsTests
15+
{
16+
/// <summary>
17+
/// ObservableForProperty (string) should emit an initial value followed by updates.
18+
/// </summary>
19+
[Fact]
20+
public void ObservableForProperty_String_Basic_InitialAndUpdate()
21+
{
22+
var obj = new TestReactiveObject();
23+
var seen = new List<string?>();
24+
25+
obj.ObservableForProperty<TestReactiveObject, string?>(nameof(TestReactiveObject.TestProperty), beforeChange: false, skipInitial: false)
26+
.Select(x => x.Value)
27+
.Subscribe(seen.Add);
28+
29+
// initial emission is null, then updated value
30+
obj.TestProperty = "v1";
31+
32+
Assert.True(seen.Count >= 2);
33+
Assert.Null(seen[0]);
34+
Assert.Equal("v1", seen[^1]);
35+
}
36+
37+
/// <summary>
38+
/// ObservableForProperty (string) with beforeChange should provide the previous value when the property changes.
39+
/// </summary>
40+
[Fact]
41+
public void ObservableForProperty_String_BeforeChange_FiresOldValue()
42+
{
43+
var obj = new TestReactiveObject { TestProperty = "start" };
44+
string? observed = null;
45+
46+
obj.ObservableForProperty<TestReactiveObject, string?>(nameof(TestReactiveObject.TestProperty), beforeChange: true, skipInitial: true)
47+
.Select(x => x.Value)
48+
.Subscribe(v => observed = v);
49+
50+
obj.TestProperty = "next";
51+
52+
Assert.Equal("start", observed);
53+
}
54+
55+
/// <summary>
56+
/// WhenAnyValue (string) should apply DistinctUntilChanged by default and include an initial emission.
57+
/// </summary>
58+
[Fact]
59+
public void WhenAnyValue_String_IsDistinct()
60+
{
61+
var obj = new TestReactiveObject();
62+
var seen = new List<string?>();
63+
64+
obj.WhenAnyValue<TestReactiveObject, string?>(nameof(TestReactiveObject.TestProperty))
65+
.Subscribe(seen.Add);
66+
67+
obj.TestProperty = "same";
68+
obj.TestProperty = "same"; // should be filtered by distinct
69+
obj.TestProperty = "other";
70+
71+
// initial null + "same" + "other" => 3 distinct emissions
72+
Assert.True(seen.Count >= 3);
73+
Assert.Equal(new[] { null, "same", "other" }, seen.TakeLast(3).ToArray());
74+
}
75+
76+
/// <summary>
77+
/// WhenAnyValue (string) tuple overload should combine the latest values from two properties.
78+
/// </summary>
79+
[Fact]
80+
public void WhenAnyValue_String_TupleCombine_Works()
81+
{
82+
var obj = new TestReactiveObject();
83+
var tuples = new List<(string?, string?)>();
84+
85+
obj.WhenAnyValue<TestReactiveObject, string?, string?>(nameof(TestReactiveObject.TestProperty), nameof(TestReactiveObject.ComputedProperty))
86+
.Subscribe(tuples.Add);
87+
88+
obj.TestProperty = "value";
89+
90+
Assert.True(tuples.Count >= 1);
91+
var last = tuples[^1];
92+
Assert.Equal("value", last.Item1);
93+
}
94+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2025 .NET Foundation and Contributors. All rights reserved.
2+
// Licensed to the .NET Foundation under one or more agreements.
3+
// The .NET Foundation licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using System.Reactive.Linq;
7+
8+
namespace ReactiveUI.AOTTests;
9+
10+
/// <summary>
11+
/// Tests for AOT-friendly mapping and ResolveView contract usage.
12+
/// </summary>
13+
public class ViewLocatorAOTMappingTests
14+
{
15+
/// <summary>
16+
/// Map/Resolve with contract and default fallback works.
17+
/// </summary>
18+
[Fact]
19+
public void Map_ResolveView_UsesAOTMappingWithContract()
20+
{
21+
var locator = new DefaultViewLocator();
22+
23+
// Register contract-specific and default mappings
24+
locator.Map<VmA, ViewA>(() => new ViewA(), contract: "mobile")
25+
.Map<VmA, ViewADefault>(() => new ViewADefault()); // default
26+
27+
var vm = new VmA();
28+
29+
var viewMobile = locator.ResolveView(vm, "mobile");
30+
Assert.IsType<ViewA>(viewMobile);
31+
32+
var viewDefaultFromExplicit = locator.ResolveView(vm, string.Empty);
33+
Assert.IsType<ViewADefault>(viewDefaultFromExplicit);
34+
35+
// Unknown contract falls back to default mapping
36+
var viewFallback = locator.ResolveView(vm, "unknown");
37+
Assert.IsType<ViewADefault>(viewFallback);
38+
}
39+
40+
/// <summary>
41+
/// Unmap removes a mapping for a contract.
42+
/// </summary>
43+
[Fact]
44+
public void Unmap_RemovesMapping()
45+
{
46+
var locator = new DefaultViewLocator();
47+
locator.Map<VmB, ViewB>(() => new ViewB(), contract: "c1");
48+
49+
var vm = new VmB();
50+
Assert.IsType<ViewB>(locator.ResolveView(vm, "c1"));
51+
52+
locator.Unmap<VmB>("c1");
53+
Assert.Null(locator.ResolveView(vm, "c1"));
54+
}
55+
56+
private sealed class ViewADefault : IViewFor<VmA>
57+
{
58+
object? IViewFor.ViewModel { get => ViewModel; set => ViewModel = (VmA?)value; }
59+
60+
public VmA? ViewModel { get; set; }
61+
}
62+
63+
private sealed class ViewB : IViewFor<VmB>
64+
{
65+
object? IViewFor.ViewModel { get => ViewModel; set => ViewModel = (VmB?)value; }
66+
67+
public VmB? ViewModel { get; set; }
68+
}
69+
70+
private sealed class VmA : ReactiveObject
71+
{
72+
}
73+
74+
private sealed class VmB : ReactiveObject
75+
{
76+
}
77+
78+
private sealed class ViewA : IViewFor<VmA>
79+
{
80+
object? IViewFor.ViewModel { get => ViewModel; set => ViewModel = (VmA?)value; }
81+
82+
public VmA? ViewModel { get; set; }
83+
}
84+
}

src/ReactiveUI.Maui/ReactiveUIBuilderMauiExtensions.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,25 @@ public static AppBuilder WithMaui(this Builder.ReactiveUIBuilder builder)
3131
return builder.WithPlatformModule<Registrations>();
3232
}
3333

34+
/// <summary>
35+
/// Registers MAUI-specific services (AOT-friendly shortcut for non-builder code).
36+
/// </summary>
37+
/// <param name="resolver">Resolver to register into.</param>
38+
/// <returns>The resolver for chaining.</returns>
39+
#if NET6_0_OR_GREATER
40+
[RequiresDynamicCode("WithMaui uses methods that require dynamic code generation")]
41+
[RequiresUnreferencedCode("WithMaui uses methods that may require unreferenced code")]
42+
#endif
43+
public static IMutableDependencyResolver WithMaui(this IMutableDependencyResolver resolver)
44+
{
45+
resolver.ArgumentNullExceptionThrowIfNull(nameof(resolver));
46+
47+
// Use the same module the builder uses to avoid duplication.
48+
var reg = new Registrations();
49+
reg.Register((f, t) => resolver.RegisterConstant(f(), t));
50+
return resolver;
51+
}
52+
3453
/// <summary>
3554
/// Registers MAUI-specific services.
3655
/// </summary>

0 commit comments

Comments
 (0)