Skip to content
Closed
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
13 changes: 12 additions & 1 deletion src/Controls/src/Core/VisualElement/VisualElement.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1866,6 +1866,17 @@ void UpdateBoundsComponents(Rect bounds)
EventHandler? _unloaded;
bool _watchingPlatformLoaded;
Rect _frame = new Rect(0, 0, -1, -1);

// Tolerance used by the Frame setter to absorb ULP-level non-determinism in measured/arranged
// bounds (e.g. iOS UILabel.SizeThatFits returning bit-different doubles across consecutive
// passes). Without this tolerance, sub-ULP differences propagate as Width/Height
// PropertyChanged and SizeChanged events, which can drive iOS layoutSubviews into a
// non-converging 2-cycle. See https://github.com/dotnet/maui/issues/35142. The threshold is
// well above ~10⁻¹³ ULP at typical layout magnitudes and well below sub-pixel resolution on
// any current device (a 3× display's pixel is ~0.333pt). Mirrors the precedent set by
// SafeAreaPadding.EqualsAtPixelLevel for issues #32586 and #33934.
const double FrameEqualityEpsilon = 1e-9;

event EventHandler? _windowChanged;
event EventHandler? _platformContainerViewChanged;

Expand All @@ -1878,7 +1889,7 @@ public Rect Frame
get => _frame;
set
{
if (_frame == value)
if (_frame.EqualsApproximately(value, FrameEqualityEpsilon))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MODERATE — UpdateBoundsComponents internal guard is now dead code (2/3 reviewers)

UpdateBoundsComponents (called on line 1895) has an internal if (_frame == bounds) return; bit-exact guard. With this PR, the Frame setter now exits early for any difference ≤ 1e-9, so UpdateBoundsComponents is only called when the difference exceeds the epsilon — making the internal bit-exact check guaranteed false (dead code).

This isn't a correctness bug today (confirmed UpdateBoundsComponents is only called from the Frame setter), but it's a maintenance trap: a future caller bypassing the Frame setter would re-expose the original oscillation.

Suggestion: Add a brief comment inside UpdateBoundsComponents noting that the Frame setter is the sole caller and the primary deduplication path is the approximate check above.

return;

UpdateBoundsComponents(value);
Expand Down
39 changes: 39 additions & 0 deletions src/Controls/tests/Core.UnitTests/VisualElementTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -307,5 +307,44 @@ public void WidthAndHeightRequestPropagateToHandler()
Assert.Equal(2, heightMapperCalled);
Assert.Equal(2, widthMapperCalled);
}

// Regression test for dotnet/maui#35142: on iOS, ULP-level non-determinism in
// VerticalStackLayout/Label measurement (CoreText subpixel rounding) propagates through
// Grid("*,Auto") star-row arithmetic into a child's Frame. The bit-exact Rect equality
// in the Frame setter then re-runs UpdateBoundsComponents, which fires Width/Height
// PropertyChanged on every ~10 ULP delta. UIKit reschedules layoutSubviews and the loop
// never converges. The values below are the actual border heights captured in the repro
// trace (see https://github.com/dotnet/maui/issues/35142).
[Fact]
public void FrameAssignmentIgnoresSubPixelDifferences()
{
var rectA = new Rect(0, 0, 390, 556.00000063578295);
var rectB = new Rect(0, 0, 390, 556.00000063578273); // ~22 ULP different from rectA

var element = new Label();
element.Frame = rectA;

int sizeChangedCount = 0;
int heightPropertyChangedCount = 0;
int widthPropertyChangedCount = 0;
element.SizeChanged += (_, _) => sizeChangedCount++;
element.PropertyChanged += (_, e) =>
{
if (e.PropertyName == VisualElement.HeightProperty.PropertyName)
{
heightPropertyChangedCount++;
}
else if (e.PropertyName == VisualElement.WidthProperty.PropertyName)
{
widthPropertyChangedCount++;
}
};

element.Frame = rectB;

Assert.Equal(0, sizeChangedCount);
Assert.Equal(0, heightPropertyChangedCount);
Assert.Equal(0, widthPropertyChangedCount);
}
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
Microsoft.Maui.Graphics.Rect.EqualsApproximately(Microsoft.Maui.Graphics.Rect other, double epsilon) -> bool
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 CRITICAL — Missing PublicAPI entries for platform-specific TFMs (3/3 reviewers)

Rect.EqualsApproximately is defined in shared Rect.cs and compiles for all TFMs. This PR adds the entry to net/ and netstandard/ only, but PublicAPI.targets resolves platform TFMs (e.g., net10.0-ios) to platform-specific directories (net-ios/PublicAPI.Unshipped.txt, etc.). CI runs in Validate mode and will emit RS0016 for every platform build.

Fix: Add Microsoft.Maui.Graphics.Rect.EqualsApproximately(Microsoft.Maui.Graphics.Rect other, double epsilon) -> bool to all 6 platform-specific PublicAPI.Unshipped.txt files:

  • net-ios
  • net-android
  • net-maccatalyst
  • net-windows
  • net-tizen
  • net-macos

Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
Microsoft.Maui.Graphics.Rect.EqualsApproximately(Microsoft.Maui.Graphics.Rect other, double epsilon) -> bool
16 changes: 16 additions & 0 deletions src/Graphics/src/Graphics/Rect.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@
return X.Equals(other.X) && Y.Equals(other.Y) && Width.Equals(other.Width) && Height.Equals(other.Height);
}

/// <summary>
/// Determines whether each component of this rectangle is within <paramref name="epsilon"/>
/// of the corresponding component of <paramref name="other"/>. Use this to compare rectangles
/// produced by floating-point arithmetic where bit-exact equality would treat ULP-level
/// differences as material changes.
/// </summary>
/// <param name="other">The rectangle to compare against.</param>
/// <param name="epsilon">The maximum absolute difference, per component, that is treated as equal.</param>
public bool EqualsApproximately(Rect other, double epsilon)

Check failure on line 87 in src/Graphics/src/Graphics/Rect.cs

View check run for this annotation

Azure Pipelines / maui-pr-uitests (Build UITests Material3 Sample App Build Sample App (Material3))

src/Graphics/src/Graphics/Rect.cs#L87

src/Graphics/src/Graphics/Rect.cs(87,15): Error RS0016: Symbol 'Microsoft.Maui.Graphics.Rect.EqualsApproximately(Microsoft.Maui.Graphics.Rect other, double epsilon) -> bool' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)

Check failure on line 87 in src/Graphics/src/Graphics/Rect.cs

View check run for this annotation

Azure Pipelines / maui-pr-uitests (Build UITests Sample App Build Sample App)

src/Graphics/src/Graphics/Rect.cs#L87

src/Graphics/src/Graphics/Rect.cs(87,15): Error RS0016: Symbol 'Microsoft.Maui.Graphics.Rect.EqualsApproximately(Microsoft.Maui.Graphics.Rect other, double epsilon) -> bool' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)

Check failure on line 87 in src/Graphics/src/Graphics/Rect.cs

View check run for this annotation

Azure Pipelines / maui-pr-uitests (Build UITests Sample App Build Sample App)

src/Graphics/src/Graphics/Rect.cs#L87

src/Graphics/src/Graphics/Rect.cs(87,15): Error RS0016: Symbol 'Microsoft.Maui.Graphics.Rect.EqualsApproximately(Microsoft.Maui.Graphics.Rect other, double epsilon) -> bool' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)

Check failure on line 87 in src/Graphics/src/Graphics/Rect.cs

View check run for this annotation

Azure Pipelines / maui-pr-uitests (Build UITests Sample App Build Sample App)

src/Graphics/src/Graphics/Rect.cs#L87

src/Graphics/src/Graphics/Rect.cs(87,15): Error RS0016: Symbol 'Microsoft.Maui.Graphics.Rect.EqualsApproximately(Microsoft.Maui.Graphics.Rect other, double epsilon) -> bool' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)

Check failure on line 87 in src/Graphics/src/Graphics/Rect.cs

View check run for this annotation

Azure Pipelines / maui-pr-uitests (Build UITests CoreCLR Sample App Build Sample App)

src/Graphics/src/Graphics/Rect.cs#L87

src/Graphics/src/Graphics/Rect.cs(87,15): Error RS0016: Symbol 'Microsoft.Maui.Graphics.Rect.EqualsApproximately(Microsoft.Maui.Graphics.Rect other, double epsilon) -> bool' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)

Check failure on line 87 in src/Graphics/src/Graphics/Rect.cs

View check run for this annotation

Azure Pipelines / maui-pr-uitests

src/Graphics/src/Graphics/Rect.cs#L87

src/Graphics/src/Graphics/Rect.cs(87,15): Error RS0016: Symbol 'Microsoft.Maui.Graphics.Rect.EqualsApproximately(Microsoft.Maui.Graphics.Rect other, double epsilon) -> bool' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)

Check failure on line 87 in src/Graphics/src/Graphics/Rect.cs

View check run for this annotation

Azure Pipelines / maui-pr-uitests

src/Graphics/src/Graphics/Rect.cs#L87

src/Graphics/src/Graphics/Rect.cs(87,15): Error RS0016: Symbol 'Microsoft.Maui.Graphics.Rect.EqualsApproximately(Microsoft.Maui.Graphics.Rect other, double epsilon) -> bool' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)

Check failure on line 87 in src/Graphics/src/Graphics/Rect.cs

View check run for this annotation

Azure Pipelines / maui-pr-uitests

src/Graphics/src/Graphics/Rect.cs#L87

src/Graphics/src/Graphics/Rect.cs(87,15): Error RS0016: Symbol 'Microsoft.Maui.Graphics.Rect.EqualsApproximately(Microsoft.Maui.Graphics.Rect other, double epsilon) -> bool' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 MINOR — NaN semantics diverge from Rect.Equals (2/3 reviewers)

Equals(Rect) uses double.Equals(double) which returns true for NaN.Equals(NaN). In contrast, EqualsApproximately uses Math.Abs(X - other.X) <= epsilon, where NaN - NaN = NaN and NaN <= epsilon = false. So a NaN-component rect compares equal to itself with Equals but not with EqualsApproximately.

In the Frame setter context: if a platform produced a NaN frame, the old code would no-op (dedup), but the new code would call UpdateBoundsComponents and fire events on every pass.

Not a practical risk (NaN frames indicate an upstream bug), but worth documenting.

Suggestion: Add a <remarks> to the XML doc: "Unlike Equals, this method returns false when any component is NaN."

{
return Math.Abs(X - other.X) <= epsilon
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 MINOR — No epsilon validation on public API (2/3 reviewers)

epsilon is an unrestricted double parameter. A negative epsilon makes Math.Abs(...) <= epsilon always false (since Math.Abs ≥ 0), so the method silently returns false for all inputs — including identical rects. A NaN epsilon has the same effect.

The internal caller uses a hardcoded const 1e-9 (safe), but this is a new public API surface.

Suggestion: Either add an ArgumentOutOfRangeException guard for epsilon < 0, or (more likely preferred on a struct hot-path) document the requirement: "epsilon must be non-negative; a negative value causes the method to always return false."

&& Math.Abs(Y - other.Y) <= epsilon
&& Math.Abs(Width - other.Width) <= epsilon
&& Math.Abs(Height - other.Height) <= epsilon;
}

public override bool Equals(object obj)
{
if (obj is null)
Expand Down
47 changes: 47 additions & 0 deletions src/Graphics/tests/Graphics.Tests/RectTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using Xunit;

namespace Microsoft.Maui.Graphics.Tests
{
public class RectTests
{
[Fact]
public void EqualsApproximatelyReturnsTrueForIdenticalRects()
{
var rect = new Rect(10, 20, 30, 40);
Assert.True(rect.EqualsApproximately(new Rect(10, 20, 30, 40), epsilon: 1e-9));
}

[Fact]
public void EqualsApproximatelyAbsorbsUlpDifferences()
{
// Values from the dotnet/maui#35142 trace: border heights captured on consecutive
// iOS layoutSubviews passes that differ by ~22 ULP.
var a = new Rect(0, 0, 390, 556.00000063578295);
var b = new Rect(0, 0, 390, 556.00000063578273);

Assert.False(a.Equals(b)); // bit-exact equality treats them as different
Assert.True(a.EqualsApproximately(b, epsilon: 1e-9));
}

[Fact]
public void EqualsApproximatelyReturnsFalseWhenAnyComponentExceedsEpsilon()
{
var rect = new Rect(0, 0, 100, 100);
const double epsilon = 1e-9;

Assert.False(rect.EqualsApproximately(new Rect(0 + 2 * epsilon, 0, 100, 100), epsilon));
Assert.False(rect.EqualsApproximately(new Rect(0, 0 + 2 * epsilon, 100, 100), epsilon));
Assert.False(rect.EqualsApproximately(new Rect(0, 0, 100 + 2 * epsilon, 100), epsilon));
Assert.False(rect.EqualsApproximately(new Rect(0, 0, 100, 100 + 2 * epsilon), epsilon));
}

[Fact]
public void EqualsApproximatelyTreatsHalfEpsilonDifferenceAsEqual()
{
var rect = new Rect(0, 0, 100, 100);
const double epsilon = 1e-9;

Assert.True(rect.EqualsApproximately(new Rect(0, 0, 100, 100 + epsilon * 0.5), epsilon));
}
}
}
Loading